Skip to main content

ferrox_models/decoder/
kv_window.rs

1//! Whether a windowed layer's host KV cache may drop rows behind its
2//! window, and which window it drops behind.
3//!
4//! [`ferrox_core::kv_swa::KvWindow`] is the arithmetic -- how many rows
5//! survive N positions. This module is the *decision*: which layers get
6//! one at all, and whether this particular run is one where dropping a
7//! row is safe. Those are different questions with different owners, and
8//! keeping them apart is what stops the budget from pricing a saving the
9//! store did not take (#33) or the store from taking one the budget did
10//! not price.
11//!
12//! # Off by default
13//!
14//! `FERROX_KV_WINDOW=1` turns it on, and nothing else does. The switch
15//! exists in the shape `FERROX_CPU_POOL` established: one env var, so
16//! the before and the after are one word apart and reverting costs
17//! nothing.
18//!
19//! # What the switch refuses to do
20//!
21//! Eviction is the contiguous host store on the CPU path, and only that
22//! (#61 steps 3 and 4 are the GPU stores and the paged one). Two things
23//! it therefore turns itself off for:
24//!
25//! - **Metal attention.** `Decoder`'s Metal arms compare
26//!   `MetalKvBuffers::seq_len` against the host cache's `rows()` and its
27//!   `positions()` in five places, and take the two as interchangeable.
28//!   They are, until a host cache evicts. So a run with
29//!   `FERROX_METAL_ATTN` on does not evict, and says so here rather than
30//!   in five separate conditions that would drift apart.
31//! - **Full-attention layers**, obviously, and that is the interesting
32//!   half of the saving rather than a caveat: an alternating-SWA model
33//!   keeps every position in its dense layers no matter what this
34//!   switch says. The Gemma-3 figure in #61 is a per-layer number, not
35//!   a whole-model one.
36//!
37//! CUDA needs no exclusion: the resident-KV decode hook in
38//! `Decoder::gqa_attention` is reachable only from the `window == None`
39//! arm of `push_and_attend_row`, so it never sees a cache that evicts.
40
41use ferrox_core::cache::KvCache;
42use ferrox_core::kv_swa::KvWindow;
43
44use super::Decoder;
45
46/// Whether this run may evict, decided once at load time.
47///
48/// A `Copy` value on the `Decoder` rather than a cached global, so a
49/// test can build a decoder that evicts and one that does not in the
50/// same process and compare their tokens. A global read once per
51/// process would need a subprocess per arm to say the same thing.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct KvWindowPolicy {
54    enabled: bool,
55}
56
57/// The one spelling of the switch.
58pub const KV_WINDOW_ENV: &str = "FERROX_KV_WINDOW";
59
60impl KvWindowPolicy {
61    /// Never evicts. What every cache in this engine did before #61, and
62    /// what a run gets unless [`KV_WINDOW_ENV`] says otherwise.
63    pub const fn off() -> Self {
64        KvWindowPolicy { enabled: false }
65    }
66
67    /// Always evicts, where the layer has a window. For tests and for a
68    /// caller that has already made the decision itself.
69    pub const fn on() -> Self {
70        KvWindowPolicy { enabled: true }
71    }
72
73    /// Reads [`KV_WINDOW_ENV`], then subtracts the runs that must not
74    /// evict. See the module doc for which and why.
75    pub fn from_env() -> Self {
76        let on = matches!(
77            std::env::var(KV_WINDOW_ENV).ok().as_deref(),
78            Some("1") | Some("on") | Some("true") | Some("yes")
79        );
80        #[cfg(feature = "metal")]
81        let on = on && !ferrox_metal::attn::metal_attn_enabled();
82        KvWindowPolicy { enabled: on }
83    }
84
85    pub fn enabled(&self) -> bool {
86        self.enabled
87    }
88
89    /// The window a layer's cache evicts behind, given the window that
90    /// layer's attention actually reads.
91    ///
92    /// **These two must be the same number.** The kernel reads the last
93    /// `window` rows; keeping fewer answers out of a truncated history,
94    /// and this is the only place that says so, so there is nowhere for
95    /// a second opinion to live.
96    pub fn window_for(&self, attention_window: Option<usize>) -> Option<KvWindow> {
97        if !self.enabled {
98            return None;
99        }
100        KvWindow::with_default_slack(attention_window?)
101    }
102
103    /// The window layer `layer_idx` of `config` evicts behind.
104    ///
105    /// The ONE expression that answers it, for the decoder
106    /// (`Decoder::kv_window_for_layer`) and for the budget
107    /// (`crate::kv_budget::KvResidency::from_config`) alike. Those two
108    /// have to agree about which layers keep how much or the budget is
109    /// pricing a cache that does not exist, which is #33.
110    pub fn layer_window(
111        &self,
112        config: &crate::config::ModelConfig,
113        layer_idx: usize,
114    ) -> Option<KvWindow> {
115        self.window_for(config.layer_sliding_window(layer_idx))
116    }
117}
118
119impl Default for KvWindowPolicy {
120    fn default() -> Self {
121        Self::off()
122    }
123}
124
125impl Decoder {
126    /// The window layer `layer_idx`'s host cache may evict behind, or
127    /// `None` when it must keep everything.
128    ///
129    /// One predicate, shared by the decode path, the prefill path and
130    /// `kv_budget`'s residency, because four spellings of one
131    /// eligibility check is exactly how the GPU-router gate drifted four
132    /// ways.
133    pub fn kv_window_for_layer(&self, layer_idx: usize) -> Option<KvWindow> {
134        self.kv_window.layer_window(&self.config, layer_idx)
135    }
136
137    /// Arms `cache` for layer `layer_idx` if this run evicts, then drops
138    /// whatever has fallen behind the window. A no-op otherwise.
139    ///
140    /// Called after the layer's attention has been computed, never
141    /// before: the rows this drops are rows the kernel has finished
142    /// with, and `forward_batch` reads a whole prefill batch back
143    /// against an offset captured before its pushes.
144    pub(crate) fn evict_layer_kv(&self, layer_idx: usize, cache: &mut KvCache) {
145        let Some(window) = self.kv_window_for_layer(layer_idx) else {
146            return;
147        };
148        if cache.window() != Some(window) {
149            cache.arm_window(window);
150        }
151        cache.evict_behind_window();
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    /// The switch is off unless it is on, and "off" means no window for
160    /// any layer however windowed the model is.
161    #[test]
162    fn the_default_policy_evicts_nothing() {
163        let off = KvWindowPolicy::off();
164        assert!(!off.enabled());
165        assert_eq!(off.window_for(Some(1024)), None);
166        assert_eq!(KvWindowPolicy::default(), off);
167    }
168
169    /// The eviction window IS the attention window. A policy that
170    /// narrowed it to save more memory would be answering out of a
171    /// shorter history than the model asks for, which is a different
172    /// model.
173    #[test]
174    fn the_eviction_window_equals_the_window_attention_reads() {
175        let on = KvWindowPolicy::on();
176        let w = on.window_for(Some(1024)).expect("a windowed layer");
177        assert_eq!(w.window(), 1024);
178        // Slack is headroom above the window, never below it.
179        assert!(w.max_rows() >= 1024);
180    }
181
182    /// A full-attention layer has no window to evict behind, switch on
183    /// or off. This is the half of an alternating-SWA model that keeps
184    /// costing what it always did.
185    #[test]
186    fn a_full_attention_layer_never_evicts() {
187        assert_eq!(KvWindowPolicy::on().window_for(None), None);
188        assert_eq!(KvWindowPolicy::off().window_for(None), None);
189    }
190
191    /// A zero window is not a window; it must not become one here by
192    /// arithmetic.
193    #[test]
194    fn a_zero_window_does_not_become_an_evicting_window() {
195        assert_eq!(KvWindowPolicy::on().window_for(Some(0)), None);
196    }
197}