metrale_model_engine/
traits.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: The `Model` trait, the per-sequence state it works on (`SequenceState`), and the
4//! input and result types of its forward passes.
5//!
6//! Owner: model-engine.
7//! Invariants: none beyond the types.
8
9use metrale_gpu_runtime::gpu::DevicePtr;
10
11use metrale_model_layers::layer::LayerState;
12use metrale_model_layers::speculative::ProposerState;
13
14/// 2026-09-25: Result of a mixed forward pass (decode rows plus one prefill chunk).
15pub struct MixedForwardResult {
16    /// 2026-09-25: Logits for the decode sequences, `[N, vocab_size]`; NULL when there are none.
17    pub decode_logits: DevicePtr,
18    /// 2026-09-25: Logits for the prefill sequence's last token, `[1, vocab_size]`; NULL unless
19    /// the chunk was its last.
20    pub prefill_logits: DevicePtr,
21}
22
23/// 2026-09-25: One prefilling stream's chunk. `prefill_batch_chunk` and `mixed_forward_batch`
24/// take a `&mut [PrefillSlice<'_>]` and run every stream's chunk in one forward pass.
25pub struct PrefillSlice<'a> {
26    /// 2026-09-25: The stream's full prompt.
27    pub prompt_tokens: &'a [u32],
28    pub seq: &'a mut SequenceState,
29    /// 2026-09-25: Offset into `prompt_tokens` where this chunk starts.
30    pub chunk_start: usize,
31    pub chunk_len: usize,
32    /// 2026-09-25: Whether this is the stream's final chunk, which produces its last-token logits.
33    pub is_last_chunk: bool,
34}
35
36/// 2026-09-25: Result of a batched mixed forward pass: M decode rows plus N prefill chunks.
37pub struct MixedBatchResult {
38    /// 2026-09-25: Logits for the decode rows, `[M, vocab]`; NULL when there are none.
39    pub decode_logits: DevicePtr,
40    /// 2026-09-25: One entry per prefill stream, in input order: `[1, vocab]` logits when that
41    /// stream's chunk was its last, NULL otherwise.
42    pub prefill_logits: Vec<DevicePtr>,
43}
44
45/// 2026-09-25: Per-sequence device copies of the paged-attention metadata for chunked prefill,
46/// kept across chunks so each chunk uploads only the block-table entries it added.
47pub struct ChunkedPrefillPageMetadata {
48    /// 2026-09-25: The block table as 32-bit entries.
49    pub block_table: DevicePtr,
50    /// 2026-09-25: The running sequence length, one `u32`.
51    pub seq_len: DevicePtr,
52    /// 2026-09-25: Block-table entries allocated, sized for the whole prompt.
53    pub block_capacity: usize,
54    /// 2026-09-25: Block-table entries already uploaded.
55    pub uploaded_blocks: usize,
56}
57
58/// 2026-09-25: One sequence's state across prefill and decode.
59pub struct SequenceState {
60    /// 2026-09-25: Prompt and generated token ids processed so far.
61    pub tokens: Vec<u32>,
62    /// 2026-09-25: Physical KV blocks of the paged cache, in logical order.
63    pub block_table: Vec<u32>,
64    pub seq_len: usize,
65    pub layer_states: Vec<Box<dyn LayerState>>,
66    /// 2026-09-25: The draft proposer's per-sequence state; `None` without a proposer.
67    pub proposer_state: Option<Box<dyn ProposerState>>,
68    /// 2026-09-25: Pool slot of this sequence, taken from `ssm_slot` at `alloc_sequence`.
69    /// `compact_sequence` moves it, and `detach_slot_for_reuse` sets it to `usize::MAX`.
70    pub slot_idx: usize,
71    /// 2026-09-25: Guard that returns the SSM pool slot to the free list on drop, so the slot is
72    /// released on every exit path, including abort and unwind. The explicit free and compact
73    /// paths `take()` or `migrate()` it so the slot is released once. `None` for models
74    /// without an SSM pool.
75    pub(crate) ssm_slot: Option<crate::model::ssm_pool::SlotGuard>,
76    /// 2026-09-25: Prompt position covered by the restored SSM snapshot and cached KV, set by the
77    /// chunk-0 prefix lookup and returned again when that lookup is replayed.
78    pub marconi_skip_to: usize,
79    /// 2026-09-25: Snapshot slot when the whole prompt matched a snapshot. The last prompt token
80    /// is then re-run for logits, which advances the SSM state a second time, so
81    /// `finalize_last` restores the state from this snapshot again and takes the first token's
82    /// hidden row from it. `None` otherwise.
83    pub marconi_exact_snap: Option<usize>,
84    /// 2026-09-25: Session identity, set by the scheduler before prefill. Saved SSM snapshots are
85    /// tagged with it and a snapshot is restored only for a matching tag
86    /// (`SsmSnapshotPool::session_matches`); `0` skips the check.
87    pub session_hash: u64,
88    /// 2026-10-04: Tenant for the scheduler's cross-request prompt-lookup cache, set by the
89    /// scheduler before prefill and carried across preemption and swap. `None` keeps the
90    /// sequence out of that cache (neither read nor written).
91    pub lookup_tenant: Option<u64>,
92    /// 2026-09-25: This sequence's claim on the single shared whole-prompt hidden capture
93    /// (`mtp_prefill_hidden`). `try_mtp_prefill_capture` sets it to a new capture generation when
94    /// it writes this sequence's rows from row 0. `ensure_drafter_context` prefills the drafter
95    /// only while it equals the model's current generation, since another sequence's chunk 0 may
96    /// have restarted the capture in between. `0` means it never owned a capture.
97    pub mtp_capture_gen: u64,
98    /// 2026-09-25: This sequence's ticket for the shared hidden-row interval (`mtp_store_range`),
99    /// drawn in `alloc_sequence` from the model's counter, and separate from `mtp_capture_gen`,
100    /// which is set only by a capture that starts at row 0. `0` means it was not drawn in
101    /// `alloc_sequence`, and matches no owner.
102    pub mtp_store_gen: u64,
103    /// 2026-09-25: Prefix-cache namespace of the sequence's LoRA adapter; `0` is the base model.
104    /// Each id has its own radix root, so adapters never reuse each other's blocks. The
105    /// scheduler sets it from `Model::adapter_id_for`.
106    pub adapter_id: u64,
107    /// 2026-09-25: Paged metadata for chunked prefill, allocated by the first chunk that needs it.
108    pub chunked_prefill_meta: Option<ChunkedPrefillPageMetadata>,
109    /// 2026-09-25: Prompt tokens matched by the chunk-0 prefix-cache lookup. The block-ref
110    /// accounting reads it (the release in `free_sequence`, the prefill-end cache insert). It is
111    /// not what clients are told: a match can be found and then recomputed
112    /// (`reused_prefix_tokens`).
113    pub cached_prefix_tokens: usize,
114    /// 2026-09-25: Prompt tokens whose KV this request read from the prefix cache instead of
115    /// recomputing (`prefix_reuse::reused_prefix_tokens`). The scheduler reports it as
116    /// `usage.prompt_tokens_details.cached_tokens`.
117    pub reused_prefix_tokens: usize,
118    /// 2026-09-25: Number of `block_table` entries that came from the prefix-cache lookup
119    /// (`matched_blocks.len()`); 0 without a hit.
120    pub cached_prefix_blocks: usize,
121    /// 2026-09-25: The matched prefix tokens (`tokens[..cached_prefix_tokens]`), stored at lookup.
122    /// When a prefill matched a prefix and then failed before `tokens` was filled,
123    /// `free_sequence` releases the lookup's radix refs over these instead. Empty when nothing
124    /// matched.
125    pub prefix_ref_tokens: Vec<u32>,
126    /// 2026-09-25: Whether the chunk-0 prefix lookup has run for this sequence.
127    ///
128    /// The lookup is not idempotent: it takes radix refs, `inc_ref`s each matched block and
129    /// pushes it onto `block_table`, all before the suffix is allocated. When that allocation
130    /// fails, the scheduler's preempt-and-retry re-enters chunk 0; with this flag set the lookup
131    /// replays its first result instead of pushing the blocks and taking the refs again.
132    pub prefix_lookup_applied: bool,
133    /// 2026-09-25: The `skip` result of the chunk-0 lookup, returned again on a replay.
134    pub prefix_lookup_skip: bool,
135    /// 2026-09-25: Token count of an SSM checkpoint near the prompt's end that the pool holds for
136    /// this sequence: saved during this prefill, or the one a warm prefill restored from. Cleared
137    /// at chunk 0; `finalize_last` reads it to decide whether the prompt-end leaf gets its own
138    /// snapshot (`prefill_b::exact_leaf`).
139    pub tail_checkpoint_tokens: Option<usize>,
140    /// 2026-09-25: Length of the prefix, from position 0, whose paged KV this sequence has written
141    /// or validly reused, updated per chunk by `prefill_b_proc_range`. The prefill-end cache
142    /// insert and checkpoint save cover at most `kv_valid_tokens / block_size` complete blocks, so
143    /// a block whose KV was never written is not cached.
144    pub kv_valid_tokens: usize,
145    /// 2026-09-25: Block index (`len / block_size`) of the latest decode-time SSM checkpoint, so a
146    /// boundary is not saved twice. Prefill sets it to the prompt's block count.
147    pub last_decode_ckpt_block: usize,
148    /// 2026-09-25: Prompt length, set by prefill. `cache_sequence` passes it to the prefix-cache
149    /// insert as `matched_tokens`, so the insert takes refs only for the generated tokens.
150    pub prompt_len: usize,
151    /// 2026-09-25: `--high-speed-swap` disk block ids, one per logical block the sequence has had,
152    /// in order. When the HBM window is at its cap, the oldest `block_table` entry is freed and
153    /// its disk id stays (`ensure_blocks_through_decode`), so `block_table` holds the newest
154    /// blocks and `hss_window_start()` is the logical index of `block_table[0]`. Empty without
155    /// `--high-speed-swap`.
156    pub disk_block_ids: Vec<u32>,
157    /// 2026-09-25: For each attention layer, how many `disk_block_ids` entries it has offloaded.
158    /// A block leaves the HBM window only when every layer has offloaded it
159    /// (`check_safe_to_evict`). `TransformerModel::alloc_sequence` sizes it to the attention-layer
160    /// count whether or not `--high-speed-swap` is on.
161    pub disk_last_offloaded_per_layer: Vec<u32>,
162    /// 2026-09-25: `Some(k)` makes prefill score every prompt position with its top-`k`
163    /// alternatives (`/v1/completions` with `echo` and `logprobs`). Set by the scheduler before
164    /// prefill. Such a request bypasses the prefix cache, so every position has a hidden row.
165    pub collect_prompt_logprobs: Option<u8>,
166    /// 2026-09-25: Filled across prefill chunks: one entry per prompt position `i` scoring
167    /// `tokens[i + 1]`. The last prompt position, whose target is the first generated token, has
168    /// no entry.
169    pub prompt_logprobs: Vec<PromptTokenLogprob>,
170    /// 2026-09-25: The LoRA pool slot this sequence's requests select (not `slot_idx`, which is
171    /// the KV/SSM pool slot); `-1` selects the installed active adapter. Set by the scheduler at
172    /// prefill; the prefill, decode and verify paths route LoRA by it (`moe_lora_route`,
173    /// `upload_seq_slot_uniform`).
174    pub adapter_slot: i32,
175    /// 2026-09-25: The resolved LoRA pool slot this sequence holds a ref on, as returned by
176    /// `Model::acquire_adapter_slot`; `-1` when none. `free_sequence` releases exactly this index
177    /// and resets it to `-1`, so a rotation of the active adapter between prefill and finish
178    /// cannot release a different slot.
179    pub acquired_adapter_slot: i32,
180    /// 2026-09-25: NLLB per-request source-language token id; `0` uses the deployment default.
181    pub src_lang_id: u32,
182    /// 2026-09-25: NLLB per-request target-language token id; `0` uses the deployment default.
183    pub tgt_lang_id: u32,
184    /// 2026-09-25: NLLB beam count; `1` turns beam search off.
185    pub num_beams: u32,
186    /// 2026-09-25: NLLB length penalty: a hypothesis scores `sum_logprob / len^length_penalty`.
187    pub length_penalty: f32,
188    /// 2026-09-25: NLLB beam search: `true` stops once `num_beams` hypotheses have finished;
189    /// `false` also waits until no running beam can beat the worst finished one.
190    pub early_stopping: bool,
191}
192
193impl SequenceState {
194    /// 2026-09-25: A host-only sequence state: no GPU resources, no SSM slot, no layer states,
195    /// every counter zeroed. NLLB's `alloc_sequence` and the test models build on it;
196    /// `TransformerModel::alloc_sequence` writes its own literal. Other crates can construct a
197    /// `SequenceState` only through this, because `ssm_slot` is crate-private.
198    pub fn host_only(slot_idx: usize) -> Self {
199        SequenceState {
200            tokens: Vec::new(),
201            block_table: Vec::new(),
202            seq_len: 0,
203            layer_states: Vec::new(),
204            proposer_state: None,
205            slot_idx,
206            ssm_slot: None,
207            marconi_skip_to: 0,
208            marconi_exact_snap: None,
209            session_hash: 0,
210            lookup_tenant: None,
211            mtp_capture_gen: 0,
212            mtp_store_gen: 0,
213            adapter_id: 0,
214            chunked_prefill_meta: None,
215            cached_prefix_tokens: 0,
216            reused_prefix_tokens: 0,
217            cached_prefix_blocks: 0,
218            prefix_ref_tokens: Vec::new(),
219            prefix_lookup_applied: false,
220            tail_checkpoint_tokens: None,
221            prefix_lookup_skip: false,
222            kv_valid_tokens: 0,
223            last_decode_ckpt_block: 0,
224            prompt_len: 0,
225            disk_block_ids: Vec::new(),
226            disk_last_offloaded_per_layer: Vec::new(),
227            collect_prompt_logprobs: None,
228            prompt_logprobs: Vec::new(),
229            adapter_slot: -1,
230            acquired_adapter_slot: -1,
231            src_lang_id: 0,
232            tgt_lang_id: 0,
233            num_beams: 1,
234            length_penalty: 1.0,
235            early_stopping: false,
236        }
237    }
238
239    /// 2026-09-25: The SSM pool slot this sequence holds, or `None` without one. The scheduler
240    /// sorts decode batches by it.
241    #[inline]
242    pub fn ssm_slot_idx(&self) -> Option<usize> {
243        self.ssm_slot.as_ref().and_then(|g| g.idx())
244    }
245
246    /// 2026-09-25: Logical block index of `block_table[0]`: `disk_block_ids.len()` minus
247    /// `block_table.len()`, saturating. 0 without `--high-speed-swap`, where `disk_block_ids` is
248    /// empty.
249    #[inline]
250    pub fn hss_window_start(&self) -> usize {
251        self.disk_block_ids
252            .len()
253            .saturating_sub(self.block_table.len())
254    }
255
256    /// 2026-09-25: Physical HBM block of a logical block index, or `None` when that block has left
257    /// the HBM window (or is past the end of `block_table`).
258    #[inline]
259    pub fn physical_block_for(&self, abs_block_idx: usize) -> Option<u32> {
260        let ws = self.hss_window_start();
261        if abs_block_idx < ws {
262            return None;
263        }
264        self.block_table.get(abs_block_idx - ws).copied()
265    }
266}
267
268mod logprobs;
269mod model;
270pub use logprobs::*;
271pub use model::{
272    BeamReq, DECODE_BATCH_LADDER, EpCommandFailed, FeedSource, ForwardDisclosure, ForwardSelect,
273    Model, ModelAdapters, ModelCircuit, ModelDeviceFeed, ModelDraft, ModelEp, ModelForward,
274    ModelLifecycle, ModelLogits, ModelSsmState, ModelStreams, ModelVerify, ModelVision,
275    PrefixReclaim, RowMask, VerifyBatchedOpts, padded_batch_n,
276};