Skip to main content

rlx_runtime/
compile_cache.rs

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