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}