spark_storage/
expert_pack.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//
3// On-disk expert-record (de)serialization + the directory manifest.
4//
5// Two layers:
6//   * Pure format functions (`pack_record` / `unpack_record`) that assemble and
7//     parse one fixed-stride record in memory. No I/O, no CUDA, no safetensors —
8//     unit-testable with synthetic bytes.
9//   * A portable file writer/reader that lays those records into one file per
10//     MoE layer, plus an `ExpertIndex` manifest (JSON) describing the geometry
11//     so the streamer can reconstruct `ExpertRecordSpec` / `ExpertLayout` and
12//     open the files without re-deriving anything from the checkpoint.
13//
14// The offline builder (checkpoint -> resident records) is the sole writer; the
15// runtime streamer is a reader. This module is the contract between them.
16
17use anyhow::{Context, Result, bail};
18use serde::{Deserialize, Serialize};
19
20use crate::expert::ExpertKey;
21use crate::expert::{ExpertLayout, ExpertRecordHeader, ExpertRecordSpec, Proj};
22
23/// Borrowed packed+scale bytes for one projection, as they will sit on disk
24/// (prefill-resident / transposed layout).
25#[derive(Clone, Copy, Debug)]
26pub struct ProjData<'a> {
27    pub packed: &'a [u8],
28    pub scale: &'a [u8],
29}
30
31/// Borrowed view of one projection's sub-buffers inside a parsed record.
32#[derive(Clone, Copy, Debug)]
33pub struct ProjView<'a> {
34    pub packed: &'a [u8],
35    pub scale: &'a [u8],
36}
37
38/// Assemble one complete `stride`-byte record: header at offset 0, each
39/// projection's packed+scale bytes placed at the spec's sub-offsets, zero
40/// padding everywhere else. Returns exactly `stride` bytes.
41///
42/// Errors (never panics) if any projection's byte lengths disagree with the
43/// spec, or if the assembled payload would not fit in `stride` — those are
44/// builder bugs we want surfaced loudly, not silently truncated records.
45pub fn pack_record(
46    spec: &ExpertRecordSpec,
47    stride: u64,
48    header: &ExpertRecordHeader,
49    projs: &[ProjData; 3],
50) -> Result<Vec<u8>> {
51    let stride = stride as usize;
52    if (spec.raw_bytes() as usize) > stride {
53        bail!(
54            "record stride {} smaller than raw record bytes {}",
55            stride,
56            spec.raw_bytes()
57        );
58    }
59    let mut buf = vec![0u8; stride];
60    let hdr = header.to_bytes();
61    buf[..hdr.len()].copy_from_slice(&hdr);
62
63    for p in Proj::ALL {
64        let pb = spec.proj_bytes(p);
65        let d = &projs[p as usize];
66        if d.packed.len() as u64 != pb.packed_bytes {
67            bail!(
68                "{:?} packed len {} != expected {}",
69                p,
70                d.packed.len(),
71                pb.packed_bytes
72            );
73        }
74        if d.scale.len() as u64 != pb.scale_bytes {
75            bail!(
76                "{:?} scale len {} != expected {}",
77                p,
78                d.scale.len(),
79                pb.scale_bytes
80            );
81        }
82        let po = spec.packed_off(p) as usize;
83        let so = spec.scale_off(p) as usize;
84        buf[po..po + d.packed.len()].copy_from_slice(d.packed);
85        buf[so..so + d.scale.len()].copy_from_slice(d.scale);
86    }
87    Ok(buf)
88}
89
90/// Parse a record `buf` (>= `spec.raw_bytes()`), returning the header and
91/// borrowed views of each projection's sub-buffers. Validates the header magic
92/// and version; returns an error on any mismatch.
93pub fn unpack_record<'a>(
94    spec: &ExpertRecordSpec,
95    buf: &'a [u8],
96) -> Result<(ExpertRecordHeader, [ProjView<'a>; 3])> {
97    if (buf.len() as u64) < spec.raw_bytes() {
98        bail!(
99            "record buffer {} smaller than raw record bytes {}",
100            buf.len(),
101            spec.raw_bytes()
102        );
103    }
104    let header = ExpertRecordHeader::from_bytes(buf)
105        .context("record header magic/version mismatch (wrong file or format version?)")?;
106    let mut views = [ProjView {
107        packed: &[],
108        scale: &[],
109    }; 3];
110    for p in Proj::ALL {
111        let pb = spec.proj_bytes(p);
112        let po = spec.packed_off(p) as usize;
113        let so = spec.scale_off(p) as usize;
114        views[p as usize] = ProjView {
115            packed: &buf[po..po + pb.packed_bytes as usize],
116            scale: &buf[so..so + pb.scale_bytes as usize],
117        };
118    }
119    Ok((header, views))
120}
121
122/// Directory manifest describing a built expert store. Serialized as
123/// `manifest.json` next to the per-layer `.xpr` files. This is the streamer's
124/// entry point — everything it needs to reconstruct geometry and open files.
125#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
126pub struct ExpertIndex {
127    /// Format version; must equal [`ExpertRecordHeader::VERSION`].
128    pub version: u32,
129    pub num_moe_layers: u32,
130    pub num_experts: u32,
131    pub inter: u64,
132    pub hidden: u64,
133    pub group_size: u64,
134    pub sub_align: u64,
135    pub fs_block_size: u64,
136    pub record_stride: u64,
137    pub record_raw_bytes: u64,
138    /// `printf`-style template for per-layer file names, e.g. `experts_{:05}.xpr`.
139    pub file_template: String,
140    /// Dense MoE-layer index -> absolute model layer index. Lets the runtime map
141    /// a model layer back to its expert file (dense attention layers are absent).
142    pub moe_layer_to_model_layer: Vec<u32>,
143}
144
145impl ExpertIndex {
146    pub const FILE_TEMPLATE: &'static str = "experts_{:05}.xpr";
147    pub const MANIFEST_NAME: &'static str = "manifest.json";
148
149    pub fn new(
150        inter: u64,
151        hidden: u64,
152        group_size: u64,
153        sub_align: u64,
154        fs_block_size: u64,
155        moe_layer_to_model_layer: Vec<u32>,
156        num_experts: u32,
157    ) -> Self {
158        let spec = ExpertRecordSpec::new(inter, hidden, group_size, sub_align);
159        let layout = ExpertLayout::from_spec(
160            moe_layer_to_model_layer.len() as u32,
161            num_experts,
162            &spec,
163            fs_block_size,
164        );
165        Self {
166            version: ExpertRecordHeader::VERSION,
167            num_moe_layers: moe_layer_to_model_layer.len() as u32,
168            num_experts,
169            inter,
170            hidden,
171            group_size,
172            sub_align,
173            fs_block_size,
174            record_stride: layout.record_stride,
175            record_raw_bytes: spec.raw_bytes(),
176            file_template: Self::FILE_TEMPLATE.to_string(),
177            moe_layer_to_model_layer,
178        }
179    }
180
181    /// Load just the manifest (`manifest.json`) from a store dir — geometry
182    /// only, no file handles. Lets the streamer size its arena before opening a
183    /// tier. Validates the format version.
184    #[cfg(unix)]
185    pub fn load(dir: &std::path::Path) -> Result<Self> {
186        let p = dir.join(Self::MANIFEST_NAME);
187        let json = std::fs::read_to_string(&p).with_context(|| format!("read {}", p.display()))?;
188        let index: ExpertIndex =
189            serde_json::from_str(&json).with_context(|| format!("parse {}", p.display()))?;
190        if index.version != ExpertRecordHeader::VERSION {
191            bail!(
192                "manifest version {} != supported {}",
193                index.version,
194                ExpertRecordHeader::VERSION
195            );
196        }
197        Ok(index)
198    }
199
200    pub fn spec(&self) -> ExpertRecordSpec {
201        ExpertRecordSpec::new(self.inter, self.hidden, self.group_size, self.sub_align)
202    }
203
204    pub fn layout(&self) -> ExpertLayout {
205        ExpertLayout::from_spec(
206            self.num_moe_layers,
207            self.num_experts,
208            &self.spec(),
209            self.fs_block_size,
210        )
211    }
212
213    /// Per-layer file name for a dense MoE-layer index.
214    pub fn file_name(&self, moe_layer: u32) -> String {
215        // Only `{:05}` is supported; kept simple + explicit rather than a format
216        // mini-language. Bump this if `file_template` ever needs to vary.
217        format!("experts_{moe_layer:05}.xpr")
218    }
219
220    /// Total on-disk bytes across all layer files.
221    pub fn total_bytes(&self) -> u64 {
222        (self.num_moe_layers as u64) * self.layout().bytes_per_layer()
223    }
224}
225
226pub use fs_impl::{ExpertFileReader, ExpertFileWriter};
227
228#[path = "expert_pack_fs.rs"]
229mod fs_impl;
230
231#[cfg(test)]
232#[path = "expert_pack_tests.rs"]
233mod tests;