spark_storage/
ngram_cache.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! NVMe-backed row cache for the n-gram embedding tables.
4//!
5//! The n-gram tables of the LongCat / Qwen3.8-Flash-Next family are the
6//! model's largest tensors by far (31.4 B params on LongCat-Flash-Lite,
7//! ~51 B announced for Flash-Next) and simultaneously its *least*
8//! bandwidth-hungry: a token touches exactly one row per table — 12 rows,
9//! ~3 KB — regardless of sequence length. Pure capacity, near-zero
10//! bandwidth, which makes them the best demotion candidate in the model.
11//!
12//! Design, and why it needs no CUDA kernel change:
13//!
14//! * The cache is a flat PINNED arena of `slots × row_stride` bytes. On
15//!   GB10 pinned host memory is GPU-addressable at the SAME virtual address
16//!   ([`ExpertArena`] asserts this), so the arena *is* a
17//!   `[slots, dim]` device-side table.
18//! * The n-gram row ids are computed HOST-side (they are a pure function of
19//!   token ids), so a lookup resolves `row_id -> slot` on the host and hands
20//!   the gather kernel the SLOT INDEX in place of the row id. `batched_embed`
21//!   / `batched_embed_fp8` then run verbatim against the arena base.
22//! * A miss reads the row straight off NVMe into its pinned slot — no
23//!   `cuMemcpyHtoD` anywhere on the path.
24//!
25//! Eviction is CLOCK (second-chance): O(1), no per-hit bookkeeping, and it
26//! approximates LRU well for the power-law access pattern these tables have.
27//! Rows touched by the CURRENT batch are pinned so a large prefill can never
28//! evict a row it is still about to read.
29//!
30//! O_DIRECT requires 4 KiB-aligned reads, while a row is typically 256 B
31//! (FP8, dim 256). Reads are therefore issued as the containing 4 KiB block
32//! into a bounce buffer and the row copied out — the block is the disk's
33//! minimum transfer anyway, so this costs no extra I/O, only a 256 B host
34//! memcpy. Cache capacity stays row-granular, which matters because the
35//! hash scatters ids: neighbouring rows in a table are unrelated.
36
37use std::collections::HashMap;
38use std::fs::File;
39use std::path::Path;
40
41use anyhow::{Context, Result, bail};
42
43use crate::expert_arena::ExpertArena;
44
45/// O_DIRECT transfer granularity (also `ExpertArena`'s stride requirement).
46pub(crate) const BLOCK: usize = 4096;
47
48/// One table's on-NVMe backing file plus its resident row cache.
49pub struct NgramRowCache {
50    /// Flat pinned, GPU-addressable `[slots, row_stride]` region.
51    arena: ExpertArena,
52    /// Backing file: row `i` at byte offset `base_offset + i * row_stride`.
53    /// `base_offset` lets the cache read STRAIGHT OUT OF A SAFETENSORS SHARD
54    /// — a table is already a contiguous row-major blob there, so no repack
55    /// or re-save is needed. Because that offset is only 8-byte aligned, a
56    /// row may straddle a 4 KiB O_DIRECT block; `fetch_into` handles the seam.
57    file: File,
58    /// Additional backing files, for a segmented table whose shards do NOT all
59    /// live in one safetensors shard. Index 0 is `file`; `Segments::shard_file`
60    /// indexes into this list.
61    ///
62    /// Qwen3.8-Flash-Next needs this and LongCat does not: the released
63    /// NVFP4 checkpoint spreads its 128 PLE shards across TEN
64    /// `model-plefp8-*.safetensors` files, so requiring one file refused the
65    /// model outright ("PLE: shard 2 lives in a different file from shard 0").
66    extra_files: Vec<File>,
67    base_offset: u64,
68    /// SEGMENTED tables: one base offset per equal-sized shard.
69    ///
70    /// LongCat ships each n-gram table as ONE contiguous safetensors tensor,
71    /// so `base_offset` alone locates every row. Qwen3.8-Flash-Next splits its
72    /// single 320M-row table across 128 shard tensors which are NOT laid out
73    /// consecutively in the file — the shards interleave with other weights,
74    /// so a global row id needs its shard's own base. `None` keeps the
75    /// original single-offset behaviour byte for byte.
76    segments: Option<Segments>,
77    /// Per-row scale file mirror (FP8 tables), `None` for BF16 tables.
78    scales: Option<ScaleCache>,
79    row_stride: usize,
80    slots: usize,
81    rows_total: u64,
82    /// row_id -> slot.
83    map: HashMap<u64, u32>,
84    /// slot -> resident row id (`u64::MAX` = empty).
85    slot_row: Vec<u64>,
86    /// CLOCK reference bits.
87    refbit: Vec<bool>,
88    /// Slots pinned for the batch in flight (never evicted).
89    pinned: Vec<bool>,
90    hand: usize,
91    bounce: AlignedBlock,
92    pub hits: u64,
93    pub misses: u64,
94    pub evictions: u64,
95}
96
97/// A table split across equal-sized shards at scattered file offsets.
98struct Segments {
99    /// Byte offset of each shard's first row, indexed by shard.
100    bases: Vec<u64>,
101    /// Which backing file each shard lives in: 0 is `file`, n>0 indexes
102    /// `extra_files[n - 1]`. All zeroes when the table is one file, which is
103    /// the LongCat shape and stays byte-for-byte what it was.
104    shard_file: Vec<usize>,
105    /// Rows per shard. Every shard but conceivably the last holds exactly
106    /// this many; `open_segmented` requires them all equal so the mapping is
107    /// a divide rather than a search.
108    rows_per: u64,
109}
110
111/// Per-row f32 scales for an FP8 table, mirrored into a device-visible
112/// `[slots]` array indexed by SLOT (parallel to the arena).
113struct ScaleCache {
114    arena: ExpertArena,
115    /// `None` for a table whose scale is a single constant for every row: the
116    /// arena is filled once at open and never faulted. The released
117    /// Qwen3.8-Flash-Next PLE table is that shape — one
118    /// `ngram_embedding.weight_scale`, BF16, shape [1] — while LongCat's is
119    /// per-row and reads from this file.
120    file: Option<File>,
121}
122
123/// A 4 KiB-aligned host buffer for O_DIRECT reads.
124pub(crate) struct AlignedBlock {
125    buf: Vec<u8>,
126    off: usize,
127}
128
129impl AlignedBlock {
130    /// Two blocks: a row whose base offset is not 4 KiB-aligned (every row of
131    /// a table read in place from a safetensors shard) can straddle one
132    /// boundary, and two blocks always cover it since `row_stride <= BLOCK`.
133    pub(crate) fn new() -> Self {
134        // Over-allocate and take an aligned window (portable, no libc::memalign).
135        let buf = vec![0u8; BLOCK * 3];
136        let addr = buf.as_ptr() as usize;
137        let off = (BLOCK - (addr % BLOCK)) % BLOCK;
138        Self { buf, off }
139    }
140    /// `n` whole blocks of aligned scratch (`n <= 2`).
141    pub(crate) fn blocks(&mut self, n: usize) -> &mut [u8] {
142        &mut self.buf[self.off..self.off + n * BLOCK]
143    }
144}
145
146impl NgramRowCache {
147    /// Open `path` as the backing store for a table of `rows_total` rows of
148    /// `row_stride` bytes, caching `slots` of them in pinned GPU-addressable
149    /// memory. `scale_path` supplies the per-row f32 scales of an FP8 table.
150    pub fn open(
151        path: &Path,
152        scale_path: Option<&Path>,
153        rows_total: u64,
154        row_stride: usize,
155        slots: usize,
156    ) -> Result<Self> {
157        Self::open_at(path, 0, scale_path, rows_total, row_stride, slots)
158    }
159
160    /// As [`Self::open`], but the table starts at `base_offset` inside the
161    /// file — the safetensors-shard case (`data_offsets[0]` + the header
162    /// length), which needs no re-save of the checkpoint.
163    #[allow(clippy::too_many_arguments)]
164    pub fn open_at(
165        path: &Path,
166        base_offset: u64,
167        scale_path: Option<&Path>,
168        rows_total: u64,
169        row_stride: usize,
170        slots: usize,
171    ) -> Result<Self> {
172        if row_stride == 0 || slots == 0 {
173            bail!("NgramRowCache: zero geometry (row_stride={row_stride}, slots={slots})");
174        }
175        if row_stride > BLOCK {
176            bail!(
177                "NgramRowCache: row_stride {row_stride} exceeds the {BLOCK}-byte \
178                 O_DIRECT block; a row would span more than the two blocks the \
179                 seam-handling fetch reads"
180            );
181        }
182        // One flat pinned region: `slots * row_stride` bytes, rounded up to the
183        // arena's 4 KiB stride requirement.
184        let bytes = slots * row_stride;
185        let blocks = bytes.div_ceil(BLOCK);
186        let arena =
187            ExpertArena::new(1, blocks as u32, BLOCK).context("NgramRowCache: pinned arena")?;
188        let file = open_direct(path)?;
189        let scales = match scale_path {
190            Some(sp) => {
191                let sbytes = slots * 4;
192                let sblocks = sbytes.div_ceil(BLOCK);
193                Some(ScaleCache {
194                    arena: ExpertArena::new(1, sblocks as u32, BLOCK)
195                        .context("NgramRowCache: scale arena")?,
196                    file: Some(open_direct(sp)?),
197                })
198            }
199            None => None,
200        };
201        Ok(Self {
202            arena,
203            file,
204            base_offset,
205            segments: None,
206            extra_files: Vec::new(),
207            scales,
208            row_stride,
209            slots,
210            rows_total,
211            map: HashMap::with_capacity(slots * 2),
212            slot_row: vec![u64::MAX; slots],
213            refbit: vec![false; slots],
214            pinned: vec![false; slots],
215            hand: 0,
216            bounce: AlignedBlock::new(),
217            hits: 0,
218            misses: 0,
219            evictions: 0,
220        })
221    }
222
223    /// As [`Self::open_at`], but for a table split across equal-sized shards
224    /// at SCATTERED file offsets — Qwen3.8-Flash-Next's PLE table, whose 128
225    /// shard tensors are not laid out consecutively inside the safetensors
226    /// file. `bases[i]` is shard `i`'s first row; every shard holds
227    /// `rows_per_shard` rows.
228    #[allow(clippy::too_many_arguments)]
229    pub fn open_segmented(
230        shards: &[(std::path::PathBuf, u64)],
231        rows_per_shard: u64,
232        scale_path: Option<&Path>,
233        row_stride: usize,
234        slots: usize,
235    ) -> Result<Self> {
236        if shards.is_empty() || rows_per_shard == 0 {
237            bail!(
238                "NgramRowCache: segmented table needs shards and rows \
239                 (shards={}, rows_per_shard={rows_per_shard})",
240                shards.len()
241            );
242        }
243        // One File per DISTINCT path, in first-seen order, so a table split
244        // across ten files costs ten descriptors rather than one per shard.
245        // Shard 0's file is the cache's own `file`; the rest are `extra_files`.
246        let mut order: Vec<&Path> = Vec::new();
247        let mut shard_file = Vec::with_capacity(shards.len());
248        for (p, _) in shards {
249            let idx = order
250                .iter()
251                .position(|q| *q == p.as_path())
252                .unwrap_or_else(|| {
253                    order.push(p.as_path());
254                    order.len() - 1
255                });
256            shard_file.push(idx);
257        }
258        let bases: Vec<u64> = shards.iter().map(|(_, o)| *o).collect();
259        let rows_total = bases.len() as u64 * rows_per_shard;
260        let mut c = Self::open_at(order[0], 0, scale_path, rows_total, row_stride, slots)?;
261        for p in &order[1..] {
262            c.extra_files.push(open_direct(p)?);
263        }
264        c.segments = Some(Segments {
265            bases,
266            shard_file,
267            rows_per: rows_per_shard,
268        });
269        Ok(c)
270    }
271
272    /// Device VA of the cache's row table — the `embed_table` argument of the
273    /// gather kernels, which then index it by SLOT.
274    pub fn table_dev_va(&self) -> Result<u64> {
275        self.arena.slot_dev_va(0, 0)
276    }
277
278    /// Give every slot the same scale, for an FP8 table quantized with ONE
279    /// factor rather than per row.
280    ///
281    /// Filled once here instead of faulted per row: the value does not depend
282    /// on which row landed in the slot, so a per-fault read would be the same
283    /// four bytes fetched again for every miss. The gather kernel is the FP8
284    /// one either way — it multiplies by `scales[slot]` and does not care where
285    /// that came from.
286    ///
287    /// # Errors
288    /// If the scale arena cannot be allocated.
289    pub fn set_constant_scale(&mut self, scale: f32) -> Result<()> {
290        let sbytes = self.slots * 4;
291        let sblocks = sbytes.div_ceil(BLOCK);
292        let arena = ExpertArena::new(1, sblocks as u32, BLOCK)
293            .context("NgramRowCache: constant scale arena")?;
294        let p = arena.slot_host_ptr(0, 0)?.cast::<f32>();
295        for i in 0..self.slots {
296            // SAFETY: the arena is at least `slots * 4` bytes and was just
297            // allocated here, so nothing else holds a reference into it.
298            unsafe { p.add(i).write(scale) };
299        }
300        self.scales = Some(ScaleCache { arena, file: None });
301        Ok(())
302    }
303
304    /// Device VA of the `[slots]` f32 scale array (FP8 tables only).
305    pub fn scale_dev_va(&self) -> Result<Option<u64>> {
306        match &self.scales {
307            Some(s) => Ok(Some(s.arena.slot_dev_va(0, 0)?)),
308            None => Ok(None),
309        }
310    }
311
312    pub fn stats(&self) -> (u64, u64, u64) {
313        (self.hits, self.misses, self.evictions)
314    }
315
316    /// Resolve `row_ids` to slot indices, faulting misses in from NVMe.
317    ///
318    /// Every returned slot is PINNED for the caller's batch: the gather runs
319    /// after this returns, so a later resolve in the same batch must not
320    /// evict a row the kernel is about to read. Call [`Self::end_batch`] once
321    /// the gather has been issued.
322    pub fn resolve(&mut self, row_ids: &[u64], out_slots: &mut Vec<u32>) -> Result<()> {
323        out_slots.clear();
324        out_slots.reserve(row_ids.len());
325        // Phase 1 — bookkeeping only: pin hits, assign a victim slot to every
326        // miss (a repeated missing id hits the map on its second occurrence,
327        // so each unique row faults once). No I/O under this loop.
328        let mut jobs: Vec<crate::ngram_cache_fault::FaultJob> = Vec::new();
329        for &id in row_ids {
330            if id >= self.rows_total {
331                bail!(
332                    "NgramRowCache: row id {id} >= table rows {} (hash/table mismatch)",
333                    self.rows_total
334                );
335            }
336            let slot = match self.map.get(&id) {
337                Some(&s) => {
338                    self.hits += 1;
339                    self.refbit[s as usize] = true;
340                    self.pinned[s as usize] = true;
341                    s
342                }
343                None => {
344                    self.misses += 1;
345                    let s = self.victim()?;
346                    self.map.insert(id, s);
347                    self.slot_row[s as usize] = id;
348                    self.refbit[s as usize] = true;
349                    self.pinned[s as usize] = true;
350                    jobs.push(self.fault_job(id, s)?);
351                    s
352                }
353            };
354            out_slots.push(slot);
355        }
356        // Phase 2 — fault every miss in, parallel past a few (the serial
357        // QD=1 pread-per-miss loop was the diverse-prefill stall).
358        if !jobs.is_empty() {
359            // Every backing file, in index order, so a job can name its own.
360            let files: Vec<&File> = std::iter::once(&self.file)
361                .chain(self.extra_files.iter())
362                .collect();
363            let r = crate::ngram_cache_fault::fault_all(
364                &jobs,
365                &files,
366                self.scales.as_ref().and_then(|sc| sc.file.as_ref()),
367                self.row_stride,
368                &mut self.bounce,
369            );
370            if let Err(e) = r {
371                // Roll the failed batch's map entries back: they were
372                // inserted in phase 1 and now describe slots holding garbage.
373                for j in &jobs {
374                    self.map.remove(&j.row_id);
375                    self.slot_row[j.slot as usize] = u64::MAX;
376                    self.pinned[j.slot as usize] = false;
377                    self.refbit[j.slot as usize] = false;
378                }
379                return Err(e);
380            }
381        }
382        Ok(())
383    }
384
385    /// Resolve one miss to byte offsets + destination addresses — the
386    /// bookkeeping-free half of the old `fetch_into`, consumed by
387    /// [`crate::ngram_cache_fault::fault_all`].
388    fn fault_job(&self, id: u64, slot: u32) -> Result<crate::ngram_cache_fault::FaultJob> {
389        let (file_idx, byte) = self.row_byte(id);
390        let block_off = byte - (byte % BLOCK as u64);
391        let within = (byte - block_off) as usize;
392        let nblocks = crate::ngram_cache_fault::nblocks_for(within, self.row_stride);
393        // SAFETY: address arithmetic only; the fault worker writes the
394        // disjoint `[dst, dst+row_stride)` region while the arena is live.
395        let dst = unsafe {
396            self.arena
397                .slot_host_ptr(0, 0)?
398                .add(slot as usize * self.row_stride)
399        } as usize;
400        let scale = match &self.scales {
401            // A constant scale has no file and never faults: the arena was
402            // filled at open and every slot already holds the right value.
403            Some(sc) if sc.file.is_some() => {
404                let sbyte = id * 4;
405                let sblock = sbyte - (sbyte % BLOCK as u64);
406                let swithin = (sbyte - sblock) as usize;
407                // SAFETY: as above, 4-byte disjoint region.
408                let sdst = unsafe { sc.arena.slot_host_ptr(0, 0)?.add(slot as usize * 4) };
409                Some((sblock, swithin, sdst as usize))
410            }
411            // No scales at all, or a constant one already resident.
412            Some(_) | None => None,
413        };
414        Ok(crate::ngram_cache_fault::FaultJob {
415            row_id: id,
416            slot,
417            block_off,
418            within,
419            nblocks,
420            dst,
421            scale,
422            file_idx,
423        })
424    }
425
426    /// Release the batch's pins (call after the gather kernels are issued).
427    pub fn end_batch(&mut self) {
428        for p in &mut self.pinned {
429            *p = false;
430        }
431    }
432
433    /// CLOCK second-chance victim among the unpinned slots.
434    fn victim(&mut self) -> Result<u32> {
435        for _ in 0..(self.slots * 2) {
436            let s = self.hand;
437            self.hand = (self.hand + 1) % self.slots;
438            if self.pinned[s] {
439                continue;
440            }
441            if self.refbit[s] {
442                self.refbit[s] = false;
443                continue;
444            }
445            if self.slot_row[s] != u64::MAX {
446                let old = self.slot_row[s];
447                self.map.remove(&old);
448                self.evictions += 1;
449            }
450            return Ok(s as u32);
451        }
452        bail!(
453            "NgramRowCache: every one of {} slots is pinned by the batch in flight — \
454             raise the cache size or lower max-prefill-tokens",
455            self.slots
456        )
457    }
458
459    /// Byte offset of row `id`, and which backing file holds it.
460    ///
461    /// The file index is part of the answer because a segmented table's shards
462    /// may live in different safetensors files — an offset alone named a byte
463    /// in the wrong one.
464    fn row_byte(&self, id: u64) -> (usize, u64) {
465        match &self.segments {
466            None => (0, self.base_offset + id * self.row_stride as u64),
467            Some(seg) => {
468                let shard = (id / seg.rows_per) as usize;
469                let local = id % seg.rows_per;
470                (
471                    seg.shard_file[shard],
472                    seg.bases[shard] + local * self.row_stride as u64,
473                )
474            }
475        }
476    }
477}
478
479#[cfg(unix)]
480fn open_direct(path: &Path) -> Result<File> {
481    use std::os::unix::fs::OpenOptionsExt;
482    std::fs::OpenOptions::new()
483        .read(true)
484        .custom_flags(libc::O_DIRECT)
485        .open(path)
486        .with_context(|| format!("NgramRowCache: open {} (O_DIRECT)", path.display()))
487}
488
489#[cfg(not(unix))]
490fn open_direct(path: &Path) -> Result<File> {
491    File::open(path).with_context(|| format!("NgramRowCache: open {}", path.display()))
492}
493
494#[cfg(test)]
495#[path = "ngram_cache/tests.rs"]
496mod tests;