spark_runtime/weights/deferred.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Tensors the checkpoint loader deliberately does NOT upload, and the hook a
4//! model's own weight loader uses to say so.
5//!
6//! # Two ways a tensor becomes deferred
7//!
8//! 1. **`is_ngram_table`** — a NAME rule baked into the runtime, for the
9//! LongCat / Qwen3.8-Flash-Next embedding tables. Always on, because those
10//! tables (63 GB to 102 GB) cannot be uploaded on any box we serve.
11//! 2. **[`DeferHook`]** — a predicate the MODEL's loader supplies, keyed on
12//! `(name, on-disk dtype)`. Off unless a loader asks for it, and plumbed
13//! exactly like `skip_vision`: `serve_phases/weights.rs` asks
14//! `ModelWeightLoader::defer_predicate` once and hands the answer to
15//! whichever checkpoint loader runs.
16//!
17//! Both end in the same place — a [`DeferredTensor`] in the store's
18//! `deferred` map — so a consumer never has to know which rule withheld it.
19//!
20//! # Why a loader would ask for this
21//!
22//! The rule is NOT "this tensor is huge". It is "the bytes that reach the
23//! device are not the bytes on disk, and the model's loader is the only thing
24//! that knows how to make them".
25//!
26//! `nvidia/GLM-5.3-Flash-NVFP4` ships the MTP block's 288 routed experts as
27//! full-width BF16 while the forward is w4a16 end to end. Every one of those
28//! tensors is read once, by `glm5_next_load::bind_expert`, quantised, and
29//! replaced by an NVFP4 triple ~3.6x smaller. Uploading the BF16 first is
30//! ~7.25 GB per EP=2 rank of device residency that lasts from the sweep until
31//! `prune_after_load` — i.e. across the WHOLE 45-layer build, which on a
32//! unified-memory box is where the host-memory guard fires. Measured
33//! 2026-09-21 on a 2-rank boot: survived at a 2048-token shape (host floor
34//! 1.74 GB), killed rank 0 at layer 38 at the 32K shape (935 MB against a
35//! 1200 MB floor) BEFORE the MTP bind could free anything.
36//!
37//! Deferring makes that transient structurally impossible: the BF16 never
38//! leaves the page cache, and the only expert bytes that reach the device are
39//! the NVFP4 ones, for the experts this rank owns.
40//!
41//! # Contract, both halves of it
42//!
43//! * The loader that defers a tensor MUST read it back — nothing else will.
44//! A predicate that over-matches withholds a tensor a binder expects, and
45//! `WeightStore::get` then fails by NAME at bind time rather than producing
46//! a subtly wrong model. That is the intended failure mode; it is still the
47//! loader author's job not to reach it.
48//! * The OOM pre-flight MUST agree with the predicate. A deferred tensor is
49//! never part of the sweep peak, so counting it at its on-disk size refuses
50//! loads that fit — the same reasoning that already excludes the n-gram
51//! tables. The pre-flight and the retain loop are handed the SAME closure
52//! for exactly this reason.
53//!
54//! # 🪤 Keyed on the ON-DISK dtype, and F16 is never offered
55//!
56//! A deferred tensor is served from its own bytes in the shard, so the
57//! predicate is asked about the width those bytes actually have. F16 is the
58//! one width both disk loaders REWRITE on the way to the store
59//! (`f16_to_bf16_bytes`), so a (path, offset) locator would hand its reader
60//! F16 where the rest of the engine expects BF16. F16 tensors are therefore
61//! never deferred — by the retain loop and by the pre-flight alike, which is
62//! the only way the two can stay the same rule.
63
64use anyhow::{Context, Result};
65
66use super::WeightDtype;
67
68/// A model loader's answer to "which tensors will I read from disk myself?".
69///
70/// `(tensor name, store dtype) -> defer`. `Arc` because both checkpoint
71/// loaders hold it for the length of a load and the fast loader's pre-flight
72/// and retain loop both call it; `Send + Sync` because the fast loader's
73/// pipeline is threaded.
74pub type DeferHook = std::sync::Arc<dyn Fn(&str, WeightDtype) -> bool + Send + Sync>;
75
76/// Where a skipped tensor lives, so a consumer can read it in place.
77#[derive(Clone, Debug)]
78pub struct DeferredTensor {
79 /// Shard file containing the tensor.
80 pub path: std::path::PathBuf,
81 /// ABSOLUTE byte offset of the tensor's first element in that file
82 /// (safetensors header length + the tensor's `data_offsets[0]`).
83 pub offset: u64,
84 pub shape: Vec<usize>,
85 pub dtype: WeightDtype,
86}
87
88impl DeferredTensor {
89 /// The tensor's on-disk footprint.
90 ///
91 /// Only the fixed-width dtypes a safetensors header can carry reach here,
92 /// so `numel * byte_size` is exact — the block-based GGUF dtypes
93 /// ([`WeightDtype::PackedQ2_0`] and the K-quants) report 0 bytes/element
94 /// and are never deferred by either rule.
95 pub fn byte_size(&self) -> usize {
96 let numel: usize = self.shape.iter().product();
97 numel * self.dtype.byte_size()
98 }
99
100 /// Read the whole tensor's raw bytes into host memory.
101 ///
102 /// The consumer for a deferred tensor is usually a row cache that faults
103 /// single rows off NVMe; this is the other shape of consumer — a loader
104 /// that needs the tensor ONCE, at bind time, to derive the buffer it will
105 /// actually upload.
106 ///
107 /// 🪤 `read_exact` rather than `read_to_end`: a short read means the shard
108 /// is truncated or the offset is wrong, and both must be an error here
109 /// rather than a shorter tensor whose element count is rejected later with
110 /// a message pointing at the wrong thing. Seek-then-read rather than
111 /// `pread`, because `WeightStore` builds on Windows too.
112 pub fn read_host_bytes(&self) -> Result<Vec<u8>> {
113 use std::io::{Read, Seek, SeekFrom};
114 let n = self.byte_size();
115 let mut f = std::fs::File::open(&self.path)
116 .with_context(|| format!("deferred tensor: opening {}", self.path.display()))?;
117 f.seek(SeekFrom::Start(self.offset))?;
118 let mut buf = vec![0u8; n];
119 f.read_exact(&mut buf).with_context(|| {
120 format!(
121 "deferred tensor: reading {n} B at offset {} of {}",
122 self.offset,
123 self.path.display()
124 )
125 })?;
126 Ok(buf)
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 fn write_shard(bytes: &[u8]) -> (tempfile::TempDir, std::path::PathBuf) {
135 let dir = tempfile::tempdir().unwrap();
136 let path = dir.path().join("shard.safetensors");
137 std::fs::write(&path, bytes).unwrap();
138 (dir, path)
139 }
140
141 #[test]
142 fn byte_size_is_the_on_disk_footprint() {
143 let d = DeferredTensor {
144 path: std::path::PathBuf::from("x"),
145 offset: 0,
146 shape: vec![2048, 4096],
147 dtype: WeightDtype::BF16,
148 };
149 assert_eq!(d.byte_size(), 2048 * 4096 * 2);
150 }
151
152 #[test]
153 fn host_read_returns_exactly_the_tensor_at_its_offset() {
154 // 8 filler bytes stand in for a header, then four BF16 values.
155 let payload: Vec<u8> = vec![1, 2, 3, 4, 5, 6, 7, 8];
156 let (_d, path) = write_shard(&[vec![0xFFu8; 8], payload.clone()].concat());
157 let d = DeferredTensor {
158 path,
159 offset: 8,
160 shape: vec![2, 2],
161 dtype: WeightDtype::BF16,
162 };
163 assert_eq!(d.read_host_bytes().unwrap(), payload);
164 }
165
166 /// Write a one-tensor-per-entry safetensors file and return its path.
167 fn write_safetensors(
168 dir: &std::path::Path,
169 entries: &[(&str, &str, Vec<usize>, usize)],
170 ) -> std::path::PathBuf {
171 let mut header = serde_json::Map::new();
172 let mut end = 0usize;
173 for (name, dtype, shape, len) in entries {
174 header.insert(
175 (*name).to_string(),
176 serde_json::json!({
177 "dtype": dtype,
178 "shape": shape,
179 "data_offsets": [end, end + len],
180 }),
181 );
182 end += len;
183 }
184 let header = serde_json::to_vec(&serde_json::Value::Object(header)).unwrap();
185 let mut blob = (header.len() as u64).to_le_bytes().to_vec();
186 blob.extend_from_slice(&header);
187 blob.extend_from_slice(&vec![0u8; end]);
188 let path = dir.join("model.safetensors");
189 std::fs::write(&path, blob).unwrap();
190 path
191 }
192
193 /// 🔴 The other half of the contract: a deferred tensor is never swept, so
194 /// the OOM pre-flight must not count it. Before this, the official GLM-5.3
195 /// export's ~7.25 GB of full-width MTP experts were charged to a peak they
196 /// never joined.
197 #[test]
198 fn the_preflight_estimate_drops_deferred_tensors() {
199 let dir = tempfile::tempdir().unwrap();
200 let path = write_safetensors(
201 dir.path(),
202 &[
203 ("big.weight", "BF16", vec![8, 8], 128),
204 ("small.weight", "BF16", vec![4], 8),
205 ],
206 );
207 let files = [path];
208
209 let all = super::super::estimate_load_bytes(&files, &|_| false, &|_, _| false).unwrap();
210 assert_eq!(all, 136);
211
212 let without = super::super::estimate_load_bytes(&files, &|_| false, &|n, d| {
213 n == "big.weight" && d == WeightDtype::BF16
214 })
215 .unwrap();
216 assert_eq!(without, 8, "the deferred tensor must leave the peak");
217
218 // 🪤 Keyed on the dtype too: the same NAME at a width the predicate
219 // does not claim is still counted, because it is still uploaded.
220 let wrong_dtype = super::super::estimate_load_bytes(&files, &|_| false, &|n, d| {
221 n == "big.weight" && d == WeightDtype::UInt8
222 })
223 .unwrap();
224 assert_eq!(wrong_dtype, 136);
225 }
226
227 /// F16 is rewritten on the way to the store, so it can never be served
228 /// from its on-disk bytes — the estimate counts it however loudly the
229 /// predicate claims it, which is exactly what the loaders then do.
230 #[test]
231 fn an_f16_tensor_is_counted_even_when_the_predicate_claims_everything() {
232 let dir = tempfile::tempdir().unwrap();
233 let path = write_safetensors(dir.path(), &[("h.weight", "F16", vec![8, 8], 128)]);
234 let got = super::super::estimate_load_bytes(&[path], &|_| false, &|_, _| true).unwrap();
235 assert_eq!(got, 128);
236 }
237
238 #[test]
239 fn a_truncated_shard_is_an_error_not_a_short_tensor() {
240 let (_d, path) = write_shard(&[0u8; 8]);
241 let d = DeferredTensor {
242 path,
243 offset: 0,
244 shape: vec![64, 64],
245 dtype: WeightDtype::BF16,
246 };
247 assert!(d.read_host_bytes().is_err());
248 }
249}