metrale_model_engine/factory/
build.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: `build_model`: loads the weights with the loader for the
4//! config's `model_type`, sizes and allocates the buffer arena and the KV
5//! cache, and assembles a `TransformerModel` with its optional MTP, DFlash and
6//! LoRA parts. NLLB / M2M-100 configs get an `NllbGpuModel` instead.
7//!
8//! Owner: metrale-model-engine.
9//! Invariants:
10//! - The LoRA adapters and the buffer arena are allocated before the KV budget
11//!   is computed, so the budget is sized after them.
12
13use anyhow::Result;
14use metrale_cache::kv_cache::{KvCacheConfig, KvCacheDtype, PagedKvCache};
15use metrale_config::ModelConfig;
16use metrale_gpu_runtime::buffers::BufferArena;
17use metrale_gpu_runtime::gpu::GpuBackend;
18use metrale_model_weights::weights::WeightStore;
19use metrale_telemetry::prefix_cache::PrefixCache;
20
21use super::loader_for_config;
22use super::m2_setup::maybe_run_minimax_m2_moe_transpose;
23use super::{DflashBuildArgs, LoraBuildArgs};
24use crate::model::TransformerModel;
25use metrale_model_layers::layers::MtpQuantization;
26
27mod alloc_digest;
28mod kv_blocks;
29mod kv_budget;
30mod kv_sizing;
31mod kv_summary;
32mod mem_trace;
33mod mtp_modules;
34mod proposers;
35pub mod slots;
36mod weights_prep;
37
38use mem_trace::MemTrace;
39
40pub fn build_model(
41    mut config: ModelConfig,
42    // 2026-09-25: By value: the model adopts the store at the end, so its
43    // teardown can free the weights. `mut` for `loader.prune_after_load`.
44    mut store: WeightStore,
45    gpu: Box<dyn GpuBackend>,
46    max_batch_tokens: usize,
47    kv_block_size: usize,
48    max_seq_len: usize,
49    // 2026-10-01: The slot request and the pre-load reserve as a function of the slot count;
50    // the count is resolved at KV sizing (`kv_sizing.rs`) and returned with the model.
51    slots: slots::SlotPlan<'_>,
52    mtp_quant: MtpQuantization,
53    use_speculative: bool,
54    prefix_cache: Box<dyn PrefixCache>,
55    mtp_vocab_size: u32,
56    comm: Option<std::sync::Arc<dyn metrale_comm::CommBackend>>,
57    self_speculative: bool,
58    num_drafts: usize,
59    kv_dtype: KvCacheDtype,
60    gpu_memory_utilization: f64,
61    ssm_cache_slots: usize,
62    layer_dtypes: Vec<KvCacheDtype>,
63    ssm_checkpoint_interval: usize,
64    // 2026-09-25: Per-sequence HBM cache cap for `--high-speed-swap`; `None`
65    // for no cap.
66    hss_cache_blocks_per_seq: Option<u32>,
67    // 2026-09-25: DFlash drafter; `None` for no DFlash.
68    dflash_args: Option<DflashBuildArgs<'_>>,
69    // 2026-09-25: LoRA adapters (`--lora-adapter`); `None` for the base model.
70    lora_args: Option<LoraBuildArgs<'_>>,
71    // 2026-09-25: NLLB / M2M-100 `(src_lang_id, tgt_lang_id)`, resolved by
72    // the server. Required for those model types.
73    nllb_lang: Option<(u32, u32)>,
74    // 2026-09-25: NLLB / M2M-100 PEFT LoRA adapter directory; `None` for the
75    // base model.
76    nllb_lora_dir: Option<std::path::PathBuf>,
77) -> Result<slots::BuiltModel> {
78    // 2026-09-25: NLLB / M2M-100 is an encoder-decoder model, served by
79    // `NllbGpuModel` from the same `store`. This returns before
80    // `loader_for_config`, so the decoder-only loader never runs for it.
81    #[cfg(feature = "cuda")]
82    if matches!(config.model_type.as_str(), "m2m_100" | "nllb") {
83        let (src, tgt) = nllb_lang.ok_or_else(|| {
84            anyhow::anyhow!(
85                "NLLB serving requires --src-lang and --tgt-lang (translation language pair)"
86            )
87        })?;
88        let lang = crate::model::nllb::NllbLang {
89            src_lang_id: src,
90            tgt_lang_id: tgt,
91            decoder_start_id: config.eos_token_id,
92            eos_id: config.eos_token_id,
93            pad_id: 1,
94        };
95        let nllb_slots = match slots.request {
96            slots::SlotRequest::Count(n) => n,
97            slots::SlotRequest::Auto => anyhow::bail!(
98                "--max-batch-size auto balances recurrent-state slots against the KV pool; \
99                 an encoder-decoder model has neither. Pass a count."
100            ),
101        };
102        let model = crate::model::nllb::NllbGpuModel::new(
103            &config,
104            &store,
105            gpu,
106            lang,
107            max_seq_len,
108            nllb_slots,
109            nllb_lora_dir.as_deref(),
110        )?;
111        return Ok(slots::BuiltModel {
112            max_batch_size: nllb_slots,
113            model: Box::new(model),
114            drafter_weights_sha256: None,
115        });
116    }
117    #[cfg(not(feature = "cuda"))]
118    let _ = (nllb_lang, nllb_lora_dir);
119
120    // 2026-09-25: Step 1: select the weight loader.
121    let loader = loader_for_config(&config)?;
122
123    // 2026-09-25: Free memory before the LoRA load and the buffer arena. The
124    // baseline-free-bytes path of the KV budget subtracts the budget-time
125    // sample from it to get what `build_model` itself allocated.
126    let build_entry_free = gpu.free_memory().ok();
127
128    // 2026-09-25: LoRA adapters load before `BufferArena::new` and before the
129    // free-memory sample the KV budget is computed from, so their pool is
130    // counted as used. `config.adapter_max_rank` is set first because the
131    // buffer arena sizes the LoRA scratch from it.
132    let lora_weights: Option<metrale_model_layers::lora::LoraWeights> =
133        if let Some(ref la) = lora_args {
134            config.adapter_max_rank = la.max_lora_rank;
135            loader.load_lora_adapters(
136                &la.adapters,
137                &config,
138                gpu.as_ref(),
139                la.max_loras,
140                la.max_lora_rank,
141            )?
142        } else {
143            None
144        };
145
146    // 2026-09-25: With DFlash, the target's capture layers are the drafter's
147    // `dflash_config.target_layer_ids`, used as they are, set before
148    // `TransformerModel::new` so it allocates the capture buffer.
149    weights_prep::apply_dflash_capture_config(&mut config, &dflash_args);
150
151    // 2026-09-25: Step 2: load the weights.
152    let attn_layer_dtypes: Vec<KvCacheDtype> = if layer_dtypes.is_empty() {
153        vec![kv_dtype; config.num_attention_layers()]
154    } else {
155        layer_dtypes.clone()
156    };
157
158    // 2026-09-25: Per-layer KV dims, from loaders whose layers differ in
159    // attention geometry (Gemma-4). The default is empty: every layer uses the
160    // global `num_kv_heads` and `head_dim`.
161    config.kv_layer_dims = loader.kv_layer_dims(&config);
162
163    // 2026-09-25: `MemTrace` logs the free-memory change at each build step;
164    // the free_before/after pair adds a per-layer average for layer
165    // construction.
166    let mut mem = MemTrace::new(gpu.as_ref());
167    let free_before_layers = gpu.free_memory().unwrap_or(0);
168    let mut layers = loader.load_layers(&store, &config, gpu.as_ref(), &attn_layer_dtypes)?;
169    let free_after_layers = gpu.free_memory().unwrap_or(0);
170    tracing::info!(
171        "Layer construction: {:.2} GB consumed ({:.2} GB free -> {:.2} GB free) \
172         across {} layers, {:.1} MB/layer average",
173        (free_before_layers.saturating_sub(free_after_layers)) as f64 / 1e9,
174        free_before_layers as f64 / 1e9,
175        free_after_layers as f64 / 1e9,
176        config.num_hidden_layers,
177        (free_before_layers.saturating_sub(free_after_layers)) as f64
178            / 1e6
179            / config.num_hidden_layers.max(1) as f64,
180    );
181    mem.mark("load_layers");
182    let embed = loader.load_embedding(&store, &config, gpu.as_ref())?;
183    mem.mark("load_embedding");
184    // 2026-09-25: n-gram fused embedding: LongCat's loader builds one, the
185    // default returns `None`. Sized for `max_batch_tokens`, the widest embed.
186    let ngram_embed =
187        loader.load_ngram_embedding(&store, &config, gpu.as_ref(), max_batch_tokens)?;
188    let final_norm = loader.load_final_norm(&store, &config, gpu.as_ref())?;
189    mem.mark("load_final_norm");
190    let lm_head = loader.load_lm_head(&store, &config, gpu.as_ref())?;
191    mem.mark("load_lm_head");
192    // 2026-10-03: The generic MTP modules load only with `--speculative`, like the GLM-5.3 and
193    // DeepSeek-V4 modules below: without it no proposer reads them (`build_mtp_proposer`), and
194    // until 2026-10-03 their load-time copies (BF16 dequants of the projections and experts,
195    // about 1.5 GiB on the FP8 35B) stayed allocated for nothing.
196    let mtp_weights = if use_speculative {
197        loader.load_mtp_weights_multi(&store, &config, gpu.as_ref())?
198    } else {
199        if metrale_model_weights::mtp_layout::detect_in_store(&store, &config).is_some() {
200            tracing::info!(
201                "the checkpoint has MTP weights, but --speculative is not set: no MTP head is \
202                 built"
203            );
204        }
205        Vec::new()
206    };
207    mem.mark("load_mtp_weights_multi");
208
209    // 2026-09-25: GLM-5.3 and DeepSeek-V4 have their own MTP modules, not the
210    // generic `MtpWeights`; each is loaded only with `--speculative`, and its
211    // proposer is built after the model (Steps 6b and 6c). The GLM-5.3 module
212    // (`layers.{num_hidden_layers}`) is loaded on every EP rank; the
213    // DeepSeek-V4 module only on rank 0.
214    let glm_mtp_module =
215        mtp_modules::load_glm_mtp_module(&store, &config, gpu.as_ref(), use_speculative);
216    let glm_mtp_embed = embed;
217    let glm_mtp_lm_head = lm_head;
218
219    let v4_mtp_module = mtp_modules::load_v4_mtp_module(
220        &store,
221        &config,
222        gpu.as_ref(),
223        &attn_layer_dtypes,
224        use_speculative,
225    );
226
227    // 2026-09-25: `--speculative` with no MTP module bound by any of the three
228    // paths (generic, GLM-5.3, DeepSeek-V4). `detect_in_store` then tells a
229    // checkpoint without an MTP head (warn) from one whose head no loader bound
230    // (error).
231    if use_speculative
232        && mtp_weights.is_empty()
233        && glm_mtp_module.is_none()
234        && v4_mtp_module.is_none()
235    {
236        mtp_modules::warn_unbound_mtp_head(&store, &config);
237    }
238    mem.mark("mtp modules (glm/v4)");
239    let vision_encoder = loader.load_vision_encoder(&store, &config, gpu.as_ref())?;
240    mem.mark("load_vision_encoder");
241
242    // 2026-09-25: When no vision encoder was bound, free the store's
243    // vision-tower tensors before the KV budget is computed. The decision keys
244    // off the bind result, not a model list, so an encoder that binds these
245    // pointers keeps them.
246    if vision_encoder.is_none() {
247        weights_prep::release_unbound_vision_tower(&mut store, gpu.as_ref(), &config)?;
248    }
249    mem.mark("vision reclaim");
250
251    // 2026-09-25: When the checkpoint's quantization config ignores the MTP
252    // weights (`mtp.fc.weight` or layer 0's q_proj), they stay BF16 whatever
253    // `--mtp-quantization` says.
254    let effective_mtp_quant =
255        mtp_modules::effective_mtp_quantization(&mtp_weights, &config, &store, mtp_quant)?;
256
257    // 2026-09-25: Step 3: LM-head quantization (NVFP4, FP8 or BF16) and the
258    // draft-only NVFP4 head for MTP, in lm_head_setup.rs.
259    let (lm_head_nvfp4, lm_head_fp8, mtp_lm_head_nvfp4) = super::lm_head_setup::setup_lm_heads(
260        &store,
261        &lm_head,
262        &config,
263        gpu.as_ref(),
264        use_speculative,
265        !mtp_weights.is_empty(),
266    )?;
267    // 2026-10-04: The drafter's stored-weight key, before anything below might free an `mtp.*`,
268    // embedding or lm_head tensor (`--spec-cost-model measured`'s drafter key).
269    let drafter_weights_sha256 = store.drafter_weights_sha256(gpu.as_ref())?;
270
271    // 2026-10-01: Step 3a: the transposed twin of the NVFP4 LM head, built here rather than in
272    // `TransformerModel::new` so the KV sizing finds it on the allocation ledger.
273    let lm_head_nvfp4_t =
274        crate::model::impl_a1::build_lm_head_nvfp4_t(&lm_head_nvfp4, &config, gpu.as_ref())?;
275
276    // 2026-09-25: Copies (DenseWeight is `Copy`) of the embedding and the BF16
277    // LM head for the DeepSeek-V4 MTP proposer, taken before both move into
278    // `TransformerModel::new`.
279    let v4_mtp_embed = embed;
280    let v4_mtp_lm_head = lm_head;
281
282    // 2026-09-25: Step 3b: the post-load MoE prefill transpose for minimax_m2,
283    // step3p7 and deepseek_v4 (m2_setup.rs); a no-op for other model types.
284    maybe_run_minimax_m2_moe_transpose(&config, gpu.as_ref(), &mut layers)?;
285
286    // 2026-09-25: Step 3c: the loader drops store tensors it has uploaded its
287    // own copies of (the default is a no-op). This runs after every `load_*`
288    // above and before the KV budget is computed, so the freed memory counts.
289    loader.prune_after_load(&mut store, &config, gpu.as_ref())?;
290    mem.mark("prune_after_load");
291    tracing::info!(
292        "WeightStore after prune: {} tensors, {:.3} GiB still resident",
293        store.len(),
294        store.resident_bytes() as f64 / (1024.0 * 1024.0 * 1024.0),
295    );
296
297    // 2026-09-25: Step 4: the buffer arena. 2026-10-01: Sized for the slot ceiling (`auto` resolves
298    // after it, at KV sizing); the preflight sized the arena reserve for the same ceiling.
299    let buffers = BufferArena::new(
300        &config,
301        max_batch_tokens,
302        max_seq_len,
303        kv_block_size,
304        slots.request.ceiling(),
305        gpu.as_ref(),
306    )?;
307
308    // 2026-09-25: Step 5: size the KV cache. With MLA (`kv_lora_rank > 0`) it
309    // caches one head of `kv_lora_rank + qk_rope_head_dim` dims per token, the
310    // compressed latent, not the expanded heads.
311    let (kv_num_heads, kv_head_dim) = if config.kv_lora_rank > 0 {
312        let mla_cache_dim = config.kv_lora_rank + config.qk_rope_head_dim;
313        tracing::info!(
314            "MLA absorbed KV cache: 1 head × {} dims ({}+{}) per token (vs {} heads × {})",
315            mla_cache_dim,
316            config.kv_lora_rank,
317            config.qk_rope_head_dim,
318            config.num_key_value_heads,
319            config.head_dim,
320        );
321        (1, mla_cache_dim)
322    } else {
323        (config.num_key_value_heads, config.head_dim)
324    };
325    let kv_config = KvCacheConfig {
326        block_size: kv_block_size,
327        num_kv_heads: kv_num_heads,
328        head_dim: kv_head_dim,
329        num_layers: config.num_attention_layers(),
330        dtype: kv_dtype,
331        layer_dtypes: layer_dtypes.clone(),
332        layer_dims: config.kv_layer_dims.clone(),
333        cache_blocks_per_seq: hss_cache_blocks_per_seq,
334    };
335
336    if hss_cache_blocks_per_seq.is_some() {
337        kv_summary::log_hss_kv_summary(&kv_config);
338    }
339    // 2026-09-25: `total_memory × gpu_memory_utilization` is the budget for
340    // everything this process holds. The KV cache gets what is left of it
341    // after `used_so_far`, `inference_reserve` and the DFlash and MTP
342    // reserves, clamped to the free memory left after those reserves.
343    // 2026-10-01: Sized with the slot count (`kv_sizing.rs`).
344    let gib = |b: usize| b as f64 / (1024.0 * 1024.0 * 1024.0);
345    // 2026-09-25: The DFlash drafter head allocates at Step 7, after this
346    // sizing, so its estimate is reserved here: drafter KV
347    // (max_seq_len × layers × 2 × kv_dim × BF16), fused KV, the hidden-state
348    // capture, FP8 weight mirrors (half the drafter store) and 300 MiB of
349    // scratch.
350    let dflash_reserve: usize =
351        kv_budget::dflash_reserve_bytes(&dflash_args, &config, max_seq_len, gib);
352    let sized = kv_sizing::size_kv(
353        &kv_sizing::KvInputs {
354            gpu: gpu.as_ref(),
355            store: &store,
356            build_entry_free,
357            config: &config,
358            kv_config: &kv_config,
359            prefix_cache: prefix_cache.as_ref(),
360            dflash_reserve,
361            use_speculative,
362            mtp_weights: &mtp_weights,
363            effective_mtp_quant,
364            max_seq_len,
365            kv_block_size,
366            hss_cache_blocks_per_seq,
367            gpu_memory_utilization,
368        },
369        &slots,
370    )?;
371    let (max_batch_size, num_kv_blocks) = (sized.slots, sized.num_kv_blocks);
372    let total_mem = gpu.total_memory()?;
373    let total_budget = (total_mem as f64 * gpu_memory_utilization) as usize;
374    let kv_cache = PagedKvCache::new(kv_config, num_kv_blocks, gpu.as_ref())?;
375
376    // 2026-09-25: Step 6: assemble the model. The DFlash drafter shares the
377    // target's embedding and LM head, so their pointers are copied first.
378    let target_embed_for_dflash = embed.weight;
379    let target_lm_head_for_dflash = lm_head.weight;
380    // 2026-09-25: The NVFP4 LM head, shared with the DFlash drafter.
381    let target_lm_head_nvfp4_for_dflash = lm_head_nvfp4;
382    let target_hidden_for_dflash = config.hidden_size;
383    // 2026-09-25: For the DFlash drafter, the checkpoint's own FP8 LM head if
384    // it ships one (`lm_head_setup::native_fp8_lm_head_share`).
385    let target_lm_head_native_fp8_for_dflash = if dflash_args.is_some() {
386        super::lm_head_setup::native_fp8_lm_head_share(&store, &config, gpu.as_ref())?
387    } else {
388        None
389    };
390
391    let mut model = TransformerModel::new(
392        config,
393        embed,
394        final_norm,
395        lm_head,
396        lm_head_nvfp4,
397        lm_head_fp8,
398        mtp_lm_head_nvfp4,
399        lm_head_nvfp4_t,
400        layers,
401        buffers,
402        kv_cache,
403        mtp_weights,
404        gpu,
405        max_seq_len,
406        max_batch_size,
407        effective_mtp_quant,
408        use_speculative,
409        prefix_cache,
410        mtp_vocab_size,
411        comm,
412        self_speculative,
413        num_drafts,
414        vision_encoder,
415        ssm_cache_slots,
416        ssm_checkpoint_interval,
417    )?;
418
419    // 2026-09-25: A Q6_K `lm_head.weight` in the store (a DeepSeek-V4.1 GGUF,
420    // kept Q6_K by the sidecar's `v41_kquant_resident_dtype`) is served by the
421    // K-quant GEMV in model/lm_head_q6k.rs.
422    if store
423        .get("lm_head.weight")
424        .is_ok_and(|w| w.dtype == metrale_model_weights::weights::WeightDtype::Q6K)
425    {
426        model.set_lm_head_q6k(lm_head.weight)?;
427    }
428    // 2026-09-28: Declared-W8A8 FP8 head (model/lm_head_fp8_rows.rs).
429    model.install_declared_lm_head_w8a8()?;
430    // 2026-10-02: Declared-W4A16 NVFP4 head on the row tiles (model/lm_head_nvfp4_rows.rs).
431    model.install_declared_lm_head_w4a16_rows()?;
432
433    // 2026-09-25: Step 6b: the DeepSeek-V4 MTP proposer, built after `new()`
434    // because it needs the model's GPU backend. `--dflash` conflicts with
435    // `--speculative` in the CLI, so Step 7 does not also install one.
436    proposers::install_v4_mtp_proposer(
437        &mut model,
438        v4_mtp_module,
439        v4_mtp_embed,
440        v4_mtp_lm_head,
441        mtp_vocab_size,
442        max_seq_len,
443    );
444
445    // 2026-09-25: Step 6c: the GLM-5.3 MTP proposer, built after `new()` for
446    // the same reason.
447    proposers::install_glm_mtp_proposer(
448        &mut model,
449        glm_mtp_module,
450        glm_mtp_embed,
451        glm_mtp_lm_head,
452        max_seq_len,
453    );
454
455    // 2026-09-25: Step 7: the DFlash drafter, which uses the target's
456    // embedding and LM head.
457    proposers::install_dflash_drafter(
458        &mut model,
459        dflash_args,
460        target_embed_for_dflash,
461        target_lm_head_for_dflash,
462        target_lm_head_nvfp4_for_dflash,
463        target_lm_head_native_fp8_for_dflash,
464        target_hidden_for_dflash,
465        max_seq_len,
466        max_batch_size,
467    )?;
468
469    // 2026-09-25: Step 8: the n-gram embedding, and the LoRA adapters loaded
470    // before KV sizing, into the model's layers.
471    if let Some(ngram) = ngram_embed {
472        model.set_ngram_embedding(ngram);
473    }
474    model.set_lora_weights(lora_weights)?;
475
476    // 2026-09-25: The model adopts the store, so its teardown frees the
477    // weights (last, after everything that reads them).
478    model.adopt_weight_store(store);
479
480    proposers::log_alloc_report(&model, total_budget, gpu_memory_utilization, total_mem, gib);
481    Ok(slots::BuiltModel {
482        model: Box::new(model),
483        max_batch_size,
484        drafter_weights_sha256,
485    })
486}