mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-30 08:16:49 +00:00
* 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>
84 lines
5.0 KiB
Markdown
84 lines
5.0 KiB
Markdown
# 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:line` plus 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.
|
|
|
|
```bash
|
|
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::pin` sites.** 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
|
|
|
|
1. Run the relevant workload 3 times on `main` and keep the `WORKER` (or `PUSH`) lines.
|
|
2. Apply the change, then run it 3 times again.
|
|
3. When the cost is CPU in a library, also confirm it in a release build: a small standalone
|
|
crate in `/tmp` that pins the same crate version (see `Cargo.lock`), with a counting global
|
|
allocator and a timing loop. Debug numbers measure allocations reliably, but they do not
|
|
measure CPU.
|
|
4. 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::query` events are enabled, sqlx
|
|
formats a summary for every statement. Never read allocations from a `PERF_SQL=1` run.
|
|
- **The sampler's own work is excluded.** `IN_SAMPLER` stops the backtrace's allocations from
|
|
being counted. Keep it if you change how samples are captured, or idle reads tens of MB/s.
|
|
- **`RUST_BACKTRACE` and `RUST_LIB_BACKTRACE` must be unset (or `0`).** 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. Check `df -h /` first.
|