* chore: add per-job perf profiler and its docs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: create perf output dir and refuse to sample with backtraces enabled Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: treat PERF_SQL=0 as off and tag reports with their mode Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
5.0 KiB
Profiling the per-job path
How to find and prove a performance improvement on the job path, which is what any run of a script or flow pays: push, pull, execution, log writes, completion. The method finds candidates by measuring rather than by reading code, and keeps only changes whose effect was measured before and after. A candidate that cannot be measured is not a finding.
The harness
backend/tests/perf_profile.rs runs real jobs through the real queue, worker and database, all
in the test process. Its tests are #[ignore]d, so CI only compiles them. For each workload it
reports the following, separately for the idle, push and worker phases:
- requested allocation bytes and allocation count per job (raw, and with the idle rate subtracted)
- allocation sites, sampled once every 128 KiB allocated and once every 2048 allocations. Each
site is shown as the deepest Windmill
file:lineplus the library call it allocated in (LEAF), and as the call chain of the four deepest Windmill frames (CHAIN) - with
PERF_SQL=1, instead of allocations: every SQL statement, how many times it runs per job, and its database time per job
Workloads: a deployed bash script, a deployed Bun script, a deployed 3-step identity flow and a
deployed 3-step bash flow. To profile another path, add a workload with the same shape: create
the script or flow with SQL, then call profile_workload. Typical additions are a Python
script, a script with many args, a flow with a branch or a loop, or a job with a large result.
cd backend && source ../.env.local # DATABASE_URL: sqlx::test creates its DBs from it
OUT=/tmp/perf && mkdir -p $OUT
# allocations and sites
PERF_OUT=$OUT PERF_N=30 cargo test --features quickjs --test perf_profile -- --ignored --nocapture --test-threads=1
# SQL statements per job (a separate run, see the caveats below)
PERF_SQL=1 PERF_OUT=$OUT/sql PERF_N=30 cargo test --features quickjs --test perf_profile -- --ignored --nocapture --test-threads=1
grep -h '^===' $OUT/*.txt # the per-job totals, one line per phase
Use the same --features as the running dev backend so the build reuses its artifacts. One
workload takes about 20 s.
Reading the output
- Bytes per job is the main metric. It is very repeatable, varying less than 0.1% between runs on the same code, so 3 runs before and 3 after a change are enough. Time per job is noisy in a debug build and is not a measure of anything.
- A site that sits on top of every script workload deserves attention first. Per-job setup that costs the same however small the job is matters most, because most jobs are small.
- Discount
pin<…>/Box::pinsites. They are async state machines, which are many times larger without optimisation. Confirm one in release before acting on it. - SQL statements per job is usually the larger lever. Each statement is a network round trip in production. Look for statements repeated per job or per step that read something already known (a cached value, a row read earlier in the same job), and for appends that could be merged.
A/B a candidate
- Run the relevant workload 3 times on
mainand keep theWORKER(orPUSH) lines. - Apply the change, then run it 3 times again.
- When the cost is CPU in a library, also confirm it in a release build: a small standalone
crate in
/tmpthat pins the same crate version (seeCargo.lock), with a counting global allocator and a timing loop. Debug numbers measure allocations reliably, but they do not measure CPU. - Run the tests that pin the behaviour you touched, and put the before/after table in the PR.
Changes to job completion, the queue pull or the flow state machine are the critical path. Test them beyond the profiler: the existing suites for that area, concurrency, and older Postgres versions.
Caveats
- Split DWARF. In dev builds a frame's location is a relative
./windmill-*/src/…:line, and inlined frames have bare names (poll<…>). The harness keys sites by location for that reason. Do not switch it to keying by symbol name. - SQL counting changes the allocation numbers. Once
sqlx::queryevents are enabled, sqlx formats a summary for every statement. Never read allocations from aPERF_SQL=1run. - The sampler's own work is excluded.
IN_SAMPLERstops the backtrace's allocations from being counted. Keep it if you change how samples are captured, or idle reads tens of MB/s. RUST_BACKTRACEandRUST_LIB_BACKTRACEmust be unset (or0). Sampling takes std's backtrace lock, which is not reentrant. With either variable enabled, errors capture backtraces of their own, and an allocation made under that lock can deadlock the sampler. The harness refuses to start when either is set.- Deployed Bun scripts need a NULL lock.
''fails at run time with "expected //bun.lock split pattern". - Disk. The test build shares
target/with the dev backend, but the first build also compiles the test-only dependencies. Checkdf -h /first.