Skip to content

perf(mtp): add second CUDA graph slot for option-c verify capture (enabling primitive) - #1648

Merged
justinchuby merged 1 commit into
mainfrom
squad/mtp-capture-verify
Aug 21, 2026
Merged

justinchuby merged 1 commit into
mainfrom
squad/mtp-capture-verify

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

Summary

Option-c (capture the M=K MTP verify forward so it replays instead of tearing down the M=1 decode graph every step — the replays=0 blocker from #1647) requires a second, independent captured-graph slot on the shared CUDA EP. Today the EP owns a single CudaGraphLifecycle (shared across the main + decode-inline execs), so the M=1 decode graph and the M=K verify graph — which bake different query geometries — cannot coexist without invalidating each other every step.

This PR lands that enabling primitive (dormant, zero hot-path/token risk). The full replay wiring is a multi-file executor change scoped for a follow-up (see the decision note). No speedup number is claimed — none exists yet (the Verify slot is not wired into decode).

Per the coordinator's fallback clause: land incremental capture-safe progress + the exact remaining gap + GPU evidence; do not fabricate a speedup.

What landed

  • onnx-runtime-ep-api: DeviceGraphSlot { Primary, Verify } enum + *_device_graph_*_in(slot, ..) trait methods. Default impls route Primary→the existing single-slot methods and reject other slots, so every existing EP compiles unchanged.
  • onnx-runtime-ep-cuda: a second CudaGraphLifecycle (verify_graph) on CudaRuntime, sharing the compute stream; slot-aware runtime + provider methods (per-slot reset also resets the capture-error latch).
  • GPU test primary_and_verify_graph_slots_are_independent: the two slots capture/replay/reset independently, interleaved, and resetting one leaves the other's executable intact.

Why the second slot is the blocker (code-anchored, origin/main 7ccdb920e)

Remaining gap to a replays-on-verify speedup (next-turn wiring, all anchored in the decision note)

  1. Session per-slot device_graph_signature + slot-parameterized capture/replay/reset (delegating to the new EP _in methods).
  2. Fixed padded verify shape (constant M = k+1, causal-masked padding; padded GDN state discarded by the existing snapshot→restore→re-advance commit) → shape-invariant replayable verify.
  3. Pinned StepScoped verify workspace (reserve at M=K peak, stop freeing while the Verify graph is installed; self-stabilizing invalidate-on-grow).
  4. Native verify state machine that captures the fixed-M verify into the Verify slot and replays it; M=1 stays in Primary.
  5. Correctness gates unchanged: MTP token-identical to greedy, greedy inert, fallbacks=0.

Validation (H200 CUDA_VISIBLE_DEVICES=5, all 8 idle; PATH/CUDA_HOME set; ORT 1.28 cuda13; int4 block-32; branch off origin/main 7ccdb920e)

  • ep-cuda graph::tests: 8/8 pass incl. the new two-slot test.
  • onnx-genai-engine native-backend lib suite: 575 pass, 0 fail (engine untouched, greedy inert).
  • Full bench build --features bench-native,native-cuda,cuda-13000: clean.
  • GPU inertness on real Qwen3.8-27B int4 hybrid: MTP steady 14.59 tok/s, acceptance 78.9%, cuda_graph captures=16 replays=0 fallbacks=0 invalidations=99, no NaN — identical to origin (Verify slot dormant).

Decision note: .squad/decisions/inbox/gaff-mtp-verify-capture-second-graph-slot.md.

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

The native MTP self-spec `replays=0` blocker is architectural: the CUDA EP
owns a single CudaGraphLifecycle, shared across the main + decode-inline
execs, so the M=1 decode graph and the M=K verify graph cannot coexist —
each invalidates the other every step. Capturing the verify (the real MTP
speedup lever) needs a second, independent captured-graph slot.

Land that enabling primitive: a DeviceGraphSlot{Primary,Verify} abstraction
threaded through the EP graph API (additive default methods route Primary to
the existing single-slot methods and reject other slots, so every existing
EP compiles unchanged), and a second CudaGraphLifecycle (verify_graph) on the
CUDA runtime sharing the compute stream. GPU test proves the two slots
capture/replay/reset independently and one reset leaves the other intact.

Dormant: the engine never captures into the Verify slot yet, so greedy and
MTP are byte-identical to origin (GPU: MTP 14.59 tok/s, 78.9% accept,
replays=0, fallbacks=0, no NaN — unchanged). No speedup number is claimed;
the remaining replay wiring (session per-slot signatures, fixed-padded verify
shape, pinned StepScoped workspace, native verify state machine) is scoped in
the decision note.

Validation: ep-cuda graph::tests 8/8 on H200 (incl. new two-slot test);
onnx-genai-engine native-backend lib suite 575 pass (greedy inert); full
bench build clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby merged commit 73e6fe1 into main Aug 21, 2026
13 of 15 checks passed
@justinchuby
justinchuby deleted the squad/mtp-capture-verify branch August 21, 2026 10:22
@codecov

codecov Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 39 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.03%. Comparing base (7ccdb92) to head (f7fa618).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
crates/onnx-runtime-ep-api/src/provider.rs 0.00% 39 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1648      +/-   ##
==========================================
- Coverage   81.04%   81.03%   -0.01%     
==========================================
  Files         384      384              
  Lines      180256   180299      +43     
  Branches   180256   180299      +43     
==========================================
+ Hits       146082   146111      +29     
- Misses      29224    29243      +19     
+ Partials     4950     4945       -5     
Flag Coverage Δ
cli-ort-linux 82.60% <ø> (?)
cli-ort-windows 82.11% <ø> (ø)
mlas 85.05% <ø> (-0.14%) ⬇️
offline 80.91% <0.00%> (-0.02%) ⬇️

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 57.46% <0.00%> (-4.18%) ⬇️

... and 4 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
…p 1) (#1650)

## Summary

Option-c's replays-on-verify speedup needs the main executor to capture
the fixed **M=k+1 verify** into a **second, independent** CUDA graph
slot so it stops invalidating the M=1 decode graph every step (the
`replays=0` root cause). #1648 added that second slot raw at the EP
(`DeviceGraphSlot::Verify`). This PR lands **step 1 of my 5-step plan**:
lifting it into the **executor/session** layer so the main executor can
actually *drive* the Verify slot independently of the Primary M=1 decode
graph.

**Dormant + byte-inert.** The native verify state machine is
intentionally NOT wired here — this is the plumbing brick. Default
`Primary` routing makes greedy and MTP behave exactly as before. **No
MTP speedup number is claimed** (the Verify slot is dormant in decode),
and — see the blocker below — none is measurable in this environment
right now regardless of wiring.

Per the coordinator's standing fallback clause: land incremental
capture-safe progress + the precise remaining gap + GPU evidence; never
fabricate a speedup or half-wire a risky, unvalidated path.

## What landed
- `Executor.graph_slot: DeviceGraphSlot` (default `Primary`), threaded
through **every** EP graph call the executor makes: capture
begin/end/abort + segment replay (dispatch.rs), single-graph replay +
reset (bindings.rs), `SegmentCaptureGuard` abort (capture.rs), defensive
resets (run.rs, mod.rs). Kernel-variant eviction resets **both** slots
(an evicted kernel can retire a graph in either; resetting an empty slot
is a no-op).
- `Executor::set_graph_slot`/`graph_slot` +
`Session::set_main_exec_graph_slot`/`main_exec_graph_slot` (re-exports
`DeviceGraphSlot`). Retargeting resets the old slot first, so a later
capture records cleanly into the new slot. Because the main-exec
`try_capture`/`replay`/`reset` now route through `self.graph_slot`, the
native verify path (step 4) captures into Verify simply by setting the
slot once — no new capture/replay Session methods needed.

Default `Primary` ⇒ `*_in(Primary)` delegates to the historical
single-slot EP methods ⇒ provably identical behavior until a caller
retargets the slot.

## Validation (H200 ord 5 idle; ORT 1.28 cuda13; int4 block-32; off
origin/main `73e6fe15a`)
- `onnx-runtime-session --features cuda` lib suite: **190/190**, incl.
new GPU test `main_exec_drives_verify_graph_slot_end_to_end` (main exec
captures→replays→resets on the **Verify** slot with persistent I/O +
zero replay-time allocs, then reverts to Primary) and all existing
Primary-path graph tests still green.
- `onnx-genai-engine --features native-backend` lib suite: **575/575**
(greedy inert; engine untouched).
- `onnx-runtime-ep-cuda --features cuda,cuda-13000 graph::tests`:
**8/8** (#1648 two-slot invariant unchanged).
- Full bench build `--features bench-native,native-cuda,cuda-13000`:
clean.
- **GPU greedy-inertness on the real Qwen3.8-27B int4 hybrid** (`--ep
cuda --steady --tokens 128 --warmups 3`): **56.56 tok/s, `cuda_graph
replays=504 fallbacks=0` (captures=4, invalidations=3), no non-finite
logits** — the Primary/inline slot captures & replays exactly as before
(≈ the ~55.9 tok/s greedy baseline). The routing change is byte-inert in
production greedy.

## ⚠️ New E2E blocker (independent of this change)
The MTP head **fails to load** on this ORT build, from the pristine
artifact dir:
```
Failed to load MTP head: ORT error: Type Error: Type parameter (T) of Optype (Add)
bound to different types (tensor(bfloat16) and tensor(float)) in node ().
```
A graph-level type mismatch **inside `mtp/model.onnx`**, rejected at ORT
session creation — this Rust change cannot affect ORT's type-checking of
the head graph. Native MTP self-spec therefore **cannot run or be
token-identity-validated end-to-end in this environment right now**,
regardless of engine wiring. Needs an artifact fix (re-export the head
with consistent `Add` operand dtypes / cast the bf16↔f32 operands)
before any real MTP number is measurable again.

## Remaining gap (steps 2-4; plan unchanged, now also gated on the head
fix)
2. Fixed padded verify shape (constant M=k+1, causal-masked trailing
padding; padded GDN advance discarded by the existing
snapshot→restore→re-advance commit). The `leverb_increment0` throwaway
probe already demonstrates the mechanism (persistent padded
`[1,M,vocab]` logits binding + pre-capture warm at M=K + KV-symbol pin →
capturable, replayable, captured-vs-eager token parity).
3. Pinned StepScoped verify workspace (the #1647 NaN fix).
4. Native `verify_graph_phase` capturing/replaying the fixed-M verify
into the Verify slot (set the main exec's slot to Verify once); Primary
M=1 stays on the decode-inline sibling.
5. GPU-validate MTP token-identical to greedy, both slots replays>0,
fallbacks=0, median-of-5 A/B — **once the MTP head loads again.**

Decision note:
`.squad/decisions/inbox/gaff-mtp-verify-slot-executor-routing.md`.

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

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

`origin/main` (`73e6fe15a`) fails a required check on an unmodified
checkout:

```
error: methods `set_retain_decode_graph_across_spec` and
       `retain_decode_graph_across_spec` are never used
  --> crates/onnx-genai-engine/src/native_decode/cuda.rs:5516
```

That is the **Check the native backend compiles** step of `Rust
quality`, so it blocks every open PR regardless of contents. It surfaced
on an unrelated CPU-kernel PR (#1628).

Both methods are `#[cfg(test)]` accessors for the option-c
graph-retention seam that #1648 landed as an *enabling primitive* —
deliberately ahead of the WP4 tests that will drive them. Their
`#[cfg(test)]` siblings either side (`set_retain_graph_on_rewind`,
`padded_query_capacity`) are already called, which is why only these two
trip.

Fix is `#[allow(dead_code)]` on the pair with the reason recorded at the
site — rather than deleting a seam that is about to be used, or widening
the allow to the whole `impl` block.

### Local verification

| gate | result |
|---|---|
| `cargo fmt --all -- --check` | clean |
| `clippy -p onnx-genai-engine --features native-backend --all-targets
-D warnings` | clean |
| `clippy -p onnx-genai-engine --features native-cuda --all-targets -D
warnings` | clean |
| `clippy -p onnx-genai-cli --all-targets -D warnings` | clean |
| `cargo test -p onnx-genai-engine --features native-backend` | 0 failed
|

> **Process note — this is the third main-is-red repair today**, and I
am only finding them because they land on an unrelated PR:
> - this morning: fmt + clippy debt from #1637/#1641 (four required
gates red at once) → fixed by #1640
> - midday: #1644 reintroduced fmt violations → fixed by #1642
> - now: #1648 introduces this dead-code lint
>
> Each costs a full CI round-trip to discover, because the gates are
sequential — the clippy steps only run once formatting passes. Running
the `Rust quality` job on `main` post-merge, or a merge queue, would
catch these at the source instead of on whoever's PR is open next.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 21, 2026
`cargo fmt --all -- --check` fails on an **unmodified `origin/main`**
(`90ddd284e`):

```
crates/onnx-runtime-session/src/lib.rs:38
```

`pub use onnx_runtime_ep_api::DeviceGraphSlot;` was added above the
existing `WorkspaceRequirement` re-export rather than in sorted order.
One-line swap, pure `cargo fmt --all` output.

Formatting is a required check, so this blocks every open PR regardless
of contents. It surfaced on an unrelated CPU-kernel PR (#1628).

### This is the fourth main-is-red repair today

| # | PR | what was red on main | source |
|---|---|---|---|
| 1 | #1640 (not mine) | fmt + 3 clippy lints, four required gates at
once | #1637 / #1641 |
| 2 | #1642 | fmt, two sites | #1644 |
| 3 | #1649 | clippy `dead_code`, `native_decode/cuda.rs` | #1648 |
| 4 | **this** | fmt, one re-export | #1647 / #1648 |

The pattern is consistent and worth fixing at the source: quality gates
run on PR branches *before* merge but not on the merge result, so any
merge can land violations that then fail whoever opens the next PR.
Because the gates are sequential — clippy steps only run once formatting
passes — each breakage costs a full CI round-trip to even *discover*,
and they arrive one at a time.

Two concrete options: enable a merge queue (gates run on the merge
result), or run the `Rust quality` job on `main` post-merge so the break
is attributed to the PR that caused it instead of the next unrelated
one.

Also still red on main and **not** fixed here, because I cannot
reproduce it locally and it is not mine: `Rust (Windows ARM64)` → *Test
cross-platform offline crates* has been failing on main since at least
`73e6fe15a` (it is non-required, so it does not block merges).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 21, 2026
`Rust quality → Check formatting` is failing on `main` at `6923a016b`
(#1652). That job runs its steps sequentially, so while formatting is
red **every other check in it is skipped** — clippy, publish order, the
dispatch-manifest lints, feature-gate coverage, all of it. Every open PR
is blocked and none of them are getting linted.

Three sites, all in
`crates/onnx-genai-engine/src/native_decode/cuda.rs`: two
`verify_graph_phase` assignments (`:1815`, `:1823`) and one `assert_eq!`
in `verify_capture_helper_tests` (`:7042`). Straight `cargo fmt --all`
output, no hand edits.

**Verified inert.** The before/after texts are identical after stripping
whitespace *and* trailing commas — the only non-whitespace delta is
commas rustfmt adds before a closing delimiter when it breaks a call
across lines, which are semantically meaningless in Rust. Reproduced on
a pristine `origin/main` worktree first, so this is main's breakage and
not an artifact of my branch.

### This is the fifth time today

`main` has been red on formatting or clippy five separate times in one
day: #1637/#1641 (fixed by #1640), #1644 (#1642), #1648 (#1649),
#1647/#1648 (#1651), and now #1652.

The cause is structural, not carelessness. Required checks run on a PR's
**merge ref**, but nothing re-runs them on `main` **after** the merge,
so two PRs that are each green against an older base can land in
sequence and leave the result red. Because the quality job is
sequential, the breakage also masks every later step in it. The cost
lands on whoever opens the next PR, who then has to distinguish "my
change broke this" from "main was already broken" — a full CI round-trip
each time.

Two things would fix it, either one sufficient:
- a **merge queue**, which tests the actual post-merge result; or
- running **`Rust quality` on `main` post-merge**, which at least
detects it immediately and attributes it correctly.

I have now spent four PRs on this. I would rather not spend a fifth. cc
@justinchuby

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
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