spark_model/model/
impl_b3_accessors.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Post-construction proposer-wiring accessors for [`TransformerModel`].
4//! Split out of `impl_b3.rs` (500-LoC cap) — borrow/install hooks only.
5
6use atlas_core::config::ModelConfig;
7use spark_runtime::gpu::GpuBackend;
8
9use super::types::TransformerModel;
10use crate::speculative::DraftProposer;
11
12impl TransformerModel {
13    /// Borrow the GPU backend for post-construction wiring (e.g. installing
14    /// a DFlash proposer that needs to allocate paged KV caches against the
15    /// same GPU the target uses).
16    pub fn gpu_backend(&self) -> &dyn GpuBackend {
17        self.gpu.as_ref()
18    }
19
20    /// Borrow the model config for post-construction wiring (e.g. building the
21    /// DeepSeek-V4 MTP proposer, which needs `hidden_size` / `kv_lora_rank` /
22    /// `qk_rope_head_dim` to size its private MLA KV cache).
23    pub fn config_ref(&self) -> &ModelConfig {
24        &self.config
25    }
26
27    /// Install a DFlash drafter as the active proposer, replacing whatever
28    /// MTP proposer (if any) `TransformerModel::new` built. The target's
29    /// hidden-state capture buffer is already allocated when the config's
30    /// `dflash_capture_layers` is non-empty (factory.rs populates it before
31    /// construction), so this method only swaps the proposer slot.
32    ///
33    /// Mutually exclusive with `--speculative` MTP at the CLI level
34    /// (clap `conflicts_with`); this method does not enforce that — the
35    /// caller is expected to have validated the flag combination already.
36    pub fn set_dflash_proposer(&mut self, proposer: std::sync::Arc<dyn DraftProposer>) {
37        if self.proposer.is_some() {
38            tracing::info!("DFlash: replacing existing MTP proposer with BlockDiffusionDraftHead");
39        }
40        // ðŸ”ī ANOMALIES A59. `new()` sized `mtp_prefill_hidden` at `max_seq_len` because the
41        // post-construction proposers (V4, GLM-5.3, DFlash) do not exist yet when it runs —
42        // they need the model's owned GPU backend and its shared embed/lm_head. Now that one
43        // is installed, ask it how many rows it can actually be handed and give back the rest.
44        //
45        // Keyed to the trait, not to a model name: a proposer that can follow the target to
46        // the end of the served context returns `max_seq_len` (the default) and nothing
47        // happens. GLM-5.3's drafter is a DSA block capped at `max_dsa_context`, so at
48        // `--max-seq-len 524288` this returns 4.0 GiB of unreachable capture buffer that was
49        // covered by no reserve at all (see `Glm5NextMtpHead::new`).
50        //
51        // Safe here and nowhere later: construction time, no sequence exists, so no capture is
52        // in flight and no `mtp_prefill_capture_len` is live. Shrink only — a proposer must
53        // never be able to GROW a buffer the capture epilogue already bounds-checks against.
54        // ðŸŠĪ FREE the old buffer BEFORE allocating the small one. The obvious alloc-then-free
55        // ordering holds both at once, and the peak it creates — 4.3 GB — is exactly the
56        // pressure this is here to remove, on a box that has ~3 GB free at this point.
57        let rows = proposer.prefill_hidden_rows(self.mtp_prefill_capacity);
58        if !self.mtp_prefill_hidden.is_null() && rows < self.mtp_prefill_capacity {
59            let was = self.mtp_prefill_capacity;
60            let bytes = rows * self.config.hidden_size * 2;
61            let old = std::mem::replace(
62                &mut self.mtp_prefill_hidden,
63                spark_runtime::gpu::DevicePtr::NULL,
64            );
65            self.mtp_prefill_capacity = 0;
66            match self.gpu.free(old).and_then(|_| self.gpu.alloc(bytes)) {
67                Ok(smaller) => {
68                    self.mtp_prefill_hidden = smaller;
69                    self.mtp_prefill_capacity = rows;
70                    tracing::info!(
71                        "MTP drafter context: capture buffer rightsized {was} -> {rows} rows \
72                         ({:.0} -> {:.0} MB) — the proposer cannot be handed a position past \
73                         {rows} (A59)",
74                        (was * self.config.hidden_size * 2) as f64 / 1e6,
75                        bytes as f64 / 1e6,
76                    );
77                }
78                // NULL + capacity 0 is the feature's own "off" state: the capture epilogue
79                // and the propose-site coverage check both gate on it, so drafter-prefill
80                // disables and the serve keeps running at plain acceptance. Losing a
81                // throughput feature beats failing a serve over an optimisation.
82                Err(e) => tracing::warn!(
83                    "MTP drafter context: rightsizing the capture buffer failed ({e:#}) — \
84                     drafter prefill and carry are DISABLED for this serve"
85                ),
86            }
87        }
88        self.proposer = Some(proposer);
89    }
90
91    /// Install the fused n-gram input embedding (LongCat family). Once set,
92    /// every embedding site routes through it instead of the plain
93    /// `embed_tokens` gather.
94    pub fn set_ngram_embedding(&mut self, ngram: crate::layers::ngram_embed::NgramEmbedding) {
95        tracing::info!("set_ngram_embedding: installed on the served model");
96        self.ngram_embed = Some(std::sync::Mutex::new(ngram));
97    }
98
99    /// True when this model fuses n-gram lookups into its input embedding.
100    pub fn has_ngram_embedding(&self) -> bool {
101        self.ngram_embed.is_some()
102    }
103
104    /// True when MLA prefill cannot honour a prefix-cache skip.
105    ///
106    /// `paged_mla`'s flash call is fed the K/V it just assembled — its own
107    /// comment says "not from paged cache" — so it attends ONLY over the
108    /// tokens being processed. For a full prompt that is correct, and it is
109    /// how every MLA model has been exercised. With a SKIPPED prefix it is
110    /// not: the cached tokens are simply absent from attention and the model
111    /// answers from the tail of its prompt, fluently and wrongly.
112    ///
113    /// MLA keeps a COMPRESSED (latent) KV cache, so letting this path attend
114    /// over history means absorbed attention against that cache, not a wider
115    /// gather. Until that exists, decline the SKIP rather than the cache:
116    /// prefix caching stays on and correct — block reuse and the decode path
117    /// still benefit — and prefill pays full price.
118    ///
119    /// ATLAS_MLA_PREFIX_SKIP=1 opts back in once `paged_mla` attends the cache.
120    pub(crate) fn mla_prefill_needs_full_recompute(&self) -> bool {
121        if std::env::var("ATLAS_MLA_PREFIX_SKIP").as_deref() == Ok("1") {
122            return false;
123        }
124        self.layers.iter().any(|l| l.uses_local_mla_prefill())
125    }
126}