spark_runtime/weights.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Weight loading from safetensors files (SBIO IORouter for filesystem I/O).
4
5use crate::gpu::{DevicePtr, GpuBackend};
6use anyhow::{Result, bail};
7use std::collections::HashMap;
8use std::path::Path;
9
10/// Advise the OS to evict a file's pages from the page cache.
11///
12/// On GB10 (unified memory), mmap'd safetensors share the GPU memory pool.
13/// After copying tensors to GPU, the mmap pages linger in the page cache,
14/// consuming memory that should be available for KV cache and inference buffers.
15/// This function tells the kernel those pages are no longer needed.
16#[cfg(target_os = "linux")]
17pub(crate) fn evict_page_cache(file: &std::fs::File) {
18 use std::os::unix::io::AsRawFd;
19 // POSIX_FADV_DONTNEED = 4 on Linux (POSIX standard).
20 // macOS lacks posix_fadvise — see the non-linux branch below.
21 const POSIX_FADV_DONTNEED: libc::c_int = 4;
22 unsafe {
23 libc::posix_fadvise(file.as_raw_fd(), 0, 0, POSIX_FADV_DONTNEED);
24 }
25}
26
27#[cfg(not(target_os = "linux"))]
28pub(crate) fn evict_page_cache(_file: &std::fs::File) {
29 // No-op: macOS/BSD have no posix_fadvise. Apple Silicon UMA already
30 // shares page cache with the GPU pool, so eviction is unnecessary.
31}
32
33/// Data type of a weight tensor.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum WeightDtype {
36 BF16,
37 FP32,
38 FP8E4M3,
39 FP8E8M0,
40 UInt8,
41 Int64,
42 /// Keep-packed PrismML ternary Q2_0 (ggml id 42): raw on-disk blocks stay
43 /// 2-bit in VRAM (fp16 scale + 2-bit codes per group of `group` elements),
44 /// dequantized in-kernel by the native `q2_0_gemv` decode path. Only
45 /// produced by the GGUF loader under `AVAROK_GGUF_NATIVE_Q2=1`. Its byte
46 /// footprint is NOT a per-element size (2-bit codes + an inline scale per
47 /// group), so [`WeightDtype::byte_size`] returns 0 for this variant and the
48 /// real size is computed in [`WeightTensor::byte_size`] (shape + group).
49 PackedQ2_0 {
50 group: u16,
51 },
52 /// Raw GGUF `Q2_K` blocks kept on the device (84 bytes per 256 weights,
53 /// `[N, K]` row-major in blocks) for the K-quant GEMV/MMQ kernels.
54 Q2K,
55 /// Raw GGUF `Q3_K` blocks kept on the device (110 bytes per 256 weights).
56 Q3K,
57 /// Raw GGUF `Q6_K` blocks kept on the device (210 bytes per 256 weights):
58 /// the DeepSeek-V4.1 output head, run by the K-quant GEMV instead of a
59 /// bf16 expansion (0.82 instead of 2 bytes a weight read every token).
60 Q6K,
61}
62
63impl WeightDtype {
64 /// Bytes per element for the fixed-width dtypes. Returns 0 for the
65 /// block-based [`WeightDtype::PackedQ2_0`] — [`WeightTensor::byte_size`]
66 /// handles that variant directly, and no caller multiplies its numel by this.
67 pub fn byte_size(self) -> usize {
68 match self {
69 Self::BF16 => 2,
70 Self::FP32 => 4,
71 Self::FP8E4M3 => 1,
72 Self::FP8E8M0 => 1,
73 Self::UInt8 => 1,
74 Self::Int64 => 8,
75 Self::PackedQ2_0 { .. } => 0,
76 Self::Q2K => 0,
77 Self::Q3K => 0,
78 Self::Q6K => 0,
79 }
80 }
81
82 fn from_safetensors(dtype: safetensors::Dtype) -> Result<Self> {
83 match dtype {
84 safetensors::Dtype::BF16 => Ok(Self::BF16),
85 safetensors::Dtype::F32 => Ok(Self::FP32),
86 safetensors::Dtype::U8 => Ok(Self::UInt8),
87 // I8: raw 1-byte container for 4-bit-packed NVFP4 (DeepSeek-V4 MTP
88 // experts). Treat as UInt8 — signedness is irrelevant for packed FP4.
89 safetensors::Dtype::I8 => Ok(Self::UInt8),
90 safetensors::Dtype::F8_E4M3 => Ok(Self::FP8E4M3),
91 safetensors::Dtype::F8_E8M0 => Ok(Self::FP8E8M0),
92 safetensors::Dtype::I64 => Ok(Self::Int64),
93 other => bail!("Unsupported safetensors dtype: {other:?}"),
94 }
95 }
96
97 /// Map a raw safetensors header dtype STRING (as it appears in the JSON
98 /// header, e.g. `"BF16"`, `"F8_E4M3"`) to a [`WeightDtype`], factored out
99 /// so the RDMA weight loader (which receives dtype as a wire string in the
100 /// peer manifest, not a `safetensors::Dtype`) resolves it identically to
101 /// the disk loaders — byte-identity depends on the two ends agreeing.
102 pub fn from_safetensors_str(s: &str) -> Result<Self> {
103 Ok(match s {
104 "F32" => Self::FP32,
105 "BF16" => Self::BF16,
106 "U8" => Self::UInt8,
107 // I8 is a 1-byte raw container (packed NVFP4); signedness is
108 // irrelevant, treat as raw bytes exactly like the disk path.
109 "I8" => Self::UInt8,
110 "F8_E4M3" => Self::FP8E4M3,
111 "F8_E8M0" => Self::FP8E8M0,
112 "I64" => Self::Int64,
113 other => bail!("Unsupported safetensors dtype '{other}'"),
114 })
115 }
116}
117
118/// Convert a little-endian IEEE-754 half-precision (F16) tensor byte buffer
119/// to BF16 bytes. F16 and BF16 are both 2 bytes/element but have different
120/// bit layouts (5-bit vs 8-bit exponent), so the bytes cannot be
121/// reinterpreted — each value goes f16 → f32 (exact) → bf16
122/// (round-to-nearest-even). Shared by both disk loaders so F16 checkpoints
123/// (e.g. centml modelopt W4A4 exports, which ship all unquantized tensors as
124/// F16) land in the store as BF16; [`WeightDtype`] itself stays closed to
125/// store-legal dtypes and F16 can never appear on the RDMA wire.
126pub(crate) fn f16_to_bf16_bytes(src: &[u8]) -> Vec<u8> {
127 use half::{bf16, f16};
128 debug_assert_eq!(src.len() % 2, 0, "F16 tensor byte length must be even");
129 let mut out = Vec::with_capacity(src.len());
130 for pair in src.chunks_exact(2) {
131 let h = f16::from_le_bytes([pair[0], pair[1]]);
132 out.extend_from_slice(&bf16::from_f32(h.to_f32()).to_le_bytes());
133 }
134 out
135}
136
137/// A weight tensor on the GPU.
138pub struct WeightTensor {
139 pub ptr: DevicePtr,
140 pub shape: Vec<usize>,
141 pub dtype: WeightDtype,
142}
143
144impl WeightTensor {
145 pub fn num_elements(&self) -> usize {
146 self.shape.iter().product()
147 }
148
149 pub fn byte_size(&self) -> usize {
150 match self.dtype {
151 // Packed Q2_0: `n_blocks = numel / group` blocks of
152 // `2 + group/4` bytes (34 @ g128, 18 @ g64) — the on-disk footprint.
153 WeightDtype::PackedQ2_0 { group } => {
154 let g = group as usize;
155 debug_assert!(g == 128 || g == 64, "unexpected Q2_0 group {g}");
156 let n_blocks = self.num_elements() / g.max(1);
157 n_blocks * (2 + g / 4)
158 }
159 WeightDtype::Q2K => self.num_elements() / 256 * 84,
160 WeightDtype::Q3K => self.num_elements() / 256 * 110,
161 WeightDtype::Q6K => self.num_elements() / 256 * 210,
162 d => self.num_elements() * d.byte_size(),
163 }
164 }
165
166 /// The Q2_0 group size if this tensor is keep-packed ternary, else `None`.
167 pub fn q2_group(&self) -> Option<u16> {
168 match self.dtype {
169 WeightDtype::PackedQ2_0 { group } => Some(group),
170 _ => None,
171 }
172 }
173
174 /// True if this tensor holds keep-packed ternary Q2_0 blocks (id 42).
175 pub fn is_packed_q2(&self) -> bool {
176 matches!(self.dtype, WeightDtype::PackedQ2_0 { .. })
177 }
178}
179
180/// All model weights loaded onto the GPU, keyed by HuggingFace name.
181pub struct WeightStore {
182 weights: HashMap<String, WeightTensor>,
183 prepartitioned_tp: Option<(usize, usize)>,
184 /// Buffers a loader derived from these tensors — fused concats, transposed
185 /// twins, requants. Owned here so teardown RELEASES them instead of the
186 /// backend sweep reclaiming them unowned (#736, #915); see `derived.rs`.
187 derived: DerivedStore,
188 /// Tensors deliberately NOT uploaded, with where they live on disk.
189 ///
190 /// The n-gram embedding tables of the LongCat / Qwen3.8-Flash-Next family
191 /// are 63 GB (LongCat-Lite) to ~102 GB (Flash-Next) of BF16. Uploading
192 /// them through the generic path would exhaust a 121 GB unified box
193 /// before any quantization could run — and on GB10 the fallback is
194 /// `alloc_managed`, i.e. Linux swap, i.e. the documented kernel freeze.
195 /// They are skipped at load and served either by streaming per-table
196 /// quantize-on-load or straight off NVMe by `NgramRowCache`, both of
197 /// which need only this (path, offset) locator.
198 deferred: HashMap<String, DeferredTensor>,
199}
200
201mod deferred;
202pub use deferred::{DeferHook, DeferredTensor};
203
204impl WeightStore {
205 /// Create an empty weight store (for testing).
206 pub fn empty() -> Self {
207 Self {
208 weights: HashMap::new(),
209 prepartitioned_tp: None,
210 deferred: HashMap::new(),
211 derived: DerivedStore::default(),
212 }
213 }
214
215 /// Record a tensor that was skipped at load, with its on-disk location.
216 pub fn defer(&mut self, name: String, t: DeferredTensor) {
217 self.deferred.insert(name, t);
218 }
219
220 /// Look up a deferred (not-uploaded) tensor's on-disk location.
221 pub fn deferred(&self, name: &str) -> Option<&DeferredTensor> {
222 self.deferred.get(name)
223 }
224
225 /// Every deferred tensor, name-sorted (NUMERIC on a trailing index, so
226 /// `embedders.10` sorts after `embedders.2` — a lexicographic sort here
227 /// silently mis-maps the n-gram tables, which cost a real debugging
228 /// session the first time).
229 pub fn deferred_sorted(&self) -> Vec<(&String, &DeferredTensor)> {
230 let mut v: Vec<_> = self.deferred.iter().collect();
231 v.sort_by_key(|(n, _)| split_trailing_index(n));
232 v
233 }
234
235 /// Wrap a pre-built map. Used by alternate loaders (e.g.
236 /// `fast_weights::FastSafetensorsLoader`, and the RDMA weight loader in
237 /// `spark-storage`, which lives in a different crate and so needs this pub).
238 pub fn from_map(weights: HashMap<String, WeightTensor>) -> Self {
239 Self {
240 weights,
241 prepartitioned_tp: None,
242 deferred: HashMap::new(),
243 derived: DerivedStore::default(),
244 }
245 }
246
247 /// Get a weight tensor by name. Fails fast if not found.
248 pub fn get(&self, name: &str) -> Result<&WeightTensor> {
249 self.weights
250 .get(name)
251 .ok_or_else(|| anyhow::anyhow!("Weight '{name}' not found in store"))
252 }
253
254 /// Check if a weight exists.
255 pub fn contains(&self, name: &str) -> bool {
256 self.weights.contains_key(name)
257 }
258
259 /// Number of loaded weights.
260 pub fn len(&self) -> usize {
261 self.weights.len()
262 }
263
264 /// True if no weights are loaded.
265 pub fn is_empty(&self) -> bool {
266 self.weights.is_empty()
267 }
268
269 /// Device bytes the store still holds. Not the on-disk load estimate:
270 /// this shrinks as `free_matching` drops tensors the binders replaced.
271 pub fn resident_bytes(&self) -> usize {
272 self.weights.values().map(|t| t.byte_size()).sum()
273 }
274
275 /// Iterator over all weight names.
276 pub fn names(&self) -> impl Iterator<Item = &str> {
277 self.weights.keys().map(|s| s.as_str())
278 }
279
280 /// Free and forget every tensor whose name matches `pred`. Returns
281 /// `(tensors freed, bytes freed)`.
282 ///
283 /// For loaders that do NOT bind zero-copy from the store's device pointers:
284 /// they upload their own copy, so the original is dead weight the moment the
285 /// binder returns, and on a unified-memory GB10 that duplicate is the
286 /// difference between fitting a KV cache and not.
287 ///
288 /// 🪤 The caller owns the "is it dead?" question. A tensor bound zero-copy
289 /// (every routed expert, and the fused per-expert views in
290 /// `weight_loader/step3p7.rs`) is still live in a layer struct — freeing it
291 /// here is a use-after-free with no diagnostic. Match narrowly.
292 ///
293 /// Per-entry free is sound for the same reason `release` gives below: the
294 /// loaders allocate one `gpu.alloc` per tensor, and no loader inserts an
295 /// `.offset()` view of a shared block into this map.
296 pub fn free_matching(
297 &mut self,
298 gpu: &dyn GpuBackend,
299 pred: impl Fn(&str) -> bool,
300 ) -> Result<(usize, usize)> {
301 let doomed: Vec<String> = self.weights.keys().filter(|n| pred(n)).cloned().collect();
302 let (mut count, mut bytes) = (0usize, 0usize);
303 for name in doomed {
304 // `remove` before `free`: the map must never hold a pointer to
305 // memory that is gone, even if the free below fails.
306 let Some(t) = self.weights.remove(&name) else {
307 continue;
308 };
309 bytes += t.byte_size();
310 gpu.free(t.ptr)
311 .map_err(|e| e.context(format!("freeing weight {name}")))?;
312 count += 1;
313 }
314 Ok((count, bytes))
315 }
316
317 /// The owner for buffers a loader derives from these tensors.
318 ///
319 /// `&self` because `ModelWeightLoader::load_layers` takes `&WeightStore`;
320 /// the interior `Mutex` is the whole reason `DerivedStore` exists as a
321 /// type rather than a `Vec` field. See `weights/derived.rs`.
322 pub fn derived(&self) -> &DerivedStore {
323 &self.derived
324 }
325
326 /// Total bytes across all weight tensors on the GPU.
327 pub fn total_bytes(&self) -> usize {
328 self.weights.values().map(|w| w.byte_size()).sum()
329 }
330
331 /// Check if any tensor has FP8 dtype.
332 pub fn has_fp8_weights(&self) -> bool {
333 self.weights
334 .values()
335 .any(|w| matches!(w.dtype, WeightDtype::FP8E4M3))
336 }
337
338 /// Number of per-layer FP8 KV-cache scale tensors (`*.k_scale`) the
339 /// checkpoint ships. `>0` means the model carries calibrated KV scales, so
340 /// FP8 KV needs no online calibration; `0` means the scales default to 1.0
341 /// (which clips BF16 into E4M3 range), so online calibration or a non-FP8 KV
342 /// dtype is required. Used to log the right guidance at serve time.
343 pub fn fp8_kv_scale_count(&self) -> usize {
344 self.names().filter(|n| n.ends_with(".k_scale")).count()
345 }
346}
347
348/// SBIO IORouter trait for weight loading.
349pub trait WeightLoader {
350 fn load(
351 &self,
352 model_dir: &Path,
353 gpu: &dyn GpuBackend,
354 oom_reserve_bytes: usize,
355 ) -> Result<WeightStore>;
356}
357
358/// Loads weights from safetensors files using mmap.
359pub struct SafetensorsLoader {
360 /// EP rank (0-based). Only used when ep_world_size > 1.
361 pub ep_rank: usize,
362 /// EP world size. When > 1, remote expert tensors are skipped.
363 pub ep_world_size: usize,
364 /// Total number of MoE experts in the model (for EP partitioning).
365 pub num_experts: usize,
366 /// Override for the peak memory multiplier in the pre-flight OOM check.
367 /// Set from QuantFormat::peak_memory_multiplier() in the caller.
368 /// When None, the pre-flight uses its own heuristic (1.3x NVFP4 / 1.5x FP8).
369 pub peak_memory_multiplier: Option<f64>,
370 /// Skip the W4A4 `*.input_scale` activation scales at load.
371 ///
372 /// ModelOpt NVFP4 checkpoints ship one 0-dim F32 scalar per quantized
373 /// projection. On a 512-expert model that is ~74k four-byte allocations,
374 /// each taking a full allocation granule — GBs of padding for values
375 /// Atlas never reads, because it serves w4a16 (BF16 activations) and the
376 /// NVFP4 loader already treats the key as optional.
377 ///
378 /// OPT-IN: `step3p7` reads this key on its own path, so it must stay off
379 /// unless the model's loader is known not to need it.
380 pub skip_activation_scales: bool,
381 /// Skip `mtp.*` tensors at load.
382 ///
383 /// For models whose loader deliberately does not build an MTP head,
384 /// uploading its weights is pure waste — on Qwen3.8-Flash-Next that is a
385 /// 1.49 GB expert shard plus the MTP backbone, held resident while the KV
386 /// cache goes without.
387 ///
388 /// OPT-IN: a model that DOES build an MTP head must keep them, so this is
389 /// set only where `load_mtp_weights` is known to return `None`.
390 pub skip_mtp: bool,
391 /// Tensors the MODEL's weight loader will read from disk itself, so this
392 /// loader must record their location instead of uploading them. See
393 /// [`DeferHook`] for the contract and why a loader asks for it.
394 pub defer: Option<DeferHook>,
395}
396
397impl Default for SafetensorsLoader {
398 fn default() -> Self {
399 Self::new()
400 }
401}
402
403impl SafetensorsLoader {
404 /// Create a loader with no expert parallelism (loads all tensors).
405 pub fn new() -> Self {
406 Self {
407 ep_rank: 0,
408 ep_world_size: 1,
409 num_experts: 0,
410 peak_memory_multiplier: None,
411 skip_activation_scales: false,
412 skip_mtp: false,
413 defer: None,
414 }
415 }
416
417 /// Create a loader with EP-aware filtering.
418 pub fn with_ep(ep_rank: usize, ep_world_size: usize, num_experts: usize) -> Self {
419 Self {
420 ep_rank,
421 ep_world_size,
422 num_experts,
423 peak_memory_multiplier: None,
424 skip_activation_scales: false,
425 skip_mtp: false,
426 defer: None,
427 }
428 }
429
430 /// Does the model's loader claim this tensor? `false` when no hook is set,
431 /// which is every model but the ones that opt in.
432 ///
433 /// 🪤 Consulted only for tensors `should_skip_tensor` KEPT — deferring a
434 /// tensor this rank was never going to load would put a remote expert's
435 /// location in the store and invite a binder to read it.
436 pub fn is_deferred(&self, name: &str, dtype: WeightDtype) -> bool {
437 self.defer.as_ref().is_some_and(|f| f(name, dtype))
438 }
439
440 /// Check if a tensor should be skipped under EP.
441 /// Skips `*.experts.{E}.*` tensors where E is not in local range.
442 /// MTP head experts are never skipped (small, fully replicated).
443 ///
444 /// 🪤 The MTP exemption keys on a leading `mtp.` — a DeepSeek-style name.
445 /// GLM-5.3 puts its MTP head at `model.language_model.layers.45.*` with no
446 /// `mtp.` prefix, so that layer's routed experts ARE sharded on GLM. Fine
447 /// while the MTP head is out of scope; revisit before enabling it.
448 ///
449 /// `pub` so residency can be PROVEN against a real checkpoint index
450 /// without collectives (see `spark-model/tests/glm53_ep_residency.rs`).
451 pub fn should_skip_tensor(&self, name: &str) -> bool {
452 // MTP head weights for a model whose loader does not build one.
453 if self.skip_mtp && name.starts_with("mtp.") {
454 return true;
455 }
456 // W4A4 activation scales: never read on the w4a16 path (the NVFP4
457 // loader falls back to `DevicePtr::NULL`), and 4-byte allocations are
458 // almost pure granule padding at expert scale.
459 if self.skip_activation_scales && name.ends_with(".input_scale") {
460 return true;
461 }
462 if self.ep_world_size <= 1 {
463 return false;
464 }
465 // MTP head experts are small — always replicate, never shard.
466 if name.starts_with("mtp.") {
467 return false;
468 }
469 // Parse expert index from patterns like "*.experts.42.gate_proj*"
470 if let Some(idx) = parse_expert_index(name) {
471 let per_rank = self.num_experts / self.ep_world_size;
472 let local_start = self.ep_rank * per_rank;
473 let local_end = if self.ep_rank == self.ep_world_size - 1 {
474 self.num_experts
475 } else {
476 local_start + per_rank
477 };
478 idx < local_start || idx >= local_end
479 } else {
480 false // Non-expert tensors are always loaded (replicated)
481 }
482 }
483}
484
485/// Split a tensor name into (everything but its last numeric path segment,
486/// that segment as a number) so names sort NUMERICALLY on the index.
487/// `embedders.2` must precede `embedders.10`; a plain lexicographic sort puts
488/// `10` first and silently mis-maps every table after the ninth.
489pub mod adapter;
490mod derived;
491pub use derived::DerivedStore;
492mod gguf;
493mod k3;
494mod loader;
495pub use k3::K3SafetensorsLoader;
496pub mod mlx_int8;
497pub use gguf::dequant_cpu;
498pub use gguf::expert_stream;
499pub use gguf::{GgufLoader, GgufShardSet, config_from_gguf_dir, find_gguf, find_gguf_shards};
500pub(crate) use loader::estimate_load_bytes;
501// Platform-independent: consumed by the unix-only fast-weights (O_DIRECT) path
502// AND by the GGUF loader, which builds everywhere. Gating this on `unix` broke
503// the Windows CUDA build the moment `gguf.rs` started using it.
504pub(crate) use loader::check_oom_guard;
505// Consumed by the unix-only fast-weights (O_DIRECT) loader path.
506#[cfg(unix)]
507pub(crate) use loader::estimate_has_fp8;
508
509mod name_utils;
510pub(crate) use name_utils::split_trailing_index;
511pub use name_utils::{is_ngram_table, parse_expert_index};
512
513#[cfg(test)]
514mod packed_q2_tests;
515mod prefix_detect;
516pub use prefix_detect::auto_detect_weight_prefix;
517
518mod release;
519
520#[cfg(test)]
521mod teardown_tests;