Skip to content

cuda : reduce large tensors through the copy engines when NCCL has no peer access - #16

Merged
Piggidragon merged 2 commits into
mainfrom
cuda/allreduce-copy-engine
Oct 16, 2026
Merged

Piggidragon merged 2 commits into
mainfrom
cuda/allreduce-copy-engine

Conversation

@Piggidragon

@Piggidragon Piggidragon commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Large all-reduces under -sm tensor now go through the copy engines instead of NCCL, on two GPUs without peer access. Prompt processing gets 15–17% faster, decode is unchanged, and the result is at least as precise as NCCL's.

Why

Without peer access, NCCL moves a reduction through host memory with an SM on each GPU. That costs twice:

  • A lost SM: the stream-k MMQ tiles launch exactly one block per SM. With one SM taken by NCCL, the last block has to wait for a second wave. In an nsys trace on the RTX 3060, matrix multiplications that overlapped an NCCL kernel took 1.4–1.8× as long (median, by grid size). NCCL_MAX_NCHANNELS=1 did not help: one SM is already enough to cause the second wave.
  • One direction at a time: NCCL does not use both directions of a link at once. The 3060 sits on PCIe 3.0 x4: 3.3 GB/s each way alone, 2.8 + 2.8 GB/s with both directions in use. NCCL reached about 2 GB/s per AllReduce (2.6 MB in 1.29 ms).

Change

ggml/src/ggml-cuda/allreduce-ce.cu, about 300 lines:

  • Send: each GPU converts its F32 part to BF16 (the same wire precision as NCCL's large-tensor path) and copies it to pinned memory in 512 KiB chunks, on its own copy stream.
  • Receive: on a second copy stream, each GPU copies the peer's chunks back as soon as they arrive. Both directions of both links are busy, and no SM is.
  • Sum: a small kernel adds the two BF16 values in F32, in the same order on both GPUs, so both get bit-identical results. This rounds once less than NCCL, which rounds the BF16 sum to BF16 again.
  • Ordering: the GPUs are ordered with events only, and the host never waits. Two slots alternate, so the next piece can be converted while the previous one is still being transferred.
  • Memory: the buffers (16 MiB VRAM, 8 MiB pinned memory per GPU) are allocated with the first large reduction, per context that has one. An MTP draft context processes the prompt batches too, so with MTP it is 32 MiB of VRAM per GPU in total. If the allocation fails, NCCL takes over.

With NCCL compiled in, two GPUs and no peer access, reductions are now routed like this:

Size Path
below 512 KiB internal pipeline (#14)
from 512 KiB copy engines (this PR)
unsupported by both (not F32, not contiguous) NCCL

With peer access everything stays on NCCL.

Environment variables:

  • GGML_CUDA_AR_CE=0: NCCL for the large reductions.
  • GGML_CUDA_AR_CE_MIN_BYTES: the threshold. Used with 1 below to send every reduction through the copy engines in the tests.

docs/multi-gpu.md now describes the three paths and their variables, and adds --attn-split (#15) to the argument table.

Results

Qwen3.8-27B UD-Q4_K_S, RTX 4070 (PCIe 4.0 x16) + RTX 3060 (PCIe 3.0 x4), -sm tensor -ts 0.625/0.375 -as 1/3, q5_1 cache, native FA, -ub 512, t/s:

test NCCL (GGML_CUDA_AR_CE=0) copy engines change
pp512 710.5 831.5 +17.0%
pp2048 700.5 817.8 +16.7%
pp512 @ d16384 638.7 733.2 +14.8%
pp2048 @ d16384 638.2 732.1 +14.7%
pp3 97.5 97.5 same
tg32 36.9 36.9 same

llama-server with the production configuration (262144 tokens of q5_1 cache in 2 slots, MTP):

prompt prefill NCCL prefill copy engines peak VRAM (4070 / 3060)
10k tokens 637 t/s 733 t/s 11730 / 11694 → 11748 / 11718 MiB
95k tokens 511 t/s 570 t/s same

Precision

llama-perplexity --kl-divergence, wikitext, 40 chunks of 512, against F32 reductions everywhere (GGML_CUDA_ALLREDUCE=internal GGML_CUDA_AR_BF16_THRESHOLD=0):

mean KLD maximum KLD same top token
NCCL (BF16 sum) 0.00262 ± 0.00022 1.88 97.53%
copy engines (F32 sum of BF16 values) 0.00243 ± 0.00012 1.07 97.81%

Tests

  • test-llama-archs: run with the defaults, and with GGML_CUDA_AR_CE_MIN_BYTES=1 (every reduction through the copy engines, including the -as 1/0 configs with empty slices). 321 OK Meta rows each. The only failure is maion-coder MoE in "Mixed batch", which also fails on a single GPU.
  • Qwen3.5-0.8B: the same greedy output with all reductions through the copy engines, with NCCL only, and with the defaults. The log confirms that the copy-engine buffers are allocated.

Review

A review (reviewer-xhigh) found no bug in the reduction itself:

  • Hazards: every RAW/WAR hazard was traced across the 6 streams and both devices, and holds.
  • Stress test: a harness with CUDA graphs and no host syncs, comparing both GPUs bitwise and against an exact CPU emulation, showed 0 mismatches, including inf/NaN/-0 and empty slices.
  • Sanitizer and trace: compute-sanitizer memcheck was clean. An nsys trace shows both link directions busy.

Four smaller points are fixed in the second commit:

  • MUSA build: the file did not compile for MUSA, because cudaHostAlloc is missing there. It now has the same guard and stubs as allreduce.cu.
  • Docs, peer access: the docs said GGML_CUDA_AR_NCCL_MIN_BYTES also enables the copy engines with peer access. They stay off there, as intended.
  • Memory, MTP: an MTP draft allocates its own buffers, see Memory above.
  • Stale reference: a header comment referred to lanes that this PR does not have.

The copy engines are now also set up when the internal pipeline is not, which needs Volta or newer.

🤖 Generated with Claude Code

… peer access

Without peer access NCCL stages a reduction through host memory with an SM
on each GPU. That SM is missing for a kernel that launches one block per SM
(the stream-k MMQ tiles), which then runs a second wave: on an RTX 3060 the
matrix multiplications took 1.4-1.8x as long while an NCCL kernel ran. NCCL
also does not use both directions of a link at the same time (3060 on PCIe
3.0 x4: 3.3 GB/s each way alone, 2.8 + 2.8 GB/s at once).

The new path (allreduce-ce.cu) converts each GPU's F32 part to BF16, copies
it to pinned memory in chunks on one copy stream and copies the peer's
chunks back on another as they arrive; a small kernel adds the two BF16
values in F32, in the same order on both GPUs. The GPUs are ordered with
events only. The buffers (16 MiB VRAM, 8 MiB pinned per GPU) are allocated
with the first large reduction, so a draft context allocates nothing; if
that fails, NCCL takes the call.

With NCCL and no peer access, reductions from 512 KiB go this way (below
it the internal pipeline, #14). GGML_CUDA_AR_CE=0 keeps NCCL for them,
GGML_CUDA_AR_CE_MIN_BYTES sets the size. docs/multi-gpu.md describes the
paths and variables, and --attn-split.

Qwen3.8-27B UD-Q4_K_S, RTX 4070 + 3060, -ts 0.625/0.375 -as 1/3, q5_1:
pp512 711 -> 831 t/s (+17%), pp2048 @16k 638 -> 732 (+15%), tg and pp3
unchanged. Against F32 reductions (40 x 512 tokens): mean KLD 0.00243
(NCCL 0.00262), same top token 97.81% (NCCL 97.53%).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- MUSA lacks cudaHostAlloc: the file gets the same guard and stubs as
  allreduce.cu, init returns nullptr there
- the copy engines no longer depend on the internal pipeline, which needs
  Volta or newer
- an MTP draft context processes the prompt batches too and allocates its
  own buffers: docs and comments say so (32 MiB VRAM per GPU with MTP)
- docs: GGML_CUDA_AR_NCCL_MIN_BYTES enables only the internal pipeline with
  peer access and is the default of GGML_CUDA_AR_CE_MIN_BYTES
- the header no longer refers to lanes that this change does not have

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Piggidragon
Piggidragon merged commit 26781de into main Oct 16, 2026
Piggidragon added a commit that referenced this pull request Oct 16, 2026
The paths of #14, #16 and this PR were chosen by the hardware and switched by environment variables. They are now
opt-in with one flag, like the other features of this fork, and the default is upstream's NCCL:

- --allreduce host: small reductions through the internal pipeline, large ones through the copy engines, through
  pinned host memory (#14, #16)
- --allreduce p2p: the peer-to-peer kernel and the copy engines from GPU to GPU; host without peer access. The paths
  enable peer access themselves, GGML_CUDA_P2P is not needed
- llama_context_params.allreduce_type, passed to the backends through the new reg proc ggml_backend_comm_set_mode
  before the meta backends of a context are created
- llama-bench -ar, test-llama-archs configs "Meta -ar host" and "Meta -ar p2p -so"
- GGML_CUDA_AR_CE, GGML_CUDA_AR_CE_P2P and GGML_CUDA_AR_P2P_SMALL are gone; the two size thresholds stay as tuning
  variables, GGML_CUDA_ALLREDUCE still overrides
- README: the features of this fork as one list (what, benefit, how to enable), and a table upstream vs this fork

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant