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;