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