Skip to content

ggml-cuda: overlap the MoE shared expert on a separate stream#36

Open
roberteg16 wants to merge 6 commits into
gfx11from
rogarcia.cuda-multistream-shexp
Open

ggml-cuda: overlap the MoE shared expert on a separate stream#36
roberteg16 wants to merge 6 commits into
gfx11from
rogarcia.cuda-multistream-shexp

Conversation

@roberteg16

@roberteg16 roberteg16 commented Jul 6, 2026

Copy link
Copy Markdown

What this changes

A decode-only optimization for MoE models that have a gated shared expert, plus a supporting refactor and tests.

Optimization

  1. honor the current stream in multi-stream concurrency - ggml_cuda_op_mul_mat hardcoded stream 0, so ops assigned to an aux stream still ran on the main stream; and the single per-device cuBLAS handle was shared across streams (its workspace corrupts concurrent GEMMs). Fixed to use ctx.stream() on the non-split path and a per-(device,stream) cuBLAS handle.
  2. isolate branch scratch - replace the fragile node-interleaving (which could let ggml-alloc recycle a tensor read across the region) with a dedicated compute buffer for concurrent branch nodes (pre-assigned offsets, reused across layers).
  3. overlap the shared expert - ggml_backend_cuda_graph_optimize detects the join = add(ffn_moe_out, ffn_shexp) diamond by callback names. Routed experts stay on the main stream; only the shared expert forks onto one aux stream, joined at the add (one fork/join, reusing the branch scratch).

Gated to decode (single token) and byte-identical to sequential. Requires CUDA graphs and a single GPU. Enabled by default on RDNA3.5 (the architecture it is tuned for) and off elsewhere; GGML_CUDA_GRAPH_OPT=1/=0 forces it on/off on any architecture.

Cleanup & refactor

  1. drop dead reorder path - the branch scratch made the node-interleaving obsolete, so branches already run in their original graph order. The "restore original order" pass in ggml_cuda_graph_evaluate_and_capture (and the original_order it consumed) is now a no-op and is removed; only the is_valid() fallback that disables an unsafe region is kept.
  2. extract detection into a pure pass - move the diamond-detection heuristics (attention QKV fan-out and the MoE shared-expert diamond) out of ggml_backend_cuda_graph_optimize into a header-only ggml_cuda_detect_concurrent_regions() that operates only on the public ggml graph API and holds no CUDA state. Behavior unchanged; the heuristics can now be exercised in isolation.

Default

  1. enable by default on RDNA3.5 - when GGML_CUDA_GRAPH_OPT is unset, the optimization now defaults on for RDNA3.5 (the architecture it is tuned for) and off elsewhere; the env var still forces it on/off on any architecture.

Tests

  1. CUDA multi-stream concurrency tests (built for CUDA/HIP/MUSA):
    • detection - drive ggml_cuda_detect_concurrent_regions() with synthetic diamond graphs and assert which regions are and are not detected: attention 3-way fan-out, MoE shared-expert diamond, and negatives (fan-out of 2 or 4, multi-row/prefill, wrong fork name, wrong MoE operand names). Also pins the tensor-name contract the detector relies on.
    • end-to-end - run the diamonds through ggml_backend_sched with GGML_CUDA_GRAPH_OPT=1 and check the output against an analytic reference

Measured results

Command: llama-bench -hf unsloth/Qwen3.6-35B-A3B-GGUF:Q4_K_M -ngl 999 -n 128,1024 -r 1

test GGML_CUDA_GRAPH_OPT=0 (t/s) GGML_CUDA_GRAPH_OPT=1 (t/s) Δ absolute (t/s) Δ relative
tg128 53.33 57.28 +3.95 +7.41%
tg512 53.72 57.55 +3.83 +7.13%
tg1024 53.62 57.63 +4.01 +7.48%
tg2048 53.48 57.41 +3.93 +7.35%
tg4096 53.23 57.14 +3.91 +7.35%

roberteg16 and others added 2 commits July 5, 2026 02:46
Two correctness fixes for the GGML_CUDA_GRAPH_OPT fork/join concurrency so that
work assigned to a non-default stream actually runs there:

- ggml_cuda_op_mul_mat (the legacy tiled dispatcher) hardcoded ctx.stream(id, 0)
  for the src0 setup and ctx.stream(id, is) for the per-column compute loop. For a
  single GPU both resolve to stream 0 regardless of curr_stream_no, so any op on
  this path ran on the main stream while its aux-stream consumers only waited the
  fork event and read stale data. Use ctx.stream() for the non-split case; the
  multi-GPU split path keeps its per-device stream 0 for peer synchronization. This
  is a no-op when concurrency is off (curr_stream_no == 0).

- The cuBLAS handle was shared across streams (one per device). The handle carries
  a workspace that concurrent GEMMs on different streams corrupt, so give each
  (device, stream) its own handle.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
The attention QKV concurrency (GGML_CUDA_GRAPH_OPT) produced incorrect results
because it relied on interleaving the branch nodes in the graph so that ggml-alloc
would keep them non-overlapping. ggml-alloc sees the interleaved order while the
executor restores the original order, and is_valid() only checks the branches
against each other, not against tensors read across the region (e.g. the fork
output every branch consumes concurrently). ggml-alloc could therefore recycle
such a tensor's memory for a branch scratch, corrupting a concurrent read.

Replace the interleave with a dedicated compute buffer for the concurrent branch
nodes. Their data/buffer is pre-assigned into one CUDA buffer, reused across layers
(which run sequentially) with distinct per-node offsets within a region, so the
branches are structurally disjoint from each other and from the rest of the graph.
is_valid() then accepts the event without any graph reordering.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
@roberteg16 roberteg16 changed the title Rogarcia.cuda multistream shexp ggml-cuda: overlap the MoE shared expert on a separate stream Jul 6, 2026
roberteg16 and others added 2 commits July 7, 2026 06:41
Add a shared-expert detector to ggml_backend_cuda_graph_optimize (after the
attention pass, under GGML_CUDA_GRAPH_OPT). It matches the diamond
join = ggml_add(ffn_moe_out, ffn_shexp*) by callback names and finds the FFN-input
norm that forks into both branches.

The routed experts stay on the main stream and only the shared expert forks onto
a single aux stream, joined at the add. Keeping the large routed branch on the
main stream (region nodes not in the stream map default to the main stream)
avoids migrating it and needs just one fork/join. The shared-expert branch
reuses the concurrent-branch scratch buffer so it is disjoint from the routed
branch without any graph reordering.

Gated to decode (single token). The overlap only helps when the routed branch
leaves the GPU underutilized for the shared expert to run alongside it: that
holds in decode (batch 1, latency/occupancy-bound) but not in prefill, where the
routed matmuls already saturate the GPU and the overlap only adds contention.
Output is byte-identical to sequential. Requires GGML_CUDA_GRAPH_OPT, CUDA graphs
and a single GPU, so it is off by default.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
The branch scratch buffer made the node interleaving obsolete, so the concurrent
branches already run in their original graph order. The "restore original order"
pass in ggml_cuda_graph_evaluate_and_capture is therefore a no-op, and the
ggml_cuda_concurrent_event::original_order it consumed is now unused.

Remove the reorder pass and original_order, keeping only the is_valid() check
that clears the events when a region cannot be safely overlapped.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
@roberteg16 roberteg16 force-pushed the rogarcia.cuda-multistream-shexp branch from e11e7b5 to 6277fbe Compare July 7, 2026 14:11
roberteg16 and others added 2 commits July 7, 2026 08:22
Move the diamond-detection heuristics (attention QKV fan-out and the MoE
shared-expert diamond) out of ggml_backend_cuda_graph_optimize into a
header-only function ggml_cuda_detect_concurrent_regions() that operates only on
the public ggml graph API and holds no CUDA state. The backend now turns the
detected regions into ggml_cuda_concurrent_event objects and lays their branch
tensors into the scratch buffer. Behavior is unchanged, and the detection
heuristics can now be exercised in isolation.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Built only for CUDA/HIP/MUSA:

- detection: drive ggml_cuda_detect_concurrent_regions() with synthetic diamond
  graphs and assert which regions are and are not detected - attention 3-way
  fan-out, MoE shared-expert diamond, and negatives (fan-out of 2 or 4,
  multi-row/prefill, wrong fork name, wrong MoE operand names). This also pins the
  tensor-name contract the detector relies on.

- end-to-end: run the diamonds through ggml_backend_sched with
  GGML_CUDA_GRAPH_OPT=1 and check the output against an analytic reference; a log
  hook confirms both concurrency paths engaged. Skipped when no GPU is available.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
@roberteg16 roberteg16 marked this pull request as ready for review July 7, 2026 15:53
@mgehre-amd

Copy link
Copy Markdown

I don't understand why this changes needs so much code changes. Is there a more minimal version? Especially because large diffs will make it hard to upstream.

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.

2 participants