[CUDA] Remove memset nodes from decode CUDA graphs and prefer GEMV at small M - #32884
Conversation
## Description During CUDA speculative decoding (DFlash2 width 7, batch 1), the GPU sat idle for about 2 ms of every ~27 ms step while the engine did host work between the drafter and target graphs. On an RTX 4090 with Qwen3.8-27B, three host-side costs accounted for most of that idle time: first-touch page faults in the memory-mapped CPU embedding table, a pinned host allocation and synchronizing free for every short-lived device view, and a device round trip for every accepted draft token. This PR removes all three. Token outputs are unchanged. ## Summary of Changes | File | Change | |------|--------| | `src/ep/cuda/interface.cpp` | `PinnedHostPool` recycles the pinned host mirrors (256 B to 1 MiB, power-of-two classes) of `GpuMemory` views, so views no longer pin with `cudaHostAlloc` and release with `cudaFreeHost`. `cudaFreeHost` also waits for the device. A mirror released while a host-to-device copy from it may still be queued keeps an event recorded behind that copy and is only handed out again after the event completes. Larger mirrors keep the old path. | | `src/ep/cuda/search_cuda.cpp` | `GreedySearch_Cuda::CommitToken` fast path for a live batch-1 search and a non-EOS token. The host already knows what `CheckForEOSAndPad` would do, so the token is written to the next-token slot and the sequence with two async copies. This replaces two kernels, a device-to-host copy and a stream synchronization per accepted draft token. EOS, finished searches and batch > 1 keep the existing path. | | `src/models/cpu_embedding.cpp`, `.h` | `CpuEmbedding::Prefault` looks every row up once, in chunks of 1,024 ids, when the session is created. ORT maps CPU initializers stored as external data straight from the file, so otherwise each decode lookup of a not-yet-touched row page-faults. | | `test/cpp/search_checkpoint_tests.cpp` | `CommitTokenAppendsNonEosTokensUntilMaxLength` checks the fast path's sequence contents, next token and max-length handling. | ## Testing - `engine_unit_tests` with the CUDA plugin EP: all 806 tests pass, including the new `CudaSearchCheckpointTest.CommitTokenAppendsNonEosTokensUntilMaxLength` and the existing EOS `CommitToken` tests. - Token-exact in-process A/B: with a temporary switch between the old and new commit and mirror paths, 16 prompts x 256 tokens at 0 and 8K context generated identical tokens. The only difference was on one prompt's first, cold-prefix-cache run, and two runs of the old path differed the same way. - CPU embedding lookups of 8 random rows over the 248,320 x 5120 fp16 table: about 1.1 ms before prefaulting and 12 us after. Prefaulting the table takes about 1.8 s at load. - Per-step host phases (nsys + NVTX, short prompt): | Phase | Before (us of GPU idle) | After | |---|---:|---:| | CPU embedding, target + drafter (nested in the two input-preparation rows) | 539 | 74 | | Accepted-token commit (`GenerateNextTokens`) | 328 | 229 | | DFlash2 input preparation | 329 | 103 | | Target decoder input preparation | 533 | 142 | | Total GPU idle per step (after column includes the ORT graph changes) | 2,828 | 1,641 | - End-to-end, RTX 4090, Qwen3.8-27B INT4 + DFlash2, greedy, 16 prompts (0 and 8K context) x 256 generated tokens, same ORT build, against two baseline runs: the median paired decode step time went down 2.8-3.1% at short context and 0.3-0.8% at 8K, and TTFT went down 4-6% because prefill no longer takes embedding page faults. Together with the ORT graph changes, the step went from 26.2 to 25.0 ms (short) and from 27.1 to 26.0 ms (8K) over 8 prompts x 1,024 tokens. ## Motivation and Context The ORT-side decode graph overheads are handled separately in microsoft/onnxruntime#32876 and microsoft/onnxruntime#32884. ## Checklist - [x] Tests added/updated - [x] No breaking changes - [ ] Documentation updated (not applicable) --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Baiju Meswani (baijumeswani)
left a comment
There was a problem hiding this comment.
In varlen_causal_conv_with_state.cc:136-139, Fill<int32_t> uses 32-bit indexing. For a state update larger than 2^31 four-byte values, the index can wrap and write outside the buffer. Please use cudaMemsetAsync for larger outputs, or make Fill safe for 64-bit counts. A boundary test would help. Also, please check the fixed 10% CUTLASS timing penalty with weights larger than L2; it could choose a slower kernel. This is a comment review, not a request for changes.
…peed up MatMulNBits GEMV (#32876) ## Description Speculative decoding verifies a small batch of rows per step (8 for DFlash2 width 7), which lands two CUDA decode paths on poor kernels. Which kernel is best for these small-M shapes depends on the GPU: on SM 8.x cuBLAS picks a serial split-K kernel that is 10x+ slower than the small-N GEMV, on SM 12.0 the GEMV only helps at M = 2..8, and on SM 9.0 cuBLAS is as fast or faster. A fixed architecture policy regressed some of these (e.g. RTX 3060 at M = 9..64, N = 1024, up to 4.1x slower), so this PR selects the kernel per shape and per device by measuring it. 1. **GEMM auto-tuning for fp16/bf16 `MatMul` (opt-in).** With session config `ep.cuda.enable_gemm_auto_tune=1` (or env `ORT_CUDA_GEMM_AUTO_TUNE=1`), the first run of each eligible shape times cuBLAS and the small-N GEMV on the current device and caches the faster one for the process. When disabled (default), MatMul uses cuBLAS as on `main`. 2. **bf16 small-N GEMV.** The GEMV is templated on fp16/bf16 (fp32 accumulation, deterministic split-K). 3. **MatMulNBits fpA_intB GEMV at M >= 4.** The M x StepK activation tile plus the CtaM x CtaN accumulators push the 4/8-bit kernel to about 250 registers; halving CtaN frees registers and doubles the block count. The existing fpA_intB tactic profiler still chooses GEMV vs CUTLASS per M bucket. ## Summary of Changes ### GEMM auto-tuner | File | Change | |------|--------| | `include/onnxruntime/core/session/onnxruntime_session_options_config_keys.h` | New key `ep.cuda.enable_gemm_auto_tune` (`kOrtSessionOptionsCudaEnableGemmAutoTune`). | | `onnxruntime/core/providers/cuda/math/gemm_auto_tuner.{h,cc}`, `gemm_auto_tuner_impl.cu` | Dispatch policy resolution, candidate timing, and a process-wide cache keyed by (device UUID, dtype, M, N, K, operand-alignment class). | | `onnxruntime/core/providers/cuda/math/matmul.{h,cc}` | `MatMul<MLFloat16/BFloat16>` resolves the policy at construction and, for eligible single GEMMs, looks up / tunes the kernel. Ineligible shapes (M > 64, N > 1024, K < 128, transposes, alpha != 1, batched) go straight to cuBLAS with no overhead. | Dispatch policy, in precedence order: | Setting | Behavior | |---|---| | `ORT_ENABLE_SMALL_N_GEMV=1` / `0` | Forces the small-N GEMV (when eligible) / cuBLAS; bypasses tuning. Kept for A/B testing and back-compat with the opt-in from #31478. | | session config `ep.cuda.enable_gemm_auto_tune` | `1` auto-tunes, `0` uses cuBLAS. | | env `ORT_CUDA_GEMM_AUTO_TUNE` | Fallback when the session config is unset. | | none | cuBLAS (same as `main`). | Tuning details: - **CUDA graph safe.** Tuning synchronizes the stream, so it is skipped while the stream is capturing; an untuned shape then uses cuBLAS and is not cached. ORT's warm-up runs before capture do the tuning. - **Representative timing.** Each timed run starts from a decode-like L2 state: a read-only pass over a scratch buffer (2x L2, capped at 256 MiB) evicts the weights without leaving dirty lines to write back, then the activation is read back in, since in decode it was just produced by the previous op (with a cold activation the H200 decisions disagreed with CUDA graph replay). A GPU delay kernel is queued first so host launch gaps of multi-launch candidates are hidden, candidates are interleaved, the fixed event-pair overhead (measured with an empty region) is subtracted, and the median of 10 runs is used. - **Stable choice.** cuBLAS is replaced only if the candidate is at least 5% faster. The cache is process-global (first insertion wins, tuning serialized by a mutex), so every session in a process runs the same kernel for the same shape. Each decision is logged at VERBOSE. - Cost: about 3 ms once per eligible (M, N, K) shape, M <= 64. ### Small-N GEMV | File | Change | |------|--------| | `onnxruntime/core/providers/cuda/math/matmul_small_n_gemv.cu`, `.h` | Covers M <= 64 in ordered 8-row chunks. Adds `SmallNGemvVecSplitKKernel` for even N, K % 8 == 0 and 16-byte aligned A. Each lane owns two columns and eight consecutive K rows, so one 16-byte broadcast A load feeds 16 FMAs; warps reduce through shared memory, then a deterministic last-block split-K reduction runs over `st.cg`/`ld.cg` partials. Completion counters are cleared by a one-block kernel instead of `cudaMemsetAsync` (a memset node costs several microseconds inside a CUDA graph). Templated on fp16/bf16. Every eligible vectorized shape splits K at least twice, so the unreachable single-slice store path was removed. | ### MatMulNBits fpA_intB GEMV | File | Change | |------|--------| | `onnxruntime/contrib_ops/cuda/llm/fpA_intB_gemv/dispatcher.h` | For M >= 4, 4/8-bit weights (StepK < 64) use `CtaNLargeM = CtaN / 2` (8 -> 4 without zero points). M = 1..3 and the 2-bit layout are unchanged. | ## Testing - CUDA internal tests (`onnxruntime_providers_cuda_ut`, H200): - `GemmAutoTunerTest.*`: policy precedence and parsing, candidate selection margin, first-insertion-wins cache, timing/selection with synthetic GPU-delay candidates, cached keys not re-timed, CUDA graph capture detection. - `MatMulSmallNGemvTest.*`: fp16 and bf16 for M = 1..8, 17, vectorized/scalar variants, column-tile boundaries, stale counters cleared by the launcher, vectorized-kernel selection. - `MatMulSmallNGemvOpTest.*`: fp16 and bf16 MatMul through the CUDA EP, both forced and auto-tuned, for eligible shapes (M = 1..64, N = 1..1024) and ineligible fallbacks (K = 127, M = 65, N = 1025). - `onnxruntime_provider_test --gtest_filter='*MatMul*:*Gemm*'` (1003 tests) passes with and without `ORT_CUDA_GEMM_AUTO_TUNE=1`. - Python `test_matmul_gemm_auto_tune_cuda_graph_replay`: fp16 MatMuls tuned during warm-up, then captured and replayed with changing inputs. - Compiled for sm_86 and sm_90 in both the in-tree and the plugin CUDA EP builds. - H200 (SM 9.0), CUDA graph replay of enough MatMul copies that weights stream from DRAM, per-MatMul us, K = 5120, best of two interleaved sessions per mode. Auto-tune picks the GEMV only where it wins, and is within 1% of the faster kernel everywhere except one near-tie inside the 5% margin (bf16 4x48). Decisions were identical across five separate processes. | dtype | M | N | cuBLAS | forced GEMV | auto-tune (choice) | |---|---:|---:|---:|---:|---| | fp16 | 1 | 48 | 6.25 | 6.23 | 6.23 (GEMV) | | fp16 | 2 | 48 | 6.95 | 6.21 | 6.20 (GEMV) | | fp16 | 4 | 48 | 6.94 | 6.72 | 6.72 (GEMV) | | fp16 | 8 | 48 | 7.00 | 7.57 | 6.99 (cuBLAS) | | fp16 | 16 | 48 | 7.31 | 13.03 | 7.31 (cuBLAS) | | fp16 | 8 | 1024 | 8.17 | 13.60 | 8.19 (cuBLAS) | | fp16 | 16 | 1024 | 8.33 | 22.62 | 8.29 (cuBLAS) | | bf16 | 2 | 48 | 6.95 | 6.31 | 6.31 (GEMV) | | bf16 | 4 | 48 | 6.94 | 6.72 | 6.96 (cuBLAS) | | bf16 | 8 | 1024 | 8.28 | 13.89 | 8.17 (cuBLAS) | Tuning adds about 3 ms to the first run of a session per eligible shape. - Previous results for the kernels themselves (RTX 4090, sm_89, CUDA 13.3, nsys, weights streamed from DRAM), which auto-tuning now reaches when it selects the GEMV: | Shape (M x N x K) | cuBLAS / before | GEMV / after | |---|---:|---:| | fp16 MatMul 8 x 48 x 5120 | 78.6 us | 4.2 us | | fp16 MatMul 8 x 1024 x 5120 | 8.4 us | 7.3 us | | MatMulNBits GEMV 8 x 34816 x 5120 | 117.1 us | 112.0 us | | MatMulNBits GEMV 8 x 10240 x 5120 | 40.4 us | 35.7 us | | MatMulNBits GEMV 8 x 5120 x 17408 | 60.0 us | 57.3 us | | MatMulNBits GEMV 8 x 1024 x 5120 | 10.4 us | 8.9 us | End-to-end on RTX 4090 (ORT GenAI, CUDA plugin EP, Qwen3.8-27B INT4 + DFlash2 width 7, greedy, 1,024 tokens) the GEMV + MatMulNBits changes gave x1.28-x1.31 decode throughput (8/8 prompts per context) with unchanged TTFT and MMLU-Pro (88/100). With this revision, set `ep.cuda.enable_gemm_auto_tune=1` in the model's session options to get the MatMul part of that gain. ## Motivation and Context - The small-N GEMV (#31478, extended to 64 rows in #32289) was opt-in because on SM 9.0 cuBLAS was faster in the real model, while on SM 8.x cuBLAS is 10x+ slower at M = 2..8. An architecture default (the first revision of this PR) regressed RTX 3060 above M = 8 (4.1x at M = 64, N = 1024) and was neutral on RTX 5060 Ti; per-shape measurement on the actual device avoids hand-maintained thresholds. - A dedicated tuner is used instead of TunableOp: TunableOp is not built into the CUDA plugin EP, has no CUDA graph capture guard, times with a hot L2, sleeps 50 ms per tuned shape, and is enabled EP-wide. - Candidates are a table, so follow-ups can add kernels (e.g. cuBLASLt heuristic algorithms, CUTLASS, TRT-LLM `cudaCoreGemm`/tinygemm2, FlashInfer TGV on Blackwell) and a persistent TSV tuning cache like #29588. - The follow-up #32884 removes the remaining memset nodes from the decode graph (fpA_intB split-K, paged XQA, causal conv) and makes the fpA_intB tactic profiler prefer the GEMV on near ties at small M. ## Checklist - [x] Tests added/updated - [x] No breaking changes. Without the new option MatMul uses cuBLAS as on `main`; `ORT_ENABLE_SMALL_N_GEMV` keeps its meaning. - [x] Documentation updated (session option key comment) --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… hybrid models - Clear the serial split-K semaphores of the fpA_intB CUTLASS GEMM, the paged XQA semaphores and the VarlenCausalConvWithState state_update output with a small kernel instead of cudaMemsetAsync. Inside a captured CUDA graph a memset node costs several microseconds of dependency latency (about 11 us per node in a synthetic graph on RTX 4090 vs 0.8 us for a zeroing kernel), and a hybrid decoder issues a few hundred of them per step. - Let the weight-only tactic profiler bias its selection: for M < 16, a CUTLASS tactic must beat the CUDA GEMV by more than 10%. The profiler times one synthetic weight matrix back to back, so matrices that fit in L2 are timed L2-resident, which favours CUTLASS in near ties even though the GEMV is faster when weights stream from DRAM during decode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
fab434d to
5a06bdb
Compare
…ode (#2643) ## Description This follows #2642. It removes most of the remaining GPU idle time between the drafter and target graphs in batch-1 CUDA speculative decoding (DFlash2 width 7). On an RTX 4090 under Windows with Qwen3.8-27B, nsys showed about 2 ms of idle GPU per ~26 ms step. Most of it came from three places: - **Copy-engine switches.** On WDDM, every switch between a copy-engine operation (`cudaMemcpyAsync`, `cudaMemsetAsync`) and a kernel on the same stream costs tens of microseconds of device idle time. The engine issues dozens of tiny copies and memsets per step. - **Synchronous state replay.** After a partially accepted verify, the compact gated-delta-net/conv state replay (~0.37 ms) ran with the host blocked on it. - **Redundant host work.** The engine rebuilt ~400 fixed-state tensor views every step, re-sampled a greedy token that verification had already computed, and drained the stream in several places where it did not need to. ## Summary of Changes | Area | Change | |------|--------| | Small copies (`src/ep/cuda/interface.cpp`, `model_kernels.cu`, `kernels.h`, `search_cuda.cpp`) | Small copies and memsets now run as kernels:<ul><li>`GpuMemory` host-to-device uploads up to 4032 bytes use `LaunchStoreBytes`, which passes the bytes as kernel parameters, so the host mirror is free when the call returns.</li><li>Copies up to 256 KiB in any direction use `LaunchCopyBytes`, which reads or writes the pinned mirror through unified addressing.</li><li>Memsets up to 256 KiB use `LaunchZeroBytes`.</li><li>The greedy argmax read-back uses a strided gather kernel that writes the pinned buffer directly.</li><li>The search transaction checkpoint copies, `ResetDone`, `RewindTo`, and `CommitToken` token stores also use these kernels.</li></ul>Larger transfers keep the copy engine. | | GDN state replay (`src/engine/fixed_state_pool.cpp`, `src/ep/cuda/interface.cpp`, `model_kernels.cu`) | `PrepareCommit` no longer launches the compact replay and waits for it:<ul><li>The replay descriptors are deferred to the pool's next operation (`Reserve`, `CapturePrefixCheckpoint`, or `PrepareCommit`), which enqueues them ahead of every later reader of the banks on the same stream. The device runs the replay while the host prepares the next step.</li><li>Discarding a prepared reservation drops its deferred replay.</li><li>A launch failure marks the pool unhealthy.</li><li>A new `ReplayGatedDeltaNetKernel` stages each head's decays, keys, and deltas in shared memory and streams whole state rows as `float4`: about 300 us instead of 320-525 us for the 151 MB of fp32 state.</li><li>Conv states and ineligible geometries keep the generic kernel.</li></ul> | | Fixed-state reservations (`src/engine/fixed_state_pool.cpp`) | <ul><li>Tensor views and bindings are cached per layout (row count, direct bank, first slot, capture mode) in `FixedStateReservationViews`, which every reservation holds by `shared_ptr`.</li><li>On CUDA, direct-binding reservations no longer drain the stream after the capture-count upload.</li><li>Replay descriptors are built from raw tensor pointers.</li></ul> | | Greedy drafted rows (`src/engine/scheduled_requests.cpp`, `.h`) | Drafted requests already run without logits processors (`Request::DraftTokenValidationError`), so the correction/bonus token of a greedy drafted request is the verification argmax of the row after the accepted prefix.<ul><li>That token is committed with `CommitToken` instead of sending the row through the batched sampler again.</li><li>These requests take the external-sampling checkpoint, as before.</li><li>They get a private next-token slot for the transaction, so they never write into a batched-sampler slot that another request now owns.</li></ul> | | Graph runs (`src/engine/decoders/simple_decoder.cpp`, `src/dflash2_drafter.cpp`) | Captured CUDA-graph replays of the target run with `disable_synchronize_execution_providers=1`. The first host read of their outputs (the sampled token ids) synchronizes the stream. The drafter does the same when it reads drafts back. | | CPU embedding (`src/models/cpu_embedding.cpp`, `src/smartptrs.h`) | New `DeviceInterface::RecyclesHostMirrorsAfterUpload`; CUDA returns true for mirrors up to 1 MiB. When it holds, each lookup takes a fresh pooled pinned mirror instead of synchronizing the stream before it reuses one. `kDeviceInterfaceVersion` goes to 7. | | Docs | `paged_attention_engine.md` describes the deferred replay and the greedy drafted-token commit. `cpu-embedding-engine.md` describes the mirror handling. | | Tests (`test/cpp/engine/fixed_state_pool_tests.cpp`) | New tests:<ul><li>`CudaFixedStatePoolTest.GatedDeltaNetReplayMatchesHostRecurrence` checks the fast and generic kernels bit-exactly against the host recurrence.</li><li>`PrefixCheckpointAfterPartialAcceptanceSeesReplayedState` covers a checkpoint captured right after a deferred replay.</li><li>`DiscardedPartialAcceptanceLeavesStateUnchanged` covers dropping the replay on discard.</li></ul> | ## Testing - `engine_unit_tests` with the CUDA plugin EP: 809/809 pass, including the three new tests. - Microbenchmark (RTX 4090, WDDM): after a busy kernel, 6 small copies interleaved with kernels added 274 us (host to device) and 343 us (device to host) of wall time as `cudaMemcpyAsync`, and 9 us as copy kernels. - nsys + temporary NVTX, short prompt, 48 steps, host pinned to the performance cores: | Per step | Before | After | |---|---:|---:| | Step span | 26.19 ms | 25.00 ms | | GPU idle (incl. ~0.4 ms of in-graph launch gaps) | 1.98 ms | 1.16 ms | | Fixed-state reserve + commit host time | ~0.56 ms | ~0.07 ms | | GDN replay on the critical path | 0.37 ms (host blocked) | mostly overlapped | - End-to-end A/B against the #2642 build (Qwen3.8-27B INT4 + DFlash2, greedy, 8 prompts x 256 tokens, 2 interleaved runs each, minimum per prompt): the median paired step time went down 1.4% at short context, 1.9% at 8K and 3.7% at 32K. The desktop was active during the runs, so single runs vary by several percent. - Greedy outputs: repeated runs of the #2642 build already differ on 2 of 8 prompts. The new build matched it on 6-7 of 8, which is within that run-to-run variation. ## Motivation and Context These changes build on #2642 (pinned mirror pool, `CommitToken` fast path, CPU embedding prefault). The ORT-side graph changes are in microsoft/onnxruntime#32876 and microsoft/onnxruntime#32884. ## Checklist - [x] Tests added/updated - [x] No breaking changes (`kDeviceInterfaceVersion` bumped for the new virtual) - [x] Documentation updated --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Baiju Meswani (baijumeswani)
left a comment
There was a problem hiding this comment.
Non-blocking suggestion: The kernel choice now depends on GPU L2 cache size, but the saved choice is shared across GPUs. Two GPUs with the same SM and different L2 sizes could reuse a slower choice. Please save choices per GPU, or include L2 size in the cache key. This can be a follow-up.
Description
Speculative decoding on a hybrid (gated delta net + full attention) decoder replays one CUDA graph per verify step, and that graph carried up to about 130 memset nodes: serial split-K semaphores of the fpA_intB CUTLASS GEMM, paged XQA semaphores, and the
VarlenCausalConvWithStatestate_updateoutput. Inside a graph each memset node adds several microseconds of dependency latency, while a small kernel adds well under one. This PR clears those buffers with kernels and makes the weight-only tactic profiler stop picking CUTLASS over the CUDA GEMV on near ties at small M, which it does because it times L2-resident synthetic weights.Summary of Changes
Replace in-graph memsets with kernels
onnxruntime/contrib_ops/cuda/llm/cutlass_extensions/gemm/device/gemm_universal_base_compat.hClearSplitKSemaphoresKernelreplacescudaMemsetAsyncfor the serial split-K semaphore workspace (int-sized workspaces up to 4 MiB; anything else keeps the memset).onnxruntime/contrib_ops/cuda/bert/xqa/xqa_paged_impl_gen.cuh,xqa_paged_loader_impl.cuhonnxruntime::cuda::Fill<int32_t>.onnxruntime/contrib_ops/cuda/bert/varlen_causal_conv_with_state.ccstate_updateoutput is zeroed withFill<int32_t>when its size is a multiple of 4 bytes, and withcudaMemsetAsyncotherwise.fpA_intB tactic selection at small M
onnxruntime/contrib_ops/cuda/llm/gemm_profiler.hgetSelectionTime(m, tactic, time)hook. The default returns the measured time unchanged, so other profilers are unaffected.onnxruntime/contrib_ops/cuda/llm/fpA_intB_gemm_profiler.h,.ccTesting
ClearSplitKSemaphoresKernelran for every split-K launch.PagedAttention/ XQA tests, and theVarlenCausalConvWithStatetests.Motivation and Context
Checklist