Skip to content

Avoid pinned buffers in CUDA Split and Concat fast paths - #32410

Merged
Baiju Meswani (baijumeswani) merged 2 commits into
microsoft:mainfrom
baijumeswani:baijumeswani/cuda-split-concat-inline-pointers
Sep 4, 2026
Merged

Baiju Meswani (baijumeswani) merged 2 commits into
microsoft:mainfrom
baijumeswani:baijumeswani/cuda-split-concat-inline-pointers

Conversation

@baijumeswani

@baijumeswani Baiju Meswani (baijumeswani) commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

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 TArray directly as a kernel argument, so the CudaAsyncBuffer allocation 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 to cudaFreeHost from 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

cudaFreeHost can 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

  • Release ARM64 CUDA build with CUDA 13.4 for SM120/SM121
  • onnxruntime_provider_test --gtest_filter=*ConcatOpTest*:*SplitOperatorTest* (53 tests passed across four suites)
  • Equal-sized fast paths and existing fallback-path coverage

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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.

@baijumeswani
Baiju Meswani (baijumeswani) marked this pull request as ready for review September 3, 2026 23:17

@tianleiwu Tianlei Wu (tianleiwu) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.inputs is populated with exactly ctx->InputCount() entries by ConcatBase::PrepareForComputeImpl, so the hoisted concat_sizes loop indexing p.inputs[i] is safe.
  • element_bytes / block_size_* are still computed after the p.output_num_elements == 0 early return, so p.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 the Shape().Size() <= 0 early return, preserving the contract that outputs are allocated for an empty input.
  • TArray<T, 32>::TArray(int32_t) ORT_ENFORCEs size <= capacity, and both call sites are guarded by <= 32.
  • Hoisting CopyToGpu above 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):

  1. axis_dimension_input_output_mapping is still allocated and filled on the same_*_size && count > 32 path 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.
  2. No test exercises the > 32 fallback, which is the branch whose structure actually changed. split_op_test.cc / concat_op_test.cc top out at a handful of inputs/outputs, so same_*_size == true && count > 32 is 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:

  • 32 now appears four times across the two files as both the TArray capacity and the dispatch guard. A shared constexpr int kMaxInlinePointerCount = 32; used for both would stop the guard and the capacity from drifting apart and turning an ORT_ENFORCE into a runtime failure.
  • Neither file directly includes <algorithm> (for std::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>
@baijumeswani
Baiju Meswani (baijumeswani) merged commit 2af39c9 into microsoft:main Sep 4, 2026
94 checks passed
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Sep 23, 2026
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.
Tianlei Wu (tianleiwu) added a commit that referenced this pull request Sep 24, 2026
## 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)
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.

4 participants

⌂ 换网址