Repository navigation
feat(ir): inline_single_trip_scan_bodies graph transform (Inc-1b PR-1, unwired) - #580
Conversation
…PR-1) Add a pure Graph -> Graph transform that lowers each structurally single-trip-eligible `Scan` node (recurrent LinearAttention body, scan axis not a static extent > 1, no nested control flow) to one straight-line iteration spliced into the parent graph: - Squeeze drops the size-1 scan axis into the body scan-input formal. - Body nodes are cloned into the parent with fresh, remapped ValueIds; state formals bind to the Scan's parent state inputs, captures resolve by name to parent-scope values (as ChildExecutor::new does), and body-local initializers are promoted to parent initializers. - State outputs are written directly by their producer (Identity only for a pass-through); scan outputs get an Unsqueeze re-adding the scan axis. - The Scan node and its body subgraph are deleted. This is the transform from cohaagen-27b-inc1b-design.md §1. It is UNWIRED / dead code: no executor, decoder, loader, or run path calls it (PR-2 wires a decode-inline plan behind a default-off flag; PR-3 adds capture). Zero behavior change; prefill / multi-trip / dense paths are untouched. onnx-runtime-ir is the dependency-free base contract, so the transform performs only the structural merge (mirroring ChildExecutor::compile's graph-building half). Callers re-run InferenceRegistry (Permissive) to re-resolve interior shapes, exactly as ChildExecutor::compile does today. Tests (CPU, fast, non-ignored): - onnx-runtime-ir scan_inline: positive lowering (Scan removed, body ops present, state/scan in+out rewired, capture by name, initializer promoted, validity, boundary/interior static shapes); three no-op negatives (non-recurrent, multi-trip static extent, dense scan-free); remap correctness (fresh non-colliding interior ids, no leaked body id). - onnx-runtime-shape-inference scan_inline_inference: Permissive whole- graph inference re-converges over the inlined body (placed here because ir cannot depend on shape-inference without a two-version cycle). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #580 +/- ##
==========================================
+ Coverage 81.36% 81.50% +0.13%
==========================================
Files 316 317 +1
Lines 123952 124181 +229
Branches 123952 124181 +229
==========================================
+ Hits 100859 101216 +357
+ Misses 19016 18869 -147
- Partials 4077 4096 +19
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
|
VERDICT: APPROVE Independent review by Harry (author-lockout in effect; I re-ran everything myself in a detached worktree at Blast radius — LOW, confirmed. The branch commit vs its fork point ( UNWIRED — confirmed (critical). Workspace grep for Build + tests — confirmed. Tests non-vacuous — mutation-verified:
Semantics — independently reasoned sound. Squeeze drops exactly the normalized scan axis; Unsqueeze re-adds the size-1 axis at the declared output axis; state formals→parent state inputs and first fmt/clippy — clean. cpuinfo caveat — confirmed environmental. Non-blocking recommendations for PR-2:
PR-3 (capture) correctly remains gated on Justin greenlight + capture-team (Mary / #443–#543) sign-off and is not in this PR. Not enabling auto-merge; coordinator handles merge. |
🔴 Benchmark Regression DetectedComparison of criterion micro-benchmarks: PR head vs merge-base, measured on the same runner in the same job (base first → PR second).
Visual flags: Host infoWhat this cannot catch
|
…R, flag default-OFF) (#588) ## Inc-1b PR-2 — wire a decode-specialized inlined-body Executor (dual-plan EAGER, flag default-OFF) Wires PR-1's `inline_single_trip_scan_bodies` transform (#580) into a **second, decode-specialized `Executor`** and routes single-token decode to it. Eager (capture stays OFF). **Flag default-OFF ⇒ zero behavior change unless explicitly enabled.** ### What this does - **Sibling Executor** (`onnx-runtime-session`): `Executor::build_decode_inline_sibling` runs the transform, re-resolves interior shapes with Permissive inference (mirrors `ChildExecutor::compile`), and builds a second executor that **shares the main exec's `Arc<WeightStore>` and `Arc<dyn ExecutionProvider>`**. Returns `None` for a dense (non-hybrid) decoder. The prefill/main exec is left **byte-identical**. - **Session API**: `InferenceSession::{enable_decode_inline, decode_inline_ready, run_decode_inline_with_device_bindings}`. The sibling binds the **identical persistent device state buffers** the main exec used at the prefill→decode hand-off (bindings resolve by name; the transform leaves graph input/output names+order unchanged ⇒ recurrent-state continuity is automatic — design §3, the integration invariant). - **Engine flag + routing** (`onnx-genai-engine`): `ONNX_GENAI_DECODE_INLINE_SCAN` (default OFF; truthy `1`/`true`/`yes`/`on`). Lazy build at the first single-token decode step. Single-token decode routes to the sibling on all three native single-token paths — CUDA greedy device-argmax fast path, CUDA logits path, and CPU in-place path. Greedy reuses the existing device-argmax kernel, so tie-breaking is byte-identical and full logits never round-trip to host. ### Flag default-OFF — zero behavior change when off When `ONNX_GENAI_DECODE_INLINE_SCAN` is unset/falsy, the sibling is never built, `decode_inline` latches `Disabled`, and every decode step uses today's Scan child-session path unchanged. An ordinary session is byte-identical to `main` and pays nothing. ### Harry's 4 mandatory guards → tests 1. **Byte-identical parity + final recurrent state** — `decode_inline_sibling_is_byte_exact_with_scan_and_preserves_state` (session): N decode steps, Scan plan vs inline plan, per-token outputs byte-identical AND final recurrent state identical. 2. **Runtime scan-axis extent==1 assertion + fallback** — `route_decode_inline_decision` (pure) + `decode_inline_routes_only_single_token_when_enabled` / `decode_inline_never_routes_when_disabled_or_unbuilt` (engine): only single-token (extent-1) steps route to the sibling; multi-token steps fall back to the main Scan exec so a wrongly-collapsed graph is never run. 3. **Persistent state-buffer continuity** — `decode_inline_sibling_preserves_persistent_state_across_prefill_handoff` (session). 4. **state_pairs ordering + shape check** — `decode_inline_sibling_preserves_state_output_order_and_resolves_shapes` (session): first `num_state` present outputs map to present-state in `io.state_pairs` order; inlined-interior shapes resolve (Permissive) before use. Plus `decode_inline_sibling_none_for_dense_graph` and `decode_inline_flag_defaults_off_and_parses_truthy`. ### Measured OFF vs ON — Qwen3.6-27B int4 hybrid (H200) `profile_native --ep cuda --backend native --steady --decode-skip 8 --warmups 2 --runs 3 --tokens 64` | flag | decode ms/tok (medians, 4 runs) | tok/s | |------|----------------------------------|-------| | OFF | ~153 (149.2 / 155.9 / 157.7 / 150.6) | ~6.5 | | ON | ~122 (117.7 / 124.0 / 121.9 / 126.7) | ~8.2 | **~1.26× decode speedup** (design §5 predicted ~1.28×; the 167→130 ms/tok absolutes were on a slower baseline — same ratio here). **Generated token ids byte-identical OFF vs ON on every run.** The eager inline plan beats even the CUDA-graph-captured baseline because the captured Scan operator still pays real per-step child-dispatch + loop-state-collect work inside each replay; inlining removes that boundary entirely (design §5). ### Byte-exact GPU e2e `native_autoderive_io_cuda_e2e.rs` (`#[ignore]`, stock 27b == CPU fp32 oracle, expected ids `[11751,13,271,248068,271,248069,271,4639,369,4252,13,11751,369,279,6511,321]`) run with the flag ON — see PR comment for the run result. ### Scope - Only native single-token decode paths route to the sibling; routed-port/`inputs_embeds` and multi-token steps keep the main exec. - **Capture is OUT OF SCOPE** — device-graph capture of the inlined body is **PR-3 (greenlight-gated, capture-team #443/#543 sign-off required)**. This PR touches none of the capture surface. - fmt clean; `clippy -D warnings` exit 0 on `onnx-runtime-session` and `onnx-genai-engine` (incl. `--features cuda,native-backend`). Design: `cohaagen-27b-inc1b-design.md` §1–3; transform PR #580. **Do not auto-merge — Harry reviews first (author-lockout).** Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…et-A) (#589) # Inc-1b PR-3 — capture-fold the decode-inline sibling (flag-gated, bucket-A) Part of the Inc-1b 27B decode-perf lane. PR-1 (#580, inline_single_trip_scan_bodies) and PR-2 (#588, the eager decode-inline sibling behind ONNX_GENAI_DECODE_INLINE_SCAN, default OFF) are merged. This is **PR-3, the capture step**: let the decode-inline sibling's inlined body ops fold into the CUDA-graph capture. Bucket-(A)-only per my accepted scope note (.squad/decisions/inbox/cohaagen-inc1b-pr3-scope.md): **no change to the shared #443/#543 capture surface**. The sibling is an ordinary Executor whose plan has no Scan after inlining, so it reuses the segmenter / warm-seed / quarantine machinery verbatim; PR-3 only drives it through the existing capture state machine. ## Flag-gated, default-OFF (structural no-op) Capture engages only when ONNX_GENAI_DECODE_INLINE_SCAN gates route_inline. Flag off: the sibling is never built and the inline branch is never taken — byte-identical to current main. ## Harry's 4 PR-3 invariants (each with a non-vacuous test) 1. **Re-introduce check_device_capture_error()** on the sibling capture path, piggybacked on the single logits/greedy device-to-host sync (detection-before-consumption). The latch lives on the shared EP, so the poll observes the sibling's captured-replay result; a latched violation rejects the token and invalidates the graph. 2. **Capture-engagement test** decode_inline_sibling_folds_body_into_captured_graph_byte_exact: the inlined body folds into >= 1 captured segment while staying byte-exact with the eager sibling run. 3. **Inlined-interior shapes join the warm-seeded snapshots** — proven by the engagement test capturing after an eager warmup (warm-seed is the precondition for capture engaging). 4. **Scope-lock** route_decode_inline_decision refuses inputs_embeds / Routed step-input decoders (new has_eager_step_inputs arg); covered by decode_inline_never_routes_when_decoder_has_eager_step_inputs. ## Single-slot / single-latch EP safety Prefill runs the main exec (multi-token, non-capturable); ALL single-token decode routes to the sibling; the main capture machine stays dormant. So the shared EP's one graph slot + one capture-error latch are owned solely by the sibling — no double-capture, no cross-latch bleed. invalidate_graph now also resets the sibling's host-side capture schedule so KV-growth / shape-change re-warms instead of replaying a dropped graph. ## Correctness gate (real 27B, H200, qwen3.6-27b-int4-cuda) Byte-exact vs the CPU fp32 oracle with capture engaged (flag ON), greedy ids: [11751, 13, 271, 248068, 271, 248069, 271, 4639, 369, 4252, 13, 11751, 369, 279, 6511, 321] native_autoderive_io_cuda_e2e passed with ONNX_GENAI_DECODE_INLINE_SCAN=1 (CUDA tokens == CPU oracle tokens). ## Measured decode perf (27B, H200, ms/tok, prefill+load cancelled by token-count delta) - flag OFF (main eager path): 143.8 ms/tok - flag ON + capture: 70.1 ms/tok => **2.05x** ORT-CUDA crashes on this hybrid linear-attention export (documented stl_vector assertion), so there is no live ORT baseline for this model; the trusted reference is our native CPU fp32 oracle. ## Mutation map - crates/onnx-runtime-session/src/lib.rs — 5 additive sibling capture wrappers on InferenceSession + the CUDA capture-engagement test. - crates/onnx-genai-engine/src/native_decode/cuda.rs — inline_graph_phase field; new run_one_token_inline capture state machine; invalidate_graph resets the sibling too; both inline branches drive capture + re-add the capture-error poll; has_eager_step_inputs widened to pub(super). - crates/onnx-genai-engine/src/native_decode/mod.rs — route_decode_inline_decision gains has_eager_step_inputs. - crates/onnx-genai-engine/src/native_decode/tests.rs — updated calls + new scope-lock test. ## Not in scope Does NOT flip the default to ON — that stays Justin's decision. Build evidence: .squad/decisions/inbox/cohaagen-inc1b-pr3-build.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Inc-1b PR-1 —
inline_single_trip_scan_bodiesgraph transformFirst bounded step of the Inc-1b lane (
cohaagen-27b-inc1b-design.md§1). Adds a new IR graph transform inonnx-runtime-irand nothing else.What the transform does
inline_single_trip_scan_bodies(&Graph) -> Graphlowers each single-trip-eligibleScannode to one straight-line iteration of its body spliced into the parent graph:Scanwith a registeredbodysubgraph, carrying recurrent state (num_state >= 1, i.e. a LinearAttention body threadingstate_pairs), whose every scan input's scan axis is not a static extent > 1 (Static(1)or dynamic — the decode case), with no nested control flow, and whose lexical captures all resolve by name in parent scope. Anything else is left untouched.Squeezedrops the size-1 scan axis and feeds the body scan-input formal.ValueIds (remap: body ValueId → new parent ValueId). Body state formals bind to the Scan's parent state-input values; the body scan-input formal binds to the squeezed slice; lexical captures resolve by name to values already in parent scope (same resolutionChildExecutor::newdoes viacapture_names); body-local initializers are promoted to parent initializers.num_statebody outputs become the parent present-state values (written directly by their producer; anIdentityis used only for a rare pass-through); scan outputs get anUnsqueezere-adding the size-1 axis to match the Scan's declared scan-output shape.Scannode and drop its body subgraph.The transform is a pure
Graph -> Graphfunction — the input graph is never mutated.Emphatically UNWIRED / zero behavior change / dead until PR-2
This pass is not called from any executor, decoder, loader, or run path. It is dead code exercised only by the unit tests. The prefill / multi-trip / dense paths are completely unaffected. No changes to
control_flow.rs,run.rs,state.rs, or decode routing.decode_inline_execfrom the transformed graph and route decode to it behind a default-off flag (eager, transform-correctness proof).Crate-boundary note (why inference isn't called inside the pass)
onnx-runtime-iris the dependency-free base contract and cannot depend ononnx-runtime-shape-inference(which depends on it) — not even as a dev-dependency, since that pulls in a second, incompatible copy of the IR types. So the transform performs the structural merge only (mirroring the graph-building half ofChildExecutor::compile); a caller (PR-2) re-runsInferenceRegistry::infer_graph(Permissive) to re-resolve interior shapes, exactly asChildExecutor::compiledoes today. The "inference re-converges" check is therefore placed in the shape-inference crate's integration tests, on the side of the edge that can see both.Tests (CPU, fast, non-ignored)
onnx-runtime-ir—src/scan_inline/tests.rs(5 tests, shared tiny-graph builder):lowers_single_trip_scan_to_straight_line_body— proves the Scan is removed; body ops (MatMul + 3 Adds) are present; oneSqueeze+ oneUnsqueezeappear; the parent present-state is produced by an inlinedAdd; the scan output is produced byUnsqueeze; the lexical capturewresolves by name to the pre-existing parent weight the inlined MatMul reads; the body-localbiasinitializer is promoted; the graph is structurally valid; and the rewired boundary/interior values carry the expected static shapes.leaves_multi_trip_scan_untouched— recurrent Scan with a static scan-axis extent of 3 is a no-op (must not statically collapse a genuine multi-trip Scan).leaves_non_recurrent_scan_untouched— a pure element-wise map Scan (num_state == 0) is a no-op.leaves_dense_scanless_graph_untouched— a dense Scan-free graph is returned structurally unchanged (non-vacuous both directions).remap_produces_fresh_non_colliding_interior_ids— with body/parent id ranges deliberately overlapping, asserts every node-referenced id is live, every interior value id is freshly allocated above the pre-transform high-water mark (no leaked body id, no alias of a pre-existing parent value), andvalidate()passes (no id collisions / consistent edges).onnx-runtime-shape-inference—tests/scan_inline_inference.rs(1 test):permissive_inference_reconverges_over_inlined_scan_body— inlines the eligible hybrid, blanks every produced value's shape, then asserts Permissive whole-graph inference re-derives the interior + boundary shapes (present state[2,4], scan output[1,2,4]) from the graph inputs/initializers through the inlined nodes.Verification
cargo test -p onnx-runtime-ir— 67 passed (incl. the 5 new).cargo test -p onnx-runtime-shape-inference --test scan_inline_inference— 1 passed.cargo fmt --all --check— clean.cargo clippy -p onnx-runtime-ir --all-targets -- -D warnings— exit 0;cargo clippy -p onnx-runtime-shape-inference --tests -- -D warnings— exit 0.cargo checkforonnx-runtime-ir,-shape-inference,-optimizer,-loader(its dependents) — clean. (A workspace-widecargo checkfails only on the unrelatedonnx-runtime-cpuinfocmake vendor submodule, which is not populated in a fresh git worktree — environmental, not from this change; PR-1 does not touch ep-cuda or any GPU code.)Scope held
Size M, LOW blast radius (new pass, dead until PR-2), exactly as the design's staging table predicts. No execution/capture surfaces were touched to compile or test it.
Per Inc-1b design:
cohaagen-27b-inc1b-design.md. Please review before merge — do not enable auto-merge (independent reviewer: Harry).