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