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}