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}