Skip to content

Commit ebf6288

Browse files
kekzlclaude
andauthored
perf(attn): persistent K/V gather scratch for the eager chunked path (#847) (#866)
Spec-verify re-enters the chunked attention gather per layer per verify step; each entry paid a cudaMallocAsync/cudaFreeAsync pair (~140 allocs per verify on the 27B hybrid). Replace with a grow-only executor-owned scratch (64 MiB growth steps, reused across layers — stream-ordered), falling back to the per-call alloc when the grow fails; freed in the workspace teardown path. Measured (27B MTP-only k=4): ~56-61 ms/verify vs ~59-60 before — inside run-to-run noise, best trial 56.4 ms / 49.7 tok/s (best MTP-only number so far). Removes the acknowledged hot-loop-malloc exception noted in the chunked-prefill comment. ChunkedPrefill + Degeneration green; verify-fast core gates green (graphs gate = documented depressed-host #526). Co-authored-by: Claude Fable 5 <[email protected]>
1 parent 7d1409d commit ebf6288

4 files changed

Lines changed: 51 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ All notable changes since v0.6. Format loosely follows [Keep a Changelog](https:
55
## [Unreleased]
66

77
### Changed
8+
- **Persistent K/V gather scratch for the eager chunked path** — spec-verify
9+
re-enters the chunked attention per layer per verify; the per-call
10+
`cudaMallocAsync`/`FreeAsync` pair (~140 allocs/verify on hybrids) is
11+
replaced by a grow-only executor-owned scratch (64 MiB steps, per-call
12+
fallback if the grow fails). Small win inside run-to-run noise
13+
(best 27B MTP-only trial 56.4 ms/verify / 49.7 tok/s) and removes the
14+
acknowledged hot-loop-malloc exception in the chunk gather.
815
- **Small hd≠128 verify/boundary chunks prefer the tiled FMHA over cuBLAS**
916
— cuBLAS re-runs its per-new-shape algo selection on every call (100 MiB
1017
workspace memset + candidate benchmark + blocking event sync); spec-verify

src/exec/executor.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,14 @@ class GraphExecutor {
436436
half* chunk_capture_v_ = nullptr;
437437
int chunk_capture_ctx_ = 0;
438438

439+
// Persistent K/V gather scratch for the EAGER chunked path. Spec-verify
440+
// re-enters that path per layer per verify step — per-call
441+
// cudaMallocAsync/FreeAsync was ~140 alloc pairs per verify on hybrids
442+
// (#847). Grow-only; reused across layers (stream-ordered use).
443+
half* chunk_eager_k_ = nullptr;
444+
half* chunk_eager_v_ = nullptr;
445+
size_t chunk_eager_bytes_ = 0;
446+
439447
// Dense FFN phase tensors (views into shared_workspace_, set by configure_ffn_workspace)
440448
Tensor gate_out_; // [max_tokens, d_ff]
441449
Tensor up_out_; // [max_tokens, d_ff]

src/exec/executor_attention_prefill.cu

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,36 @@
112112

113113
half* k_full = nullptr;
114114
half* v_full = nullptr;
115+
bool used_eager_scratch = false;
115116
if (cap_replay) {
116117
k_full = chunk_capture_k_;
117118
v_full = chunk_capture_v_;
118119
} else {
119-
cudaMallocAsync(&k_full, full_bytes, stream);
120-
cudaMallocAsync(&v_full, full_bytes, stream);
120+
// Persistent gather scratch (grow-only, 64 MiB steps so a
121+
// growing ctx doesn't re-allocate every chunk). Falls back to
122+
// the per-call alloc when the grow fails.
123+
if (chunk_eager_bytes_ < full_bytes) {
124+
constexpr size_t kGrowStep = 64u << 20;
125+
const size_t cap = ((full_bytes + kGrowStep - 1) / kGrowStep) * kGrowStep;
126+
if (chunk_eager_k_) { cudaFreeAsync(chunk_eager_k_, stream); chunk_eager_k_ = nullptr; }
127+
if (chunk_eager_v_) { cudaFreeAsync(chunk_eager_v_, stream); chunk_eager_v_ = nullptr; }
128+
chunk_eager_bytes_ = 0;
129+
if (cudaMallocAsync(&chunk_eager_k_, cap, stream) == cudaSuccess &&
130+
cudaMallocAsync(&chunk_eager_v_, cap, stream) == cudaSuccess) {
131+
chunk_eager_bytes_ = cap;
132+
} else {
133+
if (chunk_eager_k_) { cudaFreeAsync(chunk_eager_k_, stream); chunk_eager_k_ = nullptr; }
134+
if (chunk_eager_v_) { cudaFreeAsync(chunk_eager_v_, stream); chunk_eager_v_ = nullptr; }
135+
}
136+
}
137+
if (chunk_eager_bytes_ >= full_bytes) {
138+
k_full = chunk_eager_k_;
139+
v_full = chunk_eager_v_;
140+
used_eager_scratch = true;
141+
} else {
142+
cudaMallocAsync(&k_full, full_bytes, stream);
143+
cudaMallocAsync(&v_full, full_bytes, stream);
144+
}
121145
}
122146

123147
// Gather past KV [0, q_offset) directly into k_full[0..q_offset], v_full[0..q_offset].
@@ -254,7 +278,7 @@
254278
cfg.attn_logit_softcap, stream, runtime_config(), q_offset);
255279
}
256280

257-
if (!cap_replay) {
281+
if (!cap_replay && !used_eager_scratch) {
258282
cudaFreeAsync(k_full, stream);
259283
cudaFreeAsync(v_full, stream);
260284
}

src/exec/executor_workspace_buffers.cu

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1258,6 +1258,15 @@ void GraphExecutor::free_buffers() {
12581258
chunk_capture_v_ = nullptr;
12591259
}
12601260
chunk_capture_ctx_ = 0;
1261+
if (chunk_eager_k_) {
1262+
IMP_CUDA_CHECK_LOG(cudaFree(chunk_eager_k_));
1263+
chunk_eager_k_ = nullptr;
1264+
}
1265+
if (chunk_eager_v_) {
1266+
IMP_CUDA_CHECK_LOG(cudaFree(chunk_eager_v_));
1267+
chunk_eager_v_ = nullptr;
1268+
}
1269+
chunk_eager_bytes_ = 0;
12611270
ws_.free_buffers(); // shared + persistent workspace (Workspace-owned)
12621271
vfree(fp32_accum_buf_);
12631272
ssm_layer_map_.clear();

0 commit comments

Comments
 (0)