metrale_storage/
predictor.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: GPU side of the high-speed-swap block predictor. Loads the
4//! `q_lowrank_project`, `kv_lowrank_project` and `predictor_score` PTX from build.rs
5//! and owns the projection `P` and the per-token low-rank keys `A_g`.
6//!
7//! Owner: storage, high-speed swap.
8//! Invariants:
9//! - `P` is written once, in the constructor, from `projection_seed`.
10//!
11//! `project_q` computes Q @ P; `project_kv_block` computes K @ P for every token of
12//! one block; `score_blocks` gives each block the max, over query heads and tokens,
13//! of the projected dot product. `HighSpeedSwap` uses the scores only to rank
14//! eviction victims (`EvictionPolicy::record_score`); attention reads every block.
15
16use anyhow::{Context, Result, bail};
17use half::bf16;
18use std::ffi::c_void;
19
20use crate::cuda_min::{
21    CudaCtx, CudaModule, DeviceBuffer, copy_d_to_h_async, copy_h_to_d_async, launch_kernel,
22    stream_sync,
23};
24use crate::projection::{PredictorShape, build_projection};
25
26include!(concat!(env!("OUT_DIR"), "/storage_ptx.rs"));
27
28#[derive(Clone, Copy, Debug)]
29pub struct PredictorDims {
30    pub num_layers: usize,
31    pub num_q_heads: usize,
32    pub num_kv_heads: usize,
33    pub head_dim: usize,
34    pub r: usize,
35    pub block_size: usize,
36    pub max_blocks: usize,
37}
38
39impl PredictorDims {
40    pub fn validate(&self) -> Result<()> {
41        if !self.num_q_heads.is_multiple_of(self.num_kv_heads) {
42            bail!(
43                "num_q_heads ({}) must divide num_kv_heads ({})",
44                self.num_q_heads,
45                self.num_kv_heads
46            );
47        }
48        Ok(())
49    }
50    pub fn gqa_ratio(&self) -> i32 {
51        (self.num_q_heads / self.num_kv_heads) as i32
52    }
53    pub fn a_g_bytes(&self) -> usize {
54        // 2026-09-25: Per-token layout:
55        // [num_layers, max_blocks, num_kv_heads, block_size, r], BF16.
56        self.num_layers * self.max_blocks * self.num_kv_heads * self.block_size * self.r * 2
57    }
58    pub fn per_layer_block_floats(&self) -> usize {
59        self.num_kv_heads * self.block_size * self.r
60    }
61    pub fn p_bytes(&self) -> usize {
62        self.head_dim * self.r * 2
63    }
64}
65
66pub struct Predictor {
67    dims: PredictorDims,
68    _modules: Vec<CudaModule>,
69    f_q_proj: u64,
70    f_kv_proj: u64,
71    f_score: u64,
72    // 2026-09-25: `[head_dim, r]` BF16.
73    p_dev: DeviceBuffer,
74    // 2026-09-25: `[num_layers, max_blocks, num_kv_heads, block_size, r]` BF16.
75    a_g_dev: DeviceBuffer,
76}
77
78impl Predictor {
79    pub fn new(ctx: &CudaCtx, dims: PredictorDims, projection_seed: u64) -> Result<Self> {
80        Self::new_on_stream(ctx.stream, dims, projection_seed)
81    }
82
83    pub fn new_on_stream(stream: u64, dims: PredictorDims, projection_seed: u64) -> Result<Self> {
84        dims.validate()?;
85        // 2026-09-25: Only the predictor modules; `TiledAttention::new` loads the
86        // attention ones.
87        let mut modules: Vec<CudaModule> = Vec::new();
88        let mut f_q_proj = 0u64;
89        let mut f_kv_proj = 0u64;
90        let mut f_score = 0u64;
91        for entry in STORAGE_PTX.iter() {
92            match entry.name {
93                "q_lowrank_project" | "kv_lowrank_project" | "predictor_score" => {
94                    let m = CudaModule::from_ptx(entry.ptx)
95                        .with_context(|| format!("load PTX module {}", entry.name))?;
96                    match entry.name {
97                        "q_lowrank_project" => f_q_proj = m.function("q_lowrank_project")?,
98                        "kv_lowrank_project" => f_kv_proj = m.function("kv_lowrank_project")?,
99                        "predictor_score" => f_score = m.function("predictor_score")?,
100                        _ => unreachable!(),
101                    }
102                    modules.push(m);
103                }
104                _ => {}
105            }
106        }
107        if f_q_proj == 0 || f_kv_proj == 0 || f_score == 0 {
108            bail!("missing predictor kernel function — PTX list incomplete");
109        }
110        let shape = PredictorShape::new(dims.head_dim, dims.r);
111        let p_host = build_projection(shape, projection_seed);
112        let p_dev = DeviceBuffer::new(dims.p_bytes())?;
113        copy_h_to_d_async(
114            p_dev.ptr,
115            p_host.as_ptr() as *const c_void,
116            dims.p_bytes(),
117            stream,
118        )?;
119        // 2026-09-25: Refuse before allocating when A_g does not fit, with an error
120        // that names the settings that shrink it; a failed `cuMemAlloc_v2` names
121        // none.
122        let a_g_need = dims.a_g_bytes();
123        let (free_hbm, _total_hbm) = crate::cuda_min::mem_info()?;
124        // 2026-09-25: 5% of free memory is left for the scratch pool, tiled
125        // attention and the smaller buffers allocated after this.
126        let a_g_budget = free_hbm.saturating_mul(95) / 100;
127        if a_g_need > a_g_budget {
128            bail!(
129                "HSS predictor A_g would need {:.2} GB but only {:.2} GB of HBM is free \
130                 (5% margin reserved for scratch + tiled-attention).\n\
131                 Tune one of:\n  \
132                 - --high-speed-swap-rank: current {} ; try {} (halves A_g)\n  \
133                 - --max-seq-len: max_blocks={} → currently dominates A_g; halve --max-seq-len to halve A_g\n  \
134                 - --kv-cache-dtype nvfp4: halves the KV pool, freeing room for A_g\n  \
135                 - --gpu-memory-utilization: lower so weight-side allocations leave more HBM\n\
136                 A_g sizing = num_layers ({}) × max_blocks ({}) × num_kv_heads ({}) × block_size ({}) × r ({}) × 2 bytes.",
137                a_g_need as f64 / (1u64 << 30) as f64,
138                a_g_budget as f64 / (1u64 << 30) as f64,
139                dims.r,
140                dims.r / 2,
141                dims.max_blocks,
142                dims.num_layers,
143                dims.max_blocks,
144                dims.num_kv_heads,
145                dims.block_size,
146                dims.r,
147            );
148        }
149        // 2026-09-25: A_g is not zeroed. `score_blocks` also reads slots that
150        // `project_kv_block` never wrote, such as blocks offloaded through
151        // `offload_block_no_predict_on_stream`.
152        let a_g_dev = DeviceBuffer::new(a_g_need)?;
153        stream_sync(stream)?;
154        Ok(Self {
155            dims,
156            _modules: modules,
157            f_q_proj,
158            f_kv_proj,
159            f_score,
160            p_dev,
161            a_g_dev,
162        })
163    }
164
165    /// 2026-09-25: `q_proj = q @ P`. `q` is a device pointer to
166    /// `[num_q_heads, head_dim]` BF16, `q_proj` to the `[num_q_heads, r]` BF16 output.
167    pub fn project_q(&self, ctx: &CudaCtx, q: u64, q_proj: u64) -> Result<()> {
168        self.project_q_on_stream(ctx.stream, q, q_proj)
169    }
170
171    /// 2026-09-25: [`Self::project_q`] on an explicit stream.
172    pub fn project_q_on_stream(&self, stream: u64, q: u64, q_proj: u64) -> Result<()> {
173        let mut q_v = q;
174        let mut p_v = self.p_dev.ptr;
175        let mut o_v = q_proj;
176        let mut nq = self.dims.num_q_heads as i32;
177        let mut hd = self.dims.head_dim as i32;
178        let mut r = self.dims.r as i32;
179        let mut params = [
180            &mut q_v as *mut _ as *mut c_void,
181            &mut p_v as *mut _ as *mut c_void,
182            &mut o_v as *mut _ as *mut c_void,
183            &mut nq as *mut _ as *mut c_void,
184            &mut hd as *mut _ as *mut c_void,
185            &mut r as *mut _ as *mut c_void,
186        ];
187        launch_kernel(
188            self.f_q_proj,
189            (self.dims.num_q_heads as u32, 1, 1),
190            (self.dims.r as u32, 1, 1),
191            0,
192            stream,
193            &mut params,
194        )
195    }
196
197    /// 2026-09-25: Write the low-rank keys of block `block_id` at `layer` into A_g.
198    /// `k_block` is a device pointer to `[block_size, num_kv_heads, head_dim]` BF16.
199    /// An out-of-range layer or block is an error.
200    pub fn project_kv_block(
201        &self,
202        ctx: &CudaCtx,
203        layer: usize,
204        block_id: usize,
205        k_block: u64,
206    ) -> Result<()> {
207        self.project_kv_block_on_stream(ctx.stream, layer, block_id, k_block)
208    }
209
210    pub fn project_kv_block_on_stream(
211        &self,
212        stream: u64,
213        layer: usize,
214        block_id: usize,
215        k_block: u64,
216    ) -> Result<()> {
217        if layer >= self.dims.num_layers || block_id >= self.dims.max_blocks {
218            bail!("project_kv_block out of range: layer {layer}, block {block_id}");
219        }
220        let slot_floats = self.dims.per_layer_block_floats();
221        let k_lr_slot = self.a_g_dev.ptr
222            + (((layer * self.dims.max_blocks + block_id) * slot_floats) * 2) as u64;
223        let mut k_v = k_block;
224        let mut p_v = self.p_dev.ptr;
225        let mut o_v = k_lr_slot;
226        let mut bs = self.dims.block_size as i32;
227        let mut nk = self.dims.num_kv_heads as i32;
228        let mut hd = self.dims.head_dim as i32;
229        let mut r = self.dims.r as i32;
230        let mut params = [
231            &mut k_v as *mut _ as *mut c_void,
232            &mut p_v as *mut _ as *mut c_void,
233            &mut o_v as *mut _ as *mut c_void,
234            &mut bs as *mut _ as *mut c_void,
235            &mut nk as *mut _ as *mut c_void,
236            &mut hd as *mut _ as *mut c_void,
237            &mut r as *mut _ as *mut c_void,
238        ];
239        launch_kernel(
240            self.f_kv_proj,
241            (
242                self.dims.num_kv_heads as u32,
243                self.dims.block_size as u32,
244                1,
245            ),
246            (self.dims.r as u32, 1, 1),
247            0,
248            stream,
249            &mut params,
250        )
251    }
252
253    /// 2026-09-25: Score the first `num_active_blocks` blocks at `k_lr_seq`, a device
254    /// pointer to `[num_active_blocks, num_kv_heads, block_size, r]` BF16. `q_proj` is
255    /// `[num_q_heads, r]` BF16; `scores_out` receives `[num_active_blocks]` f32.
256    pub fn score_blocks(
257        &self,
258        ctx: &CudaCtx,
259        q_proj: u64,
260        k_lr_seq: u64,
261        scores_out: u64,
262        num_active_blocks: usize,
263    ) -> Result<()> {
264        self.score_blocks_on_stream(ctx.stream, q_proj, k_lr_seq, scores_out, num_active_blocks)
265    }
266
267    pub fn score_blocks_on_stream(
268        &self,
269        stream: u64,
270        q_proj: u64,
271        k_lr_seq: u64,
272        scores_out: u64,
273        num_active_blocks: usize,
274    ) -> Result<()> {
275        let mut q_v = q_proj;
276        let mut a_v = k_lr_seq;
277        let mut s_v = scores_out;
278        let mut nq = self.dims.num_q_heads as i32;
279        let mut nk = self.dims.num_kv_heads as i32;
280        let mut bs = self.dims.block_size as i32;
281        let mut r = self.dims.r as i32;
282        let mut gqa = self.dims.gqa_ratio();
283        let mut params = [
284            &mut q_v as *mut _ as *mut c_void,
285            &mut a_v as *mut _ as *mut c_void,
286            &mut s_v as *mut _ as *mut c_void,
287            &mut nq as *mut _ as *mut c_void,
288            &mut nk as *mut _ as *mut c_void,
289            &mut bs as *mut _ as *mut c_void,
290            &mut r as *mut _ as *mut c_void,
291            &mut gqa as *mut _ as *mut c_void,
292        ];
293        launch_kernel(
294            self.f_score,
295            (num_active_blocks as u32, 1, 1),
296            (128, 1, 1),
297            0,
298            stream,
299            &mut params,
300        )
301    }
302
303    pub fn dims(&self) -> PredictorDims {
304        self.dims
305    }
306    pub fn a_g_dev_ptr(&self) -> u64 {
307        self.a_g_dev.ptr
308    }
309}
310
311/// 2026-09-25: Copy the A_g slot of `(layer, block)` to the host, as BF16
312/// `[num_kv_heads, block_size, r]`, and synchronise the stream.
313pub fn read_k_lr_slot(
314    ctx: &CudaCtx,
315    pred: &Predictor,
316    layer: usize,
317    block: usize,
318) -> Result<Vec<bf16>> {
319    let dims = pred.dims();
320    let n = dims.per_layer_block_floats();
321    let slot = pred.a_g_dev.ptr + (((layer * dims.max_blocks + block) * n) * 2) as u64;
322    let mut host = vec![bf16::from_f32(0.0); n];
323    copy_d_to_h_async(host.as_mut_ptr() as *mut c_void, slot, n * 2, ctx.stream)?;
324    stream_sync(ctx.stream)?;
325    Ok(host)
326}