Skip to content

Commit 8a626fe

Browse files
committed
mtp: kill momus bugs #1-3 (recommit off-by-one, capture ownership, async stream)
#1: recommit verify wrote accept_n+2 KV slots vs fast-path's accept_n+1; runner's base_pos += accept_n+2 then re-verified bonus at its own slot, skipping every Dth token at γ≥3 partial-accept (reproduced AR/spec divergence in new test_recommit_byte_identical_to_ar). #2: capture_topology_for_chain() virtual; runner owns the call. verify_batch no longer mutates last_tree_*. #3: dedicated rollback CUDA stream + cudaMemcpy2DAsync batching in restore_kv_at_dfs (4× fewer launches per layer). Bug #5 (step_sg_cache O(n_ctx)) deferred — needs ggml_set_rows refactor.
1 parent 1edf6c8 commit 8a626fe

6 files changed

Lines changed: 225 additions & 88 deletions

File tree

dflash/src/common/dflash_target.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ struct DFlashTarget {
131131
// without DeltaNet capture support.
132132
virtual void enable_chain_capture(bool /*on*/) {}
133133

134+
// Record linear-chain "tree" topology so a subsequent restore_kv_at_chain()
135+
// knows the base_pos / n_tokens of the chain to roll back. MUST be called
136+
// by the chain runner BEFORE verify_batch on every iter that may need a
137+
// rollback. No-op for targets without chain capture support.
138+
virtual void capture_topology_for_chain(int /*n_tokens*/, int /*base_pos*/) {}
139+
134140
// ── Token utilities ─────────────────────────────────────────────
135141

136142
// Check if a token is end-of-sequence for this model.

dflash/src/common/mtp_chain_runner.cpp

Lines changed: 26 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ GenerateResult MtpChainRunner::run(const GenerateRequest & req,
165165
return result;
166166
}
167167

168+
// Caller-owned topology for restore_kv_at_chain (bug #2).
169+
target_.capture_topology_for_chain((int)candidate.size(), base_pos);
170+
168171
int last_argmax = -1;
169172
std::vector<int32_t> all_argmax;
170173
if (!target_.verify_batch(candidate, base_pos, last_argmax, &all_argmax)) {
@@ -192,50 +195,43 @@ GenerateResult MtpChainRunner::run(const GenerateRequest & req,
192195
const int total_this_iter = accept_n + 1;
193196

194197
// ── KV reconciliation ──────────────────────────────────────────
195-
// verify_batch advanced KV by 1+g_actual positions. We only want
196-
// total_this_iter additional positions past base_pos. Three paths:
197-
// 1. accept_n == g_actual: KV is correct, nothing to do.
198-
// 2. accept_n < g_actual, fast rollback: target.restore_kv_at_chain
199-
// (accept_n) reuses the per-position SSM/conv intermediates
200-
// captured by the first verify_batch to undo the rejected tail
201-
// in-place. No second backbone forward. The bonus token's KV
202-
// is NOT written; next iter's verify_batch will process it as
203-
// the new cur_tok at slot base_pos+accept_n+1. base_pos
204-
// advance and cur_tok bookkeeping match the accept-all path.
205-
// 3. accept_n < g_actual, fast path declined: fall back to the
206-
// legacy snapshot+recommit (verify_batch on [cur, accepted,
207-
// bonus]). Used by targets that lack DeltaNet capture (CPU
208-
// stubs, layer-split shards) and any iter where chain capture
209-
// was skipped defensively. base_pos advances by accept_n+2 to
210-
// skip past the recommit-written bonus slot; cur_tok_next is
211-
// taken from the recommit's argmax at the bonus position.
212-
bool recommitted = false;
213-
bool fast_rolled_back = false;
214-
std::vector<int32_t> rc_argmax;
198+
// Three paths converge on the SAME post-iter invariant:
199+
// base_pos advances by total_this_iter = accept_n + 1
200+
// cur_tok = bonus (= all_argmax[accept_n])
201+
// and the bonus's KV is written by the NEXT iter's verify_batch.
202+
//
203+
// 1. accept-all (accept_n == g_actual): verify_batch wrote g+1 slots;
204+
// we treat the last (bonus) slot as uncommitted and let next iter
205+
// overwrite it.
206+
// 2. fast rollback (restore_kv_at_chain succeeds): rolls cache.cur_pos
207+
// to base_pos + accept_n + 1, leaving the bonus slot unwritten.
208+
// 3. recommit (fast path declined): snapshot+restore, then recommit
209+
// only [cur, accepted...] (accept_n+1 slots, NO bonus). Bonus is
210+
// threaded via cur_tok like the other paths. Advancing by
211+
// accept_n+2 here would skip a position every recommit iter and
212+
// diverge from AR — see test_recommit_byte_identical_to_ar.
215213
if (accept_n < g_actual) {
216-
if (target_.restore_kv_at_chain(accept_n)) {
217-
fast_rolled_back = true;
218-
} else {
219-
recommitted = true;
214+
if (!target_.restore_kv_at_chain(accept_n)) {
215+
// Slow path: snapshot rollback + commit ONLY [cur, accepted...]
216+
// (accept_n+1 slots). Bonus stays uncommitted, threaded via
217+
// cur_tok like the fast path.
220218
if (!target_.restore_kv()) {
221219
result.ok = false;
222220
result.error = "restore_kv";
223221
return result;
224222
}
225223
std::vector<int32_t> commit_seq;
226-
commit_seq.reserve((size_t)accept_n + 2);
224+
commit_seq.reserve((size_t)accept_n + 1);
227225
commit_seq.push_back(cur_tok);
228226
for (int i = 0; i < accept_n; i++) commit_seq.push_back(drafts[i]);
229-
commit_seq.push_back(all_argmax[accept_n]);
230227
int discard = -1;
231-
if (!target_.verify_batch(commit_seq, base_pos, discard, &rc_argmax)) {
228+
if (!target_.verify_batch(commit_seq, base_pos, discard, nullptr)) {
232229
result.ok = false;
233230
result.error = "recommit";
234231
return result;
235232
}
236233
}
237234
}
238-
(void)fast_rolled_back; // bookkeeping is identical to accept-all path.
239235

240236
// ── Emit accepted prefix + bonus, capped at n_gen ──────────────
241237
// emit_cap is the absolute ceiling on tokens that may be written
@@ -262,27 +258,8 @@ GenerateResult MtpChainRunner::run(const GenerateRequest & req,
262258
cur_tok = result.tokens.empty() ? cur_tok : result.tokens.back();
263259
}
264260

265-
// base_pos advance:
266-
// accept-all / fast_rolled_back:
267-
// verify_batch wrote g+1 KV slots; the fast rollback rolled
268-
// cache.cur_pos back to base_pos+accept_n+1 = base_pos +
269-
// total_this_iter, leaving the bonus token's KV unwritten.
270-
// Advance by total_this_iter and let next iter's verify_batch
271-
// process the bonus as the new cur_tok.
272-
// recommit:
273-
// recommit wrote accept_n+2 KV slots (cur_tok + accepted +
274-
// bonus). Advance by accept_n+2 so next iter's base_pos points
275-
// to a NEW position, not the bonus slot. cur_tok_next is the
276-
// argmax at the bonus position from the recommit (a fresh
277-
// prediction, not yet in cache).
278-
if (recommitted) {
279-
base_pos += accept_n + 2;
280-
if (!hit_eos && !rc_argmax.empty()) {
281-
cur_tok = rc_argmax.back();
282-
}
283-
} else {
284-
base_pos += total_this_iter;
285-
}
261+
// All paths share: base_pos += total_this_iter and cur_tok = bonus.
262+
base_pos += total_this_iter;
286263

287264
stats_.total_iters += 1;
288265
stats_.total_accepted += accept_n;

dflash/src/device_runtime.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ using __nv_bfloat16 = __hip_bfloat16;
3636
#define cudaEventElapsedTime hipEventElapsedTime
3737
#define cudaEventDestroy hipEventDestroy
3838
#define cudaStreamSynchronize hipStreamSynchronize
39+
#define cudaStreamCreate hipStreamCreate
40+
#define cudaStreamDestroy hipStreamDestroy
41+
#define cudaMemcpy3DAsync hipMemcpy3DAsync
42+
#define cudaPitchedPtr hipPitchedPtr
43+
#define cudaPos hipPos
44+
#define cudaExtent hipExtent
45+
#define cudaMemcpy3DParms hipMemcpy3DParms
46+
#define make_cudaPitchedPtr make_hipPitchedPtr
47+
#define make_cudaPos make_hipPos
48+
#define make_cudaExtent make_hipExtent
3949
#define cudaGetLastError hipGetLastError
4050
#define cudaGetErrorString hipGetErrorString
4151
#define cudaDeviceSynchronize hipDeviceSynchronize

dflash/src/qwen35/qwen35_dflash_target.cpp

Lines changed: 44 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Qwen35DFlashTarget::~Qwen35DFlashTarget() {
6262
vprof_sum_get_hpre_, vprof_sum_get_argmax_, vprof_sum_total_);
6363
}
6464
step_graph_destroy(proj_sg_);
65+
if (rollback_stream_) { cudaStreamDestroy(rollback_stream_); rollback_stream_ = nullptr; }
6566
}
6667

6768
Qwen35DFlashTarget::Qwen35DFlashTarget(
@@ -264,27 +265,9 @@ bool Qwen35DFlashTarget::verify_batch(
264265

265266
cache_.cur_pos = base_pos + n_tokens;
266267

267-
// Record linear-chain "tree" topology so restore_kv_at_chain (and
268-
// restore_kv_at_dfs, for completeness) can roll back to any accepted
269-
// prefix of this batch. Only meaningful when capture actually fired —
270-
// when it didn't, leave last_tree_base_pos_ at its prior value (could be
271-
// -1 or a stale verify_tree value); the chain runner only calls
272-
// restore_kv_at_chain on iters where it itself enabled capture, and a
273-
// stale last_tree_* wouldn't be consulted by restore_kv_at_dfs in that
274-
// window because verify_batch resets it here on every captured call.
275-
if (capture_intermediate) {
276-
last_tree_base_pos_ = base_pos;
277-
last_tree_n_nodes_ = n_tokens - 1;
278-
last_tree_parents_.resize(n_tokens);
279-
last_tree_depths_.resize(n_tokens > 0 ? (size_t)(n_tokens - 1) : 0);
280-
if (n_tokens > 0) last_tree_parents_[0] = -1; // root marker
281-
for (int i = 1; i < n_tokens; i++) {
282-
last_tree_parents_[i] = i - 1;
283-
last_tree_depths_[i - 1] = i;
284-
}
285-
} else {
286-
// Invalidate so a stray restore_kv_at_chain() returns false instead of
287-
// rolling back against stale capture buffers from an earlier iter.
268+
// Topology is owned by the caller via capture_topology_for_chain(); if
269+
// capture fired without prior topology, invalidate to be defensive.
270+
if (!capture_intermediate) {
288271
last_tree_base_pos_ = -1;
289272
}
290273

@@ -459,7 +442,14 @@ bool Qwen35DFlashTarget::restore_kv_at_dfs(const std::vector<int> & accepted_dfs
459442
}
460443

461444
const int n_delta = (int)cache_.ssm_intermediate.size();
462-
cudaStream_t stream = nullptr; // default stream — serializes with next graph_compute
445+
// Bug #3: dedicated stream so the rollback copies don't serialize with
446+
// the default stream (e.g. ggml backend compute, host syncs).
447+
if (!rollback_stream_) {
448+
if (cudaStreamCreate(&rollback_stream_) != cudaSuccess) {
449+
rollback_stream_ = nullptr; // fall back to default
450+
}
451+
}
452+
cudaStream_t stream = rollback_stream_;
463453
for (int il = 0; il < n_delta; il++) {
464454
ggml_tensor * ssm_inter = cache_.ssm_intermediate[il];
465455
ggml_tensor * conv_in = cache_.conv_input_cache[il];
@@ -533,9 +523,9 @@ bool Qwen35DFlashTarget::restore_kv_at_dfs(const std::vector<int> & accepted_dfs
533523
}
534524

535525
// Full-attention KV compaction: verify_tree wrote K/V at slots
536-
// [base..base+N-1] in DFS order. For the next iter's verify to see the
537-
// correct committed prefix, slots [base..base+commit_n-1] must hold the
538-
// K/V of the accepted path. d==0 (root) is trivially aligned.
526+
// [base..base+N-1] in DFS order. Bug #3: collapse the per-head inner
527+
// loop into one cudaMemcpy2DAsync (pitch=nb[2], height=n_kv) — saves
528+
// 2*n_kv-2 launches per (layer, d) pair on a dedicated stream.
539529
if (walked_sibling) {
540530
const int base = last_tree_base_pos_;
541531
const int n_full_attn = (int)cache_.attn_k.size();
@@ -548,30 +538,46 @@ bool Qwen35DFlashTarget::restore_kv_at_dfs(const std::vector<int> & accepted_dfs
548538
ggml_tensor * cv = cache_.attn_v[l];
549539
if (!ck || !cv) continue;
550540
const size_t slot_bytes = ck->nb[1];
551-
const size_t src_off = (size_t)(base + src_dfs) * slot_bytes;
552-
const size_t dst_off = (size_t)(base + dst_slot) * slot_bytes;
553-
const int n_kv = (int)ck->ne[2];
554-
for (int h = 0; h < n_kv; h++) {
555-
const size_t head_src = src_off + (size_t)h * ck->nb[2];
556-
const size_t head_dst = dst_off + (size_t)h * ck->nb[2];
557-
cudaMemcpyAsync((char *)ck->data + head_dst,
558-
(const char *)ck->data + head_src,
559-
slot_bytes, cudaMemcpyDeviceToDevice, stream);
560-
cudaMemcpyAsync((char *)cv->data + head_dst,
561-
(const char *)cv->data + head_src,
562-
slot_bytes, cudaMemcpyDeviceToDevice, stream);
563-
}
541+
const int n_kv = (int)ck->ne[2];
542+
const size_t pitch = ck->nb[2];
543+
const size_t src_off = (size_t)(base + src_dfs) * slot_bytes;
544+
const size_t dst_off = (size_t)(base + dst_slot) * slot_bytes;
545+
cudaMemcpy2DAsync((char *)ck->data + dst_off, pitch,
546+
(const char *)ck->data + src_off, pitch,
547+
slot_bytes, n_kv,
548+
cudaMemcpyDeviceToDevice, stream);
549+
cudaMemcpy2DAsync((char *)cv->data + dst_off, cv->nb[2],
550+
(const char *)cv->data + src_off, cv->nb[2],
551+
slot_bytes, (int)cv->ne[2],
552+
cudaMemcpyDeviceToDevice, stream);
564553
}
565554
}
566555
}
567556

557+
// Sync rollback stream so the next graph_compute (on default stream)
558+
// sees a consistent KV/SSM state.
559+
if (rollback_stream_) cudaStreamSynchronize(rollback_stream_);
560+
568561
// Advance cur_pos to "just past the last committed slot" so the next
569562
// verify_batch's kv_start lines up. root = dfs 0 lives at base, so
570563
// commit_n committed tokens occupy slots [base..base+commit_n-1].
571564
cache_.cur_pos = last_tree_base_pos_ + commit_n;
572565
return true;
573566
}
574567

568+
void Qwen35DFlashTarget::capture_topology_for_chain(int n_tokens, int base_pos) {
569+
if (n_tokens <= 0) { last_tree_base_pos_ = -1; return; }
570+
last_tree_base_pos_ = base_pos;
571+
last_tree_n_nodes_ = n_tokens - 1;
572+
last_tree_parents_.resize(n_tokens);
573+
last_tree_depths_.resize((size_t)(n_tokens - 1));
574+
last_tree_parents_[0] = -1;
575+
for (int i = 1; i < n_tokens; i++) {
576+
last_tree_parents_[i] = i - 1;
577+
last_tree_depths_[i - 1] = i;
578+
}
579+
}
580+
575581
bool Qwen35DFlashTarget::restore_kv_at_chain(int accept_n) {
576582
// A chain of N tokens recorded by verify_batch is the DFS spine
577583
// [0, 1, ..., N-1]. Roll back to slot accept_n: the first (accept_n + 1)

dflash/src/qwen35/qwen35_dflash_target.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
#include "ggml.h"
1515
#include "ggml-backend.h"
16+
#include "device_runtime.h" // cudaStream_t
1617

1718
#include <vector>
1819

@@ -125,6 +126,11 @@ class Qwen35DFlashTarget : public DFlashTarget {
125126
// recommit fallback.
126127
void enable_chain_capture(bool on) override { chain_capture_enabled_ = on; }
127128

129+
// Record a linear-chain "tree" (spine [0..n_tokens-1] at base_pos) so
130+
// a subsequent restore_kv_at_chain() can locate the rollback slot.
131+
// Owned by the caller (chain runner), separate from verify_batch state.
132+
void capture_topology_for_chain(int n_tokens, int base_pos) override;
133+
128134
bool is_eos(int token) const override;
129135

130136
bool embed_tokens(const int32_t * tokens, int n,
@@ -255,6 +261,11 @@ class Qwen35DFlashTarget : public DFlashTarget {
255261
// n_tokens > max_verify_tokens, e.g. 512-token prefill chunks, where the
256262
// in-graph ggml_view_3d into the conv_input cache asserts).
257263
bool chain_capture_enabled_ = false;
264+
265+
// Dedicated CUDA stream for restore_kv_at_dfs copies (bug #3): avoids
266+
// serializing ~384 per-head launches on the default stream. Created on
267+
// first use, destroyed in the dtor.
268+
mutable cudaStream_t rollback_stream_ = nullptr;
258269
};
259270

260271
} // namespace dflash27b

0 commit comments

Comments
 (0)