Skip to content

test(cpu): bound forced-pool affinity child thread count to fix Windows ARM64 flaky - #117

Merged
justinchuby merged 1 commit into
mainfrom
fix/winarm64-forced-pool-flaky
Jul 24, 2026
Merged

justinchuby merged 1 commit into
mainfrom
fix/winarm64-forced-pool-flaky

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

Summary

Fixes a Windows ARM64 CI flaky (hard subprocess crash, STATUS_ACCESS_VIOLATION / empty stderr) in the persistent-pool affinity tests. Same class as #111.

Root cause

run_affinity_defer_child (test harness in matmul_nbits.rs) hard-coded ONNX_GENAI_CPU_DECODE_THREADS=8 and RAYON_NUM_THREADS=8 for its subprocess children. The forced_off scenario forces the persistent SPMD pool, which spins up ~8 busy-waiting SPMD workers (plus a Rayon pool) inside an already-spawned child test binary. On the low-core native Windows ARM64 runner this oversubscribes CPUs and hard-aborts the child before it can print — surfacing as forced_persistent_pool_ignores_explicit_affinity failing with truncated stdout and empty stderr. It intermittently fails any ep-cpu PR (hit #115 twice in a row).

Fix

Replace the hard-coded "8" with the existing parity_worker_count() helper (largest odd ≤ available_parallelism(), capped at 15, always ≥ 1) — the exact remedy #111 applied to a sibling test. Test-harness only; no product/kernel code changed.

Why the test stays meaningful

  • forced_off still forces the pool (PERSISTENT_POOL=1) and still asserts pools().is_some() (parity_worker_count() ≥ 1 ⇒ a real pool is built).
  • auto_* children still set explicit affinity and assert pools().is_none().
  • No #[ignore], no weakened assertion, no OS/arch skip — only the thread count is bounded to a runner-safe value.

Sibling tests

Two other forced-pool subprocess tests use small hard-coded counts (MLAS-shard 3/3, MLAS-prefill 3/6) — below the oversubscription threshold and not reported flaky; left unchanged to preserve the prefill test's deliberate RAYON > workers row-block invariant.

Gates (Linux x86_64)

  • cargo test -p onnx-runtime-ep-cpu --features mlas — 903 pass; no-mlas pass; clippy clean both; cargo fmt --check clean (only known mlas-sys:1211 false positive); aarch64 cross-check clean.

Reviewed independently 🟡 APPROVE-WITH-NITS (non-author).

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

…ndows ARM64 flaky)

run_affinity_defer_child hard-coded DECODE_THREADS=8 and RAYON_NUM_THREADS=8.
The forced scenario forces the persistent SPMD pool, spinning up ~8 busy-waiting
workers plus an 8-thread Rayon pool inside an already-spawned child test binary.
On the constrained native Windows ARM64 CI runner this oversubscribes the CPU and
hard-crashes the child with an empty-stderr STATUS_ACCESS_VIOLATION (0xC0000005),
intermittently failing forced_persistent_pool_ignores_explicit_affinity.

Reuse the existing parity_worker_count() helper (largest odd count <=
available_parallelism(), capped at 15) for both env vars, mirroring the PR #111 fix
for the sibling odd-worker parity flaky. Test-harness only; no product code changed.
The pool is still forced (parity_worker_count() >= 1 => pools().is_some()) and the
affinity-defer assertions are unchanged, so the test stays meaningful.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.83%. Comparing base (cbfb836) to head (c31f50f).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #117      +/-   ##
==========================================
+ Coverage   82.49%   82.83%   +0.33%     
==========================================
  Files         231      231              
  Lines       99759    99760       +1     
  Branches    99759    99760       +1     
==========================================
+ Hits        82294    82632     +338     
+ Misses      14074    13733     -341     
- Partials     3391     3395       +4     
Files with missing lines Coverage Δ
...es/onnx-runtime-ep-cpu/src/kernels/matmul_nbits.rs 83.70% <100.00%> (+7.84%) ⬆️

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@justinchuby
justinchuby merged commit 7d5ad92 into main Jul 24, 2026
10 checks passed
@justinchuby
justinchuby deleted the fix/winarm64-forced-pool-flaky branch July 24, 2026 20:35
justinchuby added a commit that referenced this pull request Jul 24, 2026
…64 exit crash (#119)

## Summary
Fixes the intermittent **Windows ARM64** `STATUS_ACCESS_VIOLATION`
(0xC0000005, empty stderr) in
`forced_persistent_pool_ignores_explicit_affinity` — the *real* root
cause behind the recurring flaky that #117's thread-cap only partially
mitigated. This is also a genuine robustness bug for real Windows ARM64
users.

## Root cause
The persistent SPMD decode pool lives in a module-level `static
OnceLock<Option<SpmdDecodePools>>`. **Rust never runs `Drop` on statics
at process exit**, so the existing `impl Drop` (which signals shutdown +
joins workers) never ran for the process-wide pool. Its worker threads —
spinning on `sequence` or parked, all holding `Arc<SharedState>` —
stayed alive into CRT teardown; on weakly-ordered Windows ARM64 a worker
touching that shared state during teardown faults the process. The
`forced_off` test child builds the pool, asserts, and returns, leaking N
live workers straight into exit. #117's thread cap only shrank the race
window (fewer live threads), which is why it reduced but didn't
eliminate the crash.

## Fix (product-first)
- `SpmdDecodePools::shutdown(&self)`: sets the stop flag, bumps
`sequence`, **unconditionally unparks all** workers, and **joins** them.
Join handles moved behind a `Mutex<Vec<JoinHandle>>` so it's reachable
through the `&'static` pool and idempotent (drained once). `Drop`
delegates to it.
- `decode_spmd::shutdown_pools()`: peeks the `OnceLock` (never
force-builds) and joins only if the pool was ever built.
- The `forced_off` test child calls `shutdown_pools()` before returning
→ deterministic stop+join before exit, closing the race.

**Deadlock/lost-wakeup safety** (independently verified): unconditional
unpark-all means a worker that just set `parked` can't be missed;
`park_timeout` consumes pending tokens with a 50 ms backstop; every
worker is guaranteed to observe stop and return, so `join()` cannot
hang.

## Hot path / perf
Zero impact. Per-decode dispatch stays lock-free (`publish` unparks via
a plain thread slice); the `Mutex<Vec<JoinHandle>>` is touched only at
build/teardown, never per-decode. No numerics change.

## Test stays meaningful
`forced_off` still forces the pool and asserts `pools().is_some()`;
`auto_*` still assert `pools().is_none()`. `shutdown_pools()` is
teardown hygiene, not a masking retry. Nothing ignored/skipped/weakened.

## Follow-up (out of scope)
`decode_numa::NumaDecodePools` uses the same static pattern and warrants
a peer `shutdown()`; and an optional tripwire so post-shutdown dispatch
panics loudly instead of hanging.

## Gates (Linux x86_64)
mlas 904+10 pass (incl. the affinity test); no-mlas 873+10 pass; clippy
clean both; `cargo fmt --check` clean (only known `mlas-sys:1211` FP);
aarch64 `-D warnings` check clean.

Reviewed independently 🟢 APPROVE (non-author).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

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