spark_model/factory/build.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! `build_model` — entry point that wires up the configured loader,
4//! buffers, KV cache, and (optional) DFlash drafter into a `TransformerModel`.
5
6use anyhow::Result;
7use atlas_core::config::ModelConfig;
8use spark_runtime::buffers::BufferArena;
9use spark_runtime::gpu::GpuBackend;
10use spark_runtime::kv_cache::{KvCacheConfig, KvCacheDtype, PagedKvCache};
11use spark_runtime::prefix_cache::PrefixCache;
12use spark_runtime::weights::WeightStore;
13
14use super::loader_for_config;
15use super::m2_setup::maybe_run_minimax_m2_moe_transpose;
16use super::{DflashBuildArgs, LoraBuildArgs};
17use crate::layers::MtpQuantization;
18use crate::model::TransformerModel;
19use crate::traits::Model;
20use crate::weight_loader::load_dflash_weights;
21
22mod kv_summary;
23
24pub fn build_model(
25 mut config: ModelConfig,
26 // BY VALUE. Every reader below still takes `&WeightStore` — the change is
27 // only in who OWNS it. The store is the one structure that knows every
28 // weight pointer, and it used to be a local in `startup()` that was dropped
29 // once the layers had copied pointers out of it: the memory stayed live
30 // with nothing able to free it. The model owns it now, so `teardown` can.
31 // `mut` for `prune_after_load` (Step 3c), which lets a loader drop the
32 // originals of tensors it re-uploaded before the KV budget is computed.
33 mut store: WeightStore,
34 gpu: Box<dyn GpuBackend>,
35 max_batch_tokens: usize,
36 kv_block_size: usize,
37 max_seq_len: usize,
38 max_batch_size: usize,
39 mtp_quant: MtpQuantization,
40 use_speculative: bool,
41 prefix_cache: Box<dyn PrefixCache>,
42 mtp_vocab_size: u32,
43 comm: Option<std::sync::Arc<dyn spark_comm::CommBackend>>,
44 self_speculative: bool,
45 num_drafts: usize,
46 kv_dtype: KvCacheDtype,
47 inference_reserve: usize,
48 gpu_memory_utilization: f64,
49 ssm_cache_slots: usize,
50 layer_dtypes: Vec<KvCacheDtype>,
51 ssm_checkpoint_interval: usize,
52 // Phase 6.1.f: per-sequence HBM cache cap. `Some(N)` enables
53 // `--high-speed-swap` HBM-shrink behavior. `None` preserves the
54 // pre-Phase-6 unbounded behavior.
55 hss_cache_blocks_per_seq: Option<u32>,
56 // DFlash speculative-decoding pairing. `None` = no DFlash; existing
57 // MTP / no-spec paths unchanged.
58 dflash_args: Option<DflashBuildArgs<'_>>,
59 // Startup-static LoRA adapter (`--lora-adapter`). `None` = base-only.
60 lora_args: Option<LoraBuildArgs<'_>>,
61 // NLLB / M2M-100 translation language pair (tokenizer-resolved
62 // `(src_lang_id, tgt_lang_id)`), resolved server-side. `None` for all other
63 // model types.
64 nllb_lang: Option<(u32, u32)>,
65 // NLLB / M2M-100 PEFT LoRA adapter directory (`--lora-adapter` for an
66 // encoder-decoder checkpoint). `None` = base model.
67 nllb_lora_dir: Option<std::path::PathBuf>,
68) -> Result<Box<dyn Model>> {
69 // NLLB / M2M-100 is an encoder-decoder model that cannot be represented by
70 // the decoder-only TransformerModel stack. Serve it with the dedicated
71 // `NllbGpuModel`, which reads its weights from the standard `store` — this
72 // returns BEFORE `loader_for_config`, so the decoder-only weight loader
73 // (and its fail-fast) never runs on this path.
74 #[cfg(feature = "cuda")]
75 if matches!(config.model_type.as_str(), "m2m_100" | "nllb") {
76 let (src, tgt) = nllb_lang.ok_or_else(|| {
77 anyhow::anyhow!(
78 "NLLB serving requires --src-lang and --tgt-lang (translation language pair)"
79 )
80 })?;
81 let lang = crate::model::nllb::NllbLang {
82 src_lang_id: src,
83 tgt_lang_id: tgt,
84 decoder_start_id: config.eos_token_id,
85 eos_id: config.eos_token_id,
86 pad_id: 1,
87 };
88 let model = crate::model::nllb::NllbGpuModel::new(
89 &config,
90 &store,
91 gpu,
92 lang,
93 max_seq_len,
94 max_batch_size,
95 nllb_lora_dir.as_deref(),
96 )?;
97 return Ok(Box::new(model));
98 }
99 #[cfg(not(feature = "cuda"))]
100 let _ = (nllb_lang, nllb_lora_dir);
101
102 // ── Step 1: Select weight loader (only model-specific dispatch) ──
103 let loader = loader_for_config(&config)?;
104
105 // Entry free-memory sample for the KV-budget transient correction below.
106 // Taken BEFORE the LoRA load and buffer arena — the delta from here to
107 // the budget-time sample then isolates exactly what build_model itself
108 // allocated, with the weight loader's transient footprint present in
109 // BOTH samples so it cancels out. The LoRA-ordering invariant in the
110 // next comment is about the BUDGET sample and is unaffected: LoRA still
111 // lands between these two samples and is therefore charged.
112 let build_entry_free = gpu.free_memory().ok();
113
114 // ── LoRA adapter load (pre-arena, pre-KV-sizing) ──
115 // MUST run before `BufferArena::new` and the `gpu.free_memory()`
116 // snapshot below: the pool allocation then lands in `used_so_far`, so
117 // the KV-cache budget shrinks automatically (positional budgeting —
118 // no arithmetic edit needed). Do NOT move this later. Setting
119 // `config.adapter_max_rank` here also lets `BufferSizes` size the
120 // lora_xa/lora_delta/lora_hact scratch.
121 let lora_weights: Option<crate::lora::LoraWeights> = if let Some(ref la) = lora_args {
122 config.adapter_max_rank = la.max_lora_rank;
123 loader.load_lora_adapters(
124 &la.adapters,
125 &config,
126 gpu.as_ref(),
127 la.max_loras,
128 la.max_lora_rank,
129 )?
130 } else {
131 None
132 };
133
134 // Pre-construction: when DFlash is active, populate the target's
135 // capture-layer indices from the drafter's `dflash_config.target_layer_ids`
136 // so `TransformerModel::new` allocates the 5×hidden_size capture buffer.
137 //
138 // The drafter's `target_layer_ids` are used DIRECTLY as Atlas capture
139 // indices. An earlier implementation subtracted 1 from each id on HF
140 // `output_hidden_states` reasoning; measurement on the z-lab drafters
141 // shows that adjustment feeds the drafter hidden states one layer early
142 // on every capture layer and costs ~1 full accepted row per step (27B
143 // dense MinHeap: mean accept 6.61 direct vs 5.56 shifted; the 2026-07-19
144 // 35B accept hunt found the same).
145 if let Some(ref args) = dflash_args
146 && let Some(ref sub) = args.drafter_config.dflash_config
147 {
148 config.dflash_capture_layers = sub.target_layer_ids.clone();
149 // gamma for the pool sizing below, resolved from the drafter itself:
150 // a DFlash2 checkpoint states its trained block size inside
151 // `dflash_config`, and the top-level field's default of 16 must not
152 // shadow it. --dflash-gamma still wins over both.
153 //
154 // MUST go through `default_dflash_gamma`, this value sizes the SSM
155 // pools (uniform K = γ+1), and the HEAD resolves its own γ through
156 // the same helper. When this said `block_size` while the head said
157 // block+2, the pools came up one verify row short and the first
158 // DFlash step died with "SSM MTP intermediate buffers not allocated
159 // (h=8, conv=9, num_tokens=10)" mid-graph-capture.
160 config.dflash_gamma = Some(args.gamma.unwrap_or_else(|| {
161 crate::layers::qwen3_ssm::default_dflash_gamma(
162 args.drafter_config.effective_block_size(),
163 )
164 }));
165 tracing::info!(
166 "DFlash: target layer capture indices = {:?} (drafter target_layer_ids, \
167 used directly), γ = {:?}",
168 config.dflash_capture_layers,
169 config.dflash_gamma,
170 );
171 }
172
173 // ── Step 2: Load weights (model-agnostic from here) ──
174 let attn_layer_dtypes: Vec<KvCacheDtype> = if layer_dtypes.is_empty() {
175 vec![kv_dtype; config.num_attention_layers()]
176 } else {
177 layer_dtypes.clone()
178 };
179
180 // Populate per-layer KV dims for heterogeneous-attention models (Gemma-4).
181 // Homogeneous models return an empty Vec which the KV cache treats as
182 // "use global num_kv_heads/head_dim for all layers" (backward compatible).
183 config.kv_layer_dims = loader.kv_layer_dims(&config);
184
185 // Attribute the memory the BUILD spends, not just the shards.
186 //
187 // Weight upload reports itself per shard, and the buffer arena reports its
188 // own total, but everything between — per-layer construction, runtime
189 // requantization, derived weights — was invisible. On qwen4_exp that gap
190 // is ~8.5 GB: shards end at 85.2 GB and the KV budget sees 94.7 GB
191 // pre-KV, of which the arena (872 MB) and the GDN prefill scratch (88 MB)
192 // explain under a gigabyte. Without these three lines the only way to
193 // find the rest is to guess.
194 //
195 // `MemTrace` (campaign) marks EVERY build step; upstream's free_before/after
196 // pair below is kept because it is the one step with a per-layer average.
197 // Reconciling the two into a single reporter belongs to the M0 telemetry
198 // commit, not to this rebase.
199 let mut mem = MemTrace::new(gpu.as_ref());
200 let free_before_layers = gpu.free_memory().unwrap_or(0);
201 let mut layers = loader.load_layers(&store, &config, gpu.as_ref(), &attn_layer_dtypes)?;
202 let free_after_layers = gpu.free_memory().unwrap_or(0);
203 tracing::info!(
204 "Layer construction: {:.2} GB consumed ({:.2} GB free -> {:.2} GB free) \
205 across {} layers, {:.1} MB/layer average",
206 (free_before_layers.saturating_sub(free_after_layers)) as f64 / 1e9,
207 free_before_layers as f64 / 1e9,
208 free_after_layers as f64 / 1e9,
209 config.num_hidden_layers,
210 (free_before_layers.saturating_sub(free_after_layers)) as f64
211 / 1e6
212 / config.num_hidden_layers.max(1) as f64,
213 );
214 mem.mark("load_layers");
215 let embed = loader.load_embedding(&store, &config, gpu.as_ref())?;
216 mem.mark("load_embedding");
217 // n-gram fused embedding (LongCat family; None everywhere else). Built
218 // before `config` is moved into the model. Staged for `max_batch_tokens`
219 // because that is exactly the widest embed the arena can be handed.
220 let ngram_embed =
221 loader.load_ngram_embedding(&store, &config, gpu.as_ref(), max_batch_tokens)?;
222 let final_norm = loader.load_final_norm(&store, &config, gpu.as_ref())?;
223 mem.mark("load_final_norm");
224 let lm_head = loader.load_lm_head(&store, &config, gpu.as_ref())?;
225 mem.mark("load_lm_head");
226 let mtp_weights = loader.load_mtp_weights_multi(&store, &config, gpu.as_ref())?;
227 mem.mark("load_mtp_weights_multi");
228
229 // DeepSeek-V4 ships an architecturally distinct MTP module (MLA + mHC), not
230 // the Qwen-shaped `MtpWeights`. Load it via the V4-specific path and keep it
231 // — the `DeepseekV4MtpHead` proposer is built from it after the model is
232 // constructed (it needs the resolved draft NVFP4 LM head + the model's
233 // owned GPU backend) and installed via `set_dflash_proposer`. Only built
234 // when `--speculative` is set; otherwise the module is loaded for
235 // verification then dropped.
236 // Only rank 0 runs the MTP draft (no-EP, all experts local). Skip loading it
237 // on the worker ranks — they never call propose(), so it would be dead weight.
238 // GLM-5.3's MTP block is architecturally distinct in a THIRD way: neither the Qwen-shaped
239 // `MtpWeights` nor DeepSeek's `mtp.0.*` module, but `layers.45` — a DSA mixer + the same
240 // 288-expert routed MoE + `shared_head.norm`, with NO hyper-connection.
241 //
242 // 🔴 Loaded on EVERY rank, unlike the V4 module below. GLM's MTP MoE is EP-sharded exactly
243 // like the text stack, so both ranks hold a half and the block's own all-reduce assembles
244 // it; a rank-0-only drafter would silently drop half the routed sum and draft from a
245 // half-computed hidden.
246 let glm_mtp_module = if config.model_type == "glm5_next" && use_speculative {
247 match crate::weight_loader::load_glm5next_mtp_module(&store, &config, gpu.as_ref()) {
248 Ok(Some(m)) => {
249 tracing::info!(
250 "GLM-5.3 MTP draft module loaded (layers.{})",
251 config.num_hidden_layers
252 );
253 Some(m)
254 }
255 Ok(None) => {
256 tracing::info!("GLM-5.3: no MTP block in checkpoint (MTP off)");
257 None
258 }
259 Err(e) => {
260 tracing::error!("GLM-5.3 MTP module load FAILED: {e:#}");
261 None
262 }
263 }
264 } else {
265 None
266 };
267 let glm_mtp_embed = embed;
268 let glm_mtp_lm_head = lm_head;
269
270 let v4_mtp_module =
271 if config.model_type == "deepseek_v4" && use_speculative && config.ep_rank == 0 {
272 match crate::weight_loader::deepseek_v4::load_v4_mtp_module(
273 &store,
274 &config,
275 gpu.as_ref(),
276 &attn_layer_dtypes,
277 ) {
278 Ok(Some(m)) => {
279 tracing::info!(
280 "DeepSeek-V4 MTP draft module loaded OK (num_mtp_modules={})",
281 config.num_mtp_modules
282 );
283 Some(m)
284 }
285 Ok(None) => {
286 tracing::info!("DeepSeek-V4: no MTP module in checkpoint (MTP off)");
287 None
288 }
289 Err(e) => {
290 tracing::error!("DeepSeek-V4 MTP module load FAILED: {e:#}");
291 None
292 }
293 }
294 } else {
295 None
296 };
297
298 // Capability warning: user asked for `--speculative` but nothing bound an
299 // MTP head, so speculative decoding will silently no-op.
300 //
301 // 🪤 `mtp_weights` is only the GENERIC (`load_mtp_weights_multi`) path.
302 // GLM-5.3 and DeepSeek-V4 bind architecturally distinct modules above and
303 // leave that vec empty, so testing it alone printed "no MTP weights were
304 // loaded" two lines under "GLM-5.3 MTP draft module loaded (layers.45)".
305 // Every binding path has to be consulted, and when none of them bound
306 // anything the checkpoint still has to be asked whether it SHIPS an MTP
307 // head — "Atlas can't read this layout" and "there is no head here" are
308 // different faults and want different messages.
309 if use_speculative
310 && mtp_weights.is_empty()
311 && glm_mtp_module.is_none()
312 && v4_mtp_module.is_none()
313 {
314 match crate::mtp_layout::detect_in_store(&store, &config) {
315 None => tracing::warn!(
316 "`--speculative` was requested but this checkpoint ships no MTP head — \
317 speculative decoding will be disabled. Either drop `--speculative` or \
318 use a checkpoint that ships one (e.g. `mtp.safetensors`)."
319 ),
320 Some(layout) => tracing::error!(
321 "`--speculative` was requested and this checkpoint DOES ship MTP weights \
322 ({layout:?}), but no loader bound them for model_type '{}' — speculative \
323 decoding will be disabled. This is an Atlas capability gap, not a \
324 checkpoint problem.",
325 config.model_type,
326 ),
327 }
328 }
329 mem.mark("mtp modules (glm/v4)");
330 let vision_encoder = loader.load_vision_encoder(&store, &config, gpu.as_ref())?;
331 mem.mark("load_vision_encoder");
332
333 // A multimodal checkpoint's vision tower is read by the weight loader like
334 // everything else, but only a loader that implements `load_vision_encoder`
335 // ever binds it. GLM-5.3's port is text-only by design
336 // (`weight_loader/glm5_next.rs`: "Vision tower — present in the checkpoint,
337 // out of scope for the text port"), so its 1.05 GiB of `model.visual.*`
338 // sat resident on BOTH ranks for the life of the process, bound to nothing,
339 // subtracted from the KV budget computed below.
340 //
341 // Freeing is keyed off the bind result, not off a model list: if the encoder
342 // was built, `vision_encoder` is `Some` and nothing is touched — including
343 // the loaders that bind zero-copy from these very pointers. The day a GLM
344 // vision encoder lands, this stops firing on its own.
345 if vision_encoder.is_none() {
346 let (n, bytes) = store.free_matching(gpu.as_ref(), |name| {
347 name.starts_with("model.visual.")
348 || name.starts_with("model.vision")
349 || name.starts_with("visual.")
350 })?;
351 if n > 0 {
352 tracing::info!(
353 "Vision tower: {n} tensors ({:.2} GiB) released — this build binds no vision \
354 encoder for model_type '{}', so the tower was resident and unreachable. \
355 Text capability is unchanged; image input was already unsupported here.",
356 bytes as f64 / (1024.0 * 1024.0 * 1024.0),
357 config.model_type,
358 );
359 }
360 }
361 mem.mark("vision reclaim");
362
363 // If the checkpoint's `quantization_config.ignore_modules` lists MTP
364 // (e.g. Sehyo/Qwen3.5-35B-A3B-NVFP4 ignores `mtp.*`), the MTP weights
365 // were stored as BF16 on disk. Runtime-quantizing them to NVFP4
366 // anyway — which is what `mtp_quant` would otherwise do — produces
367 // garbage drafts (vllm PR #38832). Force BF16 in that case.
368 let effective_mtp_quant = if !mtp_weights.is_empty() {
369 let quant_fmt = crate::quant_format::detect_quant_format(&config, &store);
370 if quant_fmt.is_ignored("mtp.fc.weight")
371 || quant_fmt.is_ignored("mtp.layers.0.self_attn.q_proj.weight")
372 {
373 if mtp_quant != MtpQuantization::Bf16 {
374 tracing::info!(
375 "MTP head listed in checkpoint ignore_modules — overriding \
376 --mtp-quantization {:?} → Bf16 to preserve precision",
377 mtp_quant,
378 );
379 }
380 MtpQuantization::Bf16
381 } else {
382 mtp_quant
383 }
384 } else {
385 mtp_quant
386 };
387
388 // ── Step 3: LM-head quantization (NVFP4 / FP8 / BF16-skip) + the
389 // draft-only NVFP4 head for MTP — extracted to lm_head_setup.rs
390 // (file-size cap; pure code move).
391 let (lm_head_nvfp4, lm_head_fp8, mtp_lm_head_nvfp4) = super::lm_head_setup::setup_lm_heads(
392 &store,
393 &lm_head,
394 &config,
395 gpu.as_ref(),
396 use_speculative,
397 !mtp_weights.is_empty(),
398 )?;
399
400 // Capture the shared embed + resolved draft NVFP4 head for the DeepSeek-V4
401 // MTP proposer BEFORE `embed` / `lm_head_nvfp4` / `mtp_lm_head_nvfp4` are
402 // moved into `TransformerModel::new`. All are `Copy` (DenseWeight /
403 // QuantizedWeight). The draft head resolves to the separate draft-only
404 // NVFP4 head (main head kept BF16) or the main NVFP4 head. `None` ⇒ no
405 // NVFP4 head available ⇒ the V4 proposer can't draft and is skipped.
406 let v4_mtp_embed = embed;
407 // DeepSeek-V4-Flash keeps the LM head in BF16; the proposer drafts with the
408 // same BF16 head via dense_gemv (drafts are re-verified by the target, so the
409 // draft head only affects acceptance). DenseWeight is Copy.
410 let v4_mtp_lm_head = lm_head;
411
412 // ── Step 3b: Post-load MoE prefill transpose (MiniMax EP=2 TTFT fix) ──
413 //
414 // MiniMax M2.7-NVFP4 EP=2 has ~46 GB free at layer-0 load time but
415 // ~65 GB free here (the BF16 lm_head just freed ~22 GB during NVFP4
416 // quantization). The transpose costs ~59 GB — fits in the post-load
417 // window but not the pre-load one. Other loaders (qwen35, qwen3,
418 // gemma4) still call `transpose_for_prefill` inline during layer
419 // construction; this default-no-op hook doesn't perturb them.
420 maybe_run_minimax_m2_moe_transpose(&config, gpu.as_ref(), &mut layers)?;
421
422 // ── Step 3c: Let the loader drop store tensors it has finished with ──
423 //
424 // Default is a no-op. Loaders that upload their OWN copies (a TP shard, a
425 // host round-trip) leave the store's originals resident for nothing; on
426 // unified-memory GB10 that duplicate is subtracted from the KV budget
427 // computed a few lines below, so it has to happen HERE — after every
428 // `load_*` reader above, before `BufferArena::new` and `gpu.free_memory()`.
429 loader.prune_after_load(&mut store, &config, gpu.as_ref())?;
430 mem.mark("prune_after_load");
431 tracing::info!(
432 "WeightStore after prune: {} tensors, {:.3} GiB still resident",
433 store.len(),
434 store.resident_bytes() as f64 / (1024.0 * 1024.0 * 1024.0),
435 );
436
437 // ── Step 4: Create buffer arena ──
438 let buffers = BufferArena::new(
439 &config,
440 max_batch_tokens,
441 max_seq_len,
442 kv_block_size,
443 max_batch_size,
444 gpu.as_ref(),
445 )?;
446
447 // ── Step 5: Size KV cache from actual free memory ──
448 // MLA absorbed: cache compressed latent [kv_lora + rope] instead of expanded [nkv * hd]
449 // This gives 12.8x smaller KV cache AND better precision (no expand→cache→read roundtrip)
450 let (kv_num_heads, kv_head_dim) = if config.kv_lora_rank > 0 {
451 let mla_cache_dim = config.kv_lora_rank + config.qk_rope_head_dim;
452 tracing::info!(
453 "MLA absorbed KV cache: 1 head × {} dims ({}+{}) per token (vs {} heads × {})",
454 mla_cache_dim,
455 config.kv_lora_rank,
456 config.qk_rope_head_dim,
457 config.num_key_value_heads,
458 config.head_dim,
459 );
460 (1, mla_cache_dim)
461 } else {
462 (config.num_key_value_heads, config.head_dim)
463 };
464 let kv_config = KvCacheConfig {
465 block_size: kv_block_size,
466 num_kv_heads: kv_num_heads,
467 head_dim: kv_head_dim,
468 num_layers: config.num_attention_layers(),
469 dtype: kv_dtype,
470 layer_dtypes: layer_dtypes.clone(),
471 layer_dims: config.kv_layer_dims.clone(),
472 cache_blocks_per_seq: hss_cache_blocks_per_seq,
473 };
474
475 if hss_cache_blocks_per_seq.is_some() {
476 kv_summary::log_hss_kv_summary(&kv_config);
477 }
478 // ── gpu_memory_utilization as fraction of TOTAL GPU memory ──
479 //
480 // User-facing contract (matches vLLM / sparkrun convention):
481 // total_memory × gpu_memory_utilization = hard ceiling on everything
482 // this process consumes (weights + buffers + KV cache + reserves).
483 //
484 // KV cache gets whatever remains inside that ceiling after deducting
485 // prior allocations (model weights, buffer arena, CUDA context/driver)
486 // and the inference reserve (SSM state pools, CUDA headroom). A safety
487 // clamp ensures we never exceed what the device can physically provide
488 // right now (handles external memory pressure on shared-memory /
489 // unified-memory systems like GB10).
490 let total_mem = gpu.total_memory()?;
491 let actual_free = gpu.free_memory()?;
492 let gib = |b: usize| b as f64 / (1024.0 * 1024.0 * 1024.0);
493 let mut used_so_far = total_mem.saturating_sub(actual_free);
494 // GB10 is shared (ComfyUI/voxel/etc.). Raw `used_so_far` counts those
495 // co-tenants against our --gpu-memory-utilization budget, so a low util
496 // needlessly starves the KV pool (vs vLLM, whose util is self-relative).
497 //
498 // We want the KV pool sized against Atlas's OWN footprint (weights +
499 // buffers), excluding co-tenants. Two ways to find that footprint:
500 //
501 // 1. AUTO via LEDGER (default, preferred): the alloc ledger's live
502 // bytes — every allocation this backend made and hasn't freed
503 // (issue #740). The former free-memory delta (baseline-at-init
504 // minus free-now) counted OS page cache against us on unified
505 // memory: streaming ~20 GB of safetensors depresses MemFree
506 // without being an allocation Atlas owns, inflating "Atlas-own"
507 // by tens of GB on a cold-cache boot and refusing serves with
508 // >100 GB actually available. The ledger is immune to page-cache
509 // noise, co-tenant churn, and mid-load sampling by construction.
510 // Slight undercount (driver context, cuBLAS workspaces are not
511 // ledgered) is absorbed by the inference reserve and the physical
512 // `.min(actual_free - reserve)` clamp below.
513 //
514 // 2. AUTO via FREE-DELTA (fallback when the backend has no ledger):
515 // free-at-context-init minus free-now. Requires
516 // `set_baseline_free_bytes` to have run (it does under the real
517 // server; absent under the mock backend → we skip it).
518 //
519 // 3. MANUAL override: ATLAS_KV_EXTERNAL_RESERVE_GB=<co-tenant GB> still
520 // wins when explicitly set (>0), for operators who want to RESERVE
521 // headroom for co-tenants that will arrive LATER (the auto measures
522 // only see current state).
523 //
524 // The `.min(actual_free - reserve)` clamp below still guarantees a physical
525 // fit regardless of which path set `used_so_far`.
526 let manual_reserve_gb = std::env::var("ATLAS_KV_EXTERNAL_RESERVE_GB")
527 .ok()
528 .and_then(|v| v.parse::<f64>().ok())
529 .filter(|&gb| gb > 0.0);
530 if let Some(gb) = manual_reserve_gb {
531 let ext = (gb * 1024.0 * 1024.0 * 1024.0) as usize;
532 let discounted = used_so_far.saturating_sub(ext);
533 tracing::info!(
534 "ATLAS_KV_EXTERNAL_RESERVE_GB={gb} (manual override): discounting \
535 external/co-tenant memory from KV budget — used_so_far {:.1} GB → \
536 Atlas-own {:.1} GB",
537 gib(used_so_far),
538 gib(discounted),
539 );
540 used_so_far = discounted;
541 } else if let Some(ledger_live) = gpu.live_bytes() {
542 // AUTO via LEDGER: what this backend actually allocated and still
543 // holds. Sanity-gate mirrors the free-delta path: the ledger can
544 // only be a subset of total used (it can't see co-tenants), so a
545 // value above `used_so_far` means the ledger and the device
546 // disagree — fall through to raw rather than oversize the pool.
547 if ledger_live > 0 && ledger_live <= used_so_far {
548 tracing::info!(
549 "KV budget self-relative (ledger): Atlas-own {:.1} GB live in \
550 the alloc ledger; {:.1} GB of co-tenant/page-cache use \
551 excluded (set ATLAS_KV_EXTERNAL_RESERVE_GB to override)",
552 gib(ledger_live),
553 gib(used_so_far - ledger_live),
554 );
555 used_so_far = ledger_live;
556 } else if ledger_live > used_so_far {
557 // Not "the ledger is implausible" — it is the DEVICE figure that
558 // cannot be true. The ledger counts only allocations this backend
559 // made and still holds, so real device usage is always at least
560 // `ledger_live`; `used_so_far` (total − free_memory()) coming out
561 // SMALLER means `free_memory()` over-reported free memory. That is
562 // exactly what a discrete GPU did when `free_memory()` substituted
563 // host MemAvailable: ~990 GB "free" on a 95 GB card → used_so_far
564 // 0 → a KV pool sized as if nothing were allocated → OOM at
565 // cuMemAlloc. Charging the ledger is strictly more conservative
566 // than charging the smaller (impossible) device-derived figure:
567 // it can only shrink the KV budget, never grow it.
568 tracing::warn!(
569 "KV budget: free_memory() looks wrong — the alloc ledger holds \
570 {:.1} GB but the device implies only {:.1} GB used, and real \
571 device usage can never be below the ledger. Charging the \
572 ledger's {:.1} GB (the larger, safer figure) instead.",
573 gib(ledger_live),
574 gib(used_so_far),
575 gib(ledger_live),
576 );
577 used_so_far = ledger_live;
578 } else {
579 // ledger_live == 0: nothing ledgered yet (or a backend that does
580 // not ledger). Nothing better to charge than the raw figure.
581 tracing::warn!(
582 "KV budget: alloc ledger reports 0 GB live against {:.1} GB \
583 used on the device — using raw used_so_far",
584 gib(used_so_far),
585 );
586 }
587 } else if let Some(baseline) = spark_runtime::gpu::baseline_free_bytes() {
588 // AUTO: bytes this process consumed since context init.
589 let atlas_own = baseline.saturating_sub(actual_free);
590 // The free-delta above charges the weight loader's TRANSIENT
591 // footprint — checkpoint mapping/staging still resident at this
592 // instant — as if it were permanent. On a 27B NVFP4 load the delta
593 // reads ~61 GB while the process's steady state is ~27 GB; the
594 // difference is the size of the safetensors file, released once the
595 // loader settles, and it is charged here regardless of page-cache
596 // warmth. The footprint that actually PERSISTS is knowable without
597 // heuristics:
598 // * `store.total_bytes()` — every weight tensor on the GPU;
599 // * entry-free − free-now — what build_model itself allocated
600 // (LoRA pool + buffer arena), transient-cancelling because the
601 // transient is present in both samples.
602 // Charge the smaller of measured and known: the delta can only
603 // OVER-count (transients), the known sum can only UNDER-count (CUDA
604 // context overhead), and the `.min(actual_free − reserve)` clamp
605 // below still guarantees a physical fit at allocation time either
606 // way.
607 let build_own = build_entry_free
608 .map(|e| e.saturating_sub(actual_free))
609 .unwrap_or(0);
610 let known_own = store.total_bytes().saturating_add(build_own);
611 let settled = atlas_own.min(known_own);
612 // Sanity-gate: baseline must be ≥ free-now, the charge positive and
613 // no larger than total used (co-tenants can't be negative). If a
614 // co-tenant *freed* memory during our load, baseline > free-now
615 // still holds and the charge just slightly overcounts (conservative
616 // — fine). If the numbers are implausible, fall back to raw
617 // used_so_far.
618 if settled > 0 && settled <= used_so_far {
619 tracing::info!(
620 "KV budget self-relative (auto): baseline-free {:.1} GB − free-now \
621 {:.1} GB = {:.1} GB measured; charging settled Atlas-own {:.1} GB \
622 (weights {:.1} GB + build allocs {:.1} GB, loader transient \
623 {:.1} GB released from the charge); co-tenants {:.1} GB excluded \
624 (set ATLAS_KV_EXTERNAL_RESERVE_GB to override)",
625 gib(baseline),
626 gib(actual_free),
627 gib(atlas_own),
628 gib(settled),
629 gib(store.total_bytes()),
630 gib(build_own),
631 gib(atlas_own.saturating_sub(settled)),
632 gib(used_so_far - atlas_own.min(used_so_far)),
633 );
634 used_so_far = settled;
635 } else {
636 tracing::warn!(
637 "KV budget auto-measure implausible (baseline {:.1} GB, free-now \
638 {:.1} GB, used {:.1} GB) — using raw used_so_far",
639 gib(baseline),
640 gib(actual_free),
641 gib(used_so_far),
642 );
643 }
644 }
645 // DFlash drafter head allocations happen at Step 7 — AFTER this sizing —
646 // so without a reserve they land OUTSIDE the util pledge (the documented
647 // dflash-oom hazard; 2026-08-19 256K/C8 boot ledger measured ~10.5 GB of
648 // post-sizing drafter allocs on a boot whose planner believed it had
649 // honored a 79 GB budget, leaving 14 GB on the whole box before the first
650 // request). Estimate mirrors serve's load_dflash_drafter pre-flight:
651 // drafter KV (max_seq_len·L·2·kv_dim·bf16) + fused_kv + prompt-hidden
652 // capture + FP8 MLP mirrors (~store/2; the lm_head mirror is shared) +
653 // scratch.
654 let dflash_reserve: usize = dflash_args
655 .as_ref()
656 .map(|a| {
657 let c = &a.drafter_config;
658 let kv_dim = c.num_key_value_heads * c.head_dim;
659 let drafter_kv = max_seq_len * c.num_hidden_layers * 2 * kv_dim * 2;
660 let fused_kv = c.num_hidden_layers * 2 * kv_dim * c.hidden_size * 2;
661 let capture = max_seq_len * config.hidden_size * 2;
662 // Same predicate as the allocating gate (`!= Some("0")`).
663 // FP8 drafter weights are default-ON, so `.is_some()` made the
664 // KV budget under-reserve by the mirror size on the default path.
665 let fp8_mirrors =
666 if std::env::var("ATLAS_DFLASH_DRAFTER_FP8").ok().as_deref() != Some("0") {
667 a.drafter_store.total_bytes() / 2
668 } else {
669 0
670 };
671 drafter_kv + fused_kv + capture + fp8_mirrors + (300 << 20)
672 })
673 .unwrap_or(0);
674 if dflash_reserve > 0 {
675 tracing::info!(
676 "KV budget: reserving {:.1} GB for post-sizing DFlash drafter allocations",
677 gib(dflash_reserve),
678 );
679 }
680 let total_budget = (total_mem as f64 * gpu_memory_utilization) as usize;
681 let kv_budget = total_budget
682 .saturating_sub(used_so_far)
683 .saturating_sub(inference_reserve)
684 .saturating_sub(dflash_reserve)
685 .min(
686 actual_free
687 .saturating_sub(inference_reserve)
688 .saturating_sub(dflash_reserve),
689 );
690 // ── MTP propose-pool pre-charge ──
691 // `MtpHead::new` allocates its own paged KV pool AFTER this sizing
692 // (per-seq blocks × the MTP concurrency cap, bounded by the MAIN pool's
693 // block count) and its comment's "well inside the serve reserve" named a
694 // reserve that never existed — ~0.97 GB at 128K/bs8 landed OUTSIDE the
695 // util pledge (the last tracked allocation that did, 2026-08-22 ledger).
696 // Mirror the pool arithmetic here and charge it. Two-pass on the cap:
697 // the bound uses the PRE-charge block count, which is >= the final one,
698 // so the miss direction is a slightly larger reserve, never a smaller
699 // pool than reserved. Gate matches `build_mtp_proposer` minus the
700 // LM-head-dtype refusal — if that refusal fires the head is skipped and
701 // this over-reserves one pool, which is the safe direction.
702 let mtp_pool_reserve: usize = if use_speculative && !mtp_weights.is_empty() {
703 // Mirrors the head's kv_config: block 16, target attention dims,
704 // K+V, BF16 KV for Bf16/Fp8 heads and FP8 KV for NVFP4
705 // (`kv_bf16` in mtp_head/new.rs).
706 let block = 16usize;
707 let per_seq_blocks = max_seq_len / block + 1;
708 let elem = match mtp_quant {
709 MtpQuantization::Nvfp4 => 1usize,
710 MtpQuantization::Fp8 | MtpQuantization::Bf16 => 2,
711 };
712 let mtp_block_bytes = block * config.num_key_value_heads * config.head_dim * elem * 2;
713 let blocks0 = PagedKvCache::compute_num_blocks(&kv_config, kv_budget).unwrap_or(0);
714 let pool_blocks = per_seq_blocks
715 .saturating_mul(crate::speculative::mtp_max_seqs())
716 .min(blocks0.max(per_seq_blocks));
717 pool_blocks * mtp_block_bytes
718 } else {
719 0
720 };
721 let kv_budget = kv_budget.saturating_sub(mtp_pool_reserve);
722 if mtp_pool_reserve > 0 {
723 tracing::info!(
724 "KV budget: reserving {:.1} GB for the MTP propose pool (post-sizing alloc in MtpHead::new)",
725 gib(mtp_pool_reserve),
726 );
727 }
728 // Phase 6.1.f: when HBM-shrink is active, size the production cache to
729 // `max_batch_size × cache_blocks_per_seq` rather than the unbounded
730 // budget-driven sum. This is the *whole point* of the HBM-shrink
731 // feature — the production cache becomes write staging only; older
732 // blocks live on disk under the orchestrator's control.
733 let num_kv_blocks = match hss_cache_blocks_per_seq {
734 Some(cap) => {
735 // Phase 6.3 (original): pool = max_batch × cap + 1 dummy + 1 spare per seq.
736 // Issue #31 (2026-05-08): the cap×bs sizing assumed prefill would
737 // fit in cap blocks AND the slide-during-prefill path would handle
738 // any overflow. Live-tested: slides during prefill produce silently
739 // wrong attention output (the orchestrator-fed disk-read path is
740 // wired up for DECODE attention only — Phase 6.2.a — not for
741 // prefill — Phase 6.2.b deferred). The companion change in
742 // `block_mgmt::ensure_blocks_through_prefill` removes the broken
743 // slide; this change resizes the pool so prefill can grow up to
744 // `max_seq_len` blocks without hitting "no free blocks". HBM-shrink
745 // remains in effect post-prefill: the FIRST decode step finds
746 // bt_len > cap and slides down via the orchestrator-aware path
747 // (which IS correct).
748 //
749 // Sizing rationale:
750 // * Per-seq blocks: `max(cap + 1, ceil(max_seq_len / block_size))`
751 // so prefill of any prompt up to max_seq_len fits in HBM.
752 // * +1 dummy slot for OOB-safe paged-kernel reads.
753 //
754 // For multi-seq HSS where the user wanted strict HBM-shrink, this
755 // increases pool size by `(max_seq_len_blocks - cap) × max_batch`
756 // bytes per block. The existing post-load OOM check (line 304+)
757 // catches infeasible configs at startup with a clear message.
758 let max_seq_blocks = max_seq_len.div_ceil(kv_block_size);
759 let per_seq = (cap as usize + 1).max(max_seq_blocks);
760 let n = max_batch_size * per_seq + 1;
761 tracing::info!(
762 "--high-speed-swap: HBM cache sized to {n} blocks ({} batch × max(cap={cap}+1, max_seq_len_blocks={max_seq_blocks}) + 1 dummy); \
763 prefill grows monotonically, decode shrinks to cap × bs and streams older blocks from disk via the orchestrator",
764 max_batch_size
765 );
766 n
767 }
768 None => {
769 if kv_budget == 0 {
770 anyhow::bail!(
771 "No memory left for KV cache: total GPU = {:.1} GB, \
772 --gpu-memory-utilization {:.0}% → budget {:.1} GB, \
773 but {:.1} GB already consumed + {:.1} GB inference reserve \
774 = {:.1} GB committed. Raise --gpu-memory-utilization or \
775 use a smaller model.",
776 total_mem as f64 / (1024.0 * 1024.0 * 1024.0),
777 gpu_memory_utilization * 100.0,
778 total_budget as f64 / (1024.0 * 1024.0 * 1024.0),
779 used_so_far as f64 / (1024.0 * 1024.0 * 1024.0),
780 inference_reserve as f64 / (1024.0 * 1024.0 * 1024.0),
781 (used_so_far + inference_reserve) as f64 / (1024.0 * 1024.0 * 1024.0),
782 );
783 }
784 let budget_blocks = PagedKvCache::compute_num_blocks(&kv_config, kv_budget)?;
785 // ── Clamp the pool to blocks the engine can actually reach ──
786 //
787 // `compute_num_blocks` spends the ENTIRE residual budget, and
788 // nothing downstream caps it: the `max_concurrent` check below is a
789 // warn/bail only, never a cap. So the pool is sized by "what is
790 // left over", not by "what can be addressed".
791 //
792 // Measured on GLM-5.3, 2xGB10, `--max-seq-len 2048
793 // --max-batch-size 1`, prefix caching off: 45,386 blocks = 7.6 GiB
794 // = 726,176 KV tokens, against a reachable ceiling of
795 // `1 x ceil(2048/16) = 128` blocks. ~99.7 % of the pool could never
796 // be addressed by any request.
797 //
798 // On a discrete GPU that waste is merely idle VRAM. On unified
799 // memory it is host RAM taken from the kernel, the page cache and
800 // every co-tenant — and it is the reason correcting an
801 // over-reservation elsewhere frees nothing: `kv_budget` is a
802 // residual, so every byte released by a smaller `inference_reserve`
803 // is immediately re-absorbed here. This clamp is what turns a
804 // reserve correction into recovered headroom.
805 //
806 // Only applied when the prefix cache is INACTIVE. An active cache
807 // makes surplus blocks genuinely reachable (they hold shared
808 // prefixes), which is exactly the case the unbounded sizing was
809 // written for. `+ max_batch_size + 1` mirrors the HBM-shrink arm
810 // above: one spare block per sequence plus the dummy slot the
811 // OOB-safe paged kernels read.
812 //
813 // Kill switch: `ATLAS_KV_POOL_UNCLAMPED` (presence — `=0` is NOT
814 // "off") restores the budget-driven pool.
815 let n = if prefix_cache.is_active() || std::env::var("ATLAS_KV_POOL_UNCLAMPED").is_ok()
816 {
817 budget_blocks
818 } else {
819 let per_seq = max_seq_len.div_ceil(kv_block_size);
820 let reachable = max_batch_size
821 .saturating_mul(per_seq)
822 .saturating_add(max_batch_size)
823 .saturating_add(1);
824 let clamped = budget_blocks.min(reachable);
825 if clamped < budget_blocks {
826 let freed = (budget_blocks - clamped) * kv_config.block_bytes_kv_all_layers();
827 tracing::info!(
828 "KV pool clamped to reachable demand: {} -> {} blocks \
829 ({} seq x {} blocks/seq + {} spare + 1 dummy); \
830 {:.2} GB not allocated (prefix caching inactive, so surplus \
831 blocks are unreachable). Restore with --enable-prefix-caching \
832 or ATLAS_KV_POOL_UNCLAMPED.",
833 budget_blocks,
834 clamped,
835 max_batch_size,
836 per_seq,
837 max_batch_size,
838 freed as f64 / (1024.0 * 1024.0 * 1024.0),
839 );
840 }
841 clamped
842 };
843 let max_kv_tokens = n * kv_block_size;
844 tracing::info!(
845 "KV cache: {:.1} GB total × {:.0}% util = {:.1} GB budget; \
846 {:.1} GB pre-KV + {:.1} GB reserve → {:.1} GB for KV \
847 → {} blocks × {} tok/block = {} max KV tokens",
848 total_mem as f64 / (1024.0 * 1024.0 * 1024.0),
849 gpu_memory_utilization * 100.0,
850 total_budget as f64 / (1024.0 * 1024.0 * 1024.0),
851 used_so_far as f64 / (1024.0 * 1024.0 * 1024.0),
852 inference_reserve as f64 / (1024.0 * 1024.0 * 1024.0),
853 kv_budget as f64 / (1024.0 * 1024.0 * 1024.0),
854 n,
855 kv_block_size,
856 max_kv_tokens,
857 );
858 n
859 }
860 };
861 let _max_kv_tokens = num_kv_blocks * kv_block_size;
862 // Phase 6.1.f / 6.2.c — when --high-speed-swap is on with HBM-shrink, the
863 // production KV cache only has to fit the per-seq HBM window, not the full
864 // sequence (older blocks live on disk). Compare against `cache_blocks_per_seq`
865 // in that mode; the legacy "blocks per max_seq_len" check is invalid for
866 // HBM-shrunk pools by design.
867 let blocks_per_seq = match hss_cache_blocks_per_seq {
868 Some(cap) => cap as usize,
869 None => max_seq_len.div_ceil(kv_block_size),
870 };
871 let max_concurrent = num_kv_blocks / blocks_per_seq.max(1);
872 if max_concurrent < max_batch_size {
873 // Suggest a max_seq_len that lets the requested batch size fit.
874 let suggested_max_seq_len = (num_kv_blocks / max_batch_size.max(1)) * kv_block_size;
875 // The check is WORST-CASE: it assumes every concurrent sequence reaches
876 // --max-seq-len. With paged KV (blocks allocated on demand) that almost
877 // never holds for real agent traffic (mixed/shorter sequences), so a high
878 // --max-seq-len (e.g. 64K for long agent contexts) needlessly caps
879 // --max-batch-size. Overcommit (DEFAULT ON since wave 10: config of
880 // record for the native bs=32 rung) downgrades the hard error to a
881 // warning: the scheduler admits up to max_batch_size and the pool fills on
882 // demand (a genuinely over-long burst gets back-pressured by the block
883 // allocator, not a boot-time refusal). Kill switch: ATLAS_KV_OVERCOMMIT=0
884 // (or =false) restores the boot-time hard refusal. Value is parsed, not
885 // presence-checked.
886 let overcommit = !matches!(
887 std::env::var("ATLAS_KV_OVERCOMMIT").as_deref(),
888 Ok("0") | Ok("false")
889 );
890 if overcommit {
891 tracing::warn!(
892 "KV OVERCOMMIT: pool fits {} seq(s) at full --max-seq-len={} but \
893 --max-batch-size={} requested ({} block(s)/seq, {} block(s) total). \
894 Paged KV allocates on demand; long-context bursts are back-pressured \
895 at the block allocator, not refused at boot.",
896 max_concurrent,
897 max_seq_len,
898 max_batch_size,
899 blocks_per_seq,
900 num_kv_blocks,
901 );
902 } else {
903 anyhow::bail!(
904 "KV cache can hold at most {} concurrent sequence(s) at --max-seq-len={}, \
905 but --max-batch-size={} was requested. \
906 KV pool has {} block(s) of {} tokens each; each sequence needs {} block(s). \
907 Try --max-seq-len {} (keeps max_batch_size={}), reduce --max-batch-size, \
908 or unset ATLAS_KV_OVERCOMMIT=0 to allow on-demand paged allocation (default).",
909 max_concurrent,
910 max_seq_len,
911 max_batch_size,
912 num_kv_blocks,
913 kv_block_size,
914 blocks_per_seq,
915 suggested_max_seq_len.max(kv_block_size),
916 max_batch_size,
917 );
918 }
919 }
920 let kv_cache = PagedKvCache::new(kv_config, num_kv_blocks, gpu.as_ref())?;
921
922 // ── Step 6: Assemble model ──
923 // Capture pointers for any post-construction sharing (DFlash drafter
924 // shares embed_tokens + lm_head with the target). DenseWeight is Copy
925 // so this clones the device pointer cheaply.
926 let target_embed_for_dflash = embed.weight;
927 let target_lm_head_for_dflash = lm_head.weight;
928 // NVFP4 lm_head (Copy) shared with the DFlash drafter so its final logits
929 // GEMM uses w4a16 instead of a BF16 dense_gemm on NVFP4-packed bytes.
930 let target_lm_head_nvfp4_for_dflash = lm_head_nvfp4;
931 let target_hidden_for_dflash = config.hidden_size;
932 // Native FP8 lm_head share for the DFlash drafter tail: when the
933 // checkpoint ships lm_head as FP8 E4M3 + per-row scale, the drafter's
934 // Phase-G tail reads THOSE bytes instead of building a 1.27 GB
935 // runtime-requantized mirror (see lm_head_setup::native_fp8_lm_head_share).
936 // Built here because `store` is dropped into the model right after.
937 let target_lm_head_native_fp8_for_dflash = if dflash_args.is_some() {
938 super::lm_head_setup::native_fp8_lm_head_share(&store, &config, gpu.as_ref())?
939 } else {
940 None
941 };
942
943 let mut model = TransformerModel::new(
944 config,
945 embed,
946 final_norm,
947 lm_head,
948 lm_head_nvfp4,
949 lm_head_fp8,
950 mtp_lm_head_nvfp4,
951 layers,
952 buffers,
953 kv_cache,
954 mtp_weights,
955 gpu,
956 max_seq_len,
957 max_batch_size,
958 effective_mtp_quant,
959 use_speculative,
960 prefix_cache,
961 mtp_vocab_size,
962 comm,
963 self_speculative,
964 num_drafts,
965 vision_encoder,
966 ssm_cache_slots,
967 ssm_checkpoint_interval,
968 )?;
969
970 // ── Step 6b: DeepSeek-V4 MTP proposer (optional, post-construction) ──
971 //
972 // Built here (not inside `new()`, which only knows the Qwen-shaped
973 // `MtpWeights`) because it needs the model's owned GPU backend, the
974 // resolved draft NVFP4 head, and the shared embedding. Installed via the
975 // existing proposer setter. DFlash (below) is CLI-exclusive with
976 // `--speculative`, so the two never both install.
977 if let Some(v4_module) = v4_mtp_module {
978 match crate::layers::DeepseekV4MtpHead::new(
979 v4_module,
980 v4_mtp_embed,
981 v4_mtp_lm_head,
982 model.config_ref(),
983 model.gpu_backend(),
984 mtp_vocab_size,
985 max_seq_len,
986 ) {
987 Ok(head) => {
988 model.set_dflash_proposer(std::sync::Arc::new(head));
989 tracing::info!("DeepSeek-V4 MTP speculative decoding: ENABLED (single-module)");
990 }
991 Err(e) => tracing::warn!(
992 "Failed to build DeepSeek-V4 MTP proposer: {e:#}. Speculative decoding disabled."
993 ),
994 }
995 }
996
997 // ── Step 6c: GLM-5.3 MTP proposer (optional, post-construction) ──
998 //
999 // Built here for the same reason as the V4 head: it needs the model's owned GPU backend and
1000 // the shared embedding + LM head, neither of which `new()` hands out.
1001 if let Some(m) = glm_mtp_module {
1002 match crate::layers::Glm5NextMtpHead::new(
1003 m,
1004 glm_mtp_embed,
1005 glm_mtp_lm_head,
1006 model.config_ref(),
1007 model.gpu_backend(),
1008 max_seq_len,
1009 ) {
1010 Ok(head) => {
1011 model.set_dflash_proposer(std::sync::Arc::new(head));
1012 tracing::info!("GLM-5.3 MTP speculative decoding: ENABLED");
1013 }
1014 Err(e) => tracing::warn!(
1015 "Failed to build GLM-5.3 MTP proposer: {e:#}. Speculative decoding disabled."
1016 ),
1017 }
1018 }
1019
1020 // ── Step 7: DFlash drafter (optional, post-construction) ──
1021 //
1022 // Loaded last because it depends on the target's `embed_tokens` and
1023 // `lm_head` pointers (the drafter checkpoint omits these — they're
1024 // shared at runtime, mirroring vLLM PR #40898's `skip_substrs` flow).
1025 if let Some(args) = dflash_args {
1026 let weights = load_dflash_weights(
1027 args.drafter_store,
1028 &args.drafter_config,
1029 model.gpu_backend(),
1030 1, // tp_size for the drafter side: replicated, so always 1
1031 )?;
1032 if let Some(weights) = weights {
1033 let head = crate::layers::BlockDiffusionDraftHead::from_weights(
1034 weights,
1035 target_embed_for_dflash,
1036 target_lm_head_for_dflash,
1037 target_lm_head_nvfp4_for_dflash,
1038 target_lm_head_native_fp8_for_dflash,
1039 target_hidden_for_dflash,
1040 args.gamma,
1041 args.window_size,
1042 model.gpu_backend(),
1043 max_seq_len,
1044 max_batch_size,
1045 )?;
1046 model.set_dflash_proposer(std::sync::Arc::new(head));
1047 tracing::info!("DFlash drafter installed as the active proposer");
1048 } else {
1049 tracing::warn!(
1050 "DFlash drafter store had no fc.weight — proposer not installed; \
1051 falling back to whatever proposer (if any) the target's MTP path built"
1052 );
1053 }
1054 }
1055
1056 // ── Step 8: LoRA adapter install (optional, post-construction) ──
1057 // The pool/tables were loaded up top (pre-KV-sizing); this walk copies
1058 // the per-layer pairs into the layer structs. M0: layers only STORE the
1059 // adapter — base output is unchanged until the M1 compute insertions.
1060 if let Some(ngram) = ngram_embed {
1061 model.set_ngram_embedding(ngram);
1062 }
1063 model.set_lora_weights(lora_weights)?;
1064
1065 // Every layer has taken the pointers it needs; hand the ledger to the model
1066 // so `teardown` can free the weights. Dropping it here — which is what used
1067 // to happen — orphaned the memory: live, referenced by the layers, with
1068 // nothing owning the ability to release it.
1069 model.adopt_weight_store(store);
1070
1071 // ── Allocation attribution, once, at the end of load ──
1072 // Everything the serve will hold is allocated by now: weights, KV, the SSM
1073 // pools, the Marconi snapshots, the arenas, the vision encoder. This is
1074 // the only point where the ledger describes the STEADY STATE rather than
1075 // some midpoint of the build.
1076 //
1077 // It exists because the KV-budget line above reports `pre-KV` as one
1078 // opaque number, and on a 27B that number is ~59 GB against 22 GB of
1079 // weights — the rest was unattributable until the ledger learned sizes.
1080 // Logged at INFO, not behind a flag: every OOM and every mis-sized-pool
1081 // investigation so far has begun by wanting exactly this table, and a
1082 // once-per-load table is not a cost worth flagging off.
1083 if let Some(report) = model.gpu_backend().alloc_report(12, 64) {
1084 for line in report.lines() {
1085 tracing::info!("{line}");
1086 }
1087 }
1088 // ── Pledge reconciliation, same place, same reason ──
1089 // The table above attributes the spend; this line judges it against the
1090 // promise. Tracked-live > budget means some allocation family was never
1091 // modeled by the preflight/KV sizing (the 2026-08-22 case: the DFlash
1092 // verify pools, 13.7 GB against a 1.3 GB reserve) — capacity the
1093 // scheduler will happily promise to requests it cannot actually fund.
1094 // WARN, not error: the serve is already up, and the operator's fix is a
1095 // sizing/reserve change, not a restart loop.
1096 if let Some(live) = model.gpu_backend().live_bytes() {
1097 if live > total_budget {
1098 tracing::warn!(
1099 "util pledge exceeded: {:.1} GB tracked live vs {:.1} GB pledged \
1100 (--gpu-memory-utilization {:.0}% of {:.1} GB) — an allocation \
1101 family above is missing from the preflight reserve",
1102 gib(live),
1103 gib(total_budget),
1104 gpu_memory_utilization * 100.0,
1105 gib(total_mem),
1106 );
1107 } else {
1108 tracing::info!(
1109 "util pledge honored: {:.1} GB tracked live within the {:.1} GB \
1110 budget ({:.1} GB pledge headroom)",
1111 gib(live),
1112 gib(total_budget),
1113 gib(total_budget - live),
1114 );
1115 }
1116 }
1117 Ok(Box::new(model))
1118}
1119
1120/// Per-step GPU residency ledger for model construction.
1121///
1122/// Every `load_*` step below allocates into the same GB10 unified pool the KV
1123/// cache is later sized from, but until now the only numbers in the log were
1124/// the loader's on-disk estimate ("Weights: 99.64 GB" — which is
1125/// `estimate_load_bytes`, an ON-DISK byte sum of the tensors this rank reads,
1126/// NOT residency) and one aggregate free-memory reading. Anything between them
1127/// — binder re-uploads, dtype conversions, the store originals a loader forgot
1128/// to drop — was unattributable, and a 4-5 GB residual is the difference
1129/// between K=3 fitting and not.
1130///
1131/// This walks `gpu.free_memory()` across the build and prints a signed delta
1132/// per step. Log-only: it allocates nothing and changes no semantics.
1133struct MemTrace<'a> {
1134 gpu: &'a dyn GpuBackend,
1135 last: usize,
1136 start: usize,
1137}
1138
1139impl<'a> MemTrace<'a> {
1140 fn new(gpu: &'a dyn GpuBackend) -> Self {
1141 let f = gpu.free_memory().unwrap_or(0);
1142 Self {
1143 gpu,
1144 last: f,
1145 start: f,
1146 }
1147 }
1148
1149 /// Log the free-memory delta since the previous mark. Negative = allocated.
1150 fn mark(&mut self, step: &str) {
1151 let Ok(now) = self.gpu.free_memory() else {
1152 return;
1153 };
1154 let gib = |b: usize| b as f64 / (1024.0 * 1024.0 * 1024.0);
1155 let delta = now as i128 - self.last as i128;
1156 tracing::info!(
1157 "build residency: {step:<26} {:+9.3} GiB (cumulative {:8.3} GiB, free {:7.3} GiB)",
1158 delta as f64 / (1024.0 * 1024.0 * 1024.0),
1159 gib(self.start) - gib(now),
1160 gib(now),
1161 );
1162 self.last = now;
1163 }
1164}