Repository navigation
Avoid pinned buffers in CUDA Split and Concat fast paths - #32410
Conversation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟢 Approval recommended
The refactoring preserves existing dispatch behavior while removing unnecessary pinned allocations.
Pull request overview
Moves CUDA Split and Concat equal-size fast paths ahead of pinned-buffer allocation.
Changes:
- Passes up to 32 pointers directly through
TArray. - Retains GPU pointer buffers for larger or unequal operations.
- No actionable issues identified.
File summaries
| File | Description |
|---|---|
split.cc |
Avoids pinned buffers for small equal splits. |
concat.cc |
Avoids pinned buffers for small equal concatenations. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0
- Review effort level: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Tianlei Wu (tianleiwu)
left a comment
There was a problem hiding this comment.
Reviewed the reordered dispatch in both kernels. The change is functionally equivalent to the previous code and the optimization is well-motivated — CudaAsyncBuffer's constructor eagerly calls AllocCpuPtr() (pinned), so declaring it before the branch really did cost a pinned alloc/free pair per Run() even on the inline path that never touched it.
Correctness checks that pass:
p.inputsis populated with exactlyctx->InputCount()entries byConcatBase::PrepareForComputeImpl, so the hoistedconcat_sizesloop indexingp.inputs[i]is safe.element_bytes/block_size_*are still computed after thep.output_num_elements == 0early return, sop.output_axis_pitch / Shape()[p.axis]cannot divide by zero.- The Concat fast path is reachable only when the output is non-empty and all concat dims are equal, which implies every input is non-empty — no null
DataRaw()reaches the kernel. - Split still materializes all output tensors via
ctx->Output(i, ...)before theShape().Size() <= 0early return, preserving the contract that outputs are allocated for an empty input. TArray<T, 32>::TArray(int32_t)ORT_ENFORCEssize <= capacity, and both call sites are guarded by<= 32.- Hoisting
CopyToGpuabove the equal/unequal branch is order-safe: all copies are issued on the same compute stream and consumed only by the following launch.
Nice side effect worth calling out: the fast path no longer touches pinned memory, which widens the set of CUDA-Graph-capturable Split/Concat configurations — the same property the existing Split3Inner comment documents.
Suggestions (see inline comments):
axis_dimension_input_output_mappingis still allocated and filled on thesame_*_size && count > 32path where it is never consumed. Since removing per-Run()allocations is the point of this PR, moving it into the unequal-size branch is in scope.- No test exercises the
> 32fallback, which is the branch whose structure actually changed.split_op_test.cc/concat_op_test.cctop out at a handful of inputs/outputs, sosame_*_size == true && count > 32is unexercised on CUDA. A case with 33 equal-sized inputs (Concat) and 33 equal-sized outputs (Split) would pin both the boundary and the GPU pointer-buffer fallback.
Minor, non-blocking:
32now appears four times across the two files as both theTArraycapacity and the dispatch guard. A sharedconstexpr int kMaxInlinePointerCount = 32;used for both would stop the guard and the capacity from drifting apart and turning anORT_ENFORCEinto a runtime failure.- Neither file directly includes
<algorithm>(forstd::all_of) or<vector>; both rely on transitive includes. Pre-existing, but the PR rewrites those exact lines.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resolve the conflict with #32410 by dispatching the ragged by-value path before the pinned CudaAsyncBuffer is allocated, so neither by-value path touches pinned host memory.
## Description CUDA `Concat`'s ragged path (inputs of unequal size along the concat axis) uploaded four `CudaAsyncBuffer`s to the device on every `Run`, one of which is sized by the *output* axis dimension. This change recovers the input index by scanning prefix sums instead, which makes it possible to pass the per-input metadata by value in `TArray`s — matching what the equal-size path already does. The ragged by-value path is dispatched before any `CudaAsyncBuffer` is created, like the equal-size path after #32410. So for up to 32 inputs, Concat allocates no pinned host memory at all. That matters under CUDA graph capture: `cudaHostAlloc` is not allowed while a stream is capturing, and the failed allocation surfaced as a misleading `alloc failed` (seen with a DFlash2 drafter graph that concatenates `[1, H]` and `[N - 1, H]` slices). The result is also a large reduction in host-to-device traffic for graphs that concatenate many small tensors, which is common in decode-time LLM workloads. ## Summary of Changes | File | Change | |------|--------| | `onnxruntime/core/providers/cuda/tensor/concat.cc` | For up to 32 inputs, dispatch both equal-size and ragged inputs by value before the pinned pointer buffer is allocated; keep the buffer path for wider concats. | | `onnxruntime/core/providers/cuda/tensor/concat_impl.cu` | Add the by-value ragged kernel; recover the input index by scanning prefix sums rather than reading the output-sized mapping table. | | `onnxruntime/core/providers/cuda/tensor/concat_impl.h` | Declare the by-value launcher. | | `onnxruntime/test/providers/cpu/tensor/concat_op_test.cc` | Ragged CUDA tests at 32 inputs (by-value) and 33 inputs (buffer fallback). | | `onnxruntime/test/python/onnxruntime_test_python_cudagraph.py` | Ragged Concat on axis 0 and axis 1 under CUDA graph capture, with the inputs updated before each replay. | Notes: - The existing buffer-based path is retained unchanged for concats wider than 32 inputs, so nothing regresses for large fan-in. - Passing parameters by value leaves no host staging buffer for a captured memcpy node to re-read on replay. ## Testing - `concat_op_test.cc`: `CudaRagged32InputsUsesByValueMetadata`, `CudaRagged33InputsUsesGpuMetadataFallback`. - `onnxruntime_test_python_cudagraph.py`: `test_concat_unequal_sizes_cuda_graph_replay` (all 6 tests in the file pass on H200, CUDA 13.0). - Kernel output is bit-exact against NumPy across fp16/fp32/int64/int8, with zero-length inputs and 32/33/40-input ragged cases, with and without CUDA graph. - End-to-end on DeepSeek-V4-Flash (8xH200, tensor parallel, CUDA graph): MMLU-Pro (800 samples) unchanged at 0.67000. - End-to-end on a Qwen3.8-27B DFlash2 model with the drafter's CUDA graph enabled: previously failed at the drafter's ragged Concat on engine step 2; now runs cleanly (54/54 draft tokens accepted). ## Motivation and Context Measured on a DeepSeek-V4-Flash decode step (8xH200, tensor parallel, CUDA graph): | Metric | Before | After | |---|---|---| | Host-to-device copies per step | 417 | 1 | | Step time | 20.2 ms | 18.8 ms | | Batch-1 decode throughput | 49.49 tok/s | 53.21 tok/s | The saving is 0.46 ms of transfer plus 1.21 ms of the stalls that trailed those transfers. The benefit is general: any model whose graph concatenates a moderate number of tensors per step pays this cost today. ## Checklist - [x] Tests added/updated - [x] No breaking changes - [ ] Documentation updated (not applicable — no schema or API change)
Avoid allocating pinned host pointer buffers in the CUDA Split and Concat fast paths when all partitions are equal-sized and there are at most 32 pointers.
These paths pass a
TArraydirectly as a kernel argument, so theCudaAsyncBufferallocation and destruction were unnecessary. In a Qwen 3.8 decode trace, their removal reduced pinned allocation/free pairs from 1,635 to 1,355 and reduced time attributed tocudaFreeHostfrom 4.90 seconds to 11.3 milliseconds.The GPU pointer-buffer fallback is unchanged for more than 32 pointers and for unequal Split/Concat dimensions.
Motivation
cudaFreeHostcan synchronize outstanding device work. Allocating pinned pointer metadata before selecting the inline fast path introduced avoidable decode gaps even though the allocation was never copied to or consumed by the GPU.Testing
onnxruntime_provider_test --gtest_filter=*ConcatOpTest*:*SplitOperatorTest*(53 tests passed across four suites)