profiling: record deployment CPU time and peak memory per PR - #3468
Open
leshy wants to merge 30 commits into
Open
profiling: record deployment CPU time and peak memory per PR#3468leshy wants to merge 30 commits into
leshy wants to merge 30 commits into
Conversation
bin/rusage runs a command in a transient systemd scope and reads the cgroup's own counters, so every child and native subprocess is counted -- /usr/bin/time sees neither unreaped grandchildren nor the tree's real memory high-water mark. Output is Bencher Metric Format, pushed from CI for a trend line. Record-only: no thresholds, so it cannot fail a build. --run-for gives a run a clean end. A replay stops producing but the blueprint keeps going, since from its side that is just a robot that went quiet, so bounded runs had no way to say when they were done.
leshy
requested review from
Dreamsorcerer,
mustafab0,
paul-nechifor and
spomichter
as code owners
August 14, 2026 16:37
checkout with lfs: true pulls everything and dies. get_data resolves data/.lfs/<name>.tar.gz for the one recording, in its own step so a pull failure is not mistaken for a profiling failure -- and so the download stays out of the measured window.
Compared against ci.yml's cockpit e2e job, which already runs a real deployment on the same replay dataset: it needs libturbojpeg for JPEG decode. Python is pinned because a moving interpreter would move the numbers this job exists to compare. The Bencher CLI rejects a project key passed as --token and wants BENCHER_API_KEY.
…test env --github-actions is the whole mechanism for a PR comment; without it Bencher stores the report and says nothing. Needs a pull_request trigger for PR context and pull-requests: write. Still no thresholds, so the comment is a table, never a failure. default-groups is ['tests'], so a plain uv sync dragged in pytest we never run. --extra unitree is what a go2 install actually pulls.
Bencher renders bytes as '3.06 units x 1e9'. MiB reads as a number a human can act on, and the measure now carries real units.
Cold start (--run-for 0) separates what the stack costs before it does any work from what the work costs -- core-seconds spent importing torch look identical to core-seconds spent on perception in the full number. Install size watches dependency bloat, which nothing else in CI does and which ships to the robot. push on this branch and pull_request both fired for the same commit, so every push profiled twice; concurrency cancels superseded runs.
A threshold is per measure, not per benchmark, so cold start and the full run shared one cpu-core-seconds limit sized for whichever is larger. It needs its own measures to be watchable; parking it until then. What it already told us: boot is 46% of CPU and 99% of peak memory.
--measure-prefix gives cold start its own measure names, so it lives under the same benchmark as the full run and still gets its own threshold -- the thing that made it unwatchable when it was a separate benchmark sharing cpu-core-seconds. Merge is a recursive jq merge, not add: both profiles write the same benchmark key, and add would silently keep only the last one's measures.
Bencher renders benchmarks as rows and measures as columns, so folding cold start in as coldstart-* measures made one row twenty columns wide. Its own benchmark is a second row instead. Separate benchmarks are fine under percentage thresholds, which compare each benchmark to its own history -- the objection only held for static limits. The user/sys split stays in the artifact and job summary but is filtered out of the Bencher push: it is what you read when a number moves, not something worth two columns on every comment.
Bencher's table is generated inside its Rust CLI and cannot be templated, so the layout now lives in bin/rusage-comment where it can be edited. Baseline comes from the metrics.json artifact of the last green run on main -- the same file this job already uploads, so no API is involved. Bencher's comment drops to --ci-only-on-alert: its wide table is worth reading exactly when something crossed a threshold.
main has never profiled, so every delta would render as '--' until this lands. Falling back to the branch's own last green run exercises the comparison now, and the table names the ref it used so the two cases are never confused.
The bash version buried the layout in a jq program. The template is now a plain string at the top of the file, and moves over 3% are bolded so a real change stands out from runner noise.
Contributor
Deployment resource footprint vs
|
The table cost a row per measure; one line per benchmark fits on a screen. Deltas are graded by magnitude through MathJax \textcolor, which is the only way to colour text in a GitHub comment without images. pids.peak comes from the same cgroup, so peak task count is free. Worth having here: the CLI already pins OMP_NUM_THREADS and KMP_BLOCKTIME because native pools sized themselves to all cores, and nothing else in CI would notice that coming back.
io.stat gives per-tree disk bytes; network has no cgroup file at all, so
it comes from systemd's own eBPF IP counters, read from inside the scope
while the unit is still alive. Both are optional: neither is available to
an unprivileged user scope, and a measure the kernel did not report is
omitted rather than sent as a zero that would read as real silence.
Optional files are now copied out raw into a temp dir and parsed outside
-- systemd expands ${...} in the command line before bash sees it, which
was corrupting the in-scope arithmetic.
MathJax was the only way to colour a delta, and it dragged the serif math font in with it. Bold carries the emphasis instead and the comment keeps its own typeface. Values now carry their unit, rolling MiB up to GiB past 1024 so peak memory reads 2.7GiB rather than 2759.
Disk and network came back absent on the runner as well as locally, and a silently omitted measure looks identical to one that was never supported. The job now says which scope it used and whether io.stat and the IP counters were there.
Mem and Thr move to a second line so the first reads as time alone. The benchmark name links to its Bencher plots -- every number in the comment is one point on a trend line, and the trend is what is worth opening.
The diagnostic showed CI picking scope=user: runners do have a user manager, so the old probe never reached sudo. Only the system manager can enable the io controller or attach the eBPF counters behind IPAccounting, so a user scope silently loses both. Probing with sudo -n picks the system scope where it is free and falls back without ever blocking on a prompt. Verified locally: scope=system io.stat=yes ip=yes.
sudoers replaces PATH via secure_path even under sudo -E, so the payload lost uv the moment the scope switched to system. The runner did report io.stat=yes ip=yes on that run, so the switch itself was right.
❌ 4 Tests Failed:
View the top 3 failed test(s) by shortest run time
View the full list of 1 ❄️ flaky test(s)
To view more test analytics, go to the Test Analytics Dashboard |
go2_short spans 59.9s but the window was 180s, so two thirds of every measurement was a deployment spinning on completed streams. That is the noisiest possible thing to measure and it diluted any real change 3:1.
Contributor
|
| Project | dimos |
| Branch | feat/ivan/rusage-profile |
| Testbed | ubuntu-latest |
🚨 4 Alerts
| Benchmark | Measure Units | View | Benchmark Result (Result Δ%) | Upper Boundary (Limit %) |
|---|---|---|---|---|
| unitree-go2-coldstart | peak-threads threads | 📈 plot 🚷 threshold 🚨 alert (🔔) | 210.00(+11.97%)Baseline: 187.56 | 206.31 (101.79%) |
| unitree-go2-coldstart | disk-write-mib MiB | 📈 plot 🚷 threshold 🚨 alert (🔔) | 79.36(+127.37%)Baseline: 34.91 | 38.40 (206.70%) |
| unitree-go2-coldstart | net-rx-mib MiB x 1e3 | 📈 plot 🚷 threshold 🚨 alert (🔔) | 8.97 x 1e3(+33.00%)Baseline: 6.74 x 1e3 | 7.42 x 1e3 (120.91%) |
| unitree-go2-coldstart | net-tx-mib MiB | 📈 plot 🚷 threshold 🚨 alert (🔔) | 248.77(+35.53%)Baseline: 183.55 | 201.91 (123.21%) |
Click to view all benchmark results
| Benchmark | cpu-core-seconds | Benchmark Result seconds (Result Δ%) | Upper Boundary seconds (Limit %) | disk-read-mib | Benchmark Result MiB (Result Δ%) | Upper Boundary MiB (Limit %) | disk-write-mib | Benchmark Result MiB (Result Δ%) | Upper Boundary MiB (Limit %) | install-size-mib | Benchmark Result MiB x 1e3 (Result Δ%) | Upper Boundary MiB x 1e3 (Limit %) | net-rx-mib | Benchmark Result MiB x 1e3 (Result Δ%) | Upper Boundary MiB x 1e3 (Limit %) | net-tx-mib | Benchmark Result MiB (Result Δ%) | Upper Boundary MiB (Limit %) | peak-memory-mib | Benchmark Result MiB x 1e3 (Result Δ%) | Upper Boundary MiB x 1e3 (Limit %) | peak-threads | Benchmark Result threads (Result Δ%) | Upper Boundary threads (Limit %) | wall-seconds | Benchmark Result seconds (Result Δ%) | Upper Boundary seconds (Limit %) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| unitree-go2-coldstart | 📈 view plot 🚷 view threshold | 42.84(-9.41%)Baseline: 47.28 | 52.01 (82.35%) | 📈 view plot 🚷 view threshold | 48.90 | 200.00 (24.45%) | 📈 view plot 🚷 view threshold 🚨 view alert (🔔) | 79.36(+127.37%)Baseline: 34.91 | 38.40 (206.70%) | 📈 view plot 🚷 view threshold 🚨 view alert (🔔) | 8.97 x 1e3(+33.00%)Baseline: 6.74 x 1e3 | 7.42 x 1e3 (120.91%) | 📈 view plot 🚷 view threshold 🚨 view alert (🔔) | 248.77(+35.53%)Baseline: 183.55 | 201.91 (123.21%) | 📈 view plot 🚷 view threshold | 2.76 x 1e3(+8.28%)Baseline: 2.55 x 1e3 | 2.80 x 1e3 (98.44%) | 📈 view plot 🚷 view threshold 🚨 view alert (🔔) | 210.00(+11.97%)Baseline: 187.56 | 206.31 (101.79%) | 📈 view plot 🚷 view threshold | 29.38(-4.29%)Baseline: 30.69 | 33.76 (87.01%) | |||
| unitree-go2-replay | 📈 view plot 🚷 view threshold | 85.50(-25.47%)Baseline: 114.72 | 126.20 (67.75%) | 📈 view plot 🚷 view threshold | 14.12 | 200.00 (7.06%) | 📈 view plot 🚷 view threshold | 0.03(-4.12%)Baseline: 0.03 | 0.04 (87.16%) | 📈 view plot 🚷 view threshold | 132.47 x 1e3(+0.04%)Baseline: 132.41 x 1e3 | 145.65 x 1e3 (90.95%) | 📈 view plot 🚷 view threshold | 2,550.98(+0.02%)Baseline: 2,550.52 | 2,805.57 (90.93%) | 📈 view plot 🚷 view threshold | 2.77 x 1e3(-1.24%)Baseline: 2.80 x 1e3 | 3.08 x 1e3 (89.78%) | 📈 view plot 🚷 view threshold | 210.00(+0.86%)Baseline: 208.20 | 229.02 (91.70%) | 📈 view plot 🚷 view threshold | 79.49(-58.19%)Baseline: 190.11 | 209.12 (38.01%) | |||
| venv | 📈 view plot 🚷 view threshold | 9.67 x 1e3(+0.06%)Baseline: 9.66 x 1e3 | 10.63 x 1e3 (90.96%) |
A fixed window measured whatever fit in it: go2_short spans 59.9s against a 180s window, so two thirds of every sample was a deployment idling on completed streams, and a slow boot silently changed how much of the recording got processed. A --replay run now defaults its own length to the recording's, so the same messages are measured every time. An explicit --run-for still wins, which is what cold start uses.
--error-on-alert turns the 10% thresholds into a gate. Disk moves to a static 200MiB budget rather than a percentage: its baseline is fractions of a MiB, where page-cache state produced a 1597% swing between runs of identical code and a percentage means nothing.
The first gated run failed on eight alerts from a commit that changed a comment and a flag. The percentage model builds its limit from the mean of history, and that history mixed three incompatible workloads -- 180s window, 70s window, and data-bound -- so the limits described a configuration that no longer exists. New testbed starts the series from the current shape, and thresholds now need five samples before they act, so the gate arms itself once it has something real to compare against instead of firing on a single point. Disk stays on an absolute budget.
It cost an extra boot per job to measure something the replay run already carries: boot is roughly half of that number either way. Dropping it halves the profile's runner time.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.