Skip to main content

rlx_distributed/cluster/
cost.rs

1// RLX — versatile ML compiler + runtime.
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! **Model cost, derived from the checkpoint** — so placement does not need a
5//! hand-supplied byte budget.
6//!
7//! [`super::placement::plan_placement`] needs to know what a layer costs. Making
8//! the caller compute that is where "automatic placement" stopped being
9//! automatic: every model crate grew its own arithmetic, and a wrong number
10//! silently produced a plan that OOMs on the third node.
11//!
12//! A checkpoint already carries the answer. A GGUF's header lists every tensor's
13//! name, shape and quant type — that is enough to sum exact bytes per block
14//! without reading a single weight, and the same holds for a safetensors index.
15//! [`ModelCost::from_tensor_index`] takes that listing and works out the layer
16//! count, the per-layer resident cost, and the embedding/head extras.
17//!
18//! ## Routed experts are counted separately, on purpose
19//!
20//! For a fine-grained MoE the expert banks *are* the model — 86 of GLM-5.3-Flash's
21//! 93 GB — but they behave nothing like the rest. Only `top_k` of `n_experts` are
22//! touched per token, so they can live on disk and page in, while the dense
23//! weights must be resident. Folding them into one number would say a 320 B model
24//! needs 93 GB of RAM per node, when in practice it needs ~10 GB resident plus
25//! disk and bandwidth. [`ModelCost::per_layer_expert_bytes`] and
26//! [`ModelCost::per_layer_expert_active_bytes`] keep the two apart so the planner
27//! can budget RAM, disk and IO independently.
28
29use anyhow::{Result, bail};
30use serde::{Deserialize, Serialize};
31
32/// One tensor from a checkpoint's index: enough to size it, not to read it.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct TensorEntry {
35    pub name: String,
36    /// Logical dims (order does not matter here; only the rank and product do).
37    pub dims: Vec<usize>,
38    /// On-disk bytes, i.e. **after** quantization.
39    pub bytes: u64,
40}
41
42impl TensorEntry {
43    pub fn new(name: impl Into<String>, dims: Vec<usize>, bytes: u64) -> Self {
44        Self {
45            name: name.into(),
46            dims,
47            bytes,
48        }
49    }
50    pub fn elements(&self) -> u64 {
51        self.dims.iter().product::<usize>() as u64
52    }
53}
54
55/// How to read a checkpoint's naming convention.
56#[derive(Debug, Clone)]
57pub struct CostOptions {
58    /// Prefixes that introduce a numbered block: `blk.12.…`, `model.layers.12.…`.
59    pub block_prefixes: Markers,
60    /// Substrings marking a **routed expert bank**.
61    ///
62    /// Matched on the name rather than the rank, because rank alone is not a
63    /// signal: MLA's per-head `attn_k_b` is also 3-D, and treating it as an
64    /// expert bank would move it to the disk budget and under-count RAM.
65    pub expert_markers: Markers,
66    /// Substrings marking the token embedding (first stage only).
67    pub embed_markers: Markers,
68    /// Substrings marking the final norm / LM head (last stage only).
69    pub head_markers: Markers,
70    /// `num_experts_per_tok / n_routed_experts` — the fraction of an expert bank
71    /// actually read per token, which is what paging bandwidth depends on.
72    /// Defaults to 1.0 (assume everything is touched) so an unknown model is
73    /// costed pessimistically rather than optimistically.
74    pub expert_active_fraction: f64,
75    /// Relative FLOPs per layer; 1.0 unless a model has non-uniform layers.
76    pub per_layer_flops: f64,
77    /// Names used to infer the KV cache width. See [`Self::kv`].
78    pub kv: KvMarkers,
79    /// Cache element dtype (`f32` default, `f16`/`bf16`, `f8`/`int8`).
80    pub kv_dtype: String,
81}
82
83/// A set of substrings that identify a tensor by name.
84///
85/// The `names.iter().any(|p| t.name.contains(p))` idiom appeared five times
86/// across this module with three different spellings; giving it a type also
87/// gives it a place to hang the rank-1 lookup that the shape probes all need.
88#[derive(Debug, Clone, Default, PartialEq, Eq)]
89pub struct Markers(Vec<String>);
90
91impl Markers {
92    pub fn new<I, S>(names: I) -> Self
93    where
94        I: IntoIterator<Item = S>,
95        S: Into<String>,
96    {
97        Self(names.into_iter().map(Into::into).collect())
98    }
99    pub fn is_empty(&self) -> bool {
100        self.0.is_empty()
101    }
102    pub fn names(&self) -> &[String] {
103        &self.0
104    }
105    /// Does this name carry any of the markers?
106    pub fn matches(&self, name: &str) -> bool {
107        self.0.iter().any(|p| name.contains(p.as_str()))
108    }
109    /// First matching tensor in a block.
110    pub fn find_in<'a>(&self, ts: &[&'a TensorEntry]) -> Option<&'a TensorEntry> {
111        ts.iter().copied().find(|t| self.matches(&t.name))
112    }
113    /// Is any tensor in the block marked?
114    pub fn any_in(&self, ts: &[&TensorEntry]) -> bool {
115        ts.iter().any(|t| self.matches(&t.name))
116    }
117    /// Length of `t` when it matches and is rank-1 — the shape probes all want
118    /// exactly this, and forgetting the rank check silently reads a matrix dim.
119    pub fn rank1_len(&self, t: &TensorEntry) -> Option<usize> {
120        match t.dims.as_slice() {
121            [n] if self.matches(&t.name) => Some(*n),
122            _ => None,
123        }
124    }
125}
126
127impl<S: Into<String>> FromIterator<S> for Markers {
128    fn from_iter<I: IntoIterator<Item = S>>(iter: I) -> Self {
129        Self::new(iter)
130    }
131}
132
133/// Tensor-name markers the KV inference keys off.
134///
135/// Every rule is anchored on `hidden_size`, which is read from a rank-1 norm
136/// inside a block. That makes the inference **orientation-agnostic**: for a 2-D
137/// projection, whichever dim is not `hidden` is the output, so it works on a
138/// GGUF index (`[in, out]`) and an HF one (`[out, in]`) without being told which.
139#[derive(Debug, Clone)]
140pub struct KvMarkers {
141    /// Rank-1 tensor inside a block whose length is `hidden_size`.
142    pub hidden_probe: Markers,
143    /// A recurrent / linear-attention layer. Its state does not grow with
144    /// context, so it contributes no per-token cache.
145    pub recurrent: Markers,
146    /// Per-head count and per-head width of a recurrent state.
147    pub recurrent_heads: Markers,
148    pub recurrent_head_dim: Markers,
149    /// Latent (MLA) attention: only the compressed latent is cached.
150    pub latent_kv: Markers,
151    /// Ordinary attention: the K projection's output width is the cached width,
152    /// doubled for K and V.
153    pub attn_k: Markers,
154}
155
156impl Default for KvMarkers {
157    fn default() -> Self {
158        Self {
159            hidden_probe: Markers::new(["attn_norm.weight", "input_layernorm.weight"]),
160            recurrent: Markers::new(["ssm_", ".mamba", "conv1d"]),
161            recurrent_heads: Markers::new(["ssm_a", "A_log"]),
162            recurrent_head_dim: Markers::new(["ssm_norm.weight", "o_norm.weight"]),
163            latent_kv: Markers::new(["attn_kv_a_mqa", "kv_a_proj_with_mqa"]),
164            attn_k: Markers::new(["attn_k.weight", "k_proj.weight"]),
165        }
166    }
167}
168
169impl Default for CostOptions {
170    fn default() -> Self {
171        Self {
172            block_prefixes: Markers::new(["blk.", "model.layers.", "layers."]),
173            expert_markers: Markers::new(["_exps", ".experts."]),
174            embed_markers: Markers::new(["token_embd", "embed_tokens", "word_embeddings"]),
175            head_markers: Markers::new(["output.weight", "output_norm", "lm_head", "model.norm"]),
176            expert_active_fraction: 1.0,
177            per_layer_flops: 1.0,
178            kv: KvMarkers::default(),
179            kv_dtype: "f32".into(),
180        }
181    }
182}
183
184impl CostOptions {
185    /// Set the active-expert fraction from a router config.
186    pub fn with_experts(mut self, experts_per_tok: usize, n_routed: usize) -> Self {
187        if n_routed > 0 {
188            self.expert_active_fraction = experts_per_tok as f64 / n_routed as f64;
189        }
190        self
191    }
192
193    /// `Some(block_index)` if `name` sits inside a numbered block.
194    fn block_of(&self, name: &str) -> Option<usize> {
195        for p in self.block_prefixes.names() {
196            if let Some(rest) = name.strip_prefix(p.as_str())
197                && let Some(idx) = rest.split('.').next()
198                && let Ok(i) = idx.parse::<usize>()
199            {
200                return Some(i);
201            }
202        }
203        None
204    }
205    fn is_expert(&self, name: &str) -> bool {
206        self.expert_markers.matches(name)
207    }
208}
209
210/// Bytes per cached element for a cache dtype name. Defaults to f32, matching
211/// the runtime's default; an f16 cache is half the reservation, and refusing a
212/// plan that would have fit is as bad as accepting one that will not.
213pub fn kv_elem_bytes(dtype: &str) -> u64 {
214    match dtype {
215        "f16" | "bf16" | "half" => 2,
216        "f8" | "fp8" | "int8" | "i8" => 1,
217        _ => 4,
218    }
219}
220
221/// One block's contribution to the cache, and which shape produced it.
222///
223/// Making the three shapes a type rather than three branches accumulating into
224/// shared counters means a block's cost and its position in the stack cannot
225/// drift apart: the vector is built by `map`, so alignment is structural.
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub enum BlockKv {
228    /// Linear / recurrent attention: a fixed state, no per-token growth.
229    Recurrent { state_bytes: u64 },
230    /// Latent (MLA): only the compressed latent is cached — the point of MLA,
231    /// and ~64× smaller than expanded keys and values.
232    Latent { bytes_per_token: u64 },
233    /// Ordinary attention: keys and values.
234    Attention { bytes_per_token: u64 },
235}
236
237impl BlockKv {
238    pub fn bytes_per_token(&self) -> u64 {
239        match *self {
240            Self::Recurrent { .. } => 0,
241            Self::Latent { bytes_per_token } | Self::Attention { bytes_per_token } => {
242                bytes_per_token
243            }
244        }
245    }
246    pub fn state_bytes(&self) -> u64 {
247        match *self {
248            Self::Recurrent { state_bytes } => state_bytes,
249            _ => 0,
250        }
251    }
252}
253
254/// How much detail we have about the cache.
255///
256/// A sum type because the two cases are genuinely different, not a vector that
257/// might be empty: an inferred profile knows every block, a declared figure is a
258/// flat rate with no block structure at all. Encoding that as `Vec` + "check if
259/// empty" pushes the distinction into every caller.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub enum KvDetail {
262    /// Per-block costs, in layer order.
263    PerBlock(Vec<BlockKv>),
264    /// One rate for every layer.
265    Flat { bytes_per_layer_token: u64 },
266}
267
268impl Default for KvDetail {
269    fn default() -> Self {
270        Self::Flat {
271            bytes_per_layer_token: 0,
272        }
273    }
274}
275
276/// Model width from any block's rank-1 norm.
277fn infer_hidden(entries: &[TensorEntry], opts: &CostOptions) -> usize {
278    entries
279        .iter()
280        .filter(|t| opts.block_of(&t.name).is_some())
281        .filter_map(|t| opts.kv.hidden_probe.rank1_len(t))
282        .max()
283        .unwrap_or(0)
284}
285
286/// Classify one block's tensors into its cache shape.
287///
288/// `None` means "could not read this block" — which is deliberately distinct
289/// from a block that reads as costing nothing. The previous accumulator-based
290/// version conflated them: a recurrent block whose head dims were unreadable was
291/// counted as known and costed at zero state.
292fn classify_block(ts: &[&TensorEntry], m: &KvMarkers, elem: u64) -> Option<BlockKv> {
293    // Anchor: the block's own rank-1 norm gives `hidden`, which is what makes
294    // the output-width read orientation-agnostic.
295    let hidden = ts.iter().find_map(|t| m.hidden_probe.rank1_len(t));
296
297    // The non-hidden dim of a 2-D projection is its output width.
298    let out_of = |t: &TensorEntry| -> Option<usize> {
299        let h = hidden?;
300        match t.dims.as_slice() {
301            [a, b] if *a == h && *b != h => Some(*b),
302            [a, b] if *b == h && *a != h => Some(*a),
303            // Square, or neither matches: ambiguous, so decline.
304            _ => None,
305        }
306    };
307
308    // Recurrent first: a linear-attention block also has an `attn_k`, and
309    // costing that as a KV cache would be badly wrong — it has none.
310    if m.recurrent.any_in(ts) {
311        let heads = ts.iter().find_map(|t| m.recurrent_heads.rank1_len(t))?;
312        let head_dim = ts.iter().find_map(|t| m.recurrent_head_dim.rank1_len(t))?;
313        return Some(BlockKv::Recurrent {
314            state_bytes: (heads * head_dim * head_dim) as u64 * elem,
315        });
316    }
317    if let Some(t) = m.latent_kv.find_in(ts) {
318        return Some(BlockKv::Latent {
319            bytes_per_token: out_of(t)? as u64 * elem,
320        });
321    }
322    if let Some(t) = m.attn_k.find_in(ts) {
323        return Some(BlockKv::Attention {
324            bytes_per_token: 2 * out_of(t)? as u64 * elem,
325        });
326    }
327    None
328}
329
330/// Infer [`KvProfile`] from tensor shapes, block by block.
331///
332/// Any block that cannot be classified makes the whole profile
333/// [`KvSource::Unknown`]: a partial average under-estimates, which is the
334/// dangerous direction for something the planner reserves RAM from.
335fn infer_kv(entries: &[TensorEntry], opts: &CostOptions) -> KvProfile {
336    use std::collections::BTreeMap;
337    let elem = kv_elem_bytes(&opts.kv_dtype);
338    let mut blocks: BTreeMap<usize, Vec<&TensorEntry>> = BTreeMap::new();
339    for t in entries {
340        if let Some(i) = opts.block_of(&t.name) {
341            blocks.entry(i).or_default().push(t);
342        }
343    }
344    if blocks.is_empty() {
345        return KvProfile::unknown();
346    }
347    match blocks
348        .values()
349        .map(|ts| classify_block(ts, &opts.kv, elem))
350        .collect::<Option<Vec<_>>>()
351    {
352        Some(per_block) => KvProfile::inferred(per_block),
353        None => KvProfile::unknown(),
354    }
355}
356
357/// Where a KV figure came from — so "unknown" is never mistaken for "zero".
358///
359/// A cache size of 0 is a legitimate answer (a purely recurrent model has no
360/// per-token cache at all) and it is also what you get when nobody worked it
361/// out. Those must not look the same to a planner that is about to reserve RAM.
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
363pub enum KvSource {
364    /// Read off the checkpoint's tensor shapes.
365    Inferred,
366    /// Supplied by the caller or the config.
367    Declared,
368    /// Could not be determined — the planner must not silently reserve nothing.
369    #[default]
370    Unknown,
371}
372
373/// What the attention cache costs, and how confident we are about it.
374#[derive(Debug, Clone, Default)]
375pub struct KvProfile {
376    detail: KvDetail,
377    source: KvSource,
378}
379
380impl KvProfile {
381    pub fn declared(bytes_per_layer_token: u64) -> Self {
382        Self {
383            detail: KvDetail::Flat {
384                bytes_per_layer_token,
385            },
386            source: KvSource::Declared,
387        }
388    }
389    pub fn inferred(blocks: Vec<BlockKv>) -> Self {
390        Self {
391            detail: KvDetail::PerBlock(blocks),
392            source: KvSource::Inferred,
393        }
394    }
395    pub fn unknown() -> Self {
396        Self::default()
397    }
398
399    pub fn source(&self) -> KvSource {
400        self.source
401    }
402    pub fn is_known(&self) -> bool {
403        self.source != KvSource::Unknown
404    }
405    /// Per-block detail, empty for a flat rate.
406    pub fn blocks(&self) -> &[BlockKv] {
407        match &self.detail {
408            KvDetail::PerBlock(b) => b,
409            KvDetail::Flat { .. } => &[],
410        }
411    }
412
413    /// Mean per-token bytes across the stack.
414    ///
415    /// Derived rather than stored: a cached average is a second copy of the same
416    /// fact that can fall out of step with the per-block detail.
417    pub fn bytes_per_layer_token(&self) -> u64 {
418        match &self.detail {
419            KvDetail::Flat {
420                bytes_per_layer_token,
421            } => *bytes_per_layer_token,
422            KvDetail::PerBlock(b) if !b.is_empty() => {
423                b.iter().map(BlockKv::bytes_per_token).sum::<u64>() / b.len() as u64
424            }
425            KvDetail::PerBlock(_) => 0,
426        }
427    }
428    /// Mean context-independent state per layer.
429    pub fn state_bytes_per_layer(&self) -> u64 {
430        match &self.detail {
431            KvDetail::Flat { .. } => 0,
432            KvDetail::PerBlock(b) if !b.is_empty() => {
433                b.iter().map(BlockKv::state_bytes).sum::<u64>() / b.len() as u64
434            }
435            KvDetail::PerBlock(_) => 0,
436        }
437    }
438
439    /// Total bytes for `layers` at `context`, using the stack average.
440    pub fn bytes(&self, layers: u64, context: usize) -> u64 {
441        self.bytes_per_layer_token()
442            .saturating_mul(layers)
443            .saturating_mul(context as u64)
444            .saturating_add(self.state_bytes_per_layer().saturating_mul(layers))
445    }
446
447    /// Exact bytes for a specific contiguous layer range.
448    ///
449    /// Falls back to the average for a flat rate, so callers need not branch.
450    pub fn bytes_for(&self, layers: std::ops::Range<usize>, context: usize) -> u64 {
451        let b = self.blocks();
452        if b.is_empty() {
453            return self.bytes(layers.len() as u64, context);
454        }
455        let hi = layers.end.min(b.len());
456        let lo = layers.start.min(hi);
457        window_bytes(&b[lo..hi], context)
458    }
459
460    /// The most any contiguous window of `len` layers can cost.
461    ///
462    /// Capacity is decided before the range is, so budgeting for the worst
463    /// placement means the plan fits wherever the range lands — instead of
464    /// fitting on average and OOM-ing on the node that draws the
465    /// attention-heavy slice.
466    pub fn max_window_bytes(&self, len: usize, context: usize) -> u64 {
467        let b = self.blocks();
468        if b.is_empty() || len == 0 {
469            return self.bytes(len as u64, context);
470        }
471        b.windows(len.min(b.len()))
472            .map(|w| window_bytes(w, context))
473            .max()
474            .unwrap_or(0)
475    }
476
477    pub fn summary(&self, layers: u64, context: usize) -> String {
478        let Some(kind) = (match self.source {
479            KvSource::Unknown => None,
480            KvSource::Inferred => Some("inferred"),
481            KvSource::Declared => Some("declared"),
482        }) else {
483            return "KV: unknown".into();
484        };
485        let state = self.state_bytes_per_layer() * layers;
486        format!(
487            "KV: {:.2} GB for {layers} layers @ {context} tokens ({kind}{})",
488            self.bytes(layers, context) as f64 / 1e9,
489            if state > 0 {
490                format!(", + {:.2} GB recurrent state", state as f64 / 1e9)
491            } else {
492                String::new()
493            },
494        )
495    }
496}
497
498/// Cache bytes for one contiguous run of blocks.
499fn window_bytes(blocks: &[BlockKv], context: usize) -> u64 {
500    let per_token: u64 = blocks.iter().map(BlockKv::bytes_per_token).sum();
501    let state: u64 = blocks.iter().map(BlockKv::state_bytes).sum();
502    per_token
503        .saturating_mul(context as u64)
504        .saturating_add(state)
505}
506
507/// Resident-memory + compute cost model for a layer-stack model.
508///
509/// Bytes are the ACTUAL per-node footprint (packed weights as stored), so the
510/// planner's budgets mean something. Build it with
511/// [`ModelCost::from_tensor_index`] rather than by hand.
512#[derive(Debug, Clone, Default)]
513pub struct ModelCost {
514    pub n_layers: usize,
515    /// Average resident bytes per layer, **excluding** routed experts.
516    pub per_layer_bytes: u64,
517    /// Average routed-expert bytes per layer. These may live on disk.
518    pub per_layer_expert_bytes: u64,
519    /// Expert bytes actually read per token — `per_layer_expert_bytes ×
520    /// expert_active_fraction`. Drives the paging-bandwidth estimate.
521    pub per_layer_expert_active_bytes: u64,
522    /// Extra bytes on the FIRST stage (token embedding).
523    pub embed_bytes: u64,
524    /// Extra bytes on the LAST stage (final norm + LM head).
525    pub head_bytes: u64,
526    /// Relative FLOPs per layer (throughput policy; 1.0 = uniform).
527    pub per_layer_flops: f64,
528    /// What the attention cache costs, and whether that is known.
529    ///
530    /// [`ModelCost::from_tensor_index`] infers it from tensor shapes where it
531    /// can (see [`CostOptions::kv`]); otherwise it is
532    /// [`KvSource::Unknown`] and the planner refuses to guess.
533    pub kv: KvProfile,
534    /// Total parameters, used to re-scale the model between precisions.
535    pub params: u64,
536    /// Model width, read from a rank-1 norm inside a block. 0 when unknown.
537    ///
538    /// Worth carrying rather than re-deriving: the worst-case KV bound needs it,
539    /// and inferring it from byte counts (`sqrt(per_layer_bytes / 12)`) is badly
540    /// wrong for a quantized MoE, whose per-layer bytes are dominated by expert
541    /// banks at ~2 bits per weight.
542    pub hidden_size: usize,
543}
544
545/// A slice of the checkpoint: how many bytes a layer touches, at that slice's
546/// own average bit width.
547struct Width {
548    bytes: u64,
549    bits_per_param: f64,
550}
551
552impl Width {
553    /// `bytes` is the per-layer figure; `total_bytes`/`total_elems` set the width.
554    fn of(bytes: u64, total_bytes: u64, total_elems: u64) -> Self {
555        let bits_per_param = if total_elems > 0 {
556            total_bytes as f64 * 8.0 / total_elems as f64
557        } else {
558            0.0
559        };
560        Self {
561            bytes,
562            bits_per_param,
563        }
564    }
565    fn params(&self) -> f64 {
566        if self.bits_per_param > 0.0 {
567            self.bytes as f64 * 8.0 / self.bits_per_param
568        } else {
569            0.0
570        }
571    }
572}
573
574/// FLOPs one layer performs for one decoded token.
575///
576/// Every weight a layer touches contributes one multiply and one add, so the
577/// count is `2 x params_touched`: the dense parameters plus the fraction of the
578/// expert banks that token's routing actually fires. Each group is converted
579/// from bytes at ITS OWN measured width, because a mixed-precision checkpoint
580/// (2-bit experts, 4-bit attention) has no single meaningful bits-per-param.
581///
582/// This being a real FLOP count is load-bearing, not cosmetic. `stage_secs`
583/// divides it by measured GFLOP/s and ADDS the result to `bytes / (MB/s)` — a
584/// genuine seconds term. While this was a dimensionless 1.0 those two terms
585/// were incommensurable, so what balanced compute against IO was the accident
586/// of `per_layer_flops` defaulting to one, and the "s/token" the planner
587/// printed was not a time.
588///
589/// Returns 0.0 when no width can be measured; callers then see a pure IO
590/// estimate rather than a fabricated one.
591fn decode_flops_per_layer(dense: Width, active_experts: Width) -> f64 {
592    2.0 * (dense.params() + active_experts.params())
593}
594
595impl ModelCost {
596    /// The same model at a different weight precision.
597    ///
598    /// Scales every byte figure by `target_bits / current_bits`, where the
599    /// current width is inferred from the checkpoint (`bytes × 8 / params`).
600    /// Approximate by construction — it assumes the whole model re-quantizes
601    /// uniformly — but it is the right shape for answering "would this fit one
602    /// step down?", which is the question a precision ladder asks.
603    ///
604    /// Returns `None` when the parameter count is unknown (nothing to scale by).
605    pub fn at_bits(&self, target_bits: f64) -> Option<Self> {
606        if self.params == 0 || target_bits <= 0.0 {
607            return None;
608        }
609        let cur = self.total_bytes() as f64 * 8.0 / self.params as f64;
610        if cur <= 0.0 {
611            return None;
612        }
613        let r = target_bits / cur;
614        let sc = |b: u64| (b as f64 * r) as u64;
615        Some(Self {
616            per_layer_bytes: sc(self.per_layer_bytes),
617            per_layer_expert_bytes: sc(self.per_layer_expert_bytes),
618            per_layer_expert_active_bytes: sc(self.per_layer_expert_active_bytes),
619            embed_bytes: sc(self.embed_bytes),
620            head_bytes: sc(self.head_bytes),
621            // KV width and layer count do not follow weight precision.
622            ..self.clone()
623        })
624    }
625
626    /// KV bytes one node holds for `layers` at `context` tokens.
627    pub fn kv_bytes(&self, layers: u64, context: usize) -> u64 {
628        self.kv.bytes(layers, context)
629    }
630
631    /// Total bytes across the whole model, experts included.
632    pub fn total_bytes(&self) -> u64 {
633        self.embed_bytes
634            + self.head_bytes
635            + (self.per_layer_bytes + self.per_layer_expert_bytes) * self.n_layers as u64
636    }
637
638    /// Bytes that must be RESIDENT for the whole model (experts excluded).
639    pub fn total_resident_bytes(&self) -> u64 {
640        self.embed_bytes + self.head_bytes + self.per_layer_bytes * self.n_layers as u64
641    }
642
643    /// True when routed experts dominate — the regime where disk and IO decide
644    /// placement rather than RAM.
645    pub fn is_expert_dominated(&self) -> bool {
646        self.per_layer_expert_bytes > self.per_layer_bytes
647    }
648
649    /// Derive the cost model from a checkpoint's tensor index.
650    ///
651    /// Layer count is `max(block index) + 1`; per-layer figures are averaged over
652    /// the blocks actually present, so a model with a trailing MTP block or an
653    /// uneven layer stack still gets a usable mean.
654    pub fn from_tensor_index(entries: &[TensorEntry], opts: &CostOptions) -> Result<Self> {
655        if entries.is_empty() {
656            bail!("empty tensor index");
657        }
658        let mut max_block: Option<usize> = None;
659        let (mut layer_bytes, mut expert_bytes) = (0u64, 0u64);
660        // Element counts alongside the bytes: experts are routinely quantized
661        // harder than the attention weights beside them (2-bit banks against
662        // 4-bit projections is ordinary), so one average width across the whole
663        // checkpoint converts bytes back into the wrong number of parameters.
664        let (mut layer_elems, mut expert_elems) = (0u64, 0u64);
665        let (mut embed, mut head) = (0u64, 0u64);
666
667        for t in entries {
668            match opts.block_of(&t.name) {
669                Some(i) => {
670                    max_block = Some(max_block.map_or(i, |m: usize| m.max(i)));
671                    if opts.is_expert(&t.name) {
672                        expert_bytes += t.bytes;
673                        expert_elems += t.elements();
674                    } else {
675                        layer_bytes += t.bytes;
676                        layer_elems += t.elements();
677                    }
678                }
679                None => {
680                    // Order matters: an embedding name is checked first because
681                    // tied-head checkpoints reuse it for both roles.
682                    if opts.embed_markers.matches(&t.name) {
683                        embed += t.bytes;
684                    } else if opts.head_markers.matches(&t.name) {
685                        head += t.bytes;
686                    }
687                }
688            }
689        }
690
691        let n_layers = max_block.map(|m| m + 1).unwrap_or(0);
692        if n_layers == 0 {
693            bail!(
694                "no numbered blocks found — checked prefixes {:?}; pass CostOptions \
695                 with the checkpoint's naming convention",
696                opts.block_prefixes
697            );
698        }
699        let n = n_layers as u64;
700        let per_layer_expert_bytes = expert_bytes / n;
701        let per_layer_bytes = layer_bytes / n;
702        let per_layer_expert_active_bytes =
703            (per_layer_expert_bytes as f64 * opts.expert_active_fraction.clamp(0.0, 1.0)) as u64;
704        let params: u64 = entries.iter().map(|t| t.elements()).sum();
705        Ok(Self {
706            n_layers,
707            per_layer_bytes,
708            per_layer_expert_bytes,
709            per_layer_expert_active_bytes,
710            embed_bytes: embed,
711            head_bytes: head,
712            per_layer_flops: decode_flops_per_layer(
713                Width::of(per_layer_bytes, layer_bytes, layer_elems),
714                Width::of(per_layer_expert_active_bytes, expert_bytes, expert_elems),
715            ) * opts.per_layer_flops,
716            hidden_size: infer_hidden(entries, opts),
717            kv: infer_kv(entries, opts),
718            params,
719        })
720    }
721
722    /// One-line summary for logs.
723    pub fn summary(&self) -> String {
724        let gb = |b: u64| b as f64 / 1e9;
725        if self.per_layer_expert_bytes > 0 {
726            format!(
727                "{} layers | {:.2} GB resident + {:.2} GB experts = {:.2} GB \
728                 | per layer {:.2} GB + {:.2} GB experts ({:.2} GB active)",
729                self.n_layers,
730                gb(self.total_resident_bytes()),
731                gb(self.per_layer_expert_bytes * self.n_layers as u64),
732                gb(self.total_bytes()),
733                gb(self.per_layer_bytes),
734                gb(self.per_layer_expert_bytes),
735                gb(self.per_layer_expert_active_bytes),
736            )
737        } else {
738            format!(
739                "{} layers | {:.2} GB | per layer {:.2} GB",
740                self.n_layers,
741                gb(self.total_bytes()),
742                gb(self.per_layer_bytes),
743            )
744        }
745    }
746}
747
748#[cfg(feature = "gguf")]
749mod from_gguf {
750    use super::*;
751    use std::path::Path;
752
753    /// Read a GGUF's tensor index — header only, no weight data.
754    ///
755    /// A split checkpoint may be passed as any one shard for the metadata, but
756    /// the byte totals are only complete if every shard is listed; prefer
757    /// [`tensor_index_multi`].
758    pub fn tensor_index(path: impl AsRef<Path>) -> Result<Vec<TensorEntry>> {
759        tensor_index_multi(std::slice::from_ref(&path.as_ref().to_path_buf()))
760    }
761
762    /// Tensor index across every shard of a split checkpoint.
763    ///
764    /// Reads headers only. A 93 GB model is indexed from a few MB, which is what
765    /// makes planning against the real checkpoint cheap enough to do every run.
766    pub fn tensor_index_multi(paths: &[std::path::PathBuf]) -> Result<Vec<TensorEntry>> {
767        let mut out = Vec::new();
768        for p in paths {
769            let f = rlx_gguf::GgufFile::from_path(p)?;
770            for t in f.tensors.values() {
771                let bytes =
772                    rlx_gguf::bytes_for_public(t.dtype, t.n_elements()).ok_or_else(|| {
773                        anyhow::anyhow!("{}: unsupported ggml type {:?}", t.name, t.dtype)
774                    })? as u64;
775                out.push(TensorEntry::new(t.name.clone(), t.shape.clone(), bytes));
776            }
777        }
778        Ok(out)
779    }
780
781    impl ModelCost {
782        /// Derive the cost model straight from GGUF shards.
783        pub fn from_gguf(paths: &[std::path::PathBuf], opts: &CostOptions) -> Result<Self> {
784            Self::from_tensor_index(&tensor_index_multi(paths)?, opts)
785        }
786    }
787}
788
789#[cfg(feature = "gguf")]
790pub use from_gguf::{tensor_index, tensor_index_multi};
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795
796    /// `per_layer_flops` has to be a real FLOP count, because `stage_secs`
797    /// divides it by GFLOP/s and adds the result to a `bytes / (MB/s)` wait. It
798    /// used to be a dimensionless 1.0, which made the compute/IO balance an
799    /// accident of that default and the printed "s/token" not a time.
800    ///
801    /// Checked against the arithmetic anyone can do by hand: a decode step
802    /// touches `2 x active_params` FLOPs, so summing the per-layer figure over
803    /// the stack must recover twice the model's active parameter count.
804    #[test]
805    fn per_layer_flops_is_a_real_decode_flop_count() {
806        let idx = moe_index();
807        // 8 of 288 experts fire per token, as GLM-5.3-Flash routes.
808        let opts = CostOptions {
809            expert_active_fraction: 8.0 / 288.0,
810            ..Default::default()
811        };
812        let c = ModelCost::from_tensor_index(&idx, &opts).unwrap();
813
814        // Active params: every dense weight in a block, plus the fired slice of
815        // each block's expert banks. Derived from the index, not from the code
816        // under test.
817        let dense_elems: u64 = idx
818            .iter()
819            .filter(|t| opts.block_of(&t.name).is_some() && !opts.is_expert(&t.name))
820            .map(|t| t.elements())
821            .sum();
822        let expert_elems: u64 = idx
823            .iter()
824            .filter(|t| opts.block_of(&t.name).is_some() && opts.is_expert(&t.name))
825            .map(|t| t.elements())
826            .sum();
827        let active = dense_elems as f64 + expert_elems as f64 * opts.expert_active_fraction;
828        let want = 2.0 * active;
829        let got = c.per_layer_flops * c.n_layers as f64;
830        assert!(
831            (got - want).abs() <= want * 0.02,
832            "stack FLOPs/token {got:.3e} against 2 x active params {want:.3e}"
833        );
834        assert!(
835            c.per_layer_flops > 1.0,
836            "still the dimensionless placeholder: {}",
837            c.per_layer_flops
838        );
839    }
840
841    /// Experts are quantized harder than the attention weights beside them, so
842    /// converting bytes back to parameters at one checkpoint-wide average width
843    /// mis-counts both groups. Each must use its own measured width.
844    #[test]
845    fn mixed_precision_groups_use_their_own_bit_width() {
846        // Same shapes, but the expert banks are stored at a quarter the width.
847        let mut idx = moe_index();
848        for t in &mut idx {
849            if t.name.contains("_exps") {
850                t.bytes /= 4;
851            }
852        }
853        let opts = CostOptions {
854            expert_active_fraction: 8.0 / 288.0,
855            ..Default::default()
856        };
857        let c = ModelCost::from_tensor_index(&idx, &opts).unwrap();
858
859        let dense_elems: u64 = idx
860            .iter()
861            .filter(|t| opts.block_of(&t.name).is_some() && !opts.is_expert(&t.name))
862            .map(|t| t.elements())
863            .sum();
864        let expert_elems: u64 = idx
865            .iter()
866            .filter(|t| opts.block_of(&t.name).is_some() && opts.is_expert(&t.name))
867            .map(|t| t.elements())
868            .sum();
869        // Squeezing the expert BYTES must not change the parameter count, and so
870        // must not change the FLOPs.
871        let want = 2.0 * (dense_elems as f64 + expert_elems as f64 * opts.expert_active_fraction);
872        let got = c.per_layer_flops * c.n_layers as f64;
873        assert!(
874            (got - want).abs() <= want * 0.02,
875            "re-quantizing the experts moved the FLOP count to {got:.3e} from \
876             {want:.3e}; a single average width was used for both groups"
877        );
878    }
879
880    /// A GLM-5.3-Flash-shaped index: 3 dense layers then MoE, routed banks
881    /// dwarfing everything else.
882    fn moe_index() -> Vec<TensorEntry> {
883        let mut v = vec![
884            TensorEntry::new("token_embd.weight", vec![4096, 154880], 357_000_000),
885            TensorEntry::new("output.weight", vec![4096, 154880], 357_000_000),
886            TensorEntry::new("output_norm.weight", vec![4096], 16_384),
887        ];
888        for i in 0..4 {
889            v.push(TensorEntry::new(
890                format!("blk.{i}.attn_q.weight"),
891                vec![4096, 8192],
892                23_000_000,
893            ));
894            v.push(TensorEntry::new(
895                format!("blk.{i}.attn_norm.weight"),
896                vec![4096],
897                16_384,
898            ));
899            if i >= 1 {
900                for b in ["ffn_gate_exps", "ffn_up_exps", "ffn_down_exps"] {
901                    v.push(TensorEntry::new(
902                        format!("blk.{i}.{b}.weight"),
903                        vec![288, 2048, 4096],
904                        600_000_000,
905                    ));
906                }
907            }
908        }
909        v
910    }
911
912    #[test]
913    fn derives_layers_and_splits_experts_from_dense() {
914        let opts = CostOptions::default().with_experts(8, 288);
915        let c = ModelCost::from_tensor_index(&moe_index(), &opts).unwrap();
916        assert_eq!(c.n_layers, 4);
917        assert_eq!(c.embed_bytes, 357_000_000);
918        // head = output.weight + output_norm.weight
919        assert_eq!(c.head_bytes, 357_000_000 + 16_384);
920        // Dense per-layer: 4 blocks × (23 MB + 16 KB), averaged.
921        assert_eq!(c.per_layer_bytes, 23_016_384);
922        // Experts: 3 blocks × 3 banks × 600 MB over 4 layers.
923        assert_eq!(c.per_layer_expert_bytes, 3 * 3 * 600_000_000 / 4);
924        assert!(c.is_expert_dominated());
925    }
926
927    /// Only `top_k / n_experts` of a bank is read per token — the number the
928    /// paging-bandwidth estimate hangs on.
929    #[test]
930    fn active_expert_bytes_track_the_router() {
931        let all = CostOptions::default();
932        let sparse = CostOptions::default().with_experts(8, 288);
933        let idx = moe_index();
934        let a = ModelCost::from_tensor_index(&idx, &all).unwrap();
935        let s = ModelCost::from_tensor_index(&idx, &sparse).unwrap();
936        assert_eq!(a.per_layer_expert_active_bytes, a.per_layer_expert_bytes);
937        assert!(s.per_layer_expert_active_bytes * 30 < s.per_layer_expert_bytes);
938        assert_eq!(s.per_layer_expert_bytes, a.per_layer_expert_bytes);
939    }
940
941    /// MLA's per-head `attn_k_b` is 3-D too. Classifying by rank instead of name
942    /// would bill it to the disk budget and under-count RAM.
943    #[test]
944    fn rank_three_non_expert_tensors_stay_resident() {
945        let idx = vec![
946            TensorEntry::new("blk.0.attn_k_b.weight", vec![64, 512, 256], 9_000_000),
947            TensorEntry::new("blk.0.attn_norm.weight", vec![4096], 16_384),
948        ];
949        let c = ModelCost::from_tensor_index(&idx, &CostOptions::default()).unwrap();
950        assert_eq!(c.per_layer_expert_bytes, 0);
951        assert_eq!(c.per_layer_bytes, 9_016_384);
952        assert!(!c.is_expert_dominated());
953    }
954
955    #[test]
956    fn hf_naming_works_too() {
957        let idx = vec![
958            TensorEntry::new("model.embed_tokens.weight", vec![32, 8], 1024),
959            TensorEntry::new("model.layers.0.self_attn.q_proj.weight", vec![8, 8], 256),
960            TensorEntry::new("model.layers.1.self_attn.q_proj.weight", vec![8, 8], 256),
961            TensorEntry::new("model.layers.1.mlp.experts.0.up.weight", vec![8, 8], 4096),
962            TensorEntry::new("lm_head.weight", vec![32, 8], 1024),
963        ];
964        let c = ModelCost::from_tensor_index(&idx, &CostOptions::default()).unwrap();
965        assert_eq!(c.n_layers, 2);
966        assert_eq!(c.embed_bytes, 1024);
967        assert_eq!(c.head_bytes, 1024);
968        assert_eq!(c.per_layer_expert_bytes, 2048);
969    }
970
971    /// Stepping down the precision ladder must shrink weights but leave the KV
972    /// width and layer count alone — the cache does not follow weight precision.
973    #[test]
974    fn at_bits_scales_weights_only() {
975        let idx = moe_index();
976        let mut c = ModelCost::from_tensor_index(&idx, &CostOptions::default()).unwrap();
977        c.kv = KvProfile::declared(4096);
978        let before = c.total_bytes();
979        let half = c
980            .at_bits(c.total_bytes() as f64 * 8.0 / c.params as f64 / 2.0)
981            .unwrap();
982        assert!(
983            (half.total_bytes() as f64 - before as f64 / 2.0).abs() < before as f64 * 0.01,
984            "halving the width should roughly halve the bytes"
985        );
986        assert_eq!(half.n_layers, c.n_layers);
987        assert_eq!(
988            half.kv.bytes_per_layer_token(),
989            c.kv.bytes_per_layer_token()
990        );
991    }
992
993    #[test]
994    fn at_bits_is_none_without_a_parameter_count() {
995        let c = ModelCost {
996            params: 0,
997            ..Default::default()
998        };
999        assert!(c.at_bits(4.0).is_none());
1000    }
1001
1002    #[test]
1003    fn kv_bytes_scale_with_layers_and_context() {
1004        let c = ModelCost {
1005            kv: KvProfile::declared(1024),
1006            ..Default::default()
1007        };
1008        assert_eq!(c.kv_bytes(4, 100), 4 * 100 * 1024);
1009        assert_eq!(c.kv_bytes(0, 100), 0);
1010    }
1011
1012    // ── KV inference ────────────────────────────────────────────────────
1013
1014    /// A GLM-5.3-Flash-shaped stack: 34 KDA layers whose state is O(1) in
1015    /// context, 11 latent-attention layers that cache only the 512-wide latent.
1016    fn hybrid_index() -> Vec<TensorEntry> {
1017        let (hidden, kv_lora, heads, head_dim) = (4096usize, 512usize, 64usize, 128usize);
1018        let mut v = Vec::new();
1019        for i in 0..45 {
1020            v.push(TensorEntry::new(
1021                format!("blk.{i}.attn_norm.weight"),
1022                vec![hidden],
1023                hidden as u64 * 4,
1024            ));
1025            if i % 4 == 3 {
1026                // latent attention: GGML dim order [in, out]
1027                v.push(TensorEntry::new(
1028                    format!("blk.{i}.attn_kv_a_mqa.weight"),
1029                    vec![hidden, kv_lora],
1030                    1,
1031                ));
1032            } else {
1033                v.push(TensorEntry::new(format!("blk.{i}.ssm_a"), vec![heads], 1));
1034                v.push(TensorEntry::new(
1035                    format!("blk.{i}.ssm_norm.weight"),
1036                    vec![head_dim],
1037                    1,
1038                ));
1039            }
1040        }
1041        v
1042    }
1043
1044    /// The headline case: a hybrid model must not be charged an attention cache
1045    /// for its recurrent layers, and its recurrent state must not be charged per
1046    /// token. Getting either wrong misprices the model by ~4×.
1047    #[test]
1048    fn infers_hybrid_recurrent_plus_latent_attention() {
1049        let c = ModelCost::from_tensor_index(&hybrid_index(), &CostOptions::default()).unwrap();
1050        assert_eq!(c.kv.source(), KvSource::Inferred);
1051        // 11 latent layers × 512 × 4 B, averaged over 45.
1052        assert_eq!(c.kv.bytes_per_layer_token(), 11 * 512 * 4 / 45);
1053        // 34 recurrent layers × 64 × 128² × 4 B, averaged over 45.
1054        assert_eq!(c.kv.state_bytes_per_layer(), 34 * 64 * 128 * 128 * 4 / 45);
1055
1056        // At 128 k this is ~2.95 GB of cache plus ~0.14 GB of fixed state.
1057        let total = c.kv.bytes(45, 131_072);
1058        assert!(
1059            (total as f64 / 1e9 - 3.09).abs() < 0.05,
1060            "expected ~3.09 GB, got {:.2} GB",
1061            total as f64 / 1e9
1062        );
1063    }
1064
1065    /// Ordinary attention caches K and V, so twice the projection width.
1066    #[test]
1067    fn infers_gqa_attention_as_two_times_k_width() {
1068        let (hidden, kv_width) = (4096usize, 1024usize);
1069        let idx = vec![
1070            TensorEntry::new("blk.0.attn_norm.weight", vec![hidden], 1),
1071            TensorEntry::new("blk.0.attn_k.weight", vec![kv_width, hidden], 1),
1072        ];
1073        let c = ModelCost::from_tensor_index(&idx, &CostOptions::default()).unwrap();
1074        assert_eq!(c.kv.source(), KvSource::Inferred);
1075        assert_eq!(c.kv.bytes_per_layer_token(), 2 * kv_width as u64 * 4);
1076    }
1077
1078    /// The inference is anchored on `hidden`, so it reads a GGUF index
1079    /// (`[in, out]`) and an HF one (`[out, in]`) identically.
1080    #[test]
1081    fn kv_inference_is_orientation_agnostic() {
1082        let (hidden, w) = (4096usize, 1024usize);
1083        let gguf = vec![
1084            TensorEntry::new("blk.0.attn_norm.weight", vec![hidden], 1),
1085            TensorEntry::new("blk.0.attn_k.weight", vec![hidden, w], 1),
1086        ];
1087        let hf = vec![
1088            TensorEntry::new("blk.0.attn_norm.weight", vec![hidden], 1),
1089            TensorEntry::new("blk.0.attn_k.weight", vec![w, hidden], 1),
1090        ];
1091        let a = ModelCost::from_tensor_index(&gguf, &CostOptions::default()).unwrap();
1092        let b = ModelCost::from_tensor_index(&hf, &CostOptions::default()).unwrap();
1093        assert_eq!(a.kv.bytes_per_layer_token(), b.kv.bytes_per_layer_token());
1094        assert_eq!(a.kv.bytes_per_layer_token(), 2 * w as u64 * 4);
1095    }
1096
1097    /// Unrecognised attention leaves it Unknown — never a silent zero, which
1098    /// would read as "no cache" and reserve nothing.
1099    #[test]
1100    fn unreadable_attention_is_unknown_not_zero() {
1101        let idx = vec![
1102            TensorEntry::new("blk.0.attn_norm.weight", vec![4096], 1),
1103            TensorEntry::new("blk.0.mystery_attention.weight", vec![7, 9], 1),
1104        ];
1105        let c = ModelCost::from_tensor_index(&idx, &CostOptions::default()).unwrap();
1106        assert_eq!(c.kv.source(), KvSource::Unknown);
1107        assert!(!c.kv.is_known());
1108        assert_eq!(c.kv.bytes_per_layer_token(), 0);
1109    }
1110
1111    /// A recurrent block whose head dims are unreadable must make the profile
1112    /// Unknown — not be counted as known and costed at zero state.
1113    ///
1114    /// The accumulator-based version had exactly this bug: the recurrent branch
1115    /// incremented `known` unconditionally and only added state when both dims
1116    /// parsed, so an unreadable linear-attention layer silently contributed
1117    /// nothing while still claiming the stack was fully understood.
1118    #[test]
1119    fn recurrent_block_with_unreadable_dims_is_unknown() {
1120        // `ssm_` marks it recurrent, but there is no rank-1 head-count tensor.
1121        let idx = vec![
1122            TensorEntry::new("blk.0.attn_norm.weight", vec![4096], 1),
1123            TensorEntry::new("blk.0.ssm_conv1d.weight", vec![8192, 1, 4], 1),
1124        ];
1125        let c = ModelCost::from_tensor_index(&idx, &CostOptions::default()).unwrap();
1126        assert_eq!(c.kv.source(), KvSource::Unknown);
1127        assert!(!c.kv.is_known());
1128    }
1129
1130    /// Classification is order-sensitive: a linear-attention block also has an
1131    /// `attn_k`, and reading that as a KV cache would invent one where the layer
1132    /// has none.
1133    #[test]
1134    fn recurrent_wins_over_attn_k_in_the_same_block() {
1135        let idx = vec![
1136            TensorEntry::new("blk.0.attn_norm.weight", vec![4096], 1),
1137            TensorEntry::new("blk.0.attn_k.weight", vec![8192, 4096], 1),
1138            TensorEntry::new("blk.0.ssm_a", vec![64], 1),
1139            TensorEntry::new("blk.0.ssm_norm.weight", vec![128], 1),
1140        ];
1141        let c = ModelCost::from_tensor_index(&idx, &CostOptions::default()).unwrap();
1142        assert_eq!(
1143            c.kv.blocks(),
1144            [BlockKv::Recurrent {
1145                state_bytes: 64 * 128 * 128 * 4
1146            }]
1147        );
1148        assert_eq!(c.kv.bytes_per_layer_token(), 0, "no per-token cache");
1149    }
1150
1151    /// Partial recognition is not knowledge: if one block cannot be read the
1152    /// average would under-estimate, which is the dangerous direction.
1153    #[test]
1154    fn partially_readable_stack_is_unknown() {
1155        let mut idx = hybrid_index();
1156        idx.retain(|t| !t.name.starts_with("blk.7."));
1157        idx.push(TensorEntry::new("blk.7.attn_norm.weight", vec![4096], 1));
1158        idx.push(TensorEntry::new("blk.7.something.weight", vec![3, 5], 1));
1159        let c = ModelCost::from_tensor_index(&idx, &CostOptions::default()).unwrap();
1160        assert_eq!(c.kv.source(), KvSource::Unknown);
1161    }
1162
1163    /// A square projection is genuinely ambiguous (which dim is the output?), so
1164    /// it declines rather than guessing.
1165    #[test]
1166    fn square_projection_is_ambiguous_and_declines() {
1167        let idx = vec![
1168            TensorEntry::new("blk.0.attn_norm.weight", vec![4096], 1),
1169            TensorEntry::new("blk.0.attn_k.weight", vec![4096, 4096], 1),
1170        ];
1171        let c = ModelCost::from_tensor_index(&idx, &CostOptions::default()).unwrap();
1172        assert_eq!(c.kv.source(), KvSource::Unknown);
1173    }
1174
1175    /// The average is not what any node pays. On the hybrid stack, layers 0..3
1176    /// hold no attention layer at all while 3..7 hold one — costing both at the
1177    /// mean over-reserves the first and under-reserves the second.
1178    #[test]
1179    fn per_range_kv_beats_the_average_on_a_hybrid_stack() {
1180        let c = ModelCost::from_tensor_index(&hybrid_index(), &CostOptions::default()).unwrap();
1181        let ctx = 4096;
1182        let none = c.kv.bytes_for(0..3, ctx); // blocks 0,1,2 are all recurrent
1183        let one = c.kv.bytes_for(3..7, ctx); // block 3 is latent attention
1184        let avg3 = c.kv.bytes(3, ctx);
1185
1186        // No attention layer ⇒ no per-token cache, only recurrent state.
1187        assert_eq!(
1188            none,
1189            c.kv.blocks()[0..3]
1190                .iter()
1191                .map(BlockKv::state_bytes)
1192                .sum::<u64>()
1193        );
1194        // One attention layer ⇒ exactly its width × context, plus 3 states.
1195        assert_eq!(
1196            one,
1197            512 * 4 * ctx as u64
1198                + c.kv.blocks()[3..7]
1199                    .iter()
1200                    .map(BlockKv::state_bytes)
1201                    .sum::<u64>()
1202        );
1203        // The mean sits between the two, so it is wrong in both directions.
1204        assert!(
1205            avg3 > none,
1206            "average over-reserves the recurrent-only range"
1207        );
1208        assert!(
1209            c.kv.bytes(4, ctx) < one,
1210            "average under-reserves the range holding an attention layer"
1211        );
1212    }
1213
1214    /// Capacity is decided before the range is, so it must budget for the WORST
1215    /// window of that length — otherwise the plan fits on average and dies on
1216    /// whichever node draws the attention-heavy slice.
1217    #[test]
1218    fn window_max_bounds_every_placement_of_a_given_length() {
1219        let c = ModelCost::from_tensor_index(&hybrid_index(), &CostOptions::default()).unwrap();
1220        let ctx = 4096;
1221        for len in [1usize, 3, 4, 8, 45] {
1222            let worst = c.kv.max_window_bytes(len, ctx);
1223            for start in 0..=(45 - len) {
1224                assert!(
1225                    c.kv.bytes_for(start..start + len, ctx) <= worst,
1226                    "window {start}..{} exceeds the bound at len {len}",
1227                    start + len
1228                );
1229            }
1230            assert!(
1231                worst >= c.kv.bytes(len as u64, ctx),
1232                "bound must cover the mean"
1233            );
1234        }
1235    }
1236
1237    /// An f16 cache is half an f32 one; over-reserving 2× can refuse a plan that
1238    /// would have fit.
1239    #[test]
1240    fn cache_dtype_scales_the_reservation() {
1241        let f32o = CostOptions::default();
1242        let f16o = CostOptions {
1243            kv_dtype: "f16".into(),
1244            ..Default::default()
1245        };
1246        let a = ModelCost::from_tensor_index(&hybrid_index(), &f32o).unwrap();
1247        let b = ModelCost::from_tensor_index(&hybrid_index(), &f16o).unwrap();
1248        // Compare the exact per-block figures: the headline averages are integer
1249        // divisions over 45 blocks and so differ by a rounding step.
1250        assert_eq!(
1251            a.kv.blocks()[3].bytes_per_token(),
1252            2 * b.kv.blocks()[3].bytes_per_token()
1253        );
1254        // The recurrent state narrows too — it is a cache like any other.
1255        assert_eq!(
1256            a.kv.blocks()[0].state_bytes(),
1257            2 * b.kv.blocks()[0].state_bytes()
1258        );
1259        assert!(a.kv.blocks()[0].state_bytes() > 0);
1260        assert_eq!(kv_elem_bytes("bf16"), 2);
1261        assert_eq!(kv_elem_bytes("int8"), 1);
1262        assert_eq!(kv_elem_bytes("anything-else"), 4);
1263    }
1264
1265    /// `hidden_size` is read from the checkpoint, not guessed from byte counts —
1266    /// the byte-count estimate is badly wrong for a quantized MoE.
1267    #[test]
1268    fn hidden_size_comes_from_the_checkpoint() {
1269        let c = ModelCost::from_tensor_index(&hybrid_index(), &CostOptions::default()).unwrap();
1270        assert_eq!(c.hidden_size, 4096);
1271        // What the old sqrt(per_layer_bytes/12) estimate would have said.
1272        let guessed = ((c.per_layer_bytes as f64 / 12.0).sqrt()).max(1.0) as usize;
1273        assert_ne!(guessed, c.hidden_size);
1274    }
1275
1276    /// A declared figure has no per-block detail, so range queries must fall
1277    /// back to the average rather than returning zero.
1278    #[test]
1279    fn declared_profile_falls_back_to_the_average_for_ranges() {
1280        let k = KvProfile::declared(1000);
1281        assert_eq!(k.bytes_for(0..4, 10), 4 * 10 * 1000);
1282        assert_eq!(k.max_window_bytes(4, 10), 4 * 10 * 1000);
1283    }
1284
1285    #[test]
1286    fn unrecognized_naming_is_an_error_not_a_zero_layer_model() {
1287        let idx = vec![TensorEntry::new("weird.0.w", vec![4], 16)];
1288        let err = ModelCost::from_tensor_index(&idx, &CostOptions::default())
1289            .unwrap_err()
1290            .to_string();
1291        assert!(err.contains("no numbered blocks"), "{err}");
1292    }
1293}