ferrum_models/common/llm.rs
1//! `DecoderOnlyLLM` trait — the "model family" interface that every
2//! decoder-only language model (Qwen3 / Llama / Mistral / DeepSeek / ...)
3//! implements, independent of backend and weight format.
4//!
5//! `LlmExecutor` (living in `ferrum-engine`) holds a `Box<dyn DecoderOnlyLLM>`
6//! and adapts it to the `ModelExecutor` trait that the scheduler calls.
7
8use ferrum_interfaces::{
9 model_executor::{
10 KvSlotCapacitySnapshot, KvSlotRequest, KvSlotReservation, LogitsReturnPolicy,
11 },
12 RecurrentStateSpec,
13};
14use ferrum_types::{RequestId, Result, TokenId};
15
16/// Runtime configuration every decoder-only LLM must expose.
17///
18/// This is the *execution-facing* config — the bare minimum the surrounding
19/// engine needs (KV cache sizing, sampler vocab bounds, scheduler quotas).
20/// It deliberately does not include architecture details like `num_heads`
21/// or `intermediate_size`; those stay private to the model implementation.
22#[derive(Clone, Debug)]
23pub struct LlmRuntimeConfig {
24 pub hidden_size: usize,
25 pub num_layers: usize,
26 pub num_kv_heads: usize,
27 pub head_dim: usize,
28 pub vocab_size: usize,
29 pub max_seq_len: usize,
30}
31
32/// A decoder-only language model.
33///
34/// Contract:
35/// - `prefill` processes a batch of prompt tokens and returns logits for the
36/// *last* token, along with initializing whatever KV cache the model
37/// maintains internally (keyed by `cache_id`).
38/// - `decode` processes a single generated token at position `pos` and
39/// returns logits for the next step.
40/// - `release` frees the KV cache for a completed sequence.
41///
42/// Today the model owns its KV cache. Integration with `ferrum-kv`'s paged
43/// KV manager is a Phase D concern; the trait is kept minimal so it can
44/// evolve then without a full refactor.
45pub trait DecoderOnlyLLM: Send + Sync {
46 /// Runtime-facing configuration.
47 fn config(&self) -> &LlmRuntimeConfig;
48
49 /// Optional model-level cache metrics.
50 ///
51 /// Models with real paged-KV prefix reuse override this so the executor
52 /// and HTTP server can distinguish true KV reuse from product-level
53 /// prompt observability.
54 fn cache_metrics_snapshot(&self) -> Option<serde_json::Value> {
55 None
56 }
57
58 /// Optional runtime LoRA metrics.
59 fn lora_metrics_snapshot(&self) -> Option<serde_json::Value> {
60 None
61 }
62
63 /// Bind or clear a startup LoRA adapter for a model-side KV cache id.
64 ///
65 /// The executor calls this before prefill/decode based on request
66 /// metadata. Models that implement real LoRA inference override it and
67 /// keep the adapter scoped to `cache_id`; unsupported models return an
68 /// explicit error instead of silently serving the base model.
69 fn set_lora_adapter_for_cache(
70 &mut self,
71 cache_id: &str,
72 adapter: Option<crate::lora::ActiveLoraAdapter>,
73 ) -> std::result::Result<(), ferrum_types::FerrumError> {
74 let _ = cache_id;
75 if let Some(adapter) = adapter {
76 return Err(ferrum_types::FerrumError::unsupported(format!(
77 "LoRA inference is not supported by this model/backend for adapter {} at {}",
78 adapter.name,
79 adapter.path.display()
80 )));
81 }
82 Ok(())
83 }
84
85 /// Hint that an upcoming `prefill` / `decode` sequence on
86 /// `cache_id` will have at most `max_tokens` tokens per call. Lets
87 /// the model eagerly grow its internal scratch buffers AND allocate
88 /// the KV cache for `cache_id` so the first real `prefill` doesn't
89 /// have to allocate them on the hot path.
90 ///
91 /// Without this, on Qwen3-MoE's first prefill the timer captures:
92 /// • ~25 scratch MTLBuffers (residual / qkv / head-major / MoE
93 /// staging / batch-logits) — ~80-150 ms total alloc
94 /// • ~96 KV-cache MTLBuffers (K and V × 48 layers) — another
95 /// ~100-500 ms total alloc
96 ///
97 /// Combined that's the ~350 ms fixed overhead that made pp50 numbers
98 /// look 40% slower than pp512 for the same per-token compute.
99 ///
100 /// Default no-op — backends without resizable buffers ignore it.
101 fn prepare(&mut self, cache_id: &str, max_tokens: usize) {
102 let _ = (cache_id, max_tokens);
103 }
104
105 /// Hint that `cache_id` will need at most `capacity_hint` KV positions
106 /// for the whole request. This is separate from [`prepare`], whose
107 /// `max_tokens` parameter sizes per-call scratch buffers.
108 fn prepare_kv_capacity(&mut self, cache_id: &str, capacity_hint: usize) {
109 let _ = (cache_id, capacity_hint);
110 }
111
112 /// Per-cache KV capacity in tokens — the maximum sequence length any
113 /// single `cache_id` can grow to before `prefill` / `decode` would
114 /// overflow the pre-allocated K/V buffers.
115 ///
116 /// Honours `FERRUM_KV_CAPACITY` and clamps to the model's declared
117 /// `max_seq_len`. Callers (REPL, HTTP server, schedulers) should
118 /// pre-check this before extending a sequence; the model panics on
119 /// append-side overflow rather than silently corrupt the cache.
120 ///
121 /// Default returns `config().max_seq_len`. Models that allocate a
122 /// smaller window (most do, capped by `FERRUM_KV_CAPACITY` or the
123 /// 4096 default in `ensure_kv`) override this to surface the real
124 /// budget.
125 fn kv_capacity(&self) -> usize {
126 self.config().max_seq_len
127 }
128
129 /// Reserve model-owned KV slots before dispatching a prefill/decode forward.
130 ///
131 /// Paged-KV models override this to allocate physical blocks and update block
132 /// tables at the admission boundary. Non-paged models return `None`.
133 fn reserve_kv_slots(
134 &mut self,
135 _requests: &[KvSlotRequest],
136 ) -> std::result::Result<Option<KvSlotReservation>, ferrum_types::FerrumError> {
137 Ok(None)
138 }
139
140 /// Snapshot model-owned paged-KV capacity without allocating slots.
141 fn kv_slot_capacity_snapshot(&self) -> Option<KvSlotCapacitySnapshot> {
142 None
143 }
144
145 /// Recurrent-state allocation spec for state-space or hybrid models.
146 ///
147 /// Most decoder-only models are KV-only and return `None`. Models with
148 /// model-owned recurrent state can return a spec here so the engine can
149 /// apply admission/backpressure before dispatching a forward.
150 fn recurrent_state_spec(
151 &self,
152 _request_id: &RequestId,
153 _input_tokens: &[TokenId],
154 ) -> Result<Option<RecurrentStateSpec>> {
155 Ok(None)
156 }
157
158 /// Prefill the model with a prompt. Returns `[vocab_size]` logits for
159 /// the last prompt token.
160 fn prefill(&mut self, cache_id: &str, tokens: &[u32]) -> Vec<f32>;
161
162 /// Advance the model by one generated token. `pos` is the position of
163 /// `token` in the sequence (number of tokens already consumed so far).
164 /// Returns `[vocab_size]` logits for the next step.
165 fn decode(&mut self, cache_id: &str, token: u32, pos: u32) -> Vec<f32>;
166
167 /// Decode multiple concurrent requests in a single forward pass.
168 ///
169 /// Each entry is `(cache_id, token, pos)` — per-request state. Returns
170 /// one `[vocab_size]` logits vec per request in the SAME order.
171 ///
172 /// Default implementation loops `decode` sequentially. Backends that
173 /// implement true batched decode (one GEMM with m=batch, per-item
174 /// attention loop) override for concurrency speedup.
175 fn decode_batch(&mut self, batch: &[(String, u32, u32)]) -> Vec<Vec<f32>> {
176 batch
177 .iter()
178 .map(|(cid, tok, p)| self.decode(cid, *tok, *p))
179 .collect()
180 }
181
182 fn decode_batch_with_full_logits(
183 &mut self,
184 batch: &[(String, u32, u32)],
185 _force_full_logits: bool,
186 ) -> Vec<Vec<f32>> {
187 self.decode_batch(batch)
188 }
189
190 fn decode_batch_with_logits_policy(
191 &mut self,
192 batch: &[(String, u32, u32)],
193 _policies: &[LogitsReturnPolicy],
194 ) -> Vec<Vec<f32>> {
195 self.decode_batch_with_full_logits(batch, true)
196 }
197
198 /// Multi-position decode-verify: run a single forward over `tokens`
199 /// starting at the current KV end, append their K/V in place, and
200 /// return `seq_len * vocab_size` logits (row-major, position-first).
201 ///
202 /// Used by speculative decoding to collect N+1 verification logits
203 /// in one target pass instead of N+1 sequential decodes.
204 ///
205 /// Default falls back to a decode loop — slower but correct, lets
206 /// minor backends not reimplement the primitive.
207 fn forward_verify(&mut self, cache_id: &str, tokens: &[u32]) -> Vec<f32> {
208 let mut out = Vec::with_capacity(tokens.len() * self.config().vocab_size);
209 // cache.len before any decode in this batch — we can derive per-token
210 // position from it. Backends override this default for real batching.
211 let start_pos = 0u32; // placeholder; real impls know their own state
212 for (i, &tok) in tokens.iter().enumerate() {
213 out.extend_from_slice(&self.decode(cache_id, tok, start_pos + i as u32));
214 }
215 out
216 }
217
218 /// Unified mixed-batch forward (chunked-prefill API).
219 ///
220 /// Accepts a heterogeneous batch where each item is `(cache_id,
221 /// q_tokens, pos_offset, is_final_chunk)`:
222 /// - `q_tokens.len() == 1` & `is_final_chunk == true` → decode step
223 /// - `q_tokens.len() >= 1` & `is_final_chunk == true` → final
224 /// prefill chunk (returns logits for sampling)
225 /// - `q_tokens.len() >= 1` & `is_final_chunk == false` → intermediate
226 /// prefill chunk (advances KV state, returns None)
227 ///
228 /// `pos_offset` is the absolute KV position of the first q-token
229 /// for that sequence (0 for fresh prefill, prior `kv_len` for
230 /// continuing chunks or decode steps).
231 ///
232 /// Returns one entry per `items[i]`: `Some(logits)` iff
233 /// `is_final_chunk == true`, else `None`.
234 ///
235 /// Default implementation: returns `Err(unsupported)`. Concrete
236 /// models that support a true unified forward (single forward pass
237 /// over the concatenated `[M_total, hidden]` tensor + varlen
238 /// attention) override this. The engine's caller (`LlmExecutor`)
239 /// recognises the unsupported error and falls back to splitting
240 /// the batch into per-item `prefill()` and a single `decode_batch()`
241 /// — behaviour-preserving but doesn't get the chunked-prefill perf
242 /// win until the model exposes a real unified path.
243 #[allow(clippy::type_complexity)]
244 fn unified_forward(
245 &mut self,
246 _items: &[(String, Vec<u32>, usize, bool)],
247 ) -> std::result::Result<Vec<Option<Vec<f32>>>, ferrum_types::FerrumError> {
248 Err(ferrum_types::FerrumError::unsupported(
249 "unified_forward not implemented for this model",
250 ))
251 }
252
253 /// Unified mixed-batch forward with per-final-item logits return policies.
254 ///
255 /// The default preserves the historical trait behavior by returning full
256 /// logits from [`Self::unified_forward`]. Implementations may override this
257 /// to return model-side greedy-argmax sentinels (`vec![token_id]`) for
258 /// policy-compatible rows and avoid downloading full vocab logits.
259 #[allow(clippy::type_complexity)]
260 fn unified_forward_with_logits_policy(
261 &mut self,
262 items: &[(String, Vec<u32>, usize, bool)],
263 _policies: &[LogitsReturnPolicy],
264 ) -> std::result::Result<Vec<Option<Vec<f32>>>, ferrum_types::FerrumError> {
265 self.unified_forward(items)
266 }
267
268 /// Whether `unified_forward` can satisfy requests that require full logits.
269 ///
270 /// The trait contract returns logits for every final chunk, so the default
271 /// is true. Implementations with an opt-in sentinel/argmax return path must
272 /// override this while that path is active.
273 fn unified_forward_can_return_full_logits(&self) -> bool {
274 true
275 }
276
277 /// Release the KV cache for a completed sequence.
278 fn release(&mut self, cache_id: &str);
279
280 /// Truncate the KV cache for `cache_id` back to `new_len` positions.
281 /// Used by speculative decoding on rejection — roll draft/target KV
282 /// back to the last accepted position before the next iteration.
283 ///
284 /// Default implementation is a panic so backends that don't support
285 /// rollback fail loudly; implementations override this.
286 fn truncate_kv(&mut self, cache_id: &str, new_len: usize) {
287 let _ = (cache_id, new_len);
288 panic!("truncate_kv not implemented for this DecoderOnlyLLM");
289 }
290
291 /// Drop all cached state (useful for tests and hot-reload).
292 fn reset(&mut self) {}
293}