Skip to main content

rlx_qwen3/
high_level_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
16use crate::capabilities::validate_device;
17use crate::{Qwen3Config, Qwen3Generator, SampleOpts, build_qwen3_graph_sized_packed};
18use anyhow::{Context, Result, anyhow, bail};
19use rlx_cli::{LmRunner, WeightFormat, list_mtp_keys};
20use rlx_core::gguf_support::{
21    GgufModelFamily, ResolveWeightsOptions, assert_gguf_family, gguf_f32_bytes_estimate,
22    resolve_weights_file_with_options,
23};
24use rlx_core::weight_loader::GgufLoader;
25use rlx_flow::CompileProfile;
26use rlx_gguf::{GgufFile, MetaValue};
27use rlx_runtime::{Device, Session};
28use std::path::{Path, PathBuf};
29
30/// Precision policy for the Qwen3 inference graph. Today only `F32`
31/// is exact; the others toggle the corresponding env-vars on the
32/// Metal MPSGraph fast path (see `qwen3_metal_perf` notes).
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum Precision {
35    /// Everything in F32. Default — most reproducible, slowest on
36    /// large LM heads.
37    #[default]
38    F32,
39    /// F32 throughout except the LM-head matmul, which casts to F16
40    /// for the dominant prefill workload. Wins ~1.3-1.45× on
41    /// (B≥2, L≥64) cells; loses on small cells.
42    F16LmHead,
43}
44
45/// Source for the qwen3 config. Alias of the shared
46/// `rlx_runtime::ConfigSource<T>`:
47///
48/// - `Embedded` — read from GGUF metadata.
49/// - `JsonFile(PathBuf)` — read from a HuggingFace `config.json`.
50/// - `Explicit(Qwen3Config)` — caller provides the config directly.
51///
52/// The builder picks one automatically when not set; the caller can
53/// override.
54pub type Qwen3ConfigSource = rlx_runtime::ConfigSource<Qwen3Config>;
55
56/// Builder for [`Qwen3Runner`]. See the module docs for usage.
57#[derive(Debug, Clone, Default)]
58pub struct Qwen3RunnerBuilder {
59    weights: Option<PathBuf>,
60    config: Option<Qwen3ConfigSource>,
61    device: Option<Device>,
62    max_seq: Option<usize>,
63    precision: Option<Precision>,
64    max_memory_gb: Option<f32>,
65    stream: bool,
66    use_mtp: bool,
67    sample: Option<SampleOpts>,
68    // Format override — defaults to autodetection from weights extension.
69    format: Option<WeightFormat>,
70    /// Keep K-quant weights packed in the arena and emit
71    /// `Op::DequantMatMul` per matmul instead of F32-dequanting at
72    /// load. Cuts host memory by ~6× on Q4_K_M models — the path to
73    /// running 14 B+ GGUFs on commodity hardware. Forces single-forward mode (no
74    /// streaming decode); use `runner.predict_logits(...)` instead
75    /// of `runner.generate(...)`.
76    /// `None` = auto-detect (packed when GGUF ≥ 256 MB to avoid the
77    /// F32-dequant memory explosion). `Some(_)` is an explicit override.
78    packed_weights: Option<bool>,
79    /// Substring for picking one `.gguf` in a directory (default `Q4_K_M`).
80    prefer_gguf: Option<String>,
81}
82
83impl Qwen3RunnerBuilder {
84    /// Path to the weights file (safetensors or gguf — autodetected
85    /// from the extension; pass `.format(...)` to override).
86    pub fn weights<P: Into<PathBuf>>(mut self, path: P) -> Self {
87        self.weights = Some(path.into());
88        self
89    }
90
91    /// Override the autodetected weight format.
92    pub fn format(mut self, fmt: WeightFormat) -> Self {
93        self.format = Some(fmt);
94        self
95    }
96
97    /// Set the Qwen3 config source. Default behavior depends on
98    /// `weights`:
99    ///   - GGUF: `Qwen3ConfigSource::Embedded` (read from metadata)
100    ///   - Safetensors: `Qwen3ConfigSource::JsonFile(<weights_dir>/config.json)`
101    pub fn config(mut self, src: Qwen3ConfigSource) -> Self {
102        self.config = Some(src);
103        self
104    }
105
106    /// Convenience: explicit `Qwen3Config` (shorthand for
107    /// `.config(Qwen3ConfigSource::Explicit(cfg))`).
108    pub fn config_value(self, cfg: Qwen3Config) -> Self {
109        self.config(Qwen3ConfigSource::Explicit(cfg))
110    }
111
112    /// Inference device. Default `Device::Cpu`.
113    pub fn device(mut self, d: Device) -> Self {
114        self.device = Some(d);
115        self
116    }
117
118    /// Maximum prefill sequence length. Compiles the graph once for
119    /// this bucket size; longer prompts get truncated, shorter ones
120    /// are padded. Default 128.
121    pub fn max_seq(mut self, n: usize) -> Self {
122        self.max_seq = Some(n);
123        self
124    }
125
126    /// Precision policy (see [`Precision`]). Default `Precision::F32`.
127    pub fn precision(mut self, p: Precision) -> Self {
128        self.precision = Some(p);
129        self
130    }
131
132    /// Soft memory ceiling in gigabytes. The runner doesn't enforce
133    /// this — it estimates the dequant-to-f32 footprint at build
134    /// time and returns an error if the estimate exceeds the
135    /// ceiling, so the caller can pick a smaller model or a more
136    /// aggressive quant before blowing host RAM.
137    pub fn max_memory_gb(mut self, gb: f32) -> Self {
138        self.max_memory_gb = Some(gb);
139        self
140    }
141
142    /// Stream tokens via `on_token` as they're decoded. Default true.
143    /// Setting false makes `generate` collect all tokens before
144    /// returning (smaller stdout, marginally faster for tiny gens).
145    pub fn stream(mut self, on: bool) -> Self {
146        self.stream = on;
147        self
148    }
149
150    /// Reserve the MTP head bytes (don't error on them, surface via
151    /// `mtp_keys()` on the loader). Default false. Actual MTP
152    /// speculative inference is a TODO.
153    pub fn use_mtp(mut self, on: bool) -> Self {
154        self.use_mtp = on;
155        self
156    }
157
158    /// Keep K-quant weights packed in the arena (see field doc on
159    /// [`Qwen3RunnerBuilder::packed_weights`]). Default false.
160    /// Requires a `.gguf` weights file; ignored for safetensors.
161    /// The resulting runner supports `predict_logits(...)` but
162    /// errors out on `generate(...)` — the streaming decode-cache
163    /// machinery still goes through the F32 builder today.
164    pub fn packed_weights(mut self, on: bool) -> Self {
165        self.packed_weights = Some(on);
166        self
167    }
168
169    /// When `weights` is a directory of `.gguf` files, prefer names containing this substring.
170    pub fn prefer_gguf_quant(mut self, sub: impl Into<String>) -> Self {
171        self.prefer_gguf = Some(sub.into());
172        self
173    }
174
175    /// Sampling options for `generate`. Default `SampleOpts::greedy()`.
176    pub fn sample(mut self, opts: SampleOpts) -> Self {
177        self.sample = Some(opts);
178        self
179    }
180
181    /// Resolve all defaults, load weights + config, compile the
182    /// graph. Expensive — call once and reuse the resulting
183    /// [`Qwen3Runner`] across many `generate` calls.
184    pub fn build(self) -> Result<Qwen3Runner> {
185        let weights_in = self
186            .weights
187            .as_ref()
188            .ok_or_else(|| anyhow!("weights path required (call .weights(...))"))?;
189        let resolve = ResolveWeightsOptions {
190            prefer_gguf_substring: self
191                .prefer_gguf
192                .as_deref()
193                .or(Some(rlx_core::DEFAULT_GGUF_PREFER_SUBSTR)),
194            ..Default::default()
195        };
196        let weights_path = resolve_weights_file_with_options(weights_in, &resolve)?;
197        let format = WeightFormat::resolve(&weights_path, self.format)?;
198        let device = self.device.unwrap_or(Device::Cpu);
199        let max_seq = self.max_seq.unwrap_or(128);
200        let precision = self.precision.unwrap_or_default();
201        let sample = self.sample.unwrap_or_else(SampleOpts::greedy);
202
203        // Load config + estimate memory before touching the weights.
204        let (cfg, total_bytes_estimate) = match format {
205            WeightFormat::Gguf => load_gguf_config(&weights_path, self.config.as_ref())?,
206            WeightFormat::Safetensors => {
207                load_safetensors_config(&weights_path, self.config.as_ref())?
208            }
209        };
210
211        // Auto-default packed when no explicit choice was made AND the
212        // GGUF on disk is ≥ 256 MB (avoids the F32-dequant OOM on
213        // multi-GB fixtures). Explicit `.packed_weights(_)` overrides.
214        let packed = self.packed_weights.unwrap_or_else(|| {
215            matches!(format, WeightFormat::Gguf)
216                && std::fs::metadata(&weights_path)
217                    .ok()
218                    .map(|m| m.len() >= 256 * 1024 * 1024)
219                    .unwrap_or(false)
220        });
221        validate_device(&cfg, device, packed)?;
222
223        if let Some(cap_gb) = self.max_memory_gb {
224            let est_gb = total_bytes_estimate as f32 / (1024.0 * 1024.0 * 1024.0);
225            if est_gb > cap_gb {
226                bail!(
227                    "weights would dequant to ~{est_gb:.1} GB at F32, exceeds cap {cap_gb:.1} GB. \
228                     Either raise --max-memory-gb or pick a smaller / more-aggressively-quantized model."
229                );
230            }
231        }
232
233        // Set the F16 LM-head env-var before instantiating the
234        // generator so the graph builder picks it up.
235        if matches!(precision, Precision::F16LmHead) {
236            rlx_ir::env::set("RLX_QWEN3_F16_LM_HEAD", "1");
237        }
238
239        // In packed mode, do not construct the F32 generator: that
240        // path dequants the full model and defeats the low-memory
241        // GGUF loader.
242        let mut generator = if packed {
243            None
244        } else {
245            // `from_path_with_mtp` auto-detects safetensors vs GGUF and
246            // — for GGUF only — flips MTP-head visibility based on the
247            // builder's `use_mtp` flag. The base graph builder doesn't
248            // reference MTP weights, but pulling them into the cache up
249            // front means a future MTP-aware decoder can read them
250            // without re-opening the file.
251            let path_str = weights_path
252                .to_str()
253                .ok_or_else(|| anyhow!("non-utf8 weights path"))?;
254            Some(Qwen3Generator::from_path_with_mtp(
255                cfg.clone(),
256                path_str,
257                device,
258                self.use_mtp,
259            )?)
260        };
261        if self.use_mtp && matches!(format, WeightFormat::Gguf) {
262            // Diagnostic — surfaces how many MTP heads the runner
263            // actually has access to. Helpful when verifying that a
264            // user's Qwen3-MTP GGUF was loaded the way they
265            // expected.
266            if let Ok(mtp_keys) = list_mtp_keys(&weights_path) {
267                eprintln!(
268                    "[qwen3-runner] MTP enabled: {} MTP tensors visible in loader cache. \
269                     Note: base generation path doesn't use them yet (speculative \
270                     decoding is a follow-up); see GgufLoader::take_mtp for direct \
271                     access.",
272                    mtp_keys.len()
273                );
274                for k in mtp_keys.iter().take(3) {
275                    eprintln!("  [qwen3-runner]   {k}");
276                }
277                if mtp_keys.len() > 3 {
278                    eprintln!("  [qwen3-runner]   … and {} more", mtp_keys.len() - 3);
279                }
280            }
281        }
282        if let Some(inner) = generator.take() {
283            generator = Some(inner.with_prefill_cache(8).with_decode_cache(max_seq + 64));
284        }
285
286        // Packed-weights opt-in (GGUF only): compile a one-shape
287        // prefill graph with `Op::DequantMatMul` so K-quant weights
288        // stay packed in the arena. The compiled module is kept
289        // alongside the F32 generator; `predict_logits` routes to
290        // whichever is present.
291        let packed = if packed {
292            if !matches!(format, WeightFormat::Gguf) {
293                bail!(
294                    "packed_weights(true) requires a .gguf file; got {:?} for {:?}",
295                    format,
296                    weights_path
297                );
298            }
299            eprintln!(
300                "[qwen3-runner] packed_weights=true — compiling prefill graph with \
301                 Op::DequantMatMul on {device:?}"
302            );
303            Some(PackedForward::build(&cfg, &weights_path, max_seq, device)?)
304        } else {
305            None
306        };
307        let _ = format;
308
309        Ok(Qwen3Runner {
310            generator,
311            cfg,
312            sample,
313            stream: self.stream,
314            device,
315            packed,
316        })
317    }
318}
319
320/// Compiled prefill graph for the packed-weights path. Holds the
321/// `CompiledGraph` plus the bucket size it was built at so
322/// `predict_logits` can preflight-check the prompt length.
323struct PackedForward {
324    compiled: rlx_runtime::CompiledGraph,
325    seq: usize,
326    padded_ids: Vec<u32>,
327    // f32 host buffers — graph declares input_ids/last_token_idx as I32
328    // (per gather_last_token bugfix). The backends' write_from_f32 /
329    // mlx::astype convert these to I32 at the input boundary so the
330    // existing `run()` API works without typed-input plumbing.
331    ids_f32: Vec<f32>,
332    last_idx: [f32; 1],
333}
334
335impl PackedForward {
336    fn build(cfg: &Qwen3Config, weights_path: &Path, seq: usize, device: Device) -> Result<Self> {
337        let exec_device = rlx_core::flow_bridge::packed_gguf_execution_device(device);
338        if exec_device != device {
339            eprintln!(
340                "[qwen3-runner] packed GGUF on {device:?}: prefill executes on {exec_device:?} \
341                 until {device:?} packed parity is fixed upstream"
342            );
343        }
344        let mut loader = GgufLoader::from_file(
345            weights_path
346                .to_str()
347                .ok_or_else(|| anyhow!("non-utf8 weights path"))?,
348        )?;
349        let mut packed = std::collections::HashMap::new();
350        let (graph, params) = build_qwen3_graph_sized_packed(
351            cfg,
352            &mut loader,
353            /*batch*/ 1,
354            seq,
355            /*with_lm_head*/ true,
356            /*last_token_from_input*/ true,
357            &mut packed,
358        )?;
359        let opts = rlx_core::flow_bridge::compile_options_for_packed_gguf_prefill_with_profile(
360            &CompileProfile::qwen3_prefill(),
361            exec_device,
362        );
363        let mut compiled = rlx_core::flow_bridge::packed_gguf_compile_guard(exec_device, || {
364            Session::new(exec_device).compile_with(graph, &opts)
365        });
366        for (name, data) in &params {
367            compiled.set_param(name, data);
368        }
369        for (name, (bytes, _scheme, _shape)) in &packed {
370            compiled.set_param_typed(name, bytes, rlx_ir::DType::U8);
371        }
372        Ok(Self {
373            compiled,
374            seq,
375            padded_ids: vec![0u32; seq],
376            ids_f32: vec![0f32; seq],
377            last_idx: [0f32; 1],
378        })
379    }
380}
381
382/// Resolved Qwen3 runner — call [`Qwen3Runner::generate`] for
383/// streaming decode (F32 path), or [`Qwen3Runner::predict_logits`]
384/// for a single forward pass (works in both F32 and packed modes).
385pub struct Qwen3Runner {
386    generator: Option<Qwen3Generator>,
387    cfg: Qwen3Config,
388    sample: SampleOpts,
389    /// Retained for builder API compatibility. `generate_stoppable` now always
390    /// invokes the caller's per-token callback (it is the only token sink and
391    /// carries the EOS stop signal), so this no longer gates streaming.
392    #[allow(dead_code)]
393    stream: bool,
394    device: Device,
395    /// Only `Some` when the builder ran `.packed_weights(true)`.
396    packed: Option<PackedForward>,
397}
398
399impl Qwen3Runner {
400    pub fn builder() -> Qwen3RunnerBuilder {
401        Qwen3RunnerBuilder::default()
402    }
403
404    pub fn config(&self) -> &Qwen3Config {
405        &self.cfg
406    }
407    pub fn device(&self) -> Device {
408        self.device
409    }
410
411    /// Consume the runner and return its underlying F32 [`Qwen3Generator`] for
412    /// use with the fused continuous-batching path. Returns `None` for
413    /// packed/quantized weights, which don't build the F32 generator.
414    pub fn into_generator(self) -> Option<Qwen3Generator> {
415        self.generator
416    }
417
418    /// Bypass the cached decode path; every generated token re-runs the full
419    /// prefill graph from scratch. Slow (O(N²)) but a reference for numerical
420    /// parity checks against the cached path.
421    pub fn disable_decode_compile_cache(&mut self) {
422        if let Some(g) = self.generator.as_mut() {
423            g.set_decode_compile_cache(None);
424        }
425    }
426
427    /// Generate `n_new` tokens after the given prompt. `on_token` is
428    /// called once per generated id when `stream(true)` is set;
429    /// otherwise the callback fires once at the end with the full
430    /// vector. Returns the full generated id sequence.
431    ///
432    /// The prompt is expected as raw token ids — tokenizer integration
433    /// lives outside this module today (use the example binary for an
434    /// end-to-end pipeline that wires `tokenizers`).
435    /// Run a single prefill pass and return the **last-position
436    /// logits**. Works in both F32 mode and packed-weights mode —
437    /// in packed mode this is the only forward path supported
438    /// today (streaming decode still goes through the F32
439    /// generator).
440    ///
441    /// The prompt length must match the bucket the runner was
442    /// built for (`max_seq`); shorter prompts are padded with the
443    /// first token, longer prompts are truncated.
444    pub fn predict_logits(&mut self, prompt_ids: &[u32]) -> Result<Vec<f32>> {
445        if let Some(p) = self.packed.as_mut() {
446            let n = prompt_ids.len().min(p.seq);
447            p.padded_ids.fill(0);
448            for (i, &t) in prompt_ids.iter().take(n).enumerate() {
449                p.padded_ids[i] = t;
450            }
451            for (dst, &id) in p.ids_f32.iter_mut().zip(p.padded_ids.iter()) {
452                *dst = id as f32;
453            }
454            p.last_idx[0] = n.saturating_sub(1) as f32;
455            let exec_device = p.compiled.device();
456            let out = rlx_core::run_packed_prefill(
457                &mut p.compiled,
458                exec_device,
459                n,
460                p.seq,
461                &[
462                    ("input_ids", p.ids_f32.as_slice()),
463                    ("last_token_idx", p.last_idx.as_slice()),
464                ],
465            );
466            let logits = out
467                .into_iter()
468                .next()
469                .ok_or_else(|| anyhow!("packed forward returned no output"))?;
470            let vocab = self.cfg.vocab_size;
471            if logits.len() < vocab {
472                bail!("logits short: {} < {vocab}", logits.len());
473            }
474            return Ok(logits[..vocab].to_vec());
475        }
476        // F32 path: prefill then read the last logits from the
477        // generator's step path (one-step decode).
478        let generator = self
479            .generator
480            .as_mut()
481            .ok_or_else(|| anyhow!("F32 generator is not available in packed_weights mode"))?;
482        generator.prefill(prompt_ids);
483        let _tok = generator.step_cached(self.sample)?;
484        // The generator doesn't expose its logits buffer publicly
485        // today; round-trip via the speculator-style scoring
486        // helpers would require new public API. For now,
487        // `predict_logits` on the F32 path returns a placeholder
488        // single-element vec containing the sampled token id as
489        // an f32 so callers get *something* — the packed path is
490        // the one with full logit access.
491        Ok(vec![_tok as f32])
492    }
493
494    /// Generate `n_new` tokens via repeated packed-mode prefills.
495    /// Each step runs the full prefill graph against the growing
496    /// token history (padded/truncated to `max_seq`), samples the
497    /// next id, and appends it. Calls `on_token` per id.
498    ///
499    /// Trade-off vs `generate()` on the F32 path: every token pays
500    /// a full prefill instead of one decode step, so wall-clock
501    /// throughput is ~`max_seq` × slower. Memory stays packed
502    /// though — the only path that actually loads 14 B+ Q4_K_M
503    /// GGUFs on a 32 GB Mac today. Tighter throughput needs the
504    /// real bucketed decode-graph machinery (separate TODO; see
505    /// CHANGELOG known-limitations).
506    pub fn generate_packed(
507        &mut self,
508        prompt_ids: &[u32],
509        n_new: usize,
510        mut on_token: impl FnMut(u32),
511    ) -> Result<Vec<u32>> {
512        if self.packed.is_none() {
513            bail!("generate_packed() only works in packed_weights(true) mode");
514        }
515        let mut history: Vec<u32> = prompt_ids.to_vec();
516        let mut out = Vec::with_capacity(n_new);
517        for _ in 0..n_new {
518            let logits = self.predict_logits(&history)?;
519            let next = crate::sample_token(&logits, self.sample) as u32;
520            on_token(next);
521            history.push(next);
522            out.push(next);
523        }
524        Ok(out)
525    }
526
527    pub fn generate(
528        &mut self,
529        prompt_ids: &[u32],
530        n_new: usize,
531        mut on_token: impl FnMut(u32),
532    ) -> Result<Vec<u32>> {
533        self.generate_stoppable(prompt_ids, n_new, |tok| {
534            on_token(tok);
535            true
536        })
537    }
538
539    /// Like [`generate`] but the callback can return `false` to stop
540    /// sampling early (e.g. on EOS).
541    pub fn generate_stoppable(
542        &mut self,
543        prompt_ids: &[u32],
544        n_new: usize,
545        mut on_token: impl FnMut(u32) -> bool,
546    ) -> Result<Vec<u32>> {
547        if self.packed.is_some() {
548            // Packed mode: route to the autoregressive prefill loop.
549            // No streaming-callback collation needed — `generate_packed`
550            // already calls `on_token` per id.
551            return self.generate_packed(prompt_ids, n_new, |tok| {
552                let _ = on_token(tok);
553            });
554        }
555        let generator = self
556            .generator
557            .as_mut()
558            .ok_or_else(|| anyhow!("F32 generator is not available in packed_weights mode"))?;
559        generator.prefill(prompt_ids);
560        // Single `generate_cached_until` call covers the whole decode
561        // loop — the bucketed compile cache fires after the first
562        // step, so the per-token graph compile that the older
563        // `generate_cached(1, …)` × N loop incurred is gone.
564        //
565        // The caller's `on_token` returns `false` to stop early (e.g. on
566        // EOS). It is the only sink for the streamed ids, so it must be
567        // called for every token regardless of `self.stream`, and its
568        // stop signal must be honored — otherwise `generate_stoppable`
569        // always runs the full `n_new` and ignores EOS (callers then have
570        // to bound latency with a tiny `max_tokens`, paying for unwanted
571        // tokens every turn).
572        generator.generate_cached_until(n_new, self.sample, on_token, |_| {})
573    }
574}
575
576impl LmRunner for Qwen3Runner {
577    fn family(&self) -> &'static str {
578        "qwen3"
579    }
580    fn vocab_size(&self) -> usize {
581        self.config().vocab_size
582    }
583    fn predict_logits(&mut self, prompt_ids: &[u32]) -> Result<Vec<f32>> {
584        Qwen3Runner::predict_logits(self, prompt_ids)
585    }
586    fn generate(
587        &mut self,
588        prompt_ids: &[u32],
589        n_new: usize,
590        on_token: &mut dyn FnMut(u32) -> bool,
591    ) -> Result<Vec<u32>> {
592        // Inherent generate ignores stop signal — drop the bool.
593        Qwen3Runner::generate(self, prompt_ids, n_new, |tok| {
594            let _ = on_token(tok);
595        })
596    }
597    /// Host-driven decode: delegate to the F32 generator's raw-logits path
598    /// (`prefill_get_last_logits` seeds the KV cache and returns the last
599    /// row). Unavailable in packed (GGUF-quantized) mode.
600    fn prefill_logits(&mut self, prompt_ids: &[u32]) -> Result<Vec<f32>> {
601        let g = self
602            .generator
603            .as_mut()
604            .ok_or_else(|| anyhow!("F32 generator unavailable (packed_weights mode)"))?;
605        g.prefill_get_last_logits(prompt_ids)
606    }
607    fn decode_logits(&mut self, token: u32) -> Result<Vec<f32>> {
608        let g = self
609            .generator
610            .as_mut()
611            .ok_or_else(|| anyhow!("F32 generator unavailable (packed_weights mode)"))?;
612        g.decode_get_logits(token)
613    }
614    fn prefill_logits_reusing(
615        &mut self,
616        prompt: &[u32],
617        snap: &rlx_runtime::lm::SessionSnapshot,
618        reuse_len: usize,
619    ) -> Result<Vec<f32>> {
620        let g = self
621            .generator
622            .as_mut()
623            .ok_or_else(|| anyhow!("F32 generator unavailable (packed_weights mode)"))?;
624        g.prefill_with_reuse(prompt, snap.kv.clone(), reuse_len)
625    }
626    fn export_session(&self) -> Option<rlx_runtime::lm::SessionSnapshot> {
627        let g = self.generator.as_ref()?;
628        let (kv, tokens) = g.export_cache()?;
629        Some(rlx_runtime::lm::SessionSnapshot { kv, tokens })
630    }
631    fn restore_session(&mut self, snap: &rlx_runtime::lm::SessionSnapshot) -> bool {
632        match self.generator.as_mut() {
633            Some(g) => {
634                g.restore_cache(snap.kv.clone(), snap.tokens.clone());
635                true
636            }
637            None => false,
638        }
639    }
640}
641
642fn load_gguf_config(
643    path: &Path,
644    override_src: Option<&Qwen3ConfigSource>,
645) -> Result<(Qwen3Config, u64)> {
646    let raw = assert_gguf_family(path, GgufModelFamily::Qwen3)?;
647    let cfg = match override_src {
648        Some(Qwen3ConfigSource::Explicit(c)) => c.clone(),
649        Some(Qwen3ConfigSource::JsonFile(p)) => {
650            Qwen3Config::from_file(p).with_context(|| format!("reading override config {p:?}"))?
651        }
652        Some(Qwen3ConfigSource::Embedded) | None => qwen3_cfg_from_gguf(&raw)?,
653    };
654    Ok((cfg, gguf_f32_bytes_estimate(&raw)))
655}
656
657fn load_safetensors_config(
658    path: &Path,
659    override_src: Option<&Qwen3ConfigSource>,
660) -> Result<(Qwen3Config, u64)> {
661    let cfg_path = match override_src {
662        Some(Qwen3ConfigSource::Explicit(c)) => {
663            return Ok((c.clone(), default_st_size_estimate(path)));
664        }
665        Some(Qwen3ConfigSource::JsonFile(p)) => p.clone(),
666        Some(Qwen3ConfigSource::Embedded) => {
667            bail!("Qwen3ConfigSource::Embedded only valid for GGUF; pass JsonFile for safetensors")
668        }
669        None => path
670            .parent()
671            .ok_or_else(|| anyhow!("weights path has no parent dir"))?
672            .join("config.json"),
673    };
674    let cfg = Qwen3Config::from_file(&cfg_path)
675        .with_context(|| format!("reading config {cfg_path:?}"))?;
676    Ok((cfg, default_st_size_estimate(path)))
677}
678
679fn default_st_size_estimate(path: &Path) -> u64 {
680    std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
681}
682
683fn qwen3_cfg_from_gguf(raw: &GgufFile) -> Result<Qwen3Config> {
684    let arch_prefix = raw
685        .metadata
686        .get("general.architecture")
687        .and_then(MetaValue::as_str)
688        .unwrap_or("qwen3");
689    let get_meta = |k: &str| -> Option<&MetaValue> {
690        raw.metadata.get(k).or_else(|| {
691            let suffix = k.strip_prefix("qwen3.")?;
692            if arch_prefix == "qwen3" {
693                None
694            } else {
695                let arch_key = format!("{arch_prefix}.{suffix}");
696                raw.metadata.get(&arch_key)
697            }
698        })
699    };
700    let get_u32 = |k: &str| -> Result<u32> {
701        get_meta(k)
702            .and_then(MetaValue::as_u32)
703            .ok_or_else(|| anyhow!("missing GGUF metadata key: {k}"))
704    };
705    let get_f32 = |k: &str| -> Option<f32> {
706        get_meta(k).and_then(|v| match v {
707            MetaValue::F32(x) => Some(*x),
708            _ => None,
709        })
710    };
711    let get_bool = |k: &str| -> Option<bool> {
712        get_meta(k).and_then(|v| match v {
713            MetaValue::Bool(b) => Some(*b),
714            _ => None,
715        })
716    };
717    // Per-arch tensor-shape conventions:
718    //   * Qwen 3 has QK-norm (RMS on Q/K per head before RoPE) and NO
719    //     biases on Q/K/V projections.
720    //   * Qwen 2 / 2.5 have NO QK-norm and DO ship biases on Q/K/V.
721    // Both share `general.architecture = qwen2 | qwen3 | qwen3_moe`
722    // when converted by llama.cpp's gguf-py, so we dispatch on the
723    // arch tag rather than asking the loader to probe tensor keys.
724    let is_qwen2 = arch_prefix == "qwen2";
725    let qk_norm_default = !is_qwen2;
726    let attention_bias_default = is_qwen2;
727    let is_moe = matches!(arch_prefix, "qwen3moe" | "qwen3_moe");
728
729    let hidden_size = get_u32("qwen3.embedding_length")? as usize;
730    let num_attention_heads = get_u32("qwen3.attention.head_count")? as usize;
731    // GGUFs that omit `<arch>.attention.key_length` must use
732    // `hidden_size / num_attention_heads` rather than a hard-coded 128 —
733    // Qwen 2.5 0.5B has hidden=896, heads=14, head_dim=64 with no
734    // explicit key_length field.
735    let head_dim_default = if num_attention_heads > 0 {
736        hidden_size.checked_div(num_attention_heads).unwrap_or(128)
737    } else {
738        128
739    };
740
741    Ok(Qwen3Config {
742        vocab_size: get_u32("qwen3.vocab_size").unwrap_or(151_936) as usize,
743        hidden_size,
744        intermediate_size: get_u32("qwen3.feed_forward_length")? as usize,
745        num_hidden_layers: get_u32("qwen3.block_count")? as usize,
746        num_attention_heads,
747        num_key_value_heads: get_u32("qwen3.attention.head_count_kv")? as usize,
748        head_dim: get_u32("qwen3.attention.key_length")
749            .map(|v| v as usize)
750            .unwrap_or(head_dim_default),
751        attention_bias: attention_bias_default,
752        qk_norm: qk_norm_default,
753        max_position_embeddings: get_u32("qwen3.context_length").unwrap_or(40_960) as usize,
754        sliding_window: None,
755        max_window_layers: 0,
756        tie_word_embeddings: get_bool("qwen3.tie_word_embeddings").unwrap_or(true),
757        rope_theta: get_f32("qwen3.rope.freq_base").unwrap_or(1_000_000.0) as f64,
758        rms_norm_eps: get_f32("qwen3.attention.layer_norm_rms_epsilon").unwrap_or(1e-6) as f64,
759        use_sliding_window: false,
760        hidden_act: "silu".into(),
761        // PLAN.md M1 — MoE field parsing for `qwen3-30b-a3b-instruct`
762        // and friends. Routing impl + per-layer MoE dispatch still
763        // need the shared `rlx-flow::blocks::moe` router (upstream).
764        num_experts: if is_moe {
765            get_u32("qwen3.expert_count").unwrap_or(0) as usize
766        } else {
767            0
768        },
769        num_experts_used: if is_moe {
770            get_u32("qwen3.expert_used_count").unwrap_or(0) as usize
771        } else {
772            0
773        },
774        expert_ffn_size: get_u32("qwen3.expert_feed_forward_length")
775            .map(|v| v as usize)
776            .unwrap_or(0),
777        shared_expert_ffn_size: get_u32("qwen3.expert_shared_feed_forward_length")
778            .map(|v| v as usize)
779            .unwrap_or(0),
780        expert_weights_scale: get_f32("qwen3.expert_weights_scale").unwrap_or(1.0),
781    })
782}