Skip to main content

rlx_voxtral/
runner.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//! Voxtral end-to-end runner — mel → audio encoder → projector → fused Llama decode.
17
18use crate::audio::{MelSpectrogram, load_wav_mono_f32, pcm_to_mel};
19use crate::config::VoxtralConfig;
20use crate::embed::{
21    argmax_token, fuse_inputs_embeds, transcription_prompt_ids, validate_prompt_audio_match,
22};
23use crate::encoder::build_voxtral_encoder_built;
24use crate::lm_flow::{build_voxtral_decode_built, build_voxtral_prefill_built};
25use crate::load::{VoxtralWeightStore, resolve_model_dir};
26use crate::projector::build_voxtral_projector_built;
27use crate::weights::VoxtralWeightPrefix;
28use anyhow::{Context, Result, ensure};
29use rlx_core::compact_bucketed_kv_buffer;
30use rlx_core::flow_util::compile_built;
31use rlx_core::validate_standard_device;
32use rlx_runtime::Device;
33use rlx_runtime::attn_mask::bucket_decode_mask;
34use std::path::{Path, PathBuf};
35
36#[derive(Debug, Clone, Default)]
37pub struct VoxtralRunnerBuilder {
38    weights: Option<PathBuf>,
39    config_path: Option<PathBuf>,
40    config: Option<VoxtralConfig>,
41    device: Option<Device>,
42    max_new_tokens: usize,
43}
44
45impl VoxtralRunnerBuilder {
46    pub fn weights(mut self, path: impl Into<PathBuf>) -> Self {
47        self.weights = Some(path.into());
48        self
49    }
50
51    pub fn config_path(mut self, path: impl Into<PathBuf>) -> Self {
52        self.config_path = Some(path.into());
53        self
54    }
55
56    pub fn config(mut self, cfg: VoxtralConfig) -> Self {
57        self.config = Some(cfg);
58        self
59    }
60
61    pub fn device(mut self, d: Device) -> Self {
62        self.device = Some(d);
63        self
64    }
65
66    pub fn max_new_tokens(mut self, n: usize) -> Self {
67        self.max_new_tokens = n;
68        self
69    }
70
71    pub fn build(self) -> Result<VoxtralRunner> {
72        let weights_path = self
73            .weights
74            .ok_or_else(|| anyhow::anyhow!("weights path required"))?;
75        let model_dir = resolve_model_dir(&weights_path)?;
76        let cfg_path = self
77            .config_path
78            .clone()
79            .unwrap_or_else(|| model_dir.join("config.json"));
80        let cfg = match self.config {
81            Some(c) => c,
82            None => VoxtralConfig::from_file(&cfg_path)
83                .with_context(|| format!("reading {cfg_path:?}"))?,
84        };
85        cfg.validate()?;
86        let device = self.device.unwrap_or(Device::Cpu);
87        validate_standard_device("voxtral", device)?;
88        let max_new_tokens = if self.max_new_tokens == 0 {
89            256
90        } else {
91            self.max_new_tokens
92        };
93        let weight_store = VoxtralWeightStore::open(&weights_path)?;
94
95        Ok(VoxtralRunner {
96            cfg,
97            device,
98            max_new_tokens,
99            weight_store,
100        })
101    }
102}
103
104pub struct VoxtralRunner {
105    cfg: VoxtralConfig,
106    device: Device,
107    max_new_tokens: usize,
108    weight_store: VoxtralWeightStore,
109}
110
111impl VoxtralRunner {
112    pub fn builder() -> VoxtralRunnerBuilder {
113        VoxtralRunnerBuilder::default()
114    }
115
116    pub fn config(&self) -> &VoxtralConfig {
117        &self.cfg
118    }
119
120    pub fn model_dir(&self) -> &Path {
121        self.weight_store.model_dir()
122    }
123
124    pub fn encode_audio(&self, mel: &MelSpectrogram) -> Result<Vec<f32>> {
125        let batch = 1;
126        let mel_frames = mel.n_frames;
127        let enc_seq = self.cfg.audio_config.encoder_seq_len(mel_frames);
128        ensure!(
129            enc_seq.is_multiple_of(4),
130            "encoder seq {enc_seq} not divisible by 4 — pad mel to a compatible length"
131        );
132
133        let mut wm = self.weight_store.load_audio_weights()?;
134        let enc_built =
135            build_voxtral_encoder_built(&self.cfg.audio_config, &mut wm, batch, mel_frames)?;
136        let enc_params = enc_built.params().clone();
137        let mut enc = compile_built(enc_built, self.device)?;
138        for (n, d) in &enc_params {
139            enc.set_param(n, d);
140        }
141        let enc_out = enc
142            .run(&[("mel", mel.data.as_slice())])
143            .into_iter()
144            .next()
145            .context("encoder output")?;
146        drop(wm);
147
148        let mut wm2 = self.weight_store.load_projector_weights()?;
149        let proj_built = build_voxtral_projector_built(&self.cfg, &mut wm2, batch, enc_seq)?;
150        let proj_params = proj_built.params().clone();
151        let mut proj = compile_built(proj_built, self.device)?;
152        for (n, d) in &proj_params {
153            proj.set_param(n, d);
154        }
155        let audio_embeds = proj
156            .run(&[("encoder_hidden", &enc_out)])
157            .into_iter()
158            .next()
159            .context("projector output")?;
160        Ok(audio_embeds)
161    }
162
163    pub fn generate(&self, prompt_ids: &[u32], mel: &MelSpectrogram) -> Result<Vec<u32>> {
164        let batch = 1;
165        let dbg = std::env::var("RLX_VOXTRAL_DEBUG").is_ok();
166        let ck = |label: &str, v: &[f32]| {
167            if dbg {
168                let sum: f64 = v.iter().map(|&x| x as f64).sum();
169                let sumsq: f64 = v.iter().map(|&x| (x as f64) * (x as f64)).sum();
170                let finite = v.iter().filter(|x| x.is_finite()).count();
171                eprintln!(
172                    "[dbg] {label}: n={} sum={sum:.5} sumsq={sumsq:.5} finite={finite} first={:?}",
173                    v.len(),
174                    &v[..v.len().min(4)]
175                );
176            }
177        };
178        let audio_embeds = self.encode_audio(mel)?;
179        let h = self.cfg.text_config.hidden_size;
180        let n_audio = audio_embeds.len() / h;
181        ck("audio_embeds", &audio_embeds);
182        validate_prompt_audio_match(&self.cfg, prompt_ids, n_audio)?;
183
184        let embed_wm = self
185            .weight_store
186            .load_keys(&[VoxtralWeightPrefix::lm_embed_tokens()])?;
187        let inputs_embeds = fuse_inputs_embeds(&self.cfg, &embed_wm, prompt_ids, &audio_embeds)?;
188        drop(embed_wm);
189        let seq = prompt_ids.len();
190
191        // Debug bisection: skip the `gather_last_token` tail so the prefill emits
192        // logits for ALL positions — lets us compare cpu↔metal↔mlx and tell whether
193        // a backend diverges in the layers vs the gather/head tail.
194        let no_gather = std::env::var("RLX_VOXTRAL_NOGATHER").is_ok();
195        let last_logits_only = !no_gather;
196        // Debug: drop the per-layer KvTap side outputs (only valid with NOGATHER,
197        // which bails before decode needs the KV cache) to test if the tap stage
198        // is what corrupts the Metal layer output.
199        let with_kv = std::env::var("RLX_VOXTRAL_NOKV").is_err();
200        let mut wm = self.weight_store.load_language_model_weights()?;
201        let prefill_built =
202            build_voxtral_prefill_built(&self.cfg, &mut wm, batch, seq, with_kv, last_logits_only)?;
203        // `build` copied the weights into the built graph; release the ~12 GB f32
204        // weight map now. `compile_built` already moves the params out of the built
205        // graph and streams them into the compiled model (freeing each tensor as it
206        // uploads), so an explicit `.params().clone()` + `set_param` loop here would
207        // just stack a redundant full-size copy — keep the unified-memory peak low.
208        drop(wm);
209        let mut prefill = compile_built(prefill_built, self.device)?;
210
211        ck("inputs_embeds", &inputs_embeds);
212        let pre_in = [("inputs_embeds", inputs_embeds.as_slice())];
213        let outs = prefill.run(&pre_in);
214        let logits = &outs[0];
215        if no_gather {
216            ck("prefill_all_logits", logits);
217            anyhow::bail!("RLX_VOXTRAL_NOGATHER: stopped after full-sequence prefill checksum");
218        }
219        ck("prefill_logits", logits);
220        let vocab = self.cfg.text_config.vocab_size;
221        let mut tokens: Vec<u32> = prompt_ids.to_vec();
222        ensure!(
223            logits.len() == batch * vocab,
224            "expected last-token logits [{batch}, {vocab}], got {}",
225            logits.len()
226        );
227        let mut next = argmax_token(logits);
228
229        let kv_start = 1usize;
230        let mut kv_caches: Vec<Vec<f32>> = outs[kv_start..].to_vec();
231        drop(prefill);
232
233        let layers = self.cfg.text_config.num_hidden_layers;
234        let key_past: Vec<String> = (0..layers)
235            .flat_map(|i| [format!("past_k_{i}"), format!("past_v_{i}")])
236            .collect();
237        let kv_dim = self.cfg.llama_config().kv_proj_dim();
238
239        // Compile the decode graph ONCE at a fixed bucket cap and upload the
240        // ~9 GB language-model weights ONCE. Each step pads the KV cache to
241        // `upper` and supplies a per-step mask that zeros the padded slots, so
242        // a single compiled graph serves every `past_len` — no per-token
243        // reload/recompile (previously the dominant cost of generation).
244        let upper = seq.saturating_add(self.max_new_tokens);
245        let mut wm_dec = self.weight_store.load_language_model_weights()?;
246        let dec_built =
247            build_voxtral_decode_built(&self.cfg, &mut wm_dec, batch, upper, false, true)?;
248        // Release the f32 weight map before compiling; `compile_built` moves the
249        // params out of the built graph and streams them into the compiled model.
250        drop(wm_dec);
251        let mut dec = compile_built(dec_built, self.device)?;
252
253        let mut padded_k = vec![vec![0f32; upper * kv_dim]; layers];
254        let mut padded_v = vec![vec![0f32; upper * kv_dim]; layers];
255
256        let eos = self.cfg.eos_token_id;
257        for past_len in seq..upper {
258            // Stop at end-of-stream so the transcript doesn't ramble past the
259            // utterance (token 0 = <unk> guard; eos = tekken </s>).
260            if next == 0 || next == eos {
261                break;
262            }
263            tokens.push(next);
264
265            let token_f = [next as f32];
266            let position = [past_len as f32];
267            let mask = bucket_decode_mask(past_len, upper);
268
269            // Place the compact past KV into the fixed-size padded buffers.
270            for i in 0..layers {
271                let kf = &kv_caches[2 * i];
272                let vf = &kv_caches[2 * i + 1];
273                padded_k[i][..kf.len()].copy_from_slice(kf);
274                padded_v[i][..vf.len()].copy_from_slice(vf);
275            }
276
277            let mut dec_in: Vec<(&str, &[f32])> = vec![
278                ("input_ids", &token_f),
279                ("position", &position),
280                ("mask", mask.as_slice()),
281            ];
282            for i in 0..layers {
283                dec_in.push((key_past[2 * i].as_str(), padded_k[i].as_slice()));
284                dec_in.push((key_past[2 * i + 1].as_str(), padded_v[i].as_slice()));
285            }
286            let step_out = dec.run(&dec_in);
287            next = argmax_token(&step_out[0]);
288
289            // Graph emits K/V at length `upper + 1` (padded past + new row at
290            // index `upper`); compact back to `past_len + 1` dense rows.
291            let new_len = past_len + 1;
292            kv_caches = step_out[1..]
293                .iter()
294                .map(|buf| compact_bucketed_kv_buffer(buf, new_len, kv_dim, batch))
295                .collect();
296        }
297
298        Ok(tokens)
299    }
300
301    /// Transcribe a 16 kHz mono WAV fully natively: Rust log-mel + Mistral
302    /// transcription prompt → RLX encoder/projector/decoder. No Python.
303    pub fn transcribe_wav(&self, wav: &Path, language: Option<&str>) -> Result<Vec<u32>> {
304        let pcm = load_wav_mono_f32(wav)?;
305        let mel = pcm_to_mel(&self.cfg.audio_config, &pcm)?;
306        let n_audio = self.cfg.audio_config.audio_token_count(mel.n_frames);
307        let prompt =
308            transcription_prompt_ids(&self.cfg, n_audio, language, Some(self.model_dir()))?;
309        self.generate(&prompt, &mel)
310    }
311}