Performance history — the Phase Perf series¶
A record of the audit-driven optimization pass that ran 2026-05-07. Captures the seven phases, what changed at each one, and the empirical wall-clock numbers that drove the next decision.
The point of this document is not just what shipped — the commit
log already says that. The value is the empirical-vs-speculative
pattern: every phase started by profiling (cProfile against
scripts/profile_perf1.py) and ended by re-profiling. Several
candidate optimizations were rejected after benchmarking even
though they looked correct on paper. Those are recorded here too,
so the next person who reads "let's parallelize the inner loop"
on a similar hot path has the previous evidence to lean on.
For the design rationale behind each Newton-solve path
(direct + chord, JFNK + ILU(0), block-Jacobi, pure JFNK) and
the preconditioner-shape lesson distilled from the empirical
evidence here, see numerics.md.
Canonical workloads¶
Two TOML cases were the calibration targets. Both run under
scripts/profile_perf1.py:
| Slug | Path | What it stresses |
|---|---|---|
| 40 | examples/40_implicit_adaptive_pipe.toml |
Single pipe, implicit BDF2, adaptive Δt, porous-Forchheimer plug. Ideal gas. The per-Newton-iter Jacobian/residual hot path with closures. |
| 54 | examples/54_adaptive_picard_turbo.toml |
4-pipe Picard turbo network with adaptive Picard. Ideal gas. The orchestration overhead, BC dispatch, and per-pipe scaling. |
| 58 | examples/58_tabulated_eos_implicit.toml |
Single pipe, implicit BDF2, tabulated CO₂ EOS. Validates the EOS-batched helpers when CoolProp is installed. |
Cumulative trajectory¶
| Phase | Case 40 wall | Case 54 wall (serial) |
|---|---|---|
| Audit baseline | 0.323 s | 39.66 s |
| Perf-1 (EOS hot-path batching) | 0.323 → 0.323 s* | 39.66 → 39.66 s* |
| Perf-2-quick (D_J broadcast) | 0.323 → 0.241 s (−25 %) | 39.66 → 27.70 s (−30 %) |
| Perf-2 (closure-Jac JIT + line-search) | 0.241 → 0.110 s (−54 %) | 27.70 → 18.09 s (−35 %) |
| Perf-3 (chord Jacobian reuse) | 0.110 → 0.048 s (−56 %) | 18.09 → 8.24 s (−54 %) |
| 27c (opt-in parallel pipes) | n/a (single pipe) | 8.24 → 5.73 s (−30 % opt-in) |
| Perf-4 (JIT'd BC dispatchers) | 0.048 → 0.020 s (−58 %) | 8.24 → 2.67 s (−68 %, serial) |
27c-cells (prange Jacobian — reverted) |
0.020 → 0.041 s (×2 slower) | 2.67 → 3.4 s (×1.3 slower) |
| Final | 0.020 s | 2.67 s |
| Cumulative reduction | 94 % (16 ×) | 93 % (15 ×) |
* Case 40 and 54 are ideal-gas; Phase Perf-1 targets the tabulated
and cubic-EOS Newton-Jac path which neither workload exercises.
Use scripts/bench_perf1_tabulated.py or example 58 for direct
Perf-1 measurements.
Phase Perf-1 — EOS / solver hot-path optimization sweep¶
Commit: 338126d. Trigger: initial audit identified
redundant bilinear table lookups + FD-fallback derivative
computations as the EOS hot-path drag.
What changed
- Wired the B23-precomputed ∂p/∂T and ∂p/∂ρ panels directly into
eos_dpdT_dpdrho_jit(replaces a 4-call central-difference fallback that the Phase B23 commit had flagged as a future optimization). - Closed-form ∂e/∂T, ∂e/∂ρ for tabulated EOS via
_bilinear_gradon the energy panel — same FD-fallback pattern eliminated for the energy derivatives. - Batched
_bilinear_twoand_bilinear_grad_twohelpers share the (T, ρ) cell-index work across two panels in one cell pass. - New fused dispatcher
eos_a_and_derivs_jit(T, ρ, kind, params)returns(a, ∂p/∂T, ∂p/∂ρ, ∂a/∂T, ∂a/∂ρ)in a single call — collapses three separate dispatches per face per Newton iter inhllc_jac._eos_primitive_gradients. The cubic-EOS body sharesp_v / p_T / p_vv / p_TT / p_vTacross all five outputs; the tabulated path takes a single shared bilinear- grad cell pass on the dpdT and dpdρ panels. - Fused tabulated
(p, a)recovery inprimitives_from_U_eos_jit(was three separate bilinear traversals). - Block-Thomas
_inv3x3_intowrites through a hoisted scratch buffer, eliminating per-cellnp.empty((3, 3))allocations.
Validation
Bit-exact (rel diff < 1e-12) on Peng-Robinson CO₂ at T = 350 K, ρ = 80 kg/m³ vs the legacy separate-call paths. 82 EOS / HLLC-Jac / real-gas / implicit-real-gas regression tests pass.
Direct wall-clock impact
Cases 40 and 54 don't exercise the analytic-EOS Jacobian path
(both are ideal-gas, which has its own fast-path). To measure
Perf-1 directly, run scripts/bench_perf1_tabulated.py or
profile examples/58_tabulated_eos_implicit.toml.
Phase Perf-2-quick — D_J broadcast¶
Commit: d56a644. Trigger: Post-Perf-1 cProfile flagged
_newton_step's self-time at 29 ms (single-pipe) and 15.5 s
(network) — most of which was a per-cell Python loop building
D_J = I − dt · D_blk.
What changed
# Before:
D_J = np.empty_like(D_blk)
for i in range(pipe.N):
D_J[i] = _I3 - dt_eff * D_blk[i]
# After:
D_J = -dt_eff * D_blk
D_J[:, _DIAG3, _DIAG3] += 1.0
Wall-clock
- case 40: 0.323 → 0.241 s (~25 %)
- case 54: 39.66 → 27.70 s (~30 %)
_newton_stepself-time: 29 ms → 1 ms (single-pipe); 15.5 s → 3.1 s (network)
The single-broadcast was dramatically more expensive than expected — the per-cell Python interp overhead × N × Newton iters × steps adds up.
Phase Perf-2 — JIT-ify closure-Jacobian dispatcher and Newton line-search¶
Commits: 9ea8bd4 (closure-Jac) + a14d29a (line-search).
Trigger: Post-Perf-2-quick cProfile showed
_augment_jacobian_with_closures at 25–37 % of single-pipe wall
time — a Python for i in range(pipe.N) loop dispatching
closure-Jacobian helpers one cell at a time.
What changed
- Five real-gas closure helpers were silently plain-Python
despite calling @njit kernels internally:
_real_gas_T_app_chain,_real_gas_heat_jacobian,_real_gas_heat_jacobian_from_chain,_real_gas_porous_sutherland_jacobian,_real_gas_porous_sutherland_jacobian_from_chain. Decorated@njit(cache=True, fastmath=True, inline="always"). - New JIT kernel
_augment_closure_jac_analytic_jitruns the per-cell loop body inside@njit, with all dispatch flags passed as integers and missing-closure params passed as length-1 sentinel arrays (numba can't take PythonNone). - Line-search positivity-guard pieces became
_line_search_trial_jit(fused trial-state alloc + ρ>0 / e>0 checks) and_residual_norm_jit(fused F = U_trial − U_hist − dt·R + ‖F‖). Eliminated 5 transient numpy allocs per backtrack.
Wall-clock
- case 40: 0.241 → 0.110 s (~54 %)
- case 54: 27.70 → 18.09 s (~35 %)
Test note — relaxed test_shared_real_gas_chain_matches_separate_helpers
from assert_array_equal to assert_allclose(rtol=1e-12):
both helpers now run under fastmath; FMA fusion gives 1 ULP
diffs even though the algebra is identical.
Phase Perf-3 — chord (lazy-Jacobian) reuse¶
Commit: 11d32f1. Trigger: Post-Perf-2 the dominant cost
on case 54 was the JIT Jacobian assembly itself (5.6 s of
18.1 s = 31 %). The kernel was already optimal; the only
remaining lever was to call it less often.
What changed
Hold J = I − dt · ∂R/∂U fixed across multiple inner Newton
iterations, accepting linear-with-good-constant convergence in
exchange for 30–50 % less Jacobian work per Newton solve.
Two NewtonConfig knobs:
chord_max_age(default 2): hard cap on iterations between refreshes.0reproduces the legacy full-Newton path bit-exactly.chord_rate_threshold(default 0.5): ifF_norm_new / F_norm_old > threshold, the chord has stopped matching the local Newton direction — refresh the Jacobian on the next iter. Line-search rejection on a chord-aged iteration also forces a refresh.
The cache is the post-broadcast (L_J, D_J, Up_J) triple, so
chord-reuse skips the closure-Jac augmentation, the geometry-Jac
augmentation, and the numpy broadcasts too.
Wall-clock
- case 40: 0.110 → 0.048 s (~56 %)
- case 54: 18.09 → 8.24 s (~54 %)
Side-effect — chord-Jacobian shifted Newton iter counts on
each Picard iter, which the adaptive Picard controller's
"easy" detection picks up. One regression test
(test_adaptive_picard_settles_on_plenum_only_network) was
relaxed from == 2 to in (2, 3) to track the shifted but
still-valid controller trajectory.
Phase 27c — opt-in parallel per-pipe Newton solves¶
Commit: 4316e08. Trigger: Post-Perf-3 the Python
orchestration around _newton_step was still 9 % of network
wall time, and the per-pipe Newton solves are theoretically
independent during a Picard iteration (junction state is
frozen between _fill_component_ghosts calls).
What changed
- New
NewtonConfig.parallel_pipesflag (defaultFalse). - New
[case] parallel_pipesTOML field onCaseHeader. - Helper
_try_parallel_first_passruns the per-pipe Newton solves viaThreadPoolExecutor(max 8 workers) and returns(max_n_iters, total_n_iters)on success orNoneon any pipe's failure. - On success the parallel result accumulates the Newton-iter stats and the sequential per-pipe loop is bypassed. On failure all pipes restore to the start-of-iter snapshot and the legacy sequential dt-halving loop runs unchanged.
Wall-clock
- case 54 (parallel, opt-in): 8.24 → 5.73 s (~30 %)
Validation — test_parallel_pipes_phase27c.py: 3 tests
(serial-vs-parallel rtol=1e-10, default-off contract, single-
pipe bypass).
Why default-off after Perf-4 — see next phase.
Phase Perf-4 — JIT'd BC dispatchers + chord knobs on TOML¶
Commit: 0acfd6a. Trigger: Post-27c parallel-pipes
cProfile showed _thread.lock.acquire at 67 % of network wall
time. The Python-level BC dispatchers (stagnation_inlet.bc,
pressure_outlet.bc) were holding the GIL during the per-pipe
apply_ghosts callback path, which serialized the parallel
threads.
What changed
make_stagnation_inlet_bcandmake_pressure_outlet_bcnow delegate to@njitkernels (_stagnation_inlet_apply_jitwith a JIT'd_solve_a_b_jitfor the characteristic+stagnation quadratic;_pressure_outlet_apply_jitwith a Python-side fallback totransmissive_bcon supersonic outflow).[case] chord_max_age/chord_rate_thresholdsurface the Phase Perf-3 chord knobs to TOML cases.
Wall-clock
- case 40: 0.048 → 0.020 s (~58 %)
- case 54 (serial): 8.24 → 2.67 s (~68 %)
- case 54 (parallel): 5.73 → 5.10 s (~11 %)
The unexpected finding
JIT-ifying the BCs sped up the serial path more than the
parallel one. Once the BC apply is GIL-released and inlined
into the @njit region, the per-pipe sequential pipeline is
bound by JIT kernel work rather than Python orchestration.
Thread-coordination overhead (ThreadPoolExecutor setup per
Picard iter, BC closure dict lookups, _thread.lock.acquire)
now dominates the parallel path.
Result: serial beats parallel ~2 × on the canonical 4-pipe
network case (2.67 s vs 5.10 s). parallel_pipes stays
default off — defaulting it on would have been a
regression.
Phase 27c-cells — prange Jacobian (prototyped, reverted)¶
Commit: b2667ae (negative-result documentation; the
prototype lives in git history pre-revert). Trigger:
Post-Perf-4, _assemble_jacobian_fo_ideal_uniform_jit is the
dominant remaining cost on case 54 (0.85 s of 2.67 s = 32 %).
Theoretically parallelizable across the face loop — each face
writes to two cells (f-1 and f), so adjacent faces conflict
but even/odd-faces colouring gives two disjoint passes.
What was tried
@njit(cache=True, fastmath=True, parallel=True)
def _assemble_jacobian_fo_ideal_uniform_jit(...):
# Pass 1: faces 0, 2, 4, …
for k in prange(n_even):
f = 2 * k
_accumulate_face_contribution_jit(f, ..., L, D, Up)
# Pass 2: faces 1, 3, 5, …
for k in prange(n_odd):
f = 2 * k + 1
_accumulate_face_contribution_jit(f, ..., L, D, Up)
The colouring is correct (even faces write to disjoint cell
sets, same for odd). Each prange iteration's scratch
(dF_dUL, dF_dUR, etc.) was made local to the helper so
each parallel iteration gets its own.
Wall-clock — slower than serial
- case 40: 0.020 → 0.041 s (~2 × slower)
- case 54: 2.67 → 3.4–3.8 s (~1.3–1.4 × slower)
Numba's parallel=True setup overhead per call dominated the
parallel work at N = 80 cells per pipe. Break-even is somewhere
around N = 200–500 cells per pipe based on rough scaling.
Reverted; the colouring scheme is recorded in BACKLOG.md for
replay if a long-pipe bench case ever justifies it.
The lesson — kernel-internal prange reads as the obvious
optimization (the data dependencies clearly factor), but at
small N the framework overhead dominates. Always benchmark.
Phase Perf-5 — odds and ends¶
Commit: b2667ae. What landed
- Fixed
examples/58_tabulated_eos_implicit.toml(EOS block was under[gas.eos]not[pipe.eos]; missingpipe =field on[[bc]]blocks). - Profile harness learns case 58.
- Picard outer-loop audit: profile shows
_ghost_snapshot_max_rel_changeat 1.7 % of case 54 wall; below the threshold where JIT-ifying pays back. Skipped. - Phase 27c-cells negative result documented.
Phase Perf-6 — chemistry advection JIT¶
Commit: 53f9bab. Trigger: Phase Perf-history-2
profile of examples/41_dpf_regen.toml (DPF + chemistry +
thermal cake) showed _species_rate was 56 % of total wall
time (1.19 s of 2.13 s). Same Python-loop-around-JIT-kernels
pattern as Phase Perf-2's _augment_jacobian_with_closures —
the per-cell for i in range(N) body in
network_timeloop._species_rate called hllc_flux(...) per
cell from Python, with Python attribute access (pipe.U_pad,
pipe.Y, pipe.gamma) per iteration.
What landed
Two @njit kernels in aeolus1d/solver/species_advection_njit.py:
species_advection_primary_jit(U_pad, Y_pad, gamma, dx, N, Ng)— primary-species-only path. Returnsd(rho*Y)/dt.species_advection_extras_jit(U_pad, Y_pad, Yx_pad, gamma, dx, N, Ng, n_extra)— primary + Phase-22 extras. Returns(d(rho*Y)/dt, d(rho*Yx)/dt).
hllc_flux is @njit and inlines into the kernel; the two
variants exist because numba's handling of zero-column 2-D
arrays is awkward and the n_extra=0 / n_extra>0 branches have
different return shapes. The Python wrapper in _species_rate
keeps:
- The species-inflow BC overlay (per-cell
Y_padbuild with optional inflow schedule lookup) — needs Python-side dict access onpipe.species_inflow_left/right. - The reaction callable invocation (
pipe.py_reaction/pipe.py_reaction_multi) — Python callbacks by definition. - The DPF filter-trap overlay via
filter_trap_rates(which is itself@njit).
This isolation is intentional: keep the JIT kernel pure-numeric + stable input shapes; let Python handle the per-pipe schema discovery and overlay calls.
Wall-clock
| Workload | Baseline | Phase Perf-6 | Speedup |
|---|---|---|---|
41_dpf_regen (default t_end = 0.06) |
4.63 s | 1.74 s | 2.66 × |
Apples-to-apples wall-clock measurement (best of 3 runs without cProfile, on the same machine, both with pre-warmed numba caches).
cProfile breakdown of _species_rate:
| Baseline | Phase Perf-6 | |
|---|---|---|
_species_rate self-time |
1.19 s | 0.27 s |
species_advection_extras_jit (new) |
— | 0.11 s |
| End-to-end species-rate cost | ~1.19 s | ~0.38 s |
The Python wrapper still has measurable overhead (Y_pad build + reaction overlay + filter call) — that's ~0.27 s. The JIT kernel itself is ~0.11 s. End-to-end the chemistry hot path is ~3 × faster.
Tests
- All 170 chemistry / DPF / SCR / filter integration tests pass.
- The Phase Perf-history-2 single-shot regression bench was
already in place (
tests/integration/test_dpf_*) — bit-exact against the pre-Perf-6 baseline because the algebra is unchanged (we just moved the for-loop from Python to numba-compiled code).
Why no make_strang_chemistry change
The Strang-split path uses _species_rate(pipe, R,
include_reaction=False) for the transport half; same kernel
fires, same speedup applies. Reaction half is BS_jit / LSODA
and was already JIT (BS) or external-C (LSODA).
Phase Perf-6r2 — chemistry / filter Python wrapper hoist¶
Trigger: Re-profiled case 41 post-Perf-6. Wall was 2.55 s down from 4.63 s, but the breakdown told a story — the wrappers that call the JIT kernels still spent more wall in Python attribute-access dispatch than in the kernels themselves:
| Wrapper | self-time | % of wall |
|---|---|---|
_species_rate |
0.386 s | 15.1 % |
regen_into |
0.237 s | 9.3 % |
cake_into |
0.079 s | 3.1 % |
Each wrapper was doing pipe.filter.X 10-25 times per
call (× ~20 K RHS calls/s = ~250 K attribute walks/s);
pipe.n_extra_species is a @property called 3× per
filter wrapper; the filter ones did 5+ float()/int()
casts on filter parameters inside the call site (so
Python evaluated each before dispatching to numba).
False start: First tried to eliminate the Y_pad /
Yx_pad allocations in _species_rate by changing the
JIT-kernel signature to read pipe.Y / pipe.Y_extras
directly with explicit ghost scalars. This regressed
22 % (2.55 s → 3.13 s). Root cause: the new kernel had
to insert a per-cell-per-species if right_is_ghost
branch into the hot inner loop because the interior and
ghost rows now live in different arrays. The old
Yx_pad[idx, k] was a single indirect load with no
branch; numba SIMD-vectorised that loop. The pad-array
allocation cost was tiny compared to the branchless-loop
benefit. Reverted.
Actual fix: Hoist every repeated attribute lookup
inside the three wrappers into a local variable at the
top of the function — f = pipe.filter, then f.kind /
f.soot_loading / etc; cache pipe.U, pipe.Y,
pipe.Y_extras, pipe.gamma, pipe.n_extra_species;
hoist the from aeolus1d.closures.filter import
filter_trap_rates out of _species_rate to module
scope (was doing an import-system lookup per RHS call
even though Python caches modules — the dict lookup
itself isn't free).
Result (CORRECTED 2026-05-15)
The original commit message claimed 1.43× wall on case 41 (2.55 → 1.78 s, 7-run median). That was a system-load artefact — measurements drifted between the two benchmark runs because the WSL2 host was under different load.
A proper back-to-back cProfile A/B comparison (PRE → HEAD → PRE → HEAD) shows the real per-function and wall-clock deltas:
| Function | PRE-r2 | HEAD | Δ |
|---|---|---|---|
_species_rate self |
0.275 s | 0.226 s | −18 % |
regen_into self |
0.171 s | 0.156 s | −9 % |
cake_into self |
0.052 s | 0.070 s | +35 %* |
pipe.U property calls |
269 724 | 249 626 | −7 % |
| Total cProfile wall | 2.387 s | 2.293 s | −4 % |
(*cake_into's cProfile self-time went up because
cProfile's per-function overhead is now a larger fraction
of a smaller function — actual production wall is faster,
just hard to measure with the wrapper's small self-time.)
So the real win is ~4 % wall and 9–18 % on the targeted wrappers, not 43 %. Still positive, still worth keeping, but a much more modest gain than the original claim.
Lessons recorded
- When a Python wrapper sits on top of a JIT kernel and the
wrapper has many
obj.attrwalks per call, hoist the walks before reshaping the kernel. The wrapper overhead is often the actual hotspot and the cleanest fix has nothing to do with the kernel. - Wall-clock benchmarks under WSL2 (and any shared
hardware) drift with host load. The audit-driven Perf
series used cProfile back-to-back deltas on the premise that
per-function self-time stays comparable across system
load while wall time does not. That premise is false
(retracted 2026-10-07;
BACKLOG_HISTORY.md): one profile read 82.29 s at-n 4against 44.64 s alone, because cProfile charges the contended time to whatever function was running. Compare self-time only between runs under the same, recorded contention (the perf records now carry CPU pressure and steal). A bench reporting "1.4×" that's really 1.04× is worse than no bench at all — it burns trust. Always confirm wall-time wins with a tight back-to-back A/B (HEAD → revert → HEAD), not before-and- after measurements separated by minutes of unrelated work.
Phase Perf-6r3 — junction-code Python wrapper hoist¶
Trigger: Re-profiled engine case 50 (2-cyl engine network) to find what's hot. The chemistry wrappers Perf-6r2 targeted are never called on engine cases (no DPF, no chemistry), so that phase had zero effect on engine wall. The actual engine hot path is the junction code:
| Function (case 50) | Self-time | Per-call cost |
|---|---|---|
_packed_residual_fo_ser (JIT) |
0.94 s | 36 µs (true compute) |
accumulate_rates |
0.39 s | 7.5 µs (Python wrapper) |
_pipe_port_flux |
0.38 s | 2.0 µs (Python dispatch) |
_fill_plenum_ghost |
0.30 s | 1.5 µs |
_pipe_fuel_key |
0.28 s | 0.9 µs |
apply_port_bcs |
0.28 s | 5.3 µs |
The cumulative junction-Python self-time at HEAD-of-Perf-6r2 was 1.94 s — 27 % of the engine-50 cProfile wall (7.31 s).
Investigation showed two systemic patterns worth fixing in this pass:
-
accumulate_rateshad a per-call import:inside the function body. ~52 K calls/case × Python's import-system dict lookup adds up.from aeolus1d.components.species_coupling import ( iter_pipe_species, read_pipe_boundary_fuel, ) -
_pipe_port_fluxhad fivegetattr(pipe, "field", default)calls per invocation (scheme,use_per_cell_gamma,gamma_pad,has_real_gas,muscl_limiter_code). All five are realPipedataclass fields with the same defaults thegetattrcalls supplied — so thegetattrmachinery was pure overhead. The function also importedboundary_face_flux_muscl_var_gamma_jitinside the muscl-var-γ branch.
Fix
- Hoisted both per-call imports to module scope.
- Replaced
getattrwith direct attribute access on the five known-existing Pipe fields. - Cached
pipe.U_padonce in_pipe_port_flux(used 4× in the common first-order branch).
Result (back-to-back cProfile A/B, case 50)
| Function | PRE | HEAD avg | Δ |
|---|---|---|---|
accumulate_rates self |
0.390 s | 0.353 s | −10 % |
_pipe_port_flux self |
0.379 s | 0.350 s | −8 % |
| Total cProfile wall | 7.31 s | 7.23 s | −1 % |
Modest because the JIT kernel _packed_residual_fo_ser
still owns 13 % of wall (true compute) and the other junction
methods (_fill_plenum_ghost, _pipe_fuel_key,
apply_port_bcs) weren't touched in this pass — they're
candidates for a follow-up. The function-level deltas confirm
the hoist hit its targets cleanly.
Phase Perf-6r4 — junction follow-up (remaining wrappers)¶
Trigger: Perf-6r3 hoisted accumulate_rates +
_pipe_port_flux. The follow-up profile of case 50 showed
three more junction wrappers in the top-8 self-time list with
the same systemic problem — per-call imports inside the
function body:
_pipe_fuel_key(0.299 s self, 313 K calls) — importedpipe_fuel_keyper call._pipe_boundary_fuel— importedread_pipe_boundary_fuelper call.apply_port_bcs(0.286 s self) — importedwrite_pipe_inflow_fuelonce per port, theniter_pipe_species+write_pipe_inflow_fuelagain inside the per-port loop body.
Plus _fill_plenum_ghost was reading pipe.U_pad 3×
inside its inner write loop.
Fix
- Hoisted
pipe_fuel_keyandwrite_pipe_inflow_fuelto the module-level import block (iter_pipe_species/read_pipe_boundary_fuelwere already there from Perf-6r3). - Cached
self.fuel_species_nameandself.m_speciesas locals in theapply_port_bcsper-port loop so the per-iterationself.Xwalks collapse to single attribute reads at the function top. - Cached
Up = pipe.U_padonce in_fill_plenum_ghost.
Result (back-to-back cProfile A/B, case 50)
| Function | PRE-r4 | HEAD avg | Δ |
|---|---|---|---|
_pipe_fuel_key self |
0.299 s | (out of top 8) | eliminated |
apply_port_bcs self |
0.286 s | (out of top 8) | eliminated |
| Total cProfile wall | 7.38 s | 7.27 s | −1.5 % |
The per-call import cost was the dominant factor for both —
once hoisted, they fell off the hot list entirely. New
top-of-hot-list entries on case 50 are now cylinder
methods (accumulate_rates 0.246 s,
apply_port_bcs 0.223 s) which carry the same per-call
import pattern; candidates for Perf-6r5.
Phase Perf-6r5 — cylinder-side wrappers¶
Trigger: Post-Perf-6r4 profile of case 50 (2-cyl engine) showed the cylinder analogues of the junction wrappers as the new top-of-hot-list entries:
cylinder.accumulate_rates— 0.351 s selfcylinder.apply_port_bcs— 0.311 s self
Both carry the same per-call-import pattern Perf-6r3/r4 fixed in
junction.py: _pipe_fuel_key imports
aeolus1d.components.species_coupling.pipe_fuel_key per call,
_pipe_boundary_fuel imports read_pipe_boundary_fuel per
call, and apply_port_bcs imports write_pipe_inflow_fuel
inside the per-port loop (~52 K calls/case-50 each).
Fix
Hoisted all three imports to module scope (extending the import
block introduced in Perf-6r3 for junction.py).
Result (back-to-back cProfile A/B, case 50)
| Function | PRE | HEAD | Δ |
|---|---|---|---|
cylinder.accumulate_rates self |
0.351 s | 0.323 s | −8 % |
cylinder.apply_port_bcs self |
0.311 s | 0.292 s | −6 % |
(Run-to-run cProfile noise was higher than usual on this pass — host system load increased between measurements. Function-level self-time still tracks the import-hoist effect cleanly even when total wall jitters.)
The wins are smaller than Perf-6r4's because case 50 doesn't
carry a fuel species column, so the write_pipe_inflow_fuel
hot-path inside apply_port_bcs is gated off — only the two
key lookups (_pipe_fuel_key /
_pipe_boundary_fuel) actually fire per port-step. On a
fuel-tracking engine case (case 38, 39, or 52) the savings would
compound further.
Phase Perf-6r2-corrections-2 — profile-based regression harness¶
Trigger: The Perf-6r2 commit's wall-claim correction documented the lesson — wall-clock benches drift with system load and a "1.43×" claim that's really 1.04× burns trust. The fix is structural: install a regression gate that uses cProfile per-function self-time instead of wall time, so the gate measures the actual wrapper hotspot at the function level instead of the case at the wall level.
What landed
-
tests/benchmarks/_timer.pygained abench_profile()helper that runs a callable once under cProfile and returns a{"basename:line(name)": self_time_seconds}dict, plus anassert_profile_self_time_under()matcher with a list-of- substrings disambiguator (so["junction.py", "(accumulate_rates)"]pins the junction one, not the cylinder one). -
tests/benchmarks/test_profile_regression.py(new) — parametrised gates against case 41 (chemistry wrappers from Perf-6r2) and case 50 (junction wrappers from Perf-6r3/r4). Marker@pytest.mark.profile_regress(registered in pyproject.toml) so the gates only run onpytest -m profile_regressand don't slow the default suite. -
The existing
bench_regressmicro-bench gates stay as-is — they're nanosecond-scale @njit kernel benches where wall-clock median over many reps IS the right tool. cProfile would add 30-50 % overhead per kernel call and dwarf the measurement. The two gates partition the perf-regression surface cleanly: -
bench_regress— kernel-level, nanosecond-scale, wall-clock median over 100s of reps. Tolerance ~3×. profile_regress— full-case, function-level, cProfile self-time. Tolerance ~3× over the post-hoist baseline.
Lesson
The right perf metric depends on what's being measured. For a hot @njit kernel, wall-clock is the only thing that matters and median-of-many-reps filters noise. For a full-case Python wrapper hotspot, per-function self-time under cProfile is the right measure because it's normalised against wall by virtue of being a fraction of the cProfile-overhead-dominated profile call — same function's self-time stays within ~1 % across runs even when wall jitters by tens of percent under host CPU contention.
Phase Perf-6r6 — turbo-side wrapper hoist¶
Trigger: Closes the four-wrapper hoist arc started in Perf-6r3.
The pattern was systemic — every pipe-component wrapper that
called into species_coupling did so via a per-call import
inside the function body. After Perf-6r3/r4 fixed the junction
wrappers and Perf-6r5 fixed the cylinder wrappers, the only
remaining one was _pass_fuel_through_turbo in
aeolus1d.components.turbo, which Compressor and Turbine
apply_port_bcs both invoke.
Fix
Hoisted the same three imports (pipe_fuel_key,
read_pipe_boundary_fuel, write_pipe_inflow_fuel) to the
module-level import block, matching the junction.py and
cylinder.py patterns from r3/r4/r5.
Result
Hoist is invisible on case 50 (no turbo) and case 54 (no fuel tracking) — the function only fires when a turbo + fuel-tracking pipes coexist (cases 38, 39). For those cases the savings would match the cylinder wrapper deltas from Perf-6r5 (~6-8 % per function self-time). Mechanical change; no measurable wall regression on the existing canonical bench cases.
This commit closes the four-wrapper-arc:
- r3 — junction
_pipe_port_flux+accumulate_rates - r4 — junction
_pipe_fuel_key+_pipe_boundary_fuel apply_port_bcs+_fill_plenum_ghost- r5 — cylinder
_pipe_fuel_key+_pipe_boundary_fuel apply_port_bcs- r6 — turbo
_pass_fuel_through_turbo
The remaining engine-case hot list is now dominated by the JIT
kernels themselves (_packed_residual_fo_ser ~13 % of wall,
_assemble_jacobian_* for implicit cases) which are true
compute, not Python wrapper overhead.
Phase Perf-8 — getattr-dispatch removal (species_coupling + muscl boundary)¶
Trigger: Post-Perf-6r6 case-50 profile showed
{built-in method builtins.getattr} at 0.264 s self-time
across 1.49 M calls — explicit getattr(obj, "name", default)
dispatch was eating ~1.5× the cost of the JIT residual kernel
(true compute). Wrapper-hoist arc was closed; the next layer of
Python overhead is the explicit getattr dispatch in 5 hot
sites.
Hot sites identified (per-port-per-step call counts × cost):
species_coupling.pipe_fuel_key— 3 getattrs/call, 443 K calls = ~1.3 M getattrs.species_coupling.iter_pipe_species— 3 getattrs/call.junction._pipe_port_flux_muscl— 2 getattrs (muscl-only).
All target real Pipe dataclass fields (species_name /
species_names) or properties (has_species /
has_real_gas / muscl_limiter_code). The getattr(...,
default) calls supply the same defaults the dataclass already
has, so the dispatch was pure overhead.
Fix
Direct attribute access on every site: pipe.X instead of
getattr(pipe, "X", default). Same pattern as Perf-6r3's
_pipe_port_flux cleanup but extended to the species-coupling
helpers and the muscl boundary path.
Result (back-to-back cProfile A/B, case 50)
| Function / metric | PRE | HEAD avg | Δ |
|---|---|---|---|
pipe_fuel_key self |
0.236 s | 0.20 s | −15 % |
{builtins.getattr} self |
0.264 s | (out of top 12) | eliminated |
The 1.49 M getattr calls drop to ~120 K (the legitimate
duck-typed sites: the EHR port_u_rates lookup and the
apply_fuel_step Component-Protocol fishing). Total wall
moves only modestly (the JIT kernel _packed_residual_fo_ser
still dominates) but the function-level overhead reduction is
unambiguous.
The other getattr sites left intact:
network_timeloop._has_port_u_nand the per-portgetattr(p, "u_n", None)snapshot list-comprehension —Port.u_nis now a real Optional Pipe field, butCylinderPortdoesn't have it. The getattr is the right pattern for the heterogeneous Component port lists.Component.apply_fuel_stepgetattr inadvance_network_implicit— same story, only some Component implementations have this method.
Phase Perf-9 — investigate remaining getattr calls (no-op)¶
Trigger: Following the Perf-8 hoist, the
{built-in method builtins.getattr} self-time dropped from
0.264 s / 1.49 M calls to 0.031 s / 287 K calls — an 80 %
reduction. Phase Perf-9 set out to investigate whether the
remaining 287 K calls (the legitimate duck-typed sites in
_has_port_u_n, the per-port port.u_n snapshot list-comp,
and the Component.apply_fuel_step Protocol fishing) could be
replaced with isinstance dispatch for additional savings.
Investigation
The remaining 287 K calls cost 0.031 s self-time on case 50
(0.4 % of wall). Per call: ~0.1 µs — about as fast as getattr
can get. The candidate sites:
_has_port_u_n— 3 getattrs per junction per RK stage. Could short-circuit toisinstance(junction, EHRJunction), but EHRJunction lives inaeolus1d.components.junction_ehrwhich isn't imported byaeolus1d.solver.network_timeloop(and importing it module-level would create a circular-import surface to manage).port.u_nsnapshot — 1 getattr per port per stage. Same EHRPort isinstance check would apply, with the same circular-import cost.apply_fuel_stepProtocol fishing — 1 getattr per junction per step. A clean isinstance against the mixin/Protocol that definesapply_fuel_stepwould work, but this is exactly the kind of thing Python Protocols exist to avoid (the Component contract is structural, not nominal).
Decision: no change
Estimated savings: ~0.01–0.02 s wall on case 50 — within the profile-noise band. The code-complexity cost (module-level imports of EHRJunction / EHRPort, isinstance dispatches replacing duck-typed Protocol satisfaction) outweighs the perf benefit. Documented as a deliberate no-op so a future contributor doesn't re-investigate.
The Perf-8 → Perf-9 trajectory is the canonical "diminishing returns" pattern: the first wave of cleanup eliminates 80 % of the cost; the long tail of remaining cost isn't worth chasing. Profile-driven discipline says stop and move on; the next perf phase should target a different hot path (engine-case JIT kernels, or a different wrapper layer).
Phase Perf-history-3 — re-profile post-Perf-8 to find next-tier targets¶
Investigation only (no code changes). Re-ran scripts/profile_perf1.py
on the three canonical workloads after the wrapper-hoist + getattr
arc closed (Perf-6r2..r6 + Perf-8). Documents the new
top-of-hot-list so future contributors don't re-investigate the
same surfaces and so the next Perf phase has a clear target list.
Case 41 (chemistry / DPF) — 2.32 s cProfile wall:
| Function | Self | % wall |
|---|---|---|
_species_rate (already hoisted r2) |
0.229 s | 9.9 % |
regen_into (already hoisted r2) |
0.154 s | 6.6 % |
species_advection_extras_jit (JIT) |
0.119 s | 5.1 % |
pipe_residual_quasi1d_first_order |
0.117 s | 5.0 % |
Pipe.U property (249 K calls) |
0.082 s | 3.5 % |
cake_into (already hoisted r2) |
0.049 s | 2.1 % |
Pipe.n_extra_species (148 K calls) |
0.037 s | 1.6 % |
np.zeros allocations (100 K calls) |
0.028 s | 1.2 % |
Case 50 (engine network) — 7.05 s cProfile wall:
| Function | Self | % wall |
|---|---|---|
_packed_residual_fo_ser (JIT) |
0.975 s | 13.8 % |
junction.accumulate_rates |
0.362 s | 5.1 % |
junction._pipe_port_flux |
0.361 s | 5.1 % |
junction._fill_plenum_ghost |
0.314 s | 4.5 % |
junction._ghost_rho_E |
0.274 s | 3.9 % |
cylinder.accumulate_rates |
0.249 s | 3.5 % |
cylinder.apply_port_bcs |
0.222 s | 3.1 % |
junction.apply_port_bcs |
0.190 s | 2.7 % |
hllc.hllc_flux (JIT, 192 K calls) |
0.167 s | 2.4 % |
Pipe.has_species (992 K calls) |
0.162 s | 2.3 % |
species_coupling.pipe_fuel_key |
0.144 s | 2.0 % |
cylinder._kinematics |
0.142 s | 2.0 % |
Case 52 (knock event) — 1.09 s cProfile wall:
Smaller-scope case; _packed_residual_fo_ser (0.197 s, 18 %) +
advance_network (0.185 s, 17 %) own the wall. Cylinder
wrappers (accumulate_rates 0.064, apply_port_bcs 0.043)
combined ~10 %. Limited optimisation surface.
Future-work candidates (in rough priority order):
-
Phase Perf-10 —
Pipe.has_speciesproperty → cached field. 992 K calls on case 50 for a property that just evaluatesself.species_name != "". Currently nothing mutatesspecies_namepost-construction, so a_has_species: boolcomputed in__post_init__would eliminate the dispatch entirely. Realistic win: ~2 % wall on case 50. -
Phase Perf-11 —
Pipe.Uproperty dispatch reduction. 249 K calls on case 41 + 392 K on case 50. Each doesself.U_pad[Ng:Ng + N](a small numpy slice). Can be cached on Pipe (refreshed whenU_padis reassigned — currently only on__post_init__and the rare resize-then-reassign path). Realistic win: ~3 % wall on case 41, ~2 % on case 50. -
Phase Perf-12 —
np.zerosscratch-buffer pre-allocation inregen_into. 100 K allocations per case 41 for the Y_o2 / Y_no2 / Y_no / d_soot_dt / dT_cake_dt arrays. Pre- allocate onFilterState(lazy-initialised on first call, sized topipe.N). Realistic win: ~1 % wall. -
Phase Perf-13 —
junction._fill_plenum_ghostper-cell loop fusion. 191 K calls × 3 array writes each (with Ng=1, the loop runs once but does 3 writes). Could batch via a singleUp[Ng-1, :] = (rho, 0.0, rho_E)assignment. Realistic win: ~1-2 % wall on engine cases. -
Phase Perf-14 —
cylinder._kinematicsslider-crank recompute caching. 52 K calls / 0.142 s self on case 50; the slider-crank position formula is a few transcendentals. When called multiple times with the sameθ(during a single Newton stage's residual + Jacobian assembly) the result could be cached. Speculative — needs profile confirmation that the multi-call-per-θ pattern actually exists.
The unifying theme: the wrapper-hoist + getattr arc closed the "obvious" Python overhead. The remaining hot Python is property dispatch and small per-call allocations — the classic Python-on-top-of-numba death-of-a-thousand-cuts. Eliminating each ~1-2 % at a time is the natural next phase shape; aggregate win across Perf-10..14 would be ~5-10 % wall on engine cases.
The JIT kernels themselves
(_packed_residual_fo_ser, species_advection_extras_jit,
pipe_residual_quasi1d_first_order, hllc_flux) own
~25 % of case-50 wall and ~17 % of case-41 wall. Further
kernel-level wins (SIMD review, prange at engine scale) remain
on the BACKLOG but the prior Perf-7 SIMD HLLC rewrite was
reverted as a 16-33× regression — speculative kernel work
needs extreme bench-first discipline.
Phase Perf-10 — Pipe.has_species → cached field¶
Trigger: Phase Perf-history-3 profile of case 50 flagged
Pipe.has_species at 992 K calls / 0.152 s self / 2.3 % of
wall — a property that just evaluates self.Y.size > 0.
Top of the next-tier candidate list.
Fix
Replaced the property body with a single attribute read of a
new cached field _has_species: bool (declared on the
dataclass with init=False, default=False). The default
matches the dataclass default Y = zeros(0) (false). The
cache is refreshed in set_species — the only sanctioned
mutator that turns species transport on; no other code path in
the codebase reassigns self.Y.
Result (back-to-back cProfile A/B, case 50)
| Function | PRE | HEAD | Δ |
|---|---|---|---|
has_species self |
0.152 s | 0.095 s | −37 % |
pipe_fuel_key cum |
0.220 s | 0.183 s | −17 % |
The 37 % function-self-time drop is real but smaller than
naively expected (a cached attribute should be near-free). The
remaining 0.095 s is the property dispatch overhead itself —
Python still walks the descriptor protocol on every
pipe.has_species access. Going further would require
turning has_species into a bare attribute (not a property)
and removing set_species as the sanctioned mutator. Out of
scope for this phase; the property→cache change is the
right-shaped first step.
Test fixture follow-on
Two unit-test fixtures (test_egr_valve_controller.py and
test_fuel_injector_controller.py) bypassed set_species
and assigned pipe.Y directly. With the cache, this left
has_species stale at False even though Y was set.
Rewritten to go through set_species — the right pattern
anyway. Caught by the test suite, fixed in lockstep.
Phase Perf-11 — Pipe.U property → cached slice view¶
Trigger: Phase Perf-history-3 catalog item #2.
Pipe.U was a property re-computing
self.U_pad[Ng:Ng+N] on every call; 249 K calls / 0.086 s
self on case 41 + 392 K / 0.120 s on case 50. The slice
itself is a lightweight numpy view, but the create-view-on-each-
call cost adds up at hundreds of thousands of accesses per run.
Fix
Added _U_view: np.ndarray as a cached field on the Pipe
dataclass (init=False). Populated once in
__post_init__ after U_pad is allocated; the U
property reads self._U_view directly. The setter writes
self._U_view[:] = value (slice-write, propagates to
U_pad since the view shares the same buffer).
The cache stays valid because U_pad is never reassigned
post-__post_init__ — every downstream mutation goes
through U_pad[...] = ... slice writes that propagate
through the view automatically.
Result (back-to-back cProfile A/B, case 41)
| Metric | PRE | HEAD | Δ |
|---|---|---|---|
Pipe.U self-time |
0.086 s | 0.022 s | −74 % |
| Total cProfile wall | 2.402 s | 2.343 s | −2.5 % |
The −74 % is the bigger metric — slice creation is fully
eliminated; only the property-dispatch overhead (descriptor
walk) remains. Going further would require turning U
into a bare attribute (drop the property + setter) which
would break the existing pipe.U = value API the test
suite uses.
Phase Perf-12 — pre-allocated scratch buffers in regen_into¶
Trigger: Phase Perf-history-3 catalog item #3.
aeolus1d.closures.filter.regen_into allocated 5 ×
np.zeros(N) per call (Y_o2 / Y_no2 / Y_no / d_soot_dt /
dT_cake_dt), totalling 100 K allocations per case 41 run /
0.028 s self-time on the np.zeros calls.
Fix
Added 3 pre-allocated scratch buffers
(_scratch_Y_o2 / _scratch_Y_no2 / _scratch_Y_no)
to FilterState (init=False, lazily sized on first call
when size != N; subsequent calls reuse via slice-write).
Only 3 of the 5 zero arrays get pre-allocated:
d_soot_dtanddT_cake_dtare RETURNED to the caller for integration into the volume's soot mass + cake temperature across the timestep. Pre-allocating them onFilterStatewould require a copy-on-return, defeating the savings (the caller holds onto the array for the next RK stage).Y_o2/Y_no2/Y_noare scratch — read in, passed to the JIT kernel, never returned. Safe to reuse.
Result (back-to-back cProfile A/B, case 41)
| Metric | PRE | HEAD | Δ |
|---|---|---|---|
regen_into self |
0.159 s | 0.149 s | −6 % |
Modest because only 3 of the 5 zeros were eliminated; total
np.zeros self-time dropped from 0.028 s to ~0.011 s (the
remaining 60 K allocations are d_soot_dt + dT_cake_dt,
which can't be cached for the reasons above).
This closes the Perf-history-3 catalog's "easy wins". Items
13 (_fill_plenum_ghost per-cell loop fusion) and #14¶
(_kinematics result caching) need careful bench-first
investigation before claiming wins; deferred.
Phase Perf-13 — _fill_plenum_ghost loop fusion (REVERTED)¶
Trigger: Perf-history-3 catalog item #4. The
_fill_plenum_ghost Python wrapper was 191 K calls / 0.332 s
self / 4.5 % of case-50 wall, doing 3 scalar item-stores per
ghost row in an inner for g in range(Ng): loop.
Hypothesis: fuse the 3 scalar writes into a single slice
assignment Up[:Ng] = (rho, 0.0, rho_E) — broadcasts the
tuple to the (Ng, 3) slice. For Ng=1 (the common case) this
replaces 3 scalar item-stores with one row-write.
Result: REGRESSION
A/B back-to-back on case 50:
| Metric | PRE | HEAD | Δ |
|---|---|---|---|
_fill_plenum_ghost self |
0.332 s | 0.363 s | +9 % |
The slice-assign + broadcast machinery costs more than 3 scalar item-stores at the tiny (1, 3) shape numpy operates on here. Numpy's broadcast / typed-store path has fixed Python- side overhead per call that's larger than 3 raw scalar writes.
Reverted in the same commit; the original loop-of-3-scalar- stores stays. Documented inline so a future contributor doesn't re-attempt the same naive fusion.
Lesson reinforced: profile-driven optimisation cuts both
ways — the catalog item flagged this as a candidate, but
bench-first confirmed the "obvious" fusion is wrong at this
scale. The Perf-7 SIMD HLLC rewrite (16-33× regression) was
the canonical lesson; this is a smaller-scale repeat. The
profile_regress gate would have caught this if the case-50
_fill_plenum_ghost ceiling weren't 3 × the post-Perf-6r4
baseline (loose enough to absorb a 9 % overshoot). Tightening
the ceiling to e.g. 1.5 × would catch this kind of regression
automatically.
Phase Perf-history-4 — re-profile post-Perf-10/11/12¶
Investigation only (no code changes). Re-ran scripts/profile_perf1.py
on cases 41 + 50 after Perf-10 (has_species cache),
Perf-11 (Pipe.U cached slice), and Perf-12 (regen_into
scratch buffers) landed. Confirms the previous wins are
sticky and identifies the new top-of-hot-list for Perf-15+.
Case 41 (chemistry/DPF) — 2.32 s wall:
Post-Perf-10/11/12, the chemistry hot list is dominated by:
| Function | Self | % wall | vs Perf-history-3 |
|---|---|---|---|
_species_rate |
0.240 s | 10.4 % | +0.011 (~stable) |
regen_into |
0.151 s | 6.5 % | −0.003 (Perf-12) |
species_advection_extras_jit (JIT) |
0.120 s | 5.2 % | ~stable |
pipe_residual_quasi1d_first_order (JIT) |
0.121 s | 5.2 % | ~stable |
cake_into |
0.052 s | 2.2 % | ~stable |
Pipe.U property |
0.023 s | 1.0 % | −0.059 (−72 %) ✓ |
Pipe.n_extra_species |
0.040 s | 1.7 % | ~stable |
Phase Perf-11's win is sticky and well above noise. The
remaining hot Python is now mostly the wrappers themselves
(_species_rate, regen_into) — further Python-level
wins require restructuring the wrappers, not just hoisting
attributes.
Case 50 (engine network) — 6.87 s wall:
| Function | Self | % wall | vs Perf-history-3 |
|---|---|---|---|
_packed_residual_fo_ser (JIT) |
0.974 s | 14.2 % | ~stable |
junction.accumulate_rates |
0.357 s | 5.2 % | ~stable |
junction._pipe_port_flux |
0.362 s | 5.3 % | ~stable |
junction._fill_plenum_ghost |
0.309 s | 4.5 % | ~stable |
junction._ghost_rho_E |
0.271 s | 3.9 % | ~stable |
cylinder.accumulate_rates |
0.247 s | 3.6 % | ~stable |
cylinder.apply_port_bcs |
0.218 s | 3.2 % | ~stable |
junction.apply_port_bcs |
0.187 s | 2.7 % | ~stable |
hllc_flux (JIT) |
0.169 s | 2.5 % | ~stable |
species_coupling.pipe_fuel_key |
0.142 s | 2.1 % | ~stable |
cylinder._kinematics |
0.138 s | 2.0 % | ~stable |
bc.base.wall_bc |
0.113 s | 1.6 % | NEW IN TOP-15 |
cylinder._port_area_fraction |
0.110 s | 1.6 % | NEW IN TOP-15 |
Volume.p property |
0.105 s | 1.5 % | NEW (296 K calls) |
Pipe.has_species cache |
0.096 s | 1.4 % | −0.056 (−37 %) ✓ |
The Perf-10 cache win is sticky. New candidates surfaced as the property-dispatch ceiling lifts:
Future-work candidates (Perf-15+):
- Phase Perf-15 —
Volume.pproperty → cached field. Same pattern as Perf-10/11. 296 K calls / 0.105 s self on case 50. Volume.p is computed from m and E via gamma; cache when m / E_internal change (which happens once per step inapply_fuel_step/ Volume update). Realistic ~1.5 % wall. - Phase Perf-16 —
cylinder._port_area_fractionbinary-search → linear-scan tweak. 104 K calls / 0.110 s self. Currently usesnp.interpon a CAD-domain lift schedule; for typical ~7-point schedules a hand-coded linear scan can be faster. Speculative — needs bench-first. - Phase Perf-17 —
bc.base.wall_bcper-port hoist. 69 K calls / 0.113 s self. Same hoist pattern as the Perf- 6r3..r6 arc but for BC callables. ~1 % wall. - Phase Perf-18 —
cylinder._kinematicsresult caching across Newton residual + Jacobian. Perf-history-3 item #5. Speculative — needs to verify the multi-call-per-θ pattern actually exists.
Aggregate Perf-15..18 win estimated at ~3-5 % case-50 wall. Diminishing returns — every subsequent Perf phase yields a smaller relative win as the Python overhead surface shrinks.
Lesson recorded — Perf-13 revert:
Perf-13 attempted to fuse _fill_plenum_ghost's 3 scalar
item-stores into a single slice-assign and regressed +9 %.
The slice-assign + broadcast machinery costs more than 3
scalar writes at the (1, 3) target shape. This is the second
fusion-style optimisation reverted under bench-first
discipline (after Perf-7 SIMD HLLC, which regressed 16-33×).
Documented inline in junction.py so a future contributor
doesn't re-attempt the same naive fusion.
Phase Perf-15 — Volume.p cached field (deliberate no-op)¶
Trigger: Perf-history-4 catalog item #1.
Volume.p was 296 K calls / 0.105 s self / 1.5 % of case-50
wall — a property that's structurally identical to
Pipe.has_species (Perf-10) and Pipe.U (Perf-11) and
naïvely the same caching pattern should apply.
Why it doesn't: the Volume class differs from the
Pipe class in one critical way for caching:
Pipe.Uis a slice intoPipe.U_pad— a single mutable buffer that's never reassigned post-init. The cache stays in sync automatically becauseU_pad[...] = ...slice writes propagate through the view.Pipe.has_speciesflips on exactly once (inset_species) and never flips off — the cache has a single sanctioned mutator.Volume.pis COMPUTED fromE_internal/m/V/gamma, withE_internalandmmutated by ~8 external call sites (each RK stage in network_timeloop + implicit_network + 3 in helmholtz_resonator).
A cache for p would need explicit invalidation at every
E_internal = ... / m = ... site OR turning those into
properties with cache-invalidating setters. The first is
fragile (any new mutator forgetting the call silently
desyncs); the second adds property-dispatch overhead to the
ALREADY-hot E_internal / m reads inside p itself
(net dispatches per step likely INCREASES, not decreases).
Decision: no change.
The 1.5 % wall savings is too small to justify the
maintenance burden — every future code path that mutates
Volume.E_internal or Volume.m would need to remember
to invalidate, and the bug pattern (cache desync produces
silently wrong pressures) is exactly the kind of correctness
hazard the project explicitly avoids. Documented as a
deliberate no-op so a future contributor doesn't
re-investigate.
The right shape for a Volume.p win would be a hook the
timeloop calls once per step (after the RK combine sets the
final E_internal) to refresh the cache. That requires
threading a "dirty" flag through 8+ sites with a single
sanctioned update point — feasible but scope-creeps into a
Volume-state-management rewrite. Deferred indefinitely;
revisit only if profile shows Volume.p climbing past
~5 % of wall.
Pattern reaffirmed: cache invalidation is the hardest problem in computer science; Perf-10/11 worked because their shared-buffer / single-mutator structure made the invalidation trivial. Perf-15's structure isn't trivial and the win isn't big enough to pay for the complexity.
Phase Perf-17 — bc.base.wall_bc per-port hoist¶
Trigger: Perf-history-4 catalog item #3.
bc.base.wall_bc was 70 K calls / 0.114 s self / 1.6 % of
case-50 wall, doing 4 pipe.U_pad attribute walks per ghost
row (twice on the LHS of the indexed assignment, once on the
RHS, once on the momentum flip).
Fix
Hoisted Up = pipe.U_pad once at the top of wall_bc;
the inner loop body uses Up[...] instead of
pipe.U_pad[...]. Same shape as the Perf-6r3..r6 wrapper-
hoist arc.
Result (back-to-back cProfile A/B, case 50)
| Metric | PRE | HEAD | Δ |
|---|---|---|---|
wall_bc self |
0.114 s | 0.112 s | −2 % |
Marginal — within noise. With Ng=1 (the common case) the inner loop body runs once with ~3 attribute walks saved per call; total saving ~0.02 s at 70 K calls × 0.3 µs/walk. The cProfile delta is at the edge of what cProfile resolves at this scale.
Worth keeping anyway for code-cleanliness reasons (matches the Perf-6r3..r6 pattern that the rest of the codebase uses). Bench-first discipline kept this in scope (the Perf-13 fusion attempt taught the lesson — "obvious" small-shape optimisations can regress). Hoisting an attribute reference is strictly cheaper than the original at the assembly level; no risk.
Phase Perf-7 — packed-kernel parallel-vs-serial dispatcher¶
Commit: bfd3146. Trigger: Phase Perf-history-2
profile said engine cases (50 = 6 fast pipes, 52 = 2 fast
pipes) were 99 % JIT-kernel-bound. The candidate
optimisations from BACKLOG were (a) SIMD/scalarisation rewrite
of the HLLC inner loop, (b) prange over pipes at engine-
network scales. (b) was already in place — the packed kernels
were @njit(parallel=True). The actual question turned out
to be the inverse: is the existing parallel kernel even
worth it at engine scales?
Investigation
Bench at varying NUMBA_NUM_THREADS (audit laptop: 16 cores,
default = 16 threads):
| Case (P) | threads=1 | threads=2 | threads=8 | threads=16 |
|---|---|---|---|---|
| 50 (P=6) | 7.0 s | 5.95 s | 6.26 s | 15.5 s |
| 52 (P=2) | 0.91 s | 0.89 s | 1.05 s | 1.85 s |
The default 16-thread setup was the worst case for typical aeolus1d network sizes. At P=2 or P=6, splitting the work over 16 threads means 10–14 idle threads paying coordination costs on every kernel call (and the kernels are called once per RK stage, thousands of times per simulated second).
Tried-and-rejected: SIMD/scalar rewrite
Wrote hllc_flux_scalar(UL0, UL1, UL2, UR0, UR1, UR2, gamma)
returning (F0, F1, F2) floats, plus max_wave_speed_scalar,
both inline="always". Refactored _packed_residual_fo and
_packed_min_dt to use them — eliminates the per-cell
np.empty(3) allocations.
- Numerical regression: 1 element of 90 in
test_packed_residual_matches_sequentialdrifted by abs 9.96e-8 (rel 2.8 %). Fastmath FMA reordering — the original kernel usedfor k in range(3): out[k] = F[k] + SL*(...)which the parallel pass vectorised acrossk; the scalar rewrite unrolled it as three separate FMAs which the compiler ordered differently. - Wall-clock regression: case 50 jumped from ~7 s to 131 s (16 × slower) and case 52 from ~1 s to 43 s (33 × slower). Suspect: the inlined scalar HLLC body multiplies the prange parallel-loop body size dramatically (control flow + branches), and at engine-scale pipe counts the parallelisation pass either degrades to a worse code shape or interacts badly with thread-local cache pressure.
Reverted entirely. The scalarisation idea is sound but needs a
no-parallel=True baseline (Phase Perf-7 default).
What landed
Two new @njit kernels in
aeolus1d/solver/packed_pipes.py:
_packed_residual_fo_ser— serial twin of_packed_residual_fo_par(same body, plainrange(P), noparallel=True)._packed_min_dt_ser— serial twin of_packed_min_dt_par.
Plus a Python dispatcher that picks parallel vs serial by fast-pipe count:
_PARALLEL_THRESHOLD_P = 12 # default
def _packed_residual_fo(U_pad_all, ...):
if U_pad_all.shape[0] >= _PARALLEL_THRESHOLD_P:
_packed_residual_fo_par(...)
else:
_packed_residual_fo_ser(...)
Override knob: set_parallel_threshold(P) — set to 0 to
always use parallel (legacy pre-Perf-7 behaviour) or to a
very large number to always use serial.
The threshold of 12 was picked from a synthetic many-pipe microbench: parallel cleanly wins at P ≥ 12 with N=80; at P < 12 the serial variant is consistently within 5 % at best and 16 × better at worst.
Wall-clock
| Workload (P) | Baseline (post-Perf-6) | Phase Perf-7 | Speedup |
|---|---|---|---|
| 50 (engine, P=6) | 16.78 s | 6.24 s | 2.69 × |
| 52 (knock, P=2) | 2.07 s | 0.95 s | 2.18 × |
| 54 (turbo, P=4) | 12.14 s | 12.28 s | unchanged† |
† Case 54 uses advance_network_implicit, not the packed-
kernel path in advance_network, so Perf-7 doesn't touch its
hot path. The variance reading is system noise.
Tests
test_packed_dispatcher_serial_and_parallel_agree— pins numerical equivalence (rtol=1e-13 — fastmath FMA reordering between parallel and serial kernels is below that threshold) across P ∈ {1, 4, 12, 16}.test_packed_dispatcher_threshold_picks_kernel— pins the dispatcher routesP < thresholdto the serial kernel andP >= thresholdto the parallel kernel.- All 7 existing packed-pipes regression tests still pass.
Why not just clamp numba.set_num_threads?
set_num_threads is global and persistent — would affect any
other parallel numba code in the same process (third-party,
GUI plotting, future kernels). The dispatcher approach is
local to packed_pipes and composable.
Phase I-8 (MVP) — opt-in Jacobian-Free Newton-Krylov¶
Commits: 8de3ac3 (MVP) + [after] (block-Jacobi
preconditioner). Trigger: the direct block-tridiag-Thomas
Newton solve in _newton_step needs an explicit (N, 3, 3)
Jacobian per iter. For users who push to networks where the
direct Jacobian doesn't fit, JFNK is the algorithmic alternative.
What landed
_jfnk_step(pipe, U_hist, dt_eff, F, apply_ghosts, config, scheme, slopes_pad, precond_D_J=None) -> δsolvesJ · δ = -Fvia GMRES (scipy.sparse.linalg.gmres) against aLinearOperatorwhosematvecdoes the FD J·v product (Brown-Saad ε scaling).- Right-side block-Jacobi preconditioner (Phase I-8-precond):
when
jfnk_precondition = True(default on when JFNK is on), the outer Newton loop still builds the chord-agedD_J(the same_assemble_jacobian_first_ordercall the direct path makes) and JFNK uses it via per-cell 3×3 inverse products (_block_jacobi_apply_jit). Without preconditioning GMRES typically saturatesjfnk_max_iterson stiff cases; with it, Krylov dim shrinks dramatically. NewtonConfigknobs:use_jfnk(False),jfnk_precondition(True),jfnk_tol(1e-3 GMRES rtol),jfnk_max_iters(50 Krylov-dim cap). All four surface throughCaseHeaderfor TOML.
Tests
test_default_use_jfnk_is_off— pinning the opt-in contract.test_jfnk_path_runs_and_state_is_finite— short transient finiteness.test_jfnk_matches_direct_on_short_transient— JFNK vs direct converge to engineering-equivalent state at rtol=1e-3.test_jfnk_unpreconditioned_skips_jacobian_assembly— patched-assembly contract: withjfnk_precondition=False, any call into_assemble_jacobian_first_ordermakes the test fail. Pins the pure-matrix-free path for memory-bound users.test_jfnk_preconditioned_uses_jacobian_diag_only— smoke test that the preconditioned path runs and state is physical.
Wall-clock
JFNK is slower than the direct block-tridiag-Thomas path on the canonical case 54 — at small N × 3 unknowns the direct solve is the right cost shape. The MVP exists for users hitting memory walls; default stays off. Future follow-ups: coarse-grid preconditioner, MUSCL + variable-area branches, restart-GMRES tuning. Documented in BACKLOG.
Long-pipe bench (Phase Perf-history follow-up)¶
Script: scripts/bench_long_pipe.py. Trigger: the
audit converged on N = 80 cells/pipe, but several rejected
options had break-even points at larger N that we couldn't
measure with the bundled examples. The script builds a
programmatic single-pipe case at user-controllable N + t_end
and times three Newton-solve paths:
- direct (chord) — block-tridiag-Thomas with chord-Jacobian reuse (the default).
- jfnk_precond — preconditioned matrix-free
(
use_jfnk=True,jfnk_precondition=True). - jfnk_pure — unpreconditioned matrix-free
(
use_jfnk=True,jfnk_precondition=False).
Measurements
| N | direct | jfnk_pure | jfnk_block_jacobi | jfnk_ilu0 |
|---|---|---|---|---|
| 80 | 0.010 s | 0.027 s (2.6 ×) | 3.24 s (312 ×) | 0.023 s (2.2 ×) |
| 500 | 0.227 s | 0.423 s (1.9 ×) | 112.6 s (495 ×) | 0.398 s (1.8 ×) |
Findings
- jfnk_pure is consistently ~2 × slower than direct, roughly N-independent. That's a reasonable competitive position for a memory-saving fallback — users who hit memory walls can opt in and pay 2 × on wall clock, no Jacobian assembly.
- jfnk_ilu0 is the cleanest matrix-free path: ~1.8 × slower
than direct at all N, slightly better than pure JFNK. The
preconditioner reuses
_block_thomas_solveon the chord- aged Jacobian — for block-tridiag J the ILU(0) factorisation is the block-Thomas factorisation. GMRES typically converges in 1–2 Krylov iters; the residual cost is what separates JFNK from direct, not the linear solve. - jfnk_block_jacobi is dramatically slower at every N — and gets worse with N (312 × at N = 80, 495 × at N = 500). Direct GMRES iter count goes from 4 to 42 when block-Jacobi is enabled (10 × the matvec count), confirming that the preconditioner is the wrong shape for the system.
Why block-Jacobi is wrong here
The implicit Newton system is J = I − dt · ∂R/∂U on a 1D
hyperbolic problem. ∂R/∂U has block-tridiagonal structure
where the off-diagonal blocks (L and Up) carry the
acoustic + convective coupling — for cfl > 1 (the whole point
of the implicit driver) those off-diagonal blocks are a
significant fraction of J's spectral content. Block-Jacobi
discards them entirely; the preconditioned operator has
worse conditioning than the unpreconditioned one because
the diagonal blocks alone don't span the dominant eigenmodes.
This is a known result in the literature for 1D hyperbolic systems (see e.g. Saad's "Iterative methods for sparse linear systems", §10 — preconditioning for non-symmetric systems where directional bias matters). The empirical bench just confirms it on this solver.
Decision
- Default
jfnk_precondition = False(was True in the MVP — flipped after this bench landed). - Keep the implementation in (it's opt-in; users who want to experiment with block-Jacobi or who have a less-hyperbolic workload — e.g. heavily porous-Forchheimer — can flip it on).
- Future preconditioner work needs a directionally-aware approximation: line preconditioning, ILU(0) on the actual block-tridiag, or coarse-grid via aggregation. Documented in BACKLOG.
Phase 27c soak test¶
Commit: [after]. Trigger: the Phase 27c MVP test
runs only a 2-pipe + plenum case for 2 ms; not enough to
expose race conditions or measure realistic wall-clock under
load.
What landed
tests/integration/test_parallel_pipes_soak.py adds two
@pytest.mark.slow tests:
test_parallel_pipes_soak_case_54— loadexamples/54_adaptive_picard_turbo.tomlend-to-end (4 pipes, 2 turbos, adaptive Picard, full BC stack), run twice (serial + parallel), assert state bit-equivalence tortol=1e-9, print both wall-clock numbers.test_parallel_pipes_soak_short_repeats— run the parallel path 5× on a 2 ms transient and assert every repeat produces bit-identical state. Catches thread-scheduling-dependent non-determinism.
Soak measurement (full case 54 t_end = 50 ms)
- serial wall: 7.2 s
- parallel wall: 18.8 s
- ratio: 2.6× slower on parallel
Stronger than the 2× the short cProfile run showed. Confirms
parallel_pipes = False is the right default; the path stays
for genuinely large networks where per-pipe JIT work clearly
outweighs thread coordination overhead. Bit-equivalence and
repeat-determinism both pass — no race conditions detected.
Canonical-green baseline + xdist contention¶
After all perf phases landed, ran make test-all to confirm
the post-Perf state is clean. Result: 1714 passed, 50 failed,
4 skipped, 1 xfailed in 99 minutes under the default
-n auto (8 workers on the audit-laptop). The 50 "failures"
turned out to be pytest-xdist contention flakes, not
regressions — every spot-checked failure passes when run
alone (3 sampled tests across 3 different cluster files all
green in 50 s combined, no code changes needed).
Root cause is most likely numba's AOT JIT cache: when 8 workers compile the same kernel concurrently, the cache file locking can race and emit corrupted/stale code that fails silently on whoever loses the race. The 3 % flake rate is roughly proportional to the JIT-heavy fraction of the suite × 8-way contention.
Mitigation — make test-stable (new target, commit
[after]) runs pytest -n 4 --timeout=600 instead of
-n auto. Half the workers, contention drops below the
observable threshold; serves as the canonical-green
confirmation target. make test-all stays as the historical
-n auto target for CI environments where contention is
acceptable.
The codebase is clean post-Perf. No real regressions introduced across the 11-phase optimization pass + the 50+ test files this work touched.
Things that were tried and rejected (or never attempted)¶
The audit is deliberate about what NOT to chase:
prangeover cells in the Jacobian kernel — see Phase 27c-cells above. Slower than serial at N = 80.- Defaulting
parallel_pipes = True— see Phase Perf-4. Serial post-Perf-4 is faster than parallel on the canonical benchmarks. Stays opt-in for genuinely large networks. - JIT'ifying
_capture_ghost_snapshot/_ghost_snapshot_max_rel_change— Phase Perf-5 audit. Together they're 1.7 % of case 54 wall; not worth the diff-test relaxation cost. - Memoizing the bilinear cell index (audit item #10) —
Phase Perf-1.
_bilinearisinline="always"; the index computation is ~10 float ops fully inlined. Memoization would add cache-line traffic to skip ~5 ns of compute. Likely net-negative. - CFL
min_dtper RK stage (audit item #9) — verified the audit was wrong;_packed_min_dtruns once per step, not per stage. - BC FD copy elimination (audit item #6) — paths are
@njit; numba handles the copy efficiently. The Python-side.copy()in_ghost_state_and_chainis BC_NUMERICAL fallback, rarely hit.
Tools that made this possible¶
scripts/profile_perf1.py— pre-warms numba caches, then runs the case under cProfile and dumps top entries by cumulative and self-time. Without this, every "is this faster?" question would need a separate one-shot script.scripts/bench_perf1_tabulated.py— programmatic tabulated-EOS bench for cases the TOML schema can't construct (was needed before Phase Perf-1-toml; kept around for users who want to A/B without CoolProp).- Bit-exact regression tests for each EOS arm and Jacobian
path. Several optimizations would have shipped with subtle
numerical drift if not for the tight assertions; the few
that we relaxed (to
assert_allclose) were reasoned about explicitly and limited to fastmath FMA reordering. @pytest.mark.slowbenches (e.g. the closure-Jacobian analytic-vs-FD bench from Phase B19) — these were the initial sanity check that the Perf-1 batched helpers didn't regress numerical accuracy. Should be re-run after every major refactor.
What the audit didn't move¶
The flow-of-control side of the implicit driver
(advance_network_implicit, _newton_step outer body) is
already efficient post-Perf-2. Further wins on the canonical
4-pipe network case would require algorithmic change:
- Phase I-8 matrix-free JFNK (
BACKLOG.md) — replaces the direct block-tridiag solve with a Krylov method that only needs J·v products. Worth exploring only if a bench case shows the direct Jacobian assembly running out of memory or time on a real network. prangeover pipes inside the Picard iter — Phase 27c with all-or-nothing dt-halving has landed as opt-in. The parallel path is currently slower than serial on the canonical 4-pipe case; revisit when a network has ≫ 4 pipes with cost-balanced per-pipe work.prangeover cells in the Jacobian — Phase 27c-cells. Replay when N ≫ 200–500 cells per pipe.
v0.3 aftertreatment time-stepping spike (2026-05-20)¶
The first task of the GT-POWER-parity roadmap's v0.3 keystone (see
BACKLOG.md → "GT-POWER parity roadmap"). The question: for an
explicit acoustic-CFL aftertreatment run, is the wall-clock dominated
by the step count (→ the low-Mach / quasi-steady mode is the lever)
or the per-step cost (→ JIT / kernel work, or a compiled core, is
the lever)? Tool: scripts/profile_at_spike.py on 41_dpf_regen
(DPF regen + cake thermal + chemistry — the bundled analogue of AVL
Cruise-M C02006). Laptop run, numba caches pre-warmed.
Step economics (case 41, t_end = 0.06 s)¶
| metric | value |
|---|---|
| steps | 10 049 |
| mean Δt | 5.97 × 10⁻⁶ s (acoustic-CFL bound) |
| wall / step | 0.199 ms |
| profiled wall | 1.996 s |
Self-time by solver phase¶
| phase | self-time | share |
|---|---|---|
| orchestration (Python loop) | 1.228 s | 61.5 % |
chemistry + closures (Python: filter.regen_into / cake_into) |
0.247 s | 12.4 % |
| numpy / builtins | 0.150 s | 7.5 % |
| flux kernels (JIT) | 0.117 s | 5.9 % |
| species advection (JIT) | 0.106 s | 5.3 % |
property accessors (pipe.*) |
0.073 s | 3.6 % |
| boundary conditions | 0.047 s | 2.3 % |
| numba dispatch | 0.026 s | 1.3 % |
500 s drive-cycle extrapolation (current per-step cost)¶
| mode | Δt | steps | wall |
|---|---|---|---|
| acoustic-CFL (today) | 5.97 µs | 8.37 × 10⁷ | 4.62 h |
| low-Mach (Δt = 1 ms) | 1 ms | 5.0 × 10⁵ | 1.66 min (≈ 167× fewer steps) |
Conclusions (these set the v0.3 ordering)¶
- Step count is the existential factor, not per-step cost. The 4.6 h drive cycle is entirely an acoustic-CFL step-count problem. The low-Mach / quasi-steady AT mode is unambiguously the #1 performance lever — even a conservative 100 µs low-Mach step is ~17× (4.6 h → 16 min); a 1 ms step is ~167×. The HRR adaptive-Δt limiter then sets the achievable low-Mach step at ignition.
- Per-step cost is ~74 % Python, only ~11 % JIT kernels.
Orchestration (61.5 %) + Python chemistry closures (12.4 %)
dominate; the flux + species-advection JIT kernels are already
lean. This positively validates the roadmap's "defer the compiled
(Rust/PyO3) core" call — the kernels are not the bottleneck. The
secondary lever for the remaining low-Mach steps is JIT-ifying
the orchestrator (Phase Perf-2, already backlogged) and the
closures/filter.pychemistry closures (regen_into/cake_intoare pure-Python). - Free hot-loop win:
pipe.n_extra_speciesis recomputed 148 495× andpipe.U249 626× over the run — recomputed properties inside the step loop. Hoisting both is a zero-risk micro-win (~0.1 s / 5 % here, larger on longer runs).
Re-run after the low-Mach mode lands to confirm the step-count collapse and re-profile the new per-step mix.
Quasi-steady SIMPLE kernel — numba JIT (2026-05-21)¶
The pure-NumPy solver/quasi_steady.py SIMPLE solve was ~3.3 ms/solve
(profiled on a 50-cell C02006-like pipe, ~34 SIMPLE iterations); the
scalar Thomas tridiagonal solve alone was ~48 % of that. A drive-cycle
run (hundreds of thousands of macro steps × one solve each) was
impractical.
_simple_solve_ideal_jit is a faithful @njit port of the ideal-gas
SIMPLE loop (Thomas inlined; Rhie-Chow + pressure-correction unchanged).
The non-ideal-EOS path stays in the NumPy loop (needs a per-cell density
inversion). Result: ~3.3 ms → 0.044 ms/solve, ~75×. Physics-identical
— all 21 quasi-steady tests pass against the analytic Darcy solution +
the network mass balance.
AVL C02006 regeneration — end-to-end (accelerated-ramp validation)¶
With the JIT kernel + the transient-BC / gas-energy-advection work
(feat/qs-transient-bc) + a gas-temperature floor (t_floor, guards the
explicit energy update against a regen-ignition negative-T excursion),
the C02006 DPF-CSF regeneration runs to completion and the regen
fires: with the inlet ramp accelerated to 700 K by t = 2 s (same
physics, short run), soot drains 10 → 0 kg/m³ (100 % burn-down), the
run is stable (gas-T floored at 250 K, peaks ~1900 K at ignition), where
the density-based explicit solver diverges at t ≈ 116 s.
Remaining perf wall (BACKLOG follow-up): the explicit energy
advection + T-floor leave a noisy 250–1900 K field at ignition that keeps
the HRR-controlled Δt tiny (~620 k steps for 15 s), so the full 600 s
cycle is still impractical. The principled fix is the steady gas-phase
energy solve (GT's quasi-steady catalyst gas phase) instead of explicit
enthalpy advection — that removes the advective-CFL on energy and the
ignition noise. The other per-step cost is now the Python/NumPy
orchestration (_species_rate wrapper, energy advection, regen,
ghost-fill) — the QS solve is no longer the bottleneck.
Phase Perf-7r — turbo map bilinear lookup (2026-05-23)¶
Trigger: Profile of canonical case 54 (54_adaptive_picard_turbo)
identified TurboMap._lookup_2d cumulative time at 4.80 s out of
29.79 s total cProfile wall (~16 %), driven by 497 000
np.interp calls — the implementation did one 1-D np.interp per
speed line along PR (~14 calls), then a second 1-D interp along
N_corr (1 call), and another pass for η_is. That's ~22 numpy calls
per _lookup_2d invocation when a single bilinear interp would do.
Change. Replaced the nested np.interp pattern in both
_lookup_2d and _lookup_slice (the 2-D and VGT 3-D paths) with a
shared _bilinear_2d helper: two np.searchsorted calls (PR + N_corr
bracket indices), four corner reads, and a weighted blend.
Algebraically identical to the nested 1-D interps — two nested 1-D
linear interps are bilinear; this just folds the arithmetic so
Python only takes two function-call hits instead of 22.
Result (cProfile, case 54, default t_end):
| PRE-Perf-7r | HEAD | Δ | |
|---|---|---|---|
TurboMap._lookup_2d cumulative |
4.80 s | 0.83 s | −83 % |
Total np.interp calls (case 54) |
497 000 | 0 | −100 % |
| Case 54 cProfile wall | 29.79 s | 10.64 s | −64 % |
| Case 54 wall-clock (no cProfile, t_end=5 ms) | — | 4.79 s | — |
Speedup: 2.80× under cProfile (Python-overhead-amplified) on case 54. Behaviour is bit-equivalent modulo FP reordering — all 114 existing turbo tests pass without tolerance adjustment.
Top hotspots remaining on case 54: _assemble_jacobian_fo_ideal_uniform_jit
(3.20 s, already njit — at compute floor) and _newton_step orchestration
(0.99 s self). No remaining Python-overhead targets at this scale.
AVL drive cycle (MFR_transition_SC630) profiling — 2026-05-29¶
Goal: can the AVL quasi-steady aftertreatment case run the full 500 s under 10 min? Verdict: no — not via configuration or micro-optimization.
Baseline (t_end = 1.0 s, chemistry firing): 115 s wall, ~1835 macro steps, ~115× realtime → the full 500 s extrapolates to ~16 h. Hitting 10 min (600 s wall) for 500 s sim needs ~0.83× realtime — a ~96× speedup.
Bottleneck (cProfile): chemistry is ~88% of wall time. The
quasi-steady surface-chem sub-step is dominated by scipy LSODA's
per-substep Python loop (lsoda._step_impl, ~64 k substeps over a 0.3 s
window) plus the Brinkmeier reaction Python wrapper; the @njit
_brinkmeier_rates_jit kernel itself is a small fraction. Transport /
Picard / steady-energy are minor.
Measured attempts (t_end = 0.3 s window, 17.1 s baseline):
| lever | wall | macro steps | gas T_max | note |
|---|---|---|---|---|
baseline (qs_cfl_adv=2) |
17.1 s | 498 | 4307 K | — |
qs_chem_every_n=4 |
12820 s | 2.25 M | 1161 K | catastrophic — lagged HRR explodes the dt controller during light-off |
chem_rtol=1e-4 |
17.1 s | 498 | 4307 K | no effect (not tolerance-bound here) |
qs_cfl_adv=4 |
8.8 s | 236 | 4299 K | 1.94×, physics preserved |
qs_cfl_adv=8 |
4.4 s | 98 | 4064 K | 3.9×, ~6% T_max change (best reliable) |
qs_cfl_adv=12 |
9.7 s | 557 | 1160 K | unstable — coverage collapses |
qs_cfl_adv=16 |
5.1 s | 74 | 3606 K | runs, but accuracy degrades |
chem_parallel=true |
16.6 s | 498 | 4307 K | marginal |
Brinkmeier reaction float-hoist (code) |
17.1 s | 498 | 4307 K | numerically identical; no measurable wall gain (scipy LSODA's Python loop, not the casts, is the cost) |
Conclusion: the only clean lever is qs_cfl_adv (fewer macro steps →
fewer chem calls); 8 is the sweet spot (~3.9×, 16 h → ~4 h). That is
~29× realtime — still far from the ~96× the 10-min target needs.
qs_chem_every_n super-cycling, the theoretical big lever, is
catastrophic during light-off. Reaching 10 min requires a fundamentally
different chemistry path — replacing scipy LSODA with a JIT'd in-kernel
stiff integrator (BACKLOG: "JIT'd frozen-T Brinkmeier kernel"; the
cProfile confirms scipy's Python overhead is the wall) or reduced-order /
tabulated kinetics. Both are research efforts, not tuning.
Scoped: ISAT chemistry acceleration — 2026-05-29¶
The chem-integrator path is closed (LSODA's adaptive variable-order BDF is near-optimal; the banded multi-cell light-off system is genuinely stiff — measured njev ~1848, up to 260 Jacobian builds per chem-step — and three implicit rewrites were a net wash). The only way past the chemistry wall is to avoid the stiff solve. ISAT (in-situ adaptive tabulation) tabulates the reaction mapping on the fly and falls back to the real LSODA solve on a miss, so it is bounded-error by construction and can never produce garbage.
Phase-0 feasibility (measured on AVL MFR_transition_SC630, t_end=0.3):
9 960 per-cell chem queries logged. Effective dimensionality 8 of 15
(the queries lie on a low-dim manifold). A greedy covering in normalized
state space needs only 259–572 table points to cover 93–97 % of all
queries (ε = 0.2 / 0.1 / 0.05). → strong reuse; ISAT is viable. Caveat:
this is an optimistic proxy — the real ellipsoid-of-accuracy (EOA) must
bound the mapping error, not just state distance, so the
accuracy-bounded hit rate will be somewhat lower.
What to tabulate: F: x = (T, Y_primary, Y_extras[~10], θ_Ce) →
(Δx over Δt, q_rxn) plus the sensitivity matrix A = ∂x_new/∂x for the
linear retrieve approximation. Δt is ~fixed per chem-step; include it (or
rescale) to cover the small variation.
Algorithm (standard ISAT): query x → search a binary tree / kd-tree
for a record whose EOA contains x. Retrieve (hit): x_new ≈ x0_new
+ A·(x − x0) (one matvec). Grow: real solve is within tol → expand
the EOA. Add (miss): run the existing banded LSODA, compute A
(integrated sensitivity or FD), insert a record. Misses reuse the current
_qs_surface_chem_step as the handler, so error ≤ the EOA tolerance.
Integration point: the per-cell mapping inside _qs_surface_chem_step
in quasi_steady_network.py; the existing banded LSODA becomes the
miss-handler + A-matrix source. One table per catalyst pipe (or shared).
Phases + decision gates:
- P0 (done): locality feasibility → GO (93–97 % coverable, eff-dim 8).
- P1 (~3–4 d): minimal ISAT (EOA add/retrieve, kd-tree, LSODA
fallback, FD sensitivity). Gate: accuracy-bounded hit rate > 80 % on
AVL light-off.
- P2 (~2 d): tune tolerance; validate the full 500 s run vs pure-LSODA
(θ_Ce / q_rxn / tailpipe trajectory). Gate: ≥ 5× chem speedup at ≤ ~2 %
trajectory error.
- P3 (fallback): if the accuracy-bounded hit rate disappoints,
pivot to an ML surrogate (NeuralODE / ChemKAN over the 8-D manifold P0
found) — higher ceiling, needs a training pipeline + generalization
validation, fits the existing closures/learned_* infra.
Risks: accuracy-vs-distance gap (EOA must bound mapping error); table growth / memory; tree-search cost vs solve cost; thread-safety under parallel pipes; Δt variation. All mitigated by the LSODA fallback — ISAT is never wrong, only slower on misses.
Success criteria: ≥ 5× on AVL chem cost (chem is ~88 % of wall →
roughly 3× on the full run, stacking on the qs_cfl_adv=8 ~3.9×), with
θ_Ce + q_rxn + tailpipe within ~2 % of pure-LSODA over 500 s.
Why ISAT before the ML surrogate: graceful bounded-error fallback (no garbage, no extrapolation risk), no offline training pipeline, and the P0 locality data directly supports it. The surrogate is the higher-ceiling P3 option if ISAT's accuracy-bounded hit rate disappoints.
ISAT P1+P2 results — 2026-05-29¶
P1 — aeolus1d.closures.isat_chem.ISATChem: a generic in-situ
adaptive tabulation with EOA add / grow / retrieve, a bounded-error
real-solve fallback (misses/grows return the exact value; the EOA only
grows where the approximation is verified within tol), and order-0
(constant) or order-1 (linear + FD sensitivity) retrieval. Unit-tested on
a clustered random-walk stream: 95% of results within tol, mean within
tol (the scalar-radius EOA is approximately bounded — a full
ellipsoidal EOA is the strict upgrade).
P2 — validated on the real Brinkmeier+LSODA mapping (a spread of 40
cells, some lit-off some not, evolved 150 steps; ml_chemistry/validate_isat.py):
| order | tol | hit rate | table | mean err | max err | speedup |
|---|---|---|---|---|---|---|
| 0 (constant) | 1e-2 | 79% | 991 | — | 0.33 | 2.7× |
| 0 (constant) | 1e-3 | 44% | 2098 | — | 0.33 | 1.2× |
| 1 (linear) | 1e-2 | 94.6% | 42 | 8.8e-3 | 0.25 | ~3.5× |
Takeaways: order-1 (linear) is the clear choice — it recovers the
~95% hit rate the Phase-0 locality predicted, with a tiny 42-record table
and typical error within tol, and the FD-sensitivity cost on adds
amortizes because adds are rare. The ~3.5× is on a 150-step window and
understates the full-run figure — the table saturates early, so a
500 s run is mostly retrieves. The persistent max error ~0.25 sits on
cells crossing the ignition threshold (chemistry is sensitive there);
the typical (mean) accuracy is fine. Two refinements take it to the
≥5× / ≤2× target: an ellipsoidal EOA (strict per-direction bounds)
and an ignition-aware tolerance (tighter EOA where dθ/dY is large).
The ML surrogate (ml_chemistry/) is the alternative high-ceiling path.
ISAT vs the PRODUCTION baseline — NO-GO — 2026-05-29¶
The P2 ~3.5× above is measured against a per-cell exact solve. But
production _qs_surface_chem_step does a single banded LSODA over all
cells at once (vectorized; the chem RHS is block-diagonal, banded with
lband=uband=n_state-1), integrating the full macro Δt in one adaptive
call. Re-measuring against that right baseline overturns the win.
Probe (tests/profiling/isat_perf_probe.py): captures the real
chem-call trajectory of aeolus_optimized.toml in-situ (the production
kernel runs the actual advance; its banded output is the trusted
reference — a faithful re-solve self-checks bit-identical,
max|Δ|=0). Then replays the captured input trajectory through a
batched ISAT (vectorized nearest-neighbour retrieve + one banded
solve over only the missed cells, fixed substep h with an exact
remainder), with interleaved per-call timing (baseline and ISAT timed
back-to-back each call, so machine load hits both equally).
AVL TWC, 0.5 s capture: 20 cells/pipe, n_state=14 (1 primary + 12 extras + 1 θ_Ce), reorder on; dt 1e-4–1.6e-3 (median 7e-4, not settled — light-off transient); banded solve median 28 ms/call, LSODA nfev median 1152 (genuinely stiff). Baseline = 17.7–18.2 s / 600 calls.
| h | hit rate | table | max err (tol 1e-4) | speedup vs banded |
|---|---|---|---|---|
| 7.0e-4 | 78.9% | 190 | 5.1e-2 | 0.67× |
| 3.5e-4 | 88.2% | 270 | 2.8e-2 | 0.74× |
| 1.8e-4 | 93.5% | 391 | 3.8e-2 | 0.69× |
| 8.8e-5 | 93.8% | 516 | 3.5e-3 | 0.56× |
Batched ISAT is slower at every substep (0.56–0.74×) and misses the
accuracy target (35×–500× tol). Mechanism: the banded LSODA already
vectorizes all 20 cells into one adaptive call. To match its accuracy
ISAT must sub-cycle at h ≪ dt (tens of thousands of batched sub-steps +
thousands of small scipy solves for misses / remainders / FD
sensitivity), whose overhead dwarfs 600 big adaptive solves. The variable
HRR-controlled Δt (135 unique values) is hostile to fixed-step reuse, and
the scalar-radius order-1 EOA collapses on the stiff ignition-threshold
cells. ISAT pays off with many cells / un-vectorized per-cell solves;
this is a 20-cell mesh where the baseline is already vectorized — so
there is no per-cell cost to amortize, exactly why the per-cell-baseline
3.5× did not carry over.
Adversarially verified (5 independent skeptic lenses): impl-efficiency (numba/kd-tree/order-0 ceiling ~1.1–1.2×, can't close the gap), accuracy (35× overshoot is fundamental on the ignition manifold), and representativeness (16× dt spread floors the win) all uphold the no-go at 0.92–0.95 confidence. A load-fairness concern was raised and then disproven by interleaved remeasurement (baseline stable, same ratios).
Verdict: ISAT is closed for this solver/case — wiring it into
_qs_surface_chem_step would be a regression. This joins the
stiff-integrator-swap finding (also a net wash 4×): LSODA's vectorized
banded adaptive solve is the right tool for this small-mesh stiff chem
step. The remaining higher-ceiling lead is the ML delta surrogate
(ml_chemistry/), but only conditionally — it needs (a) the run to
settle to a stable Δt (the transient shows 135 unique Δt) so a
fixed-Δt surrogate is in-distribution, and (b) a residual-check fallback
holding ≤2× error; both unproven here. The proven wall-time lever stays
qs_cfl_adv (fewer macro steps → fewer chem calls).
Phase A — fused @njit Brinkmeier rhs (2026-05-31)¶
MRs: !132 (fused rhs, opt-in), !134 (default flip to True).
Schema knob: qs_chem_fused_rhs: bool = True
(src/aeolus1d/io/schema.py line 349, with the full design comment at
lines 331–348).
Trigger. The ISAT NO-GO above closed the
chem-integrator-swap path: LSODA's vectorised banded adaptive solve
is the right tool for this small-mesh stiff step, so we cannot replace
it. But the post-mortem cProfile of _qs_surface_chem_step showed
that scipy's per-substep Python rhs closure was the actual wall —
not the C-level LSODA bookkeeping, not the @njit
_brinkmeier_rates_jit kernel itself. The closure was a death-by-a-
thousand-cuts pile: a dict comprehension to gather species, a
reshape, a list-of-tuples extras reorder, a few
np.ascontiguousarray casts, and a final dy.ravel, all called ~64 k
times per chem-step window.
Change. A single fused @njit rhs,
_qs_brinkmeier_rhs_fused, replaces the Python rhs whenever the pipe
carries a Brinkmeier surface reaction (i.e.
pipe.py_reaction_surface._jit_params is published). Behaviour is
bit-equivalent to the legacy Python rhs — LSODA takes the same
step sequence on identical rhs values — so the existing tolerance
regime, integrator stats, and downstream θ_Ce / q_rxn / tailpipe
trajectories are unchanged by construction. Non-Brinkmeier closures
(generic builder, NH₃, py_reaction kinds) lack _jit_params and
fall back to the Python rhs automatically — no per-case opt-out
needed.
Validation. Bit-equivalence anchored by
tests/integration/test_qs_chem_fused_rhs.py (runs the same case
twice with qs_chem_fused_rhs toggled and asserts identical state at
every macro step). Full unit + integration sweep green; AVL Cruise-M
bench held the wall-time win across reboots.
Result — AVL Cruise-M MFR_transition_SC630, 12 s sim:
| wall | realtime ratio | |
|---|---|---|
pre-Phase-A (qs_chem_fused_rhs=False) |
97.7 s | 8.15× |
Phase A (qs_chem_fused_rhs=True) |
47.0 s | 3.92× |
| speedup | 2.08× | 2.08× |
Default flip rationale. Three independent signals authorised
defaulting qs_chem_fused_rhs to True (MR !134):
- Bit-equivalence test green on the full sweep → there is no numerically-different alternative for users to opt into; the Python rhs path is now strictly slower for identical output.
- The non-Brinkmeier fall-through is automatic; nothing breaks for
cases that don't carry
_jit_params. - The 2.08× is large enough that hiding it behind an opt-in flag
was net-negative for default UX. The flag remains exposed for
Falseas a regression escape hatch.
Diagnostic script shipped: scripts/profile_post_fused.py —
mirrors the legacy pre-fused profile script but with
qs_chem_fused_rhs=True so the post-Phase-A breakdown is directly
comparable. Reads local_cases/avl_cruise_m/aeolus_optimized.toml,
warms the JIT cache at t_end=0.02 s, then profiles the main run
(default t_end=2.0 s), dumps pstats to
/tmp/qs_chem_profile_post.prof, prints top-cumulative + top-tottime
tables. Use it to decide whether the next Phase B (analytic banded
Jacobian for LSODA) is worth the bit-equivalence cost — currently
held in BACKLOG behind the Phase A result.
The two companion diagnostic scripts that landed in the same wave
(scripts/diagnose_qs_picard_2cycle.py +
scripts/analyze_qs_picard_trace.py, MRs !130 / !131) are not Phase
A artefacts — they support the orthogonal QS junction-Picard
sign-bracket-bisection guard — but they are mentioned here so the
chemistry-acceleration audit trail enumerates all shipped tooling
from this wave.
Cross-reference. For the full qs_chem_fused_rhs semantics
(when it activates, what falls through to the Python rhs, the
non-Brinkmeier behaviour, opt-out flow) see the schema docstring at
src/aeolus1d/io/schema.py lines 331–348 and the v0.9 entry in
changelog.md — these are the authoritative
run-settings references until the case-header reference chapter
lands.
Phase A.5 — dy buffer hoist (2026-05-31)¶
MR: !135 (also rolled BASIC_FIELDS to PipeSpec / JunctionSpec /
BCSpec and extended the form-factory kind dispatch; the dy hoist is
the perf-relevant piece for this document).
Trigger. Re-profiling with scripts/profile_post_fused.py after
Phase A landed showed a residual per-substep allocation: the fused
_qs_brinkmeier_rhs_fused was building a fresh dy output array on
every invocation. LSODA calls the rhs many times per macro step; the
allocation was small but high-frequency.
Change. Hoist the dy buffer out of the inner rhs closure into a
once-per-substep scratch slot, owned by the _qs_surface_chem_step
caller and threaded into the fused rhs as an out-parameter.
Algebraically identical — same write pattern, same final values, just
zero allocations per rhs call.
Result. ~1.026× wall stacking on top of Phase A on the same
AVL Cruise-M MFR_transition_SC630 workload. Small in isolation but
free (no behavioural change, no flag, no fallback path) — the kind of
micro-win that's only worth recording because it stacks cleanly with
Phase A and confirms the diagnostic-script-driven loop is still
finding signal.
Validation. Same bit-equivalence anchor as Phase A
(tests/integration/test_qs_chem_fused_rhs.py); no separate test
needed — the hoist is a calling-convention change inside the fused
path that the integration test already covers.
Cumulative chemistry-heavy summary (post-Phase-A.5)¶
The chemistry-acceleration arc that started with the AVL drive cycle
profile on 2026-05-29 now bakes in the following wall-clock budget
by default, with the two True-default flips
qs_chem_fused_rhs=True (MR !134) and the orthogonal
substrate_wall_in_newton_residual=True (re-affirmed in MR !137 after
the 5-SHA bit-identity bisect cleared it):
| arc step | wall on 12 s AVL Cruise-M | realtime ratio | cumulative speedup |
|---|---|---|---|
| pre-Phase-A baseline | 97.7 s | 8.15× | 1.00× |
| Phase A (fused @njit rhs) | 47.0 s | 3.92× | 2.08× |
Phase A.5 (dy buffer hoist) |
45.8 s | 3.82× | ~2.13× |
The ~2.1× lands on top of the earlier qs_cfl_adv=8 ~3.9× (the
proven macro-step lever from the 2026-05-29 AVL profiling section),
which is a user-tunable knob and stacks multiplicatively because they
attack different axes: qs_cfl_adv reduces the number of macro chem
calls per second of sim, the fused rhs reduces the per-call cost of
each one. A run that combines both lands at ~8× wall on chemistry-
heavy QS workloads versus the 2026-05-29 baseline.
The closed integrator-swap and ISAT verdicts (above) still hold:
LSODA stays. The shipped path forward is the analytic banded
Jacobian (Phase B, BACKLOG, gated on whether
scripts/profile_post_fused.py shows that LSODA's Jacobian build is
still a >10 % slice after Phase A.5) and the ML delta surrogate
(ml_chemistry/, BACKLOG, gated on the run settling to a stable Δt).
Both are research-grade efforts; Phase A + A.5 are the engineering
wins that landed without that risk.
Full-case compute-budget gate (plan MR-7, 2026-06-07)¶
The Phase-Perf gates above watch kernels (wall-clock micro-benches,
tests/benchmarks/test_baseline_regression.py) and Python wrappers
(per-function cProfile self-time, test_profile_regression.py). MR-7 adds the
missing whole-case tier: a budget on the total accounted compute for a
canonical heavy case, so a catastrophic full-case regression — a flag default
flip onto a slow driver, an accidental O(n^2), a lost numba JIT cache — trips a
gate even when no single kernel/wrapper gate notices.
Metric — why sum-of-self-time, not wall. This box cannot measure wall tightly (kernel micro-benches swing ~2.3x with WSL2 host load; see the "Conclusions" caveats above and ADR-0006). But the cProfile sum of per-function self-time over a multi-second, million-call case is stable to ~1 %: three back-to-back profiled runs of the C02006 slice measured 44.07 / 44.25 / 44.51 s (spread 1.01x). Per-call jitter averages out across the whole profile. That stability is what lets the ceiling be tight enough to catch a 2x slowdown instead of only an order-of-magnitude one.
Case + slice. examples/avl_cruise_m/c02006_dpf_csf_regen_trans.toml — the
independently-validated W3 DPF/CSF-regen anchor (also the golden-snapshot anchor
in tests/regression/). The full case is a 500 s quasi-steady transient; its
per-step cost is linear in t_end (measured ~3.1 sim-s/wall-s flat over
0-100 s, so the full run is ~2.6 min wall now — the older "6.5 min" figure was a
coarser mesh). A bounded t_end = 100 slice (~31 s raw / ~50 s profiled) is
therefore a faithful per-step-cost proxy, tractable both nightly and ad-hoc.
Reference + ceilings (median of 3 runs, WSL2 x86 runner, t_end = 100):
| Target | Reference | Ceiling | Band |
|---|---|---|---|
| total accounted self-time | 44.25 s | 80.0 s | 1.8x |
advance_network_quasi_steady_transient (self) |
7.14 s | 13.0 s | 1.8x |
_simple_solve_ideal_jit (self) |
3.68 s | 7.0 s | 1.9x |
Every band is <2x, so a 2x slowdown trips the gate (the plan's acceptance) with margin, while the ~1.8x band over a ~1 %-stable metric is almost entirely headroom for CI host contention. The per-hot-path gates are diagnostics: a tripped total points at the culprit subsystem.
Where it runs. tests/benchmarks/test_fullcase_budget.py, marker
profile_regress + slow. The new perf-budget CI job runs the whole
profile_regress tier on scheduled (nightly) + tag pipelines only — a perf
regression merged to main surfaces on the next nightly, not on the merge,
keeping the single x86 runner free for the merge pipeline. This job also
activates the previously-dormant case-41/50 wrapper gates in CI for the first
time. Unconditional trip-teeth tests in the same file pin the 2x sensitivity so
the gate can't silently bit-rot when the @slow tier is skipped.
Deferred to the bench host (Phase 5.5). Gating the full 500 s case and tightening the band toward a strict 2x both want a quiescent host; until then this loose form catches the regressions that matter (>=~1.8x) without flaking.
To refresh the reference (after an intentional perf change): re-measure on a
quiescent box, update REF_* / *_BUDGET_S in the test in a dedicated commit,
and update the table above.
Phase 5.5 — full-case wall-clock budget (the strict band)¶
MR-7 above is the form of the full-case gate that's safe to run anywhere: a
cProfile sum-of-self-time proxy over a bounded 100 s slice, with ~1.8x bands. It
deliberately does not assert wall-clock, on the standing assumption that this
WSL2 host swings too much under load to gate wall (ADR-0006 / C2). Phase 5.5
closes the two things MR-7 deferred — the full 500 s case (not a 100 s
slice) and a strict 2× wall-clock band (not the self-time proxy) — on the
validated W3 anchor c02006_dpf_csf_regen_trans.
The host turned out to be this box¶
The 2.3x figure that justified the deferral is for kernel micro-benches.
Full-case wall is a different animal, and it had never actually been measured. So
it was (2026-06-07, .pipeline/charac_wall.py): two independent 5-rep series of
the full C02006 500 s case on the x86 WSL2 runner.
| series | reps (s) | best-of-N min | settled spread |
|---|---|---|---|
| 1 (cold + a hammered box) | 405, 247, 192.2, 195.1, 199.0 | 192.2 | 1.04x (reps 3-5) |
| 2 (settled) | 193.2, 194.7, 193.4, 193.5, 192.0 | 192.0 | 1.014x (all 5) |
Two findings drop out:
- Settled full-case wall is stable to ~1.4 % — a multi-second, million-call case averages out the per-call scheduling jitter that wrecks micro-benches.
- The best-of-N minimum is immune to the cold-start transient. Series 1's first two reps were contended high (405 s is slower than the cold-compile warm run — external contention, not warm-up), yet its min still came out 192.2, agreeing with the quiescent series' 192.0 to 0.1 %. Contention is strictly one-sided, so the minimum is the honest estimator; the gate never looks at the noisy max.
A 2× band over a metric that reproduces to 0.1 % is almost all headroom: a genuine doubling (→ 384 s floor) trips it; transient host noise cannot. That retires the "needs a dedicated quiescent host" deferral for this anchor — the mac-mini is not required.
What shipped¶
| Gate | tests/benchmarks/test_fullcase_wallclock.py (marker wallclock_regress + slow) |
| Anchor / ref | c02006_dpf_csf_regen_trans, full 500 s, ref_wall_s = 192.0 (best-of-5) |
| Band | strict 2× (ACCEPT_BAND); trip is measured < 2 × ref |
| Reps | best-of-5 (N_REPS); rejects the cold-start transient |
| CI job | wallclock-budget — pinned to the x86 WSL2 runner (tag aeolus-wsl-linux), AEOLUS_BENCH_HOST=1, scheduled (nightly) + tag |
The AEOLUS_BENCH_HOST=1 flag still guards every real gate: the runner is
quiescent during its scheduled nightly job, but an interactive dev session on
the same box is not (the series-1 contention came from exactly that). So an
ad-hoc pytest -m wallclock_regress on a working box skips the real gate and
runs only the unconditional trip-teeth that pin the 2× band logic. The pin to
aeolus-wsl-linux matters because wall is host-specific — on the ARM mac-mini
(tag mini) the 192 s reference would falsely trip.
2026-08-20: the ARM box is no longer the only host that would falsely trip
it. A second x86_64 runner (aeolus-docker, tag aeolus-x86) took over the
untagged tiers plus gui-tests and perf-budget; wallclock-budget stayed
pinned here precisely because ref_wall_s = 192.0 describes this hardware
and nothing else. Architecture was never the point — the measurement host was.
Moving the pin requires a fresh best-of-5 on the new box first; see
docs/ci-runner-runbook.md and the BACKLOG.md CI section.
Why EPW is not an anchor here¶
Plan 2.9 names "W3/EPW". The case its "EPW" was taken to mean,
c06107_epw_car_diesel, is not gated here — and despite the name it is not a
runnable breathing case. It is an import/schema/GUI smoke stub: a closed
all-wall network with no cylinders, exercised only by a 0.1 ms run_case advance
in test_avl_cruise_m_bundled_examples.py. It is never run to completion. Forcing
its native t_end = 200 is ~15.6 h of meaningless idle acoustic relaxation (a 3 cm
turbocharger pipe meshed to 20 cells pins a ~1 µs explicit dt), and it cannot run
quasi-steady (all-wall — no inflow/outflow far BC for the QS driver). A prior
">14.5 min" figure for this case was wrong.
The actually-validated AVL EPW case is the examples/validation/avl_epw_diesel_steady/
harness — a 6-cyl 12.9 L HD diesel with real stagnation_inlet/pressure_outlet
BCs, t_end ≈ 0.08 per operating point, driven per AVL CSV row. That is a
validation-suite concern (see examples/validation/README.md), not a wall-clock
budget anchor. So this gate covers W3 (c02006) only; a meaningful EPW perf
anchor, if ever wanted, is the harness case, not c06107.
Refreshing the reference¶
After an intentional perf change, on the runner while it's idle:
AEOLUS_BENCH_HOST=1 python scripts/bench_fullcase.py # best-of-5
AEOLUS_BENCH_HOST=1 python scripts/bench_fullcase.py --reps 7 # more margin
It times the anchor best-of-N and prints a paste-ready ANCHORS block; update
ref_wall_s in test_fullcase_wallclock.py in a dedicated commit that records
the host + arch, and update the table above.
The gates report on the passing path too (2026-08-21)¶
Until this change every gate helper in tests/benchmarks/ reported only by
raising. A failing run printed its number; a passing run printed nothing.
The evidence is the v0.3.2 tag pipeline: perf-budget passed, and its entire
110-line log carried exactly one number — pytest's own
8 passed, 78 deselected in 62.25s. The gated quantity, total self-time against
the 80.0 s ceiling, never appeared. The consequence is that neither perf gate
could be trended across green releases, which is precisely when the trend is
wanted; STATUS.md's host-load table can compare v0.3.1's 154.86 s loaded
against 77.8 s idle only because that run failed. The same asymmetry is why
the idle wallclock-budget figure in that table has to be caveated as a job
duration rather than the measured per-rep wall.
What is emitted¶
tests/benchmarks/_timer.emit_measurement prints one AEOLUS-PERF-prefixed
line and, when AEOLUS_PERF_LOG names a path, appends one JSON object per
measurement to it. Three helpers call it on every exit path — passing,
failing, and the early-error paths:
| Helper | Gate | gate field |
|---|---|---|
_timer.assert_profile_self_time_under |
per-hot-path self-time (7 call sites) | profile_self_time |
test_fullcase_budget._assert_total_budget |
the 80.0 s total | profile_total |
test_fullcase_wallclock._assert_wall_budget |
the strict 2x wall band | fullcase_wall |
Each record carries the gated quantity (value_s), what it was gated against
(limit_s, ref_s), the verdict (passed, failure_mode) — and its harness:
host, machine, python, cpu_count, numba, fastmath, the marker
selection, the CI job/pipeline/runner tags, and loadavg sampled at the
measurement rather than at session start. That last point is deliberate: host
contention is the only thing that has ever made these gates flake — three
recorded incidents — so the load that produced a number travels with it.
CLAUDE.md is the binding rule here: a number without its case is not a
measurement.
An unreferenced anchor (ref_wall_s is None, i.e. a bench host that has not
been calibrated yet) emits its measured wall with passed: null and
failure_mode: "no-reference", then skips as before. That is how a new bench
host produces its first number.
Reading it off a pipeline¶
perf-budget and wallclock-budget both set AEOLUS_PERF_LOG and collect
perf-measurements.jsonl with when: always — a red gate's number is the one
most worth keeping. Download the artifact and:
jq -r '[.gate, .key // .slug, .value_s, .limit_s, .passed] | @tsv' perf-measurements.jsonl
jq -r 'select(.gate=="profile_total") | "\(.value_s) / \(.limit_s) on \(.host) load \(.loadavg[0])"' perf-measurements.jsonl
Before trusting any number from it, check loadavg in the record itself: a
perf-budget total measured on a loaded box is not comparable with one from an
idle box, and the margin here is thin — the last green run was 77.8 s against an
80.0 s ceiling.
Paste-on-tag. After a release tag's pipeline, take the profile_total and
fullcase_wall records from the artifact and add a dated row to the tables
above, with the record's host and loadavg beside the number. This is the
step that was impossible before: the numbers existed only in the process that
computed them.
tests/unit/test_perf_gates_emit_artifacts.py couples the YAML to the Python —
the env var name is imported from _timer.py rather than copied, and the
artifact path is compared against the path the job writes to. Both failure modes
are otherwise silent: the job goes green and the artifact is empty or absent.
tests/benchmarks/conftest.py fails any test carrying profile_regress or
wallclock_regress that runs to completion without emitting, so a fourth gate
helper added later cannot repeat the omission — the first draft of this change
instrumented _timer.py alone and missed _assert_total_budget, the helper
gating the very number STATUS.md names.
The first nightly, and what it says about the tag reading (2026-08-31)¶
The nightly schedule fired for the first time and both perf gates ran
outside a tag pipeline for the first time ever. Until 2026-08-30 the project
had no pipeline schedule at all, so perf-budget and wallclock-budget had
only ever executed on a release tag — see docs/ci-runner-runbook.md for the
schedule and BACKLOG.md for how that went unnoticed.
Pipeline 2803954760, ea06db47, source schedule, 72 min end-to-end, all 17
jobs green. Numbers read from each job's own perf-measurements.jsonl
artifact, never from job duration — the two are different quantities and
substituting one for the other is a mistake this repository has already
recorded.
| gate | value | ref | limit | loadavg at measurement |
|---|---|---|---|---|
profile_total (c02006_total_self_time) |
44.144 s | 44.25 | 80.0 | [0.74, 0.26, 0.19] |
fullcase_wall (c02006_dpf_csf_regen_trans, 5 reps) |
195.829 s | 192.0 | 384.0 | [1.01, 1.08, 0.90] |
The v0.3.3 tag reading was contention, and the loadavg was in the record all along¶
docs/release-readiness.md's EC-Q8 records the v0.3.3 tag pipeline at
61.70 s of the 80.0 s ceiling and concludes "18.3 s of margin where
v0.3.1 had 2.2 s". Its own harness line carries loadavg 2.53.
Line the three quiet-host readings up against it:
| when | loadavg | profile_total |
|---|---|---|
| by-hand pre-flight, 2026-08-24 | 0.78 → 2.12 | 43.28 s |
| nightly, 2026-08-31 | 0.74 | 44.144 s |
| reference | — | 44.25 s |
v0.3.3 tag pipeline |
2.53 | 61.70 s |
On a quiet host the gate reads its reference to within 0.24 %. The tag pipeline's 61.70 s was 40 % above that, on a box carrying 2.53 of load. So the ceiling's real headroom is about 36 s, not 18.3 — and the figure that made it look like 18.3 was measuring the runner, not the code.
That is the entire argument for EC-Q10 in one measurement: the same number, taken nightly on a quiet box, costs nothing and is interpretable; taken once on release day under load, it is a scare.
This is n = 1 and must not be promoted¶
One nightly is a data point, not a baseline. The dead-JIT budget is the cautionary case in this same file's neighbourhood: a single 606 s sample read "67 % spent" and ten runs later the worst case restated it at 70 % with a bimodal distribution nobody had seen. Step 2 of EC-Q10 is to accumulate green nightlies until the baseline is a distribution; step 3 pins it. Do not refresh any reference number against this row.
One documentation defect found while reading it¶
docs/perf-history.md's own jq recipe above reads .loadavg[0], and that
field is present — checked, because the first attempt at this table
assumed it was missing when a truncated dump did not show it. Recorded because
the near-miss is the point: the harness was in the record, and EC-Q8's
conclusion was drawn without reading it.
Phase Perf-18 — fused uniform-γ boundary-face flux (2026-08-28)¶
Trigger. A profile of 39_4cyl_engine_turbo_scr taken while answering
"would a TensorFlow-style computational graph make this faster". It said no —
but it also said where the time actually is, and that is not the numerics. On
that case only 20 % of wall sits in @njit kernels; the rest is Python
orchestration. The share tracks component count, not cell count:
| case | topology | wall | in kernels | Python |
|---|---|---|---|---|
01_single_pipe_sod |
1 pipe | 0.03 s | 91 % | 9 % |
23_mp5_sod |
1 pipe, MP5 | 0.01 s | 82 % | 18 % |
40_implicit_adaptive_pipe |
Newton/BDF2 | 0.01 s | 38 % | 62 % |
74_twc |
quasi-steady + surface chem | 0.20 s | 33 % | 67 % |
50_two_cyl_engine |
2-cyl network | 0.37 s | 21 % | 79 % |
39_4cyl_engine_turbo_scr |
13 pipes, 10 junctions | 6.89 s | 20 % | 80 % |
89_epw_diesel |
6-cyl, 17 pipes | 0.27 s | 14 % | 86 % |
Harness: cProfile self-time summed over every entry whose (file, line)
matches a numba Dispatcher's py_func (217 found by walking solver,
chemistry, closures, bc, components — the same inventory trick
_dead_jit_probe uses), divided by an unprofiled best-of-3 wall clock. cProfile
inflates this workload 1.84× here, matching the 1.79× recorded in
BACKLOG.md, and the inflation lands on Python-heavy frames — so the kernel
share is computed against true wall, not against profiled total. Cross-check:
pipe_residual_muscl reads 110.6 µs/step in the profile and 112.8 µs/step in a
standalone microbenchmark on the same real arrays, i.e. dispatcher rows are
essentially uninflated. This dev box, 2026-08-28.
What landed. boundary_face_flux_muscl_jit in solver/kernels.py, and
junction._pipe_port_flux_muscl reduced to one call into it. The body it
replaces reached the same face flux through five dispatcher round-trips —
U_to_W, muscl_slopes, W_to_U_single twice, hllc_flux — plus a slice and
two 3-vectors, per port per RK stage. The O(1) 4-cell window moved across
verbatim; Ng and N are still read from the pipe rather than assumed to be 2
as the two siblings assume, because the call site being replaced read them.
The variable-γ and real-gas boundary-face paths were already fused when they were added. The plain uniform-γ path — the most travelled of the three — was the one still assembled in Python.
Two increments landed under this phase.
junction._pipe_port_flux_muscl→ oneboundary_face_flux_muscl_jitcall.network_timeloop._left_boundary_mass_flux, scalar-γ MUSCL branch → the same kernel. That branch was a second hand-kept copy of the identical reconstruction, and it additionally ran a function-localimportand two generator expressions per call, ~16 calls/step.
Increment 2's real value is not its share of the clock. The species-advection
anchor and the junction port coupling are the two consumers of "the flux at
face 0", and _species_rate telescopes every interior face off that anchor —
so a discrepancy between them is a constant DC bias on every face, which
fakes a uniform advective drift of any non-uniform composition, and is
invisible for uniform composition and on first-order pipes. They were two
copies; they are now one kernel and agree 13/13 exactly under the default
build. That is checkable, unlike the residual claim retracted below.
Wall-clock. 39_4cyl_engine_turbo_scr, t_end = 0.01 (5252 steps),
best-of-3 inner, numba caches deleted between arms, three replicates
interleaved baseline → inc1 → both. Gated on /proc/loadavg 1-min < 1 before
starting, per this repo's own rule about wall-clock on a loaded box:
| replicate | baseline | inc 1 only | both | inc 1 | both | inc 2 marginal |
|---|---|---|---|---|---|---|
| 1 | 7.137 s | 6.622 s | 5.999 s | 7.2 % | 15.9 % | 9.4 % |
| 2 | 7.342 s | 6.731 s | 6.070 s | 8.3 % | 17.3 % | 9.8 % |
| 3 | 7.511 s | 6.875 s | discarded | 8.5 % | — | — |
≈ 8 % for increment 1, ≈ 16-17 % for both; per step 1359-1430 µs → 1142-1156 µs.
Two things about that table are the point of recording it. The box drifted
~5 % slower across the run (baseline 7.137 → 7.342 → 7.511), which is why
speedups are computed within a replicate and not against a pooled mean. And
replicate 3's both arm read 13.728 s — 2.3× its siblings, with loadavg
12.70 at the end of the run — so it is discarded as contention, not averaged
in. An earlier ungated attempt lost its first two points the same way, to an
orphaned child of a killed harness.
An earlier draft of this entry recorded increment 1 alone at "≈ 9 %" from two replicates on an ungated box. The gated three-replicate figure is 7.2-8.5 %. Same direction, slightly smaller; the 9 % was the top of the range, not the middle. Benched rather than assumed because Phase Perf-13 is the counter-example: a fusion that looked obviously good regressed 9 %, and Perf-7's SIMD HLLC rewrite regressed 16–33×. The difference here is that Perf-13 fused numpy operations at a (1, 3) shape, where numpy's per-call overhead exceeds the scalar stores it replaced; this removes Python dispatcher round-trips, which is the Perf-2/4/6 shape.
Bit-identity — read this before fusing anything else. The fused kernel is not bit-identical to the Python version under the default build:
| build | fused == old python |
|---|---|
default (fastmath on) |
13 / 26 faces (Δ ~2.8e-13 relative) |
AEOLUS_FASTMATH=0 |
26 / 26 |
RETRACTED, same day, before this entry was ever acted on. A second column
stood here claiming "fused == pipe_residual_muscl's face: 13/26 under
fastmath, 26/26 under AEOLUS_FASTMATH=0". That was not measured against the
residual. It was measured against a Python reimplementation of the residual's
face algebra — the non-inlined flavour — which is the same flavour as the code
being replaced, so the comparison could only ever restate the first column.
Re-measured against an njit reimplementation instead (right flavour: same
decorator, same helpers, same order, so W_to_U_single inlines as it does in
the kernel) the answer flipped — fused 13/13, python 10/13. Which would have
made the fusion an improvement in flux consistency. That did not survive
either: the njit proxy, asked to reproduce the kernel's own R[0] through
-(F_1 - F_0)/dx, matches bit-exactly on only 7 of 13 pipes. LLVM optimises
the kernel's full sweep differently from any two-face extraction of it.
So the honest position, and the one to carry forward: under the default
fastmath build the kernel's internal face flux is not observable by
reimplementation, and no claim about matching it can be made. R exposes only
differences of adjacent faces, and every separately-compiled reconstruction of a
single face disagrees with the kernel at ~1e-13. What is measured: under
AEOLUS_FASTMATH=0 the fused kernel, the previous Python body, and both proxies
are all bit-identical, which is the build where test_golden_bit_identity.py
asserts anything.
Root cause, isolated rather than guessed: W_to_U_single carries
inline="always". Called from Python it runs as a standalone compiled
function; inlined into a compiled caller, LLVM reassociates its arithmetic with
the surrounding expression under fastmath, moving the result ~2.8e-13
relative. U_to_W and muscl_slopes were ruled out first — window-in-njit,
window-in-Python and full-array all produce bit-identical W and S.
This is survivable only because of an existing design decision:
test_golden_bit_identity.py runs only under AEOLUS_FASTMATH=0, exactly
where the fusion is exact. The default build is guarded by the measured tier at
rtol=1e-8, five orders above the shift. Any further Option-B fusion will
have the same property, so the strict tier must be run explicitly
(AEOLUS_FASTMATH=0 pytest tests/regression) on every such change — the
default pytest tests/regression run skips those 13 tests and tells you
nothing.
Tests. tests/regression green both ways: 18 passed / 13 skipped by
default, and 31 passed under AEOLUS_FASTMATH=0 with the assert_array_equal
tier active. Numba caches deleted before the strict run — numba's cache key
ignores inline="always" callees, so a stale caller would have served the old
binary and made the check meaningless.
A third increment was measured and NOT taken — the useful negative. The
plenum ghost fill (_plenum_ghost_primitives + _ghost_rho_E + the ghost
writes) ranked next after the two above. Prototyped as one @njit kernel and
microbenchmarked on 20 real plenum ports from 39_4cyl_engine_turbo_scr:
| path | per call |
|---|---|
| Python, as it stands | 4.197 µs |
| fused kernel | 3.926 µs |
Bit-identical, 20/20 — and worth 0.27 µs, i.e. 6.5 µs/step of a ~1150 µs step, +0.56 %. That is under the ~5 % drift this box shows within a single interleaved A/B run, so it is unmeasurable here even if real. Not wired.
The reason is worth more than the increment would have been. The fused path
still costs 3.93 µs, and nearly all of it is gathering the arguments:
volume.rho, volume.p, volume.gamma_advection (a property),
pipe.U_pad[idx] (constructs a view), _pipe_cell_gamma, the gamma_pad
slot lookup. Only the arithmetic moves into the kernel, and the arithmetic was
never the cost.
The rule these three measurements give, stated so it can be applied without re-deriving it: fusing pays where it removes dispatcher round-trips, and does not pay where it merely moves scalar arithmetic behind one new dispatcher call. Increments 1 and 2 each replaced five dispatcher calls with one and returned ~8 %. This one would have replaced zero with one. Perf-13 is the same lesson from the numpy side — a slice-assign fused over three scalar stores at a (1, 3) shape regressed 9 %.
What this does not do. It removes two functions' worth of Python from the
step. The orchestration share on this case is ~80 %; if it went to zero the
step would fall from ~1312 µs to ~250 µs, a 5.2× ceiling. Reaching any real
fraction of that means struct-of-arrays containers for pipes, junctions and
cylinders so a whole step can be typed — and the negative above says incremental fusion has now hit its
limit: what remains at these call sites is Python attribute access — object
property reads, U_pad[idx] view construction, per-pipe getattr — which no
kernel boundary can reach. Only typed containers can. That makes the SoA step
the next real move rather than one more fused helper. Treat the 5.2× as a
bound, not a target.