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;