Skip to content

cpu: make the decode blocktime policy testable and pin its zero case - #1773

Merged
justinchuby merged 2 commits into
mainfrom
seb/blocktime-parse-coverage
Aug 22, 2026
Merged

justinchuby merged 2 commits into
mainfrom
seb/blocktime-parse-coverage

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

What

decode_blocktime() reads ONNX_GENAI_CPU_DECODE_BLOCKTIME_US into a OnceLock and had no test at all. Splits the policy into a pure parse_decode_blocktime(Option<&str>) -> Duration and 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 a OnceLock, so a test doing run(true) then run(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_var out of tests entirely: cargo test runs a binary's tests on parallel threads, and set_var racing another thread's getenv is a data race Rust 2024 forbids outright. Same reasoning as the existing parse_host_copy_threshold / resolve_persistent_decode_threads split.

The load-bearing case is 0

The sibling knob steal_tiles_per_worker does .filter(|&value| value > 0) and treats zero as unset. Blocktime must not: 0 is 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:

mutation test killed
.filter(|v| v > 0) an_explicit_zero_blocktime_parks_immediately_rather_than_taking_the_default
Duration::from_millis for from_micros a_blocktime_is_read_as_microseconds_and_tolerates_surrounding_space
unparseable defaults to Duration::ZERO an_unset_or_unparseable_blocktime_uses_the_default

Rejected spellings covered: "", " ", "abc", "-1", "1.5", "500us", and u64::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 --check clean
  • clippy --all-targets -D warnings clean, and for aarch64-unknown-linux-gnu
  • 1633 EP lib tests pass (3 new)
  • Single file changed; no unsafe touched

Refs #1736

justinchuby and others added 2 commits August 22, 2026 20:00
`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>
@justinchuby

Copy link
Copy Markdown
Owner Author

Adversarial review (Opus) — verdict

Nothing 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 ("+500", "00500", u64::MAX exactly, and non-UTF8 env values where VarError::NotUnicode and NotPresent must collapse identically — they do).

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; Duration::from_micros(u64::MAX) does not panic; and the mutation matrix is a clean diagonal.

One NIT, fixed — and it was a genuine defect

assert_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 "250" assertion in the same test, not by this line.

Worse than useless, it was fragile in the dangerous direction: it coupled the test to the numeric value of DEFAULT_BLOCKTIME, so retuning the default to 600 µs — a change this file exists to enable, and one I expect to make for the park/wake work — would have failed a correct parser. A test that fails a correct implementation is worse than no test; it teaches people to edit tests to make them pass.

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:

mutation killed by
.filter(|v| v > 0) an_explicit_zero_blocktime_parks_immediately...
from_millis for from_micros a_blocktime_is_read_as_microseconds...
unparseable → Duration::ZERO an_unset_or_unparseable_blocktime_uses_the_default
always returns DEFAULT_BLOCKTIME the first two (2 kills)

Still diagonal for the three targeted mutants, still catching the blanket one.

I did keep the redundant assert_ne! unit-pinning lines. The reviewer correctly notes they are logically implied by the preceding assert_eq! — they cost nothing and name the 1000x hazard for the next reader, which the assert_eq! alone does not.

Gates (re-run after the fix)

fmt --check clean · clippy --all-targets -D warnings clean · aarch64-unknown-linux-gnu clippy clean · 1633 EP lib tests pass · single file, no unsafe touched

@codecov

codecov Bot commented Aug 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.20%. Comparing base (4738086) to head (5876267).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            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              
Flag Coverage Δ
cli-ort-linux 72.47% <ø> (ø)
cli-ort-windows 71.97% <ø> (-0.10%) ⬇️
mlas 85.20% <ø> (-0.14%) ⬇️
offline 80.33% <100.00%> (-0.41%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
crates/onnx-runtime-ep-cpu/src/decode_spmd.rs 89.22% <100.00%> (+0.35%) ⬆️

... and 11 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@justinchuby
justinchuby merged commit 9b31fba into main Aug 22, 2026
20 checks passed
@justinchuby
justinchuby deleted the seb/blocktime-parse-coverage branch August 22, 2026 20:26
justinchuby added a commit that referenced this pull request Aug 22, 2026
## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant