Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions tools/build_timings.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Measured build times, for packing the build jobs by time (vinca-gha --timings).

Each build-branch job records how long each of its recipes took to build (`rs <distro>
build-ci`, in distros/<distro>/work/build_timings.jsonl) and keeps that file as an artifact
(vinca-gha --keep-file). `robostack-bot update-build-timings` merges the last week's into
shared/build_timings/<platform>.json, {recipe: {distro: seconds}}, one recipe per line, and
`rs <distro> gha` hands vinca-gha each recipe's average over the distributions: a recipe new
to one distribution still has a time (the names are the same, ros2-<package>). The
compatibility recipes (ros-<distro>-<name>) aren't kept: they get ALIAS_SECONDS.
build-ci`) and to upload (`rs <distro> upload`), in distros/<distro>/work/build_timings.jsonl,
and keeps that file as an artifact (vinca-gha --keep-file). `robostack-bot
update-build-timings` merges the last week's into shared/build_timings/<platform>.json,
{recipe: {distro: seconds}}, one recipe per line, and `rs <distro> gha` hands vinca-gha
each recipe's average over the distributions: a recipe new to one distribution still has a
time (the names are the same, ros2-<package>). The compatibility recipes
(ros-<distro>-<name>) aren't kept: they get ALIAS_SECONDS, and their uploads count for
ros2-<name>, which they are built right after.

pixi run python tools/build_timings.py # what the bot command does
"""
Expand All @@ -31,18 +33,21 @@
ALIAS_SECONDS = 0.3


def note(distro: str, recipe: str, seconds: float) -> None:
"""Record a recipe's build time in the work directory's journal."""
def note(distro: str, recipe: str, seconds: float, upload: bool = False) -> None:
"""Record a recipe's build (or upload) time in the work directory's journal."""
from vinca.platforms import get_conda_subdir

entry = {"platform": get_conda_subdir(), "recipe": recipe, "seconds": round(seconds, 1)}
if upload:
entry["upload"] = True
with (rs.work_dir(distro) / JOURNAL).open("a", encoding="utf-8") as fh:
fh.write(json.dumps(entry) + "\n")


def collect(days: int = 8) -> int:
"""Merge the build times the build-branch runs of the last `days` days recorded into
shared/build_timings/ (newer runs win); returns how many times were recorded."""
shared/build_timings/ (newer runs win), uploads added to the build that precedes them in
the job's journal; returns how many build times were recorded."""
since = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
runs = json.loads(subprocess.run(
["gh", "run", "list", "--repo", "RoboStack/robostack", "--workflow", "build.yaml", "--status", "completed",
Expand All @@ -59,9 +64,16 @@ def collect(days: int = 8) -> int:
for journal in sorted(out.rglob(JOURNAL)):
for line in journal.read_text().splitlines():
entry = json.loads(line)
if entry["recipe"].startswith(f"ros-{distro}-"):
recipe, alias = entry["recipe"], f"ros-{distro}-"
by_distro = files.setdefault(entry["platform"], {})
if entry.get("upload"):
times = by_distro.get(recipe.replace(alias, "ros2-", 1), {})
if distro in times:
times[distro] = round(times[distro] + entry["seconds"], 1)
continue
if recipe.startswith(alias):
continue # a compatibility recipe: ALIAS_SECONDS
files.setdefault(entry["platform"], {}).setdefault(entry["recipe"], {})[distro] = entry["seconds"]
by_distro.setdefault(recipe, {})[distro] = entry["seconds"]
count += 1
TIMINGS.mkdir(parents=True, exist_ok=True)
for platform, recipes in files.items():
Expand Down
9 changes: 8 additions & 1 deletion tools/robostack.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,14 @@ def task(distro: str, name: str, args: list[str]) -> int:
rc = run(["vinca-sort-vinca-lists", *args, "vinca.yaml"], d)
return rc or run(["vinca-sort-yaml-keys", *args, *files], d)
if name == "upload":
return upload(distro, args, w if w.is_dir() else d)
started = time.monotonic()
rc = upload(distro, args, w if w.is_dir() else d)
if rc == 0 and len(args) == 1 and os.environ.get("GITHUB_ACTIONS"):
import build_timings # uploads take long too (attestations, two channels)

recipe = Path(args[0]).name.removesuffix(".conda").removesuffix(".tar.bz2").rsplit("-", 2)[0]
build_timings.note(distro, recipe, time.monotonic() - started, upload=True)
return rc
if name in ("update-snapshot", "find-stale", "update-pinning", "dependency-report"):
import maintenance

Expand Down
24 changes: 15 additions & 9 deletions tools/tests/test_build_timings.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ def test_note_records_build_times(tmp_path, monkeypatch):
monkeypatch.setattr(build_timings.rs, "work_dir", lambda distro: tmp_path)
build_timings.note("jazzy", "ros2-a", 12.34)
build_timings.note("jazzy", "ros-jazzy-a", 1.0)
build_timings.note("jazzy", "ros2-a", 2.0, upload=True)
entries = [json.loads(line) for line in (tmp_path / build_timings.JOURNAL).read_text().splitlines()]
assert [(e["recipe"], e["seconds"]) for e in entries] == [("ros2-a", 12.3), ("ros-jazzy-a", 1.0)]
assert [(e["recipe"], e["seconds"], e.get("upload", False)) for e in entries] == [
("ros2-a", 12.3, False), ("ros-jazzy-a", 1.0, False), ("ros2-a", 2.0, True)]
assert all(e["platform"] for e in entries)


Expand All @@ -19,7 +21,10 @@ def test_collect_merges_the_runs_journals(tmp_path, monkeypatch):
runs = [{"databaseId": 2, "headBranch": "buildbranch_jazzy_linux_64", "createdAt": "2026-10-22T02:00:00Z"},
{"databaseId": 1, "headBranch": "buildbranch_jazzy_linux_64", "createdAt": "2026-10-22T01:00:00Z"},
{"databaseId": 3, "headBranch": "buildbranch_lyrical_linux_64", "createdAt": "2026-10-22T03:00:00Z"}]
journals = {1: [("ros2-a", 10.0), ("ros2-b", 5.0), ("ros-jazzy-a", 0.2)], 2: [("ros2-a", 12.0)], 3: [("ros2-a", 20.0)]}
# (recipe, seconds, upload): the uploads come after the job's builds
journals = {1: [("ros2-a", 10.0, False), ("ros2-b", 5.0, False), ("ros-jazzy-a", 0.2, False), ("ros2-b", 1.0, True)],
2: [("ros2-a", 12.0, False), ("ros2-a", 2.0, True), ("ros-jazzy-a", 1.0, True)],
3: [("ros2-a", 20.0, False)]}

def run(cmd, **kwargs):
if cmd[1:3] == ["run", "list"]:
Expand All @@ -28,23 +33,24 @@ def run(cmd, **kwargs):
job = Path(cmd[cmd.index("--dir") + 1]) / "stage_0_job_0-1"
job.mkdir(parents=True)
(job / build_timings.JOURNAL).write_text("".join(
json.dumps({"platform": "linux-64", "recipe": r, "seconds": s}) + "\n" for r, s in journals[run_id]))
json.dumps({"platform": "linux-64", "recipe": r, "seconds": s, **({"upload": True} if u else {})}) + "\n"
for r, s, u in journals[run_id]))
return subprocess.CompletedProcess(cmd, 0, b"", b"")

monkeypatch.setattr(build_timings.subprocess, "run", run)
assert build_timings.collect() == 4 # the compatibility recipe is not kept
# the newer jazzy run wins; each distribution keeps its own time; compatibility
# recipes aren't kept; one recipe per line
# the newer jazzy run wins; each distribution keeps its own time, build plus uploads;
# compatibility recipes aren't kept, their uploads count for ros2-<name>; one recipe per line
text = (tmp_path / "timings" / "linux-64.json").read_text()
assert json.loads(text) == {"ros2-a": {"jazzy": 12.0, "lyrical": 20.0}, "ros2-b": {"jazzy": 5.0}}
assert text.splitlines()[1] == '"ros2-a": {"jazzy": 12.0, "lyrical": 20.0},'
assert json.loads(text) == {"ros2-a": {"jazzy": 15.0, "lyrical": 20.0}, "ros2-b": {"jazzy": 6.0}}
assert text.splitlines()[1] == '"ros2-a": {"jazzy": 15.0, "lyrical": 20.0},'
# vinca-gha gets the average over the distributions
out = tmp_path / "vinca.json"
assert build_timings.vinca_timings("humble", "linux-64", out)
assert json.loads(out.read_text()) == {
"recipes": {"ros2-a": 16.0, "ros2-b": 5.0, "ros-humble-a": build_timings.ALIAS_SECONDS,
"recipes": {"ros2-a": 17.5, "ros2-b": 6.0, "ros-humble-a": build_timings.ALIAS_SECONDS,
"ros-humble-b": build_timings.ALIAS_SECONDS},
"default": 10.5}
"default": 11.75}
assert not build_timings.vinca_timings("humble", "win-64", out)


Expand Down
Loading