Find the slowest parts of a SteleKit Bazel build/test run and CI job, and know which fixes actually move the needle. Use when asked to speed up Bazel builds/tests, investigate why CI is slow, or find build hotspots — locally or from a GitHub Actions run.
Scanned 9/20/2026
Install to Claude Code
npx -y skills add tstapler/dotfiles --skill bazel-perf --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Bazel Perf?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/tstapler-bazel-perf)More formats (shields.io, HTML) on the badges page.
---
name: bazel-perf
description: Find the slowest parts of a SteleKit Bazel build/test run and CI job, and know which fixes actually move the needle. Use when asked to speed up Bazel builds/tests, investigate why CI is slow, or find build hotspots — locally or from a GitHub Actions run.
---
# Bazel build/CI hotspot hunting
Bazel already tells you where time goes. Don't build custom instrumentation — read
its own trace profile and `INFO:` summary lines first.
## 1. Local: profile one build/test
```bash
bazel build //kmp:android_app --config=android --profile=/tmp/profile.json.gz
# or: bazel test //kmp:jvm_tests --profile=/tmp/profile.json.gz
```
`bazel analyze-profile` is gone as of Bazel 9.2.0 (this repo's version — no `.bazelversion`
pin, Bazelisk resolves latest) — `bazel help` doesn't list it. It was still documented as
of Bazel 7.4.0, so it was removed somewhere in 8.x/9.x, not just missing from an old
install; upgrading further will not bring it back. Use the JSON trace profile directly
instead — it's the modern, still-supported path and is actually more useful (interactive
timeline, not just text):
- **Perfetto UI (recommended):** open https://ui.perfetto.dev, drag in the `.json.gz`
directly (no need to unzip) — flamegraph-style timeline, filterable by thread/action.
- **`chrome://tracing`:** same file, unzip first (`gunzip profile.json.gz`); Perfetto UI
supersedes this but it still works.
- **jq, for a quick top-N without leaving the terminal** — pull the slowest individual
actions (filter out phase/wrapper wrapper events like `Action.execute`,
`Resources acquired`, `Worker #N working`, `buildTargets` to see real action names):
```bash
zcat /tmp/profile.json.gz | jq -r '
.traceEvents[] | select(.dur != null) | "\(.dur/1000)ms \(.name)"
' | sort -rn | head -20
```
## 2. The single most useful number: elapsed vs. critical path
Every `bazel build`/`test` prints this line — it's the fastest hotspot signal there is:
```
INFO: Elapsed time: 1510.753s, Critical Path: 236.84s
```
- **Elapsed ≈ Critical Path** → the build is dependency-chain-bound. The only fix is
shortening that chain (split a slow target, parallelize within it, cache it).
- **Elapsed ≫ Critical Path** (seen here: 6.4x) → most of the wall clock is
*scheduling/spawn overhead*, not real work. Look at concurrency limits
(`--local_resources`, `--jobs`) and whether actions run as persistent workers
vs. spawning a fresh process each time (next section).
Check this for a CI run without downloading full logs:
```bash
gh run view <run-id> --log 2>/dev/null | grep -E "INFO: Elapsed time|Build completed"
```
## 3. Action-strategy breakdown (is this job action-count-bound?)
Bazel prints the execution strategy after each action line, e.g.
`; 3s disk-cache, remote-cache, processwrapper-sandbox`. Tally them:
```bash
gh run view <run-id> --log 2>/dev/null | grep "<job name>" \
| grep -oP '; \d+s \K[a-z0-9, -]+(?=\.\.\.)' | sort | uniq -c | sort -rn
```
`processwrapper-sandbox`/`linux-sandbox` means a fresh process (often a fresh JVM) per
action — expensive when there are thousands of small actions (Android dexing/desugaring,
per-file C++ compiles). `worker`/`multiplex-worker` means a warm persistent process is
reused. A job dominated by sandbox strategy with a large elapsed/critical-path gap is the
textbook case for enabling persistent workers, not for adding more CPU/jobs.
**Tried and reverted here** (do not re-attempt without the memory math below):
`build:android --persistent_android_dex_desugar` and `--persistent_android_resource_processor`
(native `rules_android`/Bazel flags — `bazel help build | grep persistent`) shift Desugar/
DexBuilder/PackageAndroidResources from `processwrapper-sandbox` to `worker` mode — real, on
`//kmp:android_app` this was the majority of a 6.4x elapsed/critical-path gap. But it doesn't
fit this repo's 16GB GH runner and got reverted after 3 failed CI attempts.
**Why it broke, and why "local passed" didn't catch it.** A persistent worker stays
resident holding its JVM heap *between* actions instead of releasing it on exit like the
sandboxed process it replaces, and these actions' declared `resource_set` apparently
understates that footprint — so `--local_resources=memory=` doesn't stop enough concurrent
workers from spawning to blow past the runner's real RAM. Local verification on a
61GB/24-core dev box showed clean action-count shifts with zero swap and looked fully
safe — the box just has too much headroom to ever hit the ceiling. **Do the memory math
against the target runner's actual RAM, not just "run it and check for failures":** this
repo's own `.bazelrc` already documents these as `-Xmx3G` processes (the comment behind
`--local_resources=memory=9000`). Even `--worker_max_instances=<Mnemonic>=2` on all three
new-worker mnemonics is 6 workers × 3GB = 18GB from *just those three*, before the 6GB
Bazel-server cap, `KotlinCompile`'s own (pre-existing, already-persistent) worker, or OS
overhead — on a 16GB box that was never going to fit, and 3 rounds of CI (runs
33929109869, 33931054162, 33931907031) each took ~12-25 minutes to confirm it the hard
way. Multiply *(cap × mnemonics × per-worker -Xmx)* against the runner's total RAM minus
the Bazel server cap **before** picking a cap number, and mnemonic name matters
(`--worker_max_instances=<Mnemonic>=N`, `--worker_max_multiplex_instances=<Mnemonic>=N` —
find every mnemonic the flag actually touches with
`grep -oP 'bazel-workers/worker-\d+-\K\w+(?=\.log)'` on a failure log, not just the ones
you expect; missing one is exactly what caused attempt #2 here). **A local "no failures"
run is not sufficient proof for any worker/concurrency flag — push it and watch the actual
CI job, and don't declare success until that's green**, not just "didn't error locally."
Revisiting this needs either a larger-memory CI runner or verified real per-worker RSS
(not the tool's understated `resource_set`) measured on hardware matching the runner.
## 3b. One giant target on the critical path
The jq top-N (§1) surfaces this directly: if the single biggest action is a `KotlinCompile`
covering hundreds of files in one target (e.g. `//kmp/src/androidMain/kotlin:android_main
{ kt: 711 }` at 71s — measured here, ~half of a 138s critical path by itself), that's a
monolithic-module problem, not a scheduling or worker problem. A single Kotlin compilation
unit can't be parallelized internally and can't get a partial cache hit — touch one file
in it and the whole 711-file action reruns. §2's rule applies: this is dependency-chain-
bound, so persistent workers/more cores/cache tuning don't touch it. The fix is splitting
the module into smaller Bazel targets (by package, e.g. `:model`, `:repository`, `:db`,
`:ui` as separate `kt_jvm_library`/`kt_android_library` targets with explicit deps) so an
unrelated change only recompiles its own slice. Check for import cycles between the
candidate packages first (`grep -rl "import dev.stapler.stelekit.<pkgA>" <pkgB>/` both
ways) — a real cycle means those two packages can't be split apart without breaking one of
them out further.
## 4. Local build (correctness first, output is secondary signal)
`bazel build ... --config=remote-cache` prints cache-hit counts at the end
(`INFO: N processes: X action cache hit, Y disk cache hit, ...`). A cold run with
near-zero cache hits on a CI branch that should have a warm cache points at cache-key
churn or GitHub Actions cache eviction (~10GB/repo), not compute — check
`.github/workflows/bazel-ci.yml`'s `setup-bazel` cache config before touching build flags.
## What NOT to do
- Don't add `--jobs=N` blindly — GH standard runners are 4 vCPU; more `--jobs` than
cores just adds queuing, it doesn't add throughput.
- Don't raise `--local_resources=memory=` without re-reading the OOM comment above it in
`.bazelrc` (run 33803270917 — Bazel server got OOM-killed at higher concurrency).
- Don't chase the slowest single action mnemonic before checking elapsed-vs-critical-path
— a build with a short critical path but a huge gap is a scheduling problem, and
shaving one slow action won't fix it.
- Don't enable a persistent-worker flag and call it done after one clean local run — verify
on the real CI runner's memory (§3's OOM story). A big dev box hides exactly this class
of regression.
- Don't reach for `bazel analyze-profile` — it doesn't exist in this repo's Bazel version.
Use the JSON trace profile (§1).
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!