Skip to content

Commit ec45ac3

Browse files
roberteg16claude
andcommitted
ggml-cuda: overlap the MoE shared expert on a separate stream
Add a shared-expert detector, ggml_cuda_detect_shared_expert_concurrency, called from 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 the mat-vec regime (up to MMVQ_MAX_BATCH_SIZE tokens). The overlap only helps when the routed branch leaves the GPU underutilized for the shared expert to run alongside it: that holds while the token count keeps the matmuls in the memory/occupancy-bound mat-vec path, but not once it grows past that and the routed matmuls saturate the GPU (prefill), where 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>
1 parent e03ce76 commit ec45ac3

1 file changed

Lines changed: 141 additions & 8 deletions

File tree

ggml/src/ggml-cuda/ggml-cuda.cu

Lines changed: 141 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
#include <cstdio>
8686
#include <cstdlib>
8787
#include <string>
88+
#include <unordered_set>
8889
#include <vector>
8990

9091
static_assert(sizeof(half) == sizeof(ggml_fp16_t), "wrong fp16 size");
@@ -4358,8 +4359,11 @@ static void ggml_cuda_graph_evaluate_and_capture(ggml_backend_cuda_context * cud
43584359
is_concurrent_event_active = false;
43594360
concurrent_event = nullptr;
43604361
} else {
4361-
GGML_ASSERT (concurrent_event->stream_mapping.find(node) != concurrent_event->stream_mapping.end());
4362-
cuda_ctx->curr_stream_no = concurrent_event->stream_mapping[node];
4362+
// region nodes not mapped to a concurrent stream run on the main stream:
4363+
// this keeps the routed branch on the main stream while only the shared
4364+
// expert forks off
4365+
auto it = concurrent_event->stream_mapping.find(node);
4366+
cuda_ctx->curr_stream_no = it != concurrent_event->stream_mapping.end() ? it->second : 0;
43634367
GGML_LOG_DEBUG("Setting stream no to %d for node %s\n", cuda_ctx->curr_stream_no, node->name);
43644368
}
43654369
} else if (i - prev_i > 1) {
@@ -4368,7 +4372,8 @@ static void ggml_cuda_graph_evaluate_and_capture(ggml_backend_cuda_context * cud
43684372
try_launch_concurrent_event(prev_node);
43694373

43704374
if (is_concurrent_event_active) {
4371-
cuda_ctx->curr_stream_no = concurrent_event->stream_mapping[node];
4375+
auto it = concurrent_event->stream_mapping.find(node);
4376+
cuda_ctx->curr_stream_no = it != concurrent_event->stream_mapping.end() ? it->second : 0;
43724377
GGML_LOG_DEBUG("Setting stream no to %d for node %s\n", cuda_ctx->curr_stream_no, node->name);
43734378
}
43744379
}
@@ -4558,6 +4563,132 @@ static void ggml_backend_cuda_event_wait(ggml_backend_t backend, ggml_backend_ev
45584563
}
45594564
}
45604565

4566+
// MoE shared-expert overlap: run the shared expert on a separate stream, overlapped with the
4567+
// routed experts. fork = the FFN-input norm feeding both branches, join = ggml_add(ffn_moe_out,
4568+
// ffn_shexp*). Operands are matched by the names set via cb() in the model graph. Decode only
4569+
// (gated below): prefill is compute-bound and gains nothing from the overlap.
4570+
static void ggml_cuda_detect_shared_expert_concurrency(
4571+
ggml_cgraph * cgraph,
4572+
ggml_backend_cuda_context * cuda_ctx,
4573+
const std::unordered_map<const ggml_tensor *, int> & node_indices,
4574+
std::vector<std::pair<int, int>> & concurrent_node_ranges,
4575+
std::vector<std::vector<const ggml_tensor *>> & concurrent_groups) {
4576+
const auto reach_backward = [](const ggml_tensor * start) {
4577+
std::unordered_set<const ggml_tensor *> seen;
4578+
std::vector<const ggml_tensor *> stack = { start };
4579+
while (!stack.empty()) {
4580+
const ggml_tensor * t = stack.back();
4581+
stack.pop_back();
4582+
if (!t || seen.count(t)) {
4583+
continue;
4584+
}
4585+
seen.insert(t);
4586+
for (int s = 0; s < GGML_MAX_SRC; ++s) {
4587+
if (t->src[s]) {
4588+
stack.push_back(t->src[s]);
4589+
}
4590+
}
4591+
}
4592+
return seen;
4593+
};
4594+
4595+
for (int join_idx = 0; join_idx < cgraph->n_nodes; ++join_idx) {
4596+
ggml_tensor * join_node = cgraph->nodes[join_idx];
4597+
if (join_node->op != GGML_OP_ADD) {
4598+
continue;
4599+
}
4600+
4601+
// Only overlap in the mat-vec regime (up to MMVQ_MAX_BATCH_SIZE tokens). The shared-expert
4602+
// overlap only helps when the routed branch leaves the GPU underutilized for it to run
4603+
// alongside; that holds while the matmuls stay in the memory/occupancy-bound mat-vec path,
4604+
// but not once the token count grows past it and the routed matmuls saturate the GPU
4605+
// (prefill), where the overlap only adds contention.
4606+
if (ggml_nrows(join_node) > MMVQ_MAX_BATCH_SIZE) {
4607+
continue;
4608+
}
4609+
4610+
ggml_tensor * routed_out = nullptr;
4611+
ggml_tensor * shexp_out = nullptr;
4612+
for (int s = 0; s < 2; ++s) {
4613+
ggml_tensor * x = join_node->src[s];
4614+
ggml_tensor * y = join_node->src[1 - s];
4615+
if (x && y && strstr(x->name, "ffn_moe_out") && strstr(y->name, "ffn_shexp")) {
4616+
routed_out = x;
4617+
shexp_out = y;
4618+
}
4619+
}
4620+
if (!routed_out || !shexp_out) {
4621+
continue;
4622+
}
4623+
4624+
const std::unordered_set<const ggml_tensor *> reach_routed = reach_backward(routed_out);
4625+
const std::unordered_set<const ggml_tensor *> reach_shexp = reach_backward(shexp_out);
4626+
4627+
// fork = highest-index node reachable from both branches (the ffn_norm output)
4628+
int fork_idx = -1;
4629+
for (const ggml_tensor * t : reach_routed) {
4630+
if (!reach_shexp.count(t)) {
4631+
continue;
4632+
}
4633+
auto it = node_indices.find(t);
4634+
if (it != node_indices.end() && it->second < join_idx && it->second > fork_idx) {
4635+
fork_idx = it->second;
4636+
}
4637+
}
4638+
if (fork_idx < 0) {
4639+
continue;
4640+
}
4641+
4642+
bool overlaps = false;
4643+
for (const auto & [start, end] : concurrent_node_ranges) {
4644+
if (!(join_idx < start || fork_idx > end)) {
4645+
overlaps = true;
4646+
}
4647+
}
4648+
if (overlaps) {
4649+
continue;
4650+
}
4651+
4652+
// partition the region (fork_idx, join_idx): shared-expert nodes -> stream 2, routed -> 1
4653+
std::vector<std::vector<const ggml_tensor *>> nodes_per_branch(2);
4654+
for (int i = fork_idx + 1; i < join_idx; ++i) {
4655+
const ggml_tensor * n = cgraph->nodes[i];
4656+
const int branch = reach_shexp.count(n) ? 1 : 0;
4657+
nodes_per_branch[branch].push_back(n);
4658+
}
4659+
if (nodes_per_branch[0].empty() || nodes_per_branch[1].empty()) {
4660+
continue;
4661+
}
4662+
4663+
// the routed experts stay on the main stream and only the shared expert forks onto a single
4664+
// aux stream, joined at the add. Keeping the large routed branch on the main stream avoids
4665+
// migrating it and needs only one fork/join.
4666+
ggml_cuda_concurrent_event concurrent_event(1);
4667+
concurrent_event.join_node = join_node;
4668+
for (const ggml_tensor * n : nodes_per_branch[1]) {
4669+
concurrent_event.stream_mapping[n] = 1;
4670+
}
4671+
4672+
const ggml_tensor * fork_node = cgraph->nodes[fork_idx];
4673+
concurrent_event.original_order.reserve(join_idx - fork_idx - 1);
4674+
for (int i = fork_idx + 1; i < join_idx; ++i) {
4675+
concurrent_event.original_order.push_back(cgraph->nodes[i]);
4676+
}
4677+
4678+
std::unordered_map<const ggml_tensor *, ggml_cuda_concurrent_event> & concurrent_events = cuda_ctx->stream_context().concurrent_events;
4679+
if (concurrent_events.find(fork_node) != concurrent_events.end()) {
4680+
continue;
4681+
}
4682+
concurrent_events.emplace(fork_node, std::move(concurrent_event));
4683+
GGML_LOG_DEBUG("Adding shared-expert stream at node %s %p\n", fork_node->name, fork_node);
4684+
concurrent_node_ranges.emplace_back(fork_idx, join_idx);
4685+
4686+
// the shared-expert nodes get a dedicated buffer (below), so the graph order is left intact
4687+
// and no interleaving is needed to keep the branch non-overlapping
4688+
concurrent_groups.push_back(nodes_per_branch[1]);
4689+
}
4690+
}
4691+
45614692
static void ggml_backend_cuda_graph_optimize(ggml_backend_t backend, ggml_cgraph * cgraph) {
45624693
ggml_backend_cuda_context * cuda_ctx = (ggml_backend_cuda_context *) backend->context;
45634694

@@ -4780,11 +4911,13 @@ static void ggml_backend_cuda_graph_optimize(ggml_backend_t backend, ggml_cgraph
47804911
}
47814912
}
47824913

4783-
// Place every concurrent branch in a dedicated buffer so its nodes never share an address with
4784-
// each other or with tensors read across the region (which ggml-alloc could otherwise recycle,
4785-
// corrupting concurrent reads). Layers run sequentially, so one buffer sized to the largest
4786-
// region is reused across all of them; within a region each node gets a distinct offset so the
4787-
// concurrent scratch stays disjoint.
4914+
ggml_cuda_detect_shared_expert_concurrency(cgraph, cuda_ctx, node_indices, concurrent_node_ranges, concurrent_groups);
4915+
4916+
// Place every concurrent branch (attention QKV and MoE shared-expert) in a dedicated buffer so
4917+
// its nodes never share an address with each other or with tensors read across the region (which
4918+
// ggml-alloc could otherwise recycle, corrupting concurrent reads). Layers run sequentially, so
4919+
// one buffer sized to the largest region is reused across all of them; within a region each node
4920+
// gets a distinct offset so the concurrent scratch stays disjoint.
47884921
if (!concurrent_groups.empty()) {
47894922
const size_t alignment = 128;
47904923

0 commit comments

Comments
 (0)