metrale_storage/ngram_cache.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: NVMe-backed row cache for n-gram embedding tables. Rows fault from the
4//! checkpoint file into a pinned arena, and the gather kernel reads the arena by slot.
5//!
6//! Owner: storage, n-gram row cache.
7//! Invariants:
8//! - `map[id] == s` exactly when `slot_row[s] == id`, on every path out of `resolve`.
9//! - A slot pinned since the last `end_batch` is never chosen as an eviction victim.
10//!
11//! The n-gram ids are computed on the host, so `resolve` maps each id to its arena slot
12//! and the caller hands the gather kernel slot indices in place of ids;
13//! `table_dev_va` is the table it gathers from. The arena's device address equals its
14//! host address (`ExpertArena::new` refuses a host where it does not), so a miss is
15//! read from NVMe straight into its slot, with no host-to-device copy. Eviction is
16//! CLOCK (second chance) over the unpinned slots.
17//!
18//! On unix the files are opened `O_DIRECT`, which needs 4 KiB-aligned transfers: a
19//! miss reads the one or two blocks covering its row into an aligned bounce buffer
20//! and copies the row out. The cache holds single rows, not blocks.
21
22use std::collections::HashMap;
23use std::fs::File;
24use std::path::Path;
25
26use anyhow::{Context, Result, bail};
27
28use crate::expert_arena::ExpertArena;
29
30/// 2026-09-25: O_DIRECT transfer size, and the stride `ExpertArena::new` requires a
31/// multiple of.
32pub(crate) const BLOCK: usize = 4096;
33
34/// 2026-09-25: One table's backing files and its resident row cache.
35pub struct NgramRowCache {
36 /// 2026-09-25: Pinned, GPU-addressable `[slots, row_stride]` rows.
37 arena: ExpertArena,
38 /// 2026-09-25: Backing file (shard 0's, for a segmented table). Unsegmented, row
39 /// `i` is at `base_offset + i * row_stride`, so a tensor is read in place from a
40 /// safetensors shard. The offset need not be block-aligned: a row that crosses a
41 /// block boundary is read as two blocks (`ngram_cache_fault::nblocks_for`).
42 file: File,
43 /// 2026-09-25: The other files of a segmented table whose shards span several
44 /// files, in first-seen order. `Segments::shard_file` value `n > 0` is
45 /// `extra_files[n - 1]`.
46 extra_files: Vec<File>,
47 base_offset: u64,
48 /// 2026-09-25: Set by `open_segmented`, for a table stored as shard tensors that
49 /// are not consecutive in the file; each shard has its own base. `None`:
50 /// `base_offset` locates every row.
51 segments: Option<Segments>,
52 /// 2026-09-25: FP8 scales by slot; `None` for a BF16 table.
53 scales: Option<ScaleCache>,
54 row_stride: usize,
55 slots: usize,
56 rows_total: u64,
57 map: HashMap<u64, u32>,
58 /// 2026-09-25: Slot to resident row id; `u64::MAX` marks an empty slot.
59 slot_row: Vec<u64>,
60 refbit: Vec<bool>,
61 /// 2026-09-25: Slots pinned for the batch in flight; `victim` skips them.
62 pinned: Vec<bool>,
63 hand: usize,
64 bounce: AlignedBlock,
65 pub hits: u64,
66 pub misses: u64,
67 pub evictions: u64,
68}
69
70/// 2026-09-25: A table split into equal-sized shards at arbitrary file offsets.
71struct Segments {
72 /// 2026-09-25: Byte offset of each shard's first row, indexed by shard.
73 bases: Vec<u64>,
74 /// 2026-09-25: Backing file of each shard: 0 is `file`, `n > 0` is
75 /// `extra_files[n - 1]`.
76 shard_file: Vec<usize>,
77 /// 2026-09-25: Rows in every shard; `row_byte` finds a row's shard by division.
78 rows_per: u64,
79}
80
81/// 2026-09-25: f32 scales of an FP8 table, in a device-visible `[slots]` array
82/// indexed by slot, parallel to the row arena.
83struct ScaleCache {
84 arena: ExpertArena,
85 /// 2026-09-25: Per-row scale file. `None` after `set_constant_scale`, which fills
86 /// the arena once; nothing is faulted for it.
87 file: Option<File>,
88}
89
90/// 2026-09-25: A 4 KiB-aligned host buffer for O_DIRECT reads.
91pub(crate) struct AlignedBlock {
92 buf: Vec<u8>,
93 off: usize,
94}
95
96impl AlignedBlock {
97 /// 2026-09-25: Room for two aligned blocks: a row that is not block-aligned can
98 /// cross one boundary, and `open_at` refuses `row_stride > BLOCK`.
99 pub(crate) fn new() -> Self {
100 // 2026-09-25: Three blocks hold an aligned two-block window at any address.
101 let buf = vec![0u8; BLOCK * 3];
102 let addr = buf.as_ptr() as usize;
103 let off = (BLOCK - (addr % BLOCK)) % BLOCK;
104 Self { buf, off }
105 }
106 pub(crate) fn blocks(&mut self, n: usize) -> &mut [u8] {
107 &mut self.buf[self.off..self.off + n * BLOCK]
108 }
109}
110
111impl NgramRowCache {
112 /// 2026-09-25: Open `path` as the backing store for a table of `rows_total` rows
113 /// of `row_stride` bytes, caching `slots` of them in pinned GPU-addressable
114 /// memory. `scale_path` supplies the per-row f32 scales of an FP8 table.
115 pub fn open(
116 path: &Path,
117 scale_path: Option<&Path>,
118 rows_total: u64,
119 row_stride: usize,
120 slots: usize,
121 ) -> Result<Self> {
122 Self::open_at(path, 0, scale_path, rows_total, row_stride, slots)
123 }
124
125 /// 2026-09-25: As [`Self::open`], with the table starting at `base_offset` in the
126 /// file. Zero geometry or `row_stride > BLOCK` is an error.
127 #[allow(clippy::too_many_arguments)]
128 pub fn open_at(
129 path: &Path,
130 base_offset: u64,
131 scale_path: Option<&Path>,
132 rows_total: u64,
133 row_stride: usize,
134 slots: usize,
135 ) -> Result<Self> {
136 if row_stride == 0 || slots == 0 {
137 bail!("NgramRowCache: zero geometry (row_stride={row_stride}, slots={slots})");
138 }
139 if row_stride > BLOCK {
140 bail!(
141 "NgramRowCache: row_stride {row_stride} exceeds the {BLOCK}-byte \
142 O_DIRECT block; a row would span more than the two blocks the \
143 seam-handling fetch reads"
144 );
145 }
146 // 2026-09-25: `slots * row_stride` bytes, rounded up to whole blocks for
147 // `ExpertArena`.
148 let bytes = slots * row_stride;
149 let blocks = bytes.div_ceil(BLOCK);
150 let arena =
151 ExpertArena::new(1, blocks as u32, BLOCK).context("NgramRowCache: pinned arena")?;
152 let file = open_direct(path)?;
153 let scales = match scale_path {
154 Some(sp) => {
155 let sbytes = slots * 4;
156 let sblocks = sbytes.div_ceil(BLOCK);
157 Some(ScaleCache {
158 arena: ExpertArena::new(1, sblocks as u32, BLOCK)
159 .context("NgramRowCache: scale arena")?,
160 file: Some(open_direct(sp)?),
161 })
162 }
163 None => None,
164 };
165 Ok(Self {
166 arena,
167 file,
168 base_offset,
169 segments: None,
170 extra_files: Vec::new(),
171 scales,
172 row_stride,
173 slots,
174 rows_total,
175 map: HashMap::with_capacity(slots * 2),
176 slot_row: vec![u64::MAX; slots],
177 refbit: vec![false; slots],
178 pinned: vec![false; slots],
179 hand: 0,
180 bounce: AlignedBlock::new(),
181 hits: 0,
182 misses: 0,
183 evictions: 0,
184 })
185 }
186
187 /// 2026-09-25: As [`Self::open_at`], for a table split into shards at arbitrary
188 /// offsets, possibly in several files. `shards[i]` is shard `i`'s file and the
189 /// offset of its first row; every shard holds `rows_per_shard` rows.
190 #[allow(clippy::too_many_arguments)]
191 pub fn open_segmented(
192 shards: &[(std::path::PathBuf, u64)],
193 rows_per_shard: u64,
194 scale_path: Option<&Path>,
195 row_stride: usize,
196 slots: usize,
197 ) -> Result<Self> {
198 if shards.is_empty() || rows_per_shard == 0 {
199 bail!(
200 "NgramRowCache: segmented table needs shards and rows \
201 (shards={}, rows_per_shard={rows_per_shard})",
202 shards.len()
203 );
204 }
205 // 2026-09-25: One `File` per distinct path, in first-seen order. Shard 0's
206 // file is the cache's own `file`; the rest are `extra_files`.
207 let mut order: Vec<&Path> = Vec::new();
208 let mut shard_file = Vec::with_capacity(shards.len());
209 for (p, _) in shards {
210 let idx = order
211 .iter()
212 .position(|q| *q == p.as_path())
213 .unwrap_or_else(|| {
214 order.push(p.as_path());
215 order.len() - 1
216 });
217 shard_file.push(idx);
218 }
219 let bases: Vec<u64> = shards.iter().map(|(_, o)| *o).collect();
220 let rows_total = bases.len() as u64 * rows_per_shard;
221 let mut c = Self::open_at(order[0], 0, scale_path, rows_total, row_stride, slots)?;
222 for p in &order[1..] {
223 c.extra_files.push(open_direct(p)?);
224 }
225 c.segments = Some(Segments {
226 bases,
227 shard_file,
228 rows_per: rows_per_shard,
229 });
230 Ok(c)
231 }
232
233 /// 2026-09-25: Device VA of the row arena, the table the gather kernels index by
234 /// slot.
235 pub fn table_dev_va(&self) -> Result<u64> {
236 self.arena.slot_dev_va(0, 0)
237 }
238
239 /// 2026-09-25: Give every slot the same scale, for an FP8 table with one scale
240 /// for all rows. The scale arena is filled here and never faulted.
241 ///
242 /// # Errors
243 /// If the scale arena cannot be allocated.
244 pub fn set_constant_scale(&mut self, scale: f32) -> Result<()> {
245 let sbytes = self.slots * 4;
246 let sblocks = sbytes.div_ceil(BLOCK);
247 let arena = ExpertArena::new(1, sblocks as u32, BLOCK)
248 .context("NgramRowCache: constant scale arena")?;
249 let p = arena.slot_host_ptr(0, 0)?.cast::<f32>();
250 for i in 0..self.slots {
251 // 2026-09-25: SAFETY: the arena is at least `slots * 4` bytes and was just
252 // allocated here, so nothing else holds a reference into it.
253 unsafe { p.add(i).write(scale) };
254 }
255 self.scales = Some(ScaleCache { arena, file: None });
256 Ok(())
257 }
258
259 /// 2026-09-25: Device VA of the `[slots]` f32 scale array; `None` for a BF16
260 /// table.
261 pub fn scale_dev_va(&self) -> Result<Option<u64>> {
262 match &self.scales {
263 Some(s) => Ok(Some(s.arena.slot_dev_va(0, 0)?)),
264 None => Ok(None),
265 }
266 }
267
268 pub fn stats(&self) -> (u64, u64, u64) {
269 (self.hits, self.misses, self.evictions)
270 }
271
272 /// 2026-09-25: Resolve `row_ids` to slot indices, faulting misses in from NVMe.
273 ///
274 /// Every returned slot stays pinned until [`Self::end_batch`], so a later resolve
275 /// in the same batch cannot evict a row the gather has yet to read. An id at or
276 /// past the table's row count, every slot pinned, or a failed read is an error.
277 pub fn resolve(&mut self, row_ids: &[u64], out_slots: &mut Vec<u32>) -> Result<()> {
278 out_slots.clear();
279 out_slots.reserve(row_ids.len());
280 // 2026-09-25: First, with no I/O: pin hits and give every miss a victim slot. A
281 // repeated missing id hits the map on its second occurrence, so each
282 // distinct row faults once.
283 let mut jobs: Vec<crate::ngram_cache_fault::FaultJob> = Vec::new();
284 for &id in row_ids {
285 if id >= self.rows_total {
286 bail!(
287 "NgramRowCache: row id {id} >= table rows {} (hash/table mismatch)",
288 self.rows_total
289 );
290 }
291 let slot = match self.map.get(&id) {
292 Some(&s) => {
293 self.hits += 1;
294 self.refbit[s as usize] = true;
295 self.pinned[s as usize] = true;
296 s
297 }
298 None => {
299 self.misses += 1;
300 let s = self.victim()?;
301 self.map.insert(id, s);
302 self.slot_row[s as usize] = id;
303 self.refbit[s as usize] = true;
304 self.pinned[s as usize] = true;
305 jobs.push(self.fault_job(id, s)?);
306 s
307 }
308 };
309 out_slots.push(slot);
310 }
311 if !jobs.is_empty() {
312 let files: Vec<&File> = std::iter::once(&self.file)
313 .chain(self.extra_files.iter())
314 .collect();
315 let r = crate::ngram_cache_fault::fault_all(
316 &jobs,
317 &files,
318 self.scales.as_ref().and_then(|sc| sc.file.as_ref()),
319 self.row_stride,
320 &mut self.bounce,
321 );
322 if let Err(e) = r {
323 // 2026-09-25: Unmap and unpin every slot this batch faulted: any of
324 // them may hold a partial row.
325 for j in &jobs {
326 self.map.remove(&j.row_id);
327 self.slot_row[j.slot as usize] = u64::MAX;
328 self.pinned[j.slot as usize] = false;
329 self.refbit[j.slot as usize] = false;
330 }
331 return Err(e);
332 }
333 }
334 Ok(())
335 }
336
337 /// 2026-09-25: Resolve one miss to file offsets and destination addresses for
338 /// [`crate::ngram_cache_fault::fault_all`].
339 fn fault_job(&self, id: u64, slot: u32) -> Result<crate::ngram_cache_fault::FaultJob> {
340 let (file_idx, byte) = self.row_byte(id);
341 let block_off = byte - (byte % BLOCK as u64);
342 let within = (byte - block_off) as usize;
343 let nblocks = crate::ngram_cache_fault::nblocks_for(within, self.row_stride);
344 // 2026-09-25: SAFETY: address arithmetic only; the fault worker writes the
345 // disjoint `[dst, dst+row_stride)` region while the arena is live.
346 let dst = unsafe {
347 self.arena
348 .slot_host_ptr(0, 0)?
349 .add(slot as usize * self.row_stride)
350 } as usize;
351 let scale = match &self.scales {
352 // 2026-09-25: A constant scale has no file and is never faulted: every
353 // slot already holds it.
354 Some(sc) if sc.file.is_some() => {
355 let sbyte = id * 4;
356 let sblock = sbyte - (sbyte % BLOCK as u64);
357 let swithin = (sbyte - sblock) as usize;
358 // 2026-09-25: SAFETY: as above, for a disjoint 4-byte region.
359 let sdst = unsafe { sc.arena.slot_host_ptr(0, 0)?.add(slot as usize * 4) };
360 Some((sblock, swithin, sdst as usize))
361 }
362 Some(_) | None => None,
363 };
364 Ok(crate::ngram_cache_fault::FaultJob {
365 row_id: id,
366 slot,
367 block_off,
368 within,
369 nblocks,
370 dst,
371 scale,
372 file_idx,
373 })
374 }
375
376 /// 2026-09-25: Release the batch's pins; call after the gather kernels are issued.
377 pub fn end_batch(&mut self) {
378 for p in &mut self.pinned {
379 *p = false;
380 }
381 }
382
383 /// 2026-09-25: CLOCK second-chance victim among the unpinned slots; an error when
384 /// every slot is pinned.
385 fn victim(&mut self) -> Result<u32> {
386 for _ in 0..(self.slots * 2) {
387 let s = self.hand;
388 self.hand = (self.hand + 1) % self.slots;
389 if self.pinned[s] {
390 continue;
391 }
392 if self.refbit[s] {
393 self.refbit[s] = false;
394 continue;
395 }
396 if self.slot_row[s] != u64::MAX {
397 let old = self.slot_row[s];
398 self.map.remove(&old);
399 self.evictions += 1;
400 }
401 return Ok(s as u32);
402 }
403 bail!(
404 "NgramRowCache: every one of {} slots is pinned by the batch in flight — \
405 raise the cache size or lower max-prefill-tokens",
406 self.slots
407 )
408 }
409
410 /// 2026-09-25: Backing-file index and byte offset of row `id`. The index is 0
411 /// unless a segmented table's shard lives in another file.
412 fn row_byte(&self, id: u64) -> (usize, u64) {
413 match &self.segments {
414 None => (0, self.base_offset + id * self.row_stride as u64),
415 Some(seg) => {
416 let shard = (id / seg.rows_per) as usize;
417 let local = id % seg.rows_per;
418 (
419 seg.shard_file[shard],
420 seg.bases[shard] + local * self.row_stride as u64,
421 )
422 }
423 }
424 }
425}
426
427#[cfg(unix)]
428fn open_direct(path: &Path) -> Result<File> {
429 use std::os::unix::fs::OpenOptionsExt;
430 std::fs::OpenOptions::new()
431 .read(true)
432 .custom_flags(libc::O_DIRECT)
433 .open(path)
434 .with_context(|| format!("NgramRowCache: open {} (O_DIRECT)", path.display()))
435}
436
437#[cfg(not(unix))]
438fn open_direct(path: &Path) -> Result<File> {
439 File::open(path).with_context(|| format!("NgramRowCache: open {}", path.display()))
440}
441
442#[cfg(test)]
443#[path = "ngram_cache/tests.rs"]
444mod tests;