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}