spark_model/model/
types.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3#![allow(unused_imports, dead_code)]
4
5use parking_lot::Mutex;
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use anyhow::{Result, bail};
10use avarok_core::config::{LayerType, ModelConfig};
11use spark_runtime::buffers::BufferArena;
12use spark_runtime::gpu::{DevicePtr, GpuBackend, GraphHandle, KernelHandle};
13use spark_runtime::kv_cache::PagedKvCache;
14
15use super::ssm_pool::SsmStatePool;
16use super::ssm_snapshot::SsmSnapshotPool;
17use crate::layer::{
18    AttnMetadataDev, ForwardContext, GdnPrefillBuffers, LayerState, SsmLayerState, TransformerLayer,
19};
20use crate::layers::ops;
21use crate::speculative::DraftProposer;
22use crate::traits::{ChunkedPrefillPageMetadata, Model, SequenceState};
23use crate::weight_map::{DenseWeight, Fp8DenseWeight, MtpWeights, QuantizedWeight};
24
25/// Architecture-agnostic transformer model.
26///
27/// Composes `Vec<Box<dyn TransformerLayer>>` into a full forward pass.
28/// Adding a new model only requires implementing [`TransformerLayer`]
29/// for each layer type — the model loop stays unchanged.
30#[allow(dead_code)]
31/// Rows in the drafter catch-up hidden ring (see `mtp_catchup_ring`):
32/// 512 covers the gate's 256-token serial re-probe interval with 2x margin.
33pub(super) const MTP_CATCHUP_RING_ROWS: usize = 512;
34
35pub struct TransformerModel {
36    pub(super) config: ModelConfig,
37    /// Which GEMM implementation each projection takes, resolved from the
38    /// environment when this model was built. Owned here and borrowed by every
39    /// `ForwardContext` this model creates, so the choice cannot outlive the
40    /// model — the property nine `OnceLock` statics could not have.
41    pub(super) dispatch: crate::layers::ops::GemmDispatch,
42    /// Weight re-encodings derived on demand and memoized for this model.
43    /// Dropped with the model, so no entry can outlive the allocation it
44    /// describes.
45    pub(super) derived: crate::layers::ops::DerivedWeights,
46    /// The weight ledger this model was built from.
47    ///
48    /// Held for TEARDOWN, not for lookup: the layers already copied the
49    /// pointers they need out of it during construction. It is the only
50    /// structure that knows every weight allocation, and it used to be dropped
51    /// at the end of `startup()` — leaving that memory live with nothing able
52    /// to free it. `None` once released.
53    pub(super) weight_store: Option<spark_runtime::weights::WeightStore>,
54    /// Non-GEMM kernel-path levers, resolved at model construction.
55    pub(super) levers: crate::layers::ops::ModelLevers,
56    /// Diagnostic counters and one-shot dump latches for this model. Sibling
57    /// to `levers`: what the kernels did, rather than what they do.
58    pub(super) stats: crate::layers::ops::ModelStats,
59    pub(super) embed_tokens: DenseWeight,
60    /// Fused n-gram input embedding (LongCat family), when the architecture
61    /// has one. `Mutex` because the forward path is `&self` while the row
62    /// cache mutates on lookup; the lock is taken once per embed, which is
63    /// nothing beside a transformer forward.
64    pub(super) ngram_embed: Option<std::sync::Mutex<crate::layers::ngram_embed::NgramEmbedding>>,
65    pub(super) final_norm: DenseWeight,
66    pub(super) lm_head_weight: DenseWeight,
67    pub(super) lm_head_nvfp4: Option<QuantizedWeight>,
68    /// TRANSPOSED `[K/2, ldb]` twin of `lm_head_nvfp4` + its PADDED row stride.
69    ///
70    /// The pad is load-bearing: the tile GEMM reads B with 16-byte `cp.async`,
71    /// which needs a 16-byte-aligned source, and row r sits at `r * stride`.
72    /// This checkpoint's vocab is 248077 — ODD — so an unpadded stride misaligns
73    /// 15 of every 16 k-rows and faults with CUDA 716. Padded to 248192.
74    ///
75    /// ADDITIVE: never replaces or aliases `lm_head_nvfp4`, so every existing
76    /// holder (including the `draft_lm_head_nvfp4` copy at `impl_a1.rs:157`)
77    /// keeps a valid row-major pointer. Built once, immutable, never freed —
78    /// so each per-`padded_n` CUDA graph binds one (kernel, tensor) pair.
79    /// `None` under `AVAROK_NO_LMHEAD_TGEMM=1`.
80    pub(super) lm_head_nvfp4_t: Option<(QuantizedWeight, u32)>,
81    /// Runtime FP8 E4M3 LM head (per-row scales), decoded via `w8a16_gemv`.
82    /// `Some` only when `--lm-head-dtype fp8` was requested; mutually exclusive
83    /// with `lm_head_nvfp4` (that stays `None` on the FP8 path). Additive: when
84    /// `None`, the NVFP4/BF16 LM-head dispatch is byte-identical to before.
85    pub(super) lm_head_fp8: Option<Fp8DenseWeight>,
86    /// The output head as raw Q6_K blocks (DeepSeek-V4.1 GGUF), run by the
87    /// K-quant GEMV; `None` on every other head. See `lm_head_q6k.rs`.
88    pub(super) lm_head_q6k: Option<super::lm_head_q6k::LmHeadQ6k>,
89    pub(super) layers: Vec<Box<dyn TransformerLayer>>,
90    /// `true` when ANY layer's decode can never be captured into a CUDA
91    /// graph, so the whole model stays eager.
92    ///
93    /// Computed ONCE at construction. It was
94    /// `self.layers.iter().any(|l| l.decode_graph_unsupported())` — 48
95    /// virtual calls through `dyn TransformerLayer` per DECODE STEP, from two
96    /// sites (`decode_a` and `decode_a2`), to recompute a value that cannot
97    /// change: every implementation is a pure function of load-time structure
98    /// (`false`, `self.qsa.is_some()`, `self.ple.is_some()`). A new model is
99    /// a new `TransformerModel`, so this cannot go stale across a swap.
100    pub(super) decode_graph_veto: bool,
101    pub(super) buffers: BufferArena,
102    /// Startup-static LoRA adapter (pool + per-layer pairs + M2 pointer
103    /// tables). `None` = no adapter. Installed post-construction via
104    /// `set_lora_weights`, which also copies the per-layer pairs into the
105    /// layer structs; kept here as the owner of the pool/tables and for
106    /// status introspection.
107    pub(super) lora: Option<crate::lora::LoraWeights>,
108    /// True when runtime adapter rotation is ARMED: `AVAROK_LORA_ROTATE=1`, or
109    /// `$AVAROK_LORA_PEER` set. Armed ⇒ decode runs eager (no CUDA-graph
110    /// capture) so a `set_active_lora` re-point is immediately live
111    /// (eager-on-rotate). `false` (single startup adapter, no rotation env)
112    /// keeps the decode-graph path byte-identical to today.
113    pub(super) lora_rotatable: bool,
114    pub(super) kv_cache: Mutex<PagedKvCache>,
115    pub(super) gpu: Box<dyn GpuBackend>,
116    /// TQ+ InnerQ calibration driver, when `TURBO_INNERQ` is set. Owned here
117    /// rather than parked in a static: it writes `__device__` globals in THIS
118    /// model's modules, so it must not outlive the model. Reached from the
119    /// scheduler through `Model::poll_innerq`.
120    #[cfg(feature = "cuda")]
121    pub(super) innerq: Option<crate::layers::qwen3_attention::InnerQDriver>,
122    pub(super) rms_norm_kernel: KernelHandle,
123    pub(super) dense_gemv_kernel: KernelHandle,
124    /// FP32-output variant of dense_gemv_bf16. Used by the LM head when
125    /// `use_fp32_logits` is true, so the FP32 accumulator is preserved across
126    /// the BF16-storage rounding boundary that flips greedy argmax tiebreaks
127    /// on Gemma-4-31B (top-1 vs top-2 = 0.125 logit gap = exact BF16 step at
128    /// value 16-32 → BF16 store snaps the wrong way and starts a stop-word
129    /// loop). Loaded once at model init.
130    pub(super) dense_gemv_fp32out_kernel: KernelHandle,
131    pub(super) w4a16_gemv_kernel: KernelHandle,
132    pub(super) w4a16_gemv_logits_kernel: KernelHandle, // FP32 output for LM head
133    /// Tile GEMM over the TRANSPOSED lm_head twin. 0 when absent.
134    pub(super) w4a16_gemm_t_kernel: KernelHandle,
135    /// LOSSLESS BF16-MMA tile GEMM over the same twin. Preferred for lm_head:
136    /// `w4a16_gemm_t` downcasts activations BF16->FP8 E4M3, and lm_head is the
137    /// layer where a near-tie argmax flip changes the emitted token. Memory
138    /// records exactly that failure mode (stop/end-of-turn mis-ranking on DEEP
139    /// agentic trajectories) for sub-bf16 lm_heads. Costs ~1% of step.
140    /// 0 when absent. Kill switch: AVAROK_NO_LMHEAD_LOSSLESS=1.
141    pub(super) w4a16_gemm_t_bf16_kernel: KernelHandle,
142    pub(super) w4a16_gemm_kernel: KernelHandle,
143    pub(super) w4a16_gemv_batch2_kernel: KernelHandle,
144    /// Narrow `w4a16_gemv_batch{M}` family (M=4..8) for the K=3..8 verify
145    /// lm_head (one weight read for all rows; nsys 2026-07-18: the M64-tile
146    /// `w4a16_gemm` at M=4 cost 19.3 ms/verify-step on the 248320-row lm_head
147    /// — 94% tile padding). Individual tiers are 0-handles when the target
148    /// lacks them (dispatch falls back).
149    pub(super) w4a16_batchm: crate::layers::w4a16_gemv_tiers::W4a16BatchmTiers,
150    pub(super) w4a16_gemv_batch16_kernel: KernelHandle,
151    /// FP8 E4M3 LUT GEMV (M=1) for the FP8 LM head. Only used when
152    /// `lm_head_fp8.is_some()`; loaded unconditionally (cheap handle) so the
153    /// dispatch in `lm_head` / batched-decode / verify can reference it.
154    pub(super) dense_gemv_fp8w_kernel: KernelHandle,
155    /// FP8-weight dual-GEMV (batch=2): reads the FP8 weight once for both K=2
156    /// verify tokens. Bit-identical to two `dense_gemv_fp8w` calls; halves the
157    /// FP8 weight bandwidth for the lm_head on the MTP verify path.
158    pub(super) dense_gemv_fp8w_batch2_kernel: KernelHandle,
159    pub(super) dense_gemm_kernel: KernelHandle,
160    /// Batched BF16 GEMV (M rows, one weight pass). Used for the BF16 lm_head
161    /// at decode: reads the ~617 MB vocab weight once with coalesced uint4
162    /// loads, vs the scalar dense_gemm_bf16 (16x16 FFMA, ~89 GB/s). 0 = absent.
163    pub(super) dense_gemv_batchm_kernel: KernelHandle,
164    /// Tensor-core BF16 decode GEMM with a 16-row M tile
165    /// (`dense_gemm_m16_bf16`, #927/#928) — the 5..=16-row BF16 lm_head arm
166    /// behind `AVAROK_LM_HEAD_M16_TC`. 0 when the kernel set lacks it, which is
167    /// how a target without it declines silently. REASSOCIATES the K reduction
168    /// against `dense_gemv_bf16_batchm`; rule in
169    /// `trait_impl/lm_head_batched.rs::lm_head_m16_tc_route`.
170    pub(super) lm_head_m16_tc_kernel: KernelHandle,
171    /// `N_TILE=64` twin of the above (`AVAROK_LM_HEAD_M16_TC_NTILE=64`).
172    /// 0 when absent — a `=64` request then falls back to the 32-wide kernel.
173    pub(super) lm_head_m16_tc_n64_kernel: KernelHandle,
174    pub(super) argmax_kernel: KernelHandle,
175    /// Batched argmax (one block per row). 0 when the kernel set lacks it.
176    pub(super) argmax_batch_kernel: KernelHandle,
177    pub(super) argmax_logits_kernel: KernelHandle, // FP32 argmax for logits
178    pub(super) batched_embed_kernel: KernelHandle,
179    pub(super) fill_slots_kernel: KernelHandle,
180    /// Cached CUDA graph for single-sequence decode (layer loop + norm + LM head).
181    /// CUDA graph cache for n=1 decode, keyed by `seq.slot_idx`. The captured
182    /// graph has SSM h_state/conv_state pointers baked in as kernel arguments,
183    /// so a graph captured for slot S can ONLY be replayed for slot S — replay
184    /// for any other slot reads/writes the wrong sequence's recurrent state
185    /// and produces gibberish for both sequences. With concurrent users we may
186    /// alternate between slots in n=1 decode (e.g. via the per-seq fresh-decode
187    /// fix in scheduler::step_decode_only), so we keep one graph per slot.
188    pub(super) decode_graph: Mutex<std::collections::HashMap<usize, GraphHandle>>,
189    /// Cached CUDA graphs for batched decode, keyed by the per-row SSM pool
190    /// slot VECTOR (`trait_impl/decode_graph_key.rs`) — the only per-sequence
191    /// addresses a capture bakes. The old `padded_n` key was sound only while
192    /// the batch was exactly slots `[0..n)` with `n == padded_n`; the MTP
193    /// Phase-A bootstrap passes a slot SUBSET of the active set and would
194    /// replay another subset's baked GDN pointers.
195    /// Value = `(graph, last_use_tick)`; the `u64` alongside the map is the
196    /// monotonically increasing tick. At `BATCH_DECODE_GRAPH_CAP` entries the
197    /// least-recently-used graph is destroyed and replaced.
198    pub(super) batch_decode_graphs: Mutex<(HashMap<Vec<u32>, (GraphHandle, u64)>, u64)>,
199    /// Pre-allocated SSM state pool for stable GPU addresses across graph replays.
200    /// `Arc` so each `SequenceState` can hold a `SlotGuard` that releases its
201    /// claimed slot on drop — guaranteeing the slot returns to the free list on
202    /// EVERY sequence-exit path (normal finish, abort, error, swap-out failure,
203    /// panic/unwind), not just the explicit `free_sequence`/`compact_sequence`
204    /// sites. See `SsmStatePool::claim_guarded` / `SlotGuard`.
205    pub(super) ssm_pool: Arc<SsmStatePool>,
206    /// SSM state snapshot pool for Marconi prefix caching.
207    pub(super) ssm_snapshots: SsmSnapshotPool,
208    /// Optional SSM snapshot spill tier (`AVAROK_SSM_TIER`). `None` (default)
209    /// keeps the drop-only reclaim path byte-identical; `Some` moves an evicted
210    /// snapshot's bytes to the tier (keeping its index entry findable) so a warm
211    /// turn faults it back instead of recomputing. Threaded into
212    /// [`SsmSnapshotPool::reclaim_from_cache`] at every reclaim call site.
213    pub(super) ssm_tier_store: Option<Arc<dyn super::ssm_tier::SnapshotBlobStore>>,
214    /// Fixed max blocks per sequence (max_seq_len / block_size + 1).
215    /// Used as constant stride in attention metadata for CUDA graph compatibility.
216    pub(super) max_blocks_per_seq: u32,
217    /// Permanent KV cache block for padding sequences in batched decode.
218    pub(super) dummy_kv_block: u32,
219    /// Profile mode: skip graphs, sync+time each layer. Set AVAROK_PROFILE=1.
220    pub(super) profile: bool,
221    /// One-shot profile flag for the next prefill request only. Set
222    /// AVAROK_PROFILE_FIRST=1 to capture per-step timing on the first prefill
223    /// after startup without disabling CUDA graphs for subsequent decodes.
224    /// Consumed (atomically swapped to false) by `prefill_chunk` / `prefill`.
225    pub(super) profile_first_pending: std::sync::atomic::AtomicBool,
226    /// When true, decode() skips CUDA graph capture/replay. Set during
227    /// per-sequence batch decode to prevent SSM state pointer baking.
228    pub(super) suppress_graphs: std::sync::atomic::AtomicBool,
229    /// MTP draft proposer (built from mtp_weights at init).
230    pub(super) proposer: Option<Arc<dyn DraftProposer>>,
231    /// Dedicated buffer for saving hidden state before MTP head runs.
232    /// Size: hidden_size * 4 bytes (one FP32 vector). MTP overwrites shared
233    /// buffers (norm_output etc.), so the target hidden must be saved here first.
234    pub(super) mtp_hidden_save: DevicePtr,
235    /// Batched-verify hidden stash: `[8, hidden_size]` BF16 — one RAW-hidden
236    /// row per batched-verify sequence (n ≤ 8 envelope). Every drafter
237    /// `forward_one` writes its hidden into `buffers.hidden_states()`
238    /// (mtp_multi.rs), so seq 0's propose clobbers seq 1..n's verify hidden
239    /// rows; the batched verdict path copies each sequence's accepted-row
240    /// hidden here FIRST (`stash_verify_hidden_rows`), then feeds the drafter
241    /// from the stash (`save_hidden_for_mtp_from_stash`). NULL without MTP.
242    pub(super) verify_hidden_stash: DevicePtr,
243    /// AVAROK_MTP_CATCHUP: circular per-position final-hidden ring captured
244    /// during serial-decode stretches (BF16 rows, slot = position % ring
245    /// len). Feeds the drafter catch-up on the next propose. NULL when the
246    /// feature is off or no proposer exists.
247    pub(super) mtp_catchup_ring: DevicePtr,
248    /// (first_position, count) of the contiguous position range currently
249    /// resident in the ring; a non-contiguous capture resets the range.
250    pub(super) mtp_catchup_meta: parking_lot::Mutex<(usize, usize)>,
251    /// AVAROK_MTP_DRAFTER_PREFILL: per-position final-layer hidden capture for
252    /// the whole prompt, `[max_seq_len, hidden_size]` BF16 (~335 MB at 32k /
253    /// h=5120). NULL unless the env is set AND an MTP proposer is built.
254    /// Filled contiguously by the prefill chunk epilogues; consumed once by
255    /// the drafter-prefill pass on the first propose() of a sequence.
256    pub(super) mtp_prefill_hidden: DevicePtr,
257    /// Row capacity of `mtp_prefill_hidden` (== max_seq_len at alloc; 0 when
258    /// the feature is off). SSOT for the capture bounds check.
259    pub(super) mtp_prefill_capacity: usize,
260    /// Rows of `mtp_prefill_hidden` captured contiguously from position 0 for
261    /// the CURRENT sequence. Reset to 0 on `alloc_sequence`; a chunk whose
262    /// start does not extend the contiguous range (prefix-cache reuse, warm
263    /// restore) leaves it stale-short, which safely disables drafter-prefill
264    /// for that sequence (coverage check at the propose site).
265    pub(super) mtp_prefill_capture_len: std::sync::atomic::AtomicUsize,
266    /// Monotonic generation of the single-slot capture above. Bumped every
267    /// time a chunk-0 prefill (re)starts the capture; the restarting
268    /// sequence is stamped with the new value (`SequenceState::
269    /// mtp_capture_gen`). Appends and the drafter-prefill consume require
270    /// `stamp == current generation`, so at C>=2 a sequence whose capture
271    /// was overwritten by ANOTHER sequence's prefill skips the drafter
272    /// prefill instead of pairing its tokens with foreign hiddens. The
273    /// current value IS the latest capture's generation (single atomic,
274    /// SSOT). 0 = no capture ever started (matches the fresh-seq stamp 0,
275    /// which is harmless: `captured >= prompt_len >= 2` fails at len 0).
276    pub(super) mtp_prefill_capture_gen: std::sync::atomic::AtomicU64,
277    /// Ticket dispenser for `mtp_store_range` ownership (`SequenceState::
278    /// mtp_store_gen`), drawn once per `alloc_sequence`.
279    ///
280    /// ★ SEPARATE FROM `mtp_prefill_capture_gen`, and it must stay separate.
281    /// Drawing the store ticket from the capture counter advances it on every
282    /// admission, and `owns_capture` (`trait_impl/speculative.rs`) requires the
283    /// sequence's captured generation to still EQUAL the current one — so any
284    /// sequence admitted between a capture and its propose silently disabled
285    /// the other sequence's drafter prefill. Measured: C=1 unaffected (no
286    /// interleaved admission), C=2 TPOT 62 -> 79 ms and 30.8 -> 23.5 tok/s,
287    /// reproduced twice. One counter, two meanings, was the whole bug.
288    pub(super) mtp_store_gen_seq: std::sync::atomic::AtomicU64,
289    /// AVAROK_MTP_CARRY_DRAFTER: the previous turn's drafter KV, held so the
290    /// next turn of the same session can adopt it instead of rebuilding
291    /// (1136 ms at 12k rows) or — as today — silently going without. Single
292    /// slot: the carry is force-disabled outside single-sequence dispatch
293    /// (`mtp_carry::carry_armed_with`), and one slot makes block ownership
294    /// unambiguous (blocks are owned here XOR by a live sequence). This used to
295    /// say "MTP is gated `active.len() == 1` on every spec path" — that is
296    /// false, the dispatch cap defaults to 32. `None` when the feature is off
297    /// or nothing has been carried.
298    pub(super) mtp_carry: parking_lot::Mutex<Option<super::mtp_carry::CarriedDrafter>>,
299    /// Absolute position interval of `mtp_prefill_hidden` rows, WITH the
300    /// sequence generation that wrote them. Only maintained when
301    /// AVAROK_MTP_CARRY_DRAFTER is on.
302    ///
303    /// ★ THE STAMP IS THE GUARD; the `alloc_sequence` reset is not. This doc
304    /// used to claim the interval was "per-sequence by construction" because
305    /// `alloc_sequence` resets it — and that was false, in two orderings. The
306    /// reset happens when a sequence is ADMITTED, but the writer
307    /// (`drafter_prefill`) had no ownership check at all, so a sequence whose
308    /// last chunk landed after another had been admitted merged its write into
309    /// the newcomer's interval and then read the newcomer's rows. Reset still
310    /// happens, as defence in depth; `gen` is what makes the claim true.
311    pub(super) mtp_store_range: parking_lot::Mutex<super::mtp_carry::StoreRange>,
312    /// DFlash 5-layer hidden-state stack. Allocated only when a
313    /// `BlockDiffusionDraftHead` proposer is built. Layout:
314    /// `[5 × hidden_size × bf16]` shallow-to-deep at the layer indices
315    /// declared by `dflash_capture_layers`. Holds the most-recently-decoded
316    /// token's intermediate hiddens; the drafter consumes them via its `fc`
317    /// projection on the next propose() call. None for non-DFlash runs.
318    pub(super) dflash_hidden_save: Option<DevicePtr>,
319    /// Lazily-allocated 16 KB metadata staging for the sliding-attention
320    /// per-token verify loop (`verify_attention_per_token`). The verify
321    /// bodies' multi-seq metadata overlay at `scratch + 32768` can span the
322    /// ENTIRE scratch buffer (scratch is sized to exactly `bt_meta`), so the
323    /// per-token loop CANNOT stage there without clobbering the metadata the
324    /// interleaved FullAttention layers' `decode_multi_seq` still reads --
325    /// doing so was a sticky CUDA-700 on Laguna-XS (2026-08-25). Layout
326    /// mirrors the scratch meta block: pos@0, slot@8, seq_len@16,
327    /// seq_slot@128, block_table@256 (cap ~15.7 KB, about 4000 blocks).
328    pub(super) verify_ptok_meta: std::sync::OnceLock<DevicePtr>,
329    /// Layer indices to capture for DFlash. Empty when DFlash is disabled.
330    /// Sourced from drafter's `dflash_config.target_layer_ids` at model build.
331    pub(super) dflash_capture_layers: Vec<usize>,
332    /// Row capacity of `dflash_hidden_save` (the K-row EAGLE capture buffer).
333    /// `try_dflash_capture_all` must never write past this many rows. Single
334    /// source of truth for the buffer's KMAX; 0 when DFlash is disabled.
335    pub(super) dflash_hidden_save_rows: usize,
336    /// Rows per per-sequence capture BAND in `dflash_hidden_save` (= γ+1).
337    /// Sequence `i` of a batched K=γ verify owns rows
338    /// `[i * dflash_kgamma, i * dflash_kgamma + k)`; single-sequence paths
339    /// use band 0. This is the stride the scheduler passes to `commit_ctx`
340    /// as `scratch_row`.
341    pub(super) dflash_kgamma: usize,
342    /// Cached CUDA graphs for K=2 verification, **keyed by `seq.slot_idx`**.
343    /// Same rationale as `decode_graph`: the captured graph has SSM
344    /// h_state/conv_state pointers baked in as kernel arguments, so replay for
345    /// a different slot writes to the wrong sequence's recurrent state. With
346    /// concurrent users alternating through MTP verify, a single
347    /// `Option<GraphHandle>` would corrupt both slots' SSM state.
348    pub(super) verify2_graph: Mutex<std::collections::HashMap<usize, GraphHandle>>,
349    /// Cached CUDA graphs for K=3 verification, keyed by `seq.slot_idx`.
350    pub(super) verify3_graph: Mutex<std::collections::HashMap<usize, GraphHandle>>,
351    /// Cached CUDA graphs for K=4 verification, keyed by `seq.slot_idx`.
352    pub(super) verify4_graph: Mutex<std::collections::HashMap<usize, GraphHandle>>,
353    /// Cached CUDA graphs for the BATCHED K-row verify (verify_e), keyed by
354    /// the batch's ssm-pool slot VECTOR (+ the per-seq row count K + a
355    /// wy-tables-present sentinel). Slot-vector keying is what a per-slot
356    /// key cannot give at n>1: the captured graph bakes every sequence's
357    /// h_state/conv_state/intermediate pointers, so it may only replay for
358    /// the exact same slot assignment in the same batch order (K is in the
359    /// key because a graph also bakes the R = n*K launch dimensions).
360    /// Attention metadata/block tables/embeds live at fixed scratch
361    /// addresses refreshed pre-replay (decode_a2 pattern).
362    /// Value = `(graph, last_use_tick)`; the `u64` alongside the map is the
363    /// monotonically increasing tick. At `VERIFY_BATCHED_GRAPH_CAP` entries
364    /// the least-recently-used graph is destroyed and replaced (slot vectors
365    /// churn with request turnover — the old insert-only map went
366    /// permanently eager after 32 distinct vectors on long serves).
367    pub(super) verify_batched_graphs:
368        Mutex<(std::collections::HashMap<Vec<u32>, (GraphHandle, u64)>, u64)>,
369    /// Batched-verify WY pointer-table staging: `num_ssm_layers` slices of
370    /// `crate::layer::VERIFY_WY_LAYER_STRIDE_BYTES` ([h|Hi0|Hi1|Hi2] × 4
371    /// u64 entries each) at a FIXED device address, refreshed pre-graph every
372    /// batched verify step (`upload_verify_wy_tables`). Enables the
373    /// single-launch table-form `gdn_decode_wy4` in the batched GDN arm.
374    /// NULL without an MTP proposer (path self-gates).
375    pub(super) verify_wy_tables: DevicePtr,
376    /// Write-on-accept: device `u32[VERIFY_WY_TABLE_SEQS]` of accepted row
377    /// counts for the post-verdict fold, and the ssm slots whose h state the
378    /// fold already committed this step (`commit_accepted_prefix` skips the
379    /// h restore for them). NULL/empty when no batched verify exists.
380    pub(super) gdn_woa_na_tab: DevicePtr,
381    pub(super) gdn_woa_folded_slots: Mutex<Vec<usize>>,
382    /// Set at the END of a batched verify that ran under a write-on-accept
383    /// request with its pointer tables staged; cleared at the START of every
384    /// batched verify and consumed by the fold. The fold declines (host h
385    /// restore runs) unless the verify that just completed set it.
386    pub(super) gdn_woa_eligible: std::sync::atomic::AtomicBool,
387    /// Write-on-accept engaged words and stash for every GDN layer, bound
388    /// on the FIRST write-on-accept request (pre-capture) and never moved:
389    /// `(flags, stash, seqs)`. NULL until then, so an MTP serve, a C=1
390    /// serve, a serve with the kernels absent or without the `AVAROK_GDN_WOA=1`
391    /// opt-in never pays the ~4 MB per layer.
392    pub(super) gdn_woa_bound: Mutex<(DevicePtr, DevicePtr, usize)>,
393    /// Encoded key of the bytes CURRENTLY staged in `verify_wy_tables`, or
394    /// `None` when nothing has been staged (the buffer is memset to zero at
395    /// allocation, which no key describes).
396    ///
397    /// `upload_verify_wy_tables` ran a 48 KB host build + a 48 KB H2D on
398    /// EVERY n>=2 verify step. The staged bytes are a pure function of
399    /// `(k, ssm-slot vector in batch order, ghost (slot, depth) pairs)` —
400    /// see `verify_wy_cache_key` for the enumeration and the proof — so a
401    /// step whose key matches what is already on the device may skip both.
402    /// Kill switch `AVAROK_NO_VERIFY_WY_CACHE` (PRESENCE) restores the
403    /// unconditional re-stage.
404    pub(super) verify_wy_cache: Mutex<Option<Vec<u64>>>,
405    /// Cached CUDA graphs for DFlash K=γ verification, keyed by
406    /// `(seq.slot_idx, K)`. K is `tokens.len()` (γ+1 typically). One graph
407    /// per (slot, K) — different γ values coexist via the K dimension.
408    pub(super) verify_kgamma_graph: Mutex<std::collections::HashMap<(usize, usize), GraphHandle>>,
409    /// Cached CUDA graphs for the DFlash decode+verify fused pass, keyed by
410    /// `(seq.slot_idx, M)` where M = tokens.len() = 1 + num_drafts.
411    /// Replaces the separate `decode_graph` (M=1) + `verify{k}_graph` (M=k)
412    /// on the DFlash path with a single M-row weight sweep.
413    pub(super) fused_graph: Mutex<std::collections::HashMap<(usize, usize), GraphHandle>>,
414    /// Prefix cache for KV block reuse across requests.
415    pub(super) prefix_cache: Box<dyn spark_runtime::prefix_cache::PrefixCache>,
416    /// Secondary CUDA stream for pipelining checkpoint D2D with MTP propose.
417    pub(super) secondary_stream: u64,
418    /// CUDA event for GPU-side inter-stream synchronization (avoids CPU-blocking sync).
419    pub(super) secondary_event: u64,
420    /// CUDA event ordering SSM-snapshot SAVES (on the default stream) before a
421    /// later warm Marconi RESTORE (on the prefill stream). Marconi saves
422    /// (`decode_marconi_checkpoint`, `finish_leaf_snapshot`, prefill-time
423    /// `prefill_save_snapshot`) record this event after their D2D copies; a
424    /// warm restore in `prefill_b_prefix_lookup` waits on it before reading the
425    /// snapshot region. Without this cross-stream edge, under concurrent
426    /// batched traffic the restore (prefill stream) can read a snapshot slot
427    /// whose save D2D (default stream) has not yet completed — restoring stale
428    /// / torn SSM recurrent state and diverging the warm decode from the cold
429    /// reference (the prefix-cache × hybrid-SSM warm-restore corruption).
430    pub(super) snapshot_event: u64,
431    /// Communication backend for expert parallelism (EP) all-reduce.
432    /// None for single-GPU (no distributed communication needed).
433    pub(super) comm: Option<std::sync::Arc<dyn spark_comm::CommBackend>>,
434    /// Small GPU buffer for EP token broadcast (4 bytes).
435    pub(super) ep_cmd_buf: DevicePtr,
436    /// EP wire-protocol version. When true, the seq_id-preamble protocol
437    /// extension from avarok#99 is active — every command broadcast is
438    /// preceded by a `seq_id` broadcast so the worker can dispatch
439    /// slot-bound work into the right `SequenceState` slot. When false,
440    /// the legacy single-sequence protocol is used. Set at construction
441    /// from `AVAROK_EP_PROTOCOL` env var; both ranks must agree.
442    pub(super) ep_protocol_v2: bool,
443    /// Self-speculative decoding mode: draft via layer-skipping (no MTP weights needed).
444    pub(super) self_speculative: bool,
445    /// Last token index passed to save_hidden_for_mtp (for EP broadcast to rank 1).
446    pub(super) last_mtp_hidden_idx: std::sync::atomic::AtomicUsize,
447    /// Optional vision encoder for VL models (Qwen3-VL).
448    pub(super) vision_encoder: Option<crate::layers::VisionTower>,
449    /// Number of patches encoded by the last prepare_vision_embed() call.
450    /// 0 means no vision embeddings pending.
451    pub(super) vision_embed_patches: Mutex<usize>,
452    /// Per-ITEM `(t_len, grid_h_post_merge, grid_w_post_merge)` from the most
453    /// recent prepare_vision_embed() call. Used by MRoPE prefill to assign
454    /// correct (t, h, w) position IDs to each vision pad token. Empty when no
455    /// vision input is pending.
456    ///
457    /// `t_len` is the number of TEMPORAL GROUPS the item spans: 1 for a still
458    /// image, `frames / temporal_patch_size` for a video. It is per item and
459    /// not per encoder row on purpose — a video feeds `t_len` rows to the ViT
460    /// but occupies ONE contiguous pad run, and the position builder has to
461    /// treat that run as a single item whose T advances rather than as
462    /// `t_len` unrelated images (which would restart T and mis-advance the
463    /// running position for everything after it).
464    pub(super) vision_image_grids: Mutex<Vec<(usize, usize, usize)>>,
465    /// Co-dispatched batched-ViT slice base for the NEXT prefill_chunk. When a
466    /// tick batches >=2 image requests into one buf_out, each request's chunk-0
467    /// splice/MRoPE must read its OWN slice: `vision_row_base` = first buf_out
468    /// row, `vision_grid_base` = first vision_image_grids index, and
469    /// `vision_owned_images` bounds the grid scan. All 0 ⇒ legacy (read from
470    /// row 0 / grid 0). Set right before prefill_chunk, reset to 0 right after.
471    pub(super) vision_row_base: Mutex<usize>,
472    pub(super) vision_grid_base: Mutex<usize>,
473    pub(super) vision_owned_images: Mutex<usize>,
474    /// Page-locked host staging for batched metadata H2D transfers.
475    /// Allocated once at init via cuMemAllocHost, freed in Drop.
476    ///
477    /// Uses UnsafeCell (not Mutex) because TransformerModel is only accessed
478    /// from the scheduler thread after construction. The Model trait requires
479    /// Send+Sync for the move to the scheduler thread, but the model is never
480    /// accessed from multiple threads simultaneously. A Mutex here caused a
481    /// 500x EP=2 decode regression (50 tok/s → 0.1 tok/s) due to contention
482    /// with the NCCL all-reduce path.
483    pub(super) pinned_staging: std::cell::UnsafeCell<PinnedMetaStaging>,
484    /// Save SSM snapshots every N blocks during chunked prefill.
485    /// 0 = disabled (leaf-only). When > 0, intermediate checkpoints are saved
486    /// at block boundaries, enabling partial prefix SSM restore.
487    pub(super) ssm_checkpoint_interval: usize,
488    /// Kernel handle for fused SSM state normalization (prevents state explosion
489    /// during long chunked prefill — the SSM forgetting bug).
490    pub(super) ssm_state_norm_kernel: KernelHandle,
491    /// FP16 h-state twin of the above (`AVAROK_SSM_H_FP16`). Selected from the
492    /// sequence's own `SsmLayerState::h_is_f16`, so the dispatch reads the
493    /// invariant rather than assuming it.
494    pub(super) ssm_state_norm_f16_kernel: KernelHandle,
495    /// GPU buffer for ssm_state_clamp_norm_fused's pointer table `[num_ssm_layers]`.
496    pub(super) ssm_norm_ptrs_buf: DevicePtr,
497    /// One-shot FP32 -> FP16 h-state converter (`AVAROK_SSM_H_FP16`).
498    pub(super) ssm_h_f32_to_f16_kernel: KernelHandle,
499    /// Its widening inverse. Used ONLY by the stage-3 f16-SIZED pool
500    /// (`--ssm-h-dtype f16-pool`) on the BATCHED prefill path, whose GDN
501    /// kernels take a device pointer TABLE and so cannot be wrapped inside
502    /// the layer the way the single-stream ladder is. Zero otherwise.
503    pub(super) ssm_h_f16_to_f32_kernel: KernelHandle,
504    /// Staging buffer for it, one layer wide (`h_bytes / 2`). The conversion is
505    /// a narrowing compaction and CANNOT be done in place: thread `2i`'s write
506    /// lands inside thread `i`'s read with nothing ordering them. Allocated
507    /// lazily on first use, so a serve without the flag pays nothing.
508    pub(super) ssm_h_f16_scratch: std::sync::OnceLock<DevicePtr>,
509
510    /// SOLID Incr-4: dedicated persistent GPU buffer for the batched-decode MoE
511    /// per-row fold map `[max_batch_size]` i32 (`< 0` = base skip, `>= 0` = fold
512    /// the active adapter). Allocated ONCE at init (fixed device address),
513    /// refreshed per decode step via copy_h2d_async — graph-capture-safe exactly
514    /// like the GDN buffers, and now DISTINCT from the old +160 metadata gap so
515    /// seq_slot@+128 reclaims its full +128..+256 range (concurrent-LoRA decode
516    /// cap 8 → 32). Always allocated (cheap, max_batch_size·4 B); never touched
517    /// when self.lora is None (upload_moe_row_adapter returns DevicePtr(0)).
518    pub(super) moe_row_adapter_buf: DevicePtr,
519
520    // ── Two-phase SSM prefill buffers ──
521    // These hold GDN inputs/outputs for the full sequence, allowing the GDN
522    // recurrence to run in a single kernel launch while GEMM projections are
523    // processed in smaller chunks (memory-bounded).
524    //
525    // Allocated at model init for max_seq_len tokens. Reused across layers
526    // (only one layer runs at a time) and across sequences.
527    /// Packed QKV for two-phase SSM prefill: [max_seq_len, conv_dim] BF16.
528    /// Layout per token: [Q(key_dim) | K(key_dim) | V(value_dim)].
529    pub(super) gdn_buf_qkv: DevicePtr,
530    /// Interleaved gate/beta for two-phase SSM prefill: [max_seq_len, 2*num_v_heads] FP32.
531    /// Layout per token: [gate(nv) | beta(nv)].
532    pub(super) gdn_buf_gate_beta: DevicePtr,
533    /// Full-sequence GDN output: [max_seq_len, value_dim] BF16
534    pub(super) gdn_buf_out: DevicePtr,
535    /// Full-sequence Z gate (for gated RMS norm in phase 3): [max_seq_len, value_dim] BF16
536    pub(super) gdn_buf_z: DevicePtr,
537    /// Max sequence length these buffers were allocated for.
538    pub(super) gdn_buf_max_len: usize,
539
540    /// Logit softcapping kernel: logits = cap * tanh(logits / cap).
541    /// KernelHandle(0) = disabled (no softcapping for this model).
542    pub(super) logit_softcap_kernel: KernelHandle,
543    /// FP32 variant of logit softcap. KernelHandle(0) when not loaded.
544    /// Used when `use_fp32_logits` is true.
545    pub(super) logit_softcap_fp32_kernel: KernelHandle,
546    /// Whether the single-token decode LM head produces FP32 logits (rather
547    /// than BF16). The FP32 logits path required an FP32 residual stream as a
548    /// precondition; with the residual stream now always BF16, this is always
549    /// false and the BF16 logits path is always taken.
550    pub(super) use_fp32_logits: bool,
551    /// FP32 logits scratch [vocab_size × 4 bytes]. NULL when `use_fp32_logits`
552    /// is false (no allocation).
553    pub(super) logits_fp32_buf: DevicePtr,
554    /// Embedding scale kernel: embeddings *= sqrt(hidden_size).
555    /// KernelHandle(0) = disabled (no scaling for this model).
556    pub(super) embed_scale_kernel: KernelHandle,
557    /// Feature-2 token overlay: per-adapter-slot embed/lm_head row-override
558    /// tables. `None` ⇒ feature OFF ⇒ every overlay forward hook early-returns
559    /// (byte-identical to a no-overlay build). Built in `set_lora_weights`
560    /// (Stage 2) from the resident pool's Stage-1 raw uploads.
561    pub(super) overlays: Option<crate::lora::TokenOverlaySet>,
562    /// Feature-2 token overlay kernels, resolved once at construction via
563    /// `try_kernel` (null-on-miss ⇒ overlay silently unused on an older image).
564    pub(super) overlay_kernels: crate::layers::ops::token_overlay::OverlayKernels,
565    /// Feature-2 per-forward overlay route: the current request's `adapter_slot`,
566    /// stamped at each `Model::{prefill,decode,...}` entry (the scheduler drives
567    /// the model serially on one thread, so a plain atomic is sufficient). The
568    /// overlay hooks resolve it through `routed_prefill_slot` so a request that
569    /// selects a NON-active pool adapter gets THAT adapter's overlay, not the
570    /// pool's active one. `i32::MIN` marks a mixed-adapter decode batch (per-token
571    /// `seq_slot` routing deferred to SOLID Incr-4) ⇒ the hooks skip.
572    pub(super) overlay_route_slot: std::sync::atomic::AtomicI32,
573    /// Feature-1 per-decode MoE route, stamped from the decode batch's adapter
574    /// slots at each `Model::{decode,decode_batch,mixed_forward}` entry (the
575    /// decode/verify `ForwardContext`s read it instead of a hardcoded `Fold`).
576    /// A pure-base decode batch resolves to `Skip` so base requests decode
577    /// normally even while an adapter is resident; any adapter-using row makes
578    /// the batch `Fold`/`Refuse`, which `reject_decode_lora` turns into a loud
579    /// bail (the decode-fold is SOLID Incr-4). Encoded 0=Skip 1=Fold 2=Refuse.
580    pub(super) decode_moe_route: std::sync::atomic::AtomicI32,
581}
582
583/// Pinned host memory staging buffer with reusable metadata Vecs.
584pub(crate) struct PinnedMetaStaging {
585    /// Page-locked host buffer (cuMemAllocHost).
586    pub(super) ptr: *mut u8,
587    /// Size in bytes.
588    pub(super) bytes: usize,
589    /// Reusable `Vec<u32>` for positions (avoids per-chunk heap allocation).
590    pub(super) positions: Vec<u32>,
591    pub(super) positions_h: Vec<u32>,
592    pub(super) positions_w: Vec<u32>,
593    /// Reusable `Vec<i64>` for slot mappings (avoids per-chunk heap allocation).
594    pub(super) slots: Vec<i64>,
595}
596
597impl PinnedMetaStaging {
598    /// The ONLY way to write this buffer: a bounds-checked cursor. See
599    /// [`crate::model::pinned_pack`] for why the rule lives there and not in
600    /// each of the five call sites that pack it.
601    ///
602    /// `dest_bytes` is how much room the DEVICE destination has, and it is
603    /// required rather than defaulted because it is the bound that was missing.
604    /// `bytes` here equals `sizes.scratch` exactly (`impl_a1.rs` allocates
605    /// `scratch.max(64 KiB)` and `sizes.rs` already floors scratch at 64 KiB),
606    /// but every one of these packs is uploaded to `scratch().offset(k)` for
607    /// some non-zero `k`. So a pack that fits the HOST staging buffer can still
608    /// run `k` bytes off the end of the DEVICE allocation, and checking only
609    /// `cursor <= stg.bytes` — which is all the old code did — never sees it.
610    /// The packer's capacity is the smaller of the two ends.
611    ///
612    /// Takes `&self` rather than `&mut self` on purpose — the bytes it writes
613    /// are the separate `cuMemAllocHost` region `ptr` refers to, not this
614    /// struct, so a shared borrow is enough and callers can still read the
615    /// reusable source `Vec`s alongside it.
616    pub(crate) fn packer_for(
617        &self,
618        dest_bytes: usize,
619    ) -> crate::model::pinned_pack::PinnedPacker<'_> {
620        // SAFETY: `ptr`/`bytes` are the `alloc_host_pinned` region installed in
621        // `impl_a1.rs` and released in `drop.rs`; it is live for the model's
622        // lifetime, zeroed at allocation (the trait's contract), and only ever
623        // touched from the single scheduler thread — the same invariant that
624        // `unsafe impl Sync for TransformerModel` above rests on. The capacity
625        // handed over is `min(host room, device room)`, never more than the
626        // allocation.
627        unsafe {
628            crate::model::pinned_pack::PinnedPacker::new(self.ptr, self.bytes.min(dest_bytes))
629        }
630    }
631}
632
633// SAFETY: TransformerModel is constructed on the main thread, then moved to
634// the scheduler thread via Box<dyn Model>. After the move, ALL access
635// (prefill, decode, batch_decode) happens on the single scheduler thread.
636// The Model trait requires Send+Sync for the cross-thread move, but the
637// Model is moved to the scheduler thread and accessed exclusively from there.
638// UnsafeCell<PinnedMetaStaging> is not inherently Sync, but single-thread
639// access is enforced at runtime by the scheduler architecture.
640// The raw pointer in PinnedMetaStaging points to cuMemAllocHost memory which
641// is process-global and valid from any thread.
642unsafe impl Send for TransformerModel {}
643// SAFETY: Model methods are only called from the scheduler thread. No concurrent &self access.
644unsafe impl Sync for TransformerModel {}
645
646/// Release every pool this model owns, newest first.
647///
648/// Construction order is buffers → kv cache → ssm pools → derived, so release
649/// runs the reverse. `Teardown` is used rather than a hand-rolled sequence
650/// because it attempts every resource even after one fails: a half-torn-down
651/// GPU is worse than a reported error.
652///
653/// NOT released here: the weights. `build_model` takes `store: &WeightStore`
654/// and the layers only copy pointers out of it, so this model does not own
655/// them — the host that retained the store releases it after this returns.
656impl TransformerModel {
657    /// Hand the model the ledger of its own weights, for teardown.
658    pub fn adopt_weight_store(&mut self, store: spark_runtime::weights::WeightStore) {
659        self.weight_store = Some(store);
660    }
661
662    pub(super) fn release_pools(&mut self) -> anyhow::Result<()> {
663        use avarok_core::scope::ModelResource;
664
665        let gpu: &dyn GpuBackend = self.gpu.as_ref();
666        let mut first_error: Option<anyhow::Error> = None;
667        let mut attempt = |label: &'static str, r: anyhow::Result<()>| {
668            if let Err(e) = r
669                && first_error.is_none()
670            {
671                first_error = Some(e.context(label));
672            }
673        };
674
675        attempt("derived weights", self.derived.release(gpu));
676        attempt("ssm snapshots", self.ssm_snapshots.release(gpu));
677        // The pool is Arc'd because slots are handed out to sequences. A live
678        // clone here means something still holds a slot, which is a drain bug,
679        // not a teardown one — so it is reported rather than forced.
680        match std::sync::Arc::get_mut(&mut self.ssm_pool) {
681            Some(pool) => attempt("ssm state pool", pool.release(gpu)),
682            None => attempt(
683                "ssm state pool",
684                Err(anyhow::anyhow!(
685                    "{} handle(s) still hold the SSM pool — a sequence was not \
686                     released before teardown",
687                    std::sync::Arc::strong_count(&self.ssm_pool) - 1
688                )),
689            ),
690        }
691        attempt("kv cache", self.kv_cache.lock().release(gpu));
692        attempt("buffer arena", self.buffers.release(gpu));
693        // Weights LAST: the layers hold pointers into them, so they must not be
694        // freed until everything that reads them is gone.
695        if let Some(mut store) = self.weight_store.take() {
696            attempt("weight store", store.release(gpu));
697        }
698        // LAST: whatever the owners above did not cover. Chiefly the loaders'
699        // fused weights, which live in layer structs and belong to no pool.
700        // Every pointer freed above has already left the ledger, so this
701        // cannot double-free — it only ever sees what was missed.
702        let swept = gpu.sweep_unreleased();
703        if swept > 0 {
704            tracing::warn!(
705                "teardown swept {swept} allocation(s) that no ModelResource \
706                 released — they are reclaimed, but each one is memory whose \
707                 owner is unaccounted for"
708            );
709        }
710
711        match first_error {
712            Some(e) => Err(e),
713            None => Ok(()),
714        }
715    }
716}