Repository navigation
perf(cuda-ep): make Transpose capture-eligible via persistent metadata (Lever 2 PR-1) - #574
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #574 +/- ##
==========================================
+ Coverage 81.32% 81.36% +0.03%
==========================================
Files 315 315
Lines 123737 123733 -4
Branches 123737 123733 -4
==========================================
+ Hits 100629 100675 +46
+ Misses 19030 18985 -45
+ Partials 4078 4073 -5
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
✅ Benchmarks — No RegressionComparison 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
|
…gs) (#575) ## CI hotfix — make `DeviceOffloadPolicy` `Default` derivable (clippy `-D warnings`) **Unblocks origin/main** — the "CUDA compile (Linux+Windows)" job is RED on every PR (including #574) due to a `clippy::derivable_impls` failure under `-D warnings`. ### Root cause #572 flipped `DeviceOffloadPolicy::async_pagein`'s default to `false`. That made the hand-written `impl Default` fully equal to a derivable default (all three fields = their type default: `bool` → `false`, `Option` → `None`), so `clippy::derivable_impls` now fires at `crates/onnx-runtime-ep-cuda/src/weight_paging.rs:117`. ### Fix (provably behavior-identical) Replace the hand-written `impl Default for DeviceOffloadPolicy` with `#[derive(Default)]` on the struct. The derive produces exactly the same defaults — `enabled: false`, `device_budget_bytes: None`, `async_pagein: false`. `from_env()` and all runtime behavior unchanged; field doc comments preserved. Net diff: **1 insertion, 11 deletions**, one file. ### Green confirmation - `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings` → **exit 0** (the failing gate; no other `derivable_impls` / `-D warnings` hits in the crate). - CI first step `cargo check --locked -p onnx-runtime-ep-cuda -p onnx-runtime-python --features onnx-runtime-python/cuda` → **exit 0**. - `cargo fmt --all --check` → clean. - Existing guard test `weight_paging::tests::device_policy_defaults_to_disabled` (asserts `enabled=false`, `device_budget_bytes=None`, `async_pagein=false`) → **passes**, locking the derived defaults against field default-drift. No new test needed. Decision note: `.squad/decisions/inbox/cohaagen-cuda-clippy-hotfix.md`. Trivial lint hotfix — fast-track to unblock main. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Transpose used launch_metadata, which per call did alloc_raw + htod (perm/stride upload) + synchronize() + free_raw. The synchronize() only guards the immediate free but serializes the stream on every one of the ~624 Transpose calls per qwen3.6-27b decode step, in both the eager (Scan-child) and captured paths, and left Transpose reporting CaptureSupport::Unsupported so it could never fold into the parent CUDA graph. Migrate Transpose to the PersistentMetadata + launch_persistent_metadata pattern already blessed for Expand and Slice (#443/#543): cache the perm/stride buffer, upload/sync once at warm-up, then run with no per-call alloc/htod/sync. Once warmed on its exact shape/dtype/perm signature the kernel reports Supported and errors on mid-capture signature change (identical guard to Expand). Same transpose_bytes kernel and identical metadata bytes -> byte-exact. Measured on stock qwen3.6-27b-int4 (auto-derived io, #573): decode 165.27 -> 156.74 ms/tok (~5.4%, ~1.05x), token IDs byte-identical to the CPU fp32 oracle (ORT-CUDA crashes on this model). Does not beat ORT alone; this is one op of the Lever-2 non-Scan swarm. Tile deferred (separate PR): it shares launch_metadata but also reads repeats device->host via host_ints, needing repeats-warming. Tests: new transpose_warmed_metadata_captures_and_matches_eager EP unit test (unwarmed declines -> warms -> capture+replay byte-identical to eager); full construction_gpu suite 19/19; 27b autoderive oracle e2e and qwen3-0.6b e2e both byte-identical. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
756062b to
c532cbe
Compare
|
VERDICT: APPROVE PR #574 "perf(cuda-ep): make Transpose capture-eligible via persistent metadata" (Lever 2 PR-1) I independently reproduced every claim on a detached worktree at the PR head. Evidence below. Blast radius (verified)
Lane that was red: clippy
EP tests
Byte-exact gates
Correctness scrutiny (the specific risks I was asked to attack)
Perf (independently re-measured, apples-to-apples, same debug build + same GPU + same command)profile_native --model qwen3.6-27b-int4-cuda --ep cuda --steady --decode-skip 8 --warmups 2 --runs 3 --tokens 64
Non-blocking recommendation (not a reject reason)The shipped test warms only one signature and never exercises the "errors if signature changes mid-capture" branch; that guard's teeth are real (I verified with a scratch test) but are not covered by the committed suite. Suggest adding a small negative case (changed shape/perm mid-capture asserts Err) so the guard is regression-protected. Optional follow-up. APPROVE: bounded, byte-exact, real host-side win, robust capture guard (double-layered). Ship. |
Adds the negative counterpart to transpose_warmed_metadata_captures_and_matches_eager: warm signature A, open a CUDA-graph capture, then feed a different input/output shape and assert execute() returns the "changed during CUDA graph capture" error instead of silently replaying the stale persistent-metadata buffer against the wrong geometry. Locks the guard Harry verified with a throwaway scratch test so it can never regress. Purely additive (test only); movement.rs logic unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ver 2 PR-2) (#577) ## What this unlocks Lever 2 PR-2. Migrates the CUDA **Tile** kernel to the `PersistentMetadata` / `launch_persistent_metadata` pattern already used by Transpose (#574) and Expand, so a warmed fixed-decode-signature Tile becomes **CUDA-graph capture-eligible** and drops its per-call host sync. Post-#574, Tile was the last `launch_metadata` caller in `movement.rs`. Per call it did: device `alloc_raw` + `htod` of the metadata + kernel launch + **`synchronize()`** (to guard the `free_raw`) + free — plus a blocking `dtoh` read of the `repeats` input. On the qwen3.6-27b linear-attention hybrid, Tile runs ~576×/step inside eager Scan bodies, so those syncs are on the decode hot path. ## Correctness — byte-exact vs CPU fp32 oracle ORT-CUDA crashes on the 27b, so the CPU fp32 backend is the oracle. - **27b stock export** native CUDA == CPU fp32 oracle, **byte-identical** (`native_autoderive_io_cuda_e2e`, `#[ignore]` GPU e2e — passed). - **qwen3-0.6b** native CUDA == ORT golden, **byte-identical** (`qwen3_0_6b_native_cuda_e2e` — passed); capture engagement unchanged (captures=2, replays=34, fallbacks=0). - New **positive** test `tile_warmed_metadata_captures_and_matches_eager`: unwarmed declines capture; warmed reports `Supported`; eager re-run and capture+replay both byte-identical. - New **negative** test `tile_rejects_signature_change_during_capture`: feeding a changed signature mid-capture returns `Err` "changed during CUDA graph capture" — locks the guard's teeth. The `repeats` input (a blocking `dtoh`, illegal mid-capture) is now read **only on the unwarmed eager path** to validate `output[i] == input[i] * repeats[i]`. Under a stable `(dtype, input_shape, output_shape)` signature, `repeats` is mathematically fixed by shape inference, so gating that read behind the warmed signature is byte-exact. Kernel `tile_bytes` metadata (`[output.shape, input.shape, input_strides]`) is unchanged; both launch paths pass identical args. ## Honest perf Stock 27b, `profile_native --steady --decode-skip 8 --warmups 2 --runs 3 --tokens 64`: | | ms/tok | tok/s | |---|---|---| | Baseline (origin/main @15288d7a) | 156.3 | 6.40 | | This PR | 150.0 | 6.67 | **~4.0% faster**, tokens byte-identical. This does **not** beat ORT (17.38 tok/s) on its own — that requires the Inc-1b Scan-body capture lane (awaiting Justin's greenlight) plus removal of the shared eager `synchronize()` on the ~50 always-sync kernels. This is one more fat op off the eager-sync hot path, in the same reviewable single-op shape as #574. ## Blast radius Tile kernel path + its two tests only. Dead `launch_metadata` removed (Tile was its sole remaining caller). **No** change to shared capture machinery, the #443/#543 capture-correctness invariants, weight offload, session-reuse, or GAP-3. Refs #574 (Lever 2 lineage), #443/#543 (capture invariants). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Lever 2 PR-1 — make CUDA
Transposecapture-eligible via persistent metadataPart of the Lever-2 non-Scan swarm from
cohaagen-27b-decode-perf.mdtoward closing the native-vs-ORT gap on the qwen3.6-27b (dense-GQA + LinearAttention hybrid). Transpose only — bounded, byte-exact, single-op.Problem
TransposeKernelusedlaunch_metadata, which on every call didalloc_raw+htod(perm/stride upload) +synchronize()+free_raw. Thesynchronize()only guards the immediatefree_raw, but it serializes the stream on every one of the ~624 Transpose calls per 27b decode step — in both the eager (Scan-child) and captured paths — and left Transpose reportingCaptureSupport::Unsupported, so it could never fold into the parent CUDA graph.Fix
Migrate
Transposeto the in-repoPersistentMetadata+launch_persistent_metadatapattern already blessed for Expand and Slice (#443/#543): cache the perm/stride buffer, upload/sync once at warm-up, then run with no per-call alloc/htod/sync. Once warmed on its exact shape/dtype/perm signature the kernel reportsSupportedand errors on a mid-capture signature change (identical guard to Expand). Sametranspose_byteskernel, identical metadata bytes → byte-exact.Blast radius
crates/onnx-runtime-ep-cuda/src/kernels/movement.rs(TransposeKernel + factory) + one new EP test.subgraph_graph_capturable,capture_quarantine_ops, run.rs) untouched — Transpose just starts reportingSupportedonce warmed.launch_metadatabut also readsrepeatsdevice→host viahost_ints, needing repeats-warming — not the same trivial mechanism.Measured yield (stock qwen3.6-27b-int4, CUDA; --steady --decode-skip 8 --warmups 2 --runs 3 --tokens 64)
-8.5 ms/tok (~5.4%, ~1.05×), token IDs byte-identical.
Honest caveat: this is one op of the Lever-2 swarm and does NOT beat ORT (17.38 tok/s / 57 ms/tok) alone — that is the two-lane result (full Lever-2 swarm + Inc-1b Scan-body capture). Body-internal Transposes get the desync benefit now; their capture-fold benefit needs Inc-1b (the Scan child never captures today).
Correctness (all pass)
transpose_warmed_metadata_captures_and_matches_eager: unwarmed → declines capture; first eager run warms →Supported; cached-metadata re-run byte-identical; capture+replay byte-identical to eager. Non-vacuous both directions.construction_gpuEP suite: 19/19 pass — capture surface unregressed.native_autoderive_io_cuda_e2e::stock_export_auto_derives_io_and_matches_cpu_oracle: PASS — stock 27b native CUDA == CPU fp32 oracle, byte-identical. (ORT-CUDA crashes on this model → CPU fp32 is the oracle, per Large-model (27B) native E2E offload blocked: Unsqueeze rank bug + missing recurrent_state + mobius#432 #384.)qwen3_0_6b_native_cuda_e2e: PASS; decode token IDs == pinned golden.cargo fmt --all --checkclean.Decision note:
.squad/decisions/inbox/cohaagen-lever2-transpose-pr.md.Do not auto-merge — Harry reviews first (author-lockout).