metrale_model_weights/fast_weights/mod.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Fast safetensors loader (InstantTensor-style) — pure Rust.
4//!
5//! Two wins over the mmap-based loader in [`crate::weights`]:
6//!
7//! 1. **`O_DIRECT`** reads. Bypasses the OS page cache, so the bytes never
8//! compete with GPU allocations on GB10 unified memory. The mmap path
9//! already works around this with `POSIX_FADV_DONTNEED` post-load; here
10//! we avoid the pollution in the first place.
11//! 2. **Pipelined read/copy**. One background reader thread fetches the
12//! next tensor while the main thread does `copy_h2d` for the current
13//! one. Overlaps disk I/O with the host→device memcpy.
14//!
15//! Behavioural parity with [`crate::weights::SafetensorsLoader`] is
16//! preserved — same EP filtering, same OOM pre-flight, same UVM fallback
17//! on GPU allocation failure, same extra-weights handling.
18
19use crate::weights::{
20 WeightLoader, WeightStore, WeightTensor, check_oom_guard, estimate_has_fp8,
21 estimate_load_bytes, evict_page_cache, f16_to_bf16_bytes,
22};
23use anyhow::{Context, Result, bail};
24use metrale_gpu_runtime::gpu::GpuBackend;
25use std::collections::HashMap;
26use std::fs::File;
27use std::path::{Path, PathBuf};
28use std::sync::mpsc::sync_channel;
29
30mod direct_io;
31mod header;
32
33use header::{parse_header, resolve_shards};
34
35/// Pure-Rust InstantTensor-style loader. Same public shape as
36/// [`crate::weights::SafetensorsLoader`].
37pub struct FastSafetensorsLoader {
38 pub ep_rank: usize,
39 pub ep_world_size: usize,
40 pub num_experts: usize,
41 pub peak_memory_multiplier: Option<f64>,
42 /// Skip the W4A4 `*.input_scale` activation scales at load.
43 ///
44 /// ModelOpt NVFP4 checkpoints ship one 0-dim F32 scalar per quantized
45 /// projection. On a 512-expert model that is ~74k four-byte allocations,
46 /// each taking a full allocation granule — GBs of padding for values
47 /// Metrale Engine never reads, because it serves w4a16 (BF16 activations) and the
48 /// NVFP4 loader already treats the key as optional.
49 ///
50 /// OPT-IN: `step3p7` reads this key on its own path, so it must stay off
51 /// unless the model's loader is known not to need it.
52 pub skip_activation_scales: bool,
53 /// Skip `mtp.*` tensors at load.
54 ///
55 /// For models whose loader deliberately does not build an MTP head,
56 /// uploading its weights is pure waste — on Qwen3.8-Flash-Next that is a
57 /// 1.49 GB expert shard plus the MTP backbone, held resident while the KV
58 /// cache goes without.
59 ///
60 /// OPT-IN: a model that DOES build an MTP head must keep them, so this is
61 /// set only where `load_mtp_weights` is known to return `None`.
62 pub skip_mtp: bool,
63 /// When true (default), attempt `O_DIRECT`; fall back to buffered reads if
64 /// the filesystem rejects it (tmpfs, overlayfs, some FUSE backends).
65 pub try_direct_io: bool,
66 /// Per-shard heuristic cap: if a shard's tensor count exceeds this,
67 /// we skip `O_DIRECT` for that shard and fall back to buffered +
68 /// pipelined reads even when [`Self::try_direct_io`] is `true`.
69 ///
70 /// Motivation: `O_DIRECT`'s 4 KiB-aligned per-tensor `pread` has a
71 /// fixed syscall + copy overhead that kernel readahead amortises for
72 /// free on the buffered path. Benchmarks on GB10 showed buffered wins
73 /// above ~5k tensors/shard; O_DIRECT wins below. Set to [`usize::MAX`]
74 /// to disable.
75 pub direct_io_tensor_cap: usize,
76 /// When true, advise the kernel to read a whole buffered shard
77 /// sequentially before the per-tensor copy loop starts. This helps NFS
78 /// mounts where many small tensor reads defeat normal readahead.
79 pub prefetch_shards: bool,
80 /// Skip a multimodal checkpoint's vision tower.
81 ///
82 /// Set by the caller from `ModelWeightLoader::binds_vision_encoder()`:
83 /// false by default, true only when the model's loader is a text-only
84 /// port that will never bind the tower. Reading it anyway costs the full
85 /// tower in unified memory (1.05 GiB/rank on GLM-5.3's checkpoint) from
86 /// load time until `build_model` frees it — which is after the inference
87 /// -buffer preflight has already refused the serve.
88 pub skip_vision: bool,
89 /// Tensors the MODEL's weight loader will read from disk itself, so this
90 /// loader records their location instead of uploading them.
91 ///
92 /// Set by the caller from `ModelWeightLoader::defer_predicate()`, the same
93 /// way `skip_vision` comes from `binds_vision_encoder()`. `None` for every
94 /// model that does not opt in. See [`crate::weights::DeferHook`] for the
95 /// contract — including the half that says the pre-flight must agree.
96 pub defer: Option<crate::weights::DeferHook>,
97}
98
99/// Is this tensor part of a multimodal checkpoint's vision tower?
100///
101/// Same three spellings `build_model`'s unbound-tower reclaim matches, kept
102/// here so the load-time skip and the post-bind free can never disagree.
103pub fn is_vision_tensor(name: &str) -> bool {
104 name.starts_with("model.visual.")
105 || name.starts_with("model.vision")
106 || name.starts_with("visual.")
107}
108
109/// Default tensor-count cap for per-shard `O_DIRECT`. Above this, the fast
110/// loader uses buffered reads even when `try_direct_io = true`. See the
111/// field doc on [`FastSafetensorsLoader::direct_io_tensor_cap`].
112pub const DEFAULT_DIRECT_IO_TENSOR_CAP: usize = 5000;
113
114impl Default for FastSafetensorsLoader {
115 fn default() -> Self {
116 Self::new()
117 }
118}
119
120mod load;
121#[path = "skip.rs"]
122mod skip;
123
124impl FastSafetensorsLoader {
125 pub fn new() -> Self {
126 Self {
127 ep_rank: 0,
128 ep_world_size: 1,
129 num_experts: 0,
130 peak_memory_multiplier: None,
131 skip_activation_scales: false,
132 skip_mtp: false,
133 try_direct_io: true,
134 direct_io_tensor_cap: DEFAULT_DIRECT_IO_TENSOR_CAP,
135 prefetch_shards: false,
136 skip_vision: false,
137 defer: None,
138 }
139 }
140
141 pub fn with_ep(ep_rank: usize, ep_world_size: usize, num_experts: usize) -> Self {
142 Self {
143 ep_rank,
144 ep_world_size,
145 num_experts,
146 peak_memory_multiplier: None,
147 skip_activation_scales: false,
148 skip_mtp: false,
149 try_direct_io: true,
150 direct_io_tensor_cap: DEFAULT_DIRECT_IO_TENSOR_CAP,
151 prefetch_shards: false,
152 skip_vision: false,
153 defer: None,
154 }
155 }
156}
157
158/// Load a single shard with O_DIRECT + pipelined read/copy.
159///
160/// Pipeline:
161/// reader thread: pread tensor N into aligned buffer → sync_channel ──▶
162/// main thread: recv → copy_h2d → store tensor
163///
164/// The channel has capacity 1, so at any time the reader is ≤1 tensor
165/// ahead of the copier. Memory overhead per shard: 2 × max_tensor_bytes
166/// (rounded up to O_DIRECT alignment).
167#[allow(clippy::too_many_arguments)]
168fn load_shard_fast(
169 shard_path: &Path,
170 tensor_filter: Option<&[String]>,
171 gpu: &dyn GpuBackend,
172 skip_fn: &dyn Fn(&str) -> bool,
173 defer_fn: &dyn Fn(&str, crate::weights::WeightDtype) -> bool,
174 try_direct_io: bool,
175 direct_io_tensor_cap: usize,
176 prefetch_shards: bool,
177 out: &mut HashMap<String, WeightTensor>,
178 deferred_out: &mut HashMap<String, crate::weights::DeferredTensor>,
179 offload_logged: &mut bool,
180) -> Result<()> {
181 // Header parsing uses a buffered fd — header is a few KB, cache pollution
182 // is negligible and buffered I/O handles short reads cleanly.
183 let mut meta_file = File::open(shard_path)
184 .with_context(|| format!("Failed to open {}", shard_path.display()))?;
185 let mut tensors = parse_header(&mut meta_file)?;
186 let file_size = meta_file.metadata()?.len();
187
188 // Filter down to tensors we actually want (index filter + EP filter).
189 if let Some(allow) = tensor_filter {
190 let allow_set: std::collections::HashSet<&str> = allow.iter().map(|s| s.as_str()).collect();
191 tensors.retain(|t| allow_set.contains(t.name.as_str()));
192 }
193 // The n-gram embedding TABLES are never uploaded with the checkpoint —
194 // 63 GB (LongCat-Lite) to ~102 GB (Flash-Next) of BF16 would exhaust a
195 // 121 GB unified box before any quantization could run, and the fallback
196 // on GB10 is managed memory, i.e. Linux swap, i.e. a kernel freeze. They
197 // are recorded with their on-disk location and served either by streaming
198 // per-table quantize-on-load or straight off NVMe by the row cache.
199 //
200 // The MODEL's loader can claim tensors too (`defer_fn`), for the other
201 // reason a tensor must not be swept: the bytes that reach the device are
202 // not the bytes on disk. `nvidia/GLM-5.3-Flash-NVFP4` ships the MTP
203 // block's routed experts full-width BF16 against a w4a16-only forward, so
204 // every one of them would be uploaded, read back, quantised and freed —
205 // ~7.25 GB per EP=2 rank resident across the whole 45-layer build.
206 let mut deferred_here: Vec<(String, crate::weights::DeferredTensor)> = Vec::new();
207 let mut ngram_count = 0usize;
208 #[allow(clippy::items_after_statements)]
209 tensors.retain(|t| {
210 let mut defer = |t: &header::TensorMeta| {
211 deferred_here.push((
212 t.name.clone(),
213 crate::weights::DeferredTensor {
214 path: shard_path.to_path_buf(),
215 offset: t.abs_offset,
216 shape: t.shape.clone(),
217 dtype: t.dtype,
218 },
219 ));
220 };
221 if crate::weights::is_ngram_table(&t.name) {
222 defer(t);
223 ngram_count += 1;
224 return false;
225 }
226 // 🪤 The model hook is asked only about tensors this rank KEEPS. Asked
227 // first, it would record a remote expert's on-disk location and invite
228 // a binder to read weights EP gave to another rank.
229 if skip_fn(&t.name) {
230 return false;
231 }
232 // 🪤 `from_f16` is not deferrable: the store's BF16 is a REWRITE of the
233 // disk bytes, so a (path, offset) locator would hand its reader F16.
234 // `estimate_load_bytes` refuses the same case, so the two agree.
235 if !t.from_f16 && defer_fn(&t.name, t.dtype) {
236 defer(t);
237 return false;
238 }
239 true
240 });
241 if !deferred_here.is_empty() {
242 tracing::info!(
243 "Deferred {} tensor(s) in {} ({} n-gram table(s), {} claimed by the model's \
244 weight loader) — served from disk, not uploaded",
245 deferred_here.len(),
246 shard_path.display(),
247 ngram_count,
248 deferred_here.len() - ngram_count,
249 );
250 deferred_out.extend(deferred_here);
251 }
252
253 // Per-shard heuristic: above `direct_io_tensor_cap` tensors, O_DIRECT's
254 // per-tensor syscall + 4 KiB alignment overhead costs more than kernel
255 // readahead on the buffered path saves. Skip the direct-open attempt
256 // entirely in that case — keeps the log clean and avoids a wasted fd.
257 let wants_direct = try_direct_io && tensors.len() <= direct_io_tensor_cap;
258 if try_direct_io && !wants_direct {
259 tracing::info!(
260 " Shard has {} tensors (> {} cap) — using buffered+pipelined path",
261 tensors.len(),
262 direct_io_tensor_cap
263 );
264 }
265
266 // File for data reads. Try O_DIRECT; if it fails, fall through to buffered.
267 let (direct_file, using_direct) = match wants_direct
268 .then(|| direct_io::open_direct(shard_path))
269 .transpose()
270 {
271 Ok(Some(f)) => (Some(f), true),
272 Ok(None) => (None, false),
273 Err(e) => {
274 tracing::warn!(
275 "O_DIRECT open failed for {} ({e}); falling back to buffered reads",
276 shard_path.display()
277 );
278 (None, false)
279 }
280 };
281 let buffered_file = File::open(shard_path)?;
282 let data_fd = direct_file.as_ref().unwrap_or(&buffered_file);
283 if prefetch_shards && !using_direct {
284 advise_prefetch_shard(&buffered_file, shard_path, file_size);
285 }
286
287 // Pipelined reader: sends (tensor_index, aligned_buffer, slice_start) to main.
288 type ReadMsg = (usize, direct_io::AlignedBuffer, usize);
289 let (tx, rx) = sync_channel::<Result<ReadMsg>>(1);
290 let tensors_for_reader: Vec<(u64, usize)> =
291 tensors.iter().map(|t| (t.abs_offset, t.len)).collect();
292 let raw_fd = {
293 use std::os::unix::io::AsRawFd;
294 data_fd.as_raw_fd()
295 };
296
297 let _ = file_size; // retained for future use (tail-fragment buffered read)
298 let reader_handle = std::thread::spawn(move || {
299 for (idx, (abs_offset, len)) in tensors_for_reader.iter().enumerate() {
300 let msg = direct_io::read_tensor_aligned(raw_fd, *abs_offset, *len, using_direct)
301 .map(|(buf, slice_start)| (idx, buf, slice_start));
302 if tx.send(msg).is_err() {
303 break; // receiver dropped
304 }
305 }
306 });
307
308 // Copier: drains the channel, does gpu alloc + copy_h2d, inserts into the map.
309 for result in rx {
310 let (idx, buf, slice_start) = result?;
311 let meta = &tensors[idx];
312 let raw = &buf.as_slice()[slice_start..slice_start + meta.len];
313 // F16 shards: convert bytes to BF16 before upload (same length,
314 // different bit layout — meta.dtype is already staged as BF16).
315 let converted: Vec<u8>;
316 let src: &[u8] = if meta.from_f16 {
317 converted = f16_to_bf16_bytes(raw);
318 &converted
319 } else {
320 raw
321 };
322
323 let ptr = match gpu.alloc(meta.len) {
324 Ok(p) => {
325 gpu.copy_h2d(src, p)?;
326 p
327 }
328 Err(_) => {
329 if !*offload_logged {
330 tracing::warn!(
331 "GPU alloc failed for {} ({} bytes) — switching to managed (UVM) memory",
332 meta.name,
333 meta.len
334 );
335 *offload_logged = true;
336 }
337 let p = gpu.alloc_managed(meta.len)?;
338 unsafe {
339 std::ptr::copy_nonoverlapping(src.as_ptr(), p.0 as *mut u8, meta.len);
340 }
341 p
342 }
343 };
344
345 out.insert(
346 meta.name.clone(),
347 WeightTensor {
348 ptr,
349 shape: meta.shape.clone(),
350 dtype: meta.dtype,
351 },
352 );
353 }
354
355 reader_handle
356 .join()
357 .map_err(|_| anyhow::anyhow!("reader thread panicked"))?;
358
359 // Release file handles, then advise the kernel to drop any pages we did
360 // end up caching on the buffered fallback path. O_DIRECT reads never hit
361 // the page cache, so the posix_fadvise is a no-op there but cheap.
362 drop(direct_file);
363 evict_page_cache(&buffered_file);
364 drop(buffered_file);
365 Ok(())
366}
367
368#[cfg(target_os = "linux")]
369fn advise_prefetch_shard(file: &File, shard_path: &Path, file_size: u64) {
370 use std::os::unix::io::AsRawFd;
371
372 let fd = file.as_raw_fd();
373 let seq_rc = unsafe { libc::posix_fadvise(fd, 0, 0, libc::POSIX_FADV_SEQUENTIAL) };
374 let willneed_rc = unsafe { libc::posix_fadvise(fd, 0, 0, libc::POSIX_FADV_WILLNEED) };
375 if seq_rc == 0 && willneed_rc == 0 {
376 tracing::info!(
377 " NFS/shard prefetch requested for {} ({:.2} GB)",
378 shard_path.display(),
379 file_size as f64 / (1024.0 * 1024.0 * 1024.0)
380 );
381 } else {
382 tracing::warn!(
383 " NFS/shard prefetch hint failed for {}: sequential_rc={}, willneed_rc={}",
384 shard_path.display(),
385 seq_rc,
386 willneed_rc
387 );
388 }
389}
390
391#[cfg(not(target_os = "linux"))]
392fn advise_prefetch_shard(_file: &File, _shard_path: &Path, _file_size: u64) {}
393
394#[cfg(test)]
395mod skip_vision_tests {
396 use super::{FastSafetensorsLoader, is_vision_tensor};
397
398 fn loader(skip_vision: bool, ep: usize) -> FastSafetensorsLoader {
399 let mut l = FastSafetensorsLoader::with_ep(0, ep, 288);
400 l.skip_vision = skip_vision;
401 l
402 }
403
404 #[test]
405 fn vision_names_are_recognised() {
406 assert!(is_vision_tensor("model.visual.blocks.0.attn.proj.weight"));
407 assert!(is_vision_tensor(
408 "model.vision_tower.encoder.layer.0.weight"
409 ));
410 assert!(is_vision_tensor("visual.merger.proj.weight"));
411 assert!(!is_vision_tensor(
412 "model.language_model.layers.45.eh_proj.weight"
413 ));
414 // The trap: a text tensor whose name merely CONTAINS "vision".
415 assert!(!is_vision_tensor(
416 "model.language_model.layers.3.mlp.revision.weight"
417 ));
418 }
419
420 #[test]
421 fn skip_vision_drops_only_the_tower() {
422 let l = loader(true, 2);
423 assert!(l.should_skip_tensor("model.visual.blocks.0.attn.proj.weight"));
424 assert!(!l.should_skip_tensor("model.language_model.layers.45.eh_proj.weight"));
425 assert!(!l.should_skip_tensor("lm_head.weight"));
426 }
427
428 #[test]
429 fn skip_vision_applies_without_ep() {
430 // The EP short-circuit must not swallow the vision rule at ep=1.
431 let l = loader(true, 1);
432 assert!(l.should_skip_tensor("model.visual.blocks.0.attn.proj.weight"));
433 assert!(!l.should_skip_tensor("model.layers.0.self_attn.q_proj.weight"));
434 }
435
436 #[test]
437 fn default_loader_keeps_the_tower() {
438 let l = loader(false, 2);
439 assert!(!l.should_skip_tensor("model.visual.blocks.0.attn.proj.weight"));
440 assert!(!FastSafetensorsLoader::new().skip_vision);
441 }
442
443 #[test]
444 fn ep_expert_filtering_is_unchanged_by_the_vision_rule() {
445 let l = loader(true, 2); // ep_rank 0 of 2, 288 experts -> keeps 0..143
446 assert!(
447 !l.should_skip_tensor("model.language_model.layers.4.mlp.experts.7.up_proj.weight")
448 );
449 assert!(
450 l.should_skip_tensor("model.language_model.layers.4.mlp.experts.200.up_proj.weight")
451 );
452 }
453}