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 MATCHED by the chunk-0 prefix-cache lookup
141 /// (block-aligned). Load-bearing for block-ref accounting: `free_sequence`
142 /// release, `cache_sequence` double-bump avoidance, and the chunked-prefill
143 /// KV write floor all key off it. NOT what gets reported to clients —
144 /// a match can be found and then discarded (see `reused_prefix_tokens`).
145 pub cached_prefix_tokens: usize,
146 /// Number of prompt tokens whose KV this request actually READ from the
147 /// prefix cache instead of recomputing. Stamped after the skip decision;
148 /// read by the scheduler to populate
149 /// `usage.prompt_tokens_details.cached_tokens`.
150 ///
151 /// Atlas #919: this used to be `cached_prefix_tokens`, which reports the
152 /// lookup result — so a request that matched 48 tokens and then recomputed
153 /// all of them (no SSM snapshot / exact-leaf bypass / declined Marconi
154 /// restore) advertised `cached_tokens: 48` next to a full-prefill log line.
155 pub reused_prefix_tokens: usize,
156 /// Number of `block_table` entries that came FROM the prefix cache on this
157 /// sequence's lookup (`matched_blocks.len()`). The cache already holds its
158 /// own "+1" KV ref on each of those blocks, and eviction returns exactly ONE
159 /// ref per radix node — so re-bumping them in `cache_sequence` would add a
160 /// ref nothing can ever release, permanently pinning the whole reused prefix
161 /// on every warm turn until the pool wedges. 0 when there was no cache hit.
162 pub cached_prefix_blocks: usize,
163 /// The matched prefix token IDs (`tokens[..cached_prefix_tokens]`) stashed
164 /// at prefix-lookup time. `free_sequence` releases the prefix cache's radix
165 /// refs over these when `tokens` is too short to cover the prefix — i.e. a
166 /// prefill that matched a prefix (bumping radix refs) then FAILED to
167 /// allocate its suffix, so `tokens` was never populated. Without this the
168 /// `release(&tokens)` on the failure path is a no-op and the matched radix
169 /// nodes stay pinned at ref≥2 forever → the pool progressively wedges. Empty
170 /// on the common path (no match / success releases over the full `tokens`).
171 pub prefix_ref_tokens: Vec<u32>,
172 /// Whether the chunk-0 prefix-cache lookup already ran for this sequence.
173 ///
174 /// The lookup is NOT idempotent: it bumps radix refs, `inc_ref`s each
175 /// matched KV block and PUSHES it onto `block_table`. It also runs BEFORE
176 /// `ensure_blocks_through_prefill`, so a chunk-0 prefill that fails to
177 /// allocate its suffix (KV exhausted) leaves all of that applied. The
178 /// preempt-and-retry in `run_standard_chunk_loop` re-enters `prefill_chunk`
179 /// for the SAME chunk, which would run the lookup a second time — appending
180 /// the matched blocks to `block_table` again (so `block_table[i]` no longer
181 /// maps to logical block `i`) and taking a second radix ref that the single
182 /// `release` in `free_sequence` can never balance. This flag makes the
183 /// re-entry a no-op that replays chunk 0's original decision.
184 pub prefix_lookup_applied: bool,
185 /// The `skip` half of the chunk-0 lookup's return value, replayed verbatim
186 /// when `prefix_lookup_applied` short-circuits a retry.
187 pub prefix_lookup_skip: bool,
188 /// Token count of an SSM anchor near this prompt's end that the pool
189 /// holds for it: the tail-split checkpoint saved during THIS prefill, or
190 /// the checkpoint a warm prefill restored from. Cleared at chunk 0; read
191 /// by `finalize_last` to decide whether the exact prefill-end leaf earns
192 /// a pool slot (`prefill_b::exact_leaf`).
193 pub tail_checkpoint_tokens: Option<usize>,
194 /// Contiguous prefix length (in tokens, from position 0) whose paged KV is
195 /// guaranteed fully written for THIS sequence — either reused from a valid
196 /// prefix-cache match or written by a real prefill pass this turn. Updated
197 /// per chunk in `prefill_b_proc_range`. The prefix-cache insert path caps
198 /// the cached complete-block count to `kv_valid_tokens / block_size` so a
199 /// block whose K/V was never written (e.g. the `proc_count==1` decode
200 /// shortcut skips an entire trailing chunk) is NEVER inserted with stale V.
201 /// Without this cap, stale (donor/zeroed) V in trailing complete blocks
202 /// gets cached and read by the next turn's full-attention layers, making
203 /// cache-ON decode nondeterministic at temperature 0 (see fix/in-think-
204 /// tool-call-leak prefix-cache stale-V diagnosis).
205 pub kv_valid_tokens: usize,
206 /// #155 iter3: block index (`seq_len / block_size`) of the most recent
207 /// decode-time Marconi checkpoint. Dedups re-saving the same boundary
208 /// across consecutive decode steps. 0 until the first decode checkpoint.
209 pub last_decode_ckpt_block: usize,
210 /// Original prompt token count, set at the first prefill and never
211 /// mutated by decode. Used by `cache_sequence` to split seq.tokens into
212 /// prompt (already inserted + ref-bumped by prefill) vs generated
213 /// (needs a fresh bump so `release` in `free_sequence` leaves the
214 /// cache's baseline ref intact). 0 before the first prefill.
215 pub prompt_len: usize,
216 /// Disk-block-ID list for `--high-speed-swap` (Phase 6.1.c).
217 /// Each entry is a stable disk-side identifier that outlives HBM block
218 /// recycling. `disk_block_ids` grows monotonically with the sequence
219 /// and represents its **full historical block list**. IDs are
220 /// layer-agnostic — the same ID indexes a slot in every layer's
221 /// on-disk file. Empty when `--high-speed-swap` is disabled.
222 ///
223 /// **Sliding-window invariant** (Phase 6.3): in HSS mode `block_table`
224 /// is the suffix `disk_block_ids[hss_window_start()..]`, so
225 /// `disk_block_ids.len() == hss_window_start() + block_table.len()`.
226 /// Both vectors are grown together by the alloc helper; the offload
227 /// helper only fills layer K/V data (no length growth). When
228 /// `block_table.len() == cap` and a new logical block is needed, the
229 /// alloc helper drops `block_table[0]` (frees the physical HBM block
230 /// back to the pool) but keeps `disk_block_ids[0]` — the evicted
231 /// block's data lives on at that disk_id for streaming reads.
232 pub disk_block_ids: Vec<u32>,
233 /// Per-attention-layer offload progress tracker for `--high-speed-swap`
234 /// (Phase 6.1.d critical fix). `disk_last_offloaded_per_layer[L]` is
235 /// the number of `disk_block_ids` entries this attention layer has
236 /// successfully offloaded to its on-disk file. Each layer maintains
237 /// its own counter because each layer writes its own K/V independently;
238 /// without per-layer tracking, only the first layer to encounter a new
239 /// block would offload, leaving subsequent layers' on-disk slots
240 /// uninitialised. Length equals the model's attention layer count;
241 /// empty when HSS is disabled.
242 pub disk_last_offloaded_per_layer: Vec<u32>,
243 /// Legacy /v1/completions echo+logprobs: Some(k) = during prefill,
244 /// project every prompt position's hidden state and record the actual
245 /// next token's logprob plus top-k alternatives. Set by the scheduler
246 /// before prefill; None = zero-cost (the collection helper
247 /// early-returns). Requests with this set bypass the prefix cache so
248 /// every position has a live hidden row.
249 pub collect_prompt_logprobs: Option<u8>,
250 /// Accumulated across prefill chunks: one entry per prompt position
251 /// i in [0, prompt_len-1) scoring tokens[i+1]. The final prompt
252 /// position (whose target is the first GENERATED token) is excluded.
253 pub prompt_logprobs: Vec<PromptTokenLogprob>,
254 /// M2 per-request LoRA routing: the adapter POOL SLOT this sequence's
255 /// requests select (NOT `slot_idx`, which is the KV/SSM pool slot). `-1`
256 /// (the default for every existing path) means "defer to the installed
257 /// active adapter" — so an unset request is byte-identical to today. Set
258 /// once from `InferenceRequest::adapter_slot()` at prefill; read by
259 /// `decode_batch` to build the per-step device `seq_slot[N]` buffer the
260 /// batched bgmv routes on.
261 pub adapter_slot: i32,
262 /// Task #25 (slot ref_count): the RESOLVED LoRA pool slot this sequence holds
263 /// a ref on (`-1` = none / not acquired — the default and every non-LoRA
264 /// path). Set at the prefill acquire (and re-acquire on swap-in resume) to
265 /// the index `Model::acquire_adapter_slot` returned; the terminal free
266 /// releases EXACTLY this index (not a re-resolved `adapter_slot`, which would
267 /// mis-decrement if `active` rotated between prefill and finish) and zeroes
268 /// it back to `-1` so release fires exactly once per acquire. Stored resolved
269 /// (not raw) so it also guards the non-scheduler alloc paths (which never
270 /// acquire) from an underflow.
271 pub acquired_adapter_slot: i32,
272 /// NLLB / M2M-100 per-request translation source-language token id (the
273 /// encoder-input prefix). `0` = use the deployment default (`--src-lang`).
274 /// Unused by every other model type.
275 pub src_lang_id: u32,
276 /// NLLB / M2M-100 per-request target-language token id (`forced_bos`).
277 /// `0` = use the deployment default (`--tgt-lang`). Unused by other models.
278 pub tgt_lang_id: u32,
279 /// NLLB beam search: number of beams for this request (`1` = greedy,
280 /// disables the beam path). Unused by every other model type.
281 pub num_beams: u32,
282 /// NLLB beam search: length penalty applied to hypothesis scores
283 /// (`1.0` = neutral). Unused by other models.
284 pub length_penalty: f32,
285 /// NLLB beam search: stop as soon as `num_beams` finished hypotheses
286 /// exist (`false` = exhaust `max_new`). Unused by other models.
287 pub early_stopping: bool,
288}
289
290impl SequenceState {
291 /// A detached, host-only sequence state: no GPU resources, no SSM
292 /// slot, no layer states, every counter zeroed. The single source
293 /// for the "empty sequence" field defaults — construction sites
294 /// that own real resources build on top of it instead of repeating
295 /// the full literal (NLLB's `alloc_sequence`, the engine-test
296 /// mock), so a new field gets ONE default site. Also the only way
297 /// for other crates to construct a `SequenceState` at all (e.g.
298 /// the scheduler's lifecycle unit tests): `ssm_slot` is
299 /// crate-private by design.
300 pub fn host_only(slot_idx: usize) -> Self {
301 SequenceState {
302 tokens: Vec::new(),
303 block_table: Vec::new(),
304 seq_len: 0,
305 layer_states: Vec::new(),
306 proposer_state: None,
307 slot_idx,
308 ssm_slot: None,
309 marconi_skip_to: 0,
310 marconi_exact_snap: None,
311 session_hash: 0,
312 mtp_capture_gen: 0,
313 // Not from `alloc_sequence`, so it owns no hidden rows.
314 mtp_store_gen: 0,
315 adapter_id: 0,
316 chunked_prefill_meta: None,
317 cached_prefix_tokens: 0,
318 reused_prefix_tokens: 0,
319 cached_prefix_blocks: 0,
320 prefix_ref_tokens: Vec::new(),
321 prefix_lookup_applied: false,
322 tail_checkpoint_tokens: None,
323 prefix_lookup_skip: false,
324 kv_valid_tokens: 0,
325 last_decode_ckpt_block: 0,
326 prompt_len: 0,
327 disk_block_ids: Vec::new(),
328 disk_last_offloaded_per_layer: Vec::new(),
329 collect_prompt_logprobs: None,
330 prompt_logprobs: Vec::new(),
331 // -1 = defer to the installed active adapter (see field docs).
332 adapter_slot: -1,
333 // -1 = no LoRA slot ref held until prefill acquires (Task #25).
334 acquired_adapter_slot: -1,
335 src_lang_id: 0,
336 tgt_lang_id: 0,
337 num_beams: 1,
338 length_penalty: 1.0,
339 early_stopping: false,
340 }
341 }
342
343 /// SSM-pool slot index for this sequence, if it has GDN/SSM (linear-attn)
344 /// layers. Used by the scheduler to order the decode batch by slot so the
345 /// batched-recurrent SSM + CUDA-graph contiguity invariant holds
346 /// (position i ↔ pool_base + i*stride). `None` for pure-attention models.
347 #[inline]
348 pub fn ssm_slot_idx(&self) -> Option<usize> {
349 self.ssm_slot.as_ref().and_then(|g| g.idx())
350 }
351
352 /// Phase 6.3 sliding-window helper: the absolute logical block index
353 /// of `block_table[0]`. Returns 0 when `--high-speed-swap` is off
354 /// (`disk_block_ids` is empty then; `block_table` is the full history).
355 /// Derived rather than stored — the invariant
356 /// `disk_block_ids.len() == hss_window_start() + block_table.len()`
357 /// is maintained by the alloc helper and asserted by the offload
358 /// helper, so no separate field is needed.
359 #[inline]
360 pub fn hss_window_start(&self) -> usize {
361 self.disk_block_ids
362 .len()
363 .saturating_sub(self.block_table.len())
364 }
365
366 /// Map an absolute logical block index → physical HBM block id.
367 /// Returns `None` when the block has been evicted to disk-only
368 /// (the caller should route attention through the HSS orchestrator's
369 /// `attend_layer_on_stream` for that position). With HSS off,
370 /// `hss_window_start()` is 0 and this is a direct lookup.
371 #[inline]
372 pub fn physical_block_for(&self, abs_block_idx: usize) -> Option<u32> {
373 let ws = self.hss_window_start();
374 if abs_block_idx < ws {
375 return None;
376 }
377 self.block_table.get(abs_block_idx - ws).copied()
378 }
379}
380
381/// Model trait for forward pass execution.
382///
383/// Implementations: `TransformerModel` (all architectures).
384///
385/// # Safety
386///
387/// `Send + Sync` is required by `Box<dyn Model>` usage patterns.
388/// `Sync` safety: the model is exclusively accessed from the scheduler
389/// thread. The `unsafe impl Sync` on `TransformerModel` documents this
390/// single-thread invariant — do NOT share `&dyn Model` across threads.
391mod logprobs;
392mod model;
393pub use logprobs::*;
394pub use model::{BeamReq, EpCommandFailed, Model, padded_batch_n};