spark_model/layers/dflash_head.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! DFlash block-diffusion draft head implementing [`DraftProposer`].
4//!
5//! Block-diffusion drafter (Z Lab, arXiv 2602.06036): a small Qwen3-architecture
6//! transformer (8 layers, hidden=2048, GQA 32:4, head_dim=128) that emits γ=16
7//! tokens **in a single forward pass** via bidirectional in-block attention.
8//! Conditioned on five intermediate hidden states captured from the target
9//! model at `target_layer_ids` (e.g., `[1, 10, 19, 28, 37]` for
10//! Qwen3.6-35B-A3B-DFlash), projected through a single `fc` layer at model
11//! entry — NOT per-layer KV injection (early plan was wrong; cf. vLLM
12//! `qwen3_dflash.py`).
13//!
14//! Phase 1 deliverable: type + trait wiring. The actual γ-block forward kernel
15//! (`inferspark_dflash_block_attn_fp8`) lands in Phase 2; until then `propose()`
16//! returns the bonus token repeated `num_drafts` times so the verify path
17//! degenerates to single-token decode (acceptance ~100% but no speedup).
18
19use parking_lot::Mutex;
20use std::any::Any;
21
22use anyhow::Result;
23use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
24use spark_runtime::kv_cache::PagedKvCache;
25
26use crate::speculative::{DraftProposer, ProposerState};
27use crate::weight_map::{DenseWeight, QuantizedWeight};
28
29/// Kernel handles for the DFlash γ-block forward chain. All resolved once
30/// at `BlockDiffusionDraftHead::from_weights` against the active GPU backend
31/// (which compiles target-specific PTX at startup); subsequent
32/// `propose()` calls just `KernelLaunch::new(...).launch(stream)`.
33pub struct DflashKernels {
34 pub rms_norm: KernelHandle,
35 pub residual_rms_norm: KernelHandle,
36 pub dense_gemv: KernelHandle,
37 pub dense_gemm: KernelHandle,
38 /// NVFP4 GEMM for the final logits when the shared lm_head is NVFP4
39 /// (e.g. Holo): a BF16 `dense_gemm` on NVFP4-packed bytes reads garbage
40 /// (and ~4× OOB → CUDA-700). `.0 == 0` when the target lm_head is BF16.
41 pub w4a16_gemm: KernelHandle,
42 pub dense_gemm_pipelined: KernelHandle,
43 pub rope_qwen3: KernelHandle,
44 pub reshape_cache_fp8: KernelHandle,
45 /// BF16 KV cache writeback. Used by Phase 2 `precompute_ctx_kv` and
46 /// the per-layer γ-block `reshape_and_cache` call to populate the
47 /// drafter's BF16 paged cache before each `prefill_attention_paged_dflash`.
48 pub reshape_cache_bf16: KernelHandle,
49 pub prefill_attn_dflash_fp8: KernelHandle,
50 /// BF16 paged-attention dispatcher for the DFlash γ-block.
51 /// Calls `inferspark_prefill_paged` with `causal_mask_enabled=0`,
52 /// reading BF16 K/V from the per-layer paged cache pool. Phase 2
53 /// (Option B) drafter attention runs through this kernel; the FP8
54 /// variant above is retained for a future quality-validated FP8 KV
55 /// path. See `ops::prefill_attention_paged_dflash`.
56 pub prefill_attn_dflash_bf16: KernelHandle,
57 /// Phase 5 (CUDA graph) variant of `prefill_attn_dflash_bf16` that reads
58 /// `kv_len` and `q_offset` from device pointers instead of taking them as
59 /// kernel scalar args. Used by the graph-captured forward_block path so a
60 /// single graph instance can be replayed across steps with different
61 /// dynamic values written to the indirect-args buffer pre-launch.
62 /// Resolves to kernel `inferspark_prefill_paged_indirect`.
63 pub prefill_attn_dflash_bf16_indirect: KernelHandle,
64 pub silu_mul: KernelHandle,
65 pub residual_add: KernelHandle,
66 pub argmax: KernelHandle,
67 pub batched_embed: KernelHandle,
68 /// Phase 2 Option B: builds `[count]` i32 slot indices on-device
69 /// from a host-provided block_table. Used by propose.rs to populate
70 /// the slot_mapping passed to reshape_and_cache and precompute_ctx_kv.
71 pub fill_slots: KernelHandle,
72 /// Non-paged prefill attention (used for the γ-block self-attention
73 /// when there's no persistent K/V cache to walk).
74 pub prefill_attn: KernelHandle,
75 /// Phase G — BF16 → FP8 E4M3 per-row weight quantization. Used at
76 /// model load time to convert the seven dense-GEMM drafter weights
77 /// (q/k/v/o/gate/up/down) when `AVAROK_DFLASH_DRAFTER_FP8=1`. Never
78 /// on the hot path.
79 pub quantize_bf16_to_fp8: KernelHandle,
80 /// Phase G — Row-scaled BF16 × FP8 → BF16 GEMM. Consumes the
81 /// `Fp8DenseWeight` (FP8 weight + per-row f32 scale) produced at
82 /// load time by `quantize_bf16_to_fp8`. Wraps
83 /// `kernels/gb10/qwen3.6-27b/nvfp4/w4a16_gemm.cu fp8_gemm_t_row_scaled`.
84 /// Replaces `dense_gemm_bf16` on the seven dense-GEMM call sites in
85 /// `forward_block_layer_pre_attn` / `_post_attn` when
86 /// `self.quant == DflashQuantization::Fp8Weights`.
87 pub fp8_gemm_n128_row_scaled: KernelHandle,
88 /// Phase G — Row-scaled BF16 × FP8 → BF16 GEMV (M=1) for the
89 /// lm_head fall-back. At γ=16 vs vocab=248320 the row-scaled GEMM
90 /// wastes 75% of its M_TILE; the GEMV in a γ-loop is faster.
91 pub dense_gemv_fp8w: KernelHandle,
92 /// Phase G — Small-M (M≤16) row-scaled FP8 GEMM. Drop-in replacement
93 /// for `fp8_gemm_n128_row_scaled` when M=γ=16. Single warp per CTA,
94 /// no wasted M_TILE rows. Used by the lm_head GEMM.
95 pub fp8_gemm_n128_row_scaled_m16: KernelHandle,
96 /// Register-tiled batched row-scaled FP8 GEMV (M<=8, T=2 outputs per
97 /// thread) — the FP8 twin of `w4a16_gemv_batch8_rt2`. Preferred over
98 /// BOTH tile GEMMs above at M<=8 (they pad 87%/50% of their M-tile;
99 /// ~100 GB/s measured vs 180+ for the rt family, nsys 2026-08-19).
100 /// `.0 == 0` on targets without the `fp8_gemv_rt` module → tile path.
101 /// Kill-switch: AVAROK_NO_DFLASH_FP8_RT=1. provenance-id:
102 /// 526f6e616c6420522e205374657369616b
103 pub fp8_gemv_rt2: KernelHandle,
104 /// MAX_M=16 sibling of `fp8_gemv_rt2` for the γ>8 propose window
105 /// (2026-08-29: STEP_TIMING measured propose 18.2ms rt2 vs 38.0ms tile
106 /// fallback at flag 9 — the entire γ>8 step tax). `.0 == 0` on stale
107 /// kernel builds → tile path, exactly as before.
108 /// provenance-id: 526f6e616c6420522e205374657369616b
109 pub fp8_gemv_rt2_16: KernelHandle,
110 /// DFlash2 two-tap grouped dynamic conv (`kernels/gb10/common/dflash2.cu`).
111 /// `.0 == 0` on targets without the module (DFlash2 then refuses to arm).
112 pub dflash2_conv2: KernelHandle,
113 /// DFlash2 per-row destructive top-16 over drafter logits.
114 pub dflash2_topk16: KernelHandle,
115 /// DFlash2 candidate-selector chain walk (single launch, whole block).
116 pub dflash2_selector_walk: KernelHandle,
117}
118
119/// Cross-sequence batch descriptor for one drafter forward.
120///
121/// Rows are seq-major: sequence `i` owns `[i*gamma, (i+1)*gamma)` in every
122/// scratch buffer, and its drafts land in band `i`. Only attention, the KV
123/// slot writes and the selector's chain seed are per-sequence; every
124/// weight-bearing op runs once over all `n * gamma` rows, which is the whole
125/// point of batching.
126pub(super) struct DflashBatch<'a> {
127 pub last_tokens: &'a [u32],
128 pub positions: &'a [usize],
129 /// Per-sequence drafter block table device pointers.
130 pub block_tables: Vec<DevicePtr>,
131 /// Per-sequence populated ctx slot counts (drives kv_len / q_offset).
132 pub ctx_counts: Vec<u32>,
133}
134
135/// Per-step scratch buffers for the γ-block forward.
136///
137/// Sized for `n_attn_slots = ctx_window + γ` rows, where ctx_window is the
138/// max number of past target positions the drafter attends to per step. The
139/// first `ctx_window` slots hold post-`fc` projected target context (K/V
140/// only — Q is zero-padded); the next γ slots hold the noise tokens.
141///
142/// At γ=16 and ctx_window=γ=16: 32 rows × 2048 BF16 × ~10 buffers = ~1.3 MB
143/// per head. lm_head logits buffer is the largest single alloc:
144/// 32 × 248320 × 2 = 15 MB.
145pub struct DflashScratch {
146 pub stream_buf: DevicePtr,
147 pub norm_buf: DevicePtr,
148 pub q_buf: DevicePtr,
149 pub k_buf: DevicePtr,
150 pub v_buf: DevicePtr,
151 pub attn_out: DevicePtr,
152 pub mlp_intermediate: DevicePtr,
153 pub mlp_up: DevicePtr,
154 pub stream_acc: DevicePtr,
155 /// `[ctx_window, draft_hidden]` BF16 — fc-projected + hidden_norm'd
156 /// ctx for the most recent `ctx_window` target positions.
157 pub fc_proj: DevicePtr,
158 /// Phase 2 (Option B) scratch for `precompute_ctx_kv`: fused KV
159 /// GEMM output, shape `[max_new_ctx, L * 2 * kv_dim]` BF16.
160 /// `max_new_ctx` = `ctx_window` (worst case: first propose runs
161 /// precompute over the entire prefix).
162 pub fused_kv_out: DevicePtr,
163 /// Phase 2 scratch: i32 slot mapping for the per-layer
164 /// `reshape_and_cache` calls. Sized `[ctx_window]`.
165 pub slot_mapping_dev: DevicePtr,
166 /// Batched ctx precompute staging: the uncommitted ctx rows of EVERY
167 /// sequence in a batched propose, gathered contiguously so the fc + fused
168 /// KV GEMMs stream their 370 MB of weights ONCE per step instead of once
169 /// per sequence. `[PRECOMPUTE_BATCH_ROWS, L_t * h_t]` BF16.
170 pub precompute_in: DevicePtr,
171 /// Phase 5 (CUDA graph) scratch: 8 bytes (`[u32 kv_len, u32 q_offset]`)
172 /// holding the per-call dynamic values that the indirect paged-attention
173 /// kernel reads at entry. Host writes via `copy_h2d` BEFORE entering the
174 /// captured region so the graph itself sees a stable device pointer.
175 pub option_b_indirect_args_dev: DevicePtr,
176 /// Phase E.2: pinned host buffer (`γ × 4` bytes) for the per-propose
177 /// draft-token D2H copy. Allocated once at construction via
178 /// `gpu.alloc_host_pinned`; the async D2H lands here without touching
179 /// the system pageable allocator each call.
180 ///
181 /// Wrapped in `AtomicPtr` to keep `DflashScratch: Send + Sync` (the
182 /// proposer is stored as `Arc<dyn DraftProposer>` which requires both
183 /// auto-traits). Reads via `Ordering::Relaxed` are safe: the pointer
184 /// itself never changes after construction; we only need atomic
185 /// access for the Send/Sync bound, not for any actual concurrency.
186 pub draft_tokens_host_pinned: std::sync::atomic::AtomicPtr<u8>,
187 /// Phase E.2: CUDA event recorded against the draft-tokens D2H so the
188 /// host can block on completion just before reading the pinned buffer,
189 /// without a full `cuStreamSynchronize`. Created once at construction.
190 pub draft_tokens_event: u64,
191 pub logits: DevicePtr,
192 pub draft_tokens_dev: DevicePtr,
193 /// `[ctx_window + γ]` i32 positions. First ctx_window are
194 /// historical target positions (decoded indices); last γ are
195 /// the to-be-predicted noise positions.
196 pub position_ids: DevicePtr,
197 /// DSpark Markov scratch: `[1, markov_rank]` BF16 latent for the
198 /// prev-token gather (`markov_w1[prev]`). `DevicePtr(0)` when the
199 /// drafter has no Markov head.
200 pub markov_embed: DevicePtr,
201 /// DSpark Markov scratch: `[vocab]` BF16 full-vocab bias
202 /// (`markov_w2 @ markov_embed`), residual-added onto one logits row
203 /// per sequential step. `DevicePtr(0)` when no Markov head.
204 pub markov_bias: DevicePtr,
205 /// DSpark confidence scratch: `[γ]` BF16 per-row acceptance logits
206 /// (`AcceptRatePredictor` output). Read back host-side after the
207 /// draft-token D2H to pick the confident prefix length.
208 /// `DevicePtr(0)` when the drafter has no confidence head.
209 pub conf_out: DevicePtr,
210
211 // ── DFlash2 scratch (DevicePtr(0) on non-DFlash2 drafters) ──
212 /// `[γ, 2*kernel*groups]` BF16 — dynamic conv kernels for one conv
213 /// site (kernel_projection GEMM output at prepare; the finish
214 /// application reads its slice after the sublayer). Reused
215 /// sequentially by both conv sites of every layer.
216 pub conv_dyn: DevicePtr,
217 /// `[γ, hidden]` BF16 — convolved-hidden staging (prepare writes here,
218 /// the sublayer GEMMs read from here; finish stages here before the
219 /// residual add).
220 pub conv_tmp: DevicePtr,
221 /// `[γ, 16]` f32 — selector top-16 unary logits per row.
222 pub sel_vals: DevicePtr,
223 /// `[γ, 16]` u32 — selector top-16 candidate token ids per row.
224 pub sel_idx: DevicePtr,
225 /// `[γ, selector_rank]` BF16 — H(h_t) context-gate projections.
226 pub sel_hproj: DevicePtr,
227}
228
229/// Drafter-side weight precision. Defaults to BF16. **Phase G (2026-05-28)**
230/// adds `Fp8Weights`, gated by env var `AVAROK_DFLASH_DRAFTER_FP8`. The
231/// historical SM12.x acceptance collapse note applied to drafter FP8 KV
232/// cache (different concern — bidirectional attention math); Phase G
233/// targets weight FP8 only, so the risk surface is dynamic-range loss
234/// in MLP intermediate activations, which per-row scales mitigate.
235/// `--mtp-quantization fp8` is still not honored for the DFlash drafter.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum DflashQuantization {
238 Bf16,
239 /// Weight-only FP8: q/k/v/o/gate/up/down BF16 → FP8 E4M3 with per-row
240 /// f32 scales at model load. Activations stay BF16; KV cache stays
241 /// BF16. GEMMs use `fp8_gemm_n128` (BF16 × FP8 → BF16).
242 Fp8Weights,
243}
244
245/// Per-drafter-layer Qwen3-style weights. Phase 1 is BF16-only; **Phase G**
246/// (2026-05-28) adds optional FP8 weight fields populated at model load
247/// when `AVAROK_DFLASH_DRAFTER_FP8=1`. The BF16 fields are always present
248/// (Fp8 path falls back to them for any GEMM whose Fp8 weight is None).
249#[allow(dead_code)]
250pub struct DflashLayer {
251 // Norms
252 pub input_layernorm: DenseWeight,
253 pub post_attention_layernorm: DenseWeight,
254 // Attention (Qwen3: per-head Q/K RMSNorm)
255 pub q_proj: DenseWeight,
256 pub k_proj: DenseWeight,
257 pub v_proj: DenseWeight,
258 pub o_proj: DenseWeight,
259 pub q_norm: DenseWeight,
260 pub k_norm: DenseWeight,
261 // MLP
262 pub gate_proj: DenseWeight,
263 pub up_proj: DenseWeight,
264 pub down_proj: DenseWeight,
265
266 // Phase G — optional FP8 mirrors of the seven dense-GEMM weights.
267 // Populated at load time when `AVAROK_DFLASH_DRAFTER_FP8=1`, consumed
268 // by forward_block_layer_pre_attn / _post_attn when self.quant ==
269 // DflashQuantization::Fp8Weights. None when BF16 path is active.
270 pub q_proj_fp8: Option<crate::weight_map::Fp8DenseWeight>,
271 pub k_proj_fp8: Option<crate::weight_map::Fp8DenseWeight>,
272 pub v_proj_fp8: Option<crate::weight_map::Fp8DenseWeight>,
273 pub o_proj_fp8: Option<crate::weight_map::Fp8DenseWeight>,
274 pub gate_proj_fp8: Option<crate::weight_map::Fp8DenseWeight>,
275 pub up_proj_fp8: Option<crate::weight_map::Fp8DenseWeight>,
276 pub down_proj_fp8: Option<crate::weight_map::Fp8DenseWeight>,
277
278 // DFlash2 grouped dynamic causal convs (None on DFlash1/DSpark).
279 /// `attention_conv.base_kernel` `[2 applications, kernel_size, hidden]`.
280 pub attention_conv_base: Option<DenseWeight>,
281 /// `attention_conv.kernel_projection.weight` `[2*kernel*groups, hidden]`.
282 pub attention_conv_proj: Option<DenseWeight>,
283 /// `mlp_conv.base_kernel`, same shape as attention_conv_base.
284 pub mlp_conv_base: Option<DenseWeight>,
285 /// `mlp_conv.kernel_projection.weight`, same shape as attention_conv_proj.
286 pub mlp_conv_proj: Option<DenseWeight>,
287}
288
289/// Per-sequence DFlash drafter state. One paged KV cache per drafter layer
290/// (8 typical), shared block table across layers since attention shape is
291/// identical layer-to-layer for a vanilla Qwen3 architecture. Mirrors
292/// `MtpProposerState` in spirit; the multi-layer cache keeps it distinct.
293pub struct DflashProposerState {
294 /// Block table for the drafter's KV cache (shared across all drafter layers).
295 pub block_table: Vec<u32>,
296 /// Current logical sequence length in the drafter's KV cache. Tracks how
297 /// many target-aligned positions have been written via
298 /// `precompute_and_store_context_kv`.
299 pub seq_len: usize,
300 /// Drafts produced in the last `propose()` call. `after_verify` consults
301 /// this to know how many KV positions to roll back when the accept
302 /// prefix is shorter than γ.
303 pub last_num_drafted: usize,
304 /// Whether the prompt-time `precompute_and_store_context_kv` has been
305 /// called. The first `propose()` after model build needs to run prefill
306 /// over the full prompt's captured hiddens; subsequent steps incrementally
307 /// append the latest accepted tokens' projections.
308 pub prefill_done: bool,
309 /// Multi-token accumulator for captured target hidden states. Layout:
310 /// `[max_ctx_len, 5 * target_hidden]` BF16 packed. The scheduler appends
311 /// the model's `dflash_hidden_save` (latest decoded position's 5 hiddens)
312 /// into slot `ctx_len` after each successful verify. `propose()` reads
313 /// the full populated prefix and projects all positions through `fc`
314 /// at forward time. Sized for `max_seq_len` total positions; not
315 /// circular — fail-fast if exceeded (drafter can't handle longer
316 /// context than allocated).
317 pub ctx_hidden_acc: DevicePtr,
318 /// Number of populated slots in `ctx_hidden_acc`. Capped at `max_ctx_len`.
319 pub ctx_len: usize,
320 /// Drafts accepted in the verify that immediately preceded this propose.
321 /// Set by `after_verify` so propose can label row-0 with its TRUE position.
322 pub last_num_accepted: usize,
323 /// EAGLE-fix one-shot: when set, the next `propose()` skips its internal
324 /// decode-append because the verify step (K=2 accept) already appended
325 /// row 0 + row 1 in EAGLE order before calling propose. Consumed (reset to
326 /// false) by propose. Set on the EAGLE-fix path, which is DEFAULT-ON
327 /// (`AVAROK_DFLASH_EAGLE_FIX=0` is the kill switch, not `=1` the opt-in ,
328 /// see verify_k2_step.rs and verify_dflash_step.rs, both `!= Some("0")`).
329 pub skip_next_decode_append: bool,
330 /// Allocation cap for `ctx_hidden_acc` (in slot count). Mirrors the
331 /// `max_seq_len` build arg so we can clamp without re-fetching it.
332 pub max_ctx_len: usize,
333 /// Width (bytes) of one `ctx_hidden_acc` slot — `5 * target_hidden * bf16`.
334 /// Stored to avoid re-deriving on every append.
335 pub ctx_slot_bytes: usize,
336
337 // ─── Phase 2 Option B fields (paged KV cache for ctx) ───────────────
338 /// Device-side block table for the drafter's paged KV cache. Allocated
339 /// once at first propose with enough u32 slots to cover `max_seq_len`
340 /// at block_size=16. Read by `prefill_attention_paged_dflash` to map
341 /// logical block indices to physical pool block indices. Mirrors the
342 /// host-side `block_table` Vec, copied to GPU after each `alloc_block`.
343 pub block_table_dev: Option<DevicePtr>,
344 /// Number of paged-cache slots populated with ctx K/V for this sequence.
345 /// Distinct from `ctx_len` (which counts target_hidden_acc slots). The
346 /// drafter writes one ctx K/V slot per accepted target token; the
347 /// γ-block then attends over `[0..ctx_count_drafter+γ)`. Bumped by γ
348 /// per propose (γ slots written for the noise rows) and trimmed in
349 /// `after_verify` by `(γ - num_accepted)`.
350 pub ctx_count_drafter: usize,
351 /// Cap for `ctx_count_drafter`. Mirrors `block_table.len() * block_size`.
352 pub max_ctx_count_drafter: usize,
353 /// Phase I — incremental ctx precompute watermark. Number of ctx slots
354 /// `[0..ctx_committed)` whose K/V is already valid in the paged cache
355 /// from a prior propose. Each step we only precompute the new tail
356 /// `[ctx_committed..ctx_len)` instead of rebuilding the whole prefix
357 /// (the old O(ctx_len²) waste — see design doc §18). Reset to the
358 /// current `ctx_len` on any rewind so stale slots can't be read.
359 /// `0` forces a full rebuild (first propose, or the debug escape hatch).
360 pub ctx_committed: usize,
361 /// Phase I (v2) — per-slot TRUE absolute decoded position, stamped once
362 /// when a ctx slot is appended and never recomputed. Indexed by ctx
363 /// slot (parallel to `ctx_hidden_acc` slots, len == `ctx_len`). This is
364 /// the vLLM convention: a cached token's rope position is fixed at
365 /// insert time, so committed slots never go stale when later accepts
366 /// shift the live `position`. Replaces the sliding `absolute_start_pos
367 /// + i` formula in `precompute_ctx_kv`. Prefill positions are seeded
368 /// `0..prompt_len` in `update_dflash_ctx_len_after_prefill`.
369 pub ctx_positions: Vec<i32>,
370}
371
372impl ProposerState for DflashProposerState {
373 fn as_any(&self) -> &dyn Any {
374 self
375 }
376 fn as_any_mut(&mut self) -> &mut dyn Any {
377 self
378 }
379}
380
381/// Block-diffusion draft head. Public API is the [`DraftProposer`] trait.
382///
383/// The drafter shares `embed_tokens` and `lm_head` with the target — these
384/// are NOT in the drafter's safetensors checkpoint (verified against
385/// `z-lab/Qwen3.6-35B-A3B-DFlash` commit 42d3b34). The constructor takes
386/// the target's `embed_tokens_shared` and `lm_head_shared` device pointers
387/// at build time and slots them in alongside the drafter's own `fc`,
388/// `hidden_norm`, `norm`, and per-layer weights.
389#[allow(dead_code)]
390/// Per-block-width propose graph state (see `BlockDiffusionDraftHead::propose_graphs`).
391/// `warmup` counts the eager passes run at a width before its capture
392/// (default target 2, `AVAROK_DFLASH_PROPOSE_WARMUP_N`): two eager passes warm
393/// the PTX->SASS cache, ramp GB10 clocks and bring hot weight tiles into L2
394/// before the capture freezes the SASS variants the driver picks.
395#[derive(Default)]
396pub struct ProposeGraphs {
397 pub by_width: std::collections::HashMap<usize, Vec<spark_runtime::gpu::GraphHandle>>,
398 pub warmup: std::collections::HashMap<usize, usize>,
399}
400
401pub struct BlockDiffusionDraftHead {
402 // Drafter-architecture config (mirrors the drafter's HF config.json).
403 pub num_layers: usize,
404 pub hidden_size: usize,
405 pub intermediate_size: usize,
406 pub num_q_heads: usize,
407 pub num_kv_heads: usize,
408 pub head_dim: usize,
409 pub vocab_size: usize,
410 pub draft_vocab_size: usize,
411 /// The drafter's WIDEST block (rows per sequence): the launch `--dflash-gamma`
412 /// or the checkpoint default. Every gamma-sized buffer (scratch bands, the
413 /// paged KV growth, the pinned draft staging) is allocated from THIS. The
414 /// block a given propose actually runs is [`Self::block_g`], which the
415 /// scheduler's gamma resolver moves per step and which never exceeds it.
416 pub gamma: usize,
417 /// ACTIVE block width for the propose in flight, `2..=gamma` (rows per
418 /// sequence, anchor + block_g-1 masks => block_g-1 drafts, verify K =
419 /// block_g). Written by `set_block_g` at the top of every propose from the
420 /// scheduler's `num_drafts`; read by the forward path through `block_g()`
421 /// instead of `gamma`. An atomic only because the head is shared: the
422 /// scheduler proposes one step at a time on one stream, so there is never
423 /// a concurrent writer.
424 /// provenance-id: 526f6e616c6420522e205374657369616b
425 pub(super) block_gamma: std::sync::atomic::AtomicUsize,
426 /// Widest cross-sequence batch the scratch bands can hold.
427 pub(super) max_batch: usize,
428 pub mask_token_id: u32,
429 pub window_size: Option<usize>,
430 /// `target_layer_ids`. Same data as `TransformerModel::dflash_capture_layers`,
431 /// repeated here so the loader is the single source of truth; the model
432 /// reads these to size its capture buffer.
433 pub target_layer_ids: Vec<usize>,
434 /// Target-side hidden_size (used for the `fc` projection input width:
435 /// `target_layer_ids.len() * target_hidden_size`).
436 pub target_hidden_size: usize,
437
438 // === Weights shared with the target ===
439 /// Target's embed_tokens GPU pointer. The drafter's checkpoint has no
440 /// own embeddings — both vocab and embedding dim must match the target
441 /// (Qwen3.6-35B-A3B-DFlash: vocab=248320, hidden=2048 — same as target).
442 pub embed_tokens_shared: DevicePtr,
443 /// Target's lm_head GPU pointer. Used for the drafter's per-position
444 /// argmax over `[γ, vocab]` logits. Valid only when the target lm_head is
445 /// BF16; when `lm_head_nvfp4` is `Some`, the NVFP4 path is used instead.
446 pub lm_head_shared: DevicePtr,
447 /// Target's NVFP4 lm_head (packed + scales), shared with the drafter for
448 /// the final logits GEMM. `Some` when the target ships an NVFP4 lm_head
449 /// (e.g. Holo) — required because a BF16 `dense_gemm` on the NVFP4 buffer
450 /// reads garbage and OOB. `None` → use the BF16 `lm_head_shared`.
451 pub lm_head_nvfp4: Option<QuantizedWeight>,
452 /// Phase G — optional FP8 mirror of the shared lm_head weight,
453 /// `[vocab_size, hidden_size]` FP8 E4M3 + per-row f32 scales.
454 /// Built at model load when `AVAROK_DFLASH_DRAFTER_FP8=1`. Owned by
455 /// the drafter (separate allocation from the shared BF16 ptr) since
456 /// it must not mutate the target model's lm_head. `None` on the
457 /// BF16 path.
458 pub lm_head_shared_fp8: Option<crate::weight_map::Fp8DenseWeight>,
459
460 // === Weights from the drafter checkpoint ===
461 /// Hidden-norm applied to the projected target context before mixing
462 /// with the embedded tokens (Qwen3-DFlash convention; see vLLM
463 /// `DFlashQwen3Model.hidden_norm`).
464 pub hidden_norm: DenseWeight,
465 /// Final RMSNorm before LM head.
466 pub norm: DenseWeight,
467 /// `fc` projection — `[draft_hidden, target_layer_ids.len() * target_hidden_size]`
468 /// BF16. Maps the stack of captured target hiddens to drafter's input space
469 /// once at model entry. Replaces the earlier (incorrect) "per-layer KV
470 /// injection" design.
471 pub fc: DenseWeight,
472 /// Optional draft-vocab-id → target-vocab-id remap. `None` when the
473 /// drafter shares vocab with the target (Qwen3.6-35B-A3B-DFlash case:
474 /// vocab_size == draft_vocab_size == 248320).
475 pub draft_id_to_target_id: Option<DevicePtr>,
476 /// Drafter transformer layers (8 for Qwen3.6-35B-A3B-DFlash).
477 pub layers: Vec<DflashLayer>,
478
479 /// Phase 2 (Option B) fused K/V projection across all L drafter layers.
480 /// Shape: `[L × 2 × kv_dim, h]` BF16 — concatenated `[K0; V0; K1; V1; …]`
481 /// (per-layer K then V interleaved). Built once at construction by
482 /// `copy_d2d`-stitching the per-layer `k_proj.weight` and `v_proj.weight`
483 /// pointers from `layers[i]`. Lets `precompute_ctx_kv` derive every
484 /// drafter layer's ctx K/V via a single `dense_gemm` of shape
485 /// `[new_ctx_count, h] × [h, L·2·kv_dim]` instead of 2·L per-layer GEMMs.
486 ///
487 /// `None` until Phase 2 lands the build (stage 1: kernel/dispatcher
488 /// scaffolding; stage 2: this allocation + the precompute_ctx_kv module;
489 /// stage 3: pyref bit-exact diff). Layout (K then V per layer) chosen
490 /// to match vLLM's `_fused_kv_weight` in `qwen3_dflash.py:381-389`.
491 pub fused_kv_weight: Option<DevicePtr>,
492
493 /// Paged FP8 KV cache. One cache holding all `num_layers` drafter layers,
494 /// laid out the same way the target's KV cache is — block-table-keyed,
495 /// `num_layers × num_kv_heads × head_dim` per slot. Allocating a single
496 /// multi-layer cache (vs. one per drafter layer) matches Atlas's existing
497 /// `PagedKvCache` ABI and lets us reuse the existing `reshape_and_cache`
498 /// kernel without per-layer dispatch overhead.
499 pub kv_cache: Mutex<PagedKvCache>,
500
501 /// Per-step scratch buffers (allocated once at construction, reused).
502 pub scratch: DflashScratch,
503
504 /// All kernel handles needed by `propose()` and the eventual prefill
505 /// projection (`precompute_and_store_context_kv`).
506 pub kernels: DflashKernels,
507
508 /// Per-sequence ctx accumulator capacity (mirrors model's `max_seq_len`).
509 /// Used by `alloc_state` to size each new sequence's `ctx_hidden_acc`.
510 pub max_seq_len: usize,
511
512 /// Pre-computed yarn inv_freq table (`[head_dim/2]` f32 on GPU).
513 /// Drafter rope_scaling: factor=64, beta_fast=32, beta_slow=1,
514 /// original_max_position_embeddings=4096 (per drafter config.json).
515 pub yarn_inv_freq: DevicePtr,
516
517 /// rope_theta (10000000 for Qwen3.6-DFlash). Stored to pass into the
518 /// rope_yarn kernel each step.
519 pub rope_theta: f32,
520
521 /// rotary_dim. Drafter uses full-rotation (rotary_dim = head_dim = 128).
522 pub rotary_dim: usize,
523
524 /// RMSNorm epsilon (drafter inherits Qwen3 default 1e-6).
525 pub rms_norm_eps: f32,
526
527 /// Max number of past target positions injected into the drafter's K/V
528 /// per step. Default γ — drafter sees at most γ ctx + γ noise = 2γ
529 /// attention positions per step. ctx_window=0 disables ctx conditioning
530 /// (degraded quality, ablation only).
531 pub ctx_window: usize,
532
533 // === Phase D (CUDA graph capture) → Phase F (piecewise) ===
534 /// Per-subgraph captured handles. `None` until warm-up completes and
535 /// the first capture pass lands; on the capture pass we fill this
536 /// `Vec` with `2 × num_layers + 1` handles laid out as
537 /// `[pre_0, post_0, pre_1, post_1, ..., pre_{N-1}, post_{N-1}, tail]`.
538 /// Slot index = `layer_idx * 2 + half` for the layer halves
539 /// (half = 0 for pre_attn, 1 for post_attn) and `num_layers * 2` for
540 /// the tail (final norm + lm_head + argmax). `GraphHandle(0)` is the
541 /// "empty capture" sentinel and means that slot replays eager.
542 ///
543 /// Phase F.2 (2026-05-28): replaces the single full-region capture
544 /// with one capture per subgraph. Attention is NEVER captured —
545 /// it's the natural sync barrier between captured subgraphs
546 /// (vLLM piecewise convention). See design doc §15.
547 ///
548 /// Keyed by the ACTIVE block width (`block_g`): a captured subgraph bakes
549 /// in its row count, so each width the gamma resolver runs gets its own
550 /// capture (once, lazily) and switching widths afterwards costs nothing.
551 pub propose_graphs: Mutex<ProposeGraphs>,
552 /// When set, all `forward_block` calls run eagerly. Mirrors target-model
553 /// `TransformerModel::suppress_graphs` so external code can disable
554 /// graphs at runtime (e.g. while calibrating FP8 KV).
555 pub suppress_graphs: std::sync::atomic::AtomicBool,
556 /// Diagnostic and A/B levers, resolved from the environment ONCE when
557 /// this head was built. `forward_block` and its per-layer helpers read
558 /// these instead of the environment — see [`levers::DFlashLevers`].
559 pub levers: levers::DFlashLevers,
560 /// How many eager warm-up calls we've executed against the graph path.
561 /// Default warmup target is 2 (override via `AVAROK_DFLASH_PROPOSE_WARMUP_N`).
562 /// Two eager passes warm the PTX→SASS cache, ramp GB10 clocks to steady
563 /// state, and bring hot weight tiles into L2 before the capture freezes
564 /// SASS variants the driver picks. Shared across all subgraphs — every
565 /// subgraph captures on the same propose call after the warmup target
566 /// is hit.
567 pub propose_warmup_count: std::sync::atomic::AtomicUsize,
568
569 // Quantization mode (BF16 only for Phase 1).
570 pub quant: DflashQuantization,
571
572 // === DSpark heads (optional; None ⇒ plain DFlash behavior) ===
573 /// Markov head rank (0 when the drafter has no Markov head). RadixArk
574 /// Qwen3.8-27B-DSpark: 256.
575 pub markov_rank: usize,
576 /// `markov_w1`: `[vocab, rank]` BF16 prev-token embedding table.
577 pub markov_w1: Option<DenseWeight>,
578 /// `markov_w2`: `[vocab, rank]` BF16 latent→vocab projection
579 /// (`Linear(rank, vocab, bias=False).weight`, `[N, K]` GEMV layout).
580 pub markov_w2: Option<DenseWeight>,
581 /// Confidence head (`AcceptRatePredictor`) weight `[1, hidden(+rank)]`.
582 /// Loaded for the dynamic-K phase; not consumed by the Markov fixup.
583 pub confidence_proj: Option<DenseWeight>,
584 /// Confidence head bias `[1]`.
585 pub confidence_bias: Option<DenseWeight>,
586 /// Whether the confidence input is `[hidden ‖ markov_embed]` (true) or
587 /// hidden only (false). Mirrors `confidence_head_with_markov`.
588 pub confidence_with_markov: bool,
589 /// SpecForge shifted row convention (drafter config
590 /// `dflash_config.projector_type == "dspark"`): row j's output is the
591 /// token at position j+1, so the returned draft vector is rotated right
592 /// by one to line up with Atlas's z-lab-convention verify indexing.
593 /// Overridable for A/B via `AVAROK_DSPARK_SHIFT=0|1`.
594 pub shifted_rows: bool,
595
596 // === DFlash2 (None/0 ⇒ plain DFlash behavior) ===
597 /// Conv kernel size (2) — taps per conv application.
598 pub conv_kernel_size: usize,
599 /// Channels per conv group (16).
600 pub conv_group_size: usize,
601 /// Selector codebook rank (256).
602 pub selector_rank: usize,
603 /// Candidates per position for the selector walk (16; the kernels are
604 /// specialized to 16 — other values refuse to arm).
605 pub selector_top_k: usize,
606 /// `candidate_selector.predecessor_codebook` `[vocab, rank]`.
607 pub selector_pred: Option<DenseWeight>,
608 /// `candidate_selector.successor_codebook` `[vocab, rank]`.
609 pub selector_succ: Option<DenseWeight>,
610 /// `candidate_selector.hidden_projection.weight` `[rank, hidden]`.
611 pub selector_hidden_proj: Option<DenseWeight>,
612}
613
614mod dflash2;
615/// Whether the Option-B paged drafter cache is on. Default ON since the 54.5
616/// record config (#649); `AVAROK_DFLASH_OPTION_B=0` is the kill switch.
617///
618/// Split into a reader and a pure predicate because the POLARITY is the whole
619/// point and it has already been flipped by accident: a merge on 2026-08-30
620/// took #817's allocator region whole, #817 branched from a tree predating the
621/// flip, and `!= Some("0")` silently became `== Some("1")`. Measured cost of
622/// that one character-class: propose 19.8 -> 618.7 ms and 49.9 -> 5.5 tok/s,
623/// because the legacy path launches one `dense_gemv` per accumulated ctx row
624/// over a 262 MB `fc` weight. Nothing logged a change.
625/// Rows the batched ctx precompute staging holds per step (C=16 at gamma 4
626/// commits ~2-4 rows per sequence per step; 256 covers 16 x 16).
627pub(super) const PRECOMPUTE_BATCH_ROWS: usize = 256;
628
629/// Batched ctx precompute (one fc + fused-KV pass over every sequence's
630/// uncommitted ctx rows) is the default on the batched propose path.
631/// `AVAROK_DFLASH_NO_BATCHED_PRECOMPUTE=1` restores the per-sequence loop.
632pub(super) fn batched_precompute_enabled() -> bool {
633 std::env::var("AVAROK_DFLASH_NO_BATCHED_PRECOMPUTE")
634 .ok()
635 .as_deref()
636 != Some("1")
637}
638
639/// The predicate itself, pure over the raw value so a test can exercise the
640/// PRODUCTION code rather than a copy of it. `set_var` is unsafe and
641/// process-global, so a test that mutated the environment would race every
642/// other test in this binary.
643pub(super) fn option_b_from(v: Option<&str>) -> bool {
644 v != Some("0")
645}
646
647#[cfg(test)]
648mod option_b_tests {
649 use super::option_b_from;
650
651 #[test]
652 fn option_b_defaults_on_and_only_zero_turns_it_off() {
653 // THE REGRESSION, and the reason this test exists: unset must mean ON.
654 // A bare `--dflash` launch is the record path with no env block (#649).
655 // When a merge turned this into opt-in, the only symptom was a run
656 // nine times slower.
657 assert!(
658 option_b_from(None),
659 "unset must be ON — this is the 9x line"
660 );
661 assert!(option_b_from(Some("1")));
662 // House convention: `=0` is the kill switch, and nothing else is.
663 assert!(!option_b_from(Some("0")));
664 assert!(
665 option_b_from(Some("true")),
666 "only the exact string 0 disables"
667 );
668 assert!(option_b_from(Some("")), "empty is not a kill switch");
669 }
670}
671
672mod forward_block;
673mod forward_block_layer;
674mod forward_block_layer_paged;
675mod from_weights;
676pub mod levers;
677mod markov;
678mod precompute_ctx_kv;
679mod precompute_ctx_kv_batched;
680mod propose;
681
682impl DraftProposer for BlockDiffusionDraftHead {
683 fn block_gamma(&self) -> Option<usize> {
684 Some(self.gamma)
685 }
686
687 fn alloc_state(&self, gpu: &dyn GpuBackend) -> Result<Box<dyn ProposerState>> {
688 self.alloc_state_windowed(gpu, usize::MAX)
689 }
690
691 fn alloc_state_for(
692 &self,
693 gpu: &dyn GpuBackend,
694 budget_tokens: usize,
695 ) -> Result<Box<dyn ProposerState>> {
696 self.alloc_state_windowed(gpu, budget_tokens)
697 }
698
699 fn propose(
700 &self,
701 last_token: u32,
702 target_hidden: spark_runtime::gpu::DevicePtr,
703 position: usize,
704 num_drafts: usize,
705 state: &mut dyn ProposerState,
706 ctx: &crate::layer::ForwardContext,
707 stream: u64,
708 draft_embed_target: Option<spark_runtime::gpu::DevicePtr>,
709 grammar_bitmask: Option<&[i32]>,
710 target_hidden_stack: Option<spark_runtime::gpu::DevicePtr>,
711 ) -> Result<Vec<u32>> {
712 self.propose_drafts(
713 last_token,
714 target_hidden,
715 position,
716 num_drafts,
717 state,
718 ctx,
719 stream,
720 draft_embed_target,
721 grammar_bitmask,
722 target_hidden_stack,
723 None,
724 )
725 }
726
727 /// Widest batch one drafter forward can carry. Bounded by the scratch
728 /// bands (`max_batch`); `1` means the batched path cannot run and the
729 /// caller stays on `propose`.
730 fn propose_batch_max(
731 &self,
732 _buffers: &spark_runtime::buffers::BufferArena,
733 _config: &avarok_core::config::ModelConfig,
734 ) -> usize {
735 if !self.dflash2_active() {
736 return 1;
737 }
738 // DEFAULT-ON. `AVAROK_DFLASH_BATCH_PROPOSE=<width>` overrides: `1`
739 // (or `0`) disables and restores the per-sequence loop, `N` caps the
740 // batch at N sequences. Numeric rather than boolean because
741 // bisecting the WIDTH against acceptance is what localises a banding
742 // bug — "correct at 2 bands, wrong at 4" is the observation that
743 // found the lm_head tile bound, and an on/off flag cannot ask it.
744 let want = self.levers.batch_propose_width;
745 if want < 2 {
746 return 1;
747 }
748 want.min(self.max_batch.max(1))
749 }
750
751 /// Cross-sequence batched propose: ONE drafter forward over `n * gamma`
752 /// rows instead of `n` forwards.
753 ///
754 /// Per-sequence preparation (ctx append, Option-B block growth, the
755 /// incremental ctx precompute) still runs per sequence — it is cheap,
756 /// touching only the uncommitted ctx tail — and it reuses
757 /// `propose_drafts`' own prep through the `collect_prep` sink so the two
758 /// paths cannot drift. The expensive part, the drafter layers plus an
759 /// lm_head against a 248k vocab, runs ONCE for the whole batch. That is
760 /// the entire win.
761 ///
762 /// Returns `Ok(None)` to decline, and the caller falls back to the
763 /// per-sequence loop — never a wrong answer.
764 fn propose_batch(
765 &self,
766 last_tokens: &[u32],
767 _target_hiddens: &[spark_runtime::gpu::DevicePtr],
768 positions: &[usize],
769 num_drafts: usize,
770 states: &mut [&mut dyn ProposerState],
771 ctx: &crate::layer::ForwardContext,
772 stream: u64,
773 _out_conf: Option<&mut Vec<Vec<f32>>>,
774 ) -> Result<Option<Vec<Vec<u32>>>> {
775 let n = last_tokens.len();
776 if n < 2
777 || n > self.max_batch
778 || positions.len() != n
779 || states.len() != n
780 || !self.dflash2_active()
781 {
782 return Ok(None);
783 }
784
785 // Phase 1 — per-sequence prep, collecting each sequence's paged
786 // descriptor. A sequence that cannot run Option B (drafter block pool
787 // exhausted, say) aborts the WHOLE batch to the per-sequence path
788 // rather than letting the rest draft against a missing band.
789 // Gamma resolver: this step's block width, from the scheduler's draft
790 // count (C>=2 lands on the K=4 write-on-accept verify).
791 self.set_block_g(num_drafts);
792 let mut prep: Vec<(spark_runtime::gpu::DevicePtr, u32)> = Vec::with_capacity(n);
793 for (i, st) in states.iter_mut().enumerate() {
794 let before = prep.len();
795 match self.propose_drafts(
796 last_tokens[i],
797 spark_runtime::gpu::DevicePtr::NULL,
798 positions[i],
799 num_drafts,
800 *st,
801 ctx,
802 stream,
803 None,
804 None,
805 None,
806 Some(&mut prep),
807 ) {
808 Ok(_) if prep.len() == before + 1 => {}
809 Ok(_) => return Ok(None),
810 Err(e) => {
811 tracing::warn!("DFlash batched propose prep (seq {i}): {e:#} — per-seq path");
812 return Ok(None);
813 }
814 }
815 }
816
817 // Phase 1b — ONE ctx precompute over every sequence's uncommitted
818 // rows (the prep above deferred it). On failure nothing was
819 // committed, so the per-sequence path recomputes safely.
820 if batched_precompute_enabled()
821 && let Err(e) = self.precompute_ctx_kv_batched(states, ctx, stream)
822 {
823 tracing::warn!("DFlash batched ctx precompute: {e:#} — per-seq path");
824 return Ok(None);
825 }
826
827 // Phase 2 — ONE forward over every band.
828 let batch = DflashBatch {
829 last_tokens,
830 positions,
831 block_tables: prep.iter().map(|p| p.0).collect(),
832 ctx_counts: prep.iter().map(|p| p.1).collect(),
833 };
834 let all = match self.forward_block(
835 last_tokens[0],
836 positions[0],
837 ctx,
838 stream,
839 None,
840 Some(prep[0]),
841 Some(&batch),
842 ) {
843 Ok(v) => v,
844 Err(e) => {
845 tracing::warn!("DFlash batched forward_block: {e:#} — falling back to per-seq");
846 return Ok(None);
847 }
848 };
849 let g = self.block_g();
850 if all.len() < n * g {
851 tracing::warn!(
852 "DFlash batched forward returned {} rows, expected {} — per-seq path",
853 all.len(),
854 n * g
855 );
856 return Ok(None);
857 }
858
859 // Phase 3 — split bands. Row 0 of each band is the anchor echo the
860 // single-sequence path drops too; the rest are that sequence's drafts.
861 let cap = self.levers.draft_cap.unwrap_or(g);
862 let mut out: Vec<Vec<u32>> = Vec::with_capacity(n);
863 for (i, st) in states.iter_mut().enumerate() {
864 let band = &all[i * g..(i + 1) * g];
865 let drafts: Vec<u32> = if self.mask_token_id != 0 {
866 band.iter().skip(1).copied().take(cap).collect()
867 } else {
868 band.iter().copied().take(cap).collect()
869 };
870 if let Some(d) = st.as_any_mut().downcast_mut::<DflashProposerState>() {
871 d.last_num_drafted = drafts.len();
872 }
873 out.push(drafts);
874 }
875 Ok(Some(out))
876 }
877
878 fn after_verify(
879 &self,
880 num_accepted: usize,
881 state: &mut dyn ProposerState,
882 _stream: u64,
883 ) -> Result<()> {
884 let dstate = state
885 .as_any_mut()
886 .downcast_mut::<DflashProposerState>()
887 .ok_or_else(|| anyhow::anyhow!("Invalid DFlash proposer state"))?;
888 // Phase 1: no real KV trim because `propose()` is a stub. Phase 2
889 // adds the rollback that drops `(last_num_drafted - num_accepted)`
890 // tokens from each layer's paged cache.
891 //
892 // Phase I invariant: `ctx_committed` is the watermark of ctx slots
893 // already precomputed into the paged cache. It is monotonic only as
894 // long as `ctx_len` is monotonic (today it is — ctx is append-only
895 // and never rewound here). IF a future rollback ever shrinks the
896 // committed ctx (rewinds `ctx_len`), it MUST also reset
897 // `dstate.ctx_committed = dstate.ctx_len` so the next propose
898 // recomputes the rolled-back tail instead of reading stale K/V.
899 // The `.min(ctx_len)` clamp in propose() is the defensive backstop.
900 let _ = num_accepted;
901 dstate.last_num_drafted = 0;
902 Ok(())
903 }
904
905 fn free_state(&self, gpu: &dyn GpuBackend, state: &mut dyn ProposerState) -> Result<()> {
906 // Phase 2 (Option B) reclaim: return the drafter's lazily-allocated
907 // paged KV blocks to the pool on request completion. Without this the
908 // ~257-block Option-B drafter cache (allocated in propose.rs when
909 // block_table_dev.is_none()) is never freed, so the SECOND request to
910 // a long-lived server starts with zero free drafter blocks and floods
911 // "DFlash Option B: paged KV cache exhausted". Mirrors MtpHead::free_state.
912 let dstate = match state.as_any_mut().downcast_mut::<DflashProposerState>() {
913 Some(s) => s,
914 // Phase 1 / non-DFlash proposer state: nothing allocated, nothing to free.
915 None => return Ok(()),
916 };
917 if !dstate.block_table.is_empty() {
918 self.kv_cache.lock().free_blocks(&dstate.block_table);
919 dstate.block_table.clear();
920 }
921 // Free the per-seq ctx accumulator — the dominant per-request
922 // allocation (`max_seq_len × 5 × target_hidden` BF16; ~320 MB at
923 // max_seq_len=16384). `DevicePtr` has no Drop, so without this every
924 // finished sequence leaks it for the server's lifetime. Guarded on a
925 // non-null pointer so a double free_state is a no-op.
926 if dstate.ctx_hidden_acc.0 != 0 {
927 gpu.free(dstate.ctx_hidden_acc)?;
928 dstate.ctx_hidden_acc = DevicePtr(0);
929 }
930 // Free the device-side block table (lazily allocated in propose.rs).
931 if let Some(bt) = dstate.block_table_dev.take() {
932 gpu.free(bt)?;
933 }
934 // Reset the lazy-alloc guard + watermarks so the NEXT request's first
935 // propose re-allocates fresh blocks and re-precomputes ctx from a clean
936 // slate (propose.rs gates alloc on block_table_dev.is_none()).
937 dstate.max_ctx_count_drafter = 0;
938 dstate.ctx_count_drafter = 0;
939 dstate.ctx_committed = 0;
940 dstate.ctx_positions.clear();
941 dstate.seq_len = 0;
942 dstate.ctx_len = 0;
943 dstate.prefill_done = false;
944 dstate.last_num_drafted = 0;
945 dstate.last_num_accepted = 0;
946 dstate.skip_next_decode_append = false;
947 Ok(())
948 }
949}
950
951/// AVAROK_NO_DFLASH_FP8_RT=1 restores the tile-GEMM propose path for A/B
952/// (strict `== "1"`, matching the sibling AVAROK_NO_* levers). OnceLock so
953/// the kernel choice is stable across CUDA-graph capture.
954pub(crate) fn fp8_rt_enabled() -> bool {
955 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
956 *ON.get_or_init(|| std::env::var("AVAROK_NO_DFLASH_FP8_RT").as_deref() != Ok("1"))
957}
958
959/// The DFlash context-window bound, in tokens: the most recent target
960/// positions the drafter is allowed to accumulate and attend to.
961///
962/// SINGLE DEFINITION on purpose. Two buffers are sized from it — the
963/// per-sequence ctx accumulator here, and the model-level whole-prompt hidden
964/// capture (`impl_a1`) that feeds `prefill_drafter` — and the drafter cannot
965/// use more prompt than it can store, so capturing past this bound is dead
966/// memory. Letting the two drift is exactly the ceiling-vs-need bug this
967/// bound exists to close.
968///
969/// `AVAROK_DFLASH_CTX_CAP=<tokens>`; `0` disables the cap entirely.
970pub fn dflash_ctx_cap() -> usize {
971 std::env::var("AVAROK_DFLASH_CTX_CAP")
972 .ok()
973 .and_then(|v| v.parse::<usize>().ok())
974 .unwrap_or(16384)
975}
976
977impl BlockDiffusionDraftHead {
978 /// Block width (rows per sequence) of the propose in flight. See `block_gamma`.
979 #[inline]
980 pub fn block_g(&self) -> usize {
981 self.block_gamma.load(std::sync::atomic::Ordering::Relaxed)
982 }
983
984 /// Arm the block width for the next propose from the scheduler's draft
985 /// count: `num_drafts + 1` rows (anchor + masks), clamped to `2..=gamma`
986 /// so a resolver that asks for more than the head was sized for gets the
987 /// widest block rather than an out-of-band scratch write.
988 #[inline]
989 pub(super) fn set_block_g(&self, num_drafts: usize) {
990 let g = (num_drafts + 1).clamp(2, self.gamma.max(2));
991 self.block_gamma
992 .store(g, std::sync::atomic::Ordering::Relaxed);
993 }
994
995 /// Allocate proposer state with the ctx accumulator sized to the smallest
996 /// of: this request's token budget, the AVAROK_DFLASH_CTX_CAP window, and
997 /// `--max-seq-len`.
998 fn alloc_state_windowed(
999 &self,
1000 gpu: &dyn GpuBackend,
1001 budget_tokens: usize,
1002 ) -> Result<Box<dyn ProposerState>> {
1003 // Per-seq ctx accumulator: `[max_seq_len, 5 * target_hidden] BF16`.
1004 // Sized once, re-used across the seq's lifetime; reset on
1005 // `free_state`. At max_seq_len=16384 and 5×2048 BF16: 320 MB per
1006 // seq — tolerable on a single Spark with max_batch_size=1; for
1007 // higher batch we may want to reduce to a smaller working window.
1008 let bf16 = 2usize;
1009 let ctx_slot_bytes = self.target_layer_ids.len() * self.target_hidden_size * bf16;
1010 // WORKING WINDOW, not max_seq_len. This buffer is per SEQUENCE and
1011 // scales with the context ceiling: at 128K x 5 layers x 5120 BF16 it
1012 // is 6.7 GB EACH, so 8 concurrent sequences ask for 53.7 GB — lazily,
1013 // as streams arrive, which is why it OOMs a long way past a clean
1014 // boot rather than at startup. Capping the window bounds it to
1015 // `cap * ctx_slot_bytes` per sequence (16K -> 839 MB, 8 seqs -> 6.7 GB).
1016 //
1017 // Correctness: `commit_ctx` already slides a watermark when the
1018 // accumulator fills, keeping the NEWEST half and re-stamping
1019 // ctx_positions, so a smaller window is an already-exercised path —
1020 // the drafter conditions on recent context instead of the whole
1021 // history. Raise with AVAROK_DFLASH_CTX_CAP=<tokens> (0 = uncapped,
1022 // the pre-cap behaviour) if you have the memory and want the drafter
1023 // to see further back.
1024 let cap = dflash_ctx_cap();
1025 let ceiling = if cap == 0 {
1026 self.max_seq_len
1027 } else {
1028 self.max_seq_len.min(cap)
1029 };
1030 // The request's own reach (prompt + max_tokens) when the caller knows
1031 // it: a 2K-token turn has no use for a 16K accumulator, and this
1032 // buffer is paid PER SEQUENCE. `+ gamma + 1` covers the draft block
1033 // and bonus slot the ctx accumulates past the last emitted token.
1034 let window = ceiling.min(budget_tokens.saturating_add(self.gamma + 1));
1035 if ceiling < self.max_seq_len {
1036 static LOGGED: std::sync::atomic::AtomicBool =
1037 std::sync::atomic::AtomicBool::new(false);
1038 if !LOGGED.swap(true, std::sync::atomic::Ordering::Relaxed) {
1039 tracing::info!(
1040 "DFlash ctx window capped to {} of --max-seq-len {} ({} MB/seq instead of \
1041 {} MB): the accumulator is PER SEQUENCE, so the uncapped size is what \
1042 OOMs a high-concurrency long-context serve. Override with \
1043 AVAROK_DFLASH_CTX_CAP=<tokens> (0 = uncapped).",
1044 ceiling,
1045 self.max_seq_len,
1046 ceiling * ctx_slot_bytes / (1024 * 1024),
1047 self.max_seq_len * ctx_slot_bytes / (1024 * 1024),
1048 );
1049 }
1050 }
1051 let total = window * ctx_slot_bytes;
1052 let ctx_hidden_acc = gpu.alloc(total)?;
1053 // Initialize to zero so stale data doesn't leak between sequences.
1054 gpu.memset(ctx_hidden_acc, 0, total)?;
1055 Ok(Box::new(DflashProposerState {
1056 block_table: Vec::with_capacity(64),
1057 seq_len: 0,
1058 last_num_drafted: 0,
1059 prefill_done: false,
1060 ctx_hidden_acc,
1061 ctx_len: 0,
1062 last_num_accepted: 0,
1063 skip_next_decode_append: false,
1064 max_ctx_len: window,
1065 ctx_slot_bytes,
1066 // Phase 2 Option B: lazily allocated on first propose when
1067 // AVAROK_DFLASH_OPTION_B=1. None until then to keep alloc_state
1068 // cheap for sequences that never use Option B.
1069 block_table_dev: None,
1070 ctx_count_drafter: 0,
1071 max_ctx_count_drafter: 0,
1072 ctx_committed: 0,
1073 ctx_positions: Vec::new(),
1074 }))
1075 }
1076}