Skip to content

fix(spec-decode): capture-stable graph slot + GQA workspace + M=1 base contract (#984 re-review) - #988

Closed
justinchuby wants to merge 8 commits into
mainfrom
squad/spec-decode-graphslot
Closed

justinchuby wants to merge 8 commits into
mainfrom
squad/spec-decode-graphslot

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

Speculative-decode capture-stability fix (#984 re-review)

Fixes the two capture-stability bugs and the greedy contract that Gaff and Chew rejected in #984, on the opt-in captured-verify speculative path. The glm path already worked; this is a targeted fix, not a rewrite. Feature stays opt-in (ONNX_GENAI_SPEC_CAPTURED_VERIFY=1), default-OFF byte-identical to main. Refs #957, #984.

⚠️ This task was flagged as "needs review" — author is locked out of further fixes per reviewer-protocol, so it is written to be airtight.

Bugs fixed

  • BUG 1 (Gaff) — miss-path graph-slot hazard. M=1 base-decode and M=width verify share the session's single device-graph slot. New invalidate_graph_for_mode_switch() drops the slot + resets both graph phases and the verify phase to NeedsWarmup; the driver tracks prev_engaged and invalidates only on an engage↔miss transition, before the incoming forward. No path can replay a stale/foreign graph; consecutive same-mode steps still replay at full speed. Kills the glm "invalidated graph replay" + qwen CUDA_ERROR_ILLEGAL_ADDRESS.
  • BUG 2 (Chew) — GQA workspace under-prep. The persistent GQA workspace is sized by query width and prepared only at prefill for q_seq=prompt_len. When prompt_len < W, the width-W verify entered undersized (workspace invariant mismatch: requires 184320, prepared 163840). run_verify_captured_cuda now reserves the workspace for q_seq=W before warming/capturing.
  • CONTRACT (Chew, binding) — base token from M=1 GEMV. Fusion sourced row 0 from Marlin M=W, so at a genuine near-tie the base argmax could flip vs the M=1 GEMV. New row-0 near-tie classifier (ONNX_GENAI_SPEC_ROW0_TIE_EPS, default 1.0): on a base-row near-tie, rewind the fused fold and recompute the base token from a fresh M=1 GEMV decode → byte-identical to plain greedy.

Approach: invalidate-both (mode-transition), not a second graph slot

Landed the coordinator-sanctioned fallback. A real second device-graph slot needs changes to the shared ExecutionProvider graph trait (implemented by the CPU EP too), the runtime's single CudaGraphLifecycle, graph-exec-handle multiplexing, and per-slot session state — deep, cross-cutting, capture-stability-risky. The reviewers required correctness, which the mode-transition fix delivers with a small auditable blast radius. Second slot is documented as a scoped, perf-only follow-on (removes the miss↔engage re-warm tax).

Validation (GPU7, H200, verified idle)

Gate Result
f64 oracle matmul_nbits_marlin_numerics 7/7
qwen degenerate (哈×20) no crash ≥160 steps ✅ 160 tok, 79 verify, no illegal-addr / workspace mismatch
spec == plain greedy — glm generic ✅ byte-identical (gate on & off)
spec == plain greedy — glm repetitive ✅ byte-identical
spec == plain greedy — qwen degenerate ✅ byte-identical (near-tie flip closed)
favorable win (glm repetitive) ✅ 326 tok/s (2.88×, past ORT 250), 97% accept
generic no re-warm regression ✅ gate disengages, byte-identical, no crash
default-OFF byte-identical ✅
clippy -D warnings / fmt ✅ (except 2 pre-existing platform_capacity.rs casts)

Tests added

  • native_captured_verify_engage_miss_reengage_no_stale_replay_cuda — BUG 1 (engaged, byte-identical, no illegal-address).
  • native_captured_verify_short_prompt_grows_gqa_workspace_cuda — BUG 2 (qwen-14b, 160 tok, 79 verify, no workspace mismatch).
  • 6 fast unit tests for the row-0 near-tie classifier.

Decision drop: .squad/decisions/inbox/deckard-specfix-graphslot.md.

justinchuby and others added 8 commits August 15, 2026 00:51
…e decode

Extract the greedy speculative accept decision into a pure, unit-tested
`greedy_accept` with a `TieGuard`. The default (TieGuard::STRICT, eps=0) is
byte-identical to plain greedy: commits are always the strict argmax. When
ONNX_GENAI_SPEC_TIE_EPS>0 the guard detects draft tokens that are near-tie
co-winners of the argmax (logit within eps of the row max) and either counts
them (diagnostic, default) or, with ONNX_GENAI_SPEC_TIE_TOLERANT=1, accepts
them to recover acceptance on tie-prone (block-32) models. Surface
`near_tie_rejections` in SpeculativeStats and profile_native observability.

Refs #957 (Stage 4 speculative-decode e2e).

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

Add the CUDA-graph-captured verify path (WP4 of #957): a persistent padded
[1, width] token/position/logits binding set and a warm->arm->capture->replay
state machine (DecodeCudaState::verify_capture + decode_verify_captured),
modelled on run_one_token and the Increment-0 capture probe. Graceful eager
fallback on NotCapturable keeps correctness independent of capture.

Session surface: set_retain_graph_on_rewind / is_cuda / reset_captured_verify;
rewind(0) tears down the retained verify graph before freeing its buffers.

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

Re-adds the CudaKvDebugStats struct header dropped when inserting
logits_bytes_to_rows, converts the read_bytes SessionError into anyhow, and
wires the prompt-lookup driver's fused captured-verify branch behind
ONNX_GENAI_SPEC_CAPTURED_VERIFY (propose-before-forward, retained graph,
row0=base / rows1..=k=verify, rewind to accepted prefix).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Completes the Stage-4 captured M=width verify path for native speculative
decode (env ONNX_GENAI_SPEC_CAPTURED_VERIFY, requires ONNX_GENAI_MARLIN_M_GT_1):

* Fixed-width fused forward: propose-before-forward, run one [1,1+draft_width]
  captured graph (row 0 = base distribution, rows 1..=k = verify rows), then
  rewind to the accepted prefix. A single constant-signature graph occupies the
  shared session slot so it CAPTURES once and REPLAYS across steps.
* Empty-draft (lookup miss) runs a cheap M=1 eager base forward
  (decode_base_eager_keep_verify_graph) instead of paying the ~2.2x width-W
  verify cost for a single token. Running an eager forward alongside an INSTALLED
  device graph corrupts memory (illegal address), so the miss path invalidates
  and the next hit re-warms — the single-graph-slot cost that caps realistic
  prompts (a dedicated second graph slot is the scoped follow-on).
* Threaded a preserve_graph flag through the eager row helpers.

Correctness (byte-identical to plain greedy) verified: glm-4-9b-int4 and
qwen2.5-14b-int4 captured-spec token streams == non-spec, CUDA smoke test green,
6 accept-guard unit tests pass. GPU7 (H200), steady tokens>=160.

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The fused captured-verify path won 1.85-1.93x on high-acceptance prompts
but REGRESSED generic prose to ~0.60x: on realistic prompts most steps
miss the prompt-lookup, and an isolated hit re-warms the width-W verify
graph (a fresh capture, not a replay) to commit only B tokens at ~2.2x
the M=1 cost -- a net loss that never amortizes.

Two fixes, both preserving byte-identity:

1. HitDensityGate: the n-gram proposal is a cheap CPU search, so always
   propose, but only ENGAGE the width-W captured verify once recent
   would-hit density (sliding 16-step window, >=50% default) predicts the
   warmed graph will replay repeatedly. Sparse hits stay on the plain
   decode floor; clustered hits get the full 1.9x win. Env-tunable
   (ONNX_GENAI_SPEC_GATE{,_WINDOW,_MIN_HITS}); GATE=0 restores always-engage.

2. Gated-out / miss steps now run the normal M=1 CAPTURED decode
   (device-graph replay = full plain baseline speed) instead of an M=1
   EAGER forward, then reset the verify phase so the next engaged step
   re-warms cleanly. `decode` invalidates the slot on signature mismatch
   before launching any kernel, so it is safe alongside a stale width-W
   graph. This removes the residual eager-vs-replay tax on miss-heavy
   prompts (generic prose 81 -> 104 tok/s, vs 111 non-spec baseline).

Result (glm-4-9b-int4, H200 GPU7, steady): generic prose 66.8 -> 104
tok/s (no catastrophic regression); repetitive 347 tok/s (beats ORT 250);
gate costs ~2% on fully-favorable prompts. glm spec == plain greedy
byte-identical; qwen control byte-identical on non-degenerate prompts
(pre-existing Marlin-row0-vs-M1-GEMV near-tie divergence in qwen's
degenerate repetition loop is unchanged by this commit).

Removes the now-obsolete decode_base_eager_keep_verify_graph and the
always-false preserve_graph plumbing on run_cuda_eager_rows. Adds 6
HitDensityGate unit tests; CUDA smoke test disables the gate to exercise
the engage path on its short fixture.

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…+ M=1 base contract (#984 re-review)

Fixes the two capture-stability bugs and the greedy contract that Gaff and Chew
rejected in #984, on the opt-in captured-verify speculative path.

- BUG 1 (Gaff): the M=1 base-decode graph and the M=width captured-verify graph
  share the session's single device-graph slot. Track prev_engaged and invalidate
  the slot (both graph phases + verify phase -> NeedsWarmup) on an engage<->miss
  TRANSITION, before the incoming forward -- so an M=1 decode can never replay a
  stale width-W verify graph (glm "invalidated graph replay" / qwen
  CUDA_ERROR_ILLEGAL_ADDRESS). Consecutive same-mode steps still replay at full
  speed. New NativeDecodeSession::invalidate_graph_for_mode_switch().

- BUG 2 (Chew): the persistent GroupQueryAttention workspace is sized by query
  width and prepared only at prefill for q_seq=prompt_len. When prompt_len < W the
  width-W verify entered undersized ("workspace invariant mismatch"). Reserve the
  workspace for q_seq=W in run_verify_captured_cuda before warming/capturing.

- CONTRACT (Chew, binding): spec output must equal plain greedy (base token from
  the M=1 GEMV), not greedy-under-Marlin. Add a row-0 near-tie classifier
  (ONNX_GENAI_SPEC_ROW0_TIE_EPS, default 1.0): on a base-row near-tie, rewind the
  fused fold and recompute the base token from a fresh M=1 GEMV decode, so the
  committed base token is byte-identical to plain greedy.

Chose the coordinator-sanctioned invalidate-both fallback over a dedicated second
graph slot (documented as scoped perf-only follow-on).

Validation (GPU7 H200): f64 oracle 7/7; qwen degenerate no crash 160 steps/79
verify; spec==plain byte-identical on glm generic+repetitive and qwen degenerate;
glm favorable 326 tok/s (2.88x, past ORT); generic no re-warm crash; default-OFF
byte-identical. New CUDA regressions (engage/miss/re-engage; qwen workspace
growth) + 6 row-0 classifier unit tests. fmt+clippy clean (except 2 pre-existing
platform_capacity casts).

Feature stays opt-in (ONNX_GENAI_SPEC_CAPTURED_VERIFY=1), default-OFF.
Refs #957, #984.

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

codecov Bot commented Aug 15, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.47%. Comparing base (532ef6b) to head (cfa96c5).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #988      +/-   ##
==========================================
+ Coverage   78.99%   79.47%   +0.47%     
==========================================
  Files         356      356              
  Lines      150211   150211              
  Branches   150211   150211              
==========================================
+ Hits       118661   119375     +714     
+ Misses      26897    26179     -718     
- Partials     4653     4657       +4     
Flag Coverage Δ
cli-ort-linux 83.52% <ø> (ø)
cli-ort-windows 83.11% <ø> (+0.09%) ⬆️
mlas 80.87% <ø> (-0.10%) ⬇️
offline 79.29% <ø> (+0.50%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 5 files with indirect coverage changes

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

@github-actions

Copy link
Copy Markdown

🔴 Benchmark Regression Detected

Comparison of criterion micro-benchmarks: PR head vs merge-base, measured on the same runner in the same job (base first → PR second).

ℹ️ Absolute times are informational only — they vary with runner load. The % change column is the reliable signal because both sides ran under identical conditions.

Status Scenario Base PR Change
🔴 matmul/medium_generic_bf16_threads=8/32x512x512 370.75 µs 877.29 µs +136.6%
🔴 block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 575.14 µs 1.08 ms +87.1%
🔴 block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 45.11 µs 77.47 µs +71.7%
🔴 matmul/medium_generic_f16_threads=8/32x512x512 29.68 µs 50.50 µs +70.2%
🔴 matmul/medium_generic_f32_threads=8/32x512x512 922.37 µs 1.50 ms +62.2%
🔴 matmul/small_generic_bf16_threads=8/1x256x256 31.54 µs 47.73 µs +51.3%
🔴 matmul/small_generic_f16_threads=8/1x256x256 29.65 µs 44.15 µs +48.9%
🔴 matmul/large_generic_f32_threads=8/32x1024x1024 3.70 ms 5.44 ms +46.8%
🔴 block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 49.48 µs 70.62 µs +42.7%
🔴 matmul/small_generic_bf16_threads=1/1x256x256 31.65 µs 45.03 µs +42.3%
🔴 matmul/small_generic_f32_threads=8/1x256x256 38.80 µs 53.31 µs +37.4%
🔴 block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 382.87 µs 507.90 µs +32.7%
⚠️ matmul/medium_generic_bf16_threads=1/32x512x512 528.50 µs 686.08 µs +29.8%
⚠️ gather/large_bf16_threads=1-internal/131072 11.22 µs 14.24 µs +26.9%
⚠️ matmul/small_generic_f16_threads=1/1x256x256 30.98 µs 39.17 µs +26.4%
⚠️ gather/medium_f16_threads=1-internal/32768 2.60 µs 3.25 µs +24.8%
⚠️ matmul/large_generic_f16_threads=1/32x1024x1024 79.42 µs 94.47 µs +18.9%
⚠️ reduce_mean/small_f32_threads=1-internal/4096 15.96 µs 18.85 µs +18.1%
⚠️ matmul/large_generic_f32_threads=1/32x1024x1024 9.33 ms 10.91 ms +16.9%
✅ matmul/medium_generic_f32_threads=1/32x512x512 2.33 ms 2.67 ms +14.7%
✅ reduce_mean/medium_f32_threads=1-internal/65536 260.23 µs 298.42 µs +14.7%
✅ block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 77.92 µs 88.50 µs +13.6%
✅ matmul/large_generic_f16_threads=8/32x1024x1024 93.43 µs 103.13 µs +10.4%
✅ gather/medium_bf16_threads=1-internal/32768 2.75 µs 3.00 µs +9.0%
✅ matmul/small_generic_f32_threads=1/1x256x256 42.22 µs 45.73 µs +8.3%
✅ add/medium_f32_threads=1-internal/262144 2.56 ms 2.74 ms +7.1%
✅ matmul/medium_generic_f16_threads=1/32x512x512 30.53 µs 32.45 µs +6.3%
✅ logit_processing/seven_processor_chain_per_step 349.80 µs 370.33 µs +5.9%
✅ gather/small_f32_threads=1-internal/4096 741.6 ns 784.6 ns +5.8%
✅ gather/small_bf16_threads=1-internal/4096 485.4 ns 500.2 ns +3.1%
✅ matmul/large_generic_bf16_threads=8/32x1024x1024 1.40 ms 1.44 ms +2.6%
✅ qwen3_sampling_processors/top_p_fast_after_top_k 517.60 µs 528.28 µs +2.1%
✅ qwen3_sampling_processors/top_k_top_p_full_sort_baseline 5.68 ms 5.80 ms +2.1%
✅ add/small_f16_threads=1-internal/1024 13.89 µs 14.11 µs +1.6%
✅ qwen3_sampling_processors/top_k_full_sort_baseline 2.13 ms 2.17 ms +1.6%
✅ sampling_latency/top_k_per_token 52.02 µs 52.71 µs +1.3%
✅ sampling_latency/greedy_per_token 3.19 µs 3.23 µs +1.0%
✅ sampling_latency/min_p_per_token 208.09 µs 209.46 µs +0.7%
✅ add/medium_f16_threads=1-internal/262144 2.76 ms 2.77 ms +0.3%
✅ qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline 3.52 ms 3.53 ms +0.3%
✅ gather/large_f16_threads=1-internal/131072 14.10 µs 14.09 µs -0.1%
✅ tokenization/decode_tokens_per_second 6.17 ms 6.16 ms -0.2%
✅ sampling_latency/top_p_per_token 379.95 µs 377.72 µs -0.6%
✅ reduce_mean/large_f32_threads=1-internal/262144 1.16 ms 1.16 ms -0.6%
✅ gather/small_f16_threads=1-internal/4096 487.4 ns 483.2 ns -0.9%
✅ qwen3_sampling_processors/top_k_partial_selection 140.35 µs 138.52 µs -1.3%
✅ kv_cache/alloc_dealloc_pages 40.30 µs 38.56 µs -4.3%
✅ add/large_bf16_threads=1-internal/4194304 54.04 ms 51.63 ms -4.5%
✅ add/small_bf16_threads=1-internal/1024 14.39 µs 13.74 µs -4.5%
✅ qwen3_sampling_processors/top_k_top_p_fast 693.83 µs 660.48 µs -4.8%
✅ gather/medium_f32_threads=1-internal/32768 4.56 µs 4.34 µs -4.9%
✅ add/small_f32_threads=1-internal/1024 222.0 ns 210.3 ns -5.3%
✅ tokenization/encode_tokens_per_second 419.65 µs 395.36 µs -5.8%
✅ add/medium_bf16_threads=1-internal/262144 3.22 ms 2.91 ms -9.8%
✅ grammar_masking/llguidance_compute_mask/32 87.31 µs 76.97 µs -11.8%
✅ matmul/large_generic_bf16_threads=1/32x1024x1024 2.41 ms 2.06 ms -14.4%
🟢 add/large_f32_threads=1-internal/4194304 50.27 ms 42.25 ms -16.0%
🟢 gather/large_f32_threads=1-internal/131072 46.23 µs 35.27 µs -23.7%
🟢 add/large_f16_threads=1-internal/4194304 55.62 ms 42.20 ms -24.1%

Visual flags: ⚠️ ≥ 15% slower, 🔴 ≥ 30% slower — calibrated against measured runner noise (~27% worst-case on multi-threaded matmul)

Host info
CPU: Apple M1 (Virtual)
Cores: 3
OS: Darwin 25.5.0 arm64
Rust: rustc 1.97.1 (8bab26f4f 2026-07-14)
Load avg: { 1.84 2.87 4.44 }
What this cannot catch
  • Regressions in code paths not covered by these benchmarks (e.g., end-to-end decode with a real model)
  • Sub-threshold regressions that compound over multiple PRs
  • Performance changes that only manifest under GPU execution
  • Latency changes in the ORT integration path (these benchmarks exercise the native Rust kernels)

justinchuby added a commit that referenced this pull request Aug 15, 2026
Scribe consolidation: merge 7 decision drops (#984/#986/#988 reviews +
wide-load design) into decisions.md, refresh stale now.md to current
base-decode-192/ORT-1.30x reality, add session+orchestration logs.
.squad/-only.

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

Copy link
Copy Markdown
Owner Author

Closing as part of the spec-decode wrap-up. The captured-speculative-decode investigation reached a definitive, evidence-backed conclusion (see PR #1006 and .squad/decisions.md):

  • Byte-identity IS achievable and capture-safe — the blocker all prior attempts hit was that the reference was non-deterministic (greedy with Marlin-prefill ON); against the deterministic Marlin-OFF reference, a batched fp32-accumulate verify GEMV is byte-identical (qwen 5/5).
  • But speedup is fundamentally unwinnable for prompt-lookup drafting — an M=W verify is inherently ~M× cost (attention + 152k-vocab lm_head + MLP all scale with rows), and prompt-lookup acceptance (2.4–3.3) is far below the break-even (>4.5).

Spec-decode is therefore parked, not abandoned: it ships as an opt-in determinism mode in #1006, and would only pay off as a speed feature with a real draft model or batch>1 (plus its own device-graph slot — the idea in this PR). The branch is preserved for that future revival. Full rationale is in the decision log.

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