Skip to main content

rlx_runtime/
lm.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Generic language-model runner trait and shared builder.
17//!
18//! Until now every `rlx-<family>` model crate carried its own
19//! `*RunnerBuilder` (Qwen3RunnerBuilder, Llama32RunnerBuilder, …)
20//! with the same fields, the same `*ConfigSource { Embedded |
21//! JsonFile | Explicit(T) }` enum, and the same auto-packed-GGUF
22//! heuristic. This module hoists those shapes upstream so that:
23//!
24//!   1. `LmRunner` can live in `rlx-runtime` (today's home in
25//!      `rlx-cli` forces every model crate to take a dependency on
26//!      the CLI helper crate).
27//!   2. Per-family runners can `Deref` to / wrap [`LmRunnerBuilder`]
28//!      instead of redefining the same fields.
29//!   3. Downstream tools (`skill`, web apps) can talk to runners
30//!      through one trait without compiling in every model crate.
31//!
32//! The trait surface mirrors the existing `rlx_cli::LmRunner`. The
33//! CLI re-export is kept for backwards compat.
34
35use std::path::{Path, PathBuf};
36
37use crate::Device;
38
39/// A model-agnostic decode-session snapshot: the KV cache plus the token
40/// history that produced it. Used by the prompt cache to reuse a prefix.
41#[derive(Debug, Clone)]
42pub struct SessionSnapshot {
43    pub kv: crate::kv_cache::LayerKvCache,
44    pub tokens: Vec<u32>,
45}
46
47/// Minimal per-family runner interface used by `auto_dispatch` and
48/// the `rlx-text` / `skill` integration.
49///
50/// Implementations must be `Send` so the boxed trait can move across
51/// threads (e.g. when a server runs inference on a worker pool).
52/// `Sync` is intentionally not required — runners hold mutable
53/// per-call compile / cache state.
54pub trait LmRunner: Send {
55    /// Short family identifier (`"qwen3"`, `"llama32"`, `"gemma"`).
56    fn family(&self) -> &'static str;
57
58    /// LM head vocabulary size.
59    fn vocab_size(&self) -> usize;
60
61    /// Run prefill on `prompt_ids` and return last-token logits.
62    fn predict_logits(&mut self, prompt_ids: &[u32]) -> anyhow::Result<Vec<f32>>;
63
64    /// Generate up to `n_new` tokens after `prompt_ids` using greedy
65    /// (argmax) sampling. The default impl re-prefills on the full
66    /// context each step — per-family runners should override with
67    /// their cached decode fast path.
68    ///
69    /// `on_token` returns `true` to continue, `false` to stop.
70    fn generate(
71        &mut self,
72        prompt_ids: &[u32],
73        n_new: usize,
74        on_token: &mut dyn FnMut(u32) -> bool,
75    ) -> anyhow::Result<Vec<u32>> {
76        let mut context: Vec<u32> = prompt_ids.to_vec();
77        let mut produced: Vec<u32> = Vec::with_capacity(n_new);
78        for _ in 0..n_new {
79            let logits = self.predict_logits(&context)?;
80            let next = argmax_u32(&logits);
81            produced.push(next);
82            let cont = on_token(next);
83            context.push(next);
84            if !cont {
85                break;
86            }
87        }
88        Ok(produced)
89    }
90
91    /// Prefill `prompt_ids`, seed the decode KV cache, and return the
92    /// last-position logits `[vocab]`. Together with [`decode_logits`] this
93    /// gives a **host-driven** decode loop: the caller owns sampling, logit
94    /// bias, log-probs, and stop detection (used by the HTTP server). The
95    /// default reports unsupported so existing runners keep compiling.
96    fn prefill_logits(&mut self, _prompt_ids: &[u32]) -> anyhow::Result<Vec<f32>> {
97        Err(anyhow::anyhow!(
98            "{}: host-driven logits decode (prefill_logits) unsupported",
99            self.family()
100        ))
101    }
102
103    /// Feed one token, advance the KV cache, and return the next-position
104    /// logits `[vocab]`. See [`LmRunner::prefill_logits`]. Default: unsupported.
105    fn decode_logits(&mut self, _token: u32) -> anyhow::Result<Vec<f32>> {
106        Err(anyhow::anyhow!(
107            "{}: host-driven logits decode (decode_logits) unsupported",
108            self.family()
109        ))
110    }
111
112    /// Prefill `prompt` reusing a session snapshot that already covers its
113    /// first `reuse_len` tokens — only the suffix is processed. Returns the
114    /// last-position logits. The default ignores the snapshot and does a full
115    /// prefill, so callers always get correct logits even without reuse.
116    fn prefill_logits_reusing(
117        &mut self,
118        prompt: &[u32],
119        _snap: &SessionSnapshot,
120        _reuse_len: usize,
121    ) -> anyhow::Result<Vec<f32>> {
122        self.prefill_logits(prompt)
123    }
124
125    /// Snapshot this runner's decode session (KV cache + token history) for
126    /// prompt-cache reuse. `None` ⇒ the family doesn't support it.
127    fn export_session(&self) -> Option<SessionSnapshot> {
128        None
129    }
130
131    /// Restore a previously exported session so generation resumes from a
132    /// cached prefix. Returns `true` if applied.
133    fn restore_session(&mut self, _snap: &SessionSnapshot) -> bool {
134        false
135    }
136
137    /// Whether this runner supports multimodal (image+text) generation.
138    fn supports_multimodal(&self) -> bool {
139        false
140    }
141
142    /// Multimodal generation: prefill with text where image markers are
143    /// spliced with vision embeddings derived from `rgb`.
144    fn generate_multimodal(
145        &mut self,
146        _prompt: &str,
147        _rgb: &[u8],
148        _img_w: usize,
149        _img_h: usize,
150        _tokenizer: Option<&Path>,
151        _n_new: usize,
152        _on_token: &mut dyn FnMut(u32) -> bool,
153    ) -> anyhow::Result<Vec<u32>> {
154        Err(anyhow::anyhow!(
155            "this LmRunner does not support multimodal generation"
156        ))
157    }
158}
159
160fn argmax_u32(logits: &[f32]) -> u32 {
161    let mut best = 0usize;
162    let mut best_v = f32::NEG_INFINITY;
163    for (i, &v) in logits.iter().enumerate() {
164        if v > best_v {
165            best_v = v;
166            best = i;
167        }
168    }
169    best as u32
170}
171
172// ─────────────────────────────────────────────────────────────────
173// Weight format + config source
174// ─────────────────────────────────────────────────────────────────
175
176/// Weight file format. Detected from the file extension by default;
177/// the CLI accepts `--format` to override.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum WeightFormat {
180    Safetensors,
181    Gguf,
182}
183
184impl WeightFormat {
185    /// Infer format from a path extension.
186    pub fn from_path(path: &Path) -> anyhow::Result<Self> {
187        match path.extension().and_then(|s| s.to_str()) {
188            Some("safetensors") => Ok(Self::Safetensors),
189            Some("gguf") => Ok(Self::Gguf),
190            other => Err(anyhow::anyhow!(
191                "cannot autodetect weight format from extension {:?} on {:?}",
192                other,
193                path
194            )),
195        }
196    }
197
198    /// Parse CLI `--format` values (`safetensors` | `gguf`).
199    pub fn parse(s: &str) -> anyhow::Result<Self> {
200        match s {
201            "safetensors" => Ok(Self::Safetensors),
202            "gguf" => Ok(Self::Gguf),
203            other => Err(anyhow::anyhow!("expected safetensors|gguf, got {other}")),
204        }
205    }
206}
207
208/// Where to read a model config from.
209///
210/// Replaces the per-family `Qwen3ConfigSource`, `Llama32ConfigSource`,
211/// `GemmaConfigSource`, `Qwen35ConfigSource` enums.
212#[derive(Debug, Clone, Default)]
213pub enum ConfigSource<T> {
214    /// Read from GGUF metadata.
215    #[default]
216    Embedded,
217    /// Read from a HuggingFace `config.json` at this path.
218    JsonFile(PathBuf),
219    /// Use the supplied config object directly.
220    Explicit(T),
221}
222
223// ─────────────────────────────────────────────────────────────────
224// Sampling
225// ─────────────────────────────────────────────────────────────────
226
227/// Mirostat variant selection. See `crate::samplers::{MirostatV1, MirostatV2}`.
228#[derive(Debug, Default, Clone, Copy, PartialEq)]
229pub enum MirostatMode {
230    #[default]
231    Off,
232    V1,
233    V2,
234}
235
236/// Sampling parameters. Greedy when `temperature == 0` and no advanced
237/// sampler is enabled. All "advanced" knobs default to off / no-op so
238/// legacy callers see classic top-k/top-p/temperature behaviour.
239///
240/// `into_chain()` turns these flat fields into a `SamplerChain` that
241/// downstream backends can execute. Ordering follows llama.cpp's
242/// canonical chain (penalties → temperature → top-k → typical → top-p
243/// → top-n-σ → xtc → mirostat).
244#[derive(Debug, Clone)]
245pub struct SampleOpts {
246    pub temperature: f32,
247    pub top_p: f32,
248    pub top_k: Option<u32>,
249    pub repetition_penalty: f32,
250
251    // ── advanced samplers ────────────────────────────────────────
252    /// Dynamic temperature [min, max] gated by softmax entropy.
253    /// `None` ⇒ flat temperature only.
254    pub dynamic_temp: Option<(f32, f32)>,
255    /// Exponent used by [`crate::samplers::DynamicTemperature`].
256    pub dynamic_temp_exponent: f32,
257    /// Locally-typical sampling (Meister et al. 2022). 1.0 ⇒ off.
258    pub typical_p: f32,
259    /// Top-n-σ cutoff (Hewitt et al. 2024). 0 ⇒ off.
260    pub top_n_sigma: f32,
261    /// Min-p cutoff (Nguyen et al. 2024): keep tokens with prob ≥ `min_p` ·
262    /// p_max. 0 ⇒ off.
263    pub min_p: f32,
264    /// XTC: probability of dropping high-confidence top tokens.
265    pub xtc_threshold: f32,
266    pub xtc_prob: f32,
267    /// DRY repetition penalty knobs.
268    pub dry_multiplier: f32,
269    pub dry_base: f32,
270    pub dry_allowed_length: usize,
271    pub dry_max_ngram: usize,
272    pub dry_sequence_breakers: Vec<u32>,
273    /// Mirostat mode + parameters.
274    pub mirostat: MirostatMode,
275    pub mirostat_tau: f32,
276    pub mirostat_eta: f32,
277    pub mirostat_m: usize,
278    /// Frequency / presence penalties (OpenAI-style).
279    pub frequency_penalty: f32,
280    pub presence_penalty: f32,
281    pub repetition_window: usize,
282    /// Minimum tokens kept by top-p / typical (avoid one-token nucleus).
283    pub min_keep: usize,
284}
285
286impl Default for SampleOpts {
287    fn default() -> Self {
288        Self::greedy()
289    }
290}
291
292impl SampleOpts {
293    pub fn greedy() -> Self {
294        Self {
295            temperature: 0.0,
296            top_p: 1.0,
297            top_k: None,
298            repetition_penalty: 1.0,
299            dynamic_temp: None,
300            dynamic_temp_exponent: 1.0,
301            typical_p: 1.0,
302            top_n_sigma: 0.0,
303            min_p: 0.0,
304            xtc_threshold: 0.0,
305            xtc_prob: 0.0,
306            dry_multiplier: 0.0,
307            dry_base: 1.75,
308            dry_allowed_length: 2,
309            dry_max_ngram: 32,
310            dry_sequence_breakers: Vec::new(),
311            mirostat: MirostatMode::Off,
312            mirostat_tau: 5.0,
313            mirostat_eta: 0.1,
314            mirostat_m: 100,
315            frequency_penalty: 0.0,
316            presence_penalty: 0.0,
317            repetition_window: 64,
318            min_keep: 1,
319        }
320    }
321
322    pub fn nucleus(temperature: f32, top_p: f32) -> Self {
323        Self {
324            temperature,
325            top_p,
326            ..Self::greedy()
327        }
328    }
329
330    pub fn is_greedy(&self) -> bool {
331        self.temperature <= 0.0 && self.mirostat == MirostatMode::Off
332    }
333
334    /// True when only classic top-k/top-p/temperature are configured;
335    /// backends can take a cheap fast path in this case (e.g. the
336    /// existing `sample_row` CPU kernel) instead of building a chain.
337    pub fn is_classic(&self) -> bool {
338        self.dynamic_temp.is_none()
339            && self.typical_p >= 1.0
340            && self.top_n_sigma <= 0.0
341            && self.min_p <= 0.0
342            && self.xtc_prob <= 0.0
343            && self.dry_multiplier <= 0.0
344            && self.mirostat == MirostatMode::Off
345            && self.frequency_penalty == 0.0
346            && self.presence_penalty == 0.0
347            && (self.repetition_penalty - 1.0).abs() < f32::EPSILON
348    }
349
350    /// Build the `SamplerChain` corresponding to these options. The
351    /// returned chain is ready to drive `SamplerChain::sample` against
352    /// a logits row + history. Greedy decoding produces a chain with
353    /// one `Temperature{t:1e-6}` step (which collapses to argmax after
354    /// softmax) — callers that want true greedy can short-circuit via
355    /// `is_greedy()` before building the chain.
356    pub fn into_chain(&self) -> crate::samplers::SamplerChain {
357        use crate::samplers::*;
358        let mut b = SamplerChain::builder();
359
360        // 1. Penalties operate on raw logits, before any temperature
361        //    scaling — matches llama.cpp's order.
362        if (self.repetition_penalty - 1.0).abs() > f32::EPSILON
363            || self.frequency_penalty != 0.0
364            || self.presence_penalty != 0.0
365        {
366            b = b.push(RepetitionPenalty {
367                penalty: self.repetition_penalty,
368                frequency: self.frequency_penalty,
369                presence: self.presence_penalty,
370                last_n: self.repetition_window,
371            });
372        }
373        if self.dry_multiplier > 0.0 {
374            b = b.push(Dry {
375                multiplier: self.dry_multiplier,
376                base: self.dry_base,
377                allowed_length: self.dry_allowed_length,
378                max_ngram: self.dry_max_ngram,
379                sequence_breakers: self.dry_sequence_breakers.clone(),
380            });
381        }
382
383        // 2. Temperature (dynamic or static). Mirostat replaces both.
384        if self.mirostat == MirostatMode::Off {
385            if let Some((mn, mx)) = self.dynamic_temp {
386                b = b.push(DynamicTemperature {
387                    min: mn,
388                    max: mx,
389                    exponent: self.dynamic_temp_exponent,
390                });
391            } else if self.temperature > 0.0 && (self.temperature - 1.0).abs() > f32::EPSILON {
392                b = b.push(Temperature {
393                    t: self.temperature,
394                });
395            } else if self.temperature <= 0.0 {
396                b = b.push(Temperature { t: 1e-6 });
397            }
398        }
399
400        // 3. Filters: top-k → typical → top-p → top-n-sigma → xtc.
401        if let Some(k) = self.top_k {
402            if k > 0 {
403                b = b.push(TopK { k: k as usize });
404            }
405        }
406        if self.typical_p < 1.0 && self.typical_p > 0.0 {
407            b = b.push(TypicalP {
408                p: self.typical_p,
409                min_keep: self.min_keep,
410            });
411        }
412        if self.top_p < 1.0 && self.top_p > 0.0 {
413            b = b.push(TopP {
414                p: self.top_p,
415                min_keep: self.min_keep,
416            });
417        }
418        if self.min_p > 0.0 {
419            b = b.push(MinP {
420                p: self.min_p,
421                min_keep: self.min_keep,
422            });
423        }
424        if self.top_n_sigma > 0.0 {
425            b = b.push(TopNSigma {
426                n: self.top_n_sigma,
427            });
428        }
429        if self.xtc_prob > 0.0 && self.xtc_threshold > 0.0 {
430            b = b.push(Xtc {
431                threshold: self.xtc_threshold,
432                prob: self.xtc_prob,
433                min_keep: self.min_keep,
434            });
435        }
436
437        // 4. Mirostat (replaces softmax+sample at the end of the chain).
438        match self.mirostat {
439            MirostatMode::Off => {}
440            MirostatMode::V1 => {
441                b = b.push(MirostatV1 {
442                    tau: self.mirostat_tau,
443                    eta: self.mirostat_eta,
444                    m: self.mirostat_m,
445                });
446            }
447            MirostatMode::V2 => {
448                b = b.push(MirostatV2 {
449                    tau: self.mirostat_tau,
450                    eta: self.mirostat_eta,
451                });
452            }
453        }
454        b.build()
455    }
456}
457
458// ─────────────────────────────────────────────────────────────────
459// Shared builder
460// ─────────────────────────────────────────────────────────────────
461
462/// Auto-packed threshold: prefer K-quant packed loading for GGUF
463/// files >= this size. Cuts host memory ~6× on Q4_K_M models.
464pub const PACKED_GGUF_AUTO_THRESHOLD_BYTES: u64 = 256 * 1024 * 1024;
465
466/// Builder fields common to every per-family runner.
467///
468/// Per-family runner builders should wrap this and forward the
469/// methods (or use `#[rlx_runner]` from `rlx-macros`).
470#[derive(Debug, Clone)]
471pub struct LmRunnerBuilder<Cfg> {
472    pub weights: Option<PathBuf>,
473    pub config: ConfigSource<Cfg>,
474    pub device: Device,
475    pub max_seq: usize,
476    pub max_memory_gb: Option<f32>,
477    pub stream: bool,
478    pub sample: SampleOpts,
479    pub format: Option<WeightFormat>,
480    /// `None` = auto-detect (packed when GGUF ≥ 256 MB).
481    pub packed_weights: Option<bool>,
482    /// Substring for picking one GGUF in a directory (default `Q4_K_M`).
483    pub prefer_gguf: Option<String>,
484}
485
486impl<Cfg> Default for LmRunnerBuilder<Cfg> {
487    fn default() -> Self {
488        Self {
489            weights: None,
490            config: ConfigSource::Embedded,
491            device: Device::Cpu,
492            max_seq: 128,
493            max_memory_gb: None,
494            stream: true,
495            sample: SampleOpts::greedy(),
496            format: None,
497            packed_weights: None,
498            prefer_gguf: None,
499        }
500    }
501}
502
503impl<Cfg> LmRunnerBuilder<Cfg> {
504    pub fn new() -> Self {
505        Self::default()
506    }
507
508    pub fn weights<P: Into<PathBuf>>(mut self, p: P) -> Self {
509        self.weights = Some(p.into());
510        self
511    }
512
513    pub fn config(mut self, src: ConfigSource<Cfg>) -> Self {
514        self.config = src;
515        self
516    }
517
518    pub fn config_value(self, cfg: Cfg) -> Self {
519        self.config(ConfigSource::Explicit(cfg))
520    }
521
522    pub fn device(mut self, d: Device) -> Self {
523        self.device = d;
524        self
525    }
526
527    pub fn max_seq(mut self, n: usize) -> Self {
528        self.max_seq = n;
529        self
530    }
531
532    pub fn max_memory_gb(mut self, gb: f32) -> Self {
533        self.max_memory_gb = Some(gb);
534        self
535    }
536
537    pub fn stream(mut self, on: bool) -> Self {
538        self.stream = on;
539        self
540    }
541
542    pub fn sample(mut self, s: SampleOpts) -> Self {
543        self.sample = s;
544        self
545    }
546
547    pub fn format(mut self, fmt: WeightFormat) -> Self {
548        self.format = Some(fmt);
549        self
550    }
551
552    pub fn packed_weights(mut self, on: bool) -> Self {
553        self.packed_weights = Some(on);
554        self
555    }
556
557    pub fn prefer_gguf<S: Into<String>>(mut self, q: S) -> Self {
558        self.prefer_gguf = Some(q.into());
559        self
560    }
561
562    /// Resolve the format using the explicit override or the file extension.
563    pub fn resolved_format(&self) -> anyhow::Result<WeightFormat> {
564        match self.format {
565            Some(f) => Ok(f),
566            None => {
567                let p = self
568                    .weights
569                    .as_deref()
570                    .ok_or_else(|| anyhow::anyhow!("weights path required"))?;
571                WeightFormat::from_path(p)
572            }
573        }
574    }
575
576    /// Determine whether packed GGUF loading should be used. Honors an
577    /// explicit override; otherwise auto-enables for GGUF files at or
578    /// above [`PACKED_GGUF_AUTO_THRESHOLD_BYTES`].
579    pub fn resolved_packed(&self, fmt: WeightFormat) -> bool {
580        match self.packed_weights {
581            Some(b) => b,
582            None => {
583                if !matches!(fmt, WeightFormat::Gguf) {
584                    return false;
585                }
586                self.weights
587                    .as_deref()
588                    .and_then(|p| std::fs::metadata(p).ok())
589                    .map(|m| m.len() >= PACKED_GGUF_AUTO_THRESHOLD_BYTES)
590                    .unwrap_or(false)
591            }
592        }
593    }
594}
595
596// ─────────────────────────────────────────────────────────────────
597// Model registry (auto-dispatch by path)
598// ─────────────────────────────────────────────────────────────────
599
600/// Family-routing entry: a short name + a probe closure that returns
601/// `true` for files this family should handle.
602///
603/// Registered at process start by `register_model` (or by a
604/// `#[rlx_runner]`-generated `inventory` entry). [`auto_runner_name`]
605/// walks the registry and returns the first matching family.
606pub struct ModelRegistration {
607    pub family: &'static str,
608    pub description: &'static str,
609    /// `(arch_str_lower_case, path) -> bool`. `arch_str_lower_case` is
610    /// the GGUF `general.architecture` (`""` for safetensors); `path`
611    /// is the concrete weights file. Implementations should return
612    /// `true` if the family owns this file.
613    pub matches: fn(arch: &str, path: &Path) -> bool,
614}
615
616inventory::collect!(ModelRegistration);
617
618/// Re-export of `inventory` so the `register_lm_runner!` proc-macro
619/// can call `::rlx_runtime::lm::inventory::submit!` without forcing
620/// every caller to add `inventory` to their Cargo.toml.
621pub extern crate inventory;
622
623/// Iterate over every registered family.
624pub fn registered_models() -> impl Iterator<Item = &'static ModelRegistration> {
625    inventory::iter::<ModelRegistration>.into_iter()
626}
627
628/// Find the family that claims `(arch, path)`.
629pub fn auto_runner_name(arch: &str, path: &Path) -> Option<&'static str> {
630    let arch_lc = arch.to_ascii_lowercase();
631    registered_models()
632        .find(|m| (m.matches)(&arch_lc, path))
633        .map(|m| m.family)
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    #[test]
641    fn config_source_default_is_embedded() {
642        let s: ConfigSource<()> = ConfigSource::default();
643        assert!(matches!(s, ConfigSource::Embedded));
644    }
645
646    #[test]
647    fn builder_defaults_match_legacy_runners() {
648        let b: LmRunnerBuilder<()> = LmRunnerBuilder::new();
649        assert_eq!(b.device, Device::Cpu);
650        assert_eq!(b.max_seq, 128);
651        assert!(b.stream);
652        assert!(b.sample.is_greedy());
653        assert!(b.packed_weights.is_none());
654    }
655
656    #[test]
657    fn packed_auto_size_threshold() {
658        let mut b: LmRunnerBuilder<()> = LmRunnerBuilder::new();
659        b.weights = Some("/nonexistent/file.gguf".into());
660        // Missing file → auto returns false (no metadata).
661        assert!(!b.resolved_packed(WeightFormat::Gguf));
662        // Explicit override wins.
663        b.packed_weights = Some(true);
664        assert!(b.resolved_packed(WeightFormat::Gguf));
665        // Non-GGUF never auto-packs.
666        b.packed_weights = None;
667        assert!(!b.resolved_packed(WeightFormat::Safetensors));
668    }
669}