Repository navigation
cpu: make the decode blocktime policy testable and pin its zero case - #1773
Conversation
`decode_blocktime()` reads `ONNX_GENAI_CPU_DECODE_BLOCKTIME_US` into a `OnceLock` on first `worker_wait` and had no test at all. That combination is the latched-control defect from #1736: a future test that set the variable and called the accessor twice would compare the first latched value against itself, pass, and measure nothing. It is also the knob a gap-aware park/wake harness has to sweep, so the vacuity would have landed exactly where it does most damage. Splits the policy into a pure `parse_decode_blocktime(Option<&str>)`, matching `parse_host_copy_threshold` and `resolve_persistent_decode_threads` in this codebase. A pure function cannot degrade that way, and it avoids `set_var` in tests entirely -- `cargo test` runs a binary's tests on parallel threads, where `set_var` racing another thread's `getenv` is a data race Rust 2024 forbids. No behaviour change: same accepted spellings, same fallback. The load-bearing case is `0`. The sibling knob `steal_tiles_per_worker` filters `> 0` and treats zero as unset; blocktime must not, because `0` is the setting that makes a worker park without spinning -- the maximally-polite mode used to measure park/wake latency. Folding it into the 500us default would give every such run a spin window and make the measurement impossible while still looking like it worked. Each test verified to fail under its own defect and no other: filter(|v| v > 0) -> an_explicit_zero_blocktime_parks_immediately... from_millis instead of micros -> a_blocktime_is_read_as_microseconds... unparseable defaults to ZERO -> an_unset_or_unparseable_blocktime_uses_the_default Three mutants, three distinct kills, no cross-talk. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`assert_eq!(parse_decode_blocktime(Some("500")), DEFAULT_BLOCKTIME)` passed
only because DEFAULT_BLOCKTIME happens to be 500us. It had no discriminating
power -- the always-returns-default mutant is caught by the "250" assertion in
the same test, not by this line -- and it coupled the test to the default's
numeric value, so retuning the default to 600us would have failed a *correct*
parser. A test that fails a correct implementation is worse than no test.
Mutation matrix re-verified after removal: M1/M2/M3 still kill exactly one test
each, and an always-returns-default mutant is still killed by two. No coverage
lost.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adversarial review (Opus) — verdictNothing BLOCKING. The reviewer verified the "no behaviour change" claim empirically rather than by inspection, by extracting old and new logic into a standalone harness and running every input class. Zero mismatches, including classes I had not enumerated ( Also confirmed: the string literal appears nowhere else in the tree (const definition plus one doc mention), character-for-character identical, so there is no silent-disable typo; One NIT, fixed — and it was a genuine defectassert_eq!(parse_decode_blocktime(Some("500")), DEFAULT_BLOCKTIME);I had written this thinking it pinned "an explicit value equal to the default still parses". It does not, and cannot: the two outcomes are indistinguishable by construction. It passed for a parser that always returns the default — the reviewer confirmed that mutant is actually caught by the Worse than useless, it was fragile in the dangerous direction: it coupled the test to the numeric value of Same class as @roy's recycled-address falsifier in #1738, which he demoted from an assertion to a report because under ASan/valgrind/musl it would have failed a correct implementation. Different axis, identical failure mode. Removed. Mutation matrix re-verified after removal, and no coverage was lost:
Still diagonal for the three targeted mutants, still catching the blanket one. I did keep the redundant Gates (re-run after the fix)
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1773 +/- ##
==========================================
- Coverage 80.59% 80.20% -0.39%
==========================================
Files 411 411
Lines 199146 199176 +30
Branches 199146 199176 +30
==========================================
- Hits 160495 159751 -744
- Misses 33208 33982 +774
Partials 5443 5443
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
## Why The persistent decode pool's wait policy — spin for the blocktime window, then futex-park — is observable today only as **wall time** and **process-wide `sys` time**. Both are contaminated by anything else on the host, and (worse) by a gap-aware harness's own gap generator. So "did the worker spin through that gap, or park and pay a futex wake?" could only be *inferred* from a timing difference, never asserted. `task_runtime` has had `PoolCounters` (`parks`, `spin_hits`, …) for exactly this reason. `decode_spmd` — the pool production decode actually runs on, and the one that owns `ONNX_GENAI_CPU_DECODE_BLOCKTIME_US` — had nothing. This is a prerequisite for tuning the blocktime window at all. #1773 made the knob's *parsing* testable; this makes its *effect* observable. ## What - `SpmdCounters { dispatches, inline_dispatches, spin_hits, parks, spurious_wakes, dispatcher_yields }` - `SpmdDecodePools::counters()` and a non-forcing module-level `decode_spmd::counters()` beside `decode_width()` — it **peeks**, like `decode_width` does, because an instrument that builds the pool changes the path it is measuring. ### The identity that makes them assertable without timing `parks` is counted **when the observation completes**, not when the worker goes to sleep. That makes ``` spin_hits + parks == dispatches * spawned_workers ``` exact at any point with no dispatch in flight. Counting at sleep time instead passes every other check here (each gap's park pairs 1:1 with the op that follows it) and breaks only when the pool parks with nothing left to wake it — which is the state a served process sits in between requests. One of the tests pins exactly that. ### Cost Per-worker and cache-line padded, not shared. At ~400 barriers/token a single shared line would take ~6400 contended RMWs per token at width 16, and this module already measured what that costs: sharing a line with `dispatching` cost ~60% of dispatch latency. Measured on an empty barrier — the most overhead-exposed shape there is — with interleaved launches, median ns/dispatch over 6 (width 4) and 4 (width 16) launches per arm, pinned to physical cores: | width | instrumented | base | delta | |---|---|---|---| | 4 | 950.1 | 977.7 | −2.8% | | 16 | 2233.5 | 2258.5 | −1.1% | The instrumented arm is *faster* at both widths. A counter cannot cause that, so the difference is build layout and the real cost sits below it. An **A/A null arm** (same binary, two names, interleaved) bounds run-to-run noise at **0.5%**, which is how we know −2.8% is a between-build effect and not variance — without that control the table would say nothing. ## Building it found a real hole When a publish lands **between a worker's window expiring and the worker actually sleeping**, the observation belongs to neither phase's obvious branch and was silently dropped. It survived both the back-to-back and the long-gap tests — they sit far from the boundary — and only appeared when the full test binary ran in parallel and widened the race. It is now counted as a spin hit (no wake was paid, which is the distinction the pair exists to draw) and guarded by a test that dispatches at *exactly* the window. That gap is also where a park/wake tuning sweep spends most of its time, so the counters have to be exact there or the sweep measures its own instrument. ## Tests Five, each verified against its own mutant (each mutant killed; the boundary test is the unique and deterministic killer of the bug above): | test | timing-dependent? | |---|---| | every observation is exactly one spin hit or one park | **no** | | a re-entrant dispatch is declined inline rather than racing the barrier | **no** | | an idle gap far past the window parks, and an idle pool with nothing to wake it does not count the sleep as an observation | yes (20x margin) | | a wake carrying no new op is spurious and loses nothing | yes | | dispatching at the window boundary keeps the accounting exact | **no** (fails in 0.02s on an idle host) | The complementary direction — "back-to-back dispatches never park" — is deliberately **not** asserted: that one really can fail on a loaded runner, because a descheduled worker's window expires while it is off-CPU. Asserting it would be a flake, not a guard. ## Scope No behaviour change beyond the counters. Full `onnx-runtime-ep-cpu` lib suite green 4x (1642 tests), clippy `-D warnings` clean, no new rustdoc warnings. Draft until review. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
What
decode_blocktime()readsONNX_GENAI_CPU_DECODE_BLOCKTIME_USinto aOnceLockand had no test at all. Splits the policy into a pureparse_decode_blocktime(Option<&str>) -> Durationand adds three mutation-verified tests. No behaviour change — same accepted spellings, same fallback.Why now
Prompted by @roy's #1736 finding, where
half_decode_gemv_enabled()caches in aOnceLock, so a test doingrun(true)thenrun(false)latches on the first call and both arms execute the same route — an A/B that has been asserting a thing equals itself.decode_blocktime()is the same shape, one step earlier: the latch is there, the vacuous test simply hasn't been written yet. And it is precisely the knob a gap-aware park/wake harness has to sweep, so the vacuity would have landed exactly where it does the most damage — on the measurement it exists to enable.A pure function cannot degrade that way. It also keeps
set_varout of tests entirely:cargo testruns a binary's tests on parallel threads, andset_varracing another thread'sgetenvis a data race Rust 2024 forbids outright. Same reasoning as the existingparse_host_copy_threshold/resolve_persistent_decode_threadssplit.The load-bearing case is
0The sibling knob
steal_tiles_per_workerdoes.filter(|&value| value > 0)and treats zero as unset. Blocktime must not:0is the one setting that makes a worker park without spinning — the maximally-polite mode used to measure park/wake latency. Folding it into the 500 µs default would silently give every such run a spin window and make the measurement impossible while still appearing to work.Nothing pinned that before this PR. A future edit copying the sibling's
.filter()would have been a plausible, review-passing change.Mutation results
Each test verified to fail under its own defect and no other — three mutants, three distinct kills, no cross-talk:
.filter(|v| v > 0)an_explicit_zero_blocktime_parks_immediately_rather_than_taking_the_defaultDuration::from_millisforfrom_microsa_blocktime_is_read_as_microseconds_and_tolerates_surrounding_spaceDuration::ZEROan_unset_or_unparseable_blocktime_uses_the_defaultRejected spellings covered:
""," ","abc","-1","1.5","500us", andu64::MAX + 1. The unit is pinned against both a 1000x-up and a 1000x-down misreading, either of which every other assertion would have passed.Validation
cargo fmt --checkcleanclippy --all-targets -D warningsclean, and foraarch64-unknown-linux-gnuunsafetouchedRefs #1736