Skip to content

Commit 507a678

Browse files
authored
Merge pull request #358 from Luce-Org/feat/laguna-cudagraph-replay
perf(laguna): CUDA-graph replay decode — 113→143 tok/s all-GPU, 101→129 at 60% residency
2 parents 72f3c04 + ece88de commit 507a678

14 files changed

Lines changed: 488 additions & 68 deletions

.github/workflows/ci.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,11 @@ jobs:
123123
if: >-
124124
github.event_name == 'workflow_dispatch' ||
125125
github.event.pull_request.head.repo.full_name == github.repository
126-
needs: [uv-workspace, build]
126+
# Gate only on the 1-minute workspace check, NOT the ~18-minute hosted
127+
# CPU build: the GPU job compiles the same tree itself in ~2 minutes on
128+
# real hardware, so serializing it behind the cloud build only delayed
129+
# the strongest signal this CI produces.
130+
needs: [uv-workspace]
127131
runs-on: [self-hosted, gpu, sm86]
128132
timeout-minutes: 30
129133
# The box has a single physical GPU: serialize GPU jobs across PRs instead
@@ -191,7 +195,11 @@ jobs:
191195
if: >-
192196
github.event_name == 'workflow_dispatch' ||
193197
github.event.pull_request.head.repo.full_name == github.repository
194-
needs: [uv-workspace, build]
198+
# Gate only on the 1-minute workspace check, NOT the ~18-minute hosted
199+
# CPU build: the GPU job compiles the same tree itself in ~2 minutes on
200+
# real hardware, so serializing it behind the cloud build only delayed
201+
# the strongest signal this CI produces.
202+
needs: [uv-workspace]
195203
runs-on: [self-hosted, rocm, gfx1151]
196204
timeout-minutes: 20
197205
# Same single box as gpu-tests: serialize GPU jobs across PRs.

.github/workflows/docker.yml

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,22 +40,17 @@ on:
4040
# from a PR — even if we wanted to, GITHUB_TOKEN on PRs from forks lacks
4141
# `packages:write`. The point is to catch Dockerfile / bake-file / arch-
4242
# list regressions before they land on main.
43+
# Only when the DOCKER surface itself changes. Source-tree changes are
44+
# already compiled and smoke-tested on the self-hosted GPU runners (sm_86 +
45+
# gfx1151) by ci.yml — rebuilding the image for every server/src PR
46+
# duplicated that coverage at ~20 GitHub-runner minutes per push.
4347
pull_request:
4448
paths:
4549
- Dockerfile
4650
- Dockerfile.rocm
4751
- docker-bake.hcl
4852
- .dockerignore
4953
- .github/workflows/docker.yml
50-
- server/CMakeLists.txt
51-
- server/src/**
52-
- server/test/**
53-
- server/include/**
54-
- server/scripts/**
55-
- server/deps/**
56-
- server/pyproject.toml
57-
- pyproject.toml
58-
- uv.lock
5954
# Manual trigger for one-off rebuilds or pre-release smoke tests. The
6055
# `push` input controls whether the resulting images land in GHCR or only
6156
# populate the buildx cache.

server/CMakeLists.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,11 @@ if(DFLASH27B_TESTS)
672672
target_include_directories(bench_laguna_generate PRIVATE ${DFLASH27B_SRC_INCLUDE_DIRS})
673673
target_link_libraries(bench_laguna_generate PRIVATE dflash_common ggml ${DFLASH27B_GGML_BACKEND_TARGET})
674674
endif()
675+
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/test/bench_laguna_spark.cpp")
676+
add_executable(bench_laguna_spark test/bench_laguna_spark.cpp)
677+
target_include_directories(bench_laguna_spark PRIVATE ${DFLASH27B_SRC_INCLUDE_DIRS})
678+
target_link_libraries(bench_laguna_spark PRIVATE dflash_common ggml ${DFLASH27B_GGML_BACKEND_TARGET})
679+
endif()
675680
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/test/test_laguna_daemon.cpp")
676681
add_executable(test_laguna_daemon test/test_laguna_daemon.cpp)
677682
target_include_directories(test_laguna_daemon PRIVATE ${DFLASH27B_SRC_INCLUDE_DIRS})

server/src/gemma4/gemma4_graph.cpp

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,9 @@ static ggml_tensor * build_gemma4_attn_block(
155155
ggml_tensor * attn_mask_full,
156156
ggml_tensor * attn_mask_swa,
157157
int kv_start,
158-
int n_tokens)
158+
int n_tokens,
159+
ggml_tensor * kv_idx_full = nullptr, // [n_tokens] I32 absolute rows (graph input)
160+
ggml_tensor * kv_idx_swa = nullptr) // [n_tokens] I32 ring rows pos%swa_size (graph input)
159161
{
160162
const int head_dim = gemma4_head_dim(w, il);
161163
const int n_head = w.n_head;
@@ -207,20 +209,35 @@ static ggml_tensor * build_gemma4_attn_block(
207209
0.0f, 1.0f, 32.0f, 1.0f);
208210

209211
// Write K/V to cache (ring-buffer position for SWA layers)
210-
const int write_pos = is_swa ? (kv_start % cache_len) : kv_start;
211212
ggml_tensor * Kcur_T = ggml_permute(ctx, Kcur, 0, 2, 1, 3);
212213
ggml_tensor * Vcur_T = ggml_permute(ctx, Vcur, 0, 2, 1, 3);
213214

214-
ggml_tensor * k_slot = ggml_view_3d(ctx, cache_k,
215-
head_dim, n_tokens, n_head_kv,
216-
cache_k->nb[1], cache_k->nb[2],
217-
cache_k->nb[1] * (size_t)write_pos);
218-
ggml_tensor * v_slot = ggml_view_3d(ctx, cache_v,
219-
head_dim, n_tokens, n_head_kv,
220-
cache_v->nb[1], cache_v->nb[2],
221-
cache_v->nb[1] * (size_t)write_pos);
222-
ggml_build_forward_expand(gf, ggml_cpy(ctx, Kcur_T, k_slot));
223-
ggml_build_forward_expand(gf, ggml_cpy(ctx, Vcur_T, v_slot));
215+
ggml_tensor * kvi = is_swa ? kv_idx_swa : kv_idx_full;
216+
if (kvi) {
217+
// CUDA-graph-stable append: dst is the whole cache tensor (stable
218+
// pointer), the row index is a graph INPUT (data changes per step,
219+
// pointer doesn't). A write_pos-offset view changes node properties
220+
// every step, which resets the ggml-cuda CUDA-graph warmup and
221+
// forfeits replay. For SWA layers the caller fills the index with
222+
// (pos % swa_size), which also handles ring wrap-around mid-chunk
223+
// correctly (the offset-view path wrote a contiguous block).
224+
ggml_tensor * Krows = ggml_cont(ctx, Kcur_T);
225+
ggml_tensor * Vrows = ggml_cont(ctx, Vcur_T);
226+
ggml_build_forward_expand(gf, ggml_set_rows(ctx, cache_k, Krows, kvi));
227+
ggml_build_forward_expand(gf, ggml_set_rows(ctx, cache_v, Vrows, kvi));
228+
} else {
229+
const int write_pos = is_swa ? (kv_start % cache_len) : kv_start;
230+
ggml_tensor * k_slot = ggml_view_3d(ctx, cache_k,
231+
head_dim, n_tokens, n_head_kv,
232+
cache_k->nb[1], cache_k->nb[2],
233+
cache_k->nb[1] * (size_t)write_pos);
234+
ggml_tensor * v_slot = ggml_view_3d(ctx, cache_v,
235+
head_dim, n_tokens, n_head_kv,
236+
cache_v->nb[1], cache_v->nb[2],
237+
cache_v->nb[1] * (size_t)write_pos);
238+
ggml_build_forward_expand(gf, ggml_cpy(ctx, Kcur_T, k_slot));
239+
ggml_build_forward_expand(gf, ggml_cpy(ctx, Vcur_T, v_slot));
240+
}
224241
}
225242
// else: KV-sharing layer — cache already written by source layer
226243

@@ -282,7 +299,9 @@ static ggml_tensor * build_gemma4_layer(
282299
ggml_tensor * per_layer_input, // [n_embd_per_layer, n_tokens] or nullptr
283300
int kv_start,
284301
int n_tokens,
285-
int capture_idx = -1) // >=0: write to target_feat at this capture slot
302+
int capture_idx = -1, // >=0: write to target_feat at this capture slot
303+
ggml_tensor * kv_idx_full = nullptr,
304+
ggml_tensor * kv_idx_swa = nullptr)
286305
{
287306
const Gemma4Layer & L = w.layers[il];
288307
ggml_tensor * inp_f32 = graph_tensor_f32(ctx, inp);
@@ -293,7 +312,7 @@ static ggml_tensor * build_gemma4_layer(
293312
// Attention
294313
cur = build_gemma4_attn_block(ctx, gf, w, L, cache, il, cur,
295314
positions, attn_mask_full, attn_mask_swa,
296-
kv_start, n_tokens);
315+
kv_start, n_tokens, kv_idx_full, kv_idx_swa);
297316

298317
// Post-attn norm
299318
if (L.attn_post_norm) {
@@ -603,9 +622,16 @@ bool gemma4_step(
603622
int kv_start,
604623
std::vector<float> & out_logits)
605624
{
606-
// Allocate graph context
625+
// Allocate graph context. Persistent thread_local arena: rebuilt graphs
626+
// land at identical addresses every step, so the ggml-cuda CUDA-graph
627+
// cache (keyed on nodes[0], memcmps node properties) can replay the
628+
// captured graph instead of re-launching every kernel per token.
629+
const size_t arena_size = ggml_tensor_overhead() * 16384 + ggml_graph_overhead() + 16 * 1024 * 1024;
630+
static thread_local std::vector<uint8_t> g_arena;
631+
if (g_arena.size() < arena_size) g_arena.resize(arena_size);
607632
ggml_init_params ip{};
608-
ip.mem_size = ggml_tensor_overhead() * 16384 + ggml_graph_overhead() + 16 * 1024 * 1024;
633+
ip.mem_size = arena_size;
634+
ip.mem_buffer = g_arena.data();
609635
ip.no_alloc = true;
610636
ggml_context * ctx = ggml_init(ip);
611637
ggml_cgraph * gf = ggml_new_graph_custom(ctx, 16384, false);
@@ -616,6 +642,18 @@ bool gemma4_step(
616642
ggml_tensor * pp = ggml_new_tensor_1d(ctx, GGML_TYPE_I32, n_tokens);
617643
ggml_set_input(pp);
618644

645+
// K/V append row indices (set_rows path; data-only per step -> stable
646+
// node properties -> CUDA-graph replay). DFLASH_GEMMA4_NO_KVPAD=1 restores
647+
// the legacy offset-view cpy append.
648+
static const bool g_no_kvpad = (std::getenv("DFLASH_GEMMA4_NO_KVPAD") != nullptr);
649+
ggml_tensor * kvi_full = nullptr, * kvi_swa = nullptr;
650+
if (!g_no_kvpad) {
651+
kvi_full = ggml_new_tensor_1d(ctx, GGML_TYPE_I32, n_tokens);
652+
ggml_set_input(kvi_full);
653+
kvi_swa = ggml_new_tensor_1d(ctx, GGML_TYPE_I32, n_tokens);
654+
ggml_set_input(kvi_swa);
655+
}
656+
619657
// Token IDs input (for per-layer embedding lookup)
620658
ggml_tensor * tok_ids = nullptr;
621659
if (token_ids && w.per_layer_tok_embd && w.per_layer_model_proj && w.n_embd_per_layer > 0) {
@@ -689,7 +727,8 @@ bool gemma4_step(
689727
}
690728
cur = build_gemma4_layer(ctx, gf, w, cache, il, cur, pp,
691729
mk_full_f16, mk_swa_f16, pl_input,
692-
kv_start, n_tokens, cap_idx);
730+
kv_start, n_tokens, cap_idx,
731+
kvi_full, kvi_swa);
693732
}
694733

695734
// Final norm
@@ -729,6 +768,17 @@ bool gemma4_step(
729768
std::vector<int32_t> pos((size_t)n_tokens);
730769
for (int i = 0; i < n_tokens; ++i) pos[i] = kv_start + i;
731770
ggml_backend_tensor_set(pp, pos.data(), 0, ggml_nbytes(pp));
771+
if (kvi_full) {
772+
// Full layers append at the absolute position; SWA layers at the ring
773+
// slot. Per-token modular indices also land chunks that cross the
774+
// ring wrap boundary correctly (the offset-view path wrote one
775+
// contiguous block).
776+
ggml_backend_tensor_set(kvi_full, pos.data(), 0, ggml_nbytes(kvi_full));
777+
GGML_ASSERT(swa_size > 0);
778+
std::vector<int32_t> ring((size_t)n_tokens);
779+
for (int i = 0; i < n_tokens; ++i) ring[i] = (kv_start + i) % swa_size;
780+
ggml_backend_tensor_set(kvi_swa, ring.data(), 0, ggml_nbytes(kvi_swa));
781+
}
732782

733783
// Set token IDs for per-layer embedding
734784
if (tok_ids && token_ids) {

server/src/gemma4/gemma4_loader.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,11 @@ bool create_gemma4_cache_partial(ggml_backend_t backend,
548548
return false;
549549
}
550550

551+
// Zero-init KV: the FA views span the 256-padded tail beyond the written
552+
// positions (mask gates it to -inf), so reads there must dequantise to
553+
// finite values — F16 garbage can be NaN/Inf and NaN + (-inf) = NaN.
554+
ggml_backend_buffer_clear(out.buf, 0);
555+
551556
out.cur_pos = 0;
552557
out.max_ctx = max_ctx;
553558
out.n_layer = w.n_layer;

server/src/internal.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,8 @@ QwenLayerPrefnOutputs build_qwen35_layer_prefn(
621621
ggml_tensor * attn_mask,
622622
int kv_start,
623623
int n_tokens,
624-
int fa_window = 0);
624+
int fa_window = 0,
625+
ggml_tensor * kv_write_rows = nullptr);
625626

626627
} // namespace dflash::common
627628

server/src/laguna/laguna_internal.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,13 @@ struct LagunaGraphInputs {
239239
ggml_tensor * attn_mask_swa; // optional [kv_len, n_tokens] F16 (causal + sliding window) for SWA layers
240240
int n_tokens;
241241
int kv_start;
242+
// CUDA-graph-stable decode: when kv_pad > 0 the FA K/V views + masks span
243+
// kv_pad slots (stride-rounded; mask gates validity) and the K/V append
244+
// goes through ggml_set_rows with kv_idx as a graph INPUT, so node
245+
// properties stay identical across decode steps and the ggml-cuda
246+
// CUDA-graph cache replays instead of re-launching every kernel.
247+
int kv_pad = 0; // 0 = legacy exact-length views + cpy append
248+
ggml_tensor * kv_idx = nullptr; // [n_tokens] I32 cache row indices (graph input)
242249
bool output_logits = true;
243250
bool output_hidden_states = false;
244251
// If true, lm_head only runs on the LAST token (saves ~6 GB of logit memory

0 commit comments

Comments
 (0)