Skip to main content

rlx_runtime/
compile_cache.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//! Shape-bucketed compile cache.
17//!
18//! Lets variable-shape callers (e.g., embedding-model wrappers that vary
19//! batch + seq per request) amortize the per-(shape) compile cost. Cache
20//! keys are caller-provided `u64`s — the caller decides what counts as a
21//! shape bucket. Typical recipe: `(batch as u64) << 32 | seq as u64`.
22//!
23//! The cache stores one `CompiledGraph` per key. Params loaded onto a
24//! cached entry persist for that entry — re-fetching from cache does
25//! **not** require re-running `set_param`. Eviction is FIFO, capped at
26//! `capacity` entries (good enough for the current "a handful of common
27//! shapes" usage pattern; switch to LRU if a real workload shows churn).
28//!
29//! # Example
30//!
31//! ```rust,ignore
32//! let mut cache = CompileCache::new(Device::Metal, 8);
33//! let key = ((batch as u64) << 32) | seq as u64;
34//! let mut compiled = cache.get_or_compile(key, || build_my_graph(batch, seq));
35//! // First call for `key`: compiles. Subsequent calls: cache hit.
36//! compiled.run(&[("x", &input_data)]);
37//! ```
38
39use crate::{CompiledGraph, Device, Session};
40use rlx_ir::DimBinding;
41use rlx_ir::Graph;
42use rlx_ir::hir::HirModule;
43use rlx_opt::CompileResult;
44use std::collections::HashMap;
45use std::collections::VecDeque;
46use std::ops::Range;
47
48/// Named runtime input for [`BucketedCompileCache::run_padded_mixed`].
49pub struct CacheRunInput<'a> {
50    pub name: &'a str,
51    pub data: &'a [f32],
52    /// Row inner stride for [`pad_rows`]; `None` = use data as-is (no padding).
53    pub row_inner: Option<usize>,
54}
55
56pub struct CompileCache {
57    device: Device,
58    capacity: usize,
59    // Per-cache precision policy. None → default (F32). Set once at
60    // construction; applies to every compile this cache performs.
61    policy: Option<rlx_opt::PrecisionPolicy>,
62    // (key, compiled). Vec keeps insertion order for FIFO eviction; the
63    // expected hit-rate at our cap (~8) makes the linear scan cheaper
64    // than a HashMap + separate eviction list.
65    entries: Vec<(u64, CompiledGraph)>,
66    // Insertion order for eviction.
67    order: VecDeque<u64>,
68}
69
70impl CompileCache {
71    pub fn new(device: Device, capacity: usize) -> Self {
72        Self::with_policy(device, capacity, None)
73    }
74
75    /// Cache that compiles every entry with the given precision policy.
76    /// Use this when the cached entries should differ from CPU-default
77    /// F32 — e.g., `PrecisionPolicy::AutoMixed` for f16 compute on Metal.
78    pub fn with_policy(
79        device: Device,
80        capacity: usize,
81        policy: Option<rlx_opt::PrecisionPolicy>,
82    ) -> Self {
83        assert!(capacity > 0, "CompileCache capacity must be ≥ 1");
84        Self {
85            device,
86            capacity,
87            policy,
88            entries: Vec::with_capacity(capacity),
89            order: VecDeque::with_capacity(capacity),
90        }
91    }
92
93    /// Compile if not present, then return a mutable reference. The borrow
94    /// lifetime is tied to `&mut self` so callers naturally serialize their
95    /// use of any one entry — the cache is single-owner today.
96    pub fn get_or_compile<F: FnOnce() -> Graph>(
97        &mut self,
98        key: u64,
99        build: F,
100    ) -> &mut CompiledGraph {
101        self.get_or_compile_with_options(key, build, &crate::CompileOptions::new())
102    }
103
104    /// Like [`Self::get_or_compile`] with explicit [`CompileOptions`].
105    pub fn get_or_compile_with_options<F: FnOnce() -> Graph>(
106        &mut self,
107        key: u64,
108        build: F,
109        options: &crate::CompileOptions,
110    ) -> &mut CompiledGraph {
111        if let Some(idx) = self.entries.iter().position(|(k, _)| *k == key) {
112            return &mut self.entries[idx].1;
113        }
114        let mut session = Session::new(self.device);
115        if let Some(p) = &self.policy {
116            session = session.with_policy(p.clone());
117        }
118        let compiled = session.compile_with(build(), options);
119
120        // Evict FIFO if at capacity.
121        if self.entries.len() >= self.capacity
122            && let Some(evict_key) = self.order.pop_front()
123        {
124            sync_evicted_entry(&mut self.entries, evict_key);
125            self.entries.retain(|(k, _)| *k != evict_key);
126        }
127        self.entries.push((key, compiled));
128        self.order.push_back(key);
129        &mut self.entries.last_mut().unwrap().1
130    }
131
132    /// Like [`Self::get_or_compile_with_options`] but builds and compiles HIR directly.
133    pub fn get_or_compile_hir_with_options<F: FnOnce() -> rlx_ir::hir::HirModule>(
134        &mut self,
135        key: u64,
136        build: F,
137        options: &crate::CompileOptions,
138    ) -> &mut CompiledGraph {
139        if let Some(idx) = self.entries.iter().position(|(k, _)| *k == key) {
140            return &mut self.entries[idx].1;
141        }
142        let mut session = Session::new(self.device);
143        if let Some(p) = &self.policy {
144            session = session.with_policy(p.clone());
145        }
146        let compiled = session
147            .compile_hir_with(build(), options)
148            .expect("HIR lower/compile in compile cache");
149
150        if self.entries.len() >= self.capacity
151            && let Some(evict_key) = self.order.pop_front()
152        {
153            sync_evicted_entry(&mut self.entries, evict_key);
154            self.entries.retain(|(k, _)| *k != evict_key);
155        }
156        self.entries.push((key, compiled));
157        self.order.push_back(key);
158        &mut self.entries.last_mut().unwrap().1
159    }
160
161    /// Number of entries currently cached. Useful for tests + diagnostics.
162    pub fn len(&self) -> usize {
163        self.entries.len()
164    }
165    pub fn is_empty(&self) -> bool {
166        self.entries.is_empty()
167    }
168    /// Was this key already compiled? Doesn't change recency.
169    pub fn contains(&self, key: u64) -> bool {
170        self.entries.iter().any(|(k, _)| *k == key)
171    }
172
173    /// Drop all cached compiled graphs (free weight params).
174    pub fn clear(&mut self) {
175        self.sync_all();
176        self.entries.clear();
177        self.order.clear();
178    }
179
180    /// Drain in-flight GPU work on every cached entry (Metal `commit_no_wait` paths).
181    pub fn sync_all(&mut self) {
182        for (_, compiled) in &mut self.entries {
183            compiled.sync_pending();
184        }
185    }
186}
187
188fn sync_evicted_entry(entries: &mut [(u64, CompiledGraph)], evict_key: u64) {
189    if let Some((_, compiled)) = entries.iter_mut().find(|(k, _)| *k == evict_key) {
190        compiled.sync_pending();
191    }
192}
193
194// ── Bucketed cache (PLAN L1) ──────────────────────────────────────────
195//
196// Variant of `CompileCache` that compiles one `CompiledGraph` per shape
197// *range* instead of per exact key. The caller declares buckets up front
198// (e.g. `1..16`, `16..64`, `64..256`); each bucket is compiled lazily at
199// its upper bound the first time a key in that bucket arrives.
200//
201// Trade vs `CompileCache`: unique keys → unique compiles becomes unique
202// buckets → unique compiles. The compiled graph is specialized for each
203// bucket's upper-bound dim. Two ways to use it:
204//
205// **Manual padding** — caller drives the pad/slice cycle:
206// ```rust,ignore
207// let buckets = vec![1..16, 16..64, 64..256];
208// let mut cache = BucketedCompileCache::new(Device::Metal, buckets);
209// let (upper, compiled) = cache
210//     .get_or_compile(seq as u64, |max_seq| build_graph(max_seq as usize))
211//     .expect("seq within buckets");
212// // pad input to `upper as usize` elements before run
213// compiled.run(&[("x", &padded)]);
214// ```
215//
216// **`run_padded` shortcut** — cache pads and slices for you:
217// ```rust,ignore
218// let (upper, outputs) = cache.run_padded(
219//     seq as u64,
220//     seq,                                    // actual rows
221//     |max_seq| build_graph(max_seq as usize),
222//     &[("x", &raw_input, hidden)],           // (name, data, inner stride)
223//     &[hidden],                              // per-output inner stride
224// ).expect("in range");
225// ```
226//
227// **How "skip compute" actually works here**: each bucket compiles at
228// its own upper bound, so kernels run at *that* extent, not at some
229// global maximum. Smaller buckets ⇒ less padded compute. The
230// `power_of_two_ladder` constructor builds a logarithmic schedule that
231// guarantees ≤2× padding waste in exchange for `O(log max)` compiled
232// artifacts. For finer control, hand-construct the bucket list.
233//
234// True per-kernel active-extent dispatch (one big compile, runtime
235// extent override that short-circuits each kernel's inner loop) is a
236// per-backend change across `rlx-cuda`, `rlx-rocm`,
237// `rlx-cpu/src/thunk.rs`, `rlx-metal/src/thunk.rs`, `rlx-mlx`,
238// `rlx-wgpu` — multi-day project, not in this layer.
239
240pub struct BucketedCompileCache {
241    device: Device,
242    policy: Option<rlx_opt::PrecisionPolicy>,
243    buckets: Vec<Bucket>,
244    /// Upper bound of the bucket whose uploaded weight buffer serves as the
245    /// canonical donor for [`Self::try_share_params_from_donor`]. The first
246    /// bucket to receive a real weight upload records itself here; later buckets
247    /// with a byte-identical layout retain its buffer instead of duplicating it.
248    weight_donor_upper: Option<u64>,
249    /// Monotonic access counter driving LRU eviction of large buckets.
250    clock: u64,
251}
252
253struct Bucket {
254    range: Range<u64>,
255    compiled: Option<CompiledGraph>,
256    /// Weight bytes uploaded into this bucket's arena (0 until compiled with
257    /// params). Used only to classify a bucket as "large" for eviction.
258    resident_bytes: usize,
259    /// `clock` value at last access; smallest = least-recently-used.
260    last_used: u64,
261}
262
263/// A bucket whose baked-in weights reach this size is "large" and eligible for
264/// LRU eviction (always on discrete VRAM; on unified memory only when the user
265/// opts in via `RLX_KV_CACHE_MAX_RESIDENT`). Below it, buckets are never evicted
266/// — small models keep the full power-of-two ladder resident (no behavior
267/// change). Off-switch everywhere: `RLX_KV_CACHE_NO_EVICT`.
268const LARGE_BUCKET_BYTES: usize = 256 * 1024 * 1024;
269
270/// Max number of *large* buckets kept resident at once. Non-packed f32 decode
271/// bakes the full weight set (multi-GB) into every bucket's arena, so an
272/// N-rung ladder would pin N copies on the GPU and OOM. Monotonic decode only
273/// ever needs the current rung, so a cap of 1 is effectively free within a
274/// generation (a smaller rung is never revisited before it would be evicted)
275/// and keeps the transient compile peak to `prefill + one arena + its upload
276/// staging buffer` instead of also pinning a spare multi-GB copy — the latter
277/// pushes a 3 GB×N ladder past a 16 GB card. Override with
278/// `RLX_KV_CACHE_MAX_RESIDENT` (min 1) to trade VRAM for cross-bucket reuse.
279fn max_resident_large_buckets() -> usize {
280    std::env::var("RLX_KV_CACHE_MAX_RESIDENT")
281        .ok()
282        .and_then(|s| s.parse::<usize>().ok())
283        .filter(|&n| n >= 1)
284        .unwrap_or(1)
285}
286
287/// Discrete-VRAM backends always cap large decode buckets (limited VRAM would
288/// OOM). Unified-memory devices (CPU / Metal / MLX / ANE) keep the full ladder
289/// resident by DEFAULT — warm for servers doing many short generations — and
290/// opt into capping via `RLX_KV_CACHE_MAX_RESIDENT` (see [`evict_for_incoming`]).
291fn device_has_discrete_vram(dev: Device) -> bool {
292    matches!(
293        dev,
294        Device::Gpu
295            | Device::Cuda
296            | Device::Vulkan
297            | Device::Rocm
298            | Device::OneApi
299            | Device::DirectX
300            | Device::WebGpu
301    )
302}
303
304impl BucketedCompileCache {
305    pub fn new(device: Device, buckets: Vec<Range<u64>>) -> Self {
306        Self::with_policy(device, buckets, None)
307    }
308
309    /// Power-of-two ladder over `[1, max]`, with extents
310    /// `[min_pow2, 2·min_pow2, 4·min_pow2, …, max_pow2]` where
311    /// `min_pow2 = min.next_power_of_two()` and `max_pow2` is the smallest
312    /// power of two ≥ `max`. Each bucket compiles at its upper-bound
313    /// extent, so an `actual` value in bucket `(prev_extent .. ext]` runs
314    /// kernels at extent `ext` (not at the worst case of the whole range).
315    /// Guarantees compute waste from padding ≤2× — `actual > ext / 2`
316    /// for every bucket except possibly the smallest.
317    ///
318    /// Example: `power_of_two_ladder(Device::Cpu, 8, 256)` yields buckets
319    /// `1..9, 9..17, 17..33, 33..65, 65..129, 129..257` with compile
320    /// extents `8, 16, 32, 64, 128, 256`. An `actual = 17` runs at extent
321    /// 32 instead of the 255 a single wide `1..256` bucket would compile
322    /// at — that's the "skip compute" win, paid for with `O(log max)`
323    /// compiled artifacts instead of one.
324    pub fn power_of_two_ladder(device: Device, min: u64, max: u64) -> Self {
325        Self::power_of_two_ladder_with_policy(device, min, max, None)
326    }
327
328    pub fn power_of_two_ladder_with_policy(
329        device: Device,
330        min: u64,
331        max: u64,
332        policy: Option<rlx_opt::PrecisionPolicy>,
333    ) -> Self {
334        assert!(min >= 1, "power_of_two_ladder: min must be ≥ 1, got {min}");
335        assert!(
336            max >= min,
337            "power_of_two_ladder: max ({max}) must be ≥ min ({min})"
338        );
339        let mut buckets: Vec<Range<u64>> = Vec::new();
340        let mut start = 1u64;
341        let mut extent = min.next_power_of_two();
342        loop {
343            buckets.push(start..(extent + 1));
344            if extent >= max {
345                break;
346            }
347            start = extent + 1;
348            extent = extent
349                .checked_mul(2)
350                .expect("power_of_two_ladder: extent overflow");
351        }
352        Self::with_policy(device, buckets, policy)
353    }
354
355    pub fn with_policy(
356        device: Device,
357        buckets: Vec<Range<u64>>,
358        policy: Option<rlx_opt::PrecisionPolicy>,
359    ) -> Self {
360        assert!(!buckets.is_empty(), "BucketedCompileCache needs ≥1 bucket");
361        for (i, b) in buckets.iter().enumerate() {
362            assert!(b.start < b.end, "bucket {i} ({b:?}) is empty");
363            if i + 1 < buckets.len() {
364                assert!(
365                    b.end <= buckets[i + 1].start,
366                    "buckets {i} ({b:?}) and {} ({:?}) overlap",
367                    i + 1,
368                    buckets[i + 1],
369                );
370            }
371        }
372        let buckets = buckets
373            .into_iter()
374            .map(|range| Bucket {
375                range,
376                compiled: None,
377                resident_bytes: 0,
378                last_used: 0,
379            })
380            .collect();
381        Self {
382            device,
383            policy,
384            buckets,
385            weight_donor_upper: None,
386            clock: 0,
387        }
388    }
389
390    /// Mark bucket `idx` as most-recently-used.
391    fn touch(&mut self, idx: usize) {
392        self.clock += 1;
393        self.buckets[idx].last_used = self.clock;
394    }
395
396    /// Before compiling a new bucket `idx` that will bake in `incoming_bytes`
397    /// of weights, free least-recently-used *large* buckets so that (existing
398    /// large + this one) stays within [`max_resident_large_buckets`]. Only
399    /// large incoming weights trigger this — small models are untouched, and
400    /// the current bucket is never a victim. Runs *before* the new arena is
401    /// allocated so the transient peak is `cap` copies, not `cap + 1`.
402    fn evict_for_incoming(&mut self, incoming_idx: usize, incoming_bytes: usize) {
403        if std::env::var("RLX_KV_CACHE_NO_EVICT").is_ok() {
404            return;
405        }
406        // Discrete VRAM always caps large buckets. Unified memory keeps the full
407        // ladder by default (no cross-generation recompiles) and opts in via
408        // RLX_KV_CACHE_MAX_RESIDENT — for a big model (e.g. Bonsai-27B, ~3.6 GB
409        // baked into every decode bucket) whose inline duplication would grow
410        // across a long generation. Monotonic host-fed-KV decode never revisits a
411        // climbed-past bucket, so capping is safe and ~free within a generation.
412        let unified_opt_in = std::env::var("RLX_KV_CACHE_MAX_RESIDENT").is_ok();
413        if !device_has_discrete_vram(self.device) && !unified_opt_in {
414            return;
415        }
416        if std::env::var("RLX_KV_CACHE_DBG").is_ok() {
417            let (n_large, resident): (usize, usize) = self
418                .buckets
419                .iter()
420                .filter(|b| b.compiled.is_some())
421                .fold((0, 0), |(n, r), b| {
422                    let is_large = b.resident_bytes >= LARGE_BUCKET_BYTES;
423                    (n + is_large as usize, r + b.resident_bytes)
424                });
425            eprintln!(
426                "[KVCACHE] compiling bucket idx={incoming_idx} incoming={:.2}GB \
427                 resident_large={n_large} resident_total={:.2}GB cap={}",
428                incoming_bytes as f64 / 1e9,
429                resident as f64 / 1e9,
430                max_resident_large_buckets(),
431            );
432        }
433        if incoming_bytes < LARGE_BUCKET_BYTES {
434            return;
435        }
436        let cap = max_resident_large_buckets();
437        loop {
438            let large: Vec<usize> = (0..self.buckets.len())
439                .filter(|&j| {
440                    j != incoming_idx
441                        && self.buckets[j].compiled.is_some()
442                        && self.buckets[j].resident_bytes >= LARGE_BUCKET_BYTES
443                })
444                .collect();
445            // `+ 1` reserves a slot for the bucket about to be compiled.
446            if large.len() < cap {
447                break;
448            }
449            let Some(&victim) = large.iter().min_by_key(|&&j| self.buckets[j].last_used) else {
450                break;
451            };
452            if let Some(c) = &mut self.buckets[victim].compiled {
453                c.sync_pending();
454            }
455            let victim_upper = self.buckets[victim].range.end.saturating_sub(1);
456            self.buckets[victim].compiled = None;
457            self.buckets[victim].resident_bytes = 0;
458            if self.weight_donor_upper == Some(victim_upper) {
459                self.weight_donor_upper = None;
460            }
461        }
462    }
463
464    /// Find the bucket containing `key`, compile if needed, return
465    /// `(upper, &mut CompiledGraph)` where `upper = range.end - 1` is the
466    /// extent the graph was compiled for. Caller pads inputs to `upper`
467    /// before calling `run`. Returns `None` if `key` is outside every
468    /// bucket — caller decides whether to fall back to a one-off compile.
469    ///
470    /// `build` receives `upper` and must return a `Graph` specialized for
471    /// that extent.
472    pub fn get_or_compile<F: FnOnce(u64) -> Graph>(
473        &mut self,
474        key: u64,
475        build: F,
476    ) -> Option<(u64, &mut CompiledGraph)> {
477        self.get_or_compile_with_options(key, build, &crate::CompileOptions::new())
478    }
479
480    /// Like [`Self::get_or_compile`] with explicit [`CompileOptions`].
481    pub fn get_or_compile_with_options<F: FnOnce(u64) -> Graph>(
482        &mut self,
483        key: u64,
484        build: F,
485        options: &crate::CompileOptions,
486    ) -> Option<(u64, &mut CompiledGraph)> {
487        let idx = self.bucket_for(key)?;
488        let upper = self.buckets[idx].range.end - 1;
489        if self.buckets[idx].compiled.is_none() {
490            let mut session = Session::new(self.device);
491            if let Some(p) = &self.policy {
492                session = session.with_policy(p.clone());
493            }
494            self.buckets[idx].compiled = Some(session.compile_with(build(upper), options));
495        }
496        Some((upper, self.buckets[idx].compiled.as_mut().unwrap()))
497    }
498
499    /// Like [`Self::get_or_compile`] but builds and compiles HIR directly
500    /// through the fusion-first pipeline (`Session::compile_hir`).
501    pub fn get_or_compile_hir<F: FnOnce(u64) -> HirModule>(
502        &mut self,
503        key: u64,
504        build: F,
505    ) -> Option<(u64, &mut CompiledGraph)> {
506        self.get_or_compile_hir_with_options(key, build, &crate::CompileOptions::new())
507    }
508
509    /// Like [`Self::get_or_compile_hir`] with explicit [`CompileOptions`] (tier-1 profile, fusion target, …).
510    pub fn get_or_compile_hir_with_options<F: FnOnce(u64) -> HirModule>(
511        &mut self,
512        key: u64,
513        build: F,
514        options: &crate::CompileOptions,
515    ) -> Option<(u64, &mut CompiledGraph)> {
516        let idx = self.bucket_for(key)?;
517        let upper = self.buckets[idx].range.end - 1;
518        if self.buckets[idx].compiled.is_none() {
519            let mut session = Session::new(self.device);
520            if let Some(p) = &self.policy {
521                session = session.with_policy(p.clone());
522            }
523            let compiled = session
524                .compile_hir_with(build(upper), options)
525                .expect("HIR lower/compile in bucketed cache");
526            self.buckets[idx].compiled = Some(compiled);
527        }
528        Some((upper, self.buckets[idx].compiled.as_mut().unwrap()))
529    }
530
531    /// Index of the bucket containing `key`, or `None` if out of range.
532    /// Linear scan — bucket counts are small in practice.
533    pub fn bucket_for(&self, key: u64) -> Option<usize> {
534        self.buckets.iter().position(|b| b.range.contains(&key))
535    }
536
537    /// Upper compile extent for `key`'s bucket (`range.end - 1`), without compiling.
538    pub fn bucket_upper_for_key(&self, key: u64) -> Option<u64> {
539        let idx = self.bucket_for(key)?;
540        Some(self.buckets[idx].range.end - 1)
541    }
542
543    pub fn buckets(&self) -> impl Iterator<Item = &Range<u64>> {
544        self.buckets.iter().map(|b| &b.range)
545    }
546
547    /// Number of buckets that have been compiled so far (≤ total buckets).
548    pub fn compiled_count(&self) -> usize {
549        self.buckets.iter().filter(|b| b.compiled.is_some()).count()
550    }
551
552    /// Mutable compiled graph for `key`'s bucket, if already compiled.
553    pub fn compiled_for_key_mut(&mut self, key: u64) -> Option<&mut CompiledGraph> {
554        let idx = self.bucket_for(key)?;
555        self.buckets[idx].compiled.as_mut()
556    }
557
558    /// Immutable compiled graph for a bucket with compile upper bound `upper`.
559    pub fn compiled_for_upper(&self, upper: u64) -> Option<&CompiledGraph> {
560        self.buckets
561            .iter()
562            .find(|b| b.range.end.saturating_sub(1) == upper)
563            .and_then(|b| b.compiled.as_ref())
564    }
565
566    /// Copy parameter storage from the bucket compiled at `src_upper` into `dst_upper`.
567    pub fn try_copy_params_between_uppers(&mut self, dst_upper: u64, src_upper: u64) -> bool {
568        if dst_upper == src_upper {
569            return true;
570        }
571        let dst_idx = self
572            .buckets
573            .iter()
574            .position(|b| b.range.end.saturating_sub(1) == dst_upper);
575        let src_idx = self
576            .buckets
577            .iter()
578            .position(|b| b.range.end.saturating_sub(1) == src_upper);
579        let (Some(dst_idx), Some(src_idx)) = (dst_idx, src_idx) else {
580            return false;
581        };
582        if dst_idx == src_idx {
583            return true;
584        }
585        let (dst, src) = if dst_idx < src_idx {
586            let (left, right) = self.buckets.split_at_mut(src_idx);
587            (&mut left[dst_idx], &right[0])
588        } else {
589            let (left, right) = self.buckets.split_at_mut(dst_idx);
590            (&mut right[0], &left[src_idx])
591        };
592        let Some(dst_c) = dst.compiled.as_mut() else {
593            return false;
594        };
595        let Some(src_c) = src.compiled.as_ref() else {
596            return false;
597        };
598        dst_c.copy_params_from(src_c)
599    }
600
601    /// The bucket currently designated as the shared weight donor, if any.
602    pub fn weight_donor(&self) -> Option<&CompiledGraph> {
603        self.compiled_for_upper(self.weight_donor_upper?)
604    }
605
606    /// Record the bucket at `upper` as the canonical shared weight donor.
607    /// The first bucket to receive a real weight upload calls this; later
608    /// buckets share its buffer via [`Self::try_share_params_from_donor`].
609    pub fn set_weight_donor(&mut self, upper: u64) {
610        if self.weight_donor_upper.is_none() {
611            self.weight_donor_upper = Some(upper);
612        }
613    }
614
615    /// Retain the donor bucket's weight buffer for the bucket at `dst_upper`
616    /// (one GPU copy for both) when their layout matches exactly. Returns false
617    /// — caller must upload — when there is no donor or the layout differs.
618    ///
619    /// If the recorded donor was evicted, tries any other live compiled bucket
620    /// as a share source (needed when `RLX_KV_CACHE_MAX_RESIDENT=1` climbs).
621    pub fn try_share_params_from_donor(&mut self, dst_upper: u64) -> bool {
622        if std::env::var("RLX_METAL_NO_SHARE").is_ok() {
623            return false;
624        }
625        let mut candidates: Vec<u64> = Vec::new();
626        if let Some(src) = self.weight_donor_upper {
627            candidates.push(src);
628        }
629        for b in &self.buckets {
630            if b.compiled.is_none() {
631                continue;
632            }
633            let u = b.range.end.saturating_sub(1);
634            if u != dst_upper && !candidates.contains(&u) {
635                candidates.push(u);
636            }
637        }
638        for src_upper in candidates {
639            if self.try_share_params_between_uppers(dst_upper, src_upper) {
640                self.weight_donor_upper = Some(src_upper);
641                return true;
642            }
643        }
644        false
645    }
646
647    fn try_share_params_between_uppers(&mut self, dst_upper: u64, src_upper: u64) -> bool {
648        if dst_upper == src_upper {
649            return true;
650        }
651        let dst_idx = self
652            .buckets
653            .iter()
654            .position(|b| b.range.end.saturating_sub(1) == dst_upper);
655        let src_idx = self
656            .buckets
657            .iter()
658            .position(|b| b.range.end.saturating_sub(1) == src_upper);
659        let (Some(dst_idx), Some(src_idx)) = (dst_idx, src_idx) else {
660            return false;
661        };
662        if dst_idx == src_idx {
663            return true;
664        }
665        let (dst, src) = if dst_idx < src_idx {
666            let (left, right) = self.buckets.split_at_mut(src_idx);
667            (&mut left[dst_idx], &right[0])
668        } else {
669            let (left, right) = self.buckets.split_at_mut(dst_idx);
670            (&mut right[0], &left[src_idx])
671        };
672        let Some(dst_c) = dst.compiled.as_mut() else {
673            return false;
674        };
675        let Some(src_c) = src.compiled.as_ref() else {
676            return false;
677        };
678        dst_c.share_params_from(src_c)
679    }
680
681    /// D2D seed resident KV from `src_key`'s bucket into `dst_key`'s bucket.
682    /// See `rlx-cuda::CudaExecutable::copy_resident_kv_rows_from` and
683    /// `rlx-llama32/docs/cuda-gguf-decode.md`.
684    pub fn seed_resident_kv_prefix_from_keys(
685        &mut self,
686        src_key: u64,
687        dst_key: u64,
688        prefix_tokens: usize,
689        outgoing_upper: usize,
690        kv_dim: usize,
691        n_layers: usize,
692    ) -> bool {
693        let Some(src_idx) = self.bucket_for(src_key) else {
694            return false;
695        };
696        let Some(dst_idx) = self.bucket_for(dst_key) else {
697            return false;
698        };
699        if src_idx == dst_idx {
700            return true;
701        }
702        if src_idx < dst_idx {
703            let (left, right) = self.buckets.split_at_mut(dst_idx);
704            let Some(src) = left[src_idx].compiled.as_ref() else {
705                return false;
706            };
707            let Some(dst) = right[0].compiled.as_mut() else {
708                return false;
709            };
710            return dst.seed_resident_kv_prefix_from(
711                src,
712                prefix_tokens,
713                outgoing_upper,
714                kv_dim,
715                n_layers,
716            );
717        }
718        let (left, right) = self.buckets.split_at_mut(src_idx);
719        let Some(src) = right[0].compiled.as_ref() else {
720            return false;
721        };
722        let Some(dst) = left[dst_idx].compiled.as_mut() else {
723            return false;
724        };
725        dst.seed_resident_kv_prefix_from(src, prefix_tokens, outgoing_upper, kv_dim, n_layers)
726    }
727
728    /// Hybrid bucket rollover: H2D the host-known prefix once, then copy only rows
729    /// the host cache does not already have from `src` (via `copy_resident_kv_rows_from`).
730    /// Not used by `rlx-llama32` today (generator flush+bind path); kept for experiments.
731    pub fn rebind_resident_kv_hybrid_from_keys(
732        &mut self,
733        src_key: u64,
734        dst_key: u64,
735        host_k: &[Vec<f32>],
736        host_v: &[Vec<f32>],
737        prefix_tokens: usize,
738        outgoing_upper: usize,
739        upper: usize,
740        kv_dim: usize,
741        n_layers: usize,
742    ) -> bool {
743        let Some(src_idx) = self.bucket_for(src_key) else {
744            return false;
745        };
746        let Some(dst_idx) = self.bucket_for(dst_key) else {
747            return false;
748        };
749        if src_idx == dst_idx {
750            return true;
751        }
752        let host_rows = host_k.first().map(|k| k.len() / kv_dim.max(1)).unwrap_or(0);
753        let rebind = |src: &CompiledGraph, dst: &mut CompiledGraph| -> bool {
754            for i in 0..n_layers {
755                let mut kp = vec![0f32; upper * kv_dim];
756                let mut vp = vec![0f32; upper * kv_dim];
757                let nk = host_k[i].len().min(kp.len());
758                let nv = host_v[i].len().min(vp.len());
759                kp[..nk].copy_from_slice(&host_k[i][..nk]);
760                vp[..nv].copy_from_slice(&host_v[i][..nv]);
761                let k_name = format!("past_k_{i}");
762                let v_name = format!("past_v_{i}");
763                if !dst.bind_gpu_handle(&k_name, &kp) || !dst.bind_gpu_handle(&v_name, &vp) {
764                    return false;
765                }
766                dst.register_kv_row_feed(&k_name, 1 + 2 * i);
767                dst.register_kv_row_feed(&v_name, 2 + 2 * i);
768            }
769            dst.stage_bound_gpu_handles_to_arena();
770            if host_rows < prefix_tokens {
771                return dst.copy_resident_kv_rows_from(
772                    src,
773                    host_rows,
774                    prefix_tokens,
775                    outgoing_upper,
776                    kv_dim,
777                    n_layers,
778                );
779            }
780            for i in 0..n_layers {
781                let k_name = format!("past_k_{i}");
782                let v_name = format!("past_v_{i}");
783                dst.prepare_resident_gpu_handle(&k_name);
784                dst.prepare_resident_gpu_handle(&v_name);
785            }
786            true
787        };
788        if src_idx < dst_idx {
789            let (left, right) = self.buckets.split_at_mut(dst_idx);
790            let Some(src) = left[src_idx].compiled.as_ref() else {
791                return false;
792            };
793            let Some(dst) = right[0].compiled.as_mut() else {
794                return false;
795            };
796            return rebind(src, dst);
797        }
798        let (left, right) = self.buckets.split_at_mut(src_idx);
799        let Some(src) = right[0].compiled.as_ref() else {
800            return false;
801        };
802        let Some(dst) = left[dst_idx].compiled.as_mut() else {
803            return false;
804        };
805        rebind(src, dst)
806    }
807
808    pub fn total_buckets(&self) -> usize {
809        self.buckets.len()
810    }
811
812    /// Drop compiled graphs for all buckets except `keep`, freeing weight params.
813    pub fn evict_except(&mut self, keep: usize) {
814        for (i, bucket) in self.buckets.iter_mut().enumerate() {
815            if i != keep {
816                bucket.compiled = None;
817            }
818        }
819    }
820
821    /// Drop the single highest-index compiled bucket that is not `protect`,
822    /// freeing its weight params, and return its index (or `None` if no
823    /// other bucket is compiled). Pass `usize::MAX` for `protect` to allow
824    /// evicting any bucket.
825    ///
826    /// This is the incremental counterpart to [`Self::evict_except`]: rather
827    /// than collapsing to a single resident bucket, callers can free one
828    /// bucket at a time (highest index first) until a memory budget is met,
829    /// keeping the rest of the ladder cached. Highest-index-first is the
830    /// right victim order for a monotonically increasing `past_seq` sweep —
831    /// the top bucket is reached last within an utterance and the low buckets
832    /// recur first at the start of the next, so evicting from the top
833    /// maximizes cross-utterance reuse.
834    pub fn evict_one_except(&mut self, protect: usize) -> Option<usize> {
835        let victim = self
836            .buckets
837            .iter()
838            .enumerate()
839            .rev()
840            .find(|(i, b)| *i != protect && b.compiled.is_some())
841            .map(|(i, _)| i)?;
842        if let Some(compiled) = self.buckets[victim].compiled.as_mut() {
843            compiled.sync_pending();
844        }
845        self.buckets[victim].compiled = None;
846        Some(victim)
847    }
848
849    /// Drop all compiled graphs (free param storage).
850    pub fn clear_compiled(&mut self) {
851        for bucket in &mut self.buckets {
852            bucket.compiled = None;
853        }
854    }
855
856    /// "Compile at max, run at less" convenience for inputs and outputs
857    /// whose outer dimension is the bucket key:
858    ///
859    /// 1. Find or compile the bucket containing `key`.
860    /// 2. For each input, pad to `upper` rows along the outer dim using
861    ///    `pad_rows` (caller passes the inner-dim stride per input;
862    ///    `inner = 1` for purely 1D inputs).
863    /// 3. Run the compiled graph at full extent.
864    /// 4. Slice each output back to `actual_rows` along its outer dim.
865    ///    Outputs flagged with `inner = 0` in `output_inners` are
866    ///    returned unsliced (use this for extent-independent outputs
867    ///    like a pooled `[hidden]` embedding). Missing entries past
868    ///    the end of `output_inners` are also returned unsliced.
869    ///
870    /// Returns `(upper, outputs)`. Returns `None` if `key` falls outside
871    /// every bucket.
872    ///
873    /// **Compute scope:** kernels execute at the bucket's compile
874    /// extent (`upper`), not at `actual_rows`. This means smaller
875    /// buckets directly translate to less padded compute. With
876    /// [`power_of_two_ladder`](Self::power_of_two_ladder) the worst-
877    /// case waste is bounded at 2×; with hand-tuned buckets it can be
878    /// arbitrarily tight. True active-extent dispatch — one big
879    /// compile, kernels short-circuit at runtime — is a separate
880    /// per-backend change.
881    pub fn run_padded<F: FnOnce(u64) -> Graph>(
882        &mut self,
883        key: u64,
884        actual_rows: usize,
885        build: F,
886        inputs: &[(&str, &[f32], usize)],
887        output_inners: &[usize],
888    ) -> Option<(u64, Vec<Vec<f32>>)> {
889        let (upper, compiled) = self.get_or_compile(key, build)?;
890
891        // Own the padded buffers so they outlive the borrow handed to `run`.
892        let padded: Vec<(&str, Vec<f32>)> = inputs
893            .iter()
894            .map(|(name, data, inner)| (*name, pad_rows(data, *inner, upper)))
895            .collect();
896        let pairs: Vec<(&str, &[f32])> = padded.iter().map(|(n, d)| (*n, d.as_slice())).collect();
897
898        // Hint active-extent: backends that support per-kernel skip-
899        // compute (today: CPU's Activation thunk family) honor it; the
900        // default trait impl is a no-op, so other backends just process
901        // full extent and the slice_rows below still gives the user
902        // correct outputs.
903        compiled.set_active_extent(Some((actual_rows, upper as usize)));
904        let raw_outputs = compiled.run(&pairs);
905        compiled.set_active_extent(None);
906        #[cfg(feature = "cpu")]
907        crate::onnx_active::set_active_token_count(None);
908
909        let outs = raw_outputs
910            .into_iter()
911            .enumerate()
912            .map(|(i, out)| match output_inners.get(i).copied() {
913                Some(0) | None => out,
914                Some(inner) => slice_rows(&out, inner, actual_rows),
915            })
916            .collect();
917
918        Some((upper, outs))
919    }
920
921    /// Like [`Self::get_or_compile_with_options`] but also uploads `params` on first compile.
922    pub fn ensure_graph_with_params<F>(
923        &mut self,
924        key: u64,
925        build: F,
926        options: &crate::CompileOptions,
927    ) -> Option<(u64, &mut CompiledGraph)>
928    where
929        F: FnOnce(u64) -> (Graph, HashMap<String, Vec<f32>>),
930    {
931        let idx = self.bucket_for(key)?;
932        let upper = self.buckets[idx].range.end - 1;
933        if self.buckets[idx].compiled.is_none() {
934            let (graph, params) = build(upper);
935            let bytes: usize = params.values().map(|v| v.len() * 4).sum();
936            self.evict_for_incoming(idx, bytes);
937            let mut session = Session::new(self.device);
938            if let Some(p) = &self.policy {
939                session = session.with_policy(p.clone());
940            }
941            let mut compiled = session.compile_with(graph, options);
942            for (name, data) in params {
943                compiled.set_param(&name, &data);
944            }
945            self.buckets[idx].compiled = Some(compiled);
946            self.buckets[idx].resident_bytes = bytes;
947        }
948        self.touch(idx);
949        Some((upper, self.buckets[idx].compiled.as_mut().unwrap()))
950    }
951
952    /// HIR variant of [`Self::ensure_graph_with_params`].
953    pub fn ensure_hir_with_params<F>(
954        &mut self,
955        key: u64,
956        build: F,
957        options: &crate::CompileOptions,
958    ) -> Option<(u64, &mut CompiledGraph)>
959    where
960        F: FnOnce(u64) -> (HirModule, HashMap<String, Vec<f32>>),
961    {
962        let idx = self.bucket_for(key)?;
963        let upper = self.buckets[idx].range.end - 1;
964        if self.buckets[idx].compiled.is_none() {
965            let (hir, params) = build(upper);
966            let bytes: usize = params.values().map(|v| v.len() * 4).sum();
967            // Defer `evict_for_incoming` until after the caller shares/uploads
968            // packed weights (`note_resident_bytes`). Compiling first keeps the
969            // previous rung alive long enough to `try_share_params_from_donor`
970            // under `RLX_KV_CACHE_MAX_RESIDENT=1` (no multi-GB re-upload).
971            let mut session = Session::new(self.device);
972            if let Some(p) = &self.policy {
973                session = session.with_policy(p.clone());
974            }
975            let mut compiled = session
976                .compile_hir_with(hir, options)
977                .expect("HIR lower/compile in ensure_hir_with_params");
978            for (name, data) in params {
979                compiled.set_param(&name, &data);
980            }
981            self.buckets[idx].compiled = Some(compiled);
982            self.buckets[idx].resident_bytes = bytes;
983        }
984        self.touch(idx);
985        Some((upper, self.buckets[idx].compiled.as_mut().unwrap()))
986    }
987
988    /// Record additional resident weight bytes after a packed GGUF upload.
989    ///
990    /// F32 `params` alone under-count packed Q1_0 / Q4_K arenas (~3–4 GiB), so
991    /// without this call discrete-VRAM eviction never classifies buckets as
992    /// "large" and keeps every rung resident → OOM on 16 GB cards.
993    pub fn note_resident_bytes(&mut self, key: u64, extra_bytes: usize) {
994        if extra_bytes == 0 {
995            return;
996        }
997        let Some(idx) = self.bucket_for(key) else {
998            return;
999        };
1000        let before = self.buckets[idx].resident_bytes;
1001        let after = before.saturating_add(extra_bytes);
1002        // Evict *other* large buckets if this update crosses the threshold.
1003        if after >= LARGE_BUCKET_BYTES {
1004            self.evict_for_incoming(idx, after);
1005        }
1006        self.buckets[idx].resident_bytes = after;
1007        self.touch(idx);
1008    }
1009
1010    /// [`Self::run_padded`] with per-input optional row padding (`CacheRunInput`).
1011    pub fn run_padded_mixed<F>(
1012        &mut self,
1013        key: u64,
1014        actual_rows: usize,
1015        build: F,
1016        inputs: &[CacheRunInput<'_>],
1017        output_inners: &[usize],
1018    ) -> Option<(u64, Vec<Vec<f32>>)>
1019    where
1020        F: FnOnce(u64) -> Graph,
1021    {
1022        let (upper, compiled) = self.get_or_compile(key, build)?;
1023
1024        let padded: Vec<(&str, Vec<f32>)> = inputs
1025            .iter()
1026            .map(|inp| match inp.row_inner {
1027                Some(inner) => (inp.name, pad_rows(inp.data, inner, upper)),
1028                None => (inp.name, inp.data.to_vec()),
1029            })
1030            .collect();
1031        let pairs: Vec<(&str, &[f32])> = padded.iter().map(|(n, d)| (*n, d.as_slice())).collect();
1032
1033        compiled.set_active_extent(Some((actual_rows, upper as usize)));
1034        let raw_outputs = compiled.run(&pairs);
1035        compiled.set_active_extent(None);
1036        #[cfg(feature = "cpu")]
1037        crate::onnx_active::set_active_token_count(None);
1038
1039        let outs = raw_outputs
1040            .into_iter()
1041            .enumerate()
1042            .map(|(i, out)| match output_inners.get(i).copied() {
1043                Some(0) | None => out,
1044                Some(inner) => slice_rows(&out, inner, actual_rows),
1045            })
1046            .collect();
1047
1048        Some((upper, outs))
1049    }
1050
1051    /// Drain in-flight GPU work on every compiled bucket.
1052    pub fn sync_all(&mut self) {
1053        for bucket in &mut self.buckets {
1054            if let Some(compiled) = &mut bucket.compiled {
1055                compiled.sync_pending();
1056            }
1057        }
1058    }
1059}
1060
1061// ── Dynamic-dim cache (plan #54) ──────────────────────────────────────
1062//
1063// Compile HIR once through the fusion pipeline (graph may contain
1064// `Dim::Dynamic` symbols), then specialize to concrete shapes per cache
1065// key and backend-compile the resulting LIR.
1066
1067/// Compile-once / specialize-at-runtime cache for symbolic HIR modules.
1068pub struct DynamicDimCompileCache {
1069    device: Device,
1070    policy: Option<rlx_opt::PrecisionPolicy>,
1071    capacity: usize,
1072    template: Option<CompileResult>,
1073    entries: Vec<(u64, CompiledGraph)>,
1074    order: VecDeque<u64>,
1075}
1076
1077impl DynamicDimCompileCache {
1078    pub fn new(device: Device, capacity: usize) -> Self {
1079        Self::with_policy(device, capacity, None)
1080    }
1081
1082    pub fn with_policy(
1083        device: Device,
1084        capacity: usize,
1085        policy: Option<rlx_opt::PrecisionPolicy>,
1086    ) -> Self {
1087        assert!(capacity > 0, "DynamicDimCompileCache capacity must be ≥ 1");
1088        Self {
1089            device,
1090            policy,
1091            capacity,
1092            template: None,
1093            entries: Vec::with_capacity(capacity),
1094            order: VecDeque::with_capacity(capacity),
1095        }
1096    }
1097
1098    pub fn compile_device(&self) -> Device {
1099        self.device
1100    }
1101
1102    /// Return a backend-compiled graph specialized for `binding`.
1103    /// `build_hir` runs at most once to populate the dynamic template.
1104    pub fn get_or_specialize<F: FnOnce() -> HirModule>(
1105        &mut self,
1106        key: u64,
1107        binding: &DimBinding,
1108        build_hir: F,
1109        options: &crate::CompileOptions,
1110    ) -> Result<&mut CompiledGraph, rlx_ir::hir::LowerError> {
1111        if let Some(idx) = self.entries.iter().position(|(k, _)| *k == key) {
1112            return Ok(&mut self.entries[idx].1);
1113        }
1114        if self.template.is_none() {
1115            let mut template_opts = options.clone();
1116            template_opts.dim_binding = None;
1117            let pipe = crate::stages::pipeline_for(self.device, &template_opts);
1118            self.template = Some(pipe.compile_hir(build_hir())?);
1119        }
1120        let template = self.template.as_ref().expect("template just set");
1121        let mut spec_opts = options.clone();
1122        spec_opts.dim_binding = None;
1123        let pipe = crate::stages::pipeline_for(self.device, &spec_opts);
1124        let specialized = template.specialize(&pipe, binding);
1125        let backend = crate::registry::backend_for(self.device).expect("backend registered");
1126        let mut compile_opts = options.clone();
1127        compile_opts.dim_binding = None;
1128        if compile_opts.policy.is_none() {
1129            if let Some(p) = &self.policy {
1130                compile_opts = compile_opts.policy(p.clone());
1131            }
1132        }
1133        let executable = backend.compile_lir(specialized.lir, &compile_opts);
1134        let compiled = CompiledGraph::new(executable, self.device);
1135
1136        if self.entries.len() >= self.capacity
1137            && let Some(evict_key) = self.order.pop_front()
1138        {
1139            sync_evicted_entry(&mut self.entries, evict_key);
1140            self.entries.retain(|(k, _)| *k != evict_key);
1141        }
1142        self.entries.push((key, compiled));
1143        self.order.push_back(key);
1144        Ok(&mut self.entries.last_mut().unwrap().1)
1145    }
1146
1147    pub fn len(&self) -> usize {
1148        self.entries.len()
1149    }
1150
1151    pub fn is_empty(&self) -> bool {
1152        self.entries.is_empty()
1153    }
1154
1155    pub fn contains(&self, key: u64) -> bool {
1156        self.entries.iter().any(|(k, _)| *k == key)
1157    }
1158
1159    pub fn clear(&mut self) {
1160        self.sync_all();
1161        self.template = None;
1162        self.entries.clear();
1163        self.order.clear();
1164    }
1165
1166    pub fn has_template(&self) -> bool {
1167        self.template.is_some()
1168    }
1169
1170    /// Drain in-flight GPU work on every specialized entry.
1171    pub fn sync_all(&mut self) {
1172        for (_, compiled) in &mut self.entries {
1173            compiled.sync_pending();
1174        }
1175    }
1176
1177    /// Build the symbolic template once (no specialization).
1178    pub fn ensure_template<F: FnOnce() -> HirModule>(
1179        &mut self,
1180        build_hir: F,
1181        options: &crate::CompileOptions,
1182    ) -> Result<&CompileResult, rlx_ir::hir::LowerError> {
1183        if self.template.is_none() {
1184            let mut opts = options.clone();
1185            opts.dim_binding = None;
1186            let pipe = crate::stages::pipeline_for(self.device, &opts);
1187            self.template = Some(pipe.compile_hir(build_hir())?);
1188        }
1189        Ok(self.template.as_ref().expect("template set"))
1190    }
1191
1192    pub fn template_result(&self) -> Option<&CompileResult> {
1193        self.template.as_ref()
1194    }
1195
1196    /// Specialize via on-disk LIR cache ([`CompilationMode::Aot`]).
1197    /// Disk-backed specialize ([`rlx_ir::CompilationMode::Aot`]).
1198    pub fn get_or_specialize_aot<F: FnOnce() -> HirModule>(
1199        &mut self,
1200        aot: &crate::AotCache,
1201        disk_base: &str,
1202        key: u64,
1203        binding: &rlx_ir::DimBinding,
1204        build_hir: F,
1205        options: &crate::CompileOptions,
1206    ) -> Result<&mut CompiledGraph, crate::AotCacheError> {
1207        if let Some(idx) = self.entries.iter().position(|(k, _)| *k == key) {
1208            return Ok(&mut self.entries[idx].1);
1209        }
1210        let device = self.device;
1211        let template = self.ensure_template(build_hir, options)?;
1212        let compiled = aot.specialize_cached(disk_base, binding, device, template, options)?;
1213        if self.entries.len() >= self.capacity
1214            && let Some(evict_key) = self.order.pop_front()
1215        {
1216            sync_evicted_entry(&mut self.entries, evict_key);
1217            self.entries.retain(|(k, _)| *k != evict_key);
1218        }
1219        self.entries.push((key, compiled));
1220        self.order.push_back(key);
1221        Ok(&mut self.entries.last_mut().unwrap().1)
1222    }
1223}
1224
1225/// Pad `data` (interpreted as `[actual, inner]` row-major) up to `upper`
1226/// rows by appending zeros. Returns a `Vec<f32>` of length
1227/// `upper * inner`. Companion of [`slice_rows`] for the
1228/// "compile at max, run at less" workflow with [`BucketedCompileCache`].
1229///
1230/// Panics if `data.len()` is not a multiple of `inner`, if `inner == 0`,
1231/// or if `data.len() / inner > upper`.
1232pub fn pad_rows(data: &[f32], inner: usize, upper: u64) -> Vec<f32> {
1233    assert!(inner > 0, "pad_rows: inner stride must be ≥ 1");
1234    assert_eq!(
1235        data.len() % inner,
1236        0,
1237        "pad_rows: data len {} not a multiple of inner {inner}",
1238        data.len(),
1239    );
1240    let upper = upper as usize;
1241    let actual = data.len() / inner;
1242    assert!(
1243        actual <= upper,
1244        "pad_rows: actual rows {actual} exceed upper bound {upper}",
1245    );
1246    let mut out = vec![0.0_f32; upper * inner];
1247    out[..actual * inner].copy_from_slice(data);
1248    out
1249}
1250
1251/// Pad `data` (`[actual, inner]` row-major) into preallocated `out` (`[upper, inner]`).
1252pub fn pad_rows_into(out: &mut [f32], data: &[f32], inner: usize) {
1253    assert!(inner > 0, "pad_rows_into: inner stride must be ≥ 1");
1254    assert_eq!(
1255        data.len() % inner,
1256        0,
1257        "pad_rows_into: data len {} not a multiple of inner {inner}",
1258        data.len(),
1259    );
1260    assert_eq!(
1261        out.len() % inner,
1262        0,
1263        "pad_rows_into: out len {} not a multiple of inner {inner}",
1264        out.len(),
1265    );
1266    let upper = out.len() / inner;
1267    let actual = data.len() / inner;
1268    assert!(
1269        actual <= upper,
1270        "pad_rows_into: actual rows {actual} exceed upper bound {upper}",
1271    );
1272    out.fill(0.0);
1273    out[..data.len()].copy_from_slice(data);
1274}
1275
1276/// Slice `data` (interpreted as `[upper, inner]` row-major) down to
1277/// `actual` rows. Companion of [`pad_rows`].
1278///
1279/// Panics if `data.len()` is not a multiple of `inner`, if `inner == 0`,
1280/// or if `actual` exceeds the number of rows in `data`.
1281pub fn slice_rows(data: &[f32], inner: usize, actual: usize) -> Vec<f32> {
1282    assert!(inner > 0, "slice_rows: inner stride must be ≥ 1");
1283    assert_eq!(
1284        data.len() % inner,
1285        0,
1286        "slice_rows: data len {} not a multiple of inner {inner}",
1287        data.len(),
1288    );
1289    let upper = data.len() / inner;
1290    assert!(
1291        actual <= upper,
1292        "slice_rows: actual rows {actual} exceed upper {upper}",
1293    );
1294    data[..actual * inner].to_vec()
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299    use super::*;
1300    use rlx_ir::infer::GraphExt;
1301    use rlx_ir::*;
1302    use std::cell::Cell;
1303
1304    fn tiny_graph(n: usize) -> Graph {
1305        let mut g = Graph::new("t");
1306        let f = DType::F32;
1307        let x = g.input("x", Shape::new(&[n], f));
1308        let y = g.activation(rlx_ir::op::Activation::Relu, x, Shape::new(&[n], f));
1309        g.set_outputs(vec![y]);
1310        g
1311    }
1312
1313    #[test]
1314    fn cache_hits_avoid_recompile() {
1315        let mut cache = CompileCache::new(Device::Cpu, 4);
1316        let calls = Cell::new(0);
1317
1318        let _ = cache.get_or_compile(1, || {
1319            calls.set(calls.get() + 1);
1320            tiny_graph(8)
1321        });
1322        let _ = cache.get_or_compile(1, || {
1323            calls.set(calls.get() + 1);
1324            tiny_graph(8)
1325        });
1326        let _ = cache.get_or_compile(1, || {
1327            calls.set(calls.get() + 1);
1328            tiny_graph(8)
1329        });
1330        // Same key three times: build closure runs once.
1331        assert_eq!(calls.get(), 1);
1332        assert_eq!(cache.len(), 1);
1333    }
1334
1335    #[test]
1336    fn fifo_evicts_oldest_at_capacity() {
1337        let mut cache = CompileCache::new(Device::Cpu, 2);
1338        let _ = cache.get_or_compile(1, || tiny_graph(4));
1339        let _ = cache.get_or_compile(2, || tiny_graph(8));
1340        assert!(cache.contains(1) && cache.contains(2));
1341        // Third entry evicts key 1 (oldest).
1342        let _ = cache.get_or_compile(3, || tiny_graph(16));
1343        assert!(!cache.contains(1));
1344        assert!(cache.contains(2) && cache.contains(3));
1345    }
1346
1347    #[test]
1348    fn different_keys_keep_separate_compiles() {
1349        let mut cache = CompileCache::new(Device::Cpu, 4);
1350        let calls = Cell::new(0);
1351        let _ = cache.get_or_compile(1, || {
1352            calls.set(calls.get() + 1);
1353            tiny_graph(8)
1354        });
1355        let _ = cache.get_or_compile(2, || {
1356            calls.set(calls.get() + 1);
1357            tiny_graph(16)
1358        });
1359        let _ = cache.get_or_compile(1, || {
1360            calls.set(calls.get() + 1);
1361            tiny_graph(8)
1362        });
1363        // Two unique keys → two compiles.
1364        assert_eq!(calls.get(), 2);
1365        assert_eq!(cache.len(), 2);
1366    }
1367
1368    // ── BucketedCompileCache ──────────────────────────────────────────
1369
1370    #[test]
1371    fn bucket_amortizes_keys_within_range() {
1372        let mut cache = BucketedCompileCache::new(Device::Cpu, vec![1..4, 4..16]);
1373        let calls = Cell::new(0);
1374        let uppers = Cell::new((0u64, 0u64));
1375
1376        // Two distinct keys (2 and 3) both fall inside bucket 0 (1..4).
1377        let (u1, _) = cache
1378            .get_or_compile(2, |upper| {
1379                calls.set(calls.get() + 1);
1380                uppers.set((upper, uppers.get().1));
1381                tiny_graph(upper as usize)
1382            })
1383            .expect("key 2 in range");
1384        let (u2, _) = cache
1385            .get_or_compile(3, |upper| {
1386                calls.set(calls.get() + 1);
1387                uppers.set((uppers.get().0, upper));
1388                tiny_graph(upper as usize)
1389            })
1390            .expect("key 3 in range");
1391
1392        // One compile, both calls saw the same upper = range.end - 1 = 3.
1393        assert_eq!(calls.get(), 1);
1394        assert_eq!(u1, 3);
1395        assert_eq!(u2, 3);
1396        assert_eq!(uppers.get().0, 3);
1397        assert_eq!(cache.compiled_count(), 1);
1398        assert_eq!(cache.total_buckets(), 2);
1399    }
1400
1401    #[test]
1402    fn bucket_lookup_returns_none_outside_range() {
1403        let mut cache = BucketedCompileCache::new(Device::Cpu, vec![1..4, 4..16]);
1404        assert!(cache.bucket_for(0).is_none());
1405        assert!(cache.bucket_for(16).is_none());
1406        assert!(cache.bucket_for(100).is_none());
1407        assert_eq!(cache.bucket_for(3), Some(0));
1408        assert_eq!(cache.bucket_for(4), Some(1));
1409        assert_eq!(cache.bucket_upper_for_key(3), Some(3));
1410        assert_eq!(cache.bucket_upper_for_key(4), Some(15));
1411        assert!(cache.bucket_upper_for_key(0).is_none());
1412
1413        let calls = Cell::new(0);
1414        let result = cache.get_or_compile(100, |u| {
1415            calls.set(calls.get() + 1);
1416            tiny_graph(u as usize)
1417        });
1418        assert!(result.is_none());
1419        assert_eq!(calls.get(), 0); // build closure must not run for OOR keys
1420        assert_eq!(cache.compiled_count(), 0);
1421    }
1422
1423    #[test]
1424    fn bucket_compiles_lazily_per_bucket() {
1425        let mut cache = BucketedCompileCache::new(Device::Cpu, vec![1..4, 4..16, 16..64]);
1426        let calls = Cell::new(0);
1427
1428        let _ = cache.get_or_compile(2, |u| {
1429            calls.set(calls.get() + 1);
1430            tiny_graph(u as usize)
1431        });
1432        let _ = cache.get_or_compile(8, |u| {
1433            calls.set(calls.get() + 1);
1434            tiny_graph(u as usize)
1435        });
1436        // Two distinct buckets hit → two compiles. Third bucket untouched.
1437        assert_eq!(calls.get(), 2);
1438        assert_eq!(cache.compiled_count(), 2);
1439        assert_eq!(cache.total_buckets(), 3);
1440    }
1441
1442    #[test]
1443    #[should_panic(expected = "overlap")]
1444    fn bucket_overlap_rejected() {
1445        let _ = BucketedCompileCache::new(Device::Cpu, vec![1..8, 4..16]);
1446    }
1447
1448    #[test]
1449    #[should_panic(expected = "≥1 bucket")]
1450    fn empty_bucket_list_rejected() {
1451        let _ = BucketedCompileCache::new(Device::Cpu, vec![]);
1452    }
1453
1454    // ── pad_rows / slice_rows ─────────────────────────────────────────
1455
1456    #[test]
1457    fn pad_rows_appends_zeros() {
1458        // 1D: actual=3 → upper=5, inner=1.
1459        let p = pad_rows(&[1.0, 2.0, 3.0], 1, 5);
1460        assert_eq!(p, vec![1.0, 2.0, 3.0, 0.0, 0.0]);
1461
1462        // 2D row-major [actual=2, inner=3] → [upper=4, inner=3].
1463        let p = pad_rows(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 3, 4);
1464        assert_eq!(
1465            p,
1466            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1467        );
1468
1469        // actual == upper: no-op pad.
1470        let p = pad_rows(&[7.0, 8.0], 1, 2);
1471        assert_eq!(p, vec![7.0, 8.0]);
1472    }
1473
1474    #[test]
1475    fn slice_rows_truncates_trailing() {
1476        let s = slice_rows(&[1.0, 2.0, 3.0, 0.0, 0.0], 1, 3);
1477        assert_eq!(s, vec![1.0, 2.0, 3.0]);
1478
1479        let s = slice_rows(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 0.0, 0.0, 0.0], 3, 2);
1480        assert_eq!(s, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
1481    }
1482
1483    #[test]
1484    #[should_panic(expected = "exceed upper")]
1485    fn pad_rows_rejects_too_long_input() {
1486        let _ = pad_rows(&[1.0, 2.0, 3.0, 4.0], 1, 3);
1487    }
1488
1489    #[test]
1490    #[should_panic(expected = "exceed upper")]
1491    fn slice_rows_rejects_too_large_actual() {
1492        let _ = slice_rows(&[1.0, 2.0, 3.0], 1, 5);
1493    }
1494
1495    // ── BucketedCompileCache::run_padded ──────────────────────────────
1496
1497    #[test]
1498    fn run_padded_pads_input_and_slices_output() {
1499        // tiny_graph is 1D [n] → relu → [n].
1500        // Compile bucket [1..16) at upper=15, run with actual_rows=10.
1501        let mut cache = BucketedCompileCache::new(Device::Cpu, vec![1..16]);
1502        let input: Vec<f32> = vec![1.0, -1.0, 2.0, -2.0, 3.0, -3.0, 4.0, -4.0, 5.0, -5.0];
1503
1504        let (upper, outs) = cache
1505            .run_padded(
1506                10, // key
1507                10, // actual rows
1508                |max| tiny_graph(max as usize),
1509                &[("x", &input, 1)], // 1D, inner stride 1
1510                &[1],                // slice the one output to actual rows
1511            )
1512            .expect("key 10 in [1..16)");
1513
1514        assert_eq!(upper, 15);
1515        assert_eq!(outs.len(), 1);
1516        let out = &outs[0];
1517        assert_eq!(out.len(), 10, "output sliced back to actual_rows");
1518        let expected: Vec<f32> = input.iter().map(|x| x.max(0.0)).collect();
1519        assert_eq!(out, &expected);
1520    }
1521
1522    #[test]
1523    fn run_padded_reuses_bucket_across_actuals() {
1524        // Same bucket, two different actuals — only one compile.
1525        let mut cache = BucketedCompileCache::new(Device::Cpu, vec![1..16]);
1526        let calls = Cell::new(0);
1527
1528        let (u1, o1) = cache
1529            .run_padded(
1530                10,
1531                10,
1532                |max| {
1533                    calls.set(calls.get() + 1);
1534                    tiny_graph(max as usize)
1535                },
1536                &[(
1537                    "x",
1538                    &[1.0, -1.0, 2.0, -2.0, 3.0, -3.0, 4.0, -4.0, 5.0, -5.0],
1539                    1,
1540                )],
1541                &[1],
1542            )
1543            .unwrap();
1544        assert_eq!(o1.len(), 1);
1545        assert_eq!(o1[0].len(), 10);
1546        assert_eq!(u1, 15);
1547
1548        let (u2, o2) = cache
1549            .run_padded(
1550                5,
1551                5,
1552                |max| {
1553                    calls.set(calls.get() + 1);
1554                    tiny_graph(max as usize)
1555                },
1556                &[("x", &[-1.0, 2.0, -3.0, 4.0, -5.0], 1)],
1557                &[1],
1558            )
1559            .unwrap();
1560        assert_eq!(o2.len(), 1);
1561        assert_eq!(o2[0].len(), 5);
1562        assert_eq!(u2, 15);
1563        assert_eq!(o2[0], vec![0.0, 2.0, 0.0, 4.0, 0.0]);
1564
1565        assert_eq!(calls.get(), 1, "bucket cached across actuals");
1566        assert_eq!(cache.compiled_count(), 1);
1567    }
1568
1569    #[test]
1570    fn run_padded_returns_none_out_of_range() {
1571        let mut cache = BucketedCompileCache::new(Device::Cpu, vec![1..16]);
1572        let calls = Cell::new(0);
1573        let result = cache.run_padded(
1574            100,
1575            5,
1576            |u| {
1577                calls.set(calls.get() + 1);
1578                tiny_graph(u as usize)
1579            },
1580            &[("x", &[1.0, 2.0, 3.0, 4.0, 5.0], 1)],
1581            &[1],
1582        );
1583        assert!(result.is_none());
1584        assert_eq!(calls.get(), 0);
1585        assert_eq!(cache.compiled_count(), 0);
1586    }
1587
1588    // ── power_of_two_ladder ───────────────────────────────────────────
1589
1590    #[test]
1591    fn power_of_two_ladder_generates_log_buckets() {
1592        let cache = BucketedCompileCache::power_of_two_ladder(Device::Cpu, 8, 64);
1593        // Expect buckets covering keys 1..=64 with extents 8, 16, 32, 64.
1594        let ranges: Vec<_> = cache.buckets().cloned().collect();
1595        assert_eq!(ranges, vec![1..9, 9..17, 17..33, 33..65]);
1596        assert_eq!(cache.total_buckets(), 4);
1597    }
1598
1599    #[test]
1600    fn power_of_two_ladder_picks_smallest_extent_for_actual() {
1601        // Ladder: extents 8, 16, 32, 64. actual=17 lands in the 32-extent
1602        // bucket, NOT the 64-extent one — that's the compute saving.
1603        let mut cache = BucketedCompileCache::power_of_two_ladder(Device::Cpu, 8, 64);
1604        let captured_uppers: std::cell::RefCell<Vec<u64>> = Default::default();
1605
1606        let (u17, _) = cache
1607            .get_or_compile(17, |upper| {
1608                captured_uppers.borrow_mut().push(upper);
1609                tiny_graph(upper as usize)
1610            })
1611            .unwrap();
1612        let (u9, _) = cache
1613            .get_or_compile(9, |upper| {
1614                captured_uppers.borrow_mut().push(upper);
1615                tiny_graph(upper as usize)
1616            })
1617            .unwrap();
1618        let (u3, _) = cache
1619            .get_or_compile(3, |upper| {
1620                captured_uppers.borrow_mut().push(upper);
1621                tiny_graph(upper as usize)
1622            })
1623            .unwrap();
1624        let (u64_, _) = cache
1625            .get_or_compile(64, |upper| {
1626                captured_uppers.borrow_mut().push(upper);
1627                tiny_graph(upper as usize)
1628            })
1629            .unwrap();
1630
1631        assert_eq!(u17, 32, "key=17 → smallest extent ≥ 17 is 32");
1632        assert_eq!(u9, 16, "key=9  → smallest extent ≥ 9  is 16");
1633        assert_eq!(u3, 8, "key=3  → smallest extent ≥ 3  is 8");
1634        assert_eq!(u64_, 64, "key=64 → exact match at 64");
1635        assert_eq!(*captured_uppers.borrow(), vec![32, 16, 8, 64]);
1636        assert_eq!(cache.compiled_count(), 4);
1637    }
1638
1639    #[test]
1640    fn power_of_two_ladder_min_above_one_starts_at_one() {
1641        // First bucket always covers from key 1, even when min > 1.
1642        // (`min` controls the ladder's first extent, not the lower edge.)
1643        let cache = BucketedCompileCache::power_of_two_ladder(Device::Cpu, 16, 32);
1644        let ranges: Vec<_> = cache.buckets().cloned().collect();
1645        // min=16 → first extent 16, second 32. Buckets: 1..17, 17..33.
1646        assert_eq!(ranges, vec![1..17, 17..33]);
1647    }
1648
1649    #[test]
1650    fn power_of_two_ladder_non_pow2_min_rounds_up() {
1651        // min=10 → next_power_of_two = 16.
1652        let cache = BucketedCompileCache::power_of_two_ladder(Device::Cpu, 10, 64);
1653        let ranges: Vec<_> = cache.buckets().cloned().collect();
1654        assert_eq!(ranges, vec![1..17, 17..33, 33..65]);
1655    }
1656
1657    #[test]
1658    fn power_of_two_ladder_max_below_pow2_extends_up() {
1659        // max=20 needs to be covered → ladder extends to 32.
1660        let cache = BucketedCompileCache::power_of_two_ladder(Device::Cpu, 8, 20);
1661        let ranges: Vec<_> = cache.buckets().cloned().collect();
1662        assert_eq!(ranges, vec![1..9, 9..17, 17..33]);
1663    }
1664
1665    #[test]
1666    fn power_of_two_ladder_min_equals_max() {
1667        let cache = BucketedCompileCache::power_of_two_ladder(Device::Cpu, 16, 16);
1668        let ranges: Vec<_> = cache.buckets().cloned().collect();
1669        assert_eq!(ranges, vec![1..17]);
1670    }
1671
1672    #[test]
1673    #[should_panic(expected = "min must be ≥ 1")]
1674    fn power_of_two_ladder_zero_min_rejected() {
1675        let _ = BucketedCompileCache::power_of_two_ladder(Device::Cpu, 0, 16);
1676    }
1677
1678    #[test]
1679    #[should_panic(expected = "max")]
1680    fn power_of_two_ladder_max_below_min_rejected() {
1681        let _ = BucketedCompileCache::power_of_two_ladder(Device::Cpu, 32, 8);
1682    }
1683
1684    // ── Active-extent dispatch (true per-kernel skip-compute) ─────────
1685    //
1686    // The 3 tests below assert per-thunk active-extent scaling on the CPU
1687    // backend. Today `rlx_cpu::thunk::execute_thunks_active` is documented
1688    // as a stub that returns false (rlx-cpu/src/thunk.rs:2100-2110), so
1689    // the runtime falls back to full-extent dispatch — overwrites the
1690    // tail and the tail-preservation assertions fail. They're left here
1691    // (marked `#[ignore]`) as the test-driven contract that the future
1692    // active-extent implementation must satisfy. Drop the `#[ignore]`
1693    // when the per-thunk scaling lands for Copy / ActivationInPlace /
1694    // BinaryFull / Attention.
1695
1696    #[test]
1697    #[ignore = "active-extent execution is a stub on CPU (thunk.rs::execute_thunks_active)"]
1698    fn active_extent_skips_compute_on_cpu_activation() {
1699        // tiny_graph(15) is `Input([15]) → Relu → Output` and lowers to
1700        // a Copy + ActivationInPlace pair on CPU — both are in the safe
1701        // set, so the active-extent path runs scaled.
1702        //
1703        // To prove kernels actually skipped: warm the arena with a prior
1704        // full-extent run whose output is `[1.0; 15]`, then run again
1705        // with a negative-only input and active=5. The first 5 outputs
1706        // get re-copied + re-relu'd to 0; the tail (indices 5..15) stays
1707        // at 1.0 because both Copy and Activation skipped it. A full-
1708        // extent fallback would clip every element to 0.
1709        let graph = tiny_graph(15);
1710        let mut compiled = Session::new(Device::Cpu).compile(graph);
1711
1712        // Warm-up: full extent, all-positive input → output [1.0; 15].
1713        let warm_input: Vec<f32> = vec![1.0; 15];
1714        let warm_outs = compiled.run(&[("x", &warm_input)]);
1715        assert_eq!(warm_outs[0], vec![1.0; 15], "warm-up sanity");
1716
1717        // Active-extent run: all-negative input, hint actual=5 of 15.
1718        // First 5: Copy(-1) + Relu → 0. Tail: kernels skip → stays 1.0.
1719        let neg_input: Vec<f32> = vec![-1.0; 15];
1720        compiled.set_active_extent(Some((5, 15)));
1721        let outs = compiled.run(&[("x", &neg_input)]);
1722        let out = &outs[0];
1723
1724        assert_eq!(out.len(), 15);
1725        assert_eq!(
1726            out[..5],
1727            [0.0; 5],
1728            "first 5 elements processed (relu of -1)"
1729        );
1730        assert_eq!(
1731            out[5..],
1732            [1.0; 10],
1733            "tail untouched — proves Copy + Activation skipped indices 5..15"
1734        );
1735
1736        // Clear the hint and run again with the negative input — full
1737        // extent now processes everything, every element clips to 0.
1738        compiled.set_active_extent(None);
1739        let outs = compiled.run(&[("x", &neg_input)]);
1740        assert_eq!(
1741            outs[0],
1742            vec![0.0; 15],
1743            "full-extent path must clip every negative"
1744        );
1745    }
1746
1747    #[test]
1748    #[ignore = "active-extent execution is a stub on CPU (thunk.rs::execute_thunks_active)"]
1749    fn active_extent_skips_compute_on_binary_full() {
1750        // Input([4]) + Input([4]) → Output. Lowers to a BinaryFull
1751        // thunk with no broadcast (lhs_len == rhs_len == len), which
1752        // is in the safe set.
1753        let mut g = Graph::new("add");
1754        let f = DType::F32;
1755        let a = g.input("a", Shape::new(&[4], f));
1756        let b = g.input("b", Shape::new(&[4], f));
1757        let c = g.add(a, b);
1758        g.set_outputs(vec![c]);
1759        let mut compiled = Session::new(Device::Cpu).compile(g);
1760
1761        // Warm: full extent, output buffer becomes [2.0; 4].
1762        let warm = compiled.run(&[("a", &[1.0f32; 4]), ("b", &[1.0f32; 4])]);
1763        assert_eq!(warm[0], vec![2.0; 4]);
1764
1765        // Active-extent run: actual=2 of upper=4. Process first 2
1766        // elements only; tail (indices 2..4) stays at 2.0 from warm.
1767        compiled.set_active_extent(Some((2, 4)));
1768        let outs = compiled.run(&[("a", &[10.0f32; 4]), ("b", &[10.0f32; 4])]);
1769        let out = &outs[0];
1770        assert_eq!(out[..2], [20.0, 20.0], "first 2 = active sum");
1771        assert_eq!(
1772            out[2..],
1773            [2.0, 2.0],
1774            "tail untouched — proves BinaryFull skipped indices 2..4"
1775        );
1776
1777        // Clear hint → full path overwrites entire output.
1778        compiled.set_active_extent(None);
1779        let outs = compiled.run(&[("a", &[10.0f32; 4]), ("b", &[10.0f32; 4])]);
1780        assert_eq!(outs[0], vec![20.0; 4]);
1781    }
1782
1783    #[test]
1784    #[ignore = "process-wide STATE; runs only in isolation via `cargo test perfetto -- --ignored`"]
1785    fn perfetto_trace_emits_per_thunk_events() {
1786        // PLAN L3: end-to-end Perfetto event capture. Requires the env
1787        // var to be set BEFORE the perfetto module is first touched
1788        // (OnceLock — can't re-init). We set it here unconditionally;
1789        // for tests run in parallel within the same process, the
1790        // earliest test wins. To avoid flake we mark this `#[ignore]`
1791        // and the developer runs it explicitly.
1792        use std::env;
1793        use std::fs;
1794        let path = env::temp_dir().join(format!("rlx-perfetto-e2e-{}.json", std::process::id()));
1795        if path.exists() {
1796            let _ = fs::remove_file(&path);
1797        }
1798        unsafe {
1799            env::set_var("RLX_TRACE_PERFETTO", &path);
1800        }
1801
1802        // Build + run a small CPU graph — Add → Relu (no fusion macros).
1803        let f = DType::F32;
1804        let mut g = Graph::new("perf");
1805        let a = g.input("a", Shape::new(&[4], f));
1806        let b = g.input("b", Shape::new(&[4], f));
1807        let s = g.add(a, b);
1808        let r = g.relu(s);
1809        g.set_outputs(vec![r]);
1810        let mut compiled = Session::new(Device::Cpu).compile(g);
1811        let _ = compiled.run(&[("a", &[1.0; 4]), ("b", &[1.0; 4])]);
1812
1813        // Force the trace file to flush its closing bracket.
1814        crate::perfetto::flush_and_finalize();
1815
1816        let contents = fs::read_to_string(&path).expect("trace file");
1817        // At minimum we should see one of our thunk names.
1818        assert!(
1819            contents.contains("\"binary\"")
1820                || contents.contains("\"activation\"")
1821                || contents.contains("\"elementwise_region\""),
1822            "expected at least one thunk-name event in perfetto trace; got: {contents}"
1823        );
1824        // JSON shape: starts with `[` and (after flush) ends with `]`.
1825        assert!(contents.trim_start().starts_with('['));
1826        let _ = fs::remove_file(&path);
1827    }
1828
1829    #[test]
1830    fn elementwise_region_fused_matches_unfused() {
1831        // PLAN L2: a chain `Add(a, b) → Mul(_, c) → Relu` should fuse
1832        // into one ElementwiseRegion thunk in the CPU backend. Compare
1833        // its output against the value computed by hand to confirm the
1834        // fused execution is numerically identical.
1835        let f = DType::F32;
1836        let mut g = Graph::new("ew_e2e");
1837        let a = g.input("a", Shape::new(&[8], f));
1838        let b = g.input("b", Shape::new(&[8], f));
1839        let c = g.input("c", Shape::new(&[8], f));
1840        let s = Shape::new(&[8], f);
1841        let add = g.add(a, b);
1842        let mul = g.mul(add, c);
1843        let relu = g.relu(mul);
1844        let _ = s;
1845        g.set_outputs(vec![relu]);
1846
1847        let mut compiled = Session::new(Device::Cpu).compile(g);
1848        let av: Vec<f32> = vec![1.0, -2.0, 3.0, -4.0, 0.5, -0.5, 1.5, -1.5];
1849        let bv: Vec<f32> = vec![0.5, 1.0, 2.0, 4.0, 0.5, 0.5, 0.5, 0.5];
1850        let cv: Vec<f32> = vec![1.0, 2.0, 1.0, 1.0, 2.0, 3.0, 0.5, 4.0];
1851        let outs = compiled.run(&[("a", &av), ("b", &bv), ("c", &cv)]);
1852        let out = &outs[0];
1853
1854        let expected: Vec<f32> = (0..8)
1855            .map(|i| {
1856                let v = (av[i] + bv[i]) * cv[i];
1857                v.max(0.0)
1858            })
1859            .collect();
1860        for (i, (got, exp)) in out.iter().zip(&expected).enumerate() {
1861            assert!(
1862                (got - exp).abs() < 1e-6,
1863                "mismatch at {i}: got {got}, expected {exp}"
1864            );
1865        }
1866    }
1867
1868    #[test]
1869    #[ignore = "active-extent execution is a stub on CPU (thunk.rs::execute_thunks_active)"]
1870    fn active_extent_skips_compute_on_attention() {
1871        // Standalone Attention with kernel-synthesized MaskKind::None.
1872        // Q/K/V shape: [batch=1, seq=4, num_heads*head_dim=8].
1873        use rlx_ir::op::MaskKind;
1874        let f = DType::F32;
1875        let mut g = Graph::new("attn");
1876        let q = g.input("q", Shape::new(&[1, 4, 8], f));
1877        let k = g.input("k", Shape::new(&[1, 4, 8], f));
1878        let v = g.input("v", Shape::new(&[1, 4, 8], f));
1879        let out = g.attention_kind(q, k, v, 2, 4, MaskKind::None, Shape::new(&[1, 4, 8], f));
1880        g.set_outputs(vec![out]);
1881        let mut compiled = Session::new(Device::Cpu).compile(g);
1882
1883        // Warm: full extent. Q=K=V uniform → output uniform-ish.
1884        let warm = compiled.run(&[
1885            ("q", &[1.0f32; 32]),
1886            ("k", &[1.0f32; 32]),
1887            ("v", &[1.0f32; 32]),
1888        ]);
1889        let warm_out = warm[0].clone();
1890        assert_eq!(warm_out.len(), 32);
1891
1892        // Active: s_active=2 of s_full=4. Different inputs.
1893        // Tail rows (indices 16..32 = positions 2,3) should be untouched
1894        // — preserved from the warm run. First 16 indices recomputed.
1895        compiled.set_active_extent(Some((2, 4)));
1896        let outs = compiled.run(&[
1897            ("q", &[3.0f32; 32]),
1898            ("k", &[3.0f32; 32]),
1899            ("v", &[3.0f32; 32]),
1900        ]);
1901        let out = &outs[0];
1902        assert_eq!(out.len(), 32);
1903        assert_eq!(
1904            &out[16..],
1905            &warm_out[16..],
1906            "tail (positions 2,3) must be untouched — proves Attention skipped"
1907        );
1908        // Sanity: first 2 positions changed since input value differs (3.0 vs 1.0).
1909        assert_ne!(
1910            &out[..16],
1911            &warm_out[..16],
1912            "first 2 positions should reflect new input"
1913        );
1914    }
1915
1916    #[test]
1917    fn active_extent_falls_back_when_unsupported_thunk_in_schedule() {
1918        // A graph containing any thunk outside `safe_for_active_extent`
1919        // (e.g. Sgemm via a matmul) must fall back to the full-extent
1920        // executor — partial application would feed garbage downstream.
1921        // We can't easily construct such a graph at this layer without
1922        // pulling in matmul builders, but we can verify the trait
1923        // contract via the simpler check: setting an extent hint on a
1924        // matmul-bearing graph still gives correct outputs (full-extent
1925        // fallback path was taken).
1926        //
1927        // Skipped explicit construction here — the safety net is the
1928        // `if !all(safe) return false` guard inside execute_thunks_active
1929        // plus the `if !active_used { execute_thunks(...) }` fallback in
1930        // the CPU executor, both unit-tested via direct safety-predicate
1931        // and the warm-arena test above.
1932    }
1933
1934    #[test]
1935    fn run_padded_uses_active_extent_on_cpu() {
1936        // End-to-end: the cache wires set_active_extent before run.
1937        // Same setup as above but driven through run_padded.
1938        let mut cache = BucketedCompileCache::new(Device::Cpu, vec![1..16]);
1939        let input: Vec<f32> = vec![
1940            1.0, -1.0, 2.0, -2.0, 3.0, // 5 real values
1941            -10.0, -20.0, -30.0, -40.0, -50.0, // padding zeros from pad_rows
1942        ];
1943        // pad_rows zero-pads from len=5 up to upper=15, so the arena
1944        // tail past index 5 is 0.0 going in. After active-extent run,
1945        // tail stays at 0.0 (untouched, but the value happens to match
1946        // what relu would produce). We can't observe skip via output
1947        // here — slice_rows trims to actual_rows anyway.
1948        let (upper, outs) = cache
1949            .run_padded(
1950                5,
1951                5,
1952                |max| tiny_graph(max as usize),
1953                &[("x", &input[..5], 1)],
1954                &[1],
1955            )
1956            .unwrap();
1957        assert_eq!(upper, 15);
1958        assert_eq!(outs[0].len(), 5);
1959        // Active-extent path (CPU honors): outputs match relu of the
1960        // first 5 inputs. Slicing already handled, so user-visible
1961        // result is the same whether or not the kernel skipped tail
1962        // compute. The point of this test is just to confirm the wiring
1963        // path doesn't crash and produces correct outputs end-to-end.
1964        assert_eq!(outs[0], vec![1.0, 0.0, 2.0, 0.0, 3.0]);
1965    }
1966
1967    #[test]
1968    fn run_padded_inner_zero_returns_output_unsliced() {
1969        // Marking output_inners[0] = 0 disables slicing for that output.
1970        // The compiled graph still runs at upper=15, so we expect 15 outputs back.
1971        let mut cache = BucketedCompileCache::new(Device::Cpu, vec![1..16]);
1972        let input: Vec<f32> = vec![1.0, -1.0, 2.0, -2.0, 3.0];
1973
1974        let (upper, outs) = cache
1975            .run_padded(
1976                5,
1977                5,
1978                |max| tiny_graph(max as usize),
1979                &[("x", &input, 1)],
1980                &[0], // don't slice this output
1981            )
1982            .unwrap();
1983
1984        assert_eq!(upper, 15);
1985        assert_eq!(
1986            outs[0].len(),
1987            15,
1988            "unsliced output preserves full upper extent"
1989        );
1990        // First 5 = relu of input, tail 10 = relu(0) = 0.
1991        assert_eq!(&outs[0][..5], &[1.0, 0.0, 2.0, 0.0, 3.0]);
1992        assert!(outs[0][5..].iter().all(|&v| v == 0.0));
1993    }
1994
1995    #[test]
1996    fn dynamic_dim_cache_specializes_per_key() {
1997        use rlx_ir::DType;
1998        use rlx_ir::Shape;
1999        use rlx_ir::hir::HirModule;
2000        use rlx_ir::sym;
2001
2002        let mut cache = DynamicDimCompileCache::new(Device::Cpu, 4);
2003        let opts = crate::CompileOptions::new();
2004        {
2005            let _short = cache
2006                .get_or_specialize(
2007                    8,
2008                    &rlx_ir::DimBinding::batch_seq(1, 8),
2009                    || {
2010                        let mut hir = HirModule::new("dyn_cache");
2011                        let x = hir.input_batch_seq("x", sym::BATCH, sym::SEQ, 4, DType::F32);
2012                        let w = hir.param("w", Shape::new(&[4, 2], DType::F32));
2013                        let y = hir.linear(
2014                            x,
2015                            w,
2016                            None,
2017                            None,
2018                            Shape::batch_seq(sym::BATCH, sym::SEQ, 2, DType::F32),
2019                        );
2020                        hir.set_outputs(vec![y]);
2021                        hir
2022                    },
2023                    &opts,
2024                )
2025                .expect("specialize short");
2026        }
2027        assert!(cache.has_template());
2028        assert_eq!(cache.len(), 1);
2029        cache
2030            .get_or_specialize(
2031                128,
2032                &rlx_ir::DimBinding::batch_seq(1, 128),
2033                || panic!("HIR builder must not run twice"),
2034                &opts,
2035            )
2036            .expect("specialize long");
2037        assert_eq!(cache.len(), 2);
2038    }
2039}