Benchmarking¶
postc ships a benchmark harness that measures its native backends against each other, against the postpyc reference baseline, and against CPython and NumPy. Its purpose is to track the postc-vs-postpyc trend over time (requirement 8): postc began as a lift of postpyc, and each recorded session makes any divergence visible as the backends evolve.
The harness is dev tooling — it lives in the top-level benchmarks/ package, outside the installed compiler. Run it with python -m benchmarks (or make bench).
Quick start¶
python -m benchmarks run # run the suite, print a summary
python -m benchmarks run --save # also record a session to benchmarks.db
python -m benchmarks run --save --label wip # tag the session
python -m benchmarks list # list recorded sessions (newest first)
python -m benchmarks compare # diff the two most recent sessions
run exits non-zero if any runtime produced an incorrect result, so it doubles as a coarse correctness gate in CI.
Commands¶
| Command | Flags | Purpose |
|---|---|---|
run |
--save, --db PATH, --label L |
Run every (kernel, runtime), print a min-time table and geomean ratios. --save records a session. |
list |
--db PATH, --limit N |
List recorded sessions (id, timestamp, git commit, label). Default --limit 10. |
compare |
--db PATH |
Compare the two most recent sessions per (kernel, runtime); +% means slower now. |
The database defaults to benchmarks.db in the working directory; override it with --db.
The runtimes¶
Each kernel is run through six contenders:
| Runtime | What it is |
|---|---|
postc-c |
postc's C backend, compiled with cc -O2. |
postc-qbe |
postc's QBE backend. |
postc-llvm |
postc's LLVM backend, assembled by llvmlite at -O2 (skipped if llvmlite is absent). |
postpyc |
The postpyc reference implementation — the baseline postc is measured against. A dev dependency (requirement 8). |
cpython |
The pure-Python computation, interpreted. |
numpy |
The vectorized NumPy computation. |
postc-c and postpyc both lower to C and hand it to cc -O2, so under this suite they compile byte-for-byte the same program (see below) and their times are expected to be equal. postc-llvm also runs at -O2, so it too lands at parity with postc-c. cpython and numpy serve as order-of-magnitude references.
If a runtime's build or setup fails — for example QBE is not installed — it is skipped with a note and the rest of the suite proceeds.
The kernels¶
Four kernels exercise different bottlenecks. Each is a POST @vectorize / @guvectorize source with a {pkg} placeholder for the compiler package, so the same source is templated for both postc and postpyc — the two compile identical programs.
| Kernel | Shape | Batch | Stresses |
|---|---|---|---|
square |
Float64 -> Float64 |
1,000,000 | Memory bandwidth |
dot |
(n),(n)->(), n=64 |
100,000 | Inner reduction |
gaussian |
Float64 -> Float64 via exp |
500,000 | libm exp |
matmul |
(m,k),(k,n)->(m,n), 4×4 |
100,000 | Nested-loop codegen |
Each kernel is compiled to a <name>_ufunc_loop symbol and driven over a large NumPy batch through the generalized-ufunc ABI (byte strides come straight from NumPy), so the entire inner computation runs in compiled code — the Python-side call overhead is amortized across the whole batch.
matmul is included deliberately: square is memory-bandwidth-bound and gaussian is exp-bound, so neither reflects codegen quality. matmul's nested reduction is where the QBE backend's gap versus cc -O2 actually shows.
Methodology¶
- Round-robin adjacent timing. The compiled backends (
postc-c,postc-qbe,postc-llvm,postpyc) are timed with their calls adjacent within each round (40 rounds, after 5 warmup passes). Timing them in adjacent slots means slow-machine drift hits all of them together and cancels in the ratio, so the backend-vs-backend comparison is trustworthy. The interpreted references are timed separately with fewer rounds — only their magnitude matters. - Min as the headline. The reported number per (kernel, runtime) is the minimum observed time, the measurement least polluted by scheduler noise. Mean and coefficient of variation (CV) are also recorded.
- Correctness-checked. Before timing, each runtime's output is compared against a NumPy reference with
np.allclose(rtol=1e-6, atol=1e-12). Incorrect results are flagged in the output and makerunexit non-zero.
The summary prints a per-kernel min-time table followed by three geomean ratios: postc-c vs postpyc, postc-qbe vs postc-c, and postc-llvm vs postc-c (in each, <1 means the first is faster).
Every backend must build to a distinct .so path
dlopen caches loaded libraries by path. If two backends build their
shared object to the same filename, the second dlopen returns the
first-loaded library instead of recompiling — so every backend silently
measures the C library and the suite reports fake parity. The harness avoids
this by giving each runtime its own path (<kernel>_<tag>.so, e.g.
matmul_qbe.so). Preserve that invariant when adding a backend.
Current results¶
Measured numbers as of this writing:
postc-c==postpyc— geomean of the min-time ratio ≈ 1.0x. Both go throughcc -O2on the same generated C, so parity is expected and confirms postc has not regressed against its baseline.postc-qbe≈ 1.15x slower thanpostc-con geomean, up to ~1.3x onmatmul. QBE's inner reduction is strength-reduced but not unrolled, which is exactly the gapmatmulwas added to expose.postc-llvm≈postc-c— geomean ≈ 1.01x, within measurement noise, includingmatmul. It emits naive IR and lets LLVM's-O2do the LICM, strength reduction, and unrolling, so it lands where the C compiler does — closing the gap QBE's hand-rolled passes could not.
See QBE backend and LLVM backend for the codegen details and C backend for the baseline.
Cross-backend comparison (with WASM)¶
run tracks postc against postpyc over time. A second command, cross, answers a different question — how do all the backends compare head-to-head, WebAssembly included? — and renders a shareable report.
python -m benchmarks cross # colour terminal table
python -m benchmarks cross --html report.html # + a self-contained HTML report
python -m benchmarks cross --quick # tiny batches (smoke run)
To put WASM on equal footing with the native backends, cross uses whole-array kernels — a plain POST function that loops over the batch internally (def k(x: Array[Float64], out: Array[Float64]) -> None) — rather than the generalized-ufunc loop the native pipeline uses. (A wasm module is not a linkable .o and has no ufunc loop yet, so the ufunc-driven run suite can't include it.) Every backend runs the same compiled kernel in a single call over the same data:
| Runtime | How it runs |
|---|---|
postc-c · postc-qbe · postc-llvm |
native .so, driven over the __pp_array ctypes ABI |
postpyc |
the original postpyc compiler, same source and ABI — the baseline postc was lifted from (requirement 8); parity with C is the health check |
wasm-wasmtime |
the .wasm in wasmtime (Cranelift, AOT) over memory64 linear memory |
wasm-node |
the same .wasm in node (V8's TurboFan JIT) |
numpy · cpython |
the same maths, for scale |
Any runtime whose toolchain is absent (no wasm-tools, node, wasmtime, or postpyc) is skipped. The headline metric is throughput (elements/second); the report leads with an overall bar chart (each backend's geomean throughput vs C) and closes with a WASM engine head-to-head — wasmtime (Cranelift) vs V8 on the byte-identical module.
Why only two WASM engines?¶
The WASM backend emits memory64 modules (i64 addresses match the frontend's byte-index arithmetic and the all-i64 __pp_array layout). Benchmarking a kernel means pre-populating linear memory with the input array, so a runtime must both support memory64 and let the host write into its memory. Only wasmtime (Cranelift) and V8 (node) qualify here: bun (JavaScriptCore) rejects memory64 outright, the wasmer CLI can't marshal arrays into memory via --invoke (and its Python bindings are unmaintained), and deno is just V8 again. wasmtime's Winch baseline compiler isn't exposed by the Python wheel. Two genuinely different engines (an AOT Cranelift and a JIT V8) is the practical maximum on this setup.
The four kernels are square (bandwidth), sumsq (reduction), gaussian (libm exp), and logistic (a tight scalar inner loop — the codegen-quality probe). Typical observations:
square— bandwidth-bound, and it is not SIMD: disassembly shows no backend auto-vectorizes here. All of C, LLVM, and postpyc saturate memory bandwidth with a 5-instruction post-increment pointer-walk inner loop; the win is-O2hoisting the array base/offset out of the loop (LICM), strength-reducing the index, and CSEingx[i]*x[i]to a single load. QBE used to trail badly (~0.23×) — its hand-rolled passes neither CSEd the duplicatex[i]load nor strength-reduced the contiguous loop. Both were added (a local CSE pass, and constant-stride recognition in the strength-reduction pass so it unrolls like the ufunc kernels), lifting QBE to ~0.55×; the residual is QBE-the-tool's instruction selection — it emitsldr [x0]; add x0, 8rather than a post-increment / scaled-index load, which the postc backend can't influence. This kernel also exposed an LLVM-backend gap — see the box below.sumsq— now at parity on every compiled backend (QBE included, once the contiguous loop strength-reduces and unrolls).logistic— LLVM tracks C; QBE trails ~1.5× on this scalar-heavy inner loop (no array strength reduction applies), the same class of gap the ufunc suite'smatmulshows.postpyc— lands at parity with C (both lower to C at-O2), confirming postc has not regressed against the compiler it was lifted from.- WASM — both engines land at a fraction of native throughput (a young backend plus the memory64 ABI), with V8 and Cranelift usually within ~1.1–1.2× of each other on pure-arithmetic kernels.
gaussiansits out the WASM race: a libm call lowers to a host import, so its cost belongs to the embedding (node's nativeMath.expvs a per-call ctypes trampoline forwasmtime-py). Timing it would measure the host bridge, which tells us nothing about the engine's codegen.
The benchmark found (and fixed) a real LLVM-backend gap
Early runs had postc + LLVM at only ~0.73× C overall, dragged down to ~0.3× on square. Disassembly showed why: the LLVM backend's naive IR carried no alias metadata, so -O2 could not prove the store to out[i] doesn't clobber the __pp_array descriptor and reloaded x.data/x.offset/out.data/out.offset every iteration instead of hoisting them. The fix was to emit TBAA metadata tagging descriptor loads and element loads/stores as disjoint types — exactly what clang does — which lets LICM hoist the descriptor loads. That's sound (unlike !invariant.load, it makes no constant-memory claim, so it survives the same array being passed twice or a ufunc rebinding a view's data pointer). LLVM now runs at ~0.98× C across the board, clearly ahead of QBE (~0.54×). See the LLVM backend page.
Correctness in cross is a checksum sanity gate against NumPy; the byte-identity oracle remains the test suite.
Tracking over time¶
run --save records a session — a git commit (git rev-parse --short HEAD), a timestamp, and an optional label — plus one row per (kernel, runtime) with min/mean time, CV, run count, and correctness, all in SQLite.
python -m benchmarks run --save --label "post qbe unroll"
python -m benchmarks list
# 12 2026-07-10T14:22:31 13bcf30 post qbe unroll
# 11 2026-07-09T09:05:12 e1972d5
python -m benchmarks compare # newest vs previous, per (kernel, runtime)
compare reports new.min_time / old.min_time - 1 as a percentage per pair, so a positive number means the newer session is slower. That is the signal to watch: the goal is to keep postc-c pinned to postpyc while closing the postc-qbe gap without regressing anything.
Tip
The schema is two plain tables (sessions, results) in
benchmarks.database. If you want a chart or a longer-term dashboard,
query benchmarks.db directly with any SQLite client.