ffai_argus/decode.rs
1//! The decode loop: `inputs_embeds` -> generated tokens, greedy, on candle.
2//!
3//! Step 5 of `docs/plans/argus-launch-plan.md`, whose gate is *"greedy decode
4//! matches reference greedy decode"*.
5//!
6//! # Why a `candle` loop rather than a `mistral.rs` call
7//!
8//! The plan offers both ("decode loop (or `mistral.rs` call)"), and the house
9//! doctrine's rule is *don't hand-roll an LLM SERVING loop on raw `candle`*,
10//! because paging, quantization, sampling and constrained decoding are solved
11//! there. That rule is about serving. What Argus needs here is a **greedy
12//! prefill plus one-token-at-a-time decode for a single sequence** — which is
13//! precisely what `mercury::asr::whisper_candle` already does in this tree,
14//! and which `candle` supports directly:
15//!
16//! * `candle_transformers::models::llama` is `SmolVLM`'s text tower verbatim —
17//! `text_config.model_type` is literally `llama`;
18//! * `Llama::forward_input_embed` takes INJECTED embeddings, which is exactly
19//! what a VLM needs and what `forward` (which embeds ids itself) cannot do;
20//! * `llama::Cache` is the `KV` cache.
21//!
22//! Three things decided it:
23//!
24//! 1. **Publication.** `mistralrs` is on crates.io at 0.8.1, but the version
25//! proven to serve `SmolVLM` here is 0.9.0 from git — and `cargo publish`
26//! refuses a git dependency outright. `ffai-media`'s manifest records that
27//! this exact constraint once made every downstream `FFai` crate
28//! unpublishable and had to be undone. Taking a git dependency now would
29//! re-import that problem into `ffai-argus`.
30//! 2. **It composes with work already gated.** Steps 3 and 4 produce
31//! `inputs_embeds` that the reference decoder turns into 32/32 identical
32//! tokens. The only missing piece is the loop itself.
33//! 3. **Size.** This is ~100 lines against a large optional dependency, for a
34//! 256M model.
35//!
36//! **`mistral.rs` is not rejected** — it remains the documented path for the
37//! serving concerns it owns (quantized weights, grammar-constrained JSON
38//! decoding — §2.3's v2 item), it is already proven to load and generate for
39//! this checkpoint (Gate 1.2), and `ffai-argus` keeps the reserved
40//! `mistralrs-backend` feature for it.
41//!
42//! # The weight-name adapter
43//!
44//! `SmolVLM` stores its text tower under `model.text_model.*` while `candle`'s
45//! `Llama::load` looks for `model.*`; `lm_head` matches on both sides. That is
46//! handled with `VarBuilder::rename_f`, which rewrites the LOOKUP rather than
47//! copying tensors — the checkpoint stays memory-mapped.
48
49use candle_core::{DType, Device, Result as CandleResult, Tensor};
50use candle_transformers::generation::{LogitsProcessor, Sampling};
51use ffai_core::engine::Decoding;
52use candle_nn::VarBuilder;
53use candle_transformers::models::llama;
54
55/// Where the time went inside one generation.
56///
57/// Milliseconds, because that is the unit a reader can compare against their
58/// own patience. Per-STEP rather than an average: the first token after a
59/// prefill behaves differently from the fiftieth, and an average of the two
60/// describes neither.
61#[derive(Debug, Clone, Default, PartialEq)]
62pub struct DecodeTrace {
63 /// One forward pass over the entire prompt.
64 pub prefill_ms: f64,
65 /// Prompt length in tokens — for a VLM, mostly image tokens.
66 pub prompt_tokens: usize,
67 /// One entry per generated token.
68 pub steps_ms: Vec<f64>,
69}
70
71impl DecodeTrace {
72 /// Total time in the decode loop, excluding prefill.
73 #[must_use]
74 pub fn decode_ms(&self) -> f64 {
75 self.steps_ms.iter().sum()
76 }
77
78 /// Generated tokens per second, prefill EXCLUDED.
79 ///
80 /// Excluded because including it makes the rate depend on the size of the
81 /// picture, which is not what "tokens per second" means to anyone reading
82 /// it. The prefill is reported separately and in full.
83 #[must_use]
84 pub fn tokens_per_sec(&self) -> f64 {
85 let ms = self.decode_ms();
86 if ms <= 0.0 {
87 return 0.0;
88 }
89 self.steps_ms.len() as f64 / (ms / 1e3)
90 }
91}
92
93/// `SmolVLM`'s text tower plus its `KV` cache.
94pub struct TextDecoder {
95 /// candle's tower — loaded ONLY when it is the one that will run.
96 ///
97 /// # This used to be loaded unconditionally, and it cost 540 MB
98 ///
99 /// The comment here previously claimed that holding both towers "costs
100 /// address space rather than memory, because the weights are mmapped".
101 /// That is wrong: `VarBuilder::get_unchecked` calls candle's `convert`,
102 /// which **allocates a tensor and copies** out of the mapping — the same
103 /// fact that motivated `ffai-carmenta`'s SVTR weight cache. Two towers
104 /// therefore meant two full f32 copies of a 135M-parameter model.
105 ///
106 /// Measured: the footprint gate went from **PASS 0.71x** to **FAIL 1.20x**
107 /// when our tower landed, steady resident rising 1309 -> 2126 MiB. A second
108 /// copy of the text weights is 540 MB of that.
109 model: Option<llama::Llama>,
110 cache: llama::Cache,
111 /// A pristine copy of the cache, cloned back over `cache` before every
112 /// generation.
113 ///
114 /// `candle`'s `llama::Cache` has no `reset` and its `kvs` are private. An
115 /// engine that generates twice from one `&self` would otherwise have its
116 /// SECOND caption prefixed by the first one's keys and values — the same
117 /// image and prompt producing a different answer depending on what ran
118 /// before it. Cloning is cheap: the cos/sin tables are `Tensor`s (an `Arc`
119 /// bump) and a pristine `kvs` is a vector of `None`.
120 pristine: llama::Cache,
121 config: llama::Config,
122 device: Device,
123 /// Our own tower — the one that actually runs, unless the toggle says
124 /// otherwise.
125 ///
126 /// candle's `llama` stays loaded beside it as the ORACLE: the A/B in
127 /// `examples/text_ab` and the `FFAI_ARGUS_CANDLE_TEXT=1` arm both need a
128 /// reference in the same process, and a reference you cannot run is not a
129 /// reference. The weights are mmapped, so holding both costs address
130 /// space rather than memory.
131 ours: Option<crate::text::TextTower>,
132}
133
134/// Force candle's text tower instead of ours.
135///
136/// Read ONCE and cached — a toggle inside a per-element loop is a
137/// vectorisation barrier, which is a mistake this workspace has already paid
138/// for once (`ffai-diana`'s `silu`, 1.92x).
139fn use_candle_text() -> bool {
140 use std::sync::atomic::{AtomicU8, Ordering};
141 static C: AtomicU8 = AtomicU8::new(u8::MAX);
142 match C.load(Ordering::Relaxed) {
143 u8::MAX => {
144 let on = std::env::var("FFAI_ARGUS_CANDLE_TEXT").is_ok_and(|v| v == "1");
145 C.store(u8::from(on), Ordering::Relaxed);
146 on
147 }
148 v => v == 1,
149 }
150}
151
152impl TextDecoder {
153 /// Load the text tower from a checkpoint.
154 ///
155 /// # Errors
156 /// Propagates `candle`'s load errors; a missing tensor names itself, which
157 /// is what a wrong prefix produces.
158 pub fn load(weights: &std::path::Path, config_json: &str, device: &Device) -> Result<Self, String> {
159 // SAFETY: the mapped file is owned by the model cache and is not
160 // mutated while this process holds it.
161 let vb = unsafe {
162 VarBuilder::from_mmaped_safetensors(std::slice::from_ref(&weights), DType::F32, device)
163 }
164 .map_err(|e| format!("load {}: {e}", weights.display()))?;
165 Self::load_vb(vb, config_json, device)
166 }
167
168 /// Build the decoder from a `VarBuilder` the caller already has.
169 ///
170 /// The path constructor above is written in terms of this, so a browser
171 /// and a server build the same decoder from the same tensors.
172 ///
173 /// **One builder, cloned — not two loads.** The path version used to map
174 /// the file twice, once renamed for candle's tower and once raw for ours.
175 /// A `VarBuilder` is cheap to clone (its backend is shared), and on wasm a
176 /// second load would mean a second COPY of the checkpoint in a 32-bit
177 /// address space that is already the binding constraint.
178 pub fn load_vb(
179 vb: VarBuilder<'static>,
180 config_json: &str,
181 device: &Device,
182 ) -> Result<Self, String> {
183 let config = text_config_from_json(config_json)?;
184 // Our tower reads the checkpoint's own names, so it keeps a builder
185 // WITHOUT the rename applied below.
186 let raw = vb.clone();
187
188 // Rewrite the lookup, do not copy the weights: candle asks for
189 // `model.embed_tokens`, the checkpoint stores
190 // `model.text_model.embed_tokens`, and `lm_head` is the same on both
191 // sides. Renaming the QUERY keeps the mmap intact.
192 let vb = vb.rename_f(|name: &str| {
193 if let Some(rest) = name.strip_prefix("model.") {
194 format!("model.text_model.{rest}")
195 } else {
196 name.to_string()
197 }
198 });
199
200 let cache = llama::Cache::new(true, DType::F32, &config, device)
201 .map_err(|e| format!("kv cache: {e}"))?;
202 // Deferred: built below only if ours could not be, so the losing
203 // tower's weights are never materialised.
204 let load_candle = |vb: VarBuilder<'static>| {
205 llama::Llama::load(vb, &config).map_err(|e| format!("text tower: {e}"))
206 };
207
208 let v: serde_json::Value =
209 serde_json::from_str(config_json).map_err(|e| format!("config.json: {e}"))?;
210 let t = v.get("text_config").unwrap_or(&v);
211 let gu = |k: &str, d: u64| t.get(k).and_then(serde_json::Value::as_u64).unwrap_or(d);
212 let gfl = |k: &str, d: f64| t.get(k).and_then(serde_json::Value::as_f64).unwrap_or(d);
213 let heads = gu("num_attention_heads", 9) as usize;
214 let hidden = gu("hidden_size", 576) as usize;
215 let cfg = crate::text::Cfg {
216 layers: gu("num_hidden_layers", 30) as usize,
217 hidden,
218 heads,
219 kv_heads: gu("num_key_value_heads", 3) as usize,
220 head_dim: hidden / heads.max(1),
221 inter: gu("intermediate_size", 1536) as usize,
222 eps: gfl("rms_norm_eps", 1e-5),
223 rope_theta: gfl("rope_theta", 100_000.0) as f32,
224 max_pos: gu("max_position_embeddings", 8192) as usize,
225 };
226 // A tower that fails to load is a fallback to candle's, not an error:
227 // the engine's contract is a caption, and candle's path is gated too.
228 let ours = if use_candle_text() {
229 None
230 } else {
231 crate::text::TextTower::load(&raw, cfg, device).ok()
232 };
233 // EXACTLY ONE tower is resident. `ours` is preferred; candle's is built
234 // only when ours is absent — because the toggle asked for it, or
235 // because ours failed to load and the engine must still caption.
236 let model = if ours.is_some() { None } else { Some(load_candle(vb)?) };
237
238 Ok(Self {
239 model,
240 pristine: cache.clone(),
241 cache,
242 config,
243 device: device.clone(),
244 ours,
245 })
246 }
247
248 /// Load with candle's tower forced — the ORACLE arm.
249 ///
250 /// `examples/text_ab` needs both implementations live in ONE process to
251 /// compare them. The env toggle cannot do that: it is read once and cached
252 /// (deliberately — a toggle re-read per call is the barrier
253 /// `ffai-diana`'s `silu` paid 1.92x for). Without this constructor the A/B
254 /// silently compared our tower against itself and reported a max logit
255 /// delta of exactly 0.000e0, which is what a broken instrument looks like
256 /// when it looks like a pass.
257 ///
258 /// # Errors
259 /// Same as [`Self::load`].
260 pub fn load_reference(
261 weights: &std::path::Path,
262 config_json: &str,
263 device: &Device,
264 ) -> Result<Self, String> {
265 // Ask for candle's tower up front rather than loading ours and then
266 // discarding it — dropping a tower still pays for having built it.
267 // SAFETY: same mapped file, same ownership as `load`.
268 let raw = unsafe {
269 VarBuilder::from_mmaped_safetensors(
270 std::slice::from_ref(&weights),
271 DType::F32,
272 device,
273 )
274 }
275 .map_err(|e| format!("load {}: {e}", weights.display()))?;
276 let config = text_config_from_json(config_json)?;
277 let vb = raw.rename_f(|name: &str| {
278 if let Some(rest) = name.strip_prefix("model.") {
279 format!("model.text_model.{rest}")
280 } else {
281 name.to_string()
282 }
283 });
284 let cache = llama::Cache::new(true, DType::F32, &config, device)
285 .map_err(|e| format!("kv cache: {e}"))?;
286 let model = llama::Llama::load(vb, &config).map_err(|e| format!("text tower: {e}"))?;
287 Ok(Self {
288 model: Some(model),
289 pristine: cache.clone(),
290 cache,
291 config,
292 device: device.clone(),
293 ours: None,
294 })
295 }
296
297 /// Drop everything the previous generation left in the `KV` cache.
298 ///
299 /// [`Self::generate`] calls this unconditionally at its top, so a caller
300 /// cannot forget it.
301 pub fn reset(&mut self) {
302 self.cache = self.pristine.clone();
303 if let Some(t) = self.ours.as_mut() {
304 t.reset();
305 }
306 }
307
308 /// Logits for the LAST position, given a slice of the sequence.
309 ///
310 /// `index_pos` is where this slice starts in the whole sequence — 0 for the
311 /// prefill, then the running length. Getting it wrong does not error: `RoPE`
312 /// simply rotates by the wrong amount and the output degrades, which is the
313 /// same silent class as a mis-assembled prompt.
314 pub fn forward_embeds(&mut self, embeds: &Tensor, index_pos: usize) -> CandleResult<Tensor> {
315 if let Some(t) = self.ours.as_mut() {
316 return t.forward(embeds, index_pos);
317 }
318 let Some(m) = self.model.as_ref() else {
319 return Err(candle_core::Error::Msg("no text tower loaded".into()));
320 };
321 m.forward_input_embed(embeds, index_pos, &mut self.cache)
322 }
323
324 /// Embed token ids through the tower's own table.
325 pub fn embed(&self, ids: &Tensor) -> CandleResult<Tensor> {
326 if let Some(t) = self.ours.as_ref() {
327 return t.embed(ids);
328 }
329 let Some(m) = self.model.as_ref() else {
330 return Err(candle_core::Error::Msg("no text tower loaded".into()));
331 };
332 m.embed(ids)
333 }
334
335 /// Greedy generation from a prefilled embedding sequence.
336 ///
337 /// Deterministic by construction — `argmax`, no sampling, no seed needed.
338 /// That is the plan's §2 Gate 2 requirement (`Decoding::Greedy` is the
339 /// default and the only variant that needs no seed) and it is also what
340 /// makes step 5's gate a token-equality check rather than a distribution
341 /// comparison.
342 ///
343 /// # Errors
344 /// Propagates `candle` errors from the forward passes.
345 pub fn generate_greedy(
346 &mut self,
347 inputs_embeds: &Tensor,
348 max_new_tokens: usize,
349 stop_ids: &[u32],
350 ) -> CandleResult<Vec<u32>> {
351 self.generate(inputs_embeds, max_new_tokens, stop_ids, &Decoding::Greedy, None)
352 }
353
354 /// Generation under any [`Decoding`] strategy.
355 ///
356 /// [`Decoding::Greedy`] takes the `argmax` path and needs no seed;
357 /// [`Decoding::Sampled`] builds `candle`'s `LogitsProcessor` from the
358 /// caller's seed, so two runs with the same seed produce the same text.
359 /// That is what Gate 2 bought by putting the seed in the TYPE rather than
360 /// in an engine's private state.
361 ///
362 /// # Errors
363 /// Propagates `candle` errors from the forward passes.
364 pub fn generate(
365 &mut self,
366 inputs_embeds: &Tensor,
367 max_new_tokens: usize,
368 stop_ids: &[u32],
369 decoding: &Decoding,
370 repetition_penalty: Option<f32>,
371 ) -> CandleResult<Vec<u32>> {
372 self.generate_traced(
373 inputs_embeds,
374 max_new_tokens,
375 stop_ids,
376 decoding,
377 repetition_penalty,
378 None,
379 )
380 }
381
382 /// [`Self::generate`], optionally filling in a per-step timing trace.
383 ///
384 /// The split it records is the one that matters for understanding VLM
385 /// latency: **prefill is one pass over the whole prompt, decode is one
386 /// pass per token.** For a VLM the prompt is mostly image tokens — 1088 of
387 /// them for a single split still — so prefill is a large, fixed cost that
388 /// has nothing to do with how long the answer is. Reporting a single
389 /// "generation" number hides that, and hiding it is how people conclude
390 /// the decoder is slow when the picture is what cost them.
391 ///
392 /// # Errors
393 /// Propagates `candle` errors from the forward passes.
394 pub fn generate_traced(
395 &mut self,
396 inputs_embeds: &Tensor,
397 max_new_tokens: usize,
398 stop_ids: &[u32],
399 decoding: &Decoding,
400 repetition_penalty: Option<f32>,
401 mut trace: Option<&mut DecodeTrace>,
402 ) -> CandleResult<Vec<u32>> {
403 // Unconditional, at the top. A stale cache presents as "the second
404 // caption is wrong", which is not a symptom anyone attributes to a
405 // cache.
406 self.reset();
407
408 let mut sampler = match decoding {
409 Decoding::Greedy => None,
410 Decoding::Sampled {
411 temperature,
412 top_p,
413 top_k,
414 seed,
415 } => {
416 let t = f64::from(*temperature);
417 Some(LogitsProcessor::from_sampling(
418 *seed,
419 match (top_k, top_p) {
420 (Some(k), Some(p)) => Sampling::TopKThenTopP {
421 k: *k,
422 p: f64::from(*p),
423 temperature: t,
424 },
425 (Some(k), None) => Sampling::TopK {
426 k: *k,
427 temperature: t,
428 },
429 (None, Some(p)) => Sampling::TopP {
430 p: f64::from(*p),
431 temperature: t,
432 },
433 (None, None) => Sampling::All { temperature: t },
434 },
435 ))
436 }
437 };
438
439 let (_b, prefill_len, _d) = inputs_embeds.dims3()?;
440 // Prefill: the whole prompt in one pass, populating the KV cache.
441 let t_prefill = crate::clock::Instant::now();
442 let mut logits = self.forward_embeds(inputs_embeds, 0)?;
443 if let Some(t) = trace.as_deref_mut() {
444 t.prefill_ms = t_prefill.elapsed().as_secs_f64() * 1e3;
445 t.prompt_tokens = prefill_len;
446 }
447 let mut out: Vec<u32> = Vec::with_capacity(max_new_tokens);
448 for pos in prefill_len..prefill_len + max_new_tokens {
449 let t_step = crate::clock::Instant::now();
450 let mut step = logits.flatten_all()?;
451 // A LOGIT transform, so it applies to greedy too — which is why it
452 // is not a field of `Decoding::Sampled`. Small models loop under
453 // greedy more than under sampling, not less.
454 if let Some(p) = repetition_penalty {
455 // SITE-REVIEWED false positive. This is not an equality
456 // test on a computed float, it is a SENTINEL check: 1.0 is the
457 // documented "penalty disabled" default and arrives as that
458 // literal from the config. An epsilon window would make a
459 // penalty of 1.0000001 silently do nothing.
460 #[allow(clippy::float_cmp)]
461 if p != 1.0 && !out.is_empty() {
462 step = candle_transformers::utils::apply_repeat_penalty(&step, p, &out)?;
463 }
464 }
465 let next = match sampler.as_mut() {
466 Some(s) => s.sample(&step)?,
467 None => argmax(&step)?,
468 };
469 if stop_ids.contains(&next) {
470 break;
471 }
472 out.push(next);
473 // One token at a time, cache-appended — the step the KV cache
474 // exists for. Re-running the whole prefix each step would be
475 // correct and quadratic.
476 let ids = Tensor::new(&[next], &self.device)?.unsqueeze(0)?;
477 let emb = self.embed(&ids)?;
478 logits = self.forward_embeds(&emb, pos)?;
479 if let Some(t) = trace.as_deref_mut() {
480 t.steps_ms.push(t_step.elapsed().as_secs_f64() * 1e3);
481 }
482 }
483 Ok(out)
484 }
485
486 #[must_use]
487 pub const fn config(&self) -> &llama::Config {
488 &self.config
489 }
490}
491
492/// Argmax over a `(1, vocab)` or `(vocab,)` logits tensor.
493///
494/// Written out rather than using a sort: it is O(vocab) against O(v log v),
495/// runs once per generated token, and — more importantly — makes the tie rule
496/// explicit. `>` keeps the FIRST maximum, which is what `torch.argmax` does;
497/// `>=` would keep the last and could disagree with the reference on an exact
498/// tie. Ties are rare and this is exactly the kind of detail that produces a
499/// one-token difference nobody can explain later.
500fn argmax(logits: &Tensor) -> CandleResult<u32> {
501 let v = logits.flatten_all()?.to_vec1::<f32>()?;
502 let mut best = 0usize;
503 let mut best_v = f32::NEG_INFINITY;
504 for (i, &x) in v.iter().enumerate() {
505 if x > best_v {
506 best_v = x;
507 best = i;
508 }
509 }
510 u32::try_from(best).map_err(|e| candle_core::Error::Msg(format!("token id overflow: {e}")))
511}
512
513/// Read `text_config` out of a checkpoint's config.json into `candle`'s shape.
514///
515/// # Errors
516/// If the JSON is malformed or `text_config` is missing/incompatible.
517pub fn text_config_from_json(config_json: &str) -> Result<llama::Config, String> {
518 let v: serde_json::Value =
519 serde_json::from_str(config_json).map_err(|e| format!("config.json: {e}"))?;
520 let tc = v
521 .get("text_config")
522 .ok_or("config.json has no text_config")?;
523 let get_usize = |k: &str| -> Result<usize, String> {
524 tc.get(k)
525 .and_then(serde_json::Value::as_u64)
526 .map(|x| x as usize)
527 .ok_or_else(|| format!("text_config has no {k}"))
528 };
529 // Built field by field rather than deserialized whole: SmolVLM's
530 // `text_config` carries ~70 keys of HF generation boilerplate that
531 // candle's `Config` has no fields for, and a strict deserialize would
532 // reject the lot. Naming what we read also makes it visible WHICH
533 // properties the decoder depends on.
534 Ok(llama::Config {
535 hidden_size: get_usize("hidden_size")?,
536 intermediate_size: get_usize("intermediate_size")?,
537 vocab_size: get_usize("vocab_size")?,
538 num_hidden_layers: get_usize("num_hidden_layers")?,
539 num_attention_heads: get_usize("num_attention_heads")?,
540 num_key_value_heads: get_usize("num_key_value_heads")
541 .or_else(|_| get_usize("num_attention_heads"))?,
542 rms_norm_eps: tc
543 .get("rms_norm_eps")
544 .and_then(serde_json::Value::as_f64)
545 .unwrap_or(1e-5),
546 rope_theta: tc
547 .get("rope_theta")
548 .and_then(serde_json::Value::as_f64)
549 .unwrap_or(10000.0) as f32,
550 bos_token_id: tc.get("bos_token_id").and_then(serde_json::Value::as_u64).map(|x| x as u32),
551 eos_token_id: tc
552 .get("eos_token_id")
553 .and_then(serde_json::Value::as_u64)
554 .map(|x| llama::LlamaEosToks::Single(x as u32)),
555 rope_scaling: None,
556 max_position_embeddings: get_usize("max_position_embeddings").unwrap_or(8192),
557 tie_word_embeddings: tc
558 .get("tie_word_embeddings")
559 .and_then(serde_json::Value::as_bool)
560 .unwrap_or(false),
561 use_flash_attn: false,
562 })
563}