Skip to main content

rlx_gemma/
generator.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//! Host-side generation loop for Gemma.
17//!
18//! This is the **naive** generator: each `step()` rebuilds the prefill
19//! graph for the full token history and runs it from scratch
20//! (O(N²) compute over N generated tokens). The API is shaped to
21//! match the upcoming KV-cache version exactly so callers don't have
22//! to change anything when the cached path lands — only the internal
23//! implementation swaps.
24//!
25//! Why ship the naive version first:
26//!   - Establishes the public API contract before the IR/kernel
27//!     changes that the cached version needs land.
28//!   - Lets you run end-to-end generation against a real checkpoint
29//!     today and validate the prefill graph is numerically correct.
30//!   - Provides a reference baseline for the cached version's own
31//!     numerical-parity test (cached vs recompute must match).
32
33use crate::builder::{
34    build_gemma_decode_graph_sized, build_gemma_decode_hir_dynamic_ext,
35    build_gemma_decode_hir_sized_ext, build_gemma_graph_sized_last_logits,
36    build_gemma_graph_sized_last_logits_hidden, build_gemma_prefill_hidden_hir_dynamic_ext,
37    build_gemma_prefill_hir_dynamic_ext,
38};
39use crate::config::GemmaConfig;
40use crate::rope::{resolve_inv_freq, rope_slice};
41use anyhow::{Context, Result};
42use rlx_core::autoregressive::{
43    KvCacheState, kv_from_prefill_outputs_per_layer, run_bucketed_kv_decode_hir_scratch,
44    split_decode_logits_kv, split_decode_logits_kv_aux,
45};
46use rlx_core::flow_bridge::compile_options_from_profile;
47use rlx_core::gpu_kv::{
48    GpuKvBinding, device_supports_gpu_kv, run_bucketed_kv_decode_gpu_hir, sync_gpu_kv_to_host,
49};
50use rlx_core::weight_loader::WeightLoader;
51use rlx_core::weight_map::WeightMap;
52use rlx_flow::CompileProfile;
53use rlx_ir::DimBinding;
54use rlx_ir::logical_kernel::KernelDispatchConfig;
55use rlx_qwen3::sampling::{SampleOpts, sample_token};
56use rlx_runtime::compile_cache::{
57    BucketedCompileCache, CacheRunInput, CompileCache, DynamicDimCompileCache,
58};
59use rlx_runtime::{CompileOptions, Device, Session};
60use std::collections::HashMap;
61use std::path::Path;
62
63/// Decode compile profile with backend-specific fixes (Metal: optional unfused GQA).
64pub fn decode_profile_for_device(device: Device) -> CompileProfile {
65    metal_decode_profile(device, CompileProfile::gemma_decode())
66}
67
68/// When `RLX_GEMMA_METAL_THUNK_DECODE=1`, compile decode graphs with
69/// `RLX_DISABLE_MPSGRAPH=1` (Metal thunk path). Default: MPSGraph + tier-1
70/// fusion for throughput; use for parity/debug if bucketed decode miscompares.
71fn metal_thunk_decode_requested() -> bool {
72    std::env::var("RLX_GEMMA_METAL_THUNK_DECODE")
73        .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes"))
74}
75
76pub(crate) fn metal_decode_compile_guard<R, F>(device: Device, decode: bool, f: F) -> R
77where
78    F: FnOnce() -> R,
79{
80    if decode && metal_thunk_decode_requested() {
81        if device == Device::Metal {
82            rlx_ir::env::set("RLX_DISABLE_MPSGRAPH", "1");
83            let out = f();
84            rlx_ir::env::unset("RLX_DISABLE_MPSGRAPH");
85            out
86        } else {
87            f()
88        }
89    } else {
90        f()
91    }
92}
93
94/// When `RLX_GEMMA_METAL_UNFUSED_DECODE=1`, disable tier-1 fusion on Metal decode
95/// (MPSGraph GQA reshape workaround). Default: fusion **on** for throughput.
96fn metal_unfused_decode_requested() -> bool {
97    std::env::var("RLX_GEMMA_METAL_UNFUSED_DECODE")
98        .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes"))
99}
100
101fn metal_decode_profile(device: Device, mut profile: CompileProfile) -> CompileProfile {
102    if device == Device::Metal && metal_unfused_decode_requested() {
103        profile.fusion.skip = true;
104        profile.backend.metal.skip_fusion = true;
105        profile.backend.metal.unfuse_regions = true;
106    }
107    profile
108}
109
110/// Stateful Gemma generation handle.
111///
112/// Holds the (config, weight bytes, token history) and rebuilds a
113/// prefill graph on each [`step`] call. Cheap to construct after
114/// initial weight load; tokens stay in-memory between calls.
115pub struct GemmaGenerator {
116    cfg: GemmaConfig,
117    /// Map of weight key → (f32 data, shape). Cloned on each step
118    /// into a fresh `WeightMap` because `WeightMap::take` is
119    /// destructive — see the cached-generator notes for the path
120    /// that avoids the clone.
121    weights_cache: HashMap<String, (Vec<f32>, Vec<usize>)>,
122    tokens: Vec<u32>,
123    device: Device,
124    /// Populated lazily on the first `step_cached` call (seeded from
125    /// the prompt via prefill-with-cache); thereafter advanced by each
126    /// decode step.
127    cache: Option<KvCacheState>,
128    /// Per-key LRU compile cache for prefill graphs. Keyed by `seq`.
129    /// Set to `None` to disable (default for new instances; opt in via
130    /// [`GemmaGenerator::with_prefill_cache`]).
131    prefill_compile_cache: Option<CompileCache>,
132    /// Compile prefill once with `sym::SEQ`, specialize per prompt length.
133    prefill_dynamic_cache: Option<DynamicDimCompileCache>,
134    /// Same as above but for fused `prefill_hidden` (multimodal).
135    embed_prefill_compile_cache: Option<CompileCache>,
136    embed_prefill_dynamic_cache: Option<DynamicDimCompileCache>,
137    /// Bucketed compile cache for decode-mode graphs. Each bucket
138    /// holds one compiled graph specialized at its upper-bound
139    /// `past_seq`; the host pads `past_k`/`past_v` and supplies a
140    /// per-step mask so a single bucket serves every `past_seq` in
141    /// its range. Opt in via [`GemmaGenerator::with_decode_cache`].
142    decode_compile_cache: Option<BucketedCompileCache>,
143    decode_dynamic_cache: Option<DynamicDimCompileCache>,
144    /// Resolved RoPE inverse frequencies (includes Llama 3 scaling).
145    inv_freq: Vec<f64>,
146    /// Tier-1 compile profile for prefill graphs.
147    prefill_profile: CompileProfile,
148    /// Tier-1 compile profile for decode graphs.
149    decode_profile: CompileProfile,
150    /// Fused prefill embeddings for the next cached prefill (multimodal).
151    pending_prefill_embeds: Option<Vec<f32>>,
152    pending_prefill_attn_bias: Option<Vec<f32>>,
153    /// GPU-resident K/V on Metal/MLX/CUDA decode (logits-only readback).
154    use_gpu_kv: bool,
155    gpu_kv_binding: GpuKvBinding,
156    /// Reused decode inputs (mask, padded K/V) to avoid per-step allocs.
157    decode_scratch: DecodeKvScratch,
158    decode_inputs: DecodeInputScratch,
159    /// EAGLE3-style aux hidden-state tap: when non-empty, decode steps
160    /// build a graph with `with_aux_hidden_layer_ids(...)` set,
161    /// extract the per-layer hidden states from the graph outputs,
162    /// and stash them in [`last_aux`] for the caller to retrieve.
163    /// Forces the `decode_step_oneshot` path (the bucketed compile
164    /// cache doesn't yet handle the extra outputs).
165    aux_hidden_layer_ids: Vec<usize>,
166    /// Aux hidden states from the most recent decode step. One
167    /// `Vec<f32>` per id in [`aux_hidden_layer_ids`], each of length
168    /// `batch * 1 * hidden_size`. Cleared by [`take_last_aux`].
169    last_aux: Option<Vec<Vec<f32>>>,
170}
171
172/// Reusable mask + rope slices for bucketed decode (no per-step alloc).
173#[derive(Default)]
174struct DecodeInputScratch {
175    mask: Vec<f32>,
176    cos: Vec<f32>,
177    sin: Vec<f32>,
178}
179
180/// Per-session padded K/V upload buffers (resized when bucket upper changes).
181#[derive(Default)]
182struct DecodeKvScratch {
183    padded_k: Vec<Vec<f32>>,
184    padded_v: Vec<Vec<f32>>,
185    bucket_upper: usize,
186}
187
188impl DecodeInputScratch {
189    fn fill_mask(&mut self, past_seq: usize, upper: usize) {
190        if self.mask.len() != upper + 1 {
191            self.mask.resize(upper + 1, 0.0);
192        }
193        for (i, m) in self.mask.iter_mut().enumerate().take(upper + 1) {
194            *m = if i < past_seq || i == upper { 1.0 } else { 0.0 };
195        }
196    }
197
198    fn fill_rope(&mut self, inv_freq: &[f64], pos: usize) {
199        let half = inv_freq.len();
200        self.cos.resize(half, 0.0);
201        self.sin.resize(half, 0.0);
202        for (i, &freq) in inv_freq.iter().enumerate() {
203            let angle = pos as f64 * freq;
204            let (s, c) = angle.sin_cos();
205            self.cos[i] = c as f32;
206            self.sin[i] = s as f32;
207        }
208    }
209}
210
211impl DecodeKvScratch {
212    fn ensure_bucket(&mut self, upper: usize, kv_dims: &[usize]) {
213        if self.bucket_upper == upper && self.padded_k.len() == kv_dims.len() {
214            return;
215        }
216        self.bucket_upper = upper;
217        self.padded_k = kv_dims.iter().map(|&d| vec![0.0_f32; upper * d]).collect();
218        self.padded_v = kv_dims.iter().map(|&d| vec![0.0_f32; upper * d]).collect();
219    }
220}
221
222fn gemma_use_gpu_kv(device: Device) -> bool {
223    if !device_supports_gpu_kv(device) {
224        return false;
225    }
226    match std::env::var("RLX_GEMMA_GPU_KV").ok().as_deref() {
227        Some("0") | Some("false") | Some("no") => false,
228        Some("1") | Some("true") | Some("yes") => true,
229        // Host readback path is faster on Gemma Metal today; Whisper/Qwen3-TTS differ.
230        _ => false,
231    }
232}
233
234impl GemmaGenerator {
235    /// Construct from any [`WeightLoader`] — drains it into an
236    /// internal cache so the loader is free after this call.
237    pub fn from_loader(
238        cfg: GemmaConfig,
239        loader: &mut dyn WeightLoader,
240        device: Device,
241    ) -> Result<Self> {
242        let keys = loader.remaining_keys();
243        // Capture the arch up front so the cache-key normalization can
244        // pick the gemma2 reverse alias (4 distinct per-layer norms)
245        // over the generic Llama-flavored one (2 norms, ambiguous on
246        // `ffn_norm`). Owned string so we don't hold a borrow across
247        // the mutable `loader.take` calls below.
248        let arch_hint: Option<String> = loader.arch_hint().map(|s| s.to_string());
249        let mut weights_cache = HashMap::with_capacity(keys.len());
250        for k in keys {
251            let v = loader
252                .take(&k)
253                .with_context(|| format!("draining weight {k}"))?;
254            // Normalize the cache key to the safetensors / HuggingFace
255            // naming convention so subsequent builder calls that ask
256            // for `model.embed_tokens.weight` (the canonical name baked
257            // into the gemma builder) hit the cache whether the
258            // loader was safetensors-native or GGUF-native.
259            let canonical = match arch_hint.as_deref() {
260                Some(a) => rlx_core::weight_loader::gguf_to_hf_name_for_arch(&k, a)
261                    .unwrap_or_else(|| k.clone()),
262                None => rlx_core::weight_loader::gguf_to_hf_name(&k).unwrap_or_else(|| k.clone()),
263            };
264            weights_cache.insert(canonical, v);
265        }
266        let rope_factors = weights_cache
267            .get("rope_freqs.weight")
268            .map(|(d, _)| d.as_slice());
269        let inv_freq = resolve_inv_freq(&cfg, rope_factors);
270        Ok(Self {
271            cfg,
272            weights_cache,
273            tokens: Vec::new(),
274            device,
275            cache: None,
276            prefill_compile_cache: None,
277            prefill_dynamic_cache: None,
278            embed_prefill_compile_cache: None,
279            embed_prefill_dynamic_cache: None,
280            decode_compile_cache: None,
281            decode_dynamic_cache: None,
282            inv_freq,
283            prefill_profile: CompileProfile::gemma_prefill(),
284            decode_profile: metal_decode_profile(device, CompileProfile::gemma_decode()),
285            pending_prefill_embeds: None,
286            pending_prefill_attn_bias: None,
287            use_gpu_kv: gemma_use_gpu_kv(device),
288            gpu_kv_binding: GpuKvBinding::default(),
289            decode_scratch: DecodeKvScratch::default(),
290            decode_inputs: DecodeInputScratch::default(),
291            aux_hidden_layer_ids: Vec::new(),
292            last_aux: None,
293        })
294    }
295
296    /// EAGLE3-style aux hidden state tap. When `ids` is non-empty,
297    /// every subsequent decode step builds the graph with
298    /// `with_aux_hidden_layer_ids(ids)` and extracts the per-layer
299    /// hidden states. Call [`take_last_aux`] after each
300    /// `step_cached` / `generate_cached_with` token to retrieve
301    /// them. Setting this disables the bucketed-decode fast path —
302    /// each step compiles via `decode_step_oneshot`.
303    pub fn set_aux_hidden_layer_ids(&mut self, ids: Vec<usize>) {
304        // The graph builder dedups + sorts the ids and filters OOB
305        // values. Mirror that here so `self.aux_hidden_layer_ids.len()`
306        // matches the number of aux outputs the graph actually emits.
307        let mut v: Vec<usize> = ids
308            .into_iter()
309            .filter(|i| *i < self.cfg.num_hidden_layers)
310            .collect();
311        v.sort_unstable();
312        v.dedup();
313        self.aux_hidden_layer_ids = v;
314        self.last_aux = None;
315    }
316
317    /// Take + clear the most recent aux hidden states.
318    pub fn take_last_aux(&mut self) -> Option<Vec<Vec<f32>>> {
319        self.last_aux.take()
320    }
321
322    /// True if an aux tap is currently configured.
323    pub fn aux_enabled(&self) -> bool {
324        !self.aux_hidden_layer_ids.is_empty()
325    }
326
327    fn reset_gpu_kv_binding(&mut self) {
328        self.gpu_kv_binding = GpuKvBinding::default();
329    }
330
331    /// Like [`Self::from_loader`] but loads tier-1 profiles from
332    /// `gemma.rlx.toml` in the weights directory when present.
333    pub fn from_loader_at(
334        cfg: GemmaConfig,
335        loader: &mut dyn WeightLoader,
336        device: Device,
337        weights_path: &Path,
338    ) -> Result<Self> {
339        let mut g = Self::from_loader(cfg, loader, device)?;
340        g.prefill_profile = crate::gemma_profile_near_weights(weights_path, false);
341        g.decode_profile = metal_decode_profile(
342            device,
343            crate::gemma_profile_near_weights(weights_path, true),
344        );
345        Ok(g)
346    }
347
348    /// Override tier-1 compile profiles explicitly.
349    pub fn with_compile_profiles(
350        mut self,
351        prefill: CompileProfile,
352        decode: CompileProfile,
353    ) -> Self {
354        self.prefill_profile = prefill;
355        self.decode_profile = metal_decode_profile(self.device, decode);
356        self
357    }
358
359    pub fn prefill_profile(&self) -> &CompileProfile {
360        &self.prefill_profile
361    }
362
363    pub fn decode_profile(&self) -> &CompileProfile {
364        &self.decode_profile
365    }
366
367    fn profile_compile_options(&self, decode: bool) -> CompileOptions {
368        let profile = if decode {
369            &self.decode_profile
370        } else {
371            &self.prefill_profile
372        };
373        compile_options_from_profile(profile, self.device, KernelDispatchConfig::default())
374    }
375
376    fn compile_graph_profiled(
377        &self,
378        session: &Session,
379        graph: rlx_ir::Graph,
380    ) -> Result<rlx_runtime::CompiledGraph> {
381        let opts = self.profile_compile_options(false);
382        Ok(session.compile_with(graph, &opts))
383    }
384
385    fn compile_graph_profiled_decode(
386        &self,
387        session: &Session,
388        graph: rlx_ir::Graph,
389    ) -> Result<rlx_runtime::CompiledGraph> {
390        Ok(metal_decode_compile_guard(self.device, true, || {
391            session.compile_with(graph, &self.profile_compile_options(true))
392        }))
393    }
394
395    /// Enable the prefill compile cache with the given LRU capacity.
396    /// Useful when the same prompt length is used across multiple
397    /// generation runs — the second + Nth run skip the compile +
398    /// param-attach roundtrip (~30-50ms per call on CPU).
399    pub fn with_prefill_cache(mut self, capacity: usize) -> Self {
400        self.prefill_compile_cache = Some(CompileCache::new(self.device, capacity));
401        self.embed_prefill_compile_cache = Some(CompileCache::new(self.device, capacity));
402        self.prefill_dynamic_cache = None;
403        self.embed_prefill_dynamic_cache = None;
404        self
405    }
406
407    /// Compile prefill once with `sym::SEQ`, specialize per prompt length.
408    pub fn with_dynamic_prefill_cache(mut self, capacity: usize) -> Self {
409        self.prefill_dynamic_cache = Some(DynamicDimCompileCache::new(self.device, capacity));
410        self.embed_prefill_dynamic_cache = Some(DynamicDimCompileCache::new(self.device, capacity));
411        self.prefill_compile_cache = None;
412        self.embed_prefill_compile_cache = None;
413        self
414    }
415
416    /// Enable the bucketed decode compile cache spanning past-seq
417    /// values in `[1, max_past]`. Buckets are power-of-two
418    /// `[1..2, 2..3, 3..5, 5..9, 9..17, …]`. Each bucket compiles
419    /// one graph at its upper bound; a steady-state generation loop
420    /// across `N` tokens compiles `O(log N)` graphs instead of `N`.
421    ///
422    /// Padding compute waste is bounded at 2×: actual `past_seq` is
423    /// at least half the bucket's upper bound (except possibly the
424    /// smallest bucket).
425    pub fn with_decode_cache(mut self, max_past: usize) -> Self {
426        let cache = BucketedCompileCache::power_of_two_ladder(
427            self.device,
428            /*min*/ 1,
429            max_past.max(1) as u64,
430        );
431        self.decode_compile_cache = Some(cache);
432        self.decode_dynamic_cache = None;
433        self
434    }
435
436    /// Compile decode once with `sym::PAST_SEQ`, specialize per prefix length.
437    pub fn with_dynamic_decode_cache(mut self, capacity: usize) -> Self {
438        self.decode_dynamic_cache = Some(DynamicDimCompileCache::new(self.device, capacity));
439        self.decode_compile_cache = None;
440        self
441    }
442
443    fn inference_dynamic_decode() -> bool {
444        std::env::var("RLX_GEMMA_DYNAMIC_DECODE").is_ok_and(|v| {
445            v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes")
446        })
447    }
448
449    /// Production inference caches: dynamic prefill (+ multimodal embed prefill)
450    /// and bucketed decode. Bucketed decode avoids the per-step specialize
451    /// overhead of `RLX_GEMMA_DYNAMIC_DECODE=1` (experimental; often much slower on Metal).
452    pub fn with_inference_caches(mut self, max_seq: usize) -> Self {
453        // Match decode buckets to the session horizon (+slack). Avoid a global
454        // 256-token floor — padded bucket attention on Metal is costly for chat.
455        let decode_horizon = max_seq.saturating_add(16).max(32);
456        self = self.with_dynamic_prefill_cache(16);
457        if Self::inference_dynamic_decode() {
458            self.with_dynamic_decode_cache(32)
459        } else {
460            self.with_decode_cache(decode_horizon)
461        }
462    }
463
464    /// Wait for in-flight Metal command buffers on all cached graphs.
465    /// Call between heavy inference phases to avoid MPS lifecycle warnings.
466    pub fn sync_device(&mut self) {
467        if let Some(c) = &mut self.prefill_compile_cache {
468            c.sync_all();
469        }
470        if let Some(c) = &mut self.embed_prefill_compile_cache {
471            c.sync_all();
472        }
473        if let Some(c) = &mut self.prefill_dynamic_cache {
474            c.sync_all();
475        }
476        if let Some(c) = &mut self.embed_prefill_dynamic_cache {
477            c.sync_all();
478        }
479        if let Some(c) = &mut self.decode_compile_cache {
480            c.sync_all();
481        }
482        if let Some(c) = &mut self.decode_dynamic_cache {
483            c.sync_all();
484        }
485        rlx_runtime::device_ext::drain_device(self.device);
486    }
487
488    /// Convenience: load weights from a safetensors or GGUF path
489    /// (dispatch by extension; see `rlx_core::weight_loader::load_from_path`).
490    pub fn from_path(cfg: GemmaConfig, path: &str, device: Device) -> Result<Self> {
491        let mut loader = rlx_core::weight_loader::load_from_path(path)?;
492        Self::from_loader(cfg, loader.as_mut(), device)
493    }
494
495    /// Same as [`from_path`] but with MTP-head visibility control.
496    /// When `include_mtp=true` and the file is GGUF, MTP weights are
497    /// drained into the generator's cache alongside the base
498    /// weights. The base inference path still ignores them — they
499    /// sit in cache for a future MTP-aware decoder. Non-GGUF formats
500    /// silently ignore the flag (safetensors files publish all
501    /// tensors uniformly; downstream code distinguishes by name).
502    pub fn from_path_with_mtp(
503        cfg: GemmaConfig,
504        path: &str,
505        device: Device,
506        include_mtp: bool,
507    ) -> Result<Self> {
508        // Branch on extension so we can flip the GGUF-specific
509        // visibility option. Safetensors has no equivalent — it
510        // doesn't isolate MTP tensors at the loader level.
511        if path.ends_with(".gguf") {
512            let mut gguf = rlx_core::weight_loader::GgufLoader::from_file(path)?;
513            gguf.include_mtp(include_mtp);
514            Self::from_loader(cfg, &mut gguf, device)
515        } else {
516            Self::from_path(cfg, path, device)
517        }
518    }
519
520    /// Replace the token history with `prompt_ids`. Does not run the
521    /// model — the next [`step`] call processes the full sequence.
522    /// Clears any KV cache from a prior generation.
523    pub fn prefill(&mut self, prompt_ids: &[u32]) {
524        self.tokens.clear();
525        self.tokens.extend_from_slice(prompt_ids);
526        self.cache = None;
527        self.reset_gpu_kv_binding();
528    }
529
530    /// Like [`prefill`], but the next cached prefill uses fused
531    /// `inputs_embeds` (`prefill_hidden`) instead of token lookup.
532    pub fn prefill_from_embeds(
533        &mut self,
534        prompt_ids: &[u32],
535        embeds: &[f32],
536        attn_bias: Option<Vec<f32>>,
537    ) -> Result<()> {
538        let h = self.cfg.hidden_size;
539        if embeds.len() != prompt_ids.len() * h {
540            anyhow::bail!(
541                "prefill_from_embeds: embeds len {} != {} tokens × hidden {}",
542                embeds.len(),
543                prompt_ids.len(),
544                h
545            );
546        }
547        if let Some(ref bias) = attn_bias {
548            let seq = prompt_ids.len();
549            let nh = self.cfg.num_attention_heads;
550            let expected = seq * seq * nh;
551            if bias.len() != expected {
552                anyhow::bail!(
553                    "prefill_from_embeds: attn_bias len {} != batch×heads×seq² ({expected})",
554                    bias.len()
555                );
556            }
557        }
558        self.prefill(prompt_ids);
559        self.pending_prefill_embeds = Some(embeds.to_vec());
560        self.pending_prefill_attn_bias = attn_bias;
561        Ok(())
562    }
563
564    /// Weight table for CPU-side embedding / fusion helpers.
565    pub fn weights_cache(&self) -> &HashMap<String, (Vec<f32>, Vec<usize>)> {
566        &self.weights_cache
567    }
568
569    /// Run one prefill over the current token history and sample the
570    /// next token. The sampled token is appended to the history and
571    /// returned. Call repeatedly to generate.
572    pub fn step(&mut self, opts: SampleOpts) -> Result<u32> {
573        if self.tokens.is_empty() {
574            anyhow::bail!("step() called with empty token history; call prefill() first");
575        }
576        let seq = self.tokens.len();
577        let mut wm = WeightMap::from_tensors(self.weights_cache.clone());
578        let (graph, params) = build_gemma_graph_sized_last_logits(
579            &self.cfg, &mut wm, /*batch*/ 1, seq, /*with_kv_outputs*/ false,
580        )?;
581        let session = Session::new(self.device);
582        let mut compiled = self.compile_graph_profiled(&session, graph)?;
583        for (name, data) in &params {
584            compiled.set_param(name, data);
585        }
586        let ids_f32: Vec<f32> = self.tokens.iter().map(|&i| i as f32).collect();
587        let outputs = compiled.run(&[("input_ids", ids_f32.as_slice())]);
588        let logits = outputs
589            .into_iter()
590            .next()
591            .context("compiled.run returned no outputs")?;
592
593        let vocab = self.cfg.vocab_size;
594        let expected = vocab;
595        if logits.len() < expected {
596            anyhow::bail!(
597                "logits length {} < expected {} (last logits, seq {seq}, vocab {vocab})",
598                logits.len(),
599                expected
600            );
601        }
602        // Last-logits graph returns [B=1, 1, vocab].
603        let last_row = &logits[..vocab];
604        let tok = sample_token(last_row, opts) as u32;
605        self.tokens.push(tok);
606        Ok(tok)
607    }
608
609    /// Run `n` steps and return the newly generated token ids
610    /// (excludes the prefill prompt).
611    pub fn generate(&mut self, n: usize, opts: SampleOpts) -> Result<Vec<u32>> {
612        let start = self.tokens.len();
613        for _ in 0..n {
614            self.step(opts)?;
615        }
616        Ok(self.tokens[start..].to_vec())
617    }
618
619    /// Cached step: O(L) per token instead of O(L²). First call seeds
620    /// the KV cache from the prompt via prefill-with-cache; subsequent
621    /// calls run the decode-mode graph on just the last token + cached
622    /// past. Output is bit-identical to [`step`] modulo reduction
623    /// order in the SDPA kernel.
624    ///
625    /// Invariant after each call: `cache.past_seq == tokens.len() - 1`
626    /// (the just-sampled token is appended but not yet in the cache;
627    /// it becomes the input for the next decode step).
628    pub fn step_cached(&mut self, opts: SampleOpts) -> Result<u32> {
629        if self.tokens.is_empty() {
630            anyhow::bail!("step_cached() called with empty token history; call prefill() first");
631        }
632        if self.cache.is_none() {
633            // The seed runs prefill, populates the cache, samples from
634            // the last position, and appends the token. Return that
635            // token directly — no decode step on this call.
636            let tok = self.seed_cache_from_prompt(opts)?;
637            return Ok(tok);
638        }
639        let cache = self.cache.as_ref().unwrap();
640        let past_seq = cache.past_len;
641        if self.tokens.len() <= past_seq {
642            anyhow::bail!(
643                "cache invariant violated: tokens.len() {} <= past_len {}",
644                self.tokens.len(),
645                past_seq
646            );
647        }
648        let input_tok = self.tokens[past_seq];
649
650        // EAGLE3 tap forces the always-correct oneshot path — the
651        // bucketed compile cache doesn't yet ingest the extra aux
652        // outputs.
653        let aux_active = self.aux_enabled();
654        let (logits, new_k, new_v) = if aux_active {
655            let (logits, k, v, aux) = self.decode_step_oneshot_with_aux(past_seq, input_tok)?;
656            self.last_aux = Some(aux);
657            (logits, k, v)
658        } else if self.decode_dynamic_cache.is_some() {
659            self.decode_step_dynamic(past_seq, input_tok)?
660        } else if self.decode_compile_cache.is_some()
661            && self
662                .decode_compile_cache
663                .as_ref()
664                .unwrap()
665                .bucket_for(past_seq as u64)
666                .is_some()
667        {
668            self.decode_step_bucketed(past_seq, input_tok)?
669        } else {
670            self.decode_step_oneshot(past_seq, input_tok)?
671        };
672
673        let cache_mut = self.cache.as_mut().unwrap();
674        cache_mut.past_len = past_seq + 1;
675        cache_mut.layers_k = new_k;
676        cache_mut.layers_v = new_v;
677
678        let vocab = self.cfg.vocab_size;
679        if logits.len() != vocab {
680            anyhow::bail!("decode logits length {} != vocab {}", logits.len(), vocab);
681        }
682        let tok = sample_token(&logits, opts) as u32;
683        self.tokens.push(tok);
684        Ok(tok)
685    }
686
687    /// Decode path that compiles a fresh graph for the exact `past_seq`
688    /// every call. Slower but always-correct fallback.
689    #[allow(clippy::type_complexity)]
690    fn decode_step_oneshot(
691        &mut self,
692        past_seq: usize,
693        input_tok: u32,
694    ) -> Result<(Vec<f32>, Vec<Vec<f32>>, Vec<Vec<f32>>)> {
695        let cache = self.cache.as_ref().unwrap();
696
697        let mut wm = WeightMap::from_tensors(self.weights_cache.clone());
698        let (graph, params) =
699            build_gemma_decode_graph_sized(&self.cfg, &mut wm, /*batch*/ 1, past_seq)?;
700        let session = Session::new(self.device);
701        let mut compiled = self.compile_graph_profiled_decode(&session, graph)?;
702        for (name, data) in &params {
703            compiled.set_param(name, data);
704        }
705
706        let input_ids_f32 = [input_tok as f32];
707        let key_strs: Vec<String> = (0..self.cfg.num_hidden_layers)
708            .flat_map(|i| [format!("past_k_{i}"), format!("past_v_{i}")])
709            .collect();
710        let mut inputs: Vec<(&str, &[f32])> =
711            Vec::with_capacity(1 + 2 * self.cfg.num_hidden_layers);
712        inputs.push(("input_ids", input_ids_f32.as_slice()));
713        for i in 0..self.cfg.num_hidden_layers {
714            inputs.push((&key_strs[2 * i], cache.layers_k[i].as_slice()));
715            inputs.push((&key_strs[2 * i + 1], cache.layers_v[i].as_slice()));
716        }
717
718        let outputs = compiled.run(&inputs);
719        split_decode_logits_kv(outputs, self.cfg.num_hidden_layers)
720    }
721
722    /// EAGLE3 variant of [`decode_step_oneshot`]. Builds the decode
723    /// graph with `aux_hidden_layer_ids` set on `GemmaDecodeOpts` so
724    /// the per-layer pre-attention-norm hidden states are appended
725    /// to the graph's outputs. Returns `(logits, K, V, aux)` where
726    /// `aux.len() == self.aux_hidden_layer_ids.len()`.
727    #[allow(clippy::type_complexity)]
728    fn decode_step_oneshot_with_aux(
729        &mut self,
730        past_seq: usize,
731        input_tok: u32,
732    ) -> Result<(Vec<f32>, Vec<Vec<f32>>, Vec<Vec<f32>>, Vec<Vec<f32>>)> {
733        let cache = self.cache.as_ref().unwrap();
734        let aux_ids = self.aux_hidden_layer_ids.clone();
735        let num_aux = aux_ids.len();
736
737        let mut wm = WeightMap::from_tensors(self.weights_cache.clone());
738        let opts = crate::flow::GemmaDecodeOpts {
739            batch: 1,
740            past_seq,
741            dynamic_past: false,
742            use_custom_mask: false,
743            profile: None,
744            aux_hidden_layer_ids: aux_ids,
745        };
746        let (graph, params) = crate::flow::build_gemma_decode_graph(&self.cfg, &mut wm, &opts)?;
747        let session = Session::new(self.device);
748        let mut compiled = self.compile_graph_profiled_decode(&session, graph)?;
749        for (name, data) in &params {
750            compiled.set_param(name, data);
751        }
752
753        let input_ids_f32 = [input_tok as f32];
754        let key_strs: Vec<String> = (0..self.cfg.num_hidden_layers)
755            .flat_map(|i| [format!("past_k_{i}"), format!("past_v_{i}")])
756            .collect();
757        let mut inputs: Vec<(&str, &[f32])> =
758            Vec::with_capacity(1 + 2 * self.cfg.num_hidden_layers);
759        inputs.push(("input_ids", input_ids_f32.as_slice()));
760        for i in 0..self.cfg.num_hidden_layers {
761            inputs.push((&key_strs[2 * i], cache.layers_k[i].as_slice()));
762            inputs.push((&key_strs[2 * i + 1], cache.layers_v[i].as_slice()));
763        }
764
765        let outputs = compiled.run(&inputs);
766        split_decode_logits_kv_aux(outputs, self.cfg.num_hidden_layers, num_aux)
767    }
768
769    #[allow(clippy::type_complexity)]
770    fn decode_step_dynamic(
771        &mut self,
772        past_seq: usize,
773        input_tok: u32,
774    ) -> Result<(Vec<f32>, Vec<Vec<f32>>, Vec<Vec<f32>>)> {
775        let cache = self.cache.as_ref().unwrap();
776        let binding = DimBinding::batch_past_seq(1, past_seq);
777        let opts = self
778            .profile_compile_options(true)
779            .dim_binding(binding.clone());
780        let cache_dyn = self
781            .decode_dynamic_cache
782            .as_mut()
783            .ok_or_else(|| anyhow::anyhow!("dynamic decode without cache"))?;
784        let needs_upload = !cache_dyn.contains(past_seq as u64);
785        let cfg = self.cfg.clone();
786        let weights_cache = self.weights_cache.clone();
787        let max_past = self.cfg.max_position_embeddings;
788        let compiled = metal_decode_compile_guard(self.device, true, || {
789            cache_dyn.get_or_specialize(
790                past_seq as u64,
791                &binding,
792                || {
793                    let mut wm = WeightMap::from_tensors(weights_cache);
794                    build_gemma_decode_hir_dynamic_ext(&cfg, &mut wm, 1, max_past)
795                        .expect("dynamic decode HIR")
796                        .0
797                },
798                &opts,
799            )
800        })?;
801        if needs_upload {
802            let mut wm = WeightMap::from_tensors(self.weights_cache.clone());
803            let (_, params) = build_gemma_decode_hir_dynamic_ext(&self.cfg, &mut wm, 1, max_past)?;
804            for (name, data) in &params {
805                compiled.set_param(name, data);
806            }
807        }
808
809        let (cos, sin) = compute_rope_slice(&self.inv_freq, past_seq);
810        let input_ids_f32 = [input_tok as f32];
811        let key_strs: Vec<String> = (0..self.cfg.num_hidden_layers)
812            .flat_map(|i| [format!("past_k_{i}"), format!("past_v_{i}")])
813            .collect();
814        let mut inputs: Vec<(&str, &[f32])> =
815            Vec::with_capacity(3 + 2 * self.cfg.num_hidden_layers);
816        inputs.push(("input_ids", input_ids_f32.as_slice()));
817        inputs.push(("rope_cos", cos.as_slice()));
818        inputs.push(("rope_sin", sin.as_slice()));
819        for i in 0..self.cfg.num_hidden_layers {
820            inputs.push((&key_strs[2 * i], cache.layers_k[i].as_slice()));
821            inputs.push((&key_strs[2 * i + 1], cache.layers_v[i].as_slice()));
822        }
823        let outputs = compiled.run(&inputs);
824        split_decode_logits_kv(outputs, self.cfg.num_hidden_layers)
825    }
826
827    #[allow(clippy::type_complexity)]
828    fn decode_step_bucketed(
829        &mut self,
830        past_seq: usize,
831        input_tok: u32,
832    ) -> Result<(Vec<f32>, Vec<Vec<f32>>, Vec<Vec<f32>>)> {
833        let kv_dims = self.per_layer_kv_dims();
834        let n_layers = self.cfg.num_hidden_layers;
835        let decode_opts = self.profile_compile_options(true);
836        let upper = self
837            .decode_compile_cache
838            .as_ref()
839            .and_then(|cache_dec| {
840                cache_dec.bucket_for(past_seq as u64).map(|idx| {
841                    cache_dec
842                        .buckets()
843                        .nth(idx)
844                        .map(|r| (r.end - 1) as usize)
845                        .unwrap_or(past_seq)
846                })
847            })
848            .unwrap_or(past_seq);
849
850        self.decode_scratch.ensure_bucket(upper, &kv_dims);
851        self.decode_inputs.fill_mask(past_seq, upper);
852        self.decode_inputs.fill_rope(&self.inv_freq, past_seq);
853
854        let input_ids_f32 = [input_tok as f32];
855        let fixed = [
856            CacheRunInput {
857                name: "input_ids",
858                data: &input_ids_f32,
859                row_inner: None,
860            },
861            CacheRunInput {
862                name: "rope_cos",
863                data: &self.decode_inputs.cos,
864                row_inner: None,
865            },
866            CacheRunInput {
867                name: "rope_sin",
868                data: &self.decode_inputs.sin,
869                row_inner: None,
870            },
871            CacheRunInput {
872                name: "mask",
873                data: &self.decode_inputs.mask,
874                row_inner: None,
875            },
876        ];
877
878        if self.use_gpu_kv && self.decode_compile_cache.is_some() {
879            let key = past_seq as u64;
880            let upper_u = upper as u64;
881            let prev_upper = self.gpu_kv_binding.upper;
882            let bucket_changed = prev_upper != 0 && prev_upper != upper_u;
883            let handles_live = self
884                .decode_compile_cache
885                .as_mut()
886                .and_then(|c| c.compiled_for_key_mut(key))
887                .map(|cg| cg.has_gpu_handle("past_k_0"))
888                .unwrap_or(false);
889            let refresh_kv = matches!(self.device, Device::Gpu | Device::Metal)
890                || bucket_changed
891                || !handles_live;
892            let cfg = self.cfg.clone();
893            let weights = self.weights_cache.clone();
894            let logits = {
895                let cache_dec = self.decode_compile_cache.as_mut().unwrap();
896                let cache_mut = self.cache.as_mut().unwrap();
897                metal_decode_compile_guard(self.device, true, || {
898                    run_bucketed_kv_decode_gpu_hir(
899                        cache_dec,
900                        key,
901                        past_seq,
902                        cache_mut,
903                        &mut self.gpu_kv_binding,
904                        self.cfg.kv_proj_dim(),
905                        n_layers,
906                        &fixed,
907                        move |upper| {
908                            let mut wm = WeightMap::from_tensors(weights.clone());
909                            build_gemma_decode_hir_sized_ext(&cfg, &mut wm, 1, upper as usize, true)
910                                .expect("gemma bucketed decode HIR")
911                        },
912                        &decode_opts,
913                        refresh_kv,
914                    )
915                })?
916            };
917            if let Some(compiled) = self
918                .decode_compile_cache
919                .as_mut()
920                .and_then(|c| c.compiled_for_key_mut(key))
921            {
922                let cache_mut = self.cache.as_mut().unwrap();
923                sync_gpu_kv_to_host(compiled, cache_mut, self.cfg.kv_proj_dim(), n_layers)?;
924            }
925            let next_key = (past_seq + 1) as u64;
926            let next_upper = self
927                .decode_compile_cache
928                .as_ref()
929                .and_then(|cache| {
930                    cache
931                        .bucket_for(next_key)
932                        .and_then(|idx| cache.buckets().nth(idx).map(|r| (r.end - 1) as usize))
933                })
934                .unwrap_or(upper);
935            if next_upper != upper {
936                self.reset_gpu_kv_binding();
937            }
938            let cache_mut = self.cache.as_ref().unwrap();
939            let new_k = cache_mut.layers_k.clone();
940            let new_v = cache_mut.layers_v.clone();
941            return Ok((logits, new_k, new_v));
942        }
943
944        let cfg = self.cfg.clone();
945        let weights = self.weights_cache.clone();
946        let cache_dec = self.decode_compile_cache.as_mut().unwrap();
947        let kv_cache = self.cache.as_ref().unwrap();
948        let DecodeKvScratch {
949            padded_k, padded_v, ..
950        } = &mut self.decode_scratch;
951        metal_decode_compile_guard(self.device, true, || {
952            run_bucketed_kv_decode_hir_scratch(
953                cache_dec,
954                past_seq,
955                kv_cache,
956                &kv_dims,
957                n_layers,
958                padded_k,
959                padded_v,
960                &fixed,
961                |upper| {
962                    let mut wm = WeightMap::from_tensors(weights.clone());
963                    build_gemma_decode_hir_sized_ext(&cfg, &mut wm, 1, upper as usize, true)
964                        .expect("gemma bucketed decode HIR")
965                },
966                &decode_opts,
967            )
968        })
969    }
970
971    /// Run prefill-with-cache and return the raw outputs. Uses the
972    /// LRU `CompileCache` when enabled; otherwise compiles fresh each
973    /// call. Keyed by `seq` because graph shape is seq-specialized.
974    #[allow(clippy::unnecessary_unwrap)]
975    fn run_prefill_with_cache(
976        &mut self,
977        batch: usize,
978        seq: usize,
979        ids_f32: &[f32],
980    ) -> Result<Vec<Vec<f32>>> {
981        if self.prefill_dynamic_cache.is_some() {
982            let binding = DimBinding::batch_seq(batch, seq);
983            let opts = compile_options_from_profile(
984                &self.prefill_profile,
985                self.device,
986                KernelDispatchConfig::default(),
987            )
988            .dim_binding(binding.clone());
989            let cache = self.prefill_dynamic_cache.as_mut().expect("checked");
990            let needs_upload = !cache.contains(seq as u64);
991            let cfg = self.cfg.clone();
992            let weights_cache = self.weights_cache.clone();
993            let max_seq = self.cfg.max_position_embeddings;
994            let compiled = cache.get_or_specialize(
995                seq as u64,
996                &binding,
997                || {
998                    let mut wm = WeightMap::from_tensors(weights_cache);
999                    build_gemma_prefill_hir_dynamic_ext(&cfg, &mut wm, batch, max_seq, true)
1000                        .expect("dynamic prefill HIR")
1001                        .0
1002                },
1003                &opts,
1004            )?;
1005            if needs_upload {
1006                let mut wm = WeightMap::from_tensors(self.weights_cache.clone());
1007                let (_, params) =
1008                    build_gemma_prefill_hir_dynamic_ext(&self.cfg, &mut wm, batch, max_seq, true)?;
1009                for (name, data) in &params {
1010                    compiled.set_param(name, data);
1011                }
1012            }
1013            let last_idx = vec![(seq - 1) as f32];
1014            Ok(compiled.run(&[("input_ids", ids_f32), ("last_token_idx", &last_idx)]))
1015        } else if self.prefill_compile_cache.is_some() {
1016            let key = ((batch as u64) << 32) | (seq as u64);
1017            let opts = self.profile_compile_options(false);
1018            if !self.prefill_compile_cache.as_ref().unwrap().contains(key) {
1019                let mut wm = WeightMap::from_tensors(self.weights_cache.clone());
1020                let (graph, params) = build_gemma_graph_sized_last_logits(
1021                    &self.cfg, &mut wm, batch, seq, /*with_kv_outputs*/ true,
1022                )?;
1023                {
1024                    let compiled = self
1025                        .prefill_compile_cache
1026                        .as_mut()
1027                        .unwrap()
1028                        .get_or_compile_with_options(key, || graph, &opts);
1029                    for (name, data) in &params {
1030                        compiled.set_param(name, data);
1031                    }
1032                }
1033            }
1034            let compiled = self
1035                .prefill_compile_cache
1036                .as_mut()
1037                .unwrap()
1038                .get_or_compile_with_options(key, || unreachable!("just populated above"), &opts);
1039            Ok(compiled.run(&[("input_ids", ids_f32)]))
1040        } else {
1041            let mut wm = WeightMap::from_tensors(self.weights_cache.clone());
1042            let (graph, params) = build_gemma_graph_sized_last_logits(
1043                &self.cfg, &mut wm, batch, seq, /*with_kv_outputs*/ true,
1044            )?;
1045            let session = Session::new(self.device);
1046            let opts = self.profile_compile_options(false);
1047            let mut compiled = session.compile_with(graph, &opts);
1048            for (name, data) in &params {
1049                compiled.set_param(name, data);
1050            }
1051            Ok(compiled.run(&[("input_ids", ids_f32)]))
1052        }
1053    }
1054
1055    fn run_prefill_hidden_with_cache(
1056        &mut self,
1057        batch: usize,
1058        seq: usize,
1059        hidden: &[f32],
1060        attn_bias: Option<&[f32]>,
1061    ) -> Result<Vec<Vec<f32>>> {
1062        if self.cfg.use_bidirectional_vision() && attn_bias.is_none() {
1063            anyhow::bail!(
1064                "multimodal prefill requires attn_bias when use_bidirectional_attention=vision"
1065            );
1066        }
1067        let mut inputs: Vec<(&str, &[f32])> = vec![("prefill_hidden", hidden)];
1068        if let Some(bias) = attn_bias {
1069            inputs.push(("attn_bias", bias));
1070        }
1071        let embed_compile_opts = self.profile_compile_options(false);
1072        if let Some(cache) = &mut self.embed_prefill_dynamic_cache {
1073            let binding = DimBinding::batch_seq(batch, seq);
1074            let opts = compile_options_from_profile(
1075                &self.prefill_profile,
1076                self.device,
1077                KernelDispatchConfig::default(),
1078            )
1079            .dim_binding(binding.clone());
1080            let needs_upload = !cache.contains(seq as u64);
1081            let cfg = self.cfg.clone();
1082            let weights_cache = self.weights_cache.clone();
1083            let max_seq = self.cfg.max_position_embeddings;
1084            let compiled = cache.get_or_specialize(
1085                seq as u64,
1086                &binding,
1087                || {
1088                    let mut wm = WeightMap::from_tensors(weights_cache);
1089                    build_gemma_prefill_hidden_hir_dynamic_ext(&cfg, &mut wm, batch, max_seq, true)
1090                        .expect("dynamic hidden prefill HIR")
1091                        .0
1092                },
1093                &opts,
1094            )?;
1095            if needs_upload {
1096                let mut wm = WeightMap::from_tensors(self.weights_cache.clone());
1097                let (_, params) = build_gemma_prefill_hidden_hir_dynamic_ext(
1098                    &self.cfg, &mut wm, batch, max_seq, true,
1099                )?;
1100                for (name, data) in &params {
1101                    compiled.set_param(name, data);
1102                }
1103            }
1104            let last_idx = vec![(seq - 1) as f32];
1105            let mut dyn_inputs = inputs.clone();
1106            dyn_inputs.push(("last_token_idx", &last_idx));
1107            Ok(compiled.run(&dyn_inputs))
1108        } else if let Some(cache) = &mut self.embed_prefill_compile_cache {
1109            let key = ((batch as u64) << 32) | (seq as u64);
1110            let opts = &embed_compile_opts;
1111            if !cache.contains(key) {
1112                let mut wm = WeightMap::from_tensors(self.weights_cache.clone());
1113                let (graph, params) = build_gemma_graph_sized_last_logits_hidden(
1114                    &self.cfg, &mut wm, batch, seq, true,
1115                )?;
1116                {
1117                    let compiled = cache.get_or_compile_with_options(key, || graph, opts);
1118                    for (name, data) in &params {
1119                        compiled.set_param(name, data);
1120                    }
1121                }
1122            }
1123            let compiled = cache.get_or_compile_with_options(
1124                key,
1125                || unreachable!("just populated above"),
1126                opts,
1127            );
1128            Ok(compiled.run(&inputs))
1129        } else {
1130            let mut wm = WeightMap::from_tensors(self.weights_cache.clone());
1131            let (graph, params) =
1132                build_gemma_graph_sized_last_logits_hidden(&self.cfg, &mut wm, batch, seq, true)?;
1133            let session = Session::new(self.device);
1134            let opts = self.profile_compile_options(false);
1135            let mut compiled = session.compile_with(graph, &opts);
1136            for (name, data) in &params {
1137                compiled.set_param(name, data);
1138            }
1139            Ok(compiled.run(&inputs))
1140        }
1141    }
1142
1143    /// Run `n` cached steps after [`prefill_from_embeds`].
1144    pub fn generate_from_embeds(
1145        &mut self,
1146        prompt_ids: &[u32],
1147        embeds: &[f32],
1148        n: usize,
1149        opts: SampleOpts,
1150    ) -> Result<Vec<u32>> {
1151        self.generate_from_embeds_with_bias(prompt_ids, embeds, None, n, opts)
1152    }
1153
1154    pub fn generate_from_embeds_with_bias(
1155        &mut self,
1156        prompt_ids: &[u32],
1157        embeds: &[f32],
1158        attn_bias: Option<Vec<f32>>,
1159        n: usize,
1160        opts: SampleOpts,
1161    ) -> Result<Vec<u32>> {
1162        self.prefill_from_embeds(prompt_ids, embeds, attn_bias)?;
1163        self.generate_cached(n, opts)
1164    }
1165
1166    /// Streaming variant of [`generate_from_embeds`].
1167    pub fn generate_from_embeds_with(
1168        &mut self,
1169        prompt_ids: &[u32],
1170        embeds: &[f32],
1171        n: usize,
1172        opts: SampleOpts,
1173        on_token: impl FnMut(u32),
1174    ) -> Result<Vec<u32>> {
1175        self.generate_from_embeds_with_bias_and_callback(
1176            prompt_ids, embeds, None, n, opts, on_token,
1177        )
1178    }
1179
1180    pub fn generate_from_embeds_with_bias_and_callback(
1181        &mut self,
1182        prompt_ids: &[u32],
1183        embeds: &[f32],
1184        attn_bias: Option<Vec<f32>>,
1185        n: usize,
1186        opts: SampleOpts,
1187        on_token: impl FnMut(u32),
1188    ) -> Result<Vec<u32>> {
1189        self.prefill_from_embeds(prompt_ids, embeds, attn_bias)?;
1190        self.generate_cached_with(n, opts, on_token)
1191    }
1192
1193    /// Run `n` cached steps and return the newly generated tokens.
1194    pub fn generate_cached(&mut self, n: usize, opts: SampleOpts) -> Result<Vec<u32>> {
1195        self.generate_cached_with(n, opts, |_| {})
1196    }
1197
1198    /// Same as [`generate_cached`] but invokes `on_token` once per
1199    /// freshly sampled id, inside the decode loop. The whole `n` step
1200    /// loop shares the bucketed compile cache — callers wanting a
1201    /// streaming UI should prefer this to calling
1202    /// `generate_cached(1, …)` `n` times (which forces a fresh
1203    /// compile per token at the bucket boundaries).
1204    pub fn generate_cached_with(
1205        &mut self,
1206        n: usize,
1207        opts: SampleOpts,
1208        mut on_token: impl FnMut(u32),
1209    ) -> Result<Vec<u32>> {
1210        let start = self.tokens.len();
1211        for _ in 0..n {
1212            let tok = self.step_cached(opts)?;
1213            on_token(tok);
1214        }
1215        Ok(self.tokens[start..].to_vec())
1216    }
1217
1218    /// Run prefill-with-cache on the current `self.tokens` (the
1219    /// prompt), populate `self.cache`, sample the next token from the
1220    /// last position's logits, and append it. Returns the sampled
1221    /// token. Invariant after: `cache.past_seq == tokens.len() - 1`.
1222    fn seed_cache_from_prompt(&mut self, opts: SampleOpts) -> Result<u32> {
1223        let seq = self.tokens.len();
1224        let batch = 1usize;
1225        let kv_dims = self.per_layer_kv_dims();
1226
1227        let outputs = if let Some(embeds) = self.pending_prefill_embeds.take() {
1228            let bias = self.pending_prefill_attn_bias.take();
1229            self.run_prefill_hidden_with_cache(batch, seq, &embeds, bias.as_deref())?
1230        } else {
1231            let ids_f32: Vec<f32> = self.tokens.iter().map(|&i| i as f32).collect();
1232            self.run_prefill_with_cache(batch, seq, &ids_f32)?
1233        };
1234        let (logits, kv) = kv_from_prefill_outputs_per_layer(
1235            outputs,
1236            batch,
1237            seq,
1238            &kv_dims,
1239            self.cfg.num_hidden_layers,
1240        )?;
1241        self.cache = Some(kv);
1242
1243        let vocab = self.cfg.vocab_size;
1244        let needed = vocab;
1245        if logits.len() < needed {
1246            anyhow::bail!("prefill logits length {} < {}", logits.len(), needed);
1247        }
1248        let last_row = &logits[..vocab];
1249        let tok = sample_token(last_row, opts) as u32;
1250        self.tokens.push(tok);
1251        Ok(tok)
1252    }
1253
1254    /// Full token history (prompt + generated).
1255    pub fn tokens(&self) -> &[u32] {
1256        &self.tokens
1257    }
1258
1259    pub fn config(&self) -> &GemmaConfig {
1260        &self.cfg
1261    }
1262
1263    /// Low-level primitive: reset internal state, run prefill-with-cache
1264    /// over `context`, and return the *last position's* logits row
1265    /// (`P(next_token | context)`). Does NOT sample or append. The
1266    /// internal `tokens` buffer is set to `context` and the KV cache
1267    /// is populated to `past_seq = context.len()`.
1268    ///
1269    /// First row of logits after prefill-with-cache (no sampling).
1270    pub fn prefill_get_last_logits(&mut self, context: &[u32]) -> Result<Vec<f32>> {
1271        if context.is_empty() {
1272            anyhow::bail!("prefill_get_last_logits: empty context");
1273        }
1274        self.tokens.clear();
1275        self.tokens.extend_from_slice(context);
1276        self.cache = None;
1277        self.reset_gpu_kv_binding();
1278
1279        let seq = context.len();
1280        let batch = 1usize;
1281        let kv_dims = self.per_layer_kv_dims();
1282
1283        let ids_f32: Vec<f32> = context.iter().map(|&i| i as f32).collect();
1284        let outputs = self.run_prefill_with_cache(batch, seq, &ids_f32)?;
1285        let (logits, kv) = kv_from_prefill_outputs_per_layer(
1286            outputs,
1287            batch,
1288            seq,
1289            &kv_dims,
1290            self.cfg.num_hidden_layers,
1291        )?;
1292        self.cache = Some(kv);
1293
1294        let vocab = self.cfg.vocab_size;
1295        let needed = vocab;
1296        if logits.len() < needed {
1297            anyhow::bail!("logits short: {} < {}", logits.len(), needed);
1298        }
1299        Ok(logits[..vocab].to_vec())
1300    }
1301
1302    /// Low-level primitive: run one decode step with the caller-
1303    /// supplied input token (no sampling), advance the KV cache, and
1304    /// return the resulting logits row `P(next | history ++ input)`.
1305    /// Appends `input` to the `tokens` buffer so the invariant
1306    /// `cache.past_seq == tokens.len()` holds after this call (note:
1307    /// differs from `step_cached` invariant because this method does
1308    /// not append a sampled token).
1309    pub fn decode_get_logits(&mut self, input: u32) -> Result<Vec<f32>> {
1310        if self.cache.is_none() {
1311            anyhow::bail!(
1312                "decode_get_logits: cache not seeded; call prefill_get_last_logits first"
1313            );
1314        }
1315        self.tokens.push(input);
1316        let seq = self.tokens.len();
1317        let batch = 1usize;
1318        let kv_dims = self.per_layer_kv_dims();
1319        let ids_f32: Vec<f32> = self.tokens.iter().map(|&i| i as f32).collect();
1320        let outputs = self.run_prefill_with_cache(batch, seq, &ids_f32)?;
1321        let (logits, kv) = kv_from_prefill_outputs_per_layer(
1322            outputs,
1323            batch,
1324            seq,
1325            &kv_dims,
1326            self.cfg.num_hidden_layers,
1327        )?;
1328        self.cache = Some(kv);
1329        let vocab = self.cfg.vocab_size;
1330        Ok(logits[..vocab].to_vec())
1331    }
1332
1333    /// Per-layer KV dimensions (`num_kv_heads * head_dim` for each
1334    /// layer, accounting for Gemma 4's full-attention shape divergence).
1335    fn per_layer_kv_dims(&self) -> Vec<usize> {
1336        (0..self.cfg.num_hidden_layers)
1337            .map(|i| self.cfg.layer_num_kv_heads(i) * self.cfg.layer_head_dim(i))
1338            .collect()
1339    }
1340}
1341
1342impl Drop for GemmaGenerator {
1343    fn drop(&mut self) {
1344        if self.device == Device::Metal {
1345            self.sync_device();
1346        }
1347    }
1348}
1349
1350/// Compute the single-row (cos, sin) RoPE slice for absolute position
1351/// `pos`. Matches the formula in the prefill builder so cached decode
1352/// and recompute prefill produce the same RoPE rotation.
1353fn compute_rope_slice(inv_freq: &[f64], pos: usize) -> (Vec<f32>, Vec<f32>) {
1354    rope_slice(inv_freq, pos)
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359    use super::*;
1360    use crate::config::GemmaConfig;
1361    use crate::rope::{build_rope_tables, resolve_inv_freq, rope_slice};
1362    use rlx_flow::CompileProfile;
1363
1364    fn tiny_cfg() -> GemmaConfig {
1365        let mut cfg = GemmaConfig::tiny_test();
1366        cfg.vocab_size = 16;
1367        cfg.head_dim = Some(8);
1368        cfg
1369    }
1370
1371    fn synthetic_tensors(cfg: &GemmaConfig) -> HashMap<String, (Vec<f32>, Vec<usize>)> {
1372        let h = cfg.hidden_size;
1373        let q_dim = cfg.q_proj_dim();
1374        let kv_dim = cfg.kv_proj_dim();
1375        let int_dim = cfg.intermediate_size;
1376        let mut t: HashMap<String, (Vec<f32>, Vec<usize>)> = HashMap::new();
1377        // Use a deterministic non-zero pattern so logits aren't all 0
1378        // (sampling on an all-zero row is undefined order).
1379        let pat = |n: usize, salt: u32| -> Vec<f32> {
1380            (0..n)
1381                .map(|i| {
1382                    let x = ((i as u32).wrapping_mul(2654435761).wrapping_add(salt)) >> 8;
1383                    (x as f32 / (1u32 << 24) as f32) - 0.5
1384                })
1385                .collect()
1386        };
1387        t.insert(
1388            "model.embed_tokens.weight".into(),
1389            (pat(cfg.vocab_size * h, 1), vec![cfg.vocab_size, h]),
1390        );
1391        for i in 0..cfg.num_hidden_layers {
1392            let lp = format!("model.layers.{i}");
1393            t.insert(
1394                format!("{lp}.input_layernorm.weight"),
1395                (pat(h, 100 + i as u32), vec![h]),
1396            );
1397            t.insert(
1398                format!("{lp}.post_attention_layernorm.weight"),
1399                (pat(h, 200 + i as u32), vec![h]),
1400            );
1401            t.insert(
1402                format!("{lp}.self_attn.q_proj.weight"),
1403                (pat(q_dim * h, 300 + i as u32), vec![q_dim, h]),
1404            );
1405            t.insert(
1406                format!("{lp}.self_attn.k_proj.weight"),
1407                (pat(kv_dim * h, 400 + i as u32), vec![kv_dim, h]),
1408            );
1409            t.insert(
1410                format!("{lp}.self_attn.v_proj.weight"),
1411                (pat(kv_dim * h, 500 + i as u32), vec![kv_dim, h]),
1412            );
1413            t.insert(
1414                format!("{lp}.self_attn.o_proj.weight"),
1415                (pat(h * q_dim, 600 + i as u32), vec![h, q_dim]),
1416            );
1417            t.insert(
1418                format!("{lp}.mlp.gate_proj.weight"),
1419                (pat(int_dim * h, 900 + i as u32), vec![int_dim, h]),
1420            );
1421            t.insert(
1422                format!("{lp}.mlp.up_proj.weight"),
1423                (pat(int_dim * h, 1000 + i as u32), vec![int_dim, h]),
1424            );
1425            t.insert(
1426                format!("{lp}.mlp.down_proj.weight"),
1427                (pat(h * int_dim, 1100 + i as u32), vec![h, int_dim]),
1428            );
1429        }
1430        t.insert("model.norm.weight".into(), (pat(h, 2000), vec![h]));
1431        t.insert(
1432            "lm_head.weight".into(),
1433            (pat(cfg.vocab_size * h, 3000), vec![cfg.vocab_size, h]),
1434        );
1435        t
1436    }
1437
1438    fn synthetic_weights(cfg: &GemmaConfig) -> WeightMap {
1439        WeightMap::from_tensors(synthetic_tensors(cfg))
1440    }
1441
1442    #[test]
1443    fn generator_drains_loader_and_runs_one_step() {
1444        let cfg = tiny_cfg();
1445        let mut wm = synthetic_weights(&cfg);
1446        let mut gn = GemmaGenerator::from_loader(cfg.clone(), &mut wm, Device::Cpu).unwrap();
1447        assert_eq!(wm.len(), 0, "loader should be drained");
1448        gn.prefill(&[1, 2, 3]);
1449        let t = gn.step(SampleOpts::greedy()).unwrap();
1450        assert!((t as usize) < cfg.vocab_size);
1451        assert_eq!(gn.tokens().len(), 4);
1452    }
1453
1454    #[test]
1455    fn generate_n_appends_n_tokens() {
1456        let cfg = tiny_cfg();
1457        let mut wm = synthetic_weights(&cfg);
1458        let mut gn = GemmaGenerator::from_loader(cfg.clone(), &mut wm, Device::Cpu).unwrap();
1459        gn.prefill(&[5, 6]);
1460        let new_tokens = gn.generate(3, SampleOpts::greedy()).unwrap();
1461        assert_eq!(new_tokens.len(), 3);
1462        assert_eq!(gn.tokens().len(), 5);
1463        for t in &new_tokens {
1464            assert!((*t as usize) < cfg.vocab_size);
1465        }
1466    }
1467
1468    #[test]
1469    fn step_cached_with_aux_populates_last_aux() {
1470        // EAGLE3 wiring smoke test: enabling the aux tap on a small
1471        // synthetic config makes `step_cached` populate `last_aux`
1472        // with one Vec<f32> per requested layer id, each of length
1473        // `hidden_size`. `take_last_aux` clears it.
1474        let cfg = tiny_cfg();
1475        let mut wm = synthetic_weights(&cfg);
1476        let mut gn = GemmaGenerator::from_loader(cfg.clone(), &mut wm, Device::Cpu).unwrap();
1477
1478        // Pick up to 3 distinct layer ids from the tiny config —
1479        // some tiny configs only have 2 layers, in which case the
1480        // dedupe-and-clamp in set_aux_hidden_layer_ids collapses to
1481        // 2 ids. Test against whatever survives.
1482        let n_layers = cfg.num_hidden_layers;
1483        let mut aux_ids: Vec<usize> = (0..n_layers).take(3).collect();
1484        aux_ids.sort_unstable();
1485        aux_ids.dedup();
1486        gn.set_aux_hidden_layer_ids(aux_ids.clone());
1487        assert!(gn.aux_enabled());
1488
1489        gn.prefill(&[1, 2, 3]);
1490        // First step_cached runs prefill+sample (cache seed), not a
1491        // decode step — no aux yet. The aux tap only fires on real
1492        // decode-mode runs.
1493        let _ = gn.step_cached(SampleOpts::greedy()).unwrap();
1494        assert!(
1495            gn.take_last_aux().is_none(),
1496            "prefill-seed call should not populate aux"
1497        );
1498        // Second + third calls are proper decode steps.
1499        let _ = gn.step_cached(SampleOpts::greedy()).unwrap();
1500        let aux = gn.take_last_aux().expect("aux populated after decode step");
1501        assert_eq!(aux.len(), aux_ids.len(), "one row per aux id");
1502        for row in &aux {
1503            assert_eq!(row.len(), cfg.hidden_size, "aux row = hidden_size f32s");
1504            assert!(row.iter().all(|v| v.is_finite()));
1505        }
1506        assert!(
1507            gn.take_last_aux().is_none(),
1508            "take_last_aux consumes the buffer"
1509        );
1510
1511        let _ = gn.step_cached(SampleOpts::greedy()).unwrap();
1512        let aux2 = gn.take_last_aux().expect("aux on next step");
1513        assert_eq!(aux2.len(), aux_ids.len());
1514    }
1515
1516    #[test]
1517    fn aux_disabled_after_clear_ids() {
1518        let cfg = tiny_cfg();
1519        let mut wm = synthetic_weights(&cfg);
1520        let mut gn = GemmaGenerator::from_loader(cfg, &mut wm, Device::Cpu).unwrap();
1521        gn.set_aux_hidden_layer_ids(vec![0, 1]);
1522        assert!(gn.aux_enabled());
1523        gn.set_aux_hidden_layer_ids(Vec::new());
1524        assert!(!gn.aux_enabled());
1525        // last_aux is also cleared on set.
1526        assert!(gn.take_last_aux().is_none());
1527    }
1528
1529    #[test]
1530    fn step_without_prefill_errors() {
1531        let cfg = tiny_cfg();
1532        let mut wm = synthetic_weights(&cfg);
1533        let mut gn = GemmaGenerator::from_loader(cfg, &mut wm, Device::Cpu).unwrap();
1534        let r = gn.step(SampleOpts::greedy());
1535        assert!(r.is_err());
1536    }
1537
1538    fn max_abs_diff(a: &[f32], b: &[f32]) -> f32 {
1539        a.iter()
1540            .zip(b.iter())
1541            .map(|(x, y)| (x - y).abs())
1542            .fold(0f32, f32::max)
1543    }
1544
1545    #[test]
1546    fn prefill_logits_unchanged_with_kv_export() {
1547        let cfg = tiny_cfg();
1548        let prompt: Vec<u32> = vec![1, 2, 3, 5];
1549
1550        let mut wm_a = synthetic_weights(&cfg);
1551        let mut wm_b = synthetic_weights(&cfg);
1552        let (graph_a, params_a) =
1553            build_gemma_graph_sized_last_logits(&cfg, &mut wm_a, 1, 4, false).unwrap();
1554        let (graph_b, params_b) =
1555            build_gemma_graph_sized_last_logits(&cfg, &mut wm_b, 1, 4, true).unwrap();
1556        let session = Session::new(Device::Cpu);
1557        let opts = CompileOptions::new();
1558        let mut ca = session.compile_with(graph_a, &opts);
1559        let mut cb = session.compile_with(graph_b, &opts);
1560        for (n, d) in &params_a {
1561            ca.set_param(n, d);
1562        }
1563        for (n, d) in &params_b {
1564            cb.set_param(n, d);
1565        }
1566        let ids: Vec<f32> = prompt.iter().map(|&i| i as f32).collect();
1567        let la = ca.run(&[("input_ids", &ids)])[0].clone();
1568        let lb = cb.run(&[("input_ids", &ids)])[0].clone();
1569        let d = max_abs_diff(&la, &lb);
1570        assert!(d < 1e-5, "kv export changed prefill logits: max_abs={d:.6}");
1571    }
1572
1573    #[test]
1574    fn incremental_decode_logits_match_full_prefill() {
1575        let cfg = tiny_cfg();
1576        let prompt: Vec<u32> = vec![1, 2, 3, 5];
1577
1578        let mut wm_a = synthetic_weights(&cfg);
1579        let mut gn_a = GemmaGenerator::from_loader(cfg.clone(), &mut wm_a, Device::Cpu).unwrap();
1580        let tok = gn_a
1581            .prefill_get_last_logits(&prompt)
1582            .map(|l| sample_token(&l, SampleOpts::greedy()) as u32)
1583            .unwrap();
1584
1585        let mut extended = prompt.clone();
1586        extended.push(tok);
1587
1588        let mut wm_b = synthetic_weights(&cfg);
1589        let mut gn_b = GemmaGenerator::from_loader(cfg.clone(), &mut wm_b, Device::Cpu).unwrap();
1590        let full = gn_b.prefill_get_last_logits(&extended).unwrap();
1591
1592        let mut wm_c = synthetic_weights(&cfg);
1593        let mut gn_c = GemmaGenerator::from_loader(cfg.clone(), &mut wm_c, Device::Cpu).unwrap();
1594        gn_c.prefill_get_last_logits(&prompt).unwrap();
1595        let incremental = gn_c.decode_get_logits(tok).unwrap();
1596
1597        let d = max_abs_diff(&full, &incremental);
1598        assert!(
1599            d < 1e-2,
1600            "decode+KV vs full prefill max_abs={d:.6} (tok={tok})"
1601        );
1602    }
1603
1604    fn run_prefill_kv(
1605        cfg: &GemmaConfig,
1606        wm: &mut WeightMap,
1607        seq: usize,
1608        ids: &[u32],
1609    ) -> Vec<Vec<f32>> {
1610        run_prefill_kv_with_options(cfg, wm, seq, ids, &kv_export_compile_options(true))
1611    }
1612
1613    fn kv_export_compile_options(prefill: bool) -> CompileOptions {
1614        let profile = if prefill {
1615            CompileProfile::gemma_prefill()
1616        } else {
1617            CompileProfile::gemma_decode()
1618        };
1619        compile_options_from_profile(&profile, Device::Cpu, KernelDispatchConfig::default())
1620    }
1621
1622    fn run_prefill_kv_with_options(
1623        cfg: &GemmaConfig,
1624        wm: &mut WeightMap,
1625        seq: usize,
1626        ids: &[u32],
1627        opts: &CompileOptions,
1628    ) -> Vec<Vec<f32>> {
1629        let ids_f32: Vec<f32> = ids.iter().map(|&i| i as f32).collect();
1630        let (graph, params) = build_gemma_graph_sized_last_logits(cfg, wm, 1, seq, true).unwrap();
1631        let session = Session::new(Device::Cpu);
1632        let mut compiled = session.compile_with(graph, opts);
1633        for (n, d) in &params {
1634            compiled.set_param(n, d);
1635        }
1636        let outputs = compiled.run(&[("input_ids", &ids_f32)]);
1637        let n_layers = cfg.num_hidden_layers;
1638        assert_eq!(outputs.len(), 1 + 2 * n_layers);
1639        let mut kv = Vec::with_capacity(2 * n_layers);
1640        let mut iter = outputs.into_iter().skip(1);
1641        for _ in 0..n_layers {
1642            kv.push(iter.next().unwrap());
1643            kv.push(iter.next().unwrap());
1644        }
1645        kv
1646    }
1647
1648    #[test]
1649    fn decode_graph_bakes_rope_slice_length() {
1650        let cfg = tiny_cfg();
1651        let past_seq = 4usize;
1652        let half = cfg.head_dim() / 2;
1653        let mut wm = synthetic_weights(&cfg);
1654        let (_, params) = build_gemma_decode_graph_sized(&cfg, &mut wm, 1, past_seq).unwrap();
1655        let cos = params
1656            .get("decode.rope.cos")
1657            .expect("decode.rope.cos param");
1658        let sin = params
1659            .get("decode.rope.sin")
1660            .expect("decode.rope.sin param");
1661        assert_eq!(
1662            cos.len(),
1663            half,
1664            "cos param should be one row (half={half}), got {}",
1665            cos.len()
1666        );
1667        assert_eq!(sin.len(), half);
1668        for key in params.keys() {
1669            assert!(
1670                !key.starts_with("rope."),
1671                "decode graph must not include prefill rope table param {key}"
1672            );
1673        }
1674        let inv = resolve_inv_freq(&cfg, None);
1675        let (c_ref, s_ref) = rope_slice(&inv, past_seq);
1676        let d = max_abs_diff(cos, &c_ref) + max_abs_diff(sin, &s_ref);
1677        assert!(d < 1e-6, "baked rope mismatch: {d}");
1678    }
1679
1680    #[test]
1681    fn decode_graph_all_rope_use_baked_cos() {
1682        use rlx_ir::Op;
1683        let cfg = tiny_cfg();
1684        let mut wm = synthetic_weights(&cfg);
1685        let (graph, _) = build_gemma_decode_graph_sized(&cfg, &mut wm, 1, 4).unwrap();
1686        for node in graph.nodes() {
1687            if let Op::Rope { .. } = &node.op {
1688                let cos_id = node.inputs[1];
1689                let cos_node = &graph.node(cos_id);
1690                match &cos_node.op {
1691                    Op::Param { name } => assert_eq!(
1692                        name, "decode.rope.cos",
1693                        "decode RoPE must use baked decode.rope.cos, got {name}"
1694                    ),
1695                    other => panic!("decode RoPE cos input is {other:?}, expected Param"),
1696                }
1697            }
1698        }
1699    }
1700
1701    #[test]
1702    fn decode_graph_rope_cos_is_single_row() {
1703        use rlx_ir::Op;
1704        let cfg = tiny_cfg();
1705        let past_seq = 4usize;
1706        let half = cfg.head_dim() / 2;
1707        let mut wm = synthetic_weights(&cfg);
1708        let (graph, _) = build_gemma_decode_graph_sized(&cfg, &mut wm, 1, past_seq).unwrap();
1709        let mut rope_cos_lens = Vec::new();
1710        for node in graph.nodes() {
1711            if let Op::Rope { .. } = &node.op {
1712                let cos_shape = &graph.node(node.inputs[1]).shape;
1713                let rows = if cos_shape.rank() >= 2 {
1714                    cos_shape.dim(0).unwrap_static()
1715                } else {
1716                    1
1717                };
1718                rope_cos_lens.push(rows);
1719            }
1720        }
1721        assert!(!rope_cos_lens.is_empty(), "decode graph has no RoPE nodes");
1722        for rows in &rope_cos_lens {
1723            assert_eq!(
1724                *rows, 1,
1725                "decode RoPE cos must be single-row [1, half], got {rows} rows"
1726            );
1727        }
1728        assert_eq!(half, cfg.head_dim() / 2);
1729    }
1730
1731    #[test]
1732    fn prefill_kv_matches_extended_prefix() {
1733        let cfg = tiny_cfg();
1734        let prompt: Vec<u32> = vec![1, 2, 3, 5];
1735        let tok = 6u32;
1736        let mut extended = prompt.clone();
1737        extended.push(tok);
1738
1739        let mut wm_prompt = synthetic_weights(&cfg);
1740        let prompt_kv = run_prefill_kv(&cfg, &mut wm_prompt, 4, &prompt);
1741        let mut wm_ext = synthetic_weights(&cfg);
1742        let ext_kv = run_prefill_kv(&cfg, &mut wm_ext, 5, &extended);
1743
1744        let kv_dim = cfg.kv_proj_dim();
1745        for layer in 0..cfg.num_hidden_layers {
1746            let k_prompt = &prompt_kv[2 * layer];
1747            let k_ext = &ext_kv[2 * layer];
1748            let prefix_len = 4 * kv_dim;
1749            assert_eq!(k_prompt.len(), prefix_len);
1750            assert_eq!(k_ext.len(), 5 * kv_dim);
1751            let d = max_abs_diff(k_prompt, &k_ext[..prefix_len]);
1752            assert!(
1753                d < 1e-4,
1754                "layer {layer} prefill K prefix vs extended K max_abs={d:.6}"
1755            );
1756        }
1757    }
1758
1759    #[test]
1760    fn decode_rope_slice_matches_prefill_table_row() {
1761        let cfg = tiny_cfg();
1762        let inv = resolve_inv_freq(&cfg, None);
1763        let (cos_tab, sin_tab) = build_rope_tables(&inv, cfg.max_position_embeddings);
1764        let half = inv.len();
1765        for pos in [3usize, 4, 5] {
1766            let (c, s) = rope_slice(&inv, pos);
1767            let off = pos * half;
1768            let d = max_abs_diff(&c, &cos_tab[off..off + half])
1769                + max_abs_diff(&s, &sin_tab[off..off + half]);
1770            assert!(d < 1e-6, "rope_slice mismatch at pos {pos}: {d}");
1771        }
1772    }
1773
1774    #[test]
1775    fn prefill_kv_export_correct_with_fusion() {
1776        let cfg = tiny_cfg();
1777        let tok = 6u32;
1778        let ids = [1u32, 2, 3, 5, tok];
1779        let opts = kv_export_compile_options(true);
1780        let mut wm_one = synthetic_weights(&cfg);
1781        let one_kv = run_prefill_kv_with_options(&cfg, &mut wm_one, 1, &[tok], &opts);
1782        let mut wm_ext = synthetic_weights(&cfg);
1783        let ext_kv = run_prefill_kv_with_options(&cfg, &mut wm_ext, 5, &ids, &opts);
1784        let kv_dim = cfg.kv_proj_dim();
1785        let d = max_abs_diff(&ext_kv[1][4 * kv_dim..], &one_kv[1][..kv_dim]);
1786        assert!(d < 1e-4, "KV export mismatch with profile fusion: {d:.6}");
1787
1788        let mut wm_default = synthetic_weights(&cfg);
1789        let default_kv =
1790            run_prefill_kv_with_options(&cfg, &mut wm_default, 5, &ids, &CompileOptions::new());
1791        let d_default = max_abs_diff(&default_kv[1][4 * kv_dim..], &one_kv[1][..kv_dim]);
1792        assert!(
1793            d_default < 1e-4,
1794            "KV export mismatch with default fusion (got {d_default:.6})"
1795        );
1796    }
1797
1798    #[test]
1799    fn decode_oneshot_kv_suffix_matches_extended() {
1800        let cfg = tiny_cfg();
1801        let prompt: Vec<u32> = vec![1, 2, 3, 5];
1802        let tok = 6u32;
1803        let mut extended = prompt.clone();
1804        extended.push(tok);
1805
1806        let opts = kv_export_compile_options(false);
1807        let mut wm_ext = synthetic_weights(&cfg);
1808        let ext_kv = run_prefill_kv_with_options(&cfg, &mut wm_ext, 5, &extended, &opts);
1809
1810        let mut wm = synthetic_weights(&cfg);
1811        let mut gn = GemmaGenerator::from_loader(cfg.clone(), &mut wm, Device::Cpu).unwrap();
1812        gn.prefill_get_last_logits(&prompt).unwrap();
1813
1814        let mut wm_d = synthetic_weights(&cfg);
1815        let (graph, params) = build_gemma_decode_graph_sized(&cfg, &mut wm_d, 1, 4).unwrap();
1816        let session = Session::new(Device::Cpu);
1817        let mut compiled = session.compile_with(graph, &opts);
1818        for (n, d) in &params {
1819            compiled.set_param(n, d);
1820        }
1821        let cache = gn.cache.as_ref().unwrap();
1822        let key_strs: Vec<String> = (0..cfg.num_hidden_layers)
1823            .flat_map(|i| [format!("past_k_{i}"), format!("past_v_{i}")])
1824            .collect();
1825        let input_ids = [tok as f32];
1826        let mut inputs: Vec<(&str, &[f32])> = vec![("input_ids", input_ids.as_slice())];
1827        for i in 0..cfg.num_hidden_layers {
1828            inputs.push((&key_strs[2 * i], cache.layers_k[i].as_slice()));
1829            inputs.push((&key_strs[2 * i + 1], cache.layers_v[i].as_slice()));
1830        }
1831        let outputs = compiled.run(&inputs);
1832        let kv_dim = cfg.kv_proj_dim();
1833        let k_dec = &outputs[1][4 * kv_dim..];
1834
1835        let d = max_abs_diff(k_dec, &ext_kv[0][4 * kv_dim..]);
1836        assert!(
1837            d < 1e-3,
1838            "decode oneshot layer0 K suffix vs extended max_abs={d:.6}"
1839        );
1840    }
1841
1842    #[test]
1843    fn decode_logits_match_extended_prefill_after_one_token() {
1844        let cfg = tiny_cfg();
1845        let prompt: Vec<u32> = vec![1, 2, 3, 5];
1846        let tok = 6u32;
1847
1848        let mut extended = prompt.clone();
1849        extended.push(tok);
1850
1851        let mut wm_a = synthetic_weights(&cfg);
1852        let mut gn_a = GemmaGenerator::from_loader(cfg.clone(), &mut wm_a, Device::Cpu).unwrap();
1853        let full = gn_a.prefill_get_last_logits(&extended).unwrap();
1854
1855        let mut wm_b = synthetic_weights(&cfg);
1856        let mut gn_b = GemmaGenerator::from_loader(cfg.clone(), &mut wm_b, Device::Cpu).unwrap();
1857        gn_b.prefill_get_last_logits(&prompt).unwrap();
1858        let inc = gn_b.decode_get_logits(tok).unwrap();
1859
1860        let d = max_abs_diff(&full, &inc);
1861        assert!(d < 1e-2, "decode vs extended prefill max_abs={d:.6}");
1862    }
1863
1864    #[test]
1865    fn cached_second_token_matches_naive() {
1866        let cfg = tiny_cfg();
1867        let prompt: Vec<u32> = vec![1, 2, 3, 5];
1868
1869        let mut wm_n = synthetic_weights(&cfg);
1870        let mut gn_n = GemmaGenerator::from_loader(cfg.clone(), &mut wm_n, Device::Cpu).unwrap();
1871        gn_n.prefill(&prompt);
1872        let n0 = gn_n.step(SampleOpts::greedy()).unwrap();
1873        let n1 = gn_n.step(SampleOpts::greedy()).unwrap();
1874
1875        let mut wm_c = synthetic_weights(&cfg);
1876        let mut gn_c = GemmaGenerator::from_loader(cfg.clone(), &mut wm_c, Device::Cpu).unwrap();
1877        gn_c.prefill(&prompt);
1878        let c = gn_c.generate_cached(2, SampleOpts::greedy()).unwrap();
1879
1880        assert_eq!(c[0], n0, "first generated token");
1881        assert_eq!(c[1], n1, "second generated token (decode step)");
1882    }
1883
1884    #[test]
1885    fn cached_matches_naive_on_greedy() {
1886        // The cached and naive paths must produce the same token
1887        // sequence given the same prompt + opts. This is the
1888        // load-bearing test for the KV-cache implementation: if the
1889        // decode-mode graph, the kernel's Lq!=Lk fix, the cache
1890        // wiring, or the RoPE position-slice is wrong, the sequences
1891        // diverge here.
1892        let cfg = tiny_cfg();
1893        let prompt: Vec<u32> = vec![1, 2, 3, 5];
1894        let steps = 4;
1895
1896        let mut wm_n = synthetic_weights(&cfg);
1897        let mut gn_naive =
1898            GemmaGenerator::from_loader(cfg.clone(), &mut wm_n, Device::Cpu).unwrap();
1899        gn_naive.prefill(&prompt);
1900        let naive_tokens = gn_naive.generate(steps, SampleOpts::greedy()).unwrap();
1901
1902        let mut wm_c = synthetic_weights(&cfg);
1903        let mut gn_cached =
1904            GemmaGenerator::from_loader(cfg.clone(), &mut wm_c, Device::Cpu).unwrap();
1905        gn_cached.prefill(&prompt);
1906        let cached_tokens = gn_cached
1907            .generate_cached(steps, SampleOpts::greedy())
1908            .unwrap();
1909
1910        assert_eq!(
1911            cached_tokens, naive_tokens,
1912            "cached vs naive token mismatch — KV cache or kernel-Lq!=Lk bug"
1913        );
1914    }
1915
1916    #[test]
1917    fn cached_step_advances_cache_invariant() {
1918        let cfg = tiny_cfg();
1919        let mut wm = synthetic_weights(&cfg);
1920        let mut gn = GemmaGenerator::from_loader(cfg.clone(), &mut wm, Device::Cpu).unwrap();
1921        gn.prefill(&[1, 2, 3]);
1922        let _ = gn.step_cached(SampleOpts::greedy()).unwrap();
1923        // After seed: tokens.len() == 4, cache.past_seq == 3 (cache holds prompt).
1924        assert_eq!(gn.tokens().len(), 4);
1925        assert_eq!(gn.cache.as_ref().unwrap().past_len, 3);
1926        let _ = gn.step_cached(SampleOpts::greedy()).unwrap();
1927        // After one decode: tokens.len() == 5, cache.past_seq == 4.
1928        assert_eq!(gn.tokens().len(), 5);
1929        assert_eq!(gn.cache.as_ref().unwrap().past_len, 4);
1930    }
1931
1932    #[test]
1933    fn bucketed_decode_matches_oneshot() {
1934        // The bucketed compile-cache path (padded K/V + custom mask)
1935        // must produce the same token sequence as the one-shot
1936        // path. Load-bearing for the bucketed cache feature: if the
1937        // mask, padding, or output slicing is wrong, sequences
1938        // diverge here.
1939        let cfg = tiny_cfg();
1940        let prompt: Vec<u32> = vec![1, 2, 3, 5];
1941        let steps = 6;
1942
1943        let mut wm_one = synthetic_weights(&cfg);
1944        let mut gn_one =
1945            GemmaGenerator::from_loader(cfg.clone(), &mut wm_one, Device::Cpu).unwrap();
1946        gn_one.prefill(&prompt);
1947        let oneshot_tokens = gn_one.generate_cached(steps, SampleOpts::greedy()).unwrap();
1948
1949        let mut wm_buc = synthetic_weights(&cfg);
1950        let mut gn_buc = GemmaGenerator::from_loader(cfg.clone(), &mut wm_buc, Device::Cpu)
1951            .unwrap()
1952            .with_decode_cache(/*max_past*/ 32);
1953        gn_buc.prefill(&prompt);
1954        let bucketed_tokens = gn_buc.generate_cached(steps, SampleOpts::greedy()).unwrap();
1955
1956        assert_eq!(
1957            bucketed_tokens, oneshot_tokens,
1958            "bucketed-cache decode diverged from one-shot decode — \
1959             mask, padding, or output-slice bug"
1960        );
1961    }
1962
1963    #[test]
1964    fn prefill_compile_cache_does_not_change_output() {
1965        let cfg = tiny_cfg();
1966        let prompt: Vec<u32> = vec![1, 2, 3, 5];
1967        let mut wm_a = synthetic_weights(&cfg);
1968        let mut gn_a = GemmaGenerator::from_loader(cfg.clone(), &mut wm_a, Device::Cpu).unwrap();
1969        gn_a.prefill(&prompt);
1970        let a = gn_a.generate_cached(4, SampleOpts::greedy()).unwrap();
1971
1972        let mut wm_b = synthetic_weights(&cfg);
1973        let mut gn_b = GemmaGenerator::from_loader(cfg.clone(), &mut wm_b, Device::Cpu)
1974            .unwrap()
1975            .with_prefill_cache(/*capacity*/ 4);
1976        gn_b.prefill(&prompt);
1977        let b = gn_b.generate_cached(4, SampleOpts::greedy()).unwrap();
1978
1979        assert_eq!(a, b, "enabling prefill_cache must not change output");
1980    }
1981
1982    #[test]
1983    fn dynamic_decode_matches_oneshot() {
1984        let cfg = tiny_cfg();
1985        let prompt: Vec<u32> = vec![1, 2, 3, 5];
1986        let steps = 6;
1987
1988        let mut wm_one = synthetic_weights(&cfg);
1989        let mut gn_one =
1990            GemmaGenerator::from_loader(cfg.clone(), &mut wm_one, Device::Cpu).unwrap();
1991        gn_one.prefill(&prompt);
1992        let oneshot_tokens = gn_one.generate_cached(steps, SampleOpts::greedy()).unwrap();
1993
1994        let mut wm_dyn = synthetic_weights(&cfg);
1995        let mut gn_dyn = GemmaGenerator::from_loader(cfg.clone(), &mut wm_dyn, Device::Cpu)
1996            .unwrap()
1997            .with_dynamic_decode_cache(/*capacity*/ 8);
1998        gn_dyn.prefill(&prompt);
1999        let dynamic_tokens = gn_dyn.generate_cached(steps, SampleOpts::greedy()).unwrap();
2000
2001        assert_eq!(
2002            dynamic_tokens, oneshot_tokens,
2003            "dynamic past_seq decode diverged from one-shot decode"
2004        );
2005    }
2006
2007    #[test]
2008    fn dynamic_prefill_matches_oneshot() {
2009        let cfg = tiny_cfg();
2010        let prompt: Vec<u32> = vec![1, 2, 3, 5];
2011        let steps = 4;
2012
2013        let mut wm_one = synthetic_weights(&cfg);
2014        let mut gn_one =
2015            GemmaGenerator::from_loader(cfg.clone(), &mut wm_one, Device::Cpu).unwrap();
2016        gn_one.prefill(&prompt);
2017        let oneshot_tokens = gn_one.generate_cached(steps, SampleOpts::greedy()).unwrap();
2018
2019        let mut wm_dyn = synthetic_weights(&cfg);
2020        let mut gn_dyn = GemmaGenerator::from_loader(cfg.clone(), &mut wm_dyn, Device::Cpu)
2021            .unwrap()
2022            .with_dynamic_prefill_cache(/*capacity*/ 8);
2023        gn_dyn.prefill(&prompt);
2024        let dynamic_tokens = gn_dyn.generate_cached(steps, SampleOpts::greedy()).unwrap();
2025
2026        assert_eq!(
2027            dynamic_tokens, oneshot_tokens,
2028            "dynamic seq prefill diverged from one-shot prefill"
2029        );
2030    }
2031
2032    #[test]
2033    fn dynamic_prefill_and_decode_matches_oneshot() {
2034        let cfg = tiny_cfg();
2035        let prompt: Vec<u32> = vec![1, 2, 3, 5];
2036        let steps = 6;
2037
2038        let mut wm_one = synthetic_weights(&cfg);
2039        let mut gn_one =
2040            GemmaGenerator::from_loader(cfg.clone(), &mut wm_one, Device::Cpu).unwrap();
2041        gn_one.prefill(&prompt);
2042        let oneshot_tokens = gn_one.generate_cached(steps, SampleOpts::greedy()).unwrap();
2043
2044        let mut wm_dyn = synthetic_weights(&cfg);
2045        let mut gn_dyn = GemmaGenerator::from_loader(cfg.clone(), &mut wm_dyn, Device::Cpu)
2046            .unwrap()
2047            .with_dynamic_prefill_cache(/*capacity*/ 8)
2048            .with_dynamic_decode_cache(/*capacity*/ 8);
2049        gn_dyn.prefill(&prompt);
2050        let dynamic_tokens = gn_dyn.generate_cached(steps, SampleOpts::greedy()).unwrap();
2051
2052        assert_eq!(
2053            dynamic_tokens, oneshot_tokens,
2054            "dynamic prefill+decode diverged from one-shot path"
2055        );
2056    }
2057
2058    #[test]
2059    fn greedy_is_deterministic_across_runs() {
2060        let cfg = tiny_cfg();
2061        let weights = synthetic_weights(&cfg);
2062        let mk = || {
2063            let mut wm = WeightMap::from_tensors(weights_as_hashmap(&weights));
2064            GemmaGenerator::from_loader(cfg.clone(), &mut wm, Device::Cpu).unwrap()
2065        };
2066        let mut a = mk();
2067        let mut b = mk();
2068        a.prefill(&[1, 2, 3]);
2069        b.prefill(&[1, 2, 3]);
2070        let ta = a.generate(4, SampleOpts::greedy()).unwrap();
2071        let tb = b.generate(4, SampleOpts::greedy()).unwrap();
2072        assert_eq!(ta, tb);
2073    }
2074
2075    fn weights_as_hashmap(wm: &WeightMap) -> HashMap<String, (Vec<f32>, Vec<usize>)> {
2076        // Reconstruct the underlying map by re-running synthetic_weights
2077        // — WeightMap doesn't expose its inner map. Sufficient for the
2078        // determinism test since synthetic_weights is itself
2079        // deterministic.
2080        let _ = wm; // silence unused
2081        let cfg = tiny_cfg();
2082        let mut new = synthetic_weights(&cfg);
2083        let keys: Vec<String> = new.keys().map(|s| s.to_string()).collect();
2084        let mut out = HashMap::new();
2085        for k in keys {
2086            out.insert(k.clone(), new.take(&k).unwrap());
2087        }
2088        out
2089    }
2090}