Verdict: PASS. B1 is 172 ns against a 5 000 ns budget — a 29× margin. B2 is 0 bytes, exactly. P0 proceeds to P1.
Recorded: 2026-07-30 · WP-11 · commit on
feat/wp-11-p0-gate
ADR-0002 chose to resolve the whole orchestration graph at compile time rather than by runtime scanning and reflection. That is an expensive decision: it buys quality goals Q1, Q3, Q7 and constraint C2, and it pays for them with generator complexity as permanent legacy risk (R1).
P0 exists to answer one question before that bill comes due:
Does the compiled path actually deliver the latency and allocation budget the decision was made for?
If it does not, ADR-0002 is not worth its cost, and the correct move is to stop, supersede it, and re-plan — not to optimise around it later.
The criterion, fixed before any of this was built:
| Criterion | Meaning | |
|---|---|---|
| B1 | flow overhead p99 ≤ 5 µs | a 4-step flow through the real engine |
| B2 | allocations per step = 0 B | exactly zero, not "low" |
Both must hold. Either failing stops P0.
BenchmarkDotNet v0.15.2
Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Intel Xeon Processor 2.10GHz, 1 CPU, 4 logical and 4 physical cores
.NET SDK 10.0.110 · .NET 10.0.10 (10.0.1026.32716)
X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
IterationCount=30 WarmupCount=10 DOTNET_TieredPGO=0
This is shared container hardware, not a dedicated benchmark machine. That was recorded as an open item against this work package, and it has not changed. The baseline already carries a retraction on exactly this point: WP-3 claimed benchmark ratios were machine-independent, two runs of the same commit on this container disagreed by up to 63 % on ratio and 159 % on absolute time, and the claim was withdrawn.
So the first question this report has to answer is not "what did it measure" but "is this hardware good enough to decide on?" — see §5.
The run used 10 warmups and 30 iterations, up from the 3 and 10 the CI configuration uses. That is not enough to make shared hardware behave like dedicated hardware. It is enough to make the distribution visible.
The benchmark is the real FlowEngine executing a real compiled ExecutionPlan, not
a step-loop stand-in.
| Benchmark | Mean | p95 | Max | Budget | Margin |
|---|---|---|---|---|---|
| 4-step flow, no compensation — B1 proper | 172.3 ns | 180.8 ns | 184.8 ns | 5 000 ns | 29× |
| 4-step saga, success path | 223.4 ns | 236.8 ns | 239.6 ns | 5 000 ns | 22× |
| 4-step saga, failure + full unwind | 266.3 ns | 276.6 ns | 279.3 ns | 5 000 ns | 19× |
B1: PASS.
Worth reading past the headline: declaring compensations costs 51 ns on the success path — that is what a saga charges you when nothing goes wrong. The failure path, including a complete reverse unwind, is still 19× inside the budget for a successful flow.
The criterion says p99. BenchmarkDotNet's percentiles are computed over iteration means, not over individual operations — each iteration times many operations and reports their average. For work at ~170 ns there is no way to time a single operation without the timer costing more than the work.
So p95 = 180.8 ns is the 95th percentile of per-iteration averages, and it is not
an operation-level p99. This report does not claim otherwise.
What can be said: the worst iteration mean observed was 184.8 ns, and for a true operation-level p99 to breach 5 000 ns the tail would have to be ~27× the mean. The usual source of a tail that shape is a GC pause — and this path allocates nothing (§4), so it does not create one. A tail can still arrive from outside the flow, from whatever else the process is doing; that is a property of the host, not of the engine, and it is the same tail any implementation would inherit.
Allocation counts are deterministic. They do not vary with hardware, load, or neighbours, which is why they — not the timings — are the contract.
| Path | Allocated |
|---|---|
| 4-step flow, no compensation | 0 B |
| 4-step saga, success path | 0 B |
| Interface dispatch | 0 B |
| Cached delegate | 0 B |
| 4-step saga, failure + unwind | 56 B — 40 B when this report was recorded |
B2: PASS. Zero, exactly, on every success path. That is the verdict, and nothing below disturbs it: B2 governs the success paths, and every one of them is still exactly zero.
The figure on the failure path is the Unwind iterator and is
recorded rather than hidden:
it occurs once per failed flow, never per step. It is measured with a ceiling so a
future change cannot quietly move an allocation from the failure path to the success
path.
It has since moved from 40 B to 56 B, in two steps, and the figure above is
restated rather than left. An iterator's state machine carries the value it yields,
so Unwind grew when CompensationEntry did: FlowContext? Scope in WP-29, so a
compensation inside a ForEach undoes the element its own step processed, and
StepScope JournalScope in WP-57, so the compensation row for the third element is
keyed as the third element. Both are correctness and neither is removable; the budget
moved instead. See README §1 and §4.
This is also asserted as a unit test, not only as a benchmark, so it gates every pull request rather than a nightly run. Those assertions are Release-only — in Debug the C# compiler emits async state machines as classes and charges the engine ~376 B of Edit-and-Continue scaffolding it does not allocate. The tests skip in Debug and say so.
Yes, and the reason is arithmetic rather than optimism.
The measured value is 172 ns. The budget is 5 000 ns. For the verdict to flip, the true value on any reasonable machine would have to be 29× worse than measured.
The observed run-to-run variance on this container — the variance that caused WP-3's ratio claim to be retracted — was up to 159 %, i.e. a factor of ~2.6. That variance was concentrated in benchmarks sitting at 10–20 ns, on the timer's noise floor. B1 is an order of magnitude above that floor.
A factor of 2.6 does not close a factor of 29. The margin is not within the error bars; it is a long way outside them.
Where this hardware would not be good enough: a decision resting on a 10–30 %
difference. That describes the ratio comparisons in DispatchBenchmarks, which is
precisely why they are advisory in the baseline and why this report does not lean on
them. It does not describe the kill criterion.
What would change the verdict: a machine where the compiled path is structurally different — not slower, but different. Nothing here is hardware-sensitive in that way: the step loop indexes an array, the dispatcher is a switch, the context is pooled. There is no cache-hostile data structure and no lock on the path.
The decision was not "compiled is faster". It was that runtime reflection fails Q1, Q3, Q7 and C2 simultaneously, and that the manifest (Q3) is only obtainable at build time.
The reflection benchmark exists to size one part of that:
| Dispatch strategy | Mean | Allocated |
|---|---|---|
| Direct call (sealed type) | 19.9 ns | 0 B |
| Interface dispatch | 21.9 ns | 0 B |
| Cached delegate | 21.0 ns | 0 B |
Reflection (cached MethodInfo) |
106.4 ns | 48 B |
Two things to be careful about here.
The 48 B is the load-bearing number, not the 5.34×. Reflection allocates on every
invocation — that is what makes a zero-allocation budget unreachable through it, at
any speed. The time ratio is measured with a cached MethodInfo, which is the
friendliest possible reflection; the honest comparison for a scanning framework would
also include the assembly scan at startup (Q7) and the trimming failure (C2), neither
of which appears in this table.
Interface dispatch is not slower than a direct call in any way that matters. 21.9 ns against 19.9 ns is a 2 ns difference at the noise floor, and the JIT devirtualises a sealed monomorphic call site. An earlier version of this reasoning argued that interface dispatch justified the generated switch on speed grounds. The data does not support that, and the argument was withdrawn. The switch is justified by AOT compatibility and typed step binding, not by dispatch cost.
| Criterion | Target | Measured | Result |
|---|---|---|---|
| B1 flow overhead | ≤ 5 000 ns | 172.3 ns (max iteration mean 184.8 ns) | PASS — 29× margin |
| B2 allocations per step | 0 B exactly | 0 B | PASS |
| B3 capability dispatch | ≤ 150 ns | 21.9 ns | pass — 6.9× margin |
Both kill criteria pass. P0 proceeds to P1.
A later full run of the same commit measured B1 at 129.5 ns rather than 172.3 ns — a 24 % swing, recorded here rather than quietly re-titled. It is precisely the variance §5 describes, it moves the margin from 29× to 39×, and it changes nothing about the verdict. The committed baseline carries the later numbers; the table above is the run this report was written from.
ADR-0002 stands. The compiled path delivers the budget it was chosen for, with enough margin that shared hardware cannot account for the difference.
- It does not say FlowX is fast end to end. B1 measures engine overhead. The reference sample's HTTP request also pays for Kestrel, JSON, and the capabilities' own work — none of which is FlowX's to spend, and none of which is measured here.
- It does not retire risk R1. Generator complexity is a maintenance cost, and this report measures runtime performance. R1 is revisited on its own terms: > 3 generator defects per phase, or > 8 % build overhead. Build overhead (budget B12) is still unmeasured, and remains open against WP-5.
- It does not establish a p99 in the strict sense. See §3.
./scripts/run-benchmarks.shRuns every benchmark and gates the result against baseline.json.
The gate is blocking on allocations (exact) and budget ceilings, and advisory
on absolute times and ratios — for the reasons in §5.
The report above used a longer run than the script's default:
dotnet run -c Release --project tests/FlowX.Benchmarks -- \
--filter '*' --warmupCount 10 --iterationCount 30See also: ADR-0002 · Benchmark harness · Quality gates · Plan