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};