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
13,600 changes: 1,274 additions & 12,326 deletions shared/build_timings/linux-64.json

Large diffs are not rendered by default.

13,446 changes: 1,260 additions & 12,186 deletions shared/build_timings/linux-aarch64.json

Large diffs are not rendered by default.

11,322 changes: 1,107 additions & 10,215 deletions shared/build_timings/osx-64.json

Large diffs are not rendered by default.

11,940 changes: 1,136 additions & 10,804 deletions shared/build_timings/osx-arm64.json

Large diffs are not rendered by default.

9,657 changes: 946 additions & 8,711 deletions shared/build_timings/win-64.json

Large diffs are not rendered by default.

30 changes: 23 additions & 7 deletions tools/build_timings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
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}}, 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>).
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.

pixi run python tools/build_timings.py # what the bot command does
"""
Expand All @@ -25,6 +26,9 @@

TIMINGS = rs.ROOT / "shared" / "build_timings"
JOURNAL = "build_timings.jsonl"
# a compatibility recipe (ros-<distro>-<name>) only repackages: measured median, the same
# everywhere, so the files don't keep one per distribution
ALIAS_SECONDS = 0.3


def note(distro: str, recipe: str, seconds: float) -> None:
Expand Down Expand Up @@ -55,22 +59,34 @@ 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}-"):
continue # a compatibility recipe: ALIAS_SECONDS
files.setdefault(entry["platform"], {}).setdefault(entry["recipe"], {})[distro] = entry["seconds"]
count += 1
TIMINGS.mkdir(parents=True, exist_ok=True)
for platform, recipes in files.items():
(TIMINGS / f"{platform}.json").write_text(json.dumps(recipes, indent=0, sort_keys=True) + "\n")
(TIMINGS / f"{platform}.json").write_text(dumps(recipes))
return count


def vinca_timings(platform: str, path: Path) -> bool:
def dumps(recipes: dict) -> str:
"""One recipe per line, sorted: small files, and a changed time is a one-line diff."""
lines = [f"{json.dumps(r)}: {json.dumps(recipes[r], sort_keys=True)}" for r in sorted(recipes)]
return "{\n" + ",\n".join(lines) + "\n}\n"


def vinca_timings(distro: str, platform: str, path: Path) -> bool:
"""Write vinca-gha's --timings file for the platform: each recipe's average over the
distributions. False when there are no times yet."""
distributions, and ALIAS_SECONDS for the distribution's compatibility recipes
(ros-<distro>-<name>, built right after ros2-<name>). False when there are no times yet."""
source = TIMINGS / f"{platform}.json"
if not source.is_file():
return False
recipes = {r: round(statistics.fmean(by.values()), 1) for r, by in json.loads(source.read_text()).items()}
path.write_text(json.dumps({"recipes": recipes}) + "\n")
# unmeasured recipes: the median of the packages (ros2-*)
default = statistics.median([s for r, s in recipes.items() if r.startswith("ros2-")] or list(recipes.values()))
recipes.update({f"ros-{distro}-{r[len('ros2-'):]}": ALIAS_SECONDS for r in list(recipes) if r.startswith("ros2-")})
path.write_text(json.dumps({"recipes": recipes, "default": default}) + "\n")
return True


Expand Down
2 changes: 1 addition & 1 deletion tools/robostack.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ def generate_gha(distro: str, args: list[str]) -> int:
import build_timings

extra = ["--keep-file", f"distros/{distro}/work/{build_timings.JOURNAL}"]
if build_timings.vinca_timings(ns.platform, d / "build_timings.json"):
if build_timings.vinca_timings(distro, ns.platform, d / "build_timings.json"):
extra += ["--timings", "build_timings.json"]
# vinca-gha keeps the workflow under GitHub's size limit itself (a job budget); only
# when even the fewest jobs the batch size allows are too many, retry with larger batches
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 @@ -19,7 +19,7 @@ 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)], 2: [("ros2-a", 12.0)], 3: [("ros2-a", 20.0)]}
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)]}

def run(cmd, **kwargs):
if cmd[1:3] == ["run", "list"]:
Expand All @@ -32,15 +32,20 @@ def run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, b"", b"")

monkeypatch.setattr(build_timings.subprocess, "run", run)
assert build_timings.collect() == 4
# the newer jazzy run wins; each distribution keeps its own time
assert json.loads((tmp_path / "timings" / "linux-64.json").read_text()) == {
"ros2-a": {"jazzy": 12.0, "lyrical": 20.0}, "ros2-b": {"jazzy": 5.0}}
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
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},'
# vinca-gha gets the average over the distributions
out = tmp_path / "vinca.json"
assert build_timings.vinca_timings("linux-64", out)
assert json.loads(out.read_text()) == {"recipes": {"ros2-a": 16.0, "ros2-b": 5.0}}
assert not build_timings.vinca_timings("win-64", out)
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,
"ros-humble-b": build_timings.ALIAS_SECONDS},
"default": 10.5}
assert not build_timings.vinca_timings("humble", "win-64", out)


def test_update_build_timings_command(tmp_path, monkeypatch):
Expand Down Expand Up @@ -84,4 +89,5 @@ def fake_run_tee(cmd, cwd):
(tmp_path / "timings" / "linux-64.json").write_text('{"ros2-a": {"jazzy": 3.0}}\n')
assert rs.generate_gha("jazzy", ["--platform", "linux-64"]) == 0
assert calls[-1][calls[-1].index("--timings") + 1] == "build_timings.json"
assert json.loads((work / "build_timings.json").read_text()) == {"recipes": {"ros2-a": 3.0}}
assert json.loads((work / "build_timings.json").read_text()) == {
"recipes": {"ros2-a": 3.0, "ros-jazzy-a": build_timings.ALIAS_SECONDS}, "default": 3.0}
Loading