Skip to content

MTP verify-dedicated interior arena: both CUDA-graph slots replay (Primary + Verify) - #1658

Merged
justinchuby merged 4 commits into
mainfrom
squad/mtp-verify-arena
Aug 21, 2026
Merged

justinchuby merged 4 commits into
mainfrom
squad/mtp-verify-arena

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

Summary

Gives the fixed M=k+1 speculative-verify forward its OWN executor sibling with a private interior device-buffer arena, unblocking verify CUDA-graph capture+replay, then closes Blocker B so the M=1 Primary decode graph also replays. Both cuda_graph slots now replay on one executor — the campaign's core proof — GPU-validated on the real qwen38-27b-int4 hybrid (GDN+GQA) MTP artifact.

Branches off 39885ffc5 (per-slot executor host capture state, #1654).

What changed

  • Verify-dedicated sibling (build_verify_sibling, Session::verify_exec + verify-sibling run/capture/replay/reset methods): a structural clone of the main graph on the Verify slot, sharing only immutable weights/EP + the persistent external KV/recurrent-state bindings. Its interior [1,M] scratch is independent of the M=1 decode's [1,1] scratch, so the interleaved base decode can no longer resize/clobber the M=2 verify graph (the old Slice [1,2] vs [1,1] decline → eager fallback).
  • Race fix (CUDA-700): the sibling PINS its StepScoped workspace (it only ever runs the fixed M=K shape), so its captured graph's baked scratch pointer is never freed back to the shared EP arena between replays. Reset the verify sibling's graph only at the generation boundary.
  • Blocker B: re-advance accepted tokens one at a time (M=1, state-equivalent, byte-identical) instead of a batched M=num_accepted forward; and enable retain_decode_graph_across_spec when verify-capture arms so the contents-only KV roll-back no longer invalidates the Primary graph.

No hardcoded dims (M derived from num_speculative_tokens+1). Greedy path inert (no verify_width, never hits a non-zero rewind → byte-identical).

Validation (H200, k=1, 83.3% accept, median of 5, ORT 1.28 cuda13, int4 block-32)

Both slots replay, fallbacks=0, invalidations→~0 (every one of 5 runs):

cuda_graph:        captures=4 replays=504 fallbacks=0 invalidations=3
cuda_graph_verify: captures=4 replays=184 fallbacks=0 invalidations=0
  • Token-identical across all 5 runs (deterministic), no NaN, coherent text.
  • MTP throughput: median 20.63 tok/s (up from 15.6 pre-fix).
  • engine lib 579/0 (native-backend), ep-cuda graph::tests 8/8, recurrent-commit + native_verify_logits_require_restored_recurrent_state byte-identity oracles pass.

Honest perf finding (not a speedup)

Graphing the verify was necessary but not sufficient. Approach-A (snapshot→restore→re-advance) redundantly re-runs the accepted tokens on commit, and MTP runs verify(M=2) + per-token re-advance forwards per step for ~2.67 tokens/step — strictly more device work per accepted token than greedy's single graphed M=1 forward on this launch-bound decode. So MTP (20.63 tok/s) still trails the campaign-established greedy baseline (~56 tok/s; corroborated here by Primary replays=504 exactly matching the greedy run). The greedy baseline artifact is out-of-sandbox this session.

Next lever: move to approach-B — thread num_accepted through the verify to SELECT the committed recurrent state from per-position post-states instead of re-scanning, removing the redundant re-advance forwards. The two-slot graphed executor here is the prerequisite.

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

justinchuby and others added 4 commits August 21, 2026 12:34
…erify

Give the fixed M=k+1 MTP verify forward its own executor sibling (structural
clone of the main graph, shares only weights+EP) so its JIT-sized interior
device-buffer arena is independent of the M=1 base decode on the main executor.
This removes the shared-arena clobber (interior Slice [1,1] vs [1,2]) that made
the verify capture decline, and lets the Verify-slot graph coexist with the
Primary M=1 decode graph and replay every step.

- Executor::build_verify_sibling (build.rs): structural clone, graph_slot=Verify.
- Session: verify_exec field + enable/run/try_capture/replay/reset_verify_sibling
  methods; pin_fixed_capacity_kv_capture_symbols also pins the sibling.
- cuda.rs: configure_verify_capture builds the sibling (drops the now-unneeded
  main-exec workspace pin + slot flips); run_verify_graph_phase routes the
  verify forward through the sibling.

Engine lib suite --features native-backend: 579 passed / 0 failed.

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

The verify-dedicated sibling executor's captured M=k+1 verify graph bakes its
StepScoped workspace device pointer. Without pinning, `release_step_workspace`
returned that scratch to the shared EP arena between replays; the interleaved
M=1 decode on the main executor then reserved the same freed slot, so the next
verify replay read reallocated memory -- a nondeterministic CUDA illegal access
(700) at higher token counts / multi-generation runs. The sibling only ever
runs the fixed M=K shape, so its workspace is reserved once at that peak and
never grows: pin it permanently (`build_verify_sibling`) to keep the baked
scratch pointer stable across every replay.

Also reset the verify sibling's captured graph only at the generation boundary
(`rewind_inner` target_len==0, where recurrent/conv states are re-zeroed), not
in the generic per-step `invalidate_graph` (whose Blocker-B teardowns must not
stale the verify, since fixed-capacity KV never moves).

GPU-proven on qwen38-27b-int4-mtp (H200, k=1, 83.3% accept):
  cuda_graph_verify: captures=4 replays=184 fallbacks=0 invalidations=0
Previously-crashing configs (warmups>=2, tokens>=112) now run clean, no NaN,
coherent output. Primary slot still replays=0 (Blocker B) -- separate fix.

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

With the verify running on its own sibling, the M=1 Primary decode graph was
still invalidated every spec step (`cuda_graph: replays=0 invalidations=195`) by
the commit path. Two causes, both fixed:

1. `commit_recurrent_state_to_accepted` re-advanced the accepted tokens with a
   single M=num_accepted batched forward, resizing the shared Primary interior
   arena to [1,num_accepted] and invalidating the captured M=1 graph. The
   recurrent/conv advance is inherently sequential, so re-advance ONE token at a
   time (M=1): state-equivalent (unit tests stay byte-identical) but keeps the
   Primary executor pinned at the [1,1] decode shape so its graph replays.
2. The contents-only KV roll-back (`rewind_inner` at non-zero target_len) inside
   the commit invalidated the Primary graph. That rewind leaves every binding's
   physical_shape/device_ptr fixed, so the M=1 replay signature stays valid —
   the exact case the `retain_decode_graph_across_spec` seam covers. Its old
   caveat (eager verify tears the graph down; unsafe until the verify workspace
   is pinned) is resolved now the verify is a separate workspace-pinned sibling,
   so enable the seam when verify-capture arms. Scoped to recurrent MTP; greedy
   has no verify_width, never hits a non-zero rewind, stays byte-identical.

GPU-proven on qwen38-27b-int4-mtp (H200, k=1, 83.3% accept) — BOTH slots replay:
  cuda_graph:        captures=4 replays=504 fallbacks=0 invalidations=3
  cuda_graph_verify: captures=4 replays=184 fallbacks=0 invalidations=0
Primary invalidations 195->3 (one per generation reset); throughput 15.6->20.7
tok/s; token-identical, no NaN. (Primary replays=504 matches the greedy baseline.)

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby merged commit 66e19c2 into main Aug 21, 2026
8 of 15 checks passed
@justinchuby
justinchuby deleted the squad/mtp-verify-arena branch August 21, 2026 13:36
@codecov

codecov Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.65%. Comparing base (72403a2) to head (96e271e).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             main    #1658       +/-   ##
===========================================
+ Coverage   81.02%   82.65%    +1.62%     
===========================================
  Files         384       12      -372     
  Lines      180358     5477   -174881     
  Branches   180358     5477   -174881     
===========================================
- Hits       146142     4527   -141615     
+ Misses      29271      757    -28514     
+ Partials     4945      193     -4752     
Flag Coverage Δ
cli-ort-linux 82.60% <ø> (ø)
cli-ort-windows 82.11% <ø> (ø)
mlas ?
offline ?

Flags with carried forward coverage won't be shown. Click here to find out more.
see 372 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 added a commit that referenced this pull request Aug 21, 2026
InferenceSession gained a verify_exec field in #1658 but the two
executor/tests.rs InferenceSession initializers were not updated, leaving
`cargo test -p onnx-runtime-session --features cuda` red on main. Add
verify_exec: None to both.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 21, 2026
…vice recurrent snapshot — 20.68→34.34 tok/s, token-identical (#1663)

## Summary
Two GPU-validated, **token-identical** speedups on the real Qwen3.8-27B
int4 hybrid (GDN+GQA) MTP self-speculative decode (k=1, 83.3% accept,
2.67 tok/step). Branch off origin/main \`66e19c2b5\`. Median-of-5 on an
idle H200: **20.68 → 34.34 tok/s (+66%)**.

### 1. approach-B — eliminate the redundant re-advance
On **full accept** (~67% of steps), the eager M=K verify forward already
advanced KV + GDN/conv recurrent state to exactly the committed length,
so the snapshot→restore→re-advance commit is pure redundancy and is
skipped. Partial accept still rebuilds by re-running the accepted prefix
(GDN exposes no intermediate per-position states). **20.68 → 26.73 tok/s
(+29%)**; Primary graph replays 504 → 248.

### 2. device-to-device recurrent snapshot
The per-step recurrent snapshot copied all fixed-state (GDN SSM +
conv1d) bindings device→HOST every spec step (~10ms PCIe round-trip),
unused on full-accept steps. Replaced with a **stream-ordered
device→device** snapshot into a per-binding device scratch arena:
- \`ExecutionProvider::copy_device_to_device\` (default errors; CUDA
overrides with \`cuMemcpyDtoDAsync\` on the EP stream → ordered ahead of
the verify forward, no host sync).
- \`DeviceIoBinding::snapshot_device_into\` / \`restore_device_from\` +
\`allocator()\`; \`DeviceBuffer\` re-exported from onnx-runtime-session.
- \`DecodeCudaState.fixed_state_snapshot_scratch\` lazily allocated,
sized from \`fixed_state_binding_range\` (no hardcoded dims).
\`RecurrentStateSnapshot\` carries a \`device_scratch\` flag; CPU
host-past path unchanged.

**26.73 → 34.34 tok/s.**

Also fixes a pre-existing red on main: \`InferenceSession\` gained
\`verify_exec\` in #1658 but two \`executor/tests.rs\` initializers were
never updated, breaking \`cargo test -p onnx-runtime-session --features
cuda\`.

## Per-step target-forward budget (48 verify steps)
| | base M=1 | re-advance M=1 | verify M=2 | Primary replays |
|---|---|---|---|---|
| before approach-B | 48 | 80 | 48 | 504 |
| after approach-B  | 48 | 16 | 48 | 248 |

D2D snapshot does not change the forward count; it removes the per-step
PCIe D2H state stage.

## Validation (all green)
- **Both slots replay, fallbacks=0:** \`cuda_graph: replays=248
fallbacks=0 invalidations=3\`; \`cuda_graph_verify: replays=184
fallbacks=0 invalidations=0\`.
- **Token-identical:** \`generated_token_ids\` md5 \`be7ed565\` ==
baseline across median-of-5, no NaN.
- **Greedy inert:** engine lib \`--features native-backend\` **579
passed / 0 failed** (snapshot machinery only runs under spec-decode with
recurrent state).
- ep-cuda \`graph::tests\` **8/8**; session \`--features cuda\` green.
- **Median-of-5 A/B** (H200, \`CUDA_VISIBLE_DEVICES=5\`, batch=1,
tokens=128, warmups=3, int4 block-32, ORT 1.28 cuda13, \`--release
--features bench-native,native-cuda,cuda-13000\`, \`profile_native
--steady\`): **34.34 tok/s** (34.29–34.36).

## Honest remaining gap
Still below the ~56 tok/s greedy baseline. The structural lever left is
**fusing out the separate M=1 base decode** (per-step cost → a single
M=2 verify forward). Blocked by a circular dependency (MTP head needs
the hidden after the last committed token, only produced by the base
decode); solvable via EAGLE-style frontier-hidden reseeding while
staying token-identical, but the speedup hinges on preserving acceptance
rate through the changed seeding — deferred as the next, riskier turn.
approach-B + D2D are landed and durable regardless. Validated by unit
tests + GPU token-identity; end-to-end campaign number reported
honestly.

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