bga reads a BuildStream build and answers the four questions a build owner actually has:
- Where did the time go? — per-element attribution, every category summing to exactly the wall clock, not aggregate stats.
- How much faster could this build possibly be? — a proven lower bound, not an estimate. When there is nothing to win from rescheduling, it says so, which saves you the week you would have spent tuning
--builders. - What should I fix first, and what is it actually worth? — ranked by how much the build would really lose if that element were free, which on a dense graph is a very different number from how big it is.
- And then what? — the next few fixes, what the build drops to after each, and whether their savings add — projected from the capture you already have (
bga whatif), instead of costing you another full build per finding.
It works in three planes — one build's element schedule, the processes inside a single element's sandbox, and the per-element logs BuildStream already wrote for every build on your machine. What each one sees and costs is the table in docs/README.md. New here? Install, then run the two commands under Use it on your real project; docs/guides/real-project.md is the same path at length, with real output at every step.
Into the venv of the project you want to analyze — bga does not have to live in that project, or anywhere near it. Clone --single-branch: this repository also carries captures/* branches of published capture data — twelve as of 2026-09-07 (git ls-remote --heads origin 'captures/*' | wc -l; a capture job may add more at any time) — and a default clone fetches all of them (104 MiB against 47 MiB as of 2026-09-14: git clone vs git clone --single-branch of this repository, each then du -sh); nothing needs them up front, and bga baseline fetches the refs it wants on demand.
git clone --single-branch https://github.com/rmorozov/buildstream-graph-analysis
pip install ./buildstream-graph-analysis # or the git URL directlyNeeds Python 3.12 or newer — that is requires-python in pyproject.toml, and CI runs 3.12-3.14. Plane 1 and Plane 3 work on that alone; capturing Plane 2 also needs a real bst and bubblewrap
in the same venv (pip install "./buildstream-graph-analysis[bst]", or your project's own BuildStream install; bga[all]
is bst plus completion). pip install -e . from inside this checkout is the contributor mode,
which is what make test and make lint expect and not what a user needs.
Tab completion — subcommands, flags, and @last/@prev/stamps wherever a run is accepted — is pip install "bga[completion]" plus one line in your shell rc: eval "$(register-python-argcomplete bga)" for bash/zsh, or register-python-argcomplete --shell fish bga | source for fish.
bga analyze tests/fixtures/golden/mixed_task_kinds --diagnostics # or: make dev-runA three-element fixture that runs instantly. The report is 107 lines;
its two headline sections are below, verbatim, with every cut marked —
UX-192 is on file for a block that claimed to be full output and was
not:
Key Findings:
This build is chain-bound, not scheduler-bound: the critical path is 100.0% of the time tasks were running, at or above the 90.0% chain-bound line, so the way to a shorter build is a shorter chain.
12.5% of wall-clock time is untracked tail (2 ms) — the biggest wait category
-> real time after the last tracked task finished — outside per-task tracking, not a scheduling issue
100.0% of the 14 ms critical path is 3 elements — chain-bound, not scheduler-bound
[... elided: the step, the three ranked elements, the chain note, the joint saving and its order, the latent heavies, the fan-in ranking and the graph width ...]
87.5% confidence (high)
[... elided: the remote-execution what-if ...]
100.0% efficiency score — scheduling is near the certified floor
[... elided: Certified Floors, Attribution Breakdown ...]
Critical Path Length: 3 elements
Path: base.bst → lib.bst → app.bst
[... elided: CPU Utilisation, Advanced Diagnostics ...]
The first two lines repay a second read, because they name two
different denominators on purpose. The critical path is 100% of the
time tasks were running; 12.5% of wall-clock is untracked tail, which
is time no task was running and no scheduler could have compressed.
UX-477 is on file for the round when the first line divided by the
second's denominator too, and called a strict chain "scheduler-bound"
because BuildStream's own startup was in the divisor. The 90% line the
sentence names is what flips it, and --explain prints that constant
from the same place (UX-331).
Bigger fixtures need no BuildStream either: make dev-run ARGS=--large runs a 14-element sample, and bga gen-synthetic /tmp/scale --seed 1 a byte-reproducible 1202-element one — which is how round 2 found four defects invisible at eleven elements.
analyze reads a run directory, but most of bga reads a store —
two runs to compare, a Plane 2 report, a wrapped log to draw a timeline
from. One command plants one:
bga gen-synthetic --store /tmp/bga-demo
cd /tmp/bga-demo
bga snapshot --list # the two runs it planted, newest first
bga analyze @last # the report
bga compare @prev @last # what moved between them
bga view @last # the same report, in a browser
bga timeline @last -o t.gz # both planes in one trace, for PerfettoNothing there needs bst or bubblewrap. The seed is synthetic and says
so — it is a shape to learn the commands on, not a measurement of
anything — but it is the same shape a real capture has, read by the same
code, so every answer above is the answer you will get from your own
build.
The short version is two commands, run from inside the project:
pip install "./buildstream-graph-analysis[bst]" # from beside your project, after the clone above; needs a real bst binary + bubblewrap - see docs/spec/ingestion-pipeline.md
cd /path/to/your/project
bga doctor . # is this machine able to capture at all?
bga snapshot -- bst build <targets> # capture + extract + analyze
bga view @last # the same report, in a browser (UX-193)
bga snapshot -- bst build <targets> # after your change: compares against the previous runRun bga doctor first — it takes a second or two. Every capture environment this project has stood
up was assembled by failure (a missing plugin, an absent compiler, bwrap blocked by a sysctl); each
has a one-line remedy, cheaper to read before a thirty-minute build than after.
bga view has one boundary worth knowing: the report has no time axis — every number in it is a
total, a per-element aggregate or a ranking. A question needing when, or one individual process
rather than the element around it, is a question for the trace, and the page's Open timeline in
Perfetto button is the way there.
docs/guides/what-the-viewer-answers.md sorts all eighteen
canned questions by which side answers them, and says which roles the trip serves.
The second snapshot prints the analysis and the verdict against the first. Captures land
in .bga/runs/<UTC-stamp>/ under the project (gitignored), and every command taking a run
directory also takes @last, @prev or a stamp prefix — bga analyze @last,
bga compare @prev @last. bga snapshot is those commands composed, so it changes no number
and keeps every refusal: a caches-off run compared against a caches-on one still refuses. Each snapshot stays until pruned: Disk has a measured size per run and bga snapshot --prune.
The pieces underneath, for a log captured elsewhere or a capture that cannot live in the project directory:
# Capture through the wrapper: it records the real invocation on its own first
# line, which is where `--max-jobs` lives - without it bga's capacity checks
# have nothing to check against and say so.
bga wrap /path/to/your/project /tmp/build.log -- bst build <targets>
bga extract --format wrapped /path/to/your/project /tmp/build.log /tmp/my-run
bga analyze /tmp/my-run --diagnosticsEither way, comparing two runs is one command — bga snapshot calls it for you, and you can call it on any two run directories yourself:
bga compare /tmp/my-run-before /tmp/my-run-afterIt reports a signed delta for every certified floor, both efficiency signals, and each attribution category, plus a verdict (improved/regressed/no significant change/different work when the two runs built different elements, or within the baseline set's own observed range when a duration your own baselines already reached falls outside their band) — gated on confidence. Two runs that are not comparable are refused rather than compared, with an exit code of their own (cli.md).
One capture is not a baseline. Five captures of the same freedesktop-sdk commit, nothing changed, span 33% (3614.2s → 2712.4s) against a default significance rule of 1%. So gate CI on a baseline set and its noise band, not on a single pair —
bga baselineassembles one from published capture refs, andbga snapshot --aggregatesays the same thing about the runs you already have (min/median/p95 per host class;--blendto mix classes, which it refuses by default). The figures, the band those five define, and where it is still not enough:real-project.mdandci-comment.md.
Below is bga analyze on a real 3614-second freedesktop-sdk build — commit 953683fb, 4-core runner, --builders 4 --max-jobs 4, captured 2026-08-17 by run 32064333551 and published as captures/fdsdk/953683fb-incremental-b4j4-32064333551. It is kept, not current: an hour-long build that neither a clone nor CI can re-run, so what follows is that run's report as it printed then, but for one label refreshed since — wrapped to this page's width, and cut where marked.
Key Findings:
Incremental run (caches on): BuildStream skipped elements it had already built, 2 of
them on the critical path. Coverage and the floors below describe the work this run
actually did, not the whole project - compare against another incremental run, not
against a caches-off nightly
Confidence: 1.00 (high)
Biggest wait category: this build is execution-bound - no wait category exceeds 1% of
wall-clock time, so there is no scheduling gap to close
Where the time is: 4 element(s) are 94.0% of the 3610.5s critical path - this build is
chain-bound, not scheduler-bound
components/_private/cmake-stage1.bst 1569.8s (43.5% of path) -> fixing it saves 1569.8s (43.4% of the build)
components/openssl.bst 672.1s (18.6% of path) -> fixing it saves 522.5s (14.5% of the build)
components/python3.bst 639.8s (17.7% of path) -> fixing it saves 114.1s (3.2% of the build)
components/doxygen.bst 513.5s (14.2% of path) -> fixing it saves 513.5s (14.2% of the build)
-> these elements must get faster, or come off the chain; the scheduler has no room
left to give
Note: 77% of elements have zero slack - this graph is a mesh of near-equal chains, so
savings on one element are often capped by the next chain rather than by its own duration
Together, the top 3 are worth 2605.8s (72% of the build) - exactly the sum of their
individual savings, so they are three separate pieces of work that do not overlap
Work them in this order (by what a fix is worth, not by size), with what the build drops
to: components/_private/cmake-stage1.bst (2041s) -> components/openssl.bst (1518s) ->
components/doxygen.bst (1005s)
- the last of those leaves 72% of the build removed, projected from this run without
building again
Waiting off the critical path, worth nothing to fix today:
components/_private/git-minimal.bst (548s), components/icu.bst (431s) (+2 more) - they
bound how far shortening the chain can go
(structural projections over this run's measured durations, where "fixed" means the
element becomes instant - a re-capture is still the ground truth)
[... elided: the Efficiency Score line, and every section below Key Findings ...]
Five rows have changed the report since that run. UX-207 put the headline diagnosis on the line under Key Findings:; UX-365 scoped Biggest Opportunity to Biggest wait category — refreshed in place above — and moved Confidence below the actions it frames, which the block still shows in this run's own order; UX-475 split the Note: line in two, so the mesh wording now also counts the zero-slack elements off the critical path and a graph with none of them gets the opposite sentence. UX-478 and UX-479 then added two findings this block predates entirely. The numbers are this run's; the wording is that round's. The block under Quick start is the one checked line by line against a live run.
One command, every number measured rather than estimated. Three things worth taking from it:
it names the constraint ("chain-bound, not scheduler-bound" is a different problem from a
scheduling gap, and it says which one you have first); share of the path and what a fix is
worth are different numbers (python3.bst holds 17.7% of the chain and fixing it recovers
3.2% of the build — on a mesh graph that gap is the norm); and it refuses to double-count
(the top three are "exactly the sum of their individual savings", said because elsewhere they
would not be). --explain prints the evidence, the rule and a Perfetto query behind every one of
those claims, and bga whatif <element>… prices any set you pick instead of the three it ranked.
Line by line: Reading the report; the same build walked
end to end: docs/guides/real-project.md.
Two independent gates, because "slower" and "less efficient" are different verdicts — and on a growing project the first fails legitimately while the second is the one that catches real harm:
bga compare runs/baseline runs/candidate --fail-on-regression # exit 4: slower
bga compare runs/baseline runs/candidate --fail-on-efficiency-regression # exit 5: less efficient
bga compare runs/baseline runs/candidate --fail-on-inefficient-additions # exit 5: judged on the diff aloneThe third is the one to reach for as a project grows: dispatch occupancy is a whole-build average, so two maximally-mis-added elements move it −14.6pp in an 11-element project and −0.5pp in a 1201-element one — the gate goes blind exactly where it is needed. Judging the change scores those same two elements at 1.00 in both.
The whole CI sequence — capture, baseline set, gates, and posting the verdict as a PR comment —
is one page: docs/guides/ci-comment.md. To try it on your own
pipeline first, report-only, with every switch in one table:
docs/guides/pilot.md.
A git source keys on its ref, so directory: changes where a checkout is staged and not
what its cache key covers: twenty elements sourcing one monorepo all rebuild on any commit to
it. A local source keys on content, so only the elements whose files changed rebuild.
bga measures which one your project does, and what it costs:
bga analyze @last # a Shared Sources table, and a headline
# when one repo's ref decides the graph
bga blast https://…/monorepo.git # what a commit to it rebuilds, and for how long
bga blast components/lib-a # the same question about one directoryThe four ways to consume a monorepo and what each costs:
real-project.md.
A BuildStream log goes one start/end pair deep per element and says nothing about what happened
inside the sandbox. A second plane traces the real process tree there — make -jN,
cmake --build — through an LD_PRELOAD hook, plus a ptrace spine for statically linked
processes the hook structurally cannot see:
bga snapshot -- bst build <target> # both planes, one build
bga correlate @last # and what neither can say aloneIt answers what timing cannot: real CPU time per element (getrusage, the only genuine CPU
measurement in bga) separating compute-bound from waiting; where that CPU went, ranked by
time rather than invocation count; peak memory, which is what decides whether --builders
can go up — published as memory_envelope in correlate/v2, in megabytes, and explained in cli.md; achieved parallelism against the -jN it asked for, which is how a one-line
notparallel: True shows up as an element taking 3× as long as its work; and with
--trace-opens, which declared build dependencies an element never actually read — evidence,
never a verdict, since a runtime-only dependency is indistinguishable from an unused one.
Real output at every step, on a 127,627-process capture:
docs/guides/real-project.md. What the spine costs and when to
pay it: docs/design/architecture.md.
Both planes above need a build you decided to capture. A third needs nothing: BuildStream writes a log for every element it builds and keeps them, so every build already on your machine — including the ones nobody thought to instrument — is evidence.
bga cache-logs /path/to/your/projectIt answers what neither capture plane can, because it sees history rather than one run: which
elements this project keeps rebuilding, how much of each element's time never reached the build
at all (the sandbox tax — staging, integrating, caching), and what the build tools claim they
spent on configure. It costs one second of resolution and knows nothing about the scheduler, and
says so. Worked example and limits:
docs/guides/real-project.md.
docs/README.md is the index: one row per job — try it, optimise a real
project, run a pilot in CI, share a capture, read the report, look up a command or a contract —
and one link per row.
pip install -e '.[dev]' # pytest + ruff; `make test`/`make lint` need this, not the base install
make test-small # the tier to run while you work - wall clock is a property of the machine, below
make test # the whole suite - wall clock is a property of the machine, below
make lint # ruff + markdown (`make dev-run` prints a real report)Neither tier's wall clock above is a number this file pins (UX-551
retired the fixed figures UX-236 chose here, both of them: the guide
carries two dated make test-small readings, 18.2s and 20.8s, and
says wall clock there "moves more than 2x with load" the same as it
does for the whole suite): budget against the
fixing guide's measured range
rather than a reading dated to one afternoon.
Tiers come from measured per-file duration (tests/tiers.py, UX-238), not from taste; small is
the default, so a new file joins it free. pytest -m bst needs a real BuildStream, and CI's
bst-tests job fails if any of that tier is skipped — a skipped tier would read as a pass.
MIT