Skip to main content

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}