Skip to content

Commit 367c4d0

Browse files
authored
Merge pull request #36 from ROCm/rogarcia.cuda-multistream-shexp
ggml-cuda: overlap the MoE shared expert on a separate stream
2 parents 442b17e + 7fc317f commit 367c4d0

2 files changed

Lines changed: 203 additions & 39 deletions

File tree

ggml/src/ggml-cuda/common.cuh

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,7 +1394,9 @@ struct ggml_backend_cuda_context {
13941394
cudaEvent_t copy_event = nullptr;
13951395

13961396
cudaStream_t streams[GGML_CUDA_MAX_DEVICES][GGML_CUDA_MAX_STREAMS] = { { nullptr } };
1397-
cublasHandle_t cublas_handles[GGML_CUDA_MAX_DEVICES] = {nullptr};
1397+
// one cuBLAS handle per (device, stream): the handle carries a workspace that must not be shared
1398+
// by concurrent streams, otherwise overlapped GEMMs corrupt each other's results
1399+
cublasHandle_t cublas_handles[GGML_CUDA_MAX_DEVICES][GGML_CUDA_MAX_STREAMS] = { { nullptr } };
13981400

13991401
int curr_stream_no = 0;
14001402

@@ -1457,6 +1459,11 @@ struct ggml_backend_cuda_context {
14571459

14581460
ggml_cuda_stream_context concurrent_stream_context;
14591461

1462+
// dedicated buffer for the branches of overlapped concurrent regions (attention QKV, MoE shared
1463+
// expert), reused across layers so their scratch never aliases tensors read across the region
1464+
ggml_backend_buffer_t concurrent_scratch = nullptr;
1465+
size_t concurrent_scratch_size = 0;
1466+
14601467
~ggml_backend_cuda_context();
14611468

14621469
cudaStream_t stream(int device, int stream) {
@@ -1472,12 +1479,13 @@ struct ggml_backend_cuda_context {
14721479
ggml_cuda_stream_context & stream_context() { return concurrent_stream_context; }
14731480

14741481
cublasHandle_t cublas_handle(int device) {
1475-
if (cublas_handles[device] == nullptr) {
1482+
cublasHandle_t & handle = cublas_handles[device][curr_stream_no];
1483+
if (handle == nullptr) {
14761484
ggml_cuda_set_device(device);
1477-
CUBLAS_CHECK(cublasCreate(&cublas_handles[device]));
1478-
CUBLAS_CHECK(cublasSetMathMode(cublas_handles[device], CUBLAS_TF32_TENSOR_OP_MATH));
1485+
CUBLAS_CHECK(cublasCreate(&handle));
1486+
CUBLAS_CHECK(cublasSetMathMode(handle, CUBLAS_TF32_TENSOR_OP_MATH));
14791487
}
1480-
return cublas_handles[device];
1488+
return handle;
14811489
}
14821490

14831491
cublasHandle_t cublas_handle() {

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

Lines changed: 190 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
#include <cstdio>
9090
#include <cstdlib>
9191
#include <string>
92+
#include <unordered_set>
9293
#include <vector>
9394

9495
static_assert(sizeof(half) == sizeof(ggml_fp16_t), "wrong fp16 size");
@@ -648,10 +649,15 @@ ggml_backend_cuda_context::~ggml_backend_cuda_context() {
648649
CUDA_CHECK(cudaStreamDestroy(streams[i][j]));
649650
}
650651
}
651-
if (cublas_handles[i] != nullptr) {
652-
CUBLAS_CHECK(cublasDestroy(cublas_handles[i]));
652+
for (int j = 0; j < GGML_CUDA_MAX_STREAMS; ++j) {
653+
if (cublas_handles[i][j] != nullptr) {
654+
CUBLAS_CHECK(cublasDestroy(cublas_handles[i][j]));
655+
}
653656
}
654657
}
658+
if (concurrent_scratch != nullptr) {
659+
ggml_backend_buffer_free(concurrent_scratch);
660+
}
655661
}
656662

657663

@@ -3895,8 +3901,11 @@ static void ggml_cuda_graph_evaluate_and_capture(ggml_backend_cuda_context * cud
38953901
is_concurrent_event_active = false;
38963902
concurrent_event = nullptr;
38973903
} else {
3898-
GGML_ASSERT (concurrent_event->stream_mapping.find(node) != concurrent_event->stream_mapping.end());
3899-
cuda_ctx->curr_stream_no = concurrent_event->stream_mapping[node];
3904+
// region nodes not mapped to a concurrent stream run on the main stream:
3905+
// this keeps the routed branch on the main stream while only the shared
3906+
// expert forks off (the vLLM shared-expert model)
3907+
auto it = concurrent_event->stream_mapping.find(node);
3908+
cuda_ctx->curr_stream_no = it != concurrent_event->stream_mapping.end() ? it->second : 0;
39003909
GGML_LOG_DEBUG("Setting stream no to %d for node %s\n", cuda_ctx->curr_stream_no, node->name);
39013910
}
39023911
} else if (i - prev_i > 1) {
@@ -3905,7 +3914,8 @@ static void ggml_cuda_graph_evaluate_and_capture(ggml_backend_cuda_context * cud
39053914
try_launch_concurrent_event(prev_node);
39063915

39073916
if (is_concurrent_event_active) {
3908-
cuda_ctx->curr_stream_no = concurrent_event->stream_mapping[node];
3917+
auto it = concurrent_event->stream_mapping.find(node);
3918+
cuda_ctx->curr_stream_no = it != concurrent_event->stream_mapping.end() ? it->second : 0;
39093919
GGML_LOG_DEBUG("Setting stream no to %d for node %s\n", cuda_ctx->curr_stream_no, node->name);
39103920
}
39113921
}
@@ -4186,6 +4196,11 @@ static void ggml_backend_cuda_graph_optimize(ggml_backend_t backend, ggml_cgraph
41864196
// store {fork_idx, join_idx}
41874197
std::vector<std::pair<int, int>> concurrent_node_ranges;
41884198

4199+
// per-event lists of concurrent branch nodes to place in the dedicated scratch buffer (below),
4200+
// so the branches are mutually disjoint and disjoint from tensors read across the region -
4201+
// this replaces the fragile node interleaving that ggml-alloc/execution order can desync
4202+
std::vector<std::vector<const ggml_tensor *>> concurrent_groups;
4203+
41894204
for (const auto & [root_node, count] : fan_out) {
41904205
if (count >= min_fan_out && count <= max_fan_out) {
41914206
const int root_node_idx = node_indices[root_node];
@@ -4277,10 +4292,6 @@ static void ggml_backend_cuda_graph_optimize(ggml_backend_t backend, ggml_cgraph
42774292
int fork_node_idx = node_indices[root_node];
42784293
int join_node_idx = node_indices[join_node];
42794294

4280-
int current_branch_idx = 0;
4281-
int current_node_idx = fork_node_idx + 1;
4282-
const int n_branches = nodes_per_branch.size();
4283-
42844295
int total_branch_nodes = 0;
42854296
for (std::vector<const ggml_tensor *> branch_nodes : nodes_per_branch) {
42864297
total_branch_nodes += branch_nodes.size();
@@ -4309,37 +4320,182 @@ static void ggml_backend_cuda_graph_optimize(ggml_backend_t backend, ggml_cgraph
43094320
GGML_LOG_DEBUG("Adding stream at node %s %p\n", root_node->name, root_node);
43104321
concurrent_node_ranges.emplace_back(fork_node_idx, join_node_idx);
43114322

4312-
// interleave tensors to extend lifetimes so that ggml graph doesn't recycle them
4313-
// example transformation:
4314-
// [attn-norm, QMul, QNorm, QRope, KMul, KNorm, KRope, VMul, attn] ->
4315-
// [attn-norm, QMul, KMul, VMul, QNorm, VNorm, QRope, KRope, attn]
4316-
while (current_node_idx < join_node_idx) {
4317-
std::vector<const ggml_tensor *> & branch_nodes = nodes_per_branch[current_branch_idx];
4318-
4319-
bool has_node = false;
4320-
for (std::vector<const ggml_tensor *> branch_node : nodes_per_branch) {
4321-
has_node |= branch_node.size() > 0;
4323+
// place all branch nodes in the dedicated scratch buffer (below) instead of
4324+
// interleaving them: ggml-alloc then keeps the branches mutually disjoint and
4325+
// disjoint from the fork output that every branch reads concurrently
4326+
std::vector<const ggml_tensor *> group;
4327+
for (const auto & branch_nodes : nodes_per_branch) {
4328+
for (const ggml_tensor * n : branch_nodes) {
4329+
group.push_back(n);
43224330
}
4331+
}
4332+
concurrent_groups.push_back(std::move(group));
4333+
}
4334+
}
4335+
}
43234336

4324-
GGML_ASSERT(has_node);
4337+
// MoE shared-expert overlap: run the shared expert on a separate stream, overlapped with the
4338+
// routed experts. fork = the FFN-input norm feeding both branches, join = ggml_add(ffn_moe_out,
4339+
// ffn_shexp*). Operands are matched by the names set via cb() in the model graph. Decode only
4340+
// (gated below): prefill is compute-bound and gains nothing from the overlap.
4341+
const auto reach_backward = [](const ggml_tensor * start) {
4342+
std::unordered_set<const ggml_tensor *> seen;
4343+
std::vector<const ggml_tensor *> stack = { start };
4344+
while (!stack.empty()) {
4345+
const ggml_tensor * t = stack.back();
4346+
stack.pop_back();
4347+
if (!t || seen.count(t)) {
4348+
continue;
4349+
}
4350+
seen.insert(t);
4351+
for (int s = 0; s < GGML_MAX_SRC; ++s) {
4352+
if (t->src[s]) {
4353+
stack.push_back(t->src[s]);
4354+
}
4355+
}
4356+
}
4357+
return seen;
4358+
};
43254359

4326-
if (branch_nodes.empty()) {
4327-
current_branch_idx = (current_branch_idx + 1) % n_branches;
4328-
continue;
4329-
}
4360+
for (int join_idx = 0; join_idx < cgraph->n_nodes; ++join_idx) {
4361+
ggml_tensor * join_node = cgraph->nodes[join_idx];
4362+
if (join_node->op != GGML_OP_ADD) {
4363+
continue;
4364+
}
43304365

4331-
cgraph->nodes[current_node_idx] = const_cast<ggml_tensor *>(branch_nodes.front());
4332-
current_node_idx++;
4333-
branch_nodes.erase(branch_nodes.begin());
4366+
// Only overlap during decode (single token). Overlapping the shared expert only helps when
4367+
// the routed branch leaves the GPU underutilized for it to run alongside; that is the case in
4368+
// decode (batch 1, latency/occupancy-bound) but not in prefill, where the routed matmuls are
4369+
// large and already saturate the GPU, so the overlap adds contention without a speedup.
4370+
if (ggml_nrows(join_node) > 1) {
4371+
continue;
4372+
}
43344373

4335-
// append all empty nodes
4336-
while (!branch_nodes.empty() && is_noop(branch_nodes.front())) {
4337-
cgraph->nodes[current_node_idx] = const_cast<ggml_tensor *>(branch_nodes.front());
4338-
current_node_idx++;
4339-
branch_nodes.erase(branch_nodes.begin());
4340-
}
4374+
ggml_tensor * routed_out = nullptr;
4375+
ggml_tensor * shexp_out = nullptr;
4376+
for (int s = 0; s < 2; ++s) {
4377+
ggml_tensor * x = join_node->src[s];
4378+
ggml_tensor * y = join_node->src[1 - s];
4379+
if (x && y && strstr(x->name, "ffn_moe_out") && strstr(y->name, "ffn_shexp")) {
4380+
routed_out = x;
4381+
shexp_out = y;
4382+
}
4383+
}
4384+
if (!routed_out || !shexp_out) {
4385+
continue;
4386+
}
4387+
4388+
const std::unordered_set<const ggml_tensor *> reach_routed = reach_backward(routed_out);
4389+
const std::unordered_set<const ggml_tensor *> reach_shexp = reach_backward(shexp_out);
43414390

4342-
current_branch_idx = (current_branch_idx + 1) % n_branches;
4391+
// fork = highest-index node reachable from both branches (the ffn_norm output)
4392+
int fork_idx = -1;
4393+
for (const ggml_tensor * t : reach_routed) {
4394+
if (!reach_shexp.count(t)) {
4395+
continue;
4396+
}
4397+
auto it = node_indices.find(t);
4398+
if (it != node_indices.end() && it->second < join_idx && it->second > fork_idx) {
4399+
fork_idx = it->second;
4400+
}
4401+
}
4402+
if (fork_idx < 0) {
4403+
continue;
4404+
}
4405+
4406+
bool overlaps = false;
4407+
for (const auto & [start, end] : concurrent_node_ranges) {
4408+
if (!(join_idx < start || fork_idx > end)) {
4409+
overlaps = true;
4410+
}
4411+
}
4412+
if (overlaps) {
4413+
continue;
4414+
}
4415+
4416+
// partition the region (fork_idx, join_idx): shared-expert nodes -> stream 2, routed -> 1
4417+
std::vector<std::vector<const ggml_tensor *>> nodes_per_branch(2);
4418+
for (int i = fork_idx + 1; i < join_idx; ++i) {
4419+
const ggml_tensor * n = cgraph->nodes[i];
4420+
const int branch = reach_shexp.count(n) ? 1 : 0;
4421+
nodes_per_branch[branch].push_back(n);
4422+
}
4423+
if (nodes_per_branch[0].empty() || nodes_per_branch[1].empty()) {
4424+
continue;
4425+
}
4426+
4427+
// vLLM shared-expert model: the routed experts stay on the main stream and only the shared
4428+
// expert forks onto a single aux stream, joined at the add. Keeping the large routed branch
4429+
// on the main stream avoids migrating it and needs only one fork/join.
4430+
ggml_cuda_concurrent_event concurrent_event(1);
4431+
concurrent_event.join_node = join_node;
4432+
for (const ggml_tensor * n : nodes_per_branch[1]) {
4433+
concurrent_event.stream_mapping[n] = 1;
4434+
}
4435+
4436+
const ggml_tensor * fork_node = cgraph->nodes[fork_idx];
4437+
concurrent_event.original_order.reserve(join_idx - fork_idx - 1);
4438+
for (int i = fork_idx + 1; i < join_idx; ++i) {
4439+
concurrent_event.original_order.push_back(cgraph->nodes[i]);
4440+
}
4441+
4442+
std::unordered_map<const ggml_tensor *, ggml_cuda_concurrent_event> & concurrent_events = cuda_ctx->stream_context().concurrent_events;
4443+
if (concurrent_events.find(fork_node) != concurrent_events.end()) {
4444+
continue;
4445+
}
4446+
concurrent_events.emplace(fork_node, std::move(concurrent_event));
4447+
GGML_LOG_DEBUG("Adding shared-expert stream at node %s %p\n", fork_node->name, fork_node);
4448+
concurrent_node_ranges.emplace_back(fork_idx, join_idx);
4449+
4450+
// the shared-expert nodes get a dedicated buffer (below), so the graph order is left intact
4451+
// and no interleaving is needed to keep the branch non-overlapping
4452+
concurrent_groups.push_back(nodes_per_branch[1]);
4453+
}
4454+
4455+
// Place every concurrent branch (attention QKV and MoE shared-expert) in a dedicated buffer so
4456+
// its nodes never share an address with each other or with tensors read across the region (which
4457+
// ggml-alloc could otherwise recycle, corrupting concurrent reads). Layers run sequentially, so
4458+
// one buffer sized to the largest region is reused across all of them; within a region each node
4459+
// gets a distinct offset so the concurrent scratch stays disjoint.
4460+
if (!concurrent_groups.empty()) {
4461+
const size_t alignment = 128;
4462+
4463+
const auto group_footprint = [&](const std::vector<const ggml_tensor *> & group) {
4464+
size_t off = 0;
4465+
for (const ggml_tensor * n : group) {
4466+
if (is_noop(n) || n->view_src != nullptr) {
4467+
continue;
4468+
}
4469+
off += GGML_PAD(ggml_nbytes(n), alignment);
4470+
}
4471+
return off;
4472+
};
4473+
4474+
size_t needed = 0;
4475+
for (const auto & group : concurrent_groups) {
4476+
needed = std::max(needed, group_footprint(group));
4477+
}
4478+
4479+
if (needed > 0) {
4480+
if (cuda_ctx->concurrent_scratch == nullptr || cuda_ctx->concurrent_scratch_size < needed) {
4481+
if (cuda_ctx->concurrent_scratch != nullptr) {
4482+
ggml_backend_buffer_free(cuda_ctx->concurrent_scratch);
4483+
}
4484+
cuda_ctx->concurrent_scratch = ggml_backend_buft_alloc_buffer(ggml_backend_cuda_buffer_type(cuda_ctx->device), needed);
4485+
cuda_ctx->concurrent_scratch_size = needed;
4486+
}
4487+
4488+
char * const base = (char *) ggml_backend_buffer_get_base(cuda_ctx->concurrent_scratch);
4489+
for (const auto & group : concurrent_groups) {
4490+
size_t off = 0;
4491+
for (const ggml_tensor * cn : group) {
4492+
if (is_noop(cn) || cn->view_src != nullptr) {
4493+
continue;
4494+
}
4495+
ggml_tensor * n = const_cast<ggml_tensor *>(cn);
4496+
n->data = base + off;
4497+
n->buffer = cuda_ctx->concurrent_scratch;
4498+
off += GGML_PAD(ggml_nbytes(n), alignment);
43434499
}
43444500
}
43454501
}

0 commit comments

Comments
 (0)