spark_storage/
expert.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//
3// Expert identity + on-disk record geometry for the MoE expert streamer.
4//
5// This is the expert-streaming analogue of `group.rs`: where a *group* is the
6// unit of NVMe <-> HBM movement for the KV cache (one `(layer, block, kv_head)`
7// K/V stripe), an *expert record* is the unit of movement for MoE weights — one
8// `(moe_layer, expert)` tuple's full set of gate/up/down projections, stored
9// contiguously and rounded up to the device's optimal I/O block (4 KiB).
10//
11// The engine that moves these records is the exact one that already ships for
12// KV (`backend::{IoUringBackend, PosixBackend}` driven off a `Layout`): the
13// backend only ever calls `fd(layer)`, `offset(key)` and `*_bytes()`. So the
14// streamer reuses that machinery unchanged by presenting expert geometry
15// through the same trio of accessors.
16//
17// Two facts make expert geometry simpler than KV geometry:
18//   * There is no K/V duplication and no per-head striping — one record per
19//     expert, period.
20//   * On every Atlas MoE checkpoint the expert dims are uniform across MoE
21//     layers, so `record_stride` is a single constant for the whole model.
22//
23// The bijection `(layer, expert) <-> record` is computed deterministically from
24// the dims; nothing stores the inverse.
25
26/// Dense 64-bit expert-record id (analogue of `group::GroupId`).
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
28pub struct ExpertRecordId(pub u64);
29
30/// Identifies one expert's weight record: `(moe_layer, expert)`.
31///
32/// `layer` is a *dense MoE-layer index* (0..num_moe_layers), not the model's
33/// absolute layer index — dense attention layers carry no experts and are
34/// skipped when the index is built.
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
36pub struct ExpertKey {
37    pub layer: u32,
38    pub expert: u32,
39}
40
41impl ExpertKey {
42    pub fn new(layer: u32, expert: u32) -> Self {
43        Self { layer, expert }
44    }
45}
46
47/// The three projections that make up one routed expert, in a fixed order.
48///
49/// Order is load-bearing: it is the order sub-buffers are laid out inside a
50/// record and the order the streamer patches pointer tables in. Never reorder
51/// without bumping [`ExpertRecordHeader::VERSION`].
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum Proj {
54    Gate = 0,
55    Up = 1,
56    Down = 2,
57}
58
59impl Proj {
60    pub const ALL: [Proj; 3] = [Proj::Gate, Proj::Up, Proj::Down];
61}
62
63/// Byte geometry of one NVFP4 projection sub-buffer inside an expert record.
64///
65/// NVFP4 (W4A4, group_size 16) stores each projection as two device buffers:
66///   * `packed`  — E2M1 nibbles, 2 values/byte, `[K/2, N]` in prefill-resident
67///     (transposed) layout, so `packed_bytes = N * K / 2`.
68///   * `scale`   — per-group FP8-E4M3 block scales, `[K/16, N]`, so
69///     `scale_bytes = N * K / group_size`.
70///
71/// The two per-projection scalars (`weight_scale_2`, `input_scale`) are carried
72/// in the record header, which is why they are absent here.
73#[derive(Clone, Copy, Debug, PartialEq, Eq)]
74pub struct ProjBytes {
75    pub packed_bytes: u64,
76    pub scale_bytes: u64,
77}
78
79impl ProjBytes {
80    /// `n` = output rows, `k` = contraction dim, `group_size` = NVFP4 block.
81    pub fn nvfp4(n: u64, k: u64, group_size: u64) -> Self {
82        Self {
83            packed_bytes: n * k / 2,
84            scale_bytes: n * k / group_size,
85        }
86    }
87}
88
89/// Where every sub-buffer of one expert record sits, relative to the record's
90/// base. Shared by the offline builder (to place bytes) and the streamer (to
91/// compute the device pointers it patches into the `ExpertPtrTable`).
92///
93/// Layout within a record (all offsets are relative to the record base and are
94/// aligned to `sub_align`, which must satisfy the fused MoE kernels' pointer
95/// alignment requirement):
96///
97/// ```text
98///   [ header (ExpertRecordHeader::BYTES) ]
99///   [ gate.packed ][ gate.scale ]
100///   [ up.packed   ][ up.scale   ]
101///   [ down.packed ][ down.scale ]
102///   [ pad to record_stride ]
103/// ```
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub struct ExpertRecordSpec {
106    pub inter: u64,
107    pub hidden: u64,
108    pub group_size: u64,
109    pub sub_align: u64,
110    /// `[packed_off, scale_off]` for gate, up, down — relative to record base.
111    offsets: [(u64, u64); 3],
112    bytes: [ProjBytes; 3],
113    /// Total raw bytes (header + all sub-buffers), before rounding to a device
114    /// I/O block. `ExpertLayout` rounds this up to `record_stride`.
115    raw_bytes: u64,
116}
117
118/// Rounds `off` up to the next multiple of `align` (align must be a power of 2).
119#[inline]
120fn align_up(off: u64, align: u64) -> u64 {
121    (off + align - 1) & !(align - 1)
122}
123
124impl ExpertRecordSpec {
125    /// Build the canonical record layout for a model whose experts have the
126    /// given `inter`(mediate) and `hidden` dims. `sub_align` is the alignment
127    /// applied to every sub-buffer (256 is a safe default for CUTLASS/MMQ).
128    pub fn new(inter: u64, hidden: u64, group_size: u64, sub_align: u64) -> Self {
129        assert!(
130            sub_align.is_power_of_two(),
131            "sub_align must be a power of two"
132        );
133        // gate/up: N=inter, K=hidden ; down: N=hidden, K=inter.
134        // packed = N*K/2 and scale = N*K/group_size are symmetric in (N,K),
135        // so all three projections have identical byte sizes — but we keep them
136        // per-projection so a future non-square expert stays correct.
137        let bytes = [
138            ProjBytes::nvfp4(inter, hidden, group_size), // gate
139            ProjBytes::nvfp4(inter, hidden, group_size), // up
140            ProjBytes::nvfp4(hidden, inter, group_size), // down
141        ];
142        let mut cursor = align_up(ExpertRecordHeader::BYTES, sub_align);
143        let mut offsets = [(0u64, 0u64); 3];
144        for i in 0..3 {
145            let packed_off = cursor;
146            cursor = align_up(packed_off + bytes[i].packed_bytes, sub_align);
147            let scale_off = cursor;
148            cursor = align_up(scale_off + bytes[i].scale_bytes, sub_align);
149            offsets[i] = (packed_off, scale_off);
150        }
151        Self {
152            inter,
153            hidden,
154            group_size,
155            sub_align,
156            offsets,
157            bytes,
158            raw_bytes: cursor,
159        }
160    }
161
162    pub fn proj_bytes(&self, p: Proj) -> ProjBytes {
163        self.bytes[p as usize]
164    }
165
166    /// Offset of a projection's packed-weight sub-buffer within the record.
167    pub fn packed_off(&self, p: Proj) -> u64 {
168        self.offsets[p as usize].0
169    }
170
171    /// Offset of a projection's block-scale sub-buffer within the record.
172    pub fn scale_off(&self, p: Proj) -> u64 {
173        self.offsets[p as usize].1
174    }
175
176    /// Total raw record bytes (header + sub-buffers), before I/O-block rounding.
177    pub fn raw_bytes(&self) -> u64 {
178        self.raw_bytes
179    }
180
181    /// Sum of all six sub-buffer payloads (excludes header + alignment padding).
182    pub fn payload_bytes(&self) -> u64 {
183        self.bytes
184            .iter()
185            .map(|b| b.packed_bytes + b.scale_bytes)
186            .sum()
187    }
188}
189
190/// Fixed-size, versioned header written at the front of every expert record.
191///
192/// Invariant D of the streaming-experts plan: *disk format = resident format*.
193/// Nothing is transformed at fetch time, so the format must be self-describing
194/// and versioned — there is no runtime enforcement otherwise. The header
195/// carries exactly the per-expert data that is *not* recomputable from the
196/// model dims: the two NVFP4 scalars per projection (`weight_scale_2` and
197/// `input_scale`), plus enough identity/shape to detect a mismatched file.
198#[derive(Clone, Copy, Debug, PartialEq)]
199pub struct ExpertRecordHeader {
200    pub layer: u32,
201    pub expert: u32,
202    pub inter: u32,
203    pub hidden: u32,
204    pub group_size: u32,
205    /// Per-projection `weight_scale_2` (per-tensor FP32 scale), gate/up/down.
206    pub scale2: [f32; 3],
207    /// Per-projection `input_scale` (activation scale). `None` = weight-only
208    /// W4A16 path with no activation scale (the streamer patches a NULL device
209    /// pointer). Presence is stored out of band in a flags byte, so equality is
210    /// exact (no NaN sentinel to break `PartialEq`).
211    pub input_scale: [Option<f32>; 3],
212}
213
214impl ExpertRecordHeader {
215    pub const MAGIC: u32 = 0x5850_5254; // "XPRT"
216    pub const VERSION: u32 = 1;
217    /// Reserved on-disk header size. Generous vs. the packed fields so the
218    /// format can grow without moving sub-buffer offsets.
219    pub const BYTES: u64 = 256;
220
221    /// Serialize into the fixed 256-byte on-disk header block. Layout:
222    ///   u32 magic, u32 version, u32 layer, u32 expert,
223    ///   u32 inter, u32 hidden, u32 group_size, u32 input_scale_flags,
224    ///   `f32 scale2[3]`, `f32 input_scale[3]` (0.0 where absent), zero pad to 256.
225    /// `input_scale_flags` bit `i` set => projection `i` has an activation scale.
226    pub fn to_bytes(&self) -> [u8; Self::BYTES as usize] {
227        let mut out = [0u8; Self::BYTES as usize];
228        let mut w = |off: usize, v: u32| out[off..off + 4].copy_from_slice(&v.to_le_bytes());
229        w(0, Self::MAGIC);
230        w(4, Self::VERSION);
231        w(8, self.layer);
232        w(12, self.expert);
233        w(16, self.inter);
234        w(20, self.hidden);
235        w(24, self.group_size);
236        let mut flags = 0u32;
237        for (i, s) in self.input_scale.iter().enumerate() {
238            if s.is_some() {
239                flags |= 1 << i;
240            }
241        }
242        w(28, flags);
243        for (i, s) in self.scale2.iter().enumerate() {
244            out[32 + i * 4..36 + i * 4].copy_from_slice(&s.to_le_bytes());
245        }
246        for (i, s) in self.input_scale.iter().enumerate() {
247            let v = s.unwrap_or(0.0);
248            out[44 + i * 4..48 + i * 4].copy_from_slice(&v.to_le_bytes());
249        }
250        out
251    }
252
253    /// Parse a header block, validating magic + version. Returns `None` on any
254    /// mismatch (wrong file, wrong version) — never panics on bad input.
255    pub fn from_bytes(buf: &[u8]) -> Option<Self> {
256        if buf.len() < Self::BYTES as usize {
257            return None;
258        }
259        let r = |off: usize| -> u32 {
260            u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]])
261        };
262        let rf = |off: usize| -> f32 {
263            f32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]])
264        };
265        if r(0) != Self::MAGIC || r(4) != Self::VERSION {
266            return None;
267        }
268        let flags = r(28);
269        let iscale = |i: usize, off: usize| -> Option<f32> {
270            if flags & (1 << i) != 0 {
271                Some(rf(off))
272            } else {
273                None
274            }
275        };
276        Some(Self {
277            layer: r(8),
278            expert: r(12),
279            inter: r(16),
280            hidden: r(20),
281            group_size: r(24),
282            scale2: [rf(32), rf(36), rf(40)],
283            input_scale: [iscale(0, 44), iscale(1, 48), iscale(2, 52)],
284        })
285    }
286}
287
288/// Deterministic file geometry for a directory of per-MoE-layer expert files.
289///
290/// One file per MoE layer (`experts_{layer:05}.xpr`), each holding
291/// `num_experts` fixed-stride records back to back. `record_stride` is the raw
292/// record size rounded up to `fs_block_size` (O_DIRECT requires the read
293/// offset and length to be block-aligned). Mirrors `group::GroupLayout`'s
294/// `fd`/`offset`/`*_bytes` surface so the KV backends drive it verbatim.
295#[derive(Clone, Copy, Debug, PartialEq, Eq)]
296pub struct ExpertLayout {
297    pub num_layers: u32,
298    pub num_experts: u32,
299    /// Fixed per-expert record stride on disk (a multiple of `fs_block_size`).
300    pub record_stride: u64,
301    pub fs_block_size: u64,
302}
303
304impl ExpertLayout {
305    /// Build a layout from a record spec. `record_stride` is `spec.raw_bytes()`
306    /// rounded up to `fs_block_size`.
307    pub fn from_spec(
308        num_layers: u32,
309        num_experts: u32,
310        spec: &ExpertRecordSpec,
311        fs_block_size: u64,
312    ) -> Self {
313        let record_stride = spec.raw_bytes().div_ceil(fs_block_size) * fs_block_size;
314        Self {
315            num_layers,
316            num_experts,
317            record_stride,
318            fs_block_size,
319        }
320    }
321
322    /// Bytes occupied by one MoE layer's file (all experts, back to back).
323    pub fn bytes_per_layer(&self) -> u64 {
324        (self.num_experts as u64) * self.record_stride
325    }
326
327    /// File offset of `key`'s record within its layer file.
328    pub fn file_offset(&self, key: ExpertKey) -> u64 {
329        debug_assert!(key.expert < self.num_experts);
330        (key.expert as u64) * self.record_stride
331    }
332
333    /// Dense record id across the whole model (layer-major).
334    pub fn record_id(&self, key: ExpertKey) -> ExpertRecordId {
335        ExpertRecordId((key.layer as u64) * (self.num_experts as u64) + (key.expert as u64))
336    }
337
338    /// Bytes of one record on disk (== `record_stride`); the fixed read size.
339    pub fn record_bytes(&self) -> u64 {
340        self.record_stride
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    // Qwen3.5-35B-A3B dims: inter=512, hidden=2048, group_size=16.
349    const A3B_INTER: u64 = 512;
350    const A3B_HIDDEN: u64 = 2048;
351    const GS: u64 = 16;
352
353    #[test]
354    fn a3b_per_expert_payload_matches_formula() {
355        // Plan formula: per-expert payload = 3 * inter * hidden * 9/16.
356        let spec = ExpertRecordSpec::new(A3B_INTER, A3B_HIDDEN, GS, 256);
357        let expected = 3 * A3B_INTER * A3B_HIDDEN * 9 / 16;
358        assert_eq!(spec.payload_bytes(), expected);
359        assert_eq!(expected, 1_769_472); // 1.6875 MiB, from recon.
360    }
361
362    #[test]
363    fn projection_bytes_split_8_to_1() {
364        // packed is 8/9 of payload, scale is 1/9 (0.5 byte/elem vs 1 byte/16).
365        let pb = ProjBytes::nvfp4(A3B_INTER, A3B_HIDDEN, GS);
366        assert_eq!(pb.packed_bytes, A3B_INTER * A3B_HIDDEN / 2);
367        assert_eq!(pb.scale_bytes, A3B_INTER * A3B_HIDDEN / 16);
368        assert_eq!(pb.packed_bytes, 8 * pb.scale_bytes);
369    }
370
371    #[test]
372    fn sub_buffers_are_aligned_and_non_overlapping() {
373        let align = 256;
374        let spec = ExpertRecordSpec::new(A3B_INTER, A3B_HIDDEN, GS, align);
375        // Header first, everything after it aligned and monotonic.
376        let mut prev_end = ExpertRecordHeader::BYTES;
377        for p in Proj::ALL {
378            let po = spec.packed_off(p);
379            let so = spec.scale_off(p);
380            let pb = spec.proj_bytes(p);
381            assert_eq!(po % align, 0, "packed off aligned");
382            assert_eq!(so % align, 0, "scale off aligned");
383            assert!(po >= prev_end, "packed does not overlap previous");
384            assert!(so >= po + pb.packed_bytes, "scale does not overlap packed");
385            prev_end = so + pb.scale_bytes;
386        }
387        assert!(spec.raw_bytes() >= prev_end);
388    }
389
390    #[test]
391    fn layout_offsets_are_record_strided() {
392        let spec = ExpertRecordSpec::new(A3B_INTER, A3B_HIDDEN, GS, 256);
393        let layout = ExpertLayout::from_spec(40, 256, &spec, 4096);
394        assert_eq!(layout.record_stride % 4096, 0, "O_DIRECT alignment");
395        assert!(layout.record_stride >= spec.raw_bytes());
396        assert_eq!(layout.file_offset(ExpertKey::new(3, 0)), 0);
397        assert_eq!(
398            layout.file_offset(ExpertKey::new(3, 5)),
399            5 * layout.record_stride
400        );
401        assert_eq!(layout.bytes_per_layer(), 256 * layout.record_stride);
402    }
403
404    #[test]
405    fn record_id_is_dense_layer_major() {
406        let spec = ExpertRecordSpec::new(A3B_INTER, A3B_HIDDEN, GS, 256);
407        let layout = ExpertLayout::from_spec(40, 256, &spec, 4096);
408        assert_eq!(layout.record_id(ExpertKey::new(0, 0)).0, 0);
409        assert_eq!(layout.record_id(ExpertKey::new(0, 255)).0, 255);
410        assert_eq!(layout.record_id(ExpertKey::new(1, 0)).0, 256);
411    }
412
413    #[test]
414    fn header_round_trips() {
415        let h = ExpertRecordHeader {
416            layer: 7,
417            expert: 42,
418            inter: A3B_INTER as u32,
419            hidden: A3B_HIDDEN as u32,
420            group_size: GS as u32,
421            scale2: [0.5, 0.25, 1.5],
422            input_scale: [Some(2.0), None, Some(3.0)],
423        };
424        let bytes = h.to_bytes();
425        assert_eq!(bytes.len(), ExpertRecordHeader::BYTES as usize);
426        let back = ExpertRecordHeader::from_bytes(&bytes).expect("valid header");
427        // Exact struct equality now holds (no NaN sentinel).
428        assert_eq!(back, h);
429        assert_eq!(back.scale2, [0.5, 0.25, 1.5]);
430        assert_eq!(back.input_scale, [Some(2.0), None, Some(3.0)]);
431    }
432
433    #[test]
434    fn header_rejects_bad_magic_and_version() {
435        let mut bytes = ExpertRecordHeader {
436            layer: 0,
437            expert: 0,
438            inter: 1,
439            hidden: 1,
440            group_size: GS as u32,
441            scale2: [1.0; 3],
442            input_scale: [Some(1.0); 3],
443        }
444        .to_bytes();
445        // Corrupt the magic.
446        bytes[0] ^= 0xFF;
447        assert!(ExpertRecordHeader::from_bytes(&bytes).is_none());
448        // Too-short buffer.
449        assert!(ExpertRecordHeader::from_bytes(&bytes[..10]).is_none());
450    }
451
452    #[test]
453    fn a3b_record_stride_is_4k_aligned_and_reasonable() {
454        // The full-model on-disk size should land near the recon's ~200 GB for
455        // 397B and a small multiple of payload for a3b. Here just sanity-check
456        // a3b: stride within one 4K block of the raw size.
457        let spec = ExpertRecordSpec::new(A3B_INTER, A3B_HIDDEN, GS, 256);
458        let layout = ExpertLayout::from_spec(40, 256, &spec, 4096);
459        assert!(layout.record_stride - spec.raw_bytes() < 4096);
460    }
461}