Skip to content

perf(mtp): per-slot executor host capture state (foundation for two-slot verify replay) - #1654

Merged
justinchuby merged 3 commits into
mainfrom
squad/mtp-perslot-capture
Aug 21, 2026
Merged

justinchuby merged 3 commits into
mainfrom
squad/mtp-perslot-capture

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

Per-slot executor host capture state (foundation for two-slot MTP verify replay)

Makes the Executor's host-side CUDA-graph capture state per graph slot so the M=1 Primary
decode graph and the M=k+1 Verify speculative-verify graph can coexist on one executor.

Changes (coordinator's 4-item scope + test)

  1. DeviceGraphSlot::{COUNT=2, index()} (Primary=0, Verify=1) in ep-api.
  2. Extracted the 8 capture fields into SlotCaptureState; Executor holds
    slot_capture: [SlotCaptureState; 2] with cap()/cap_mut() indexed by graph_slot.
    buffer_shapes/if_last_predicate/control_flow_output_values stay shared per-run scratch.
  3. set_graph_slot is now a non-resetting pure retarget.
  4. Dropped the enable_decode_inline() gate on configure_verify_capture; main exec stays on
    Primary for M=1 decode, run_verify_captured flips to Verify around the verify forward.
  5. Removed dead set_retain_decode_graph_across_spec/retain_decode_graph_across_spec accessors.
  6. Regression test set_graph_slot_is_non_resetting_and_per_slot_isolated (greedy uses only
    Primary=index0; Primary marker survives a Verify round-trip, no cross-slot bleed).

Byte-identity / gates

  • Greedy uses only slot Primary (index 0) → bookkeeping byte-identical to the historical
    single-field layout (proven by the new test).
  • Engine lib suite --features native-backend: 579 passed / 0 failed (greedy inert, oracle
    native_verify_logits_require_restored_recurrent_state green).
  • ep-cuda graph::tests --features cuda,cuda-13000: 8/8.
  • GPU (H200, int4 block-32, ORT 1.28 cuda13, median-of-5, 128 tokens): MTP clean, no NaN,
    83.3% acceptance, 14.67 tok/s; verify now ARMS+DECLINES gracefully (was inert in MTP verify-capture infra (option-c): capture-safe, gated on decode-inline sibling #1652).

Remaining gap (GPU-proven, honest — no speedup yet)

Per-slot host state is necessary but not sufficient for verify replays>0. The Verify capture
declines because the interior device buffers arena is SHARED: the M=1 decode JIT-sizes interior
scratch to [1,1], so the M=2 verify capture errors Slice output shape [1,2], expected [1,1] →
graceful eager fallback (cuda_graph_verify: captures=0 replays=0 fallbacks=1). Next lever = a
verify-dedicated device buffer arena / verify executor so interior scratch is independent of the
interleaved M=1 decode. Blocker B (separate/pre-existing): Primary M=1 decode is invalidated by
the variable-M commit_recurrent_state_to_accepted re-advance sharing Primary bindings.

Validated by unit tests + GPU no-regression; end-to-end replays>0 pending the verify-dedicated arena.

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

justinchuby and others added 3 commits August 21, 2026 12:07
Make the executor's host-side captured-graph bookkeeping per-slot so the M=1
Primary decode graph and the M=k+1 Verify speculative-verify graph can be
captured and replayed independently on ONE executor, instead of sharing a single
signature/schedule that the M=1 decode clobbered every verify step (the
replays=0 MTP blocker).

onnx-runtime-ep-api: DeviceGraphSlot::{COUNT, index()} (Primary=0, Verify=1).

onnx-runtime-session:
- New SlotCaptureState struct holding the 8 host capture fields
  (device_graph_signature, capture_schedule, capture_segmentation,
  capture_cf_shapes, capture_warm_signature, capture_warm_shapes,
  capture_warm_seeded, capture_quarantine_ops). Executor now holds one per slot
  in `slot_capture: [SlotCaptureState; COUNT]`, indexed by graph_slot via
  cap()/cap_mut(). buffer_shapes/if_last_predicate stay per-run scratch.
- set_graph_slot is now a pure retarget (NO reset of the other slot) — switching
  slots keeps each slot's installed graph, the precondition for coexistence.
- All capture/replay/seed sites in bindings/run/capture/mod/tests route through
  cap()/cap_mut(). Primary maps to index 0, so greedy (Primary-only) bookkeeping
  is byte-identical to the historical single-field layout.

onnx-genai-engine:
- Drop the enable_decode_inline() gate on configure_verify_capture: verify
  capture now arms on sibling-less GDN models too (both graphs coexist safely).
- configure_verify_capture no longer permanently retargets the main exec to
  Verify; it only pins the StepScoped workspace. run_verify_captured flips the
  main exec to Verify around the verify forward and back to Primary after, so
  M=1 decode captures into Primary and the verify into Verify.
- Remove the dead set_retain_decode_graph_across_spec/retain_decode_graph_across_spec
  accessors (superseded by verify-capture). Refresh stale comments.

Compiles (session + engine native-backend). Tests/GPU validation follow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Proves Primary (greedy's only slot) host capture state survives a switch to
Verify and back — the invariant that keeps greedy byte-identical while MTP flips
the executor to Verify around each verify forward.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…by shared interior buffer arena

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby merged commit 39885ff into main Aug 21, 2026
12 of 15 checks passed
@justinchuby
justinchuby deleted the squad/mtp-perslot-capture branch August 21, 2026 12:24
@codecov

codecov Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 33.33333% with 40 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.02%. Comparing base (6923a01) to head (8a6cc53).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
crates/onnx-runtime-session/src/executor/run.rs 6.89% 27 Missing ⚠️
...ates/onnx-runtime-session/src/executor/bindings.rs 36.84% 12 Missing ⚠️
...rates/onnx-runtime-session/src/executor/capture.rs 83.33% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1654      +/-   ##
==========================================
- Coverage   81.41%   81.02%   -0.39%     
==========================================
  Files         384      384              
  Lines      180337   180358      +21     
  Branches   180337   180358      +21     
==========================================
- Hits       146813   146141     -672     
- Misses      28582    29272     +690     
- Partials     4942     4945       +3     
Flag Coverage Δ
cli-ort-linux 82.60% <ø> (ø)
cli-ort-windows 82.20% <ø> (+0.09%) ⬆️
mlas 85.09% <ø> (-0.14%) ⬇️
offline 80.90% <33.33%> (-0.40%) ⬇️

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

Files with missing lines Coverage Δ
crates/onnx-runtime-ep-api/src/provider.rs 58.69% <100.00%> (+0.35%) ⬆️
crates/onnx-runtime-session/src/executor/mod.rs 56.11% <100.00%> (ø)
crates/onnx-runtime-session/src/executor/state.rs 83.33% <ø> (ø)
crates/onnx-runtime-session/src/lib.rs 60.26% <ø> (ø)
...rates/onnx-runtime-session/src/executor/capture.rs 50.26% <83.33%> (+0.26%) ⬆️
...ates/onnx-runtime-session/src/executor/bindings.rs 59.55% <36.84%> (+1.02%) ⬆️
crates/onnx-runtime-session/src/executor/run.rs 67.02% <6.89%> (-0.99%) ⬇️

... and 9 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
The rustfmt breakage is main's, not this branch's: a clean `origin/main`
worktree at 9fd0526 fails `cargo fmt --all -- --check` with exit 1 at the
same four sites (capture.rs:585, run.rs:670, tests.rs:4979 and :4990),
introduced by #1652/#1654. Carried here only because this branch cannot be
green while its base is red.

The three reformatted files are byte-identical to #1655, which fixes the same
four sites upstream, so that merge will not conflict.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c80f8522-983c-47f7-8241-2155a823aabe
justinchuby added a commit that referenced this pull request Aug 21, 2026
…d again) (#1655)

Sixth formatting break on `main` today, from #1654 — a few hours after
#1653 fixed the fifth.

Four sites in
`onnx-runtime-session/src/executor/{capture.rs,run.rs,tests.rs}`, all
the same shape: a `self.cap_mut().field.insert(..)` chain rustfmt wants
split across lines. Straight `cargo fmt --all`, no hand edits.

**Verified inert** per file: each before/after pair is identical after
stripping whitespace and trailing commas. Reproduced on a pristine
`origin/main` worktree first, so it is main's breakage, not my branch's.

Because `Rust quality` runs sequentially, this red `Check formatting`
step is again **skipping every other lint in that job** — clippy,
publish order, dispatch-manifest lints, feature-gate coverage. Those
have effectively not run on main since #1654 landed.

This is the sixth today (#1637/#1641, #1644, #1648, #1647/#1648, #1652,
#1654) and my fifth repair PR. The structural cause and the two
candidate fixes — a merge queue, or running `Rust quality` on `main`
post-merge — are written up in #1653. Nothing about the individual
changes is careless; required checks simply run on each PR's merge ref
and never on the actual post-merge `main`, so sequentially-green PRs can
still leave the tip red. cc @justinchuby

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 21, 2026
…imary + Verify) (#1658)

## 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>

---------

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