spark_model/
traits.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Model trait (SDD: single trait, multiple implementations possible).
4//!
5//! The Model trait defines the interface for running inference. Business
6//! logic (scheduler, engine) programs against this trait, not concrete types.
7
8use spark_runtime::gpu::DevicePtr;
9
10use crate::layer::LayerState;
11use crate::speculative::ProposerState;
12
13/// Result of a mixed forward pass (decode + prefill in one pass).
14pub struct MixedForwardResult {
15    /// Logits for decode sequences: [N, vocab_size] BF16.
16    /// NULL if no decode sequences.
17    pub decode_logits: DevicePtr,
18    /// Logits for the prefill sequence's last token: [1, vocab_size] BF16.
19    /// NULL if `is_last_chunk` was false (intermediate chunk, no logits).
20    pub prefill_logits: DevicePtr,
21}
22
23/// Per-stream input slice for batched prefill.
24///
25/// One of these per concurrent prefilling stream — `prefill_batch_chunk` and
26/// `mixed_forward_batch` accept a `&mut [PrefillSlice<'_>]` and process all
27/// streams' chunks in a single forward pass. See Q12 in
28/// `/workspace/atlas-internal/qwen-refactor/notes.md` for the bug this
29/// fixes (concurrent prefills serialized through `prefilling.first_mut()`
30/// in the scheduler, causing 5× asymmetric TTFT).
31pub struct PrefillSlice<'a> {
32    /// Full prompt tokens for this stream.
33    pub prompt_tokens: &'a [u32],
34    /// Per-stream sequence state (KV blocks, SSM slot, etc.).
35    pub seq: &'a mut SequenceState,
36    /// Token offset into `prompt_tokens` where this chunk starts.
37    pub chunk_start: usize,
38    /// Number of tokens in this chunk.
39    pub chunk_len: usize,
40    /// Whether this is the final chunk for this stream (controls whether
41    /// the model emits last-token logits for sampling).
42    pub is_last_chunk: bool,
43}
44
45/// Result of a fully-batched mixed forward pass: M decode tokens + N prefill
46/// chunks in one pass.
47pub struct MixedBatchResult {
48    /// Logits for decode lanes: [M, vocab] BF16. NULL if no decode lanes.
49    pub decode_logits: DevicePtr,
50    /// Logits per prefill stream — one DevicePtr per stream in the input
51    /// slice, in the same order. Each entry is `[1, vocab]` BF16 when that
52    /// stream's chunk was `is_last_chunk`, or NULL otherwise.
53    pub prefill_logits: Vec<DevicePtr>,
54}
55
56/// Per-sequence paged attention metadata for chunked prefill.
57///
58/// Positions and slots remain chunk-local, but the paged block table and
59/// running sequence length can persist across chunks so we only upload the
60/// changed tail instead of rebuilding the full page metadata every time.
61pub struct ChunkedPrefillPageMetadata {
62    /// Device buffer holding the sequence block table as raw 32-bit entries.
63    pub block_table: DevicePtr,
64    /// Device buffer holding the running paged-prefill sequence length.
65    pub seq_len: DevicePtr,
66    /// Total block-table entries allocated for this prompt.
67    pub block_capacity: usize,
68    /// Number of block-table entries already uploaded to `block_table`.
69    pub uploaded_blocks: usize,
70}
71
72/// Sequence state tracked across decode steps.
73pub struct SequenceState {
74    /// Token IDs generated so far (including prompt).
75    pub tokens: Vec<u32>,
76    /// Block table for paged KV cache (indices into PagedKvCache).
77    pub block_table: Vec<u32>,
78    /// Current sequence length (prompt + generated).
79    pub seq_len: usize,
80    /// Per-layer state (EmptyLayerState for attention, SsmLayerState for SSM).
81    pub layer_states: Vec<Box<dyn LayerState>>,
82    /// Per-sequence state for speculative decoding proposer (None if no proposer).
83    pub proposer_state: Option<Box<dyn ProposerState>>,
84    /// SSM state pool slot index. Used for CUDA graph stability — all sequences
85    /// at the same slot_idx use the same fixed GPU addresses. Derived from
86    /// `ssm_slot` at claim time (the guard is the authority on release
87    /// responsibility; this index is the authority on pool-offset math).
88    pub slot_idx: usize,
89    /// RAII guard that returns `slot_idx` to the SSM pool's free list on drop.
90    /// Guarantees the slot is released on EVERY sequence-exit path — including
91    /// abort/cancel, decode error, swap-out failure, and panic/unwind — not
92    /// just the explicit `free_sequence`/`compact_sequence` sites, which
93    /// `take()`/`migrate()` the guard so the release happens EXACTLY once.
94    /// `None` for models without an SSM pool (e.g. the unit-test mock).
95    pub(crate) ssm_slot: Option<crate::model::ssm_pool::SlotGuard>,
96    /// Marconi: token position up to which SSM state is valid from a snapshot.
97    /// Set on chunk 0's prefix cache lookup, read by subsequent chunks to skip
98    /// computation for tokens already covered by the snapshot + KV cache.
99    pub marconi_skip_to: usize,
100    /// Marconi exact-hit: snapshot slot when the *entire* prompt matched a
101    /// leaf snapshot (`matched == total`). On this path the last prompt
102    /// token is re-run for logits, which double-advances the SSM recurrent
103    /// state; `finalize_last` uses this to re-restore the pristine state@N
104    /// and emit the first token's logits from the snapshot's stashed hidden
105    /// instead. `None` for all other paths.
106    pub marconi_exact_snap: Option<usize>,
107    /// Session hash for SSM snapshot isolation. Set by the scheduler before
108    /// prefill. The model uses this to tag saved snapshots and verify ownership
109    /// before restoring. 0 = no session tracking (legacy behavior).
110    pub session_hash: u64,
111    /// Ownership stamp for the SINGLE-SLOT whole-prompt hidden capture
112    /// (`mtp_prefill_hidden`). Written by `try_mtp_prefill_capture` when THIS
113    /// sequence's chunk 0 (re)starts the capture, with the model's monotonic
114    /// capture generation. `ensure_drafter_context` prefills the drafter only
115    /// while the stamp still matches the model's current generation — at
116    /// C>=2 interleaved prefills restart the shared capture, and without this
117    /// check a sequence's first propose could pair ITS tokens with ANOTHER
118    /// sequence's captured hiddens (poisoned drafter KV; blind is strictly
119    /// better than poisoned). 0 = never owned a capture.
120    pub mtp_capture_gen: u64,
121    /// Ownership ticket for the shared hidden-row interval
122    /// (`mtp_store_range`), drawn at `alloc_sequence` from the same atomic
123    /// that issues capture generations.
124    ///
125    /// Distinct from `mtp_capture_gen` because that one is assigned ONLY under
126    /// `chunk_start == 0`, and a warm turn never starts at 0 — so it is `0` for
127    /// the entire life of exactly the sequences the carry path serves, and
128    /// would make every warm sequence look like the same owner. This is drawn
129    /// unconditionally at admission. `0` = drawn outside `alloc_sequence` (the
130    /// mock and test fakes), and never matches anything.
131    pub mtp_store_gen: u64,
132    /// Per-adapter prefix-cache namespace (adapter-correct KV). Folded into the
133    /// prefix hash so two adapters that share a token prefix never reuse each
134    /// other's blocks. `0` = base / no adapter (a strict no-op in the fold, so
135    /// behavior is byte-identical until a LoRA path stamps a non-zero id).
136    pub adapter_id: u64,
137    /// Persistent paged metadata for chunked prefill, allocated lazily on the
138    /// first chunk that needs paged attention.
139    pub chunked_prefill_meta: Option<ChunkedPrefillPageMetadata>,
140    /// Number of prompt tokens served by the prefix cache (block-aligned).
141    /// Set by the model layer on the chunk-0 prefix-cache lookup; read by
142    /// the scheduler to populate `usage.prompt_tokens_details.cached_tokens`.
143    /// 0 when prefix caching is disabled or the prompt had no cache match.
144    pub cached_prefix_tokens: usize,
145    /// Number of `block_table` entries that came FROM the prefix cache on this
146    /// sequence's lookup (`matched_blocks.len()`). The cache already holds its
147    /// own "+1" KV ref on each of those blocks, and eviction returns exactly ONE
148    /// ref per radix node — so re-bumping them in `cache_sequence` would add a
149    /// ref nothing can ever release, permanently pinning the whole reused prefix
150    /// on every warm turn until the pool wedges. 0 when there was no cache hit.
151    pub cached_prefix_blocks: usize,
152    /// The matched prefix token IDs (`tokens[..cached_prefix_tokens]`) stashed
153    /// at prefix-lookup time. `free_sequence` releases the prefix cache's radix
154    /// refs over these when `tokens` is too short to cover the prefix — i.e. a
155    /// prefill that matched a prefix (bumping radix refs) then FAILED to
156    /// allocate its suffix, so `tokens` was never populated. Without this the
157    /// `release(&tokens)` on the failure path is a no-op and the matched radix
158    /// nodes stay pinned at ref≥2 forever → the pool progressively wedges. Empty
159    /// on the common path (no match / success releases over the full `tokens`).
160    pub prefix_ref_tokens: Vec<u32>,
161    /// Whether the chunk-0 prefix-cache lookup already ran for this sequence.
162    ///
163    /// The lookup is NOT idempotent: it bumps radix refs, `inc_ref`s each
164    /// matched KV block and PUSHES it onto `block_table`. It also runs BEFORE
165    /// `ensure_blocks_through_prefill`, so a chunk-0 prefill that fails to
166    /// allocate its suffix (KV exhausted) leaves all of that applied. The
167    /// preempt-and-retry in `run_standard_chunk_loop` re-enters `prefill_chunk`
168    /// for the SAME chunk, which would run the lookup a second time — appending
169    /// the matched blocks to `block_table` again (so `block_table[i]` no longer
170    /// maps to logical block `i`) and taking a second radix ref that the single
171    /// `release` in `free_sequence` can never balance. This flag makes the
172    /// re-entry a no-op that replays chunk 0's original decision.
173    pub prefix_lookup_applied: bool,
174    /// The `skip` half of the chunk-0 lookup's return value, replayed verbatim
175    /// when `prefix_lookup_applied` short-circuits a retry.
176    pub prefix_lookup_skip: bool,
177    /// Contiguous prefix length (in tokens, from position 0) whose paged KV is
178    /// guaranteed fully written for THIS sequence — either reused from a valid
179    /// prefix-cache match or written by a real prefill pass this turn. Updated
180    /// per chunk in `prefill_b_proc_range`. The prefix-cache insert path caps
181    /// the cached complete-block count to `kv_valid_tokens / block_size` so a
182    /// block whose K/V was never written (e.g. the `proc_count==1` decode
183    /// shortcut skips an entire trailing chunk) is NEVER inserted with stale V.
184    /// Without this cap, stale (donor/zeroed) V in trailing complete blocks
185    /// gets cached and read by the next turn's full-attention layers, making
186    /// cache-ON decode nondeterministic at temperature 0 (see fix/in-think-
187    /// tool-call-leak prefix-cache stale-V diagnosis).
188    pub kv_valid_tokens: usize,
189    /// #155 iter3: block index (`seq_len / block_size`) of the most recent
190    /// decode-time Marconi checkpoint. Dedups re-saving the same boundary
191    /// across consecutive decode steps. 0 until the first decode checkpoint.
192    pub last_decode_ckpt_block: usize,
193    /// Original prompt token count, set at the first prefill and never
194    /// mutated by decode. Used by `cache_sequence` to split seq.tokens into
195    /// prompt (already inserted + ref-bumped by prefill) vs generated
196    /// (needs a fresh bump so `release` in `free_sequence` leaves the
197    /// cache's baseline ref intact). 0 before the first prefill.
198    pub prompt_len: usize,
199    /// Disk-block-ID list for `--high-speed-swap` (Phase 6.1.c).
200    /// Each entry is a stable disk-side identifier that outlives HBM block
201    /// recycling. `disk_block_ids` grows monotonically with the sequence
202    /// and represents its **full historical block list**. IDs are
203    /// layer-agnostic — the same ID indexes a slot in every layer's
204    /// on-disk file. Empty when `--high-speed-swap` is disabled.
205    ///
206    /// **Sliding-window invariant** (Phase 6.3): in HSS mode `block_table`
207    /// is the suffix `disk_block_ids[hss_window_start()..]`, so
208    /// `disk_block_ids.len() == hss_window_start() + block_table.len()`.
209    /// Both vectors are grown together by the alloc helper; the offload
210    /// helper only fills layer K/V data (no length growth). When
211    /// `block_table.len() == cap` and a new logical block is needed, the
212    /// alloc helper drops `block_table[0]` (frees the physical HBM block
213    /// back to the pool) but keeps `disk_block_ids[0]` — the evicted
214    /// block's data lives on at that disk_id for streaming reads.
215    pub disk_block_ids: Vec<u32>,
216    /// Per-attention-layer offload progress tracker for `--high-speed-swap`
217    /// (Phase 6.1.d critical fix). `disk_last_offloaded_per_layer[L]` is
218    /// the number of `disk_block_ids` entries this attention layer has
219    /// successfully offloaded to its on-disk file. Each layer maintains
220    /// its own counter because each layer writes its own K/V independently;
221    /// without per-layer tracking, only the first layer to encounter a new
222    /// block would offload, leaving subsequent layers' on-disk slots
223    /// uninitialised. Length equals the model's attention layer count;
224    /// empty when HSS is disabled.
225    pub disk_last_offloaded_per_layer: Vec<u32>,
226    /// Legacy /v1/completions echo+logprobs: Some(k) = during prefill,
227    /// project every prompt position's hidden state and record the actual
228    /// next token's logprob plus top-k alternatives. Set by the scheduler
229    /// before prefill; None = zero-cost (the collection helper
230    /// early-returns). Requests with this set bypass the prefix cache so
231    /// every position has a live hidden row.
232    pub collect_prompt_logprobs: Option<u8>,
233    /// Accumulated across prefill chunks: one entry per prompt position
234    /// i in [0, prompt_len-1) scoring tokens[i+1]. The final prompt
235    /// position (whose target is the first GENERATED token) is excluded.
236    pub prompt_logprobs: Vec<PromptTokenLogprob>,
237    /// M2 per-request LoRA routing: the adapter POOL SLOT this sequence's
238    /// requests select (NOT `slot_idx`, which is the KV/SSM pool slot). `-1`
239    /// (the default for every existing path) means "defer to the installed
240    /// active adapter" — so an unset request is byte-identical to today. Set
241    /// once from `InferenceRequest::adapter_slot()` at prefill; read by
242    /// `decode_batch` to build the per-step device `seq_slot[N]` buffer the
243    /// batched bgmv routes on.
244    pub adapter_slot: i32,
245    /// Task #25 (slot ref_count): the RESOLVED LoRA pool slot this sequence holds
246    /// a ref on (`-1` = none / not acquired — the default and every non-LoRA
247    /// path). Set at the prefill acquire (and re-acquire on swap-in resume) to
248    /// the index `Model::acquire_adapter_slot` returned; the terminal free
249    /// releases EXACTLY this index (not a re-resolved `adapter_slot`, which would
250    /// mis-decrement if `active` rotated between prefill and finish) and zeroes
251    /// it back to `-1` so release fires exactly once per acquire. Stored resolved
252    /// (not raw) so it also guards the non-scheduler alloc paths (which never
253    /// acquire) from an underflow.
254    pub acquired_adapter_slot: i32,
255    /// NLLB / M2M-100 per-request translation source-language token id (the
256    /// encoder-input prefix). `0` = use the deployment default (`--src-lang`).
257    /// Unused by every other model type.
258    pub src_lang_id: u32,
259    /// NLLB / M2M-100 per-request target-language token id (`forced_bos`).
260    /// `0` = use the deployment default (`--tgt-lang`). Unused by other models.
261    pub tgt_lang_id: u32,
262    /// NLLB beam search: number of beams for this request (`1` = greedy,
263    /// disables the beam path). Unused by every other model type.
264    pub num_beams: u32,
265    /// NLLB beam search: length penalty applied to hypothesis scores
266    /// (`1.0` = neutral). Unused by other models.
267    pub length_penalty: f32,
268    /// NLLB beam search: stop as soon as `num_beams` finished hypotheses
269    /// exist (`false` = exhaust `max_new`). Unused by other models.
270    pub early_stopping: bool,
271}
272
273impl SequenceState {
274    /// A detached, host-only sequence state: no GPU resources, no SSM
275    /// slot, no layer states, every counter zeroed. The single source
276    /// for the "empty sequence" field defaults — construction sites
277    /// that own real resources build on top of it instead of repeating
278    /// the full literal (NLLB's `alloc_sequence`, the engine-test
279    /// mock), so a new field gets ONE default site. Also the only way
280    /// for other crates to construct a `SequenceState` at all (e.g.
281    /// the scheduler's lifecycle unit tests): `ssm_slot` is
282    /// crate-private by design.
283    pub fn host_only(slot_idx: usize) -> Self {
284        SequenceState {
285            tokens: Vec::new(),
286            block_table: Vec::new(),
287            seq_len: 0,
288            layer_states: Vec::new(),
289            proposer_state: None,
290            slot_idx,
291            ssm_slot: None,
292            marconi_skip_to: 0,
293            marconi_exact_snap: None,
294            session_hash: 0,
295            mtp_capture_gen: 0,
296            // Not from `alloc_sequence`, so it owns no hidden rows.
297            mtp_store_gen: 0,
298            adapter_id: 0,
299            chunked_prefill_meta: None,
300            cached_prefix_tokens: 0,
301            cached_prefix_blocks: 0,
302            prefix_ref_tokens: Vec::new(),
303            prefix_lookup_applied: false,
304            prefix_lookup_skip: false,
305            kv_valid_tokens: 0,
306            last_decode_ckpt_block: 0,
307            prompt_len: 0,
308            disk_block_ids: Vec::new(),
309            disk_last_offloaded_per_layer: Vec::new(),
310            collect_prompt_logprobs: None,
311            prompt_logprobs: Vec::new(),
312            // -1 = defer to the installed active adapter (see field docs).
313            adapter_slot: -1,
314            // -1 = no LoRA slot ref held until prefill acquires (Task #25).
315            acquired_adapter_slot: -1,
316            src_lang_id: 0,
317            tgt_lang_id: 0,
318            num_beams: 1,
319            length_penalty: 1.0,
320            early_stopping: false,
321        }
322    }
323
324    /// SSM-pool slot index for this sequence, if it has GDN/SSM (linear-attn)
325    /// layers. Used by the scheduler to order the decode batch by slot so the
326    /// batched-recurrent SSM + CUDA-graph contiguity invariant holds
327    /// (position i ↔ pool_base + i*stride). `None` for pure-attention models.
328    #[inline]
329    pub fn ssm_slot_idx(&self) -> Option<usize> {
330        self.ssm_slot.as_ref().and_then(|g| g.idx())
331    }
332
333    /// Phase 6.3 sliding-window helper: the absolute logical block index
334    /// of `block_table[0]`. Returns 0 when `--high-speed-swap` is off
335    /// (`disk_block_ids` is empty then; `block_table` is the full history).
336    /// Derived rather than stored — the invariant
337    /// `disk_block_ids.len() == hss_window_start() + block_table.len()`
338    /// is maintained by the alloc helper and asserted by the offload
339    /// helper, so no separate field is needed.
340    #[inline]
341    pub fn hss_window_start(&self) -> usize {
342        self.disk_block_ids
343            .len()
344            .saturating_sub(self.block_table.len())
345    }
346
347    /// Map an absolute logical block index → physical HBM block id.
348    /// Returns `None` when the block has been evicted to disk-only
349    /// (the caller should route attention through the HSS orchestrator's
350    /// `attend_layer_on_stream` for that position). With HSS off,
351    /// `hss_window_start()` is 0 and this is a direct lookup.
352    #[inline]
353    pub fn physical_block_for(&self, abs_block_idx: usize) -> Option<u32> {
354        let ws = self.hss_window_start();
355        if abs_block_idx < ws {
356            return None;
357        }
358        self.block_table.get(abs_block_idx - ws).copied()
359    }
360}
361
362/// Model trait for forward pass execution.
363///
364/// Implementations: `TransformerModel` (all architectures).
365///
366/// # Safety
367///
368/// `Send + Sync` is required by `Box<dyn Model>` usage patterns.
369/// `Sync` safety: the model is exclusively accessed from the scheduler
370/// thread. The `unsafe impl Sync` on `TransformerModel` documents this
371/// single-thread invariant — do NOT share `&dyn Model` across threads.
372mod logprobs;
373mod model;
374pub use logprobs::*;
375pub use model::{BeamReq, EpCommandFailed, Model, padded_batch_n};