Skip to main content

rlx_orpheus/backbone/
rlx.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3
4//! Orpheus GGUF backbone — KV-cache decode when supported, packed GGUF on CPU.
5
6use std::path::{Path, PathBuf};
7use std::sync::Mutex;
8
9use anyhow::{Context, Result, bail};
10use rlx_core::validate_standard_device;
11use rlx_core::weight_loader::GgufLoader;
12use rlx_llama_base::LlamaBaseConfig;
13use rlx_llama32::{Llama32Generator, Llama32Runner, Llama32RunnerBuilder, MetalGgufPrefillMode};
14
15use crate::backbone::BackboneLoadOptions;
16use crate::device::lm_kv_decode_supported;
17use rlx_qwen3::{SampleOpts, apply_repetition_penalty, sample_token_at};
18use rlx_qwen35::encode_prompt_from_gguf;
19use rlx_runtime::Device;
20use rlx_runtime::{
21    llama_decode_bucket_compile_peak_bytes, llama_decode_oneshot_compile_peak_bytes,
22    memory_headroom_bytes, process_rss_bytes, soft_memory_budget_bytes, would_exceed_soft_budget,
23};
24
25use crate::runner::GenerationConfig;
26use crate::tokens::{
27    STOP_TOKEN_ID, custom_token_id_to_code, is_snac_slot_token, mask_logits_for_snac_slot,
28    use_snac_logit_mask_for,
29};
30
31pub const DEFAULT_N_CTX: u32 = 2048;
32/// KV compile / bucket cap for TTS (prompt + speech tokens). Full [`DEFAULT_N_CTX`]
33/// is only used for prompt-length validation; compiling at 2048 OOMs on Metal.
34pub const DEFAULT_COMPILE_SEQ_CAP: u32 = 256;
35
36/// Default compile cap for synthesis when not in high-memory mode (speech tokens +
37/// short prompt fit in 128).
38pub const SYNTHESIS_COMPILE_SEQ_CAP: u32 = 128;
39
40enum DecodeStrategy {
41    /// Power-of-two bucket ladder — fast multi-step decode, high compile RAM (~12 GiB peak).
42    Bucket { max_past: usize },
43    /// Single dynamic graph specialized per `past_seq` — lower RAM, default for TTS.
44    Dynamic { cache_capacity: usize },
45}
46
47fn shared_ram_mode() -> bool {
48    if matches!(
49        std::env::var("ORPHEUS_LOW_MEM").ok().as_deref(),
50        Some("1") | Some("true") | Some("TRUE")
51    ) {
52        return true;
53    }
54    // Tighter caps when the soft budget is below half the bucket compile estimate.
55    soft_memory_budget_bytes()
56        .is_some_and(|budget| budget < llama_decode_bucket_compile_peak_bytes() / 2)
57}
58
59pub(crate) fn low_mem_mode() -> bool {
60    shared_ram_mode()
61        || memory_headroom_bytes().is_some_and(|h| h < llama_decode_oneshot_compile_peak_bytes())
62}
63
64fn log_soft_memory_budget() {
65    if let Some(budget) = soft_memory_budget_bytes() {
66        let rss = process_rss_bytes().unwrap_or(0);
67        let headroom = budget.saturating_sub(rss);
68        eprintln!(
69            "[orpheus/backbone] soft RAM budget {:.1} GiB (RSS {:.1} GiB, headroom {:.1} GiB)",
70            budget as f64 / 1024.0 / 1024.0 / 1024.0,
71            rss as f64 / 1024.0 / 1024.0 / 1024.0,
72            headroom as f64 / 1024.0 / 1024.0 / 1024.0,
73        );
74    }
75}
76
77/// Pick decode cache strategy. Default is **dynamic** decode (low RAM). Bucket
78/// ladder is opt-in via `ORPHEUS_BUCKET_DECODE=1` (tests / throughput).
79fn synthesis_decode_plan(compile_cap: usize, opts: &BackboneLoadOptions) -> DecodeStrategy {
80    let max_past = decode_bucket_max_past(compile_cap);
81    let want_bucket = env_flag("ORPHEUS_BUCKET_DECODE") == Some(true)
82        || (!opts.memory_efficient && env_flag("ORPHEUS_BUCKET_DECODE") != Some(false));
83
84    if want_bucket {
85        if would_exceed_soft_budget(llama_decode_bucket_compile_peak_bytes()) {
86            eprintln!(
87                "[orpheus/backbone] bucket decode denied (headroom < {:.1} GiB) — dynamic decode",
88                llama_decode_bucket_compile_peak_bytes() as f64 / 1024.0 / 1024.0 / 1024.0,
89            );
90        } else {
91            eprintln!(
92                "[orpheus/backbone] bucket decode enabled (max_past={max_past}) — set ORPHEUS_BUCKET_DECODE=0 to save RAM"
93            );
94            return DecodeStrategy::Bucket { max_past };
95        }
96    } else if env_flag("ORPHEUS_BUCKET_DECODE") == Some(false) {
97        eprintln!("[orpheus/backbone] ORPHEUS_BUCKET_DECODE=0 — dynamic decode");
98    }
99
100    let cache_capacity = if low_mem_mode() || opts.memory_efficient {
101        1
102    } else {
103        2
104    };
105    DecodeStrategy::Dynamic { cache_capacity }
106}
107
108fn compile_seq_cap_for(n_ctx: u32, opts: &BackboneLoadOptions) -> usize {
109    let base = if env_flag("ORPHEUS_HIGH_MEM") == Some(true) {
110        DEFAULT_COMPILE_SEQ_CAP
111    } else if low_mem_mode() {
112        64
113    } else if opts.memory_efficient {
114        SYNTHESIS_COMPILE_SEQ_CAP
115    } else {
116        DEFAULT_COMPILE_SEQ_CAP
117    };
118    std::env::var("ORPHEUS_COMPILE_SEQ_CAP")
119        .ok()
120        .and_then(|s| s.parse::<u32>().ok())
121        .map(|c| c.min(n_ctx) as usize)
122        .unwrap_or((base.min(n_ctx)) as usize)
123}
124
125fn prefill_cache_capacity() -> usize {
126    std::env::var("ORPHEUS_PREFILL_CACHE")
127        .ok()
128        .and_then(|s| s.parse().ok())
129        .unwrap_or(if low_mem_mode() { 1 } else { 2 })
130}
131
132fn decode_bucket_max_past(compile_cap: usize) -> usize {
133    std::env::var("ORPHEUS_DECODE_CACHE_CAP")
134        .ok()
135        .and_then(|s| s.parse().ok())
136        .unwrap_or(compile_cap.min(64))
137}
138
139fn env_flag(name: &str) -> Option<bool> {
140    match std::env::var(name).ok().as_deref() {
141        Some("1") | Some("true") | Some("TRUE") => Some(true),
142        Some("0") | Some("false") | Some("FALSE") => Some(false),
143        _ => None,
144    }
145}
146
147fn sample_opts(cfg: &GenerationConfig) -> SampleOpts {
148    if cfg.greedy || std::env::var("ORPHEUS_GREEDY").ok().as_deref() == Some("1") {
149        return SampleOpts::greedy();
150    }
151    SampleOpts::temperature(cfg.temperature, cfg.seed)
152        .with_top_p(cfg.top_p)
153        .with_top_k(cfg.top_k)
154}
155
156/// Metal KV path: prefill via [`MetalGgufPrefillMode`] on [`BackboneLoadOptions`]
157/// (CLI: `--metal-prefill auto|cpu|packed|metal`).
158fn use_compile_caches() -> bool {
159    env_flag("ORPHEUS_COMPILE_CACHE") == Some(true) && !low_mem_mode()
160}
161
162fn metal_f32_kv_enabled() -> bool {
163    env_flag("ORPHEUS_METAL_KV") == Some(true)
164}
165
166/// Slow packed GGUF recompute path (opt-in). Default: CPU uses packed; GPU uses KV.
167fn use_packed_lm(device: Device) -> bool {
168    if let Some(v) = env_flag("ORPHEUS_PACKED_LM") {
169        return v;
170    }
171    if env_flag("ORPHEUS_FAST_LM") == Some(true) {
172        return false;
173    }
174    if matches!(device, Device::Metal) && metal_f32_kv_enabled() {
175        return false;
176    }
177    // CPU default: packed prefill (memory-friendly; correct but O(n²) on 3B).
178    matches!(device, Device::Cpu) && !lm_kv_decode_supported(device)
179}
180
181/// Incremental KV decode (O(n) per token).
182fn use_fast_kv(device: Device, opts: &BackboneLoadOptions) -> bool {
183    if let Some(v) = opts.use_fast_kv {
184        return v;
185    }
186    if env_flag("ORPHEUS_FAST_LM") == Some(false) {
187        return false;
188    }
189    if env_flag("ORPHEUS_METAL_KV") == Some(false) {
190        return false;
191    }
192    lm_kv_decode_supported(device) && !use_packed_lm(device)
193}
194
195enum LmEngine {
196    Packed(Box<Llama32Runner>),
197    Kv(Box<Llama32Generator>),
198}
199
200fn push_orpheus_stream_token(
201    tok: u32,
202    stream_index: &mut usize,
203    on_code: &mut impl FnMut(i32) -> Result<()>,
204) -> Result<bool> {
205    if tok == STOP_TOKEN_ID {
206        return Ok(true);
207    }
208    // Match `orpheus_tts/decoder.py`: only accept custom tokens for the active
209    // slot; skip code index 0 when building the SNAC buffer.
210    if is_snac_slot_token(tok, *stream_index) {
211        if let Some(code) = custom_token_id_to_code(tok, *stream_index) {
212            if code > 0 {
213                on_code(code)?;
214            }
215        }
216        *stream_index += 1;
217    }
218    Ok(false)
219}
220
221fn adjust_orpheus_logits(
222    logits: &mut [f32],
223    stream_index: usize,
224    token_counts: &std::collections::HashMap<u32, u32>,
225    repetition_penalty: f32,
226    apply_penalty: bool,
227    lm_device: Device,
228    lm_decode_on_cpu: bool,
229) {
230    if use_snac_logit_mask_for(lm_device, lm_decode_on_cpu) {
231        mask_logits_for_snac_slot(logits, stream_index);
232    }
233    if apply_penalty {
234        apply_repetition_penalty(logits, token_counts, repetition_penalty);
235    }
236}
237
238pub struct BackboneModel {
239    engine: Mutex<LmEngine>,
240    weights: PathBuf,
241    n_ctx: u32,
242    lm_device: Device,
243    lm_decode_on_cpu: bool,
244    pub seed: Option<u32>,
245}
246
247impl BackboneModel {
248    pub fn weights_path(&self) -> &Path {
249        &self.weights
250    }
251
252    pub fn load(path: &Path, n_ctx: u32) -> Result<Self> {
253        Self::load_on(path, n_ctx, Device::Cpu)
254    }
255
256    pub fn load_on(path: &Path, n_ctx: u32, device: Device) -> Result<Self> {
257        Self::load_on_with(path, n_ctx, device, BackboneLoadOptions::for_device(device))
258    }
259
260    pub fn load_on_with(
261        path: &Path,
262        n_ctx: u32,
263        device: Device,
264        opts: BackboneLoadOptions,
265    ) -> Result<Self> {
266        validate_standard_device("orpheus", device)?;
267        let path_str = path
268            .to_str()
269            .ok_or_else(|| anyhow::anyhow!("non-utf8 weights path"))?;
270
271        let compile_cap = compile_seq_cap_for(n_ctx, &opts);
272        log_soft_memory_budget();
273        let lm_device = device;
274        let lm_decode_on_cpu = matches!(lm_device, Device::Cpu)
275            || matches!(lm_device, Device::Gpu | Device::Vulkan)
276            || (lm_device == Device::Metal
277                && matches!(
278                    opts.metal_prefill.resolve(),
279                    MetalGgufPrefillMode::CpuF32
280                        | MetalGgufPrefillMode::Auto
281                        | MetalGgufPrefillMode::PackedGguf,
282                ));
283
284        let engine = if use_fast_kv(lm_device, &opts) {
285            let mut gguf = GgufLoader::from_file(path_str).context("open GGUF for KV generator")?;
286            if gguf.architecture() != "llama" {
287                bail!(
288                    "rlx-orpheus: expected llama GGUF at {}; got `{}`",
289                    path.display(),
290                    gguf.architecture()
291                );
292            }
293            let cfg = rlx_llama32::llama32_cfg_from_gguf(gguf.file())?;
294            let mut generator = Llama32Generator::from_loader_at_mode(
295                cfg,
296                &mut gguf,
297                lm_device,
298                path,
299                if lm_device == Device::Cpu {
300                    MetalGgufPrefillMode::CpuF32
301                } else {
302                    opts.metal_prefill.resolve()
303                },
304            )?
305            .with_compile_seq_cap(compile_cap);
306            let decode = synthesis_decode_plan(compile_cap, &opts);
307            let use_bucket = matches!(decode, DecodeStrategy::Bucket { .. });
308            generator = match decode {
309                DecodeStrategy::Bucket { max_past } => generator.with_decode_cache(max_past),
310                DecodeStrategy::Dynamic { cache_capacity } => {
311                    generator.with_dynamic_decode_cache(cache_capacity)
312                }
313            };
314            if use_bucket && use_compile_caches() {
315                generator = generator.with_prefill_cache(prefill_cache_capacity());
316            }
317            let decode_on_cpu = lm_decode_on_cpu;
318            eprintln!(
319                "[orpheus/backbone] kv-cache LM on {lm_device:?} {} (prefill={:?}, decode={}, compile_cap={compile_cap}, decode={})",
320                path.display(),
321                if lm_device == Device::Cpu {
322                    MetalGgufPrefillMode::CpuF32
323                } else {
324                    opts.metal_prefill.resolve()
325                },
326                if decode_on_cpu { "cpu" } else { "device" },
327                if use_bucket { "bucket" } else { "dynamic" },
328            );
329            LmEngine::Kv(Box::new(generator))
330        } else {
331            let base = LlamaBaseConfig::from_gguf_path(path)
332                .with_context(|| format!("parse GGUF {}", path.display()))?;
333            if base.arch != "llama" {
334                bail!(
335                    "rlx-orpheus: expected llama GGUF at {}; got `{}`",
336                    path.display(),
337                    base.arch
338                );
339            }
340            let runner = Llama32RunnerBuilder::default()
341                .weights(path)
342                .max_seq(n_ctx as usize)
343                .device(lm_device)
344                .packed_weights(use_packed_lm(lm_device))
345                .stream(false)
346                .sample(SampleOpts::greedy())
347                .build()
348                .context("build Llama32Runner for Orpheus")?;
349            eprintln!(
350                "[orpheus/backbone] {} on {device:?} {}",
351                if use_packed_lm(device) {
352                    "packed GGUF prefill"
353                } else {
354                    "f32 runner"
355                },
356                path.display()
357            );
358            LmEngine::Packed(Box::new(runner))
359        };
360
361        Ok(Self {
362            engine: Mutex::new(engine),
363            weights: path.to_path_buf(),
364            n_ctx,
365            lm_device,
366            lm_decode_on_cpu,
367            seed: Some(GenerationConfig::default().seed as u32),
368        })
369    }
370
371    pub fn generate_codes(&self, prompt: &str, cfg: &GenerationConfig) -> Result<Vec<i32>> {
372        let prompt_ids = encode_prompt_from_gguf(&self.weights, prompt)
373            .with_context(|| format!("tokenize prompt for {}", self.weights.display()))?;
374        self.generate_codes_from_prompt(&prompt_ids, cfg)
375    }
376
377    pub fn generate_codes_from_prompt(
378        &self,
379        prompt_ids: &[u32],
380        cfg: &GenerationConfig,
381    ) -> Result<Vec<i32>> {
382        let mut codes = Vec::new();
383        self.generate_codes_from_prompt_streaming(prompt_ids, cfg, |c| {
384            codes.push(c);
385            Ok(())
386        })?;
387        Ok(codes)
388    }
389
390    pub fn generate_codes_from_prompt_streaming(
391        &self,
392        prompt_ids: &[u32],
393        cfg: &GenerationConfig,
394        mut on_code: impl FnMut(i32) -> Result<()>,
395    ) -> Result<()> {
396        if prompt_ids.len() as u32 > self.n_ctx {
397            bail!(
398                "prompt too long: {} tokens > n_ctx={}",
399                prompt_ids.len(),
400                self.n_ctx
401            );
402        }
403
404        let lm_device = self.lm_device;
405        let lm_decode_on_cpu = self.lm_decode_on_cpu;
406        let mut engine = self
407            .engine
408            .lock()
409            .map_err(|e| anyhow::anyhow!("backbone lock poisoned: {e}"))?;
410
411        let mut stream_index = 0usize;
412
413        match &mut *engine {
414            LmEngine::Kv(generator) => {
415                let sample = sample_opts(cfg);
416                let apply_penalty = cfg.repetition_penalty > 1.0;
417                let mut token_counts = std::collections::HashMap::<u32, u32>::new();
418                generator.prefill(prompt_ids);
419                eprintln!(
420                    "[orpheus/backbone] generating up to {} tokens (prompt len={})",
421                    cfg.max_new_tokens,
422                    prompt_ids.len()
423                );
424                for step in 0..cfg.max_new_tokens {
425                    let penalty = cfg.repetition_penalty;
426                    let slot_ix = stream_index;
427                    let next = generator
428                        .step_cached_adjust(sample, step as u64, |logits| {
429                            adjust_orpheus_logits(
430                                logits,
431                                slot_ix,
432                                &token_counts,
433                                penalty,
434                                apply_penalty,
435                                lm_device,
436                                lm_decode_on_cpu,
437                            );
438                        })
439                        .context("Orpheus LM cached decode step")?;
440                    if step > 0 && step % 7 == 0 {
441                        eprintln!(
442                            "[orpheus/backbone] step {}/{} ({} tokens in history)",
443                            step,
444                            cfg.max_new_tokens,
445                            generator.tokens().len()
446                        );
447                    }
448                    if push_orpheus_stream_token(next, &mut stream_index, &mut on_code)? {
449                        break;
450                    }
451                    *token_counts.entry(next).or_insert(0) += 1;
452                }
453                if std::env::var("ORPHEUS_DEBUG_TOKENS").ok().as_deref() == Some("1") {
454                    let generated = generator.tokens()[prompt_ids.len()..].to_vec();
455                    eprintln!(
456                        "[orpheus/backbone] raw tokens ({}): {:?}",
457                        generated.len(),
458                        &generated[..generated.len().min(16)]
459                    );
460                }
461            }
462            LmEngine::Packed(runner) => {
463                let sample = sample_opts(cfg);
464                let mut history = prompt_ids.to_vec();
465                let mut token_counts = std::collections::HashMap::<u32, u32>::new();
466                for step in 0..cfg.max_new_tokens {
467                    let slot_ix = stream_index;
468                    let mut logits = runner.predict_logits(&history)?;
469                    adjust_orpheus_logits(
470                        &mut logits,
471                        slot_ix,
472                        &token_counts,
473                        cfg.repetition_penalty,
474                        true,
475                        lm_device,
476                        lm_decode_on_cpu,
477                    );
478                    let next = sample_token_at(&logits, sample, step as u64) as u32;
479                    if push_orpheus_stream_token(next, &mut stream_index, &mut on_code)? {
480                        break;
481                    }
482                    *token_counts.entry(next).or_insert(0) += 1;
483                    history.push(next);
484                }
485                if std::env::var("ORPHEUS_DEBUG_TOKENS").ok().as_deref() == Some("1") {
486                    let generated = history[prompt_ids.len()..].to_vec();
487                    eprintln!(
488                        "[orpheus/backbone] raw tokens ({}): {:?}",
489                        generated.len(),
490                        &generated[..generated.len().min(16)]
491                    );
492                }
493            }
494        }
495        Ok(())
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use crate::backbone::BackboneLoadOptions;
503    use crate::tokens::STOP_TOKEN_ID;
504
505    #[test]
506    fn synthesis_defaults_to_dynamic_decode() {
507        let _guard = EnvGuard::unset("ORPHEUS_BUCKET_DECODE");
508        let plan = synthesis_decode_plan(128, &BackboneLoadOptions::synthesis());
509        assert!(matches!(plan, DecodeStrategy::Dynamic { .. }));
510    }
511
512    #[test]
513    fn reference_parity_allows_bucket_when_env_set() {
514        let _guard = EnvGuard::set("ORPHEUS_BUCKET_DECODE", "1");
515        let plan = synthesis_decode_plan(128, &BackboneLoadOptions::reference_parity());
516        // May be bucket or dynamic if soft budget denies — just must not panic.
517        let _ = plan;
518    }
519
520    /// Restore env after tests that set ORPHEUS_* vars.
521    struct EnvGuard {
522        key: &'static str,
523        prev: Option<String>,
524    }
525
526    impl EnvGuard {
527        fn set(key: &'static str, val: &str) -> Self {
528            let prev = std::env::var(key).ok();
529            unsafe { std::env::set_var(key, val) };
530            Self { key, prev }
531        }
532
533        fn unset(key: &'static str) -> Self {
534            let prev = std::env::var(key).ok();
535            unsafe { std::env::remove_var(key) };
536            Self { key, prev }
537        }
538    }
539
540    impl Drop for EnvGuard {
541        fn drop(&mut self) {
542            match &self.prev {
543                Some(v) => unsafe { std::env::set_var(self.key, v) },
544                None => unsafe { std::env::remove_var(self.key) },
545            }
546        }
547    }
548
549    #[test]
550    fn stop_token_yields_no_codes() {
551        let mut codes = Vec::new();
552        let tokens = [STOP_TOKEN_ID];
553        let mut stream_index = 0usize;
554        for &tok in &tokens {
555            let stop = push_orpheus_stream_token(tok, &mut stream_index, &mut |c| {
556                codes.push(c);
557                Ok(())
558            })
559            .expect("push token");
560            if stop {
561                break;
562            }
563        }
564        assert!(codes.is_empty());
565    }
566}