Skip to content

Latest commit

 

History

History
252 lines (186 loc) · 10.8 KB

File metadata and controls

252 lines (186 loc) · 10.8 KB

P0 kill criterion — the result

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


1. What was being decided

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.


2. Hardware, and why it is disclosed first

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.


3. B1 — flow overhead

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 compensationB1 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.

On "p99"

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.


4. B2 — allocations

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.


5. Is shared hardware good enough to decide on?

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.


6. Does the measurement support ADR-0002's actual argument?

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.


7. Verdict

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.

What this does not say

  • 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.

8. Reproducing this

./scripts/run-benchmarks.sh

Runs 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 30

See also: ADR-0002 · Benchmark harness · Quality gates · Plan