Skip to main content

cortiq_engine/
mimo_moe.rs

1//! MiMo-V2 expert placement on a card that cannot hold every expert.
2//!
3//! MiMo-V2.6-Flash has 47 MoE layers of 256 experts (top-8, sigmoid +
4//! selection-bias routing, no shared expert). At the default q4tp profile
5//! one expert is 13.1 MB, a layer 3.36 GB and all experts 157.7 GB, so on
6//! every card some experts live only in host RAM. Three placements exist:
7//!
8//! * **prefix** — whole MoE layers resident from the bottom of the stack up
9//!   while the budget lasts (the generic token graph's device prefix, or the
10//!   per-op residency arena when the graph declines), the remaining layers
11//!   walk the host: attention AND all 8 experts of every tail layer stream
12//!   from RAM. Cost per token ≈ one graph submit + (L−P)·(A + 8e)/B_cpu.
13//! * **dynamic** — every MoE layer walks the host loop with its projections
14//!   on the device (per-op) and its experts in ONE model-wide VRAM bank
15//!   keyed (layer, expert) with an LRU ([`Bank`]: the segmented
16//!   `dsv4_global_*` device buffers DeepSeek-V4.1 and Qwen3.8 already use,
17//!   with its own slot map whose fills upload on a background thread while
18//!   the missing token computes the expert on the host). The host
19//!   routes exactly (`moe_ffn_route`), resident picks run in
20//!   `dsv4_moe_frame` (forced ids, preweighted, no shared expert) while the
21//!   cold picks run CONCURRENTLY on the CPU with the same q4tp kernels the
22//!   host path uses (`moe_cold_experts_cpu`), and the two parts are summed.
23//!   Cost per token ≈ L·(3 fences) + all bytes at device speed + the cold
24//!   share 8·(1−h(s))·e/B_cpu per layer, h(s) = LRU hit rate at s slots.
25//! * **hybrid** — a whole-layer prefix of P layers plus the bank for the
26//!   rest. Only a device graph makes whole-layer residency cheaper than the
27//!   bank (one submit for the prefix instead of per-layer fences); without
28//!   one, pinning 256 slots of a layer serves fewer hits than giving the same
29//!   slots to the LRU.
30//!
31//! Nothing here approximates: routing is the host's, every chosen expert is
32//! computed (on the device or on the host) with its full weight, and the
33//! result differs from the host path only in f32 summation order.
34//!
35//! The mode is chosen once per process from the VRAM budget and the measured
36//! costs in [`Costs::measured`] (see [`place`]); `CMF_MIMO_MOE` =
37//! `prefix|dynamic|hybrid|auto` overrides it. One `info` line states the
38//! choice and why.
39
40use crate::pipeline::{MoeFfn, MoeRoute};
41use crate::pool::Pool;
42#[cfg(feature = "gpu")]
43use cortiq_core::{CmfModel, TensorDtype};
44#[cfg(feature = "gpu")]
45use std::sync::Arc;
46
47/// Where the experts of the MoE layers execute.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum MoeMode {
50    Prefix,
51    Dynamic,
52    Hybrid,
53}
54
55impl MoeMode {
56    pub fn name(self) -> &'static str {
57        match self {
58            Self::Prefix => "prefix",
59            Self::Dynamic => "dynamic",
60            Self::Hybrid => "hybrid",
61        }
62    }
63
64    #[cfg_attr(not(feature = "gpu"), allow(dead_code))]
65    fn parse(s: &str) -> Option<Option<Self>> {
66        match s.trim().to_ascii_lowercase().as_str() {
67            "prefix" => Some(Some(Self::Prefix)),
68            "dynamic" => Some(Some(Self::Dynamic)),
69            "hybrid" => Some(Some(Self::Hybrid)),
70            "auto" | "" => Some(None),
71            _ => None,
72        }
73    }
74}
75
76/// Everything the placement decision reads — plain data, so the policy is
77/// testable without a device.
78#[derive(Clone, Debug)]
79pub struct PlacementInputs {
80    /// Device weight budget in bytes (`CMF_GPU_VRAM_MB` or VRAM − reserve).
81    pub budget: u64,
82    /// Device bytes of every non-expert weight (attention, norms, routers,
83    /// dense layers, lm_head) — they must stay resident in every mode.
84    pub non_expert: u64,
85    /// Bytes of one expert (gate + up + down).
86    pub per_expert: u64,
87    pub moe_layers: usize,
88    pub n_experts: usize,
89    pub top_k: usize,
90    /// Mean non-expert device bytes of one layer (attention, router, norms).
91    pub attn_per_layer: u64,
92    /// A whole-token device graph can run whole resident layers of this
93    /// model (then a whole-layer prefix costs one submit, not per-layer
94    /// fences).
95    pub graph_prefix: bool,
96}
97
98/// A placement: the mode, how many MoE layers (from the first MoE layer)
99/// run on the whole-layer prefix path, and how many bank slots to allocate.
100#[derive(Clone, Debug, PartialEq)]
101pub struct Placement {
102    pub mode: MoeMode,
103    pub prefix_layers: usize,
104    pub bank_slots: usize,
105    /// Predicted seconds per token of the chosen mode (model only).
106    pub predicted_s: f64,
107    pub reason: String,
108}
109
110/// Measured cost constants of the decision.
111#[derive(Clone, Debug)]
112pub struct Costs {
113    /// Effective device bytes/s for a resident weight stream.
114    pub dev_bytes_per_s: f64,
115    /// Effective host bytes/s for q4tp expert / q8_2f projection streams.
116    pub cpu_bytes_per_s: f64,
117    /// Fixed cost of one host-walked layer whose projections and resident
118    /// experts run per-op on the device: the QKV and O submits, the MoE frame
119    /// fence, the host attention core and route.
120    pub dyn_layer_s: f64,
121    /// Fixed cost of one whole-token graph submit.
122    pub graph_submit_s: f64,
123    /// Fixed cost of one pure-host layer (pool dispatch barriers).
124    pub cpu_layer_s: f64,
125    /// Upload bytes/s of a bank fill.
126    pub fill_bytes_per_s: f64,
127    /// Per-layer LRU hit rate of a decode stream by slots per layer (trace
128    /// replay of real routing). Interpolated in log(slots).
129    pub hit_curve: Vec<(f64, f64)>,
130}
131
132impl Costs {
133    /// RTX PRO 6000 Blackwell (96 GB, Vulkan) + EPYC 9655 in a 24-core
134    /// cgroup, MiMo-V2.6-Flash q4tp (runs of 2026-09-24):
135    /// - `dyn_layer_s`: a bank-served layer measured 0.9–1.0 ms of host
136    ///   walk per layer at ~100 % hits with the generic frame (QKV submit
137    ///   0.22 ms, O 0.14 ms, frame 0.43 ms, attention core 0.06 ms); the
138    ///   dedicated bank kernels take ~0.2 ms off the frame. The device's
139    ///   own bytes are charged separately at `dev_bytes_per_s`.
140    /// - `hit_curve`: per-layer LRU hit rate after a 64-token warm-up,
141    ///   replayed from a 446-token CMF_MOE_TRACE of docs/ppl_nat.txt
142    ///   (tools/moe_lru_sim.py).
143    /// - `cpu_bytes_per_s`: q4tp/q8_2f streaming of the host walk with the
144    ///   22-thread pool.
145    pub fn measured() -> Self {
146        Self {
147            dev_bytes_per_s: 1.2e12,
148            cpu_bytes_per_s: 60e9,
149            dyn_layer_s: 0.6e-3,
150            graph_submit_s: 0.3e-3,
151            cpu_layer_s: 0.1e-3,
152            fill_bytes_per_s: 20e9,
153            hit_curve: vec![
154                (8.0, 0.341),
155                (16.0, 0.460),
156                (32.0, 0.603),
157                (64.0, 0.777),
158                (96.0, 0.875),
159                (128.0, 0.930),
160                (160.0, 0.959),
161                (192.0, 0.972),
162                (224.0, 0.975),
163                (256.0, 1.0),
164            ],
165        }
166    }
167
168    /// LRU hit rate at `slots` per layer.
169    pub fn hit_rate(&self, slots: f64, n_experts: usize) -> f64 {
170        if slots >= n_experts as f64 {
171            return 1.0;
172        }
173        if slots <= 0.0 {
174            return 0.0;
175        }
176        let c = &self.hit_curve;
177        if c.is_empty() {
178            return 0.0;
179        }
180        if slots <= c[0].0 {
181            return c[0].1 * slots / c[0].0;
182        }
183        for w in c.windows(2) {
184            let ((s0, h0), (s1, h1)) = (w[0], w[1]);
185            if slots <= s1 {
186                let t = (slots.ln() - s0.ln()) / (s1.ln() - s0.ln());
187                return h0 + t * (h1 - h0);
188            }
189        }
190        c[c.len() - 1].1
191    }
192}
193
194/// Device reserve kept outside the bank: the allocator workspace
195/// (budget/10 within 2–4 GiB, the same rule `dsv4_global_moe_create`
196/// applies) plus 1 GiB for activations, KV mirrors and driver slack.
197pub fn device_reserve(budget: u64) -> u64 {
198    let gib = 1u64 << 30;
199    (budget / 10).clamp(2 * gib, 4 * gib) + gib
200}
201
202/// Choose the placement. `forced` = the `CMF_MIMO_MOE` override.
203pub fn place(inp: &PlacementInputs, costs: &Costs, forced: Option<MoeMode>) -> Placement {
204    let gb = |b: u64| b as f64 / 1e9;
205    let l = inp.moe_layers.max(1);
206    let ne = inp.n_experts.max(1);
207    let k = inp.top_k as f64;
208    let e = inp.per_expert as f64;
209    let a = inp.attn_per_layer as f64;
210    let total_experts = l * ne;
211    let room = inp
212        .budget
213        .saturating_sub(inp.non_expert)
214        .saturating_sub(device_reserve(inp.budget));
215    let slots = (room / inp.per_expert.max(1)) as usize;
216    let whole = (slots / ne).min(l);
217    // Per-token time of the placements (the dense layers and the head are
218    // common to all of them and left out).
219    let dev_layer = (a + k * e) / costs.dev_bytes_per_s;
220    let prefix_cost = |p: usize| -> f64 {
221        if costs.dev_bytes_per_s <= 0.0 {
222            return f64::INFINITY;
223        }
224        let dev = if inp.graph_prefix {
225            costs.graph_submit_s + p as f64 * dev_layer
226        } else {
227            p as f64 * (costs.dyn_layer_s + dev_layer)
228        };
229        dev + (l - p) as f64 * ((a + k * e) / costs.cpu_bytes_per_s + costs.cpu_layer_s)
230    };
231    let dyn_cost = |p: usize| -> (f64, f64) {
232        // p whole layers, the rest share what is left of the bank.
233        let rest = l - p;
234        if rest == 0 {
235            return (prefix_cost(p), 1.0);
236        }
237        let per = slots.saturating_sub(p * ne) as f64 / rest as f64;
238        let h = costs.hit_rate(per, ne);
239        let hot = k * h * e / costs.dev_bytes_per_s;
240        let cold = k * (1.0 - h) * e / costs.cpu_bytes_per_s;
241        let layer = costs.dyn_layer_s + a / costs.dev_bytes_per_s + hot.max(cold);
242        let head = if p == 0 {
243            0.0
244        } else if inp.graph_prefix {
245            costs.graph_submit_s + p as f64 * dev_layer
246        } else {
247            p as f64 * (costs.dyn_layer_s + dev_layer)
248        };
249        (head + rest as f64 * layer, h)
250    };
251    let describe = |mode: MoeMode, p: usize, t: f64, h: f64, why: &str| -> String {
252        format!(
253            "{why}; budget {:.1} GB, non-expert {:.1} GB, experts {:.1} GB ({} × {:.1} MB), \
254             room {} slots = {:.0}/layer, whole layers {whole}/{l}{}; predicted {:.1} ms/token \
255             ({}{})",
256            gb(inp.budget),
257            gb(inp.non_expert),
258            gb(inp.per_expert * total_experts as u64),
259            total_experts,
260            e / 1e6,
261            slots,
262            slots as f64 / l as f64,
263            if inp.graph_prefix {
264                ", graph prefix"
265            } else {
266                ", no graph prefix"
267            },
268            t * 1e3,
269            mode.name(),
270            match mode {
271                MoeMode::Prefix => format!(" P={p}"),
272                MoeMode::Dynamic => format!(" hit≈{:.0}%", h * 100.0),
273                MoeMode::Hybrid => format!(" P={p} hit≈{:.0}%", h * 100.0),
274            },
275        )
276    };
277    let mk = |mode: MoeMode, p: usize, bank: usize, t: f64, why: String| Placement {
278        mode,
279        prefix_layers: p,
280        bank_slots: bank,
281        predicted_s: t,
282        reason: why,
283    };
284    if slots >= total_experts && forced.is_none() {
285        let t = prefix_cost(l);
286        let why = describe(MoeMode::Prefix, l, t, 1.0, "every expert fits");
287        return mk(MoeMode::Prefix, l, 0, t, why);
288    }
289    let (t_prefix, t_dyn) = (prefix_cost(whole), dyn_cost(0));
290    // Best hybrid split: at least one whole layer, at least one bank layer.
291    let hybrid = (1..whole.min(l.saturating_sub(1)) + 1)
292        .map(|p| (p, dyn_cost(p)))
293        .min_by(|x, y| {
294            x.1.0
295                .partial_cmp(&y.1.0)
296                .unwrap_or(std::cmp::Ordering::Equal)
297        });
298    let bank_for = |p: usize| slots.saturating_sub(p * ne);
299    match forced {
300        Some(MoeMode::Prefix) => {
301            let why = describe(MoeMode::Prefix, whole, t_prefix, 0.0, "CMF_MIMO_MOE=prefix");
302            mk(MoeMode::Prefix, whole, 0, t_prefix, why)
303        }
304        Some(MoeMode::Dynamic) => {
305            let why = describe(
306                MoeMode::Dynamic,
307                0,
308                t_dyn.0,
309                t_dyn.1,
310                "CMF_MIMO_MOE=dynamic",
311            );
312            mk(MoeMode::Dynamic, 0, bank_for(0), t_dyn.0, why)
313        }
314        Some(MoeMode::Hybrid) => {
315            let (p, (t, h)) = hybrid.unwrap_or((0, t_dyn));
316            let why = describe(MoeMode::Hybrid, p, t, h, "CMF_MIMO_MOE=hybrid");
317            mk(MoeMode::Hybrid, p, bank_for(p), t, why)
318        }
319        None => {
320            let mut best = (MoeMode::Prefix, whole, t_prefix, 0.0);
321            if t_dyn.0 < best.2 {
322                best = (MoeMode::Dynamic, 0, t_dyn.0, t_dyn.1);
323            }
324            if let Some((p, (t, h))) = hybrid
325                && t < best.2
326            {
327                best = (MoeMode::Hybrid, p, t, h);
328            }
329            let (mode, p, t, h) = best;
330            let alt = format!(
331                "auto: prefix {:.1} ms, dynamic {:.1} ms{}",
332                t_prefix * 1e3,
333                t_dyn.0 * 1e3,
334                hybrid.map_or(String::new(), |(p, (t, _))| format!(
335                    ", best hybrid P={p} {:.1} ms",
336                    t * 1e3
337                )),
338            );
339            let bank = if mode == MoeMode::Prefix {
340                0
341            } else {
342                bank_for(p)
343            };
344            let why = describe(mode, p, t, h, &alt);
345            mk(mode, p, bank, t, why)
346        }
347    }
348}
349
350/// Per-process counters of the dynamic executor.
351#[derive(Clone, Copy, Debug, Default)]
352pub struct Stats {
353    /// Completed attention-only graph calls / rows in the dynamic tail.
354    pub attn_graph_calls: u64,
355    pub attn_graph_rows: u64,
356    pub attn_graph_ns: u64,
357    /// Layer calls served by the bank frame.
358    pub calls: u64,
359    /// Expert picks seen by those calls.
360    pub picks: u64,
361    /// Picks already resident before the call.
362    pub hits: u64,
363    /// Picks uploaded into the bank by the call (then run on the device).
364    pub fills: u64,
365    /// Picks computed on the host.
366    pub cold: u64,
367    /// Wall time inside the device frame (submit + wait), ns.
368    pub frame_ns: u64,
369    /// Wall time of the whole layer call, ns.
370    pub call_ns: u64,
371    /// Calls that fell back to the host path.
372    pub fallbacks: u64,
373    /// Wall time of the host route (router matvec + top-k) of bank calls, ns.
374    pub route_ns: u64,
375}
376
377static STATS: std::sync::Mutex<Stats> = std::sync::Mutex::new(Stats {
378    attn_graph_calls: 0,
379    attn_graph_rows: 0,
380    attn_graph_ns: 0,
381    calls: 0,
382    picks: 0,
383    hits: 0,
384    fills: 0,
385    cold: 0,
386    frame_ns: 0,
387    call_ns: 0,
388    fallbacks: 0,
389    route_ns: 0,
390});
391
392pub(crate) fn note_attention_graph(rows: usize, ns: u64) {
393    let mut s = STATS.lock().unwrap();
394    s.attn_graph_calls += 1;
395    s.attn_graph_rows += rows as u64;
396    s.attn_graph_ns += ns;
397}
398
399/// Process-wide executor counters (benches read deltas).
400pub fn stats() -> Stats {
401    *STATS.lock().unwrap()
402}
403
404static LAST_DECISION: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
405
406/// The placement line of the most recent decision (also logged at `info`).
407pub fn last_decision() -> String {
408    LAST_DECISION.lock().unwrap().clone()
409}
410
411/// The model-wide expert bank: which `(layer, expert)` occupies which slot
412/// of the segmented device buffers (`dsv4_global_*`), and the policy that
413/// admits and evicts.
414///
415/// Fills never stall a token. An admitted expert is uploaded by a filler
416/// thread while the token that missed it computes it on the host; the slot
417/// becomes visible to the device only once its bytes are queued, so no
418/// frame can read a half-written slot (and a frame already queued finishes
419/// on the evicted expert's bytes: queue writes land between submits).
420///
421/// Admission: while free slots remain every miss is admitted; once the bank
422/// is full a miss must recur (`min_seen` sightings within the decay window)
423/// and evicts the least recently used slot of a layer holding more than its
424/// floor, never a slot the current token already used.
425///
426/// The threshold follows the bank size ([`default_min_seen`]): a fill is
427/// ~13 MB of PCIe traffic queued ahead of the next frame, a cold pick is
428/// the same bytes streamed by the host in parallel with the frame, so a
429/// fill pays only for an expert that stays long enough to be reused.
430#[cfg(feature = "gpu")]
431pub(crate) struct Bank {
432    pub(crate) segment_slots: usize,
433    n_experts: usize,
434    /// Per `(layer, expert)` key: its slot, `NONE`, or `PENDING` (a fill in
435    /// flight — cold until it lands).
436    slot_for: Vec<u32>,
437    /// Per slot: its key, or `NONE`.
438    owner: Vec<u32>,
439    /// Per slot: the token that last used it.
440    last: Vec<u64>,
441    occupancy: Vec<u32>,
442    free: Vec<u32>,
443    floor: u32,
444    /// Decayed sightings per key and the token they were last decayed at.
445    seen: Vec<u16>,
446    seen_tok: Vec<u32>,
447    tok: u64,
448    pending: usize,
449    /// Fills queued so far (the profile's "fills").
450    admitted: u64,
451    max_pending: usize,
452    /// Queue bound while priming from a prompt (fills are cheap to queue;
453    /// the filler drains them during the rest of the prefill).
454    prime_queue: usize,
455    min_seen: u16,
456    decay_tokens: u64,
457    tx: Option<std::sync::mpsc::Sender<(u32, (usize, usize, usize))>>,
458    done: Arc<std::sync::Mutex<Vec<(u32, bool)>>>,
459    filler: Option<std::thread::JoinHandle<()>>,
460}
461
462/// Recurrences a miss needs before a full bank admits it. Replaying the
463/// 446-token docs/ppl_nat.txt routing trace through this policy (after a
464/// 64-token warm-up): at 142 slots/layer `1` → 96.6 % hits with 12.8
465/// fills/token, `2` → 95.9 % with 6.4; at 64 slots `2` → 79.7 % / 17.1
466/// against `1` → 77.9 % / 82.9; at 13 slots `3` → 41.8 % / 66.5 against
467/// `2` → 39.7 % / 137. Small banks churn: demand more evidence there.
468pub fn default_min_seen(slots_per_layer: usize) -> u64 {
469    if slots_per_layer >= 48 { 2 } else { 3 }
470}
471
472#[cfg(feature = "gpu")]
473const NONE: u32 = u32::MAX;
474#[cfg(feature = "gpu")]
475const PENDING: u32 = u32::MAX - 1;
476
477#[cfg(feature = "gpu")]
478impl Bank {
479    /// Allocate `slots` device slots (rounded to whole segments) for
480    /// `n_layers × n_experts` keys.
481    fn create(
482        model: &Arc<CmfModel>,
483        inter: usize,
484        hidden: usize,
485        n_layers: usize,
486        moe_layers: usize,
487        n_experts: usize,
488        slots: usize,
489    ) -> Option<Self> {
490        let keys = n_layers.checked_mul(n_experts)?;
491        if keys >= PENDING as usize {
492            return None;
493        }
494        let (capacity, segment_slots) =
495            crate::gpu_wgpu::dsv4_global_moe_create_slots(model, slots, inter, hidden, false)?;
496        let env = |k: &str| std::env::var(k).ok().and_then(|v| v.parse::<u64>().ok());
497        let done = Arc::new(std::sync::Mutex::new(Vec::new()));
498        let (tx, rx) = std::sync::mpsc::channel::<(u32, (usize, usize, usize))>();
499        let filler = {
500            let (model, done) = (model.clone(), done.clone());
501            let dev = crate::gpu::current_device();
502            std::thread::Builder::new()
503                .name("mimo-bank-fill".into())
504                .spawn(move || {
505                    crate::gpu::set_current_device(dev);
506                    while let Ok((slot, triple)) = rx.recv() {
507                        let ok =
508                            crate::gpu_wgpu::dsv4_global_slot_fill(&model, slot as usize, triple);
509                        done.lock().unwrap().push((slot, ok));
510                    }
511                })
512                .ok()?
513        };
514        Some(Self {
515            segment_slots,
516            n_experts,
517            slot_for: vec![NONE; keys],
518            owner: vec![NONE; capacity],
519            last: vec![0; capacity],
520            occupancy: vec![0; n_layers],
521            free: (0..capacity as u32).rev().collect(),
522            floor: (capacity / moe_layers.max(1) / 2) as u32,
523            seen: vec![0; keys],
524            seen_tok: vec![0; keys],
525            tok: 1,
526            pending: 0,
527            admitted: 0,
528            max_pending: env("CMF_MIMO_FILL_QUEUE").unwrap_or(256) as usize,
529            prime_queue: env("CMF_MIMO_PRIME_QUEUE").unwrap_or(4096) as usize,
530            min_seen: env("CMF_MIMO_FETCH_MIN_SEEN")
531                .unwrap_or(default_min_seen(capacity / moe_layers.max(1)))
532                as u16,
533            decay_tokens: env("CMF_MIMO_SEEN_DECAY").unwrap_or(16).max(1),
534            tx: Some(tx),
535            done,
536            filler: Some(filler),
537        })
538    }
539
540    pub(crate) fn capacity(&self) -> usize {
541        self.owner.len()
542    }
543
544    pub(crate) fn free_slots(&self) -> usize {
545        self.free.len()
546    }
547
548    pub(crate) fn pending(&self) -> usize {
549        self.pending
550    }
551
552    /// A new token starts (called at its first bank layer).
553    fn next_token(&mut self) {
554        self.tok += 1;
555    }
556
557    /// Fold the filler's completions into the slot map.
558    fn drain(&mut self) {
559        let done = std::mem::take(&mut *self.done.lock().unwrap());
560        for (slot, ok) in done {
561            self.pending = self.pending.saturating_sub(1);
562            let key = self.owner[slot as usize];
563            if key == NONE {
564                continue;
565            }
566            if ok {
567                self.slot_for[key as usize] = slot;
568                self.last[slot as usize] = self.tok;
569            } else {
570                self.slot_for[key as usize] = NONE;
571                self.owner[slot as usize] = NONE;
572                let layer = key as usize / self.n_experts;
573                self.occupancy[layer] = self.occupancy[layer].saturating_sub(1);
574                self.free.push(slot);
575            }
576        }
577    }
578
579    fn victim(&mut self, layer: usize) -> Option<u32> {
580        if let Some(s) = self.free.pop() {
581            return Some(s);
582        }
583        let ne = self.n_experts as u32;
584        let tok = self.tok;
585        let pick = |over_floor: bool, me: &Self| -> Option<u32> {
586            let mut best: Option<(u64, u32)> = None;
587            for (slot, &key) in me.owner.iter().enumerate() {
588                if key == NONE || me.slot_for[key as usize] != slot as u32 {
589                    continue; // empty or still filling
590                }
591                let l = me.last[slot];
592                if l >= tok {
593                    continue; // this token used it
594                }
595                let kl = (key / ne) as usize;
596                if over_floor && me.occupancy[kl] <= me.floor && kl != layer {
597                    continue;
598                }
599                if best.is_none_or(|(bl, _)| l < bl) {
600                    best = Some((l, slot as u32));
601                }
602            }
603            best.map(|(_, s)| s)
604        };
605        pick(true, self).or_else(|| pick(false, self))
606    }
607
608    /// Resolve `picks` of `layer`: the remap (slot or `u32::MAX` per expert)
609    /// for this call, and admissions of recurring misses for later tokens.
610    fn resolve(
611        &mut self,
612        layer: usize,
613        picks: &[usize],
614        triples: &[(usize, usize, usize)],
615    ) -> Option<Vec<u32>> {
616        self.drain();
617        let base = layer.checked_mul(self.n_experts)?;
618        let mut remap = vec![NONE; self.n_experts];
619        for &e in picks {
620            let key = base + *(e < self.n_experts).then_some(&e)?;
621            self.see(key, 1);
622            let slot = self.slot_for[key];
623            if slot < PENDING {
624                remap[e] = slot;
625                self.last[slot as usize] = self.tok;
626            }
627        }
628        for &e in picks {
629            if !self.admit(layer, e, triples[e], self.max_pending)? {
630                break;
631            }
632        }
633        Some(remap)
634    }
635
636    /// Count `n` sightings of `key`, decaying older ones by half per
637    /// `decay_tokens` tokens.
638    fn see(&mut self, key: usize, n: u16) {
639        let tok32 = (self.tok / self.decay_tokens).min(u32::MAX as u64) as u32;
640        let shift = tok32.saturating_sub(self.seen_tok[key]);
641        self.seen[key] = if shift >= 16 {
642            0
643        } else {
644            self.seen[key] >> shift
645        };
646        self.seen_tok[key] = tok32;
647        self.seen[key] = self.seen[key].saturating_add(n);
648    }
649
650    /// Queue `(layer, e)` for a fill if the policy admits it. `Some(false)`
651    /// = no victim left (stop admitting this call), `None` = the filler is
652    /// gone.
653    fn admit(
654        &mut self,
655        layer: usize,
656        e: usize,
657        triple: (usize, usize, usize),
658        queue_cap: usize,
659    ) -> Option<bool> {
660        let key = layer * self.n_experts + e;
661        if self.slot_for[key] != NONE || self.pending >= queue_cap {
662            return Some(true);
663        }
664        if self.free.is_empty() && self.seen[key] < self.min_seen {
665            return Some(true);
666        }
667        let Some(slot) = self.victim(layer) else {
668            return Some(false);
669        };
670        let old = self.owner[slot as usize];
671        if old != NONE {
672            self.slot_for[old as usize] = NONE;
673            let ol = old as usize / self.n_experts;
674            self.occupancy[ol] = self.occupancy[ol].saturating_sub(1);
675        }
676        self.owner[slot as usize] = key as u32;
677        self.slot_for[key] = PENDING;
678        self.occupancy[layer] += 1;
679        self.pending += 1;
680        self.admitted += 1;
681        self.tx
682            .as_ref()
683            .is_some_and(|tx| tx.send((slot, triple)).is_ok())
684            .then_some(true)
685    }
686
687    /// A prompt's expert usage for `layer` (the host-computed prefill):
688    /// count it as sightings and queue the most used experts, so decode
689    /// starts on a bank the prompt already warmed. The fills run in the
690    /// background; nothing waits on them.
691    fn prime(&mut self, layer: usize, counts: &[u64], triples: &[(usize, usize, usize)]) {
692        self.drain();
693        let base = layer * self.n_experts;
694        let mut used: Vec<(usize, u64)> = counts
695            .iter()
696            .enumerate()
697            .filter(|&(e, &c)| c > 0 && e < self.n_experts && e < triples.len())
698            .map(|(e, &c)| (e, c))
699            .collect();
700        used.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
701        for &(e, c) in &used {
702            self.see(base + e, c.min(u16::MAX as u64) as u16);
703        }
704        for &(e, _) in &used {
705            match self.admit(layer, e, triples[e], self.prime_queue) {
706                Some(true) => {}
707                _ => break,
708            }
709        }
710    }
711}
712
713#[cfg(feature = "gpu")]
714impl Drop for Bank {
715    fn drop(&mut self) {
716        self.stop_filler();
717    }
718}
719
720#[cfg(feature = "gpu")]
721impl Bank {
722    fn stop_filler(&mut self) {
723        // Dropping the only sender lets the worker drain every queued upload
724        // while the device still exists, then exit. Joining is essential:
725        // BANKS owns an Arc after the final Pipeline has already dropped.
726        self.tx = None;
727        if let Some(h) = self.filler.take() {
728            let _ = h.join();
729        }
730    }
731}
732
733/// Process-final only, with no inference running. Stop model-bank uploads
734/// BEFORE wgpu drains its contexts; otherwise a late fill can dereference a
735/// freed context or initialize a second device during Vulkan destruction.
736#[cfg(feature = "gpu")]
737pub(crate) fn shutdown_banks() {
738    let banks = std::mem::take(&mut *BANKS.lock().unwrap());
739    for (_, bank) in banks {
740        bank.lock().unwrap().stop_filler();
741    }
742}
743
744/// One bank per model file: pipelines sharing a model share its device
745/// slots, so they must share the slot map too.
746#[cfg(feature = "gpu")]
747static BANKS: std::sync::Mutex<Vec<(u64, Arc<std::sync::Mutex<Bank>>)>> =
748    std::sync::Mutex::new(Vec::new());
749
750/// The per-pipeline state: undecided until the first forward, then off or
751/// on with its bank.
752#[derive(Default)]
753pub enum Slot {
754    #[default]
755    Undecided,
756    Off,
757    #[cfg(feature = "gpu")]
758    On(Box<Dynamic>),
759}
760
761/// A decided placement with its bank.
762#[cfg(feature = "gpu")]
763pub struct Dynamic {
764    pub placement: Placement,
765    model: Arc<CmfModel>,
766    bank: Arc<std::sync::Mutex<Bank>>,
767    /// `(gate, up, down)` directory indices of every expert, by absolute
768    /// layer; empty on layers without experts.
769    ids: Vec<Vec<(usize, usize, usize)>>,
770    /// Absolute index of the first layer the bank serves.
771    pub dyn_from: usize,
772    failed: bool,
773    /// The dedicated bank kernels serve this model (decided on first use).
774    fast: Option<bool>,
775    /// Last layer with experts (`CMF_MIMO_PROF` prints here). A bank
776    /// epoch starts at `dyn_from`, not the first graph-resident MoE layer.
777    last_moe: usize,
778    prof: bool,
779    prof_mark: (std::time::Instant, Stats, [u64; 7]),
780}
781
782/// Device-side counters the profile line reports as deltas: frame encode,
783/// frame wait, frame uploads, frame passes (ns), queue submits, and the
784/// card's own time inside the frames with `CMF_GPU_TS=1` (ns, frames).
785#[cfg(feature = "gpu")]
786fn device_counters() -> [u64; 7] {
787    use std::sync::atomic::Ordering::Relaxed;
788    [
789        crate::gpu_wgpu::MOE_ENC_NS.load(Relaxed),
790        crate::gpu_wgpu::MOE_WAIT_NS.load(Relaxed),
791        crate::gpu_wgpu::MOE_UP_NS.load(Relaxed),
792        crate::gpu_wgpu::MOE_PASS_NS.load(Relaxed),
793        crate::gpu_wgpu::SUBMITS.load(Relaxed),
794        crate::gpu_wgpu::MOE_GPU_NS[0].load(Relaxed),
795        crate::gpu_wgpu::MOE_GPU_N.load(Relaxed),
796    ]
797}
798
799#[cfg(feature = "gpu")]
800fn env_mode() -> Option<MoeMode> {
801    let raw = std::env::var("CMF_MIMO_MOE").ok()?;
802    match MoeMode::parse(&raw) {
803        Some(m) => m,
804        None => {
805            tracing::warn!("CMF_MIMO_MOE={raw}: not prefix|dynamic|hybrid|auto — using auto");
806            None
807        }
808    }
809}
810
811/// Why a layer stack cannot use the bank, if it cannot.
812#[cfg(feature = "gpu")]
813fn bank_refusal(layers: &[(usize, &MoeFfn)]) -> Option<String> {
814    let first = layers.first()?.1;
815    let model = first
816        .experts
817        .first()
818        .and_then(|e| e.gate_proj.model_arc())?;
819    let inter = first.experts[0].gate_proj.rows();
820    let hidden = first.experts[0].gate_proj.cols();
821    for &(li, m) in layers {
822        if m.shared.is_some()
823            || m.per_expert_scale.is_some()
824            || m.resonance.is_some()
825            || m.route_tau.is_some()
826            || m.mask.is_some()
827            || m.router_input_norm
828        {
829            return Some(format!(
830                "layer {li}: routing extras the bank frame does not carry"
831            ));
832        }
833        for (ei, d) in m.experts.iter().enumerate() {
834            let q4tp = |t: &crate::qtensor::QTensor| {
835                t.model_dtype() == Some(TensorDtype::Q4TiledP)
836                    && t.model_arc().is_some_and(|a| a.uid() == model.uid())
837            };
838            if !(q4tp(&d.gate_proj) && q4tp(&d.up_proj) && q4tp(&d.down_proj))
839                || d.act != crate::pipeline::Act::Silu
840                || d.gate_proj.rows() != inter
841                || d.gate_proj.cols() != hidden
842                || d.down_proj.rows() != hidden
843            {
844                return Some(format!(
845                    "layer {li} expert {ei}: not a mapped q4tp SiLU expert of the common shape"
846                ));
847            }
848        }
849    }
850    if hidden % 32 != 0 || inter % 32 != 0 {
851        return Some(format!(
852            "hidden {hidden} / inter {inter} not multiples of 32"
853        ));
854    }
855    None
856}
857
858impl Slot {
859    pub fn is_undecided(&self) -> bool {
860        matches!(self, Self::Undecided)
861    }
862
863    /// Whether the bank is active.
864    pub fn is_on(&self) -> bool {
865        match self {
866            #[cfg(feature = "gpu")]
867            Self::On(d) => !d.failed,
868            _ => false,
869        }
870    }
871
872    /// First bank-owned layer. Graph builders must stop before it even
873    /// when the generic capacity heuristic would admit more layers.
874    pub(crate) fn graph_prefix_end(&self) -> Option<usize> {
875        match self {
876            #[cfg(feature = "gpu")]
877            Self::On(d) => Some(d.dyn_from),
878            _ => None,
879        }
880    }
881
882    /// Does layer `li` run its experts through the bank? `host_tail` = the
883    /// walk reached this layer after a device graph prefix handed it over:
884    /// with a bank present every such MoE layer takes the bank (a whole-layer
885    /// per-op path would stream experts through the residency arena).
886    pub fn is_dynamic(&self, li: usize, host_tail: bool) -> bool {
887        match self {
888            #[cfg(feature = "gpu")]
889            Self::On(d) => {
890                !d.failed
891                    && d.ids.get(li).is_some_and(|v| !v.is_empty())
892                    && (li >= d.dyn_from || host_tail)
893            }
894            _ => {
895                let _ = (li, host_tail);
896                false
897            }
898        }
899    }
900
901    /// Decide the placement for a MiMo-V2 stack (`layers` = its MoE layers,
902    /// by absolute index). Any other model is `Off`.
903    #[cfg(not(feature = "gpu"))]
904    pub fn decide(layers: &[(usize, &MoeFfn)], n_layers: usize, graph_prefix: bool) -> Self {
905        let _ = (layers, n_layers, graph_prefix);
906        Self::Off
907    }
908
909    /// Decide the placement for a MiMo-V2 stack (`layers` = its MoE layers,
910    /// by absolute index). Any other model is `Off`.
911    #[cfg(feature = "gpu")]
912    pub fn decide(layers: &[(usize, &MoeFfn)], n_layers: usize, graph_prefix: bool) -> Self {
913        let exact = std::env::var("CMF_MIMO_EXPERT_SLOTS")
914            .ok()
915            .and_then(|v| v.parse::<usize>().ok());
916        Self::decide_with(layers, n_layers, graph_prefix, env_mode(), exact)
917    }
918
919    /// `decide` with the operator knobs passed in: `forced` =
920    /// `CMF_MIMO_MOE`, `exact` = `CMF_MIMO_EXPERT_SLOTS` (a bank of exactly
921    /// that many slots, for tests and A/B runs).
922    #[cfg(feature = "gpu")]
923    pub fn decide_with(
924        layers: &[(usize, &MoeFfn)],
925        n_layers: usize,
926        graph_prefix: bool,
927        forced: Option<MoeMode>,
928        exact: Option<usize>,
929    ) -> Self {
930        let Some(model) = layers
931            .first()
932            .and_then(|(_, m)| m.experts.first())
933            .and_then(|e| e.gate_proj.model_arc())
934        else {
935            return Self::Off;
936        };
937        if model.arch().arch_name != "mimo_v2" {
938            return Self::Off;
939        }
940        let say = |msg: &str| {
941            tracing::info!("MiMo MoE placement: {msg}");
942            *LAST_DECISION.lock().unwrap() = msg.to_string();
943        };
944        if !crate::gpu::enabled() || !crate::gpu::wgpu_active() {
945            say("prefix — no wgpu device (experts on the host path)");
946            return Self::Off;
947        }
948        if let Some(why) = bank_refusal(layers) {
949            say(&format!("prefix — {why}"));
950            return Self::Off;
951        }
952        let first = layers[0].1;
953        let inter = first.experts[0].gate_proj.rows();
954        let hidden = first.experts[0].gate_proj.cols();
955        let n_experts = first.experts.len();
956        if layers.iter().any(|(_, m)| m.experts.len() != n_experts) {
957            say("prefix — MoE layers differ in expert count");
958            return Self::Off;
959        }
960        let Some(budget) = crate::gpu_wgpu::dsv4_vram_budget() else {
961            say("prefix — no device budget");
962            return Self::Off;
963        };
964        if budget == u64::MAX {
965            say("prefix — unified memory: the host pages experts, nothing to place");
966            return Self::Off;
967        }
968        if !crate::gpu_wgpu::dsv4_global_moe_supported() {
969            say("prefix — this adapter has no segmented expert bank (descriptor arrays)");
970            return Self::Off;
971        }
972        let per_expert = {
973            let gu = cortiq_core::quant::expected_nbytes(TensorDtype::Q4TiledP, &[inter, hidden]);
974            let dn = cortiq_core::quant::expected_nbytes(TensorDtype::Q4TiledP, &[hidden, inter]);
975            match (gu, dn) {
976                (Some(gu), Some(dn)) => (2 * gu + dn) as u64,
977                _ => {
978                    say("prefix — expert size unknown");
979                    return Self::Off;
980                }
981            }
982        };
983        let is_expert = |name: &str| name.contains(".mlp.experts.");
984        let non_expert: u64 = model
985            .tensors
986            .iter()
987            .filter(|t| !is_expert(&t.name) && !t.name.starts_with("model.embed_tokens."))
988            .map(|t| t.nbytes)
989            .sum();
990        let layer_non_expert: u64 = model
991            .tensors
992            .iter()
993            .filter(|t| t.name.starts_with("model.layers.") && !is_expert(&t.name))
994            .map(|t| t.nbytes)
995            .sum();
996        let inp = PlacementInputs {
997            budget,
998            non_expert,
999            per_expert,
1000            moe_layers: layers.len(),
1001            n_experts,
1002            top_k: first.top_k,
1003            attn_per_layer: layer_non_expert / n_layers.max(1) as u64,
1004            graph_prefix,
1005        };
1006        let costs = Costs::measured();
1007        let mut placement = place(&inp, &costs, forced);
1008        if placement.mode == MoeMode::Prefix {
1009            say(&placement.reason);
1010            return Self::Off;
1011        }
1012        if let Some(n) = exact {
1013            placement.bank_slots = n;
1014            placement.reason = format!("{} [CMF_MIMO_EXPERT_SLOTS={n}]", placement.reason);
1015        }
1016        let bank = {
1017            let mut reg = BANKS.lock().unwrap();
1018            match reg.iter().find(|(uid, _)| *uid == model.uid()) {
1019                Some((_, b)) => Some(b.clone()),
1020                None => Bank::create(
1021                    &model,
1022                    inter,
1023                    hidden,
1024                    n_layers,
1025                    layers.len().saturating_sub(placement.prefix_layers),
1026                    n_experts,
1027                    placement.bank_slots,
1028                )
1029                .map(|b| {
1030                    let b = Arc::new(std::sync::Mutex::new(b));
1031                    reg.push((model.uid(), b.clone()));
1032                    b
1033                }),
1034            }
1035        };
1036        let Some(bank) = bank else {
1037            say(&format!(
1038                "prefix — the {}-slot expert bank could not be allocated ({})",
1039                placement.bank_slots, placement.reason
1040            ));
1041            return Self::Off;
1042        };
1043        let mut ids = vec![Vec::new(); n_layers];
1044        for &(li, m) in layers {
1045            let triples: Option<Vec<_>> = m
1046                .experts
1047                .iter()
1048                .map(|d| {
1049                    Some((
1050                        d.gate_proj.model_idx()?,
1051                        d.up_proj.model_idx()?,
1052                        d.down_proj.model_idx()?,
1053                    ))
1054                })
1055                .collect();
1056            match (triples, ids.get_mut(li)) {
1057                (Some(t), Some(slot)) => *slot = t,
1058                _ => {
1059                    say("prefix — an expert is not mmap-backed");
1060                    return Self::Off;
1061                }
1062            }
1063        }
1064        // The whole-layer prefix counts MoE layers from the first one.
1065        let dyn_from = layers
1066            .get(placement.prefix_layers)
1067            .map_or(n_layers, |&(li, _)| li);
1068        let cap = bank.lock().unwrap().capacity();
1069        say(&format!(
1070            "{} — {}; bank {cap} slots ({:.1} GB), bank layers from {dyn_from}",
1071            placement.mode.name(),
1072            placement.reason,
1073            cap as f64 * per_expert as f64 / 1e9,
1074        ));
1075        Self::On(Box::new(Dynamic {
1076            placement,
1077            model,
1078            bank,
1079            ids,
1080            dyn_from,
1081            failed: false,
1082            fast: None,
1083            last_moe: layers.last().map_or(0, |&(li, _)| li),
1084            prof: std::env::var_os("CMF_MIMO_PROF").is_some(),
1085            prof_mark: (std::time::Instant::now(), stats(), device_counters()),
1086        }))
1087    }
1088
1089    /// After a host-computed prefill of bank layer `li`: `before` is the
1090    /// layer's selection counters (`MoeFfn::stats`) from before the chunk;
1091    /// the difference is the prompt's expert usage, which primes the bank.
1092    pub(crate) fn prime(&mut self, li: usize, m: &MoeFfn, before: &[u64]) {
1093        #[cfg(feature = "gpu")]
1094        if let Self::On(d) = self
1095            && !d.failed
1096            && let Some(triples) = d.ids.get(li).filter(|t| !t.is_empty())
1097            && std::env::var("CMF_MIMO_PRIME").as_deref() != Ok("0")
1098        {
1099            let now = m.stats.borrow();
1100            let counts: Vec<u64> = (0..now.len())
1101                .map(|e| now[e].saturating_sub(before.get(e).copied().unwrap_or(0)))
1102                .collect();
1103            drop(now);
1104            d.bank.lock().unwrap().prime(li, &counts, triples);
1105        }
1106        #[cfg(not(feature = "gpu"))]
1107        let _ = (li, m, before);
1108    }
1109
1110    /// Charge a bank call's host route time (the caller routes).
1111    pub(crate) fn note_route(&self, ns: u64) {
1112        if self.is_on() {
1113            STATS.lock().unwrap().route_ns += ns;
1114        }
1115    }
1116
1117    /// Run a routed MoE layer through the bank. `None` = not served (the
1118    /// caller runs the host path with the SAME route).
1119    pub(crate) fn forward(
1120        &mut self,
1121        li: usize,
1122        m: &MoeFfn,
1123        x: &[f32],
1124        route: &MoeRoute,
1125        pool: Option<&Pool>,
1126    ) -> Option<Vec<f32>> {
1127        #[cfg(not(feature = "gpu"))]
1128        {
1129            let _ = (li, m, x, route, pool);
1130            None
1131        }
1132        #[cfg(feature = "gpu")]
1133        self.forward_bank(li, m, x, route, pool)
1134    }
1135
1136    /// Verify a short block against one stable snapshot of the bank. The
1137    /// union route is pinned before any admission, so filling a later row
1138    /// cannot overwrite a slot used by an earlier row in the same submit.
1139    pub(crate) fn forward_rows(
1140        &mut self,
1141        li: usize,
1142        m: &MoeFfn,
1143        xs: &[f32],
1144        routes: &[MoeRoute],
1145        pool: Option<&Pool>,
1146    ) -> Option<Vec<f32>> {
1147        #[cfg(feature = "gpu")]
1148        {
1149            let Self::On(d) = self else { return None };
1150            if d.failed || d.fast == Some(false) {
1151                return None;
1152            }
1153            let t0 = std::time::Instant::now();
1154            let out = d.run_rows(li, m, xs, routes, pool);
1155            STATS.lock().unwrap().call_ns += t0.elapsed().as_nanos() as u64;
1156            out
1157        }
1158        #[cfg(not(feature = "gpu"))]
1159        {
1160            let _ = (li, m, xs, routes, pool);
1161            None
1162        }
1163    }
1164
1165    #[cfg(feature = "gpu")]
1166    fn forward_bank(
1167        &mut self,
1168        li: usize,
1169        m: &MoeFfn,
1170        x: &[f32],
1171        route: &MoeRoute,
1172        pool: Option<&Pool>,
1173    ) -> Option<Vec<f32>> {
1174        let Self::On(d) = self else { return None };
1175        if d.failed {
1176            return None;
1177        }
1178        let t0 = std::time::Instant::now();
1179        let out = d.run(li, m, x, route, pool);
1180        {
1181            let mut st = STATS.lock().unwrap();
1182            st.call_ns += t0.elapsed().as_nanos() as u64;
1183            if out.is_none() {
1184                st.fallbacks += 1;
1185            }
1186        }
1187        if d.prof && li == d.last_moe {
1188            let (t, s0, c0) = d.prof_mark;
1189            let s1 = stats();
1190            let c1 = device_counters();
1191            let ms = |i: usize| (c1[i] - c0[i]) as f64 / 1e6;
1192            let picks = (s1.picks - s0.picks).max(1);
1193            eprintln!(
1194                "mimo-moe token: {:.1} ms wall, bank calls {} | hits {:.1}% fills {} cold {} of {} \
1195                 picks | frame {:.2} ms (encode {:.2} wait {:.2} upload {:.2} pass {:.2}), bank \
1196                 calls {:.2} ms, route {:.2} ms | submits {} | card {:.2} ms over {} frames | bank \
1197                 free {} of {}",
1198                t.elapsed().as_secs_f64() * 1e3,
1199                s1.calls - s0.calls,
1200                (s1.hits - s0.hits) as f64 / picks as f64 * 100.0,
1201                s1.fills - s0.fills,
1202                s1.cold - s0.cold,
1203                picks,
1204                (s1.frame_ns - s0.frame_ns) as f64 / 1e6,
1205                ms(0),
1206                ms(1),
1207                ms(2),
1208                ms(3),
1209                (s1.call_ns - s0.call_ns) as f64 / 1e6,
1210                (s1.route_ns - s0.route_ns) as f64 / 1e6,
1211                c1[4] - c0[4],
1212                ms(5),
1213                c1[6] - c0[6],
1214                {
1215                    let b = d.bank.lock().unwrap();
1216                    format!("{} (filling {})", b.free_slots(), b.pending())
1217                },
1218                d.bank.lock().unwrap().capacity(),
1219            );
1220            d.prof_mark = (std::time::Instant::now(), s1, c1);
1221        }
1222        out
1223    }
1224}
1225
1226#[cfg(feature = "gpu")]
1227impl Dynamic {
1228    fn run_rows(
1229        &mut self,
1230        li: usize,
1231        m: &MoeFfn,
1232        xs: &[f32],
1233        routes: &[MoeRoute],
1234        pool: Option<&Pool>,
1235    ) -> Option<Vec<f32>> {
1236        let rows = routes.len();
1237        let top_k = routes.first()?.idx.len();
1238        let hidden = m.experts.first()?.gate_proj.cols();
1239        let inter = m.experts[0].gate_proj.rows();
1240        if rows > 4
1241            || top_k == 0
1242            || xs.len() != rows * hidden
1243            || routes
1244                .iter()
1245                .any(|r| r.idx.len() != top_k || r.logits.len() != m.experts.len())
1246            || std::env::var("CMF_MIMO_BANK_KERNEL").as_deref() == Ok("generic")
1247        {
1248            return None;
1249        }
1250        let triples = self.ids.get(li).filter(|t| t.len() == m.experts.len())?;
1251        let mut union = Vec::new();
1252        for r in routes {
1253            for &e in &r.idx {
1254                if e >= triples.len() {
1255                    return None;
1256                }
1257                if !union.contains(&e) {
1258                    union.push(e);
1259                }
1260            }
1261        }
1262        let mut bank = self.bank.lock().unwrap();
1263        if li == self.dyn_from {
1264            bank.next_token();
1265        }
1266        let admitted0 = bank.admitted;
1267        let remap = bank.resolve(li, &union, triples)?;
1268        let mut sel = Vec::with_capacity(rows * top_k);
1269        let mut wt = Vec::with_capacity(rows * top_k);
1270        let mut cold_jobs = Vec::with_capacity(rows);
1271        for r in routes {
1272            let mut jobs = Vec::new();
1273            for &e in &r.idx {
1274                let w = r.p[e] / r.wsum;
1275                sel.push(remap[e]);
1276                wt.push(w);
1277                if remap[e] == u32::MAX {
1278                    jobs.push((&m.experts[e], w));
1279                }
1280            }
1281            cold_jobs.push(jobs);
1282        }
1283        let cold = sel.iter().filter(|&&s| s == u32::MAX).count();
1284        let host_rows = || {
1285            crate::gpu::cpu_scope(|| {
1286                crate::qtensor::float_activations_scope(|| {
1287                    crate::pipeline::moe_cold_experts_rows_cpu(&cold_jobs, xs, hidden, pool)
1288                })
1289            })
1290        };
1291        let t_frame = std::time::Instant::now();
1292        let mut out = vec![0.0; xs.len()];
1293        if cold == sel.len() {
1294            out = host_rows();
1295        } else {
1296            let (ok, host) = std::thread::scope(|scope| {
1297                let host = (cold > 0).then(|| scope.spawn(host_rows));
1298                let ok = crate::gpu_wgpu::mimo_bank::mimo_bank_rows(
1299                    &self.model,
1300                    xs,
1301                    &sel,
1302                    &wt,
1303                    inter,
1304                    rows,
1305                    &mut out,
1306                );
1307                (
1308                    ok,
1309                    host.map(|h| h.join().expect("MiMo cold-expert worker panicked")),
1310                )
1311            });
1312            if !ok {
1313                return None;
1314            }
1315            if let Some(host) = host {
1316                for (o, h) in out.iter_mut().zip(host) {
1317                    *o += h;
1318                }
1319            }
1320        }
1321        let mut st = STATS.lock().unwrap();
1322        st.calls += 1;
1323        st.picks += sel.len() as u64;
1324        st.hits += (sel.len() - cold) as u64;
1325        st.cold += cold as u64;
1326        st.fills += bank.admitted - admitted0;
1327        st.frame_ns += t_frame.elapsed().as_nanos() as u64;
1328        Some(out)
1329    }
1330
1331    fn run(
1332        &mut self,
1333        li: usize,
1334        m: &MoeFfn,
1335        x: &[f32],
1336        route: &MoeRoute,
1337        pool: Option<&Pool>,
1338    ) -> Option<Vec<f32>> {
1339        let triples = self.ids.get(li).filter(|t| t.len() == m.experts.len())?;
1340        let picks = &route.idx;
1341        if picks.is_empty() || route.logits.len() != m.experts.len() {
1342            return None;
1343        }
1344        let hidden = x.len();
1345        // The host route's final weights, exactly as the host path applies
1346        // them (`moe_ffn_cpu`: p[e] / wsum).
1347        let mut mix = vec![0.0f32; m.experts.len()];
1348        for &e in picks {
1349            mix[e] = route.p[e] / route.wsum;
1350        }
1351        // The slot map stays locked for the whole call: another pipeline on
1352        // the same model must not evict a slot between this resolve and the
1353        // frame that reads it.
1354        let bank_arc = self.bank.clone();
1355        let mut bank = bank_arc.lock().unwrap();
1356        if li == self.dyn_from {
1357            bank.next_token();
1358        }
1359        let admitted0 = bank.admitted;
1360        let Some(remap) = bank.resolve(li, picks, triples) else {
1361            tracing::warn!("MiMo MoE bank: slot map refused layer {li} — host path from now on");
1362            self.failed = true;
1363            return None;
1364        };
1365        let cold_ids: Vec<usize> = picks
1366            .iter()
1367            .copied()
1368            .filter(|&e| remap[e] == u32::MAX)
1369            .collect();
1370        let cold_jobs: Vec<(&crate::pipeline::DenseFfn, f32)> =
1371            cold_ids.iter().map(|&e| (&m.experts[e], mix[e])).collect();
1372        let weights = crate::gpu_wgpu::Dsv4MoeW {
1373            router: &[],
1374            experts: triples,
1375            logits: &route.logits,
1376            // Forced + preweighted: this is the final route table, the
1377            // shader does no scoring or normalization of its own.
1378            bias: Some(&mix),
1379            mask: None,
1380            forced: Some(picks),
1381            remap: Some(&remap),
1382            global: Some(crate::gpu_wgpu::Dsv4GlobalMoe {
1383                pool_uid: self.model.uid(),
1384                shared_slot: 0,
1385                segment_slots: bank.segment_slots as u32,
1386            }),
1387            has_shared: false,
1388            shared_weight: 1.0,
1389            preweighted: true,
1390            qwen_softmax: false,
1391        };
1392        let geom = crate::gpu_wgpu::Dsv4MoeGeom {
1393            hidden,
1394            inter: m.experts[0].gate_proj.rows(),
1395            top_k: picks.len(),
1396            route_scale: 1.0,
1397            swiglu_limit: 0.0,
1398            gu_q2: false,
1399            bf16: false,
1400        };
1401        let mut out = vec![0.0f32; hidden];
1402        let mut cold_seen = Vec::new();
1403        let mut cold_x = Vec::new();
1404        let model = self.model.clone();
1405        let t_frame = std::time::Instant::now();
1406        if cold_ids.len() == picks.len() {
1407            // Nothing resident: the frame would run eight zero-weight slots.
1408            let fills = bank.admitted - admitted0;
1409            drop(bank);
1410            let out = crate::gpu::cpu_scope(|| {
1411                crate::qtensor::float_activations_scope(|| {
1412                    crate::pipeline::moe_cold_experts_cpu(&cold_jobs, x, pool)
1413                })
1414            });
1415            let mut st = STATS.lock().unwrap();
1416            st.calls += 1;
1417            st.picks += picks.len() as u64;
1418            st.cold += picks.len() as u64;
1419            st.fills += fills;
1420            return Some(out);
1421        }
1422        // The dedicated bank kernels (`gpu_wgpu::mimo_bank`) when this
1423        // adapter builds them; the generic DSV4 bank frame otherwise, or
1424        // with `CMF_MIMO_BANK_KERNEL=generic`.
1425        let inter = geom.inter;
1426        let fast = *self.fast.get_or_insert_with(|| {
1427            std::env::var("CMF_MIMO_BANK_KERNEL").as_deref() != Ok("generic")
1428                && crate::gpu_wgpu::mimo_bank::mimo_bank_ready(&model, hidden, inter, picks.len())
1429        });
1430        let sel: Vec<u32> = picks.iter().map(|&e| remap[e]).collect();
1431        let wt: Vec<f32> = picks.iter().map(|&e| mix[e]).collect();
1432        let (ok, cold_out) = std::thread::scope(|s| {
1433            // Cold picks run on the host WHILE the device runs the resident
1434            // ones; the scope keeps their matvecs off the per-op GPU hooks.
1435            let host = (!cold_jobs.is_empty()).then(|| {
1436                s.spawn(|| {
1437                    crate::gpu::cpu_scope(|| {
1438                        crate::qtensor::float_activations_scope(|| {
1439                            crate::pipeline::moe_cold_experts_cpu(&cold_jobs, x, pool)
1440                        })
1441                    })
1442                })
1443            });
1444            let ok = if fast {
1445                crate::gpu_wgpu::mimo_bank::mimo_bank_frame(&model, x, &sel, &wt, inter, &mut out)
1446            } else {
1447                crate::gpu_wgpu::dsv4_moe_frame(
1448                    &model,
1449                    &weights,
1450                    geom,
1451                    x,
1452                    &mut cold_seen,
1453                    &mut cold_x,
1454                    None,
1455                    None,
1456                    &mut out,
1457                ) && cold_seen
1458                    .iter()
1459                    .map(|&(e, _)| e)
1460                    .eq(cold_ids.iter().copied())
1461            };
1462            let cold = host.and_then(|h| h.join().ok());
1463            (ok, cold)
1464        });
1465        let frame_ns = t_frame.elapsed().as_nanos() as u64;
1466        let fills = bank.admitted - admitted0;
1467        drop(bank);
1468        // The device hands back the picks it could not serve; they must be
1469        // exactly the ones the host computed.
1470        if !ok || (!cold_ids.is_empty() && cold_out.is_none()) {
1471            tracing::warn!(
1472                "MiMo MoE bank: frame refused at layer {li} (ok={ok}, {} kernels, {} cold) — host \
1473                 path from now on",
1474                if fast { "bank" } else { "generic" },
1475                cold_ids.len()
1476            );
1477            self.failed = true;
1478            return None;
1479        }
1480        if let Some(mut c) = cold_out {
1481            for (o, v) in out.iter_mut().zip(&c) {
1482                *o += v;
1483            }
1484            crate::attention::recycle_buf(&mut c);
1485        }
1486        let mut st = STATS.lock().unwrap();
1487        st.calls += 1;
1488        st.picks += picks.len() as u64;
1489        st.hits += (picks.len() - cold_ids.len()) as u64;
1490        st.cold += cold_ids.len() as u64;
1491        st.fills += fills;
1492        st.frame_ns += frame_ns;
1493        Some(out)
1494    }
1495}
1496
1497#[cfg(test)]
1498mod tests {
1499    use super::*;
1500
1501    const GB: u64 = 1_000_000_000;
1502
1503    fn mimo(budget: u64, graph: bool) -> PlacementInputs {
1504        PlacementInputs {
1505            budget,
1506            non_expert: 5_500_000_000,
1507            per_expert: 13_107_200,
1508            moe_layers: 47,
1509            n_experts: 256,
1510            top_k: 8,
1511            attn_per_layer: 93_000_000,
1512            graph_prefix: graph,
1513        }
1514    }
1515
1516    #[test]
1517    fn everything_fits_means_prefix() {
1518        let p = place(&mimo(400 * GB, true), &Costs::measured(), None);
1519        assert_eq!(p.mode, MoeMode::Prefix);
1520        assert_eq!(p.bank_slots, 0);
1521        assert_eq!(p.prefix_layers, 47);
1522    }
1523
1524    #[test]
1525    fn forced_modes_are_honoured_and_sized_from_the_budget() {
1526        let c = Costs::measured();
1527        let inp = mimo(98 * GB, false);
1528        let room = inp.budget - inp.non_expert - device_reserve(inp.budget);
1529        let slots = (room / inp.per_expert) as usize;
1530        let d = place(&inp, &c, Some(MoeMode::Dynamic));
1531        assert_eq!(
1532            (d.mode, d.prefix_layers, d.bank_slots),
1533            (MoeMode::Dynamic, 0, slots)
1534        );
1535        let p = place(&inp, &c, Some(MoeMode::Prefix));
1536        assert_eq!((p.mode, p.bank_slots), (MoeMode::Prefix, 0));
1537        assert_eq!(p.prefix_layers, slots / 256);
1538        let h = place(&inp, &c, Some(MoeMode::Hybrid));
1539        assert_eq!(h.mode, MoeMode::Hybrid);
1540        assert!(h.prefix_layers >= 1 && h.prefix_layers <= slots / 256);
1541        assert_eq!(h.bank_slots, slots - h.prefix_layers * 256);
1542    }
1543
1544    #[test]
1545    fn auto_prefers_the_cheapest_prediction() {
1546        let c = Costs::measured();
1547        for budget in [16 * GB, 24 * GB, 48 * GB, 80 * GB, 98 * GB] {
1548            for graph in [false, true] {
1549                let inp = mimo(budget, graph);
1550                let auto = place(&inp, &c, None);
1551                for m in [MoeMode::Prefix, MoeMode::Dynamic, MoeMode::Hybrid] {
1552                    let f = place(&inp, &c, Some(m));
1553                    assert!(
1554                        auto.predicted_s <= f.predicted_s + 1e-12,
1555                        "budget {budget} graph {graph}: auto {:?} {} > {m:?} {}",
1556                        auto.mode,
1557                        auto.predicted_s,
1558                        f.predicted_s
1559                    );
1560                }
1561            }
1562        }
1563    }
1564
1565    /// The measured costs on MiMo-V2.6-Flash q4tp: without a device graph
1566    /// for its layers every ladder budget places the experts in the bank
1567    /// (a per-op whole-layer prefix pays the same fences as a bank layer and
1568    /// streams the rest from RAM); `cargo test -- --nocapture` prints the
1569    /// predictions with and without a graph prefix.
1570    #[test]
1571    fn ladder_choices_for_mimo() {
1572        let c = Costs::measured();
1573        for mb in [16_000u64, 24_000, 48_000, 80_000, 93_791] {
1574            let budget = mb * 1024 * 1024;
1575            let no_graph = place(&mimo(budget, false), &c, None);
1576            let graph = place(&mimo(budget, true), &c, None);
1577            println!(
1578                "{mb} MB: no graph → {:?} P={} bank {} ({:.1} ms); graph → {:?} P={} bank {} ({:.1} ms)",
1579                no_graph.mode,
1580                no_graph.prefix_layers,
1581                no_graph.bank_slots,
1582                no_graph.predicted_s * 1e3,
1583                graph.mode,
1584                graph.prefix_layers,
1585                graph.bank_slots,
1586                graph.predicted_s * 1e3,
1587            );
1588            assert_eq!(
1589                no_graph.mode,
1590                MoeMode::Dynamic,
1591                "{mb} MB: {}",
1592                no_graph.reason
1593            );
1594            assert!(graph.predicted_s <= no_graph.predicted_s + 1e-12);
1595        }
1596    }
1597
1598    #[test]
1599    fn default_min_seen_follows_bank_size() {
1600        assert_eq!(default_min_seen(142), 2);
1601        assert_eq!(default_min_seen(48), 2);
1602        assert_eq!(default_min_seen(47), 3);
1603        assert_eq!(default_min_seen(13), 3);
1604    }
1605
1606    #[test]
1607    fn a_budget_below_the_non_expert_weights_leaves_no_bank() {
1608        let inp = mimo(4 * GB, false);
1609        let d = place(&inp, &Costs::measured(), Some(MoeMode::Dynamic));
1610        assert_eq!(d.bank_slots, 0);
1611    }
1612
1613    #[test]
1614    fn hit_curve_interpolates_monotonically() {
1615        let c = Costs::measured();
1616        let mut last = 0.0;
1617        for s in [
1618            1.0, 4.0, 8.0, 20.0, 32.0, 50.0, 64.0, 100.0, 128.0, 160.0, 192.0, 255.0,
1619        ] {
1620            let h = c.hit_rate(s, 256);
1621            assert!(h >= last && (0.0..=1.0).contains(&h), "{s}: {h}");
1622            last = h;
1623        }
1624        assert_eq!(c.hit_rate(256.0, 256), 1.0);
1625        assert_eq!(c.hit_rate(0.0, 256), 0.0);
1626    }
1627
1628    #[test]
1629    fn env_mode_names_parse() {
1630        assert_eq!(MoeMode::parse("dynamic"), Some(Some(MoeMode::Dynamic)));
1631        assert_eq!(MoeMode::parse("HYBRID"), Some(Some(MoeMode::Hybrid)));
1632        assert_eq!(MoeMode::parse("prefix"), Some(Some(MoeMode::Prefix)));
1633        assert_eq!(MoeMode::parse("auto"), Some(None));
1634        assert_eq!(MoeMode::parse("fast"), None);
1635    }
1636}
1637
1638/// The bank against the host path on a MiMo-shaped synthetic file whose
1639/// bank is far smaller than its expert count, so every run mixes resident
1640/// hits, fills and cold host picks. Needs a wgpu adapter with descriptor
1641/// arrays; skips otherwise. Run the CPU arm exact for the tight bounds:
1642///
1643///     CMF_SDOT=0 cargo test --release -p cortiq-engine --features gpu --lib bank_tests
1644#[cfg(all(test, feature = "gpu"))]
1645mod bank_tests {
1646    use super::*;
1647    use crate::pipeline::{FfnKind, Pipeline};
1648    use crate::sampler::SamplerConfig;
1649    use cortiq_core::CMF_VERSION;
1650    use cortiq_core::format::{CmfHeader, TensorSpec};
1651    use cortiq_core::quant::{
1652        GROUP_SIZE, dequant_q4tp, f32_to_f16, q4tp_code_stride, q4tp_put_code, q4tp_sections,
1653    };
1654    use cortiq_core::types::{ModelArch, QuantType};
1655    use std::collections::HashMap;
1656
1657    const HS: usize = 256;
1658    const INTER: usize = 64;
1659    const NE: usize = 16;
1660    const TOPK: usize = 4;
1661    const NH: usize = 4;
1662    const HD: usize = 32;
1663    const VD: usize = 16;
1664    const VOCAB: usize = 64;
1665    const DENSE_INTER: usize = 96;
1666    /// KV heads per layer: full, sliding, sliding, full (MiMo's pattern).
1667    const KVH: [usize; 4] = [1, 2, 2, 1];
1668    /// The bank: 8 slots for 3 × 16 experts.
1669    const SLOTS: usize = 8;
1670
1671    struct Rng(u64);
1672    impl Rng {
1673        fn next(&mut self) -> u64 {
1674            self.0 = self
1675                .0
1676                .wrapping_mul(6_364_136_223_846_793_005)
1677                .wrapping_add(1_442_695_040_888_963_407);
1678            self.0 >> 33
1679        }
1680        /// Uniform in [-0.5, 0.5).
1681        fn f(&mut self) -> f32 {
1682            (self.next() & 0xFF_FFFF) as f32 / (1u32 << 24) as f32 - 0.5
1683        }
1684    }
1685
1686    fn f32_spec(name: String, shape: &[usize], rng: &mut Rng, scale: f32, bias: f32) -> TensorSpec {
1687        let n: usize = shape.iter().product();
1688        TensorSpec {
1689            name,
1690            dtype: TensorDtype::F32,
1691            shape: shape.to_vec(),
1692            data: (0..n)
1693                .flat_map(|_| (bias + scale * rng.f()).to_le_bytes())
1694                .collect(),
1695        }
1696    }
1697
1698    /// A valid q4tp payload: random nibbles and rung codes, a per-row ladder
1699    /// around 2^-7 so the dequantized weights sit near ±0.1.
1700    fn q4tp_bytes(rows: usize, cols: usize, rng: &mut Rng) -> Vec<u8> {
1701        let gpr = cols / GROUP_SIZE;
1702        let stride = q4tp_code_stride(gpr);
1703        let (params_off, codes_off, _) = q4tp_sections(rows, cols);
1704        let mut b = vec![0u8; codes_off + rows * stride];
1705        for byte in b[..params_off].iter_mut() {
1706            *byte = rng.next() as u8;
1707        }
1708        for r in 0..rows {
1709            let p = params_off + r * 4;
1710            let lo = -7.0 + 0.5 * rng.f();
1711            let st = 0.06 + 0.02 * rng.f();
1712            b[p..p + 2].copy_from_slice(&f32_to_f16(lo).to_le_bytes());
1713            b[p + 2..p + 4].copy_from_slice(&f32_to_f16(st).to_le_bytes());
1714            let crow = &mut b[codes_off + r * stride..codes_off + (r + 1) * stride];
1715            for g in 0..gpr {
1716                q4tp_put_code(crow, g, (rng.next() % 32) as usize);
1717            }
1718        }
1719        b
1720    }
1721
1722    fn arch() -> ModelArch {
1723        serde_json::from_value(serde_json::json!({
1724            "arch_name": "mimo_v2",
1725            "hidden_size": HS,
1726            "intermediate_size": DENSE_INTER,
1727            "num_layers": 4,
1728            "num_attention_heads": NH,
1729            "num_kv_heads": KVH[0],
1730            "head_dim": HD,
1731            "vocab_size": VOCAB,
1732            "layer_types": ["FullAttention", "SlidingAttention", "SlidingAttention", "FullAttention"],
1733            "rms_norm_eps": 1e-6,
1734            "rope_theta": 10_000_000.0,
1735            "rope_local_base_freq": 10_000.0,
1736            "partial_rotary_factor": 0.5,
1737            "sliding_window": 3,
1738            "tie_word_embeddings": true,
1739            "max_position_embeddings": 256,
1740            "linear_conv_kernel_dim": 0,
1741            "linear_num_key_heads": 0,
1742            "linear_num_value_heads": 0,
1743            "kv_heads_per_layer": KVH,
1744            "v_head_dim": VD,
1745            "moe": {
1746                "num_experts": NE,
1747                "top_k": TOPK,
1748                "moe_intermediate_size": INTER,
1749                "norm_topk_prob": true,
1750                "router_sigmoid": true
1751            }
1752        }))
1753        .expect("arch")
1754    }
1755
1756    /// Write the file; returns the expert payloads by tensor name for the
1757    /// exact reference.
1758    fn write_model(tag: &str) -> (std::path::PathBuf, Arc<CmfModel>, HashMap<String, Vec<u8>>) {
1759        let mut rng = Rng(0x5EED_0000 ^ tag.len() as u64);
1760        let mut specs = vec![
1761            f32_spec(
1762                "model.embed_tokens.weight".into(),
1763                &[VOCAB, HS],
1764                &mut rng,
1765                2.0,
1766                0.0,
1767            ),
1768            f32_spec("model.norm.weight".into(), &[HS], &mut rng, 0.2, 1.0),
1769        ];
1770        let mut experts = HashMap::new();
1771        for (li, &kv) in KVH.iter().enumerate() {
1772            let p = format!("model.layers.{li}.");
1773            specs.push(f32_spec(
1774                format!("{p}input_layernorm.weight"),
1775                &[HS],
1776                &mut rng,
1777                0.2,
1778                1.0,
1779            ));
1780            specs.push(f32_spec(
1781                format!("{p}post_attention_layernorm.weight"),
1782                &[HS],
1783                &mut rng,
1784                0.2,
1785                1.0,
1786            ));
1787            for (n, shape) in [
1788                ("q_proj", [NH * HD, HS]),
1789                ("k_proj", [kv * HD, HS]),
1790                ("v_proj", [kv * VD, HS]),
1791                ("o_proj", [HS, NH * VD]),
1792            ] {
1793                let mut spec = f32_spec(
1794                    format!("{p}self_attn.{n}.weight"),
1795                    &shape,
1796                    &mut rng,
1797                    0.2,
1798                    0.0,
1799                );
1800                if tag == "attn-graph" {
1801                    // The real MiMo graph skeleton is q8_2f. F32 fixture
1802                    // projections deliberately cannot enter the batch GEMM.
1803                    let scale = 0.2f32 / 127.0;
1804                    let mut data: Vec<u8> = spec.data.chunks_exact(4)
1805                        .map(|v| (f32::from_le_bytes(v.try_into().unwrap()) / scale)
1806                            .round().clamp(-127.0, 127.0) as i8 as u8).collect();
1807                    for _ in 0..shape[0] { data.extend_from_slice(&f32_to_f16(scale).to_le_bytes()); }
1808                    for _ in 0..shape[1] { data.extend_from_slice(&f32_to_f16(1.0).to_le_bytes()); }
1809                    spec.dtype = TensorDtype::Q8_2f;
1810                    spec.data = data;
1811                }
1812                specs.push(spec);
1813            }
1814            if li == 1 || li == 2 {
1815                specs.push(f32_spec(
1816                    format!("{p}self_attn.sinks"),
1817                    &[NH],
1818                    &mut rng,
1819                    2.0,
1820                    0.0,
1821                ));
1822            }
1823            if li == 0 {
1824                for (n, shape) in [
1825                    ("gate_proj", [DENSE_INTER, HS]),
1826                    ("up_proj", [DENSE_INTER, HS]),
1827                    ("down_proj", [HS, DENSE_INTER]),
1828                ] {
1829                    specs.push(f32_spec(
1830                        format!("{p}mlp.{n}.weight"),
1831                        &shape,
1832                        &mut rng,
1833                        0.2,
1834                        0.0,
1835                    ));
1836                }
1837                continue;
1838            }
1839            specs.push(f32_spec(
1840                format!("{p}mlp.gate.weight"),
1841                &[NE, HS],
1842                &mut rng,
1843                0.4,
1844                0.0,
1845            ));
1846            specs.push(f32_spec(
1847                format!("{p}mlp.expert_bias"),
1848                &[NE],
1849                &mut rng,
1850                0.2,
1851                0.0,
1852            ));
1853            for e in 0..NE {
1854                for (n, rows, cols) in [
1855                    ("gate_proj", INTER, HS),
1856                    ("up_proj", INTER, HS),
1857                    ("down_proj", HS, INTER),
1858                ] {
1859                    let name = format!("{p}mlp.experts.{e}.{n}.weight");
1860                    let data = q4tp_bytes(rows, cols, &mut rng);
1861                    experts.insert(name.clone(), data.clone());
1862                    specs.push(TensorSpec {
1863                        name,
1864                        dtype: TensorDtype::Q4TiledP,
1865                        shape: vec![rows, cols],
1866                        data,
1867                    });
1868                }
1869            }
1870        }
1871        let header = CmfHeader {
1872            format: "cmf".into(),
1873            version: CMF_VERSION,
1874            arch: arch(),
1875            quant_type: QuantType::F32,
1876            provenance: None,
1877            tokenizer_config: None,
1878            section_hashes: None,
1879            skills: Vec::new(),
1880            shard: None,
1881            calibration: None,
1882            routing: None,
1883        };
1884        let dir = std::env::temp_dir().join(format!("cmf-mimo-bank-{}-{tag}", std::process::id()));
1885        let _ = std::fs::remove_dir_all(&dir);
1886        std::fs::create_dir_all(&dir).unwrap();
1887        let path = dir.join("m.cmf");
1888        CmfModel::write(&path, &header, &specs, None, None).unwrap();
1889        (dir, Arc::new(CmfModel::open(&path).unwrap()), experts)
1890    }
1891
1892    fn moe_layers(p: &Pipeline) -> Vec<(usize, &MoeFfn)> {
1893        p.weights
1894            .layers
1895            .iter()
1896            .enumerate()
1897            .filter_map(|(li, lw)| match &lw.ffn {
1898                FfnKind::Moe(m) => Some((li, m)),
1899                _ => None,
1900            })
1901            .collect()
1902    }
1903
1904    fn bank(p: &Pipeline) -> Slot {
1905        Slot::decide_with(
1906            &moe_layers(p),
1907            p.num_layers,
1908            false,
1909            Some(MoeMode::Dynamic),
1910            Some(SLOTS),
1911        )
1912    }
1913
1914    /// max|a−b| / max|b|.
1915    fn rel(a: &[f32], b: &[f32]) -> f32 {
1916        let d = a
1917            .iter()
1918            .zip(b)
1919            .map(|(x, y)| (x - y).abs())
1920            .fold(0f32, f32::max);
1921        d / b.iter().map(|v| v.abs()).fold(1e-30f32, f32::max)
1922    }
1923
1924    /// Σ w·down(silu(gate·x) ⊙ up·x) from the dequantized payloads, in f64.
1925    fn exact_moe(
1926        experts: &HashMap<String, Vec<u8>>,
1927        li: usize,
1928        x: &[f32],
1929        picks: &[usize],
1930        w: &[f32],
1931    ) -> Vec<f32> {
1932        let deq = |n: &str, rows: usize, cols: usize| {
1933            let mut v = vec![0f32; rows * cols];
1934            dequant_q4tp(&experts[n], rows, cols, &mut v);
1935            v
1936        };
1937        let mut out = vec![0f64; HS];
1938        for &e in picks {
1939            let p = format!("model.layers.{li}.mlp.experts.{e}.");
1940            let g = deq(&format!("{p}gate_proj.weight"), INTER, HS);
1941            let u = deq(&format!("{p}up_proj.weight"), INTER, HS);
1942            let d = deq(&format!("{p}down_proj.weight"), HS, INTER);
1943            let mut act = vec![0f64; INTER];
1944            for r in 0..INTER {
1945                let (mut gv, mut uv) = (0f64, 0f64);
1946                for c in 0..HS {
1947                    gv += g[r * HS + c] as f64 * x[c] as f64;
1948                    uv += u[r * HS + c] as f64 * x[c] as f64;
1949                }
1950                act[r] = gv / (1.0 + (-gv).exp()) * uv;
1951            }
1952            for r in 0..HS {
1953                let mut acc = 0f64;
1954                for c in 0..INTER {
1955                    acc += d[r * INTER + c] as f64 * act[c];
1956                }
1957                out[r] += w[e] as f64 * acc;
1958            }
1959        }
1960        out.into_iter().map(|v| v as f32).collect()
1961    }
1962
1963    fn bank_ready() -> bool {
1964        crate::gpu::enabled()
1965            && crate::gpu::wgpu_active()
1966            && crate::gpu_wgpu::dsv4_global_moe_supported()
1967            // Match Slot::decide_with: UMA intentionally keeps experts on
1968            // the host/shared-memory path, even if descriptor arrays exist.
1969            && crate::gpu_wgpu::dsv4_vram_budget().is_some_and(|b| b != u64::MAX)
1970    }
1971
1972    #[test]
1973    fn unified_memory_keeps_the_expert_bank_off() {
1974        let _g = serial();
1975        if !crate::gpu::enabled() || !crate::gpu::wgpu_active()
1976            || crate::gpu_wgpu::dsv4_vram_budget() != Some(u64::MAX)
1977        {
1978            return;
1979        }
1980        let (dir, model, _) = write_model("unified-placement");
1981        let p = Pipeline::from_model(&model, SamplerConfig::default()).unwrap();
1982        assert!(!bank(&p).is_on(), "UMA must retain host-paged expert placement");
1983        drop(p);
1984        drop(model);
1985        std::fs::remove_dir_all(dir).unwrap();
1986    }
1987
1988    fn serial() -> std::sync::MutexGuard<'static, ()> {
1989        static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
1990        GPU.lock().unwrap_or_else(|e| e.into_inner())
1991    }
1992
1993    /// Layer level: every routed call through an 8-slot bank equals the
1994    /// exact f64 expert sum to f32 summation order, with hits, fills and
1995    /// cold host picks all exercised.
1996    #[test]
1997    fn bank_layer_equals_exact_expert_sum() {
1998        layer_check("layer", false);
1999    }
2000
2001    /// The same check through the generic DSV4 bank frame (the fallback
2002    /// when the dedicated kernels cannot be built).
2003    #[test]
2004    fn generic_bank_layer_equals_exact_expert_sum() {
2005        layer_check("layer-generic", true);
2006    }
2007
2008    fn layer_check(tag: &str, generic: bool) {
2009        let _g = serial();
2010        if !bank_ready() {
2011            eprintln!("skip: no wgpu adapter with an expert bank");
2012            return;
2013        }
2014        let (dir, model, experts) = write_model(tag);
2015        let p = Pipeline::from_model(&model, SamplerConfig::default()).expect("load");
2016        let mut slot = bank(&p);
2017        assert!(
2018            slot.is_on(),
2019            "the bank must come up on this adapter: {}",
2020            last_decision()
2021        );
2022        if let Slot::On(d) = &mut slot {
2023            d.fast = generic.then_some(false);
2024        }
2025        let s0 = stats();
2026        let mut rng = Rng(77);
2027        let strict = !crate::qtensor::a8w8_enabled();
2028        let (mut worst_dyn, mut worst_host, mut calls) = (0f32, 0f32, 0usize);
2029        for round in 0..16 {
2030            for (li, m) in moe_layers(&p) {
2031                // Tokens drift slowly, as decode hiddens do: routes repeat
2032                // (hits) and change (fills, cold picks).
2033                let x: Vec<f32> = (0..HS)
2034                    .map(|i| {
2035                        ((i * 7 + li * 3) as f32 * 0.37).sin() + 0.35 * rng.f() * (round % 3) as f32
2036                    })
2037                    .collect();
2038                let r = crate::pipeline::moe_ffn_route(m, &x, None, None);
2039                assert_eq!(r.idx.len(), TOPK);
2040                let mix: Vec<f32> = (0..NE)
2041                    .map(|e| {
2042                        if r.idx.contains(&e) {
2043                            r.p[e] / r.wsum
2044                        } else {
2045                            0.0
2046                        }
2047                    })
2048                    .collect();
2049                let want = exact_moe(&experts, li, &x, &r.idx, &mix);
2050                let jobs: Vec<_> = r.idx.iter().map(|&e| (&m.experts[e], mix[e])).collect();
2051                let host = crate::gpu::cpu_scope(|| {
2052                    crate::pipeline::moe_cold_experts_cpu(&jobs, &x, None)
2053                });
2054                let got = slot
2055                    .forward(li, m, &x, &r, None)
2056                    .expect("the bank served the layer");
2057                worst_dyn = worst_dyn.max(rel(&got, &want));
2058                worst_host = worst_host.max(rel(&host, &want));
2059                calls += 1;
2060            }
2061        }
2062        let s1 = stats();
2063        let (hits, fills, cold) = (s1.hits - s0.hits, s1.fills - s0.fills, s1.cold - s0.cold);
2064        let kernels = match &slot {
2065            Slot::On(d) => d.fast,
2066            _ => None,
2067        };
2068        assert_eq!(
2069            kernels,
2070            Some(!generic),
2071            "expected the {} kernels",
2072            if generic { "generic" } else { "bank" }
2073        );
2074        eprintln!(
2075            "bank layer check ({tag}): {calls} calls, {} picks: hits {hits} fills {fills} cold {cold}; \
2076             max rel |bank−exact| {worst_dyn:.2e}, |host−exact| {worst_host:.2e} (a8w8 {})",
2077            s1.picks - s0.picks,
2078            !strict
2079        );
2080        assert!(
2081            hits > 0 && fills > 0 && cold > 0,
2082            "hits {hits} fills {fills} cold {cold}"
2083        );
2084        // Device f32 against an f64 sum of the same dequantized weights; the
2085        // cold picks ride the host kernels, exact under CMF_SDOT=0 and int8
2086        // activations otherwise.
2087        // Under A8W8 the host picks carry the int8-activation error; the
2088        // bank's result must not be further from exact than the host's.
2089        let bound = if strict { 1e-5 } else { worst_host.max(1e-5) };
2090        assert!(
2091            worst_dyn <= bound,
2092            "bank vs exact {worst_dyn:.3e} > {bound:e}"
2093        );
2094        drop(p);
2095        let _ = std::fs::remove_dir_all(&dir);
2096    }
2097
2098    #[test]
2099    fn cold_batch_rows_equal_single_token_kernels() {
2100        let (dir, model, _) = write_model("cold-rows");
2101        let p = Pipeline::from_model(&model, SamplerConfig::default()).unwrap();
2102        let (_, m) = moe_layers(&p)[0];
2103        let xs: Vec<f32> = (0..4 * HS).map(|i| (i as f32 * 0.17).sin()).collect();
2104        let jobs = vec![
2105            vec![(&m.experts[0], 0.3), (&m.experts[1], 0.7)],
2106            vec![],
2107            vec![(&m.experts[1], 0.2), (&m.experts[0], 0.8)],
2108            vec![(&m.experts[0], 1.0)],
2109        ];
2110        crate::gpu::cpu_scope(|| {
2111            let batch = crate::pipeline::moe_cold_experts_rows_cpu(&jobs, &xs, HS, None);
2112            for (r, jobs) in jobs.iter().enumerate() {
2113                let one =
2114                    crate::pipeline::moe_cold_experts_cpu(jobs, &xs[r * HS..(r + 1) * HS], None);
2115                assert_eq!(batch[r * HS..(r + 1) * HS], one, "cold row {r}");
2116            }
2117        });
2118        drop(p);
2119        std::fs::remove_dir_all(dir).unwrap();
2120    }
2121
2122    #[test]
2123    fn dynamic_attention_graph_batches_preserve_layer_keys_and_rewind() {
2124        let _g = serial();
2125        if !bank_ready() {
2126            return;
2127        }
2128        let (dir, model, _) = write_model("attn-graph");
2129        let mut batch = Pipeline::from_model(&model, SamplerConfig::default()).unwrap();
2130        let mut single = Pipeline::from_model(&model, SamplerConfig::default()).unwrap();
2131        batch.mimo_moe = bank(&batch);
2132        single.mimo_moe = bank(&single);
2133        // The short-panel head/projection kernel must equal the old
2134        // eight-lane panel bit for bit, including every batch width.
2135        let crate::pipeline::AttnKind::Full { wq, .. } = &batch.weights.layers[2].attn else {
2136            unreachable!()
2137        };
2138        let (owner, idx, _, _) = wq.graph_weight().unwrap();
2139        for b in 1..=4 {
2140            let xs: Vec<f32> = (0..b * HS).map(|i| (i as f32 * 0.071).cos()).collect();
2141            let mut actual = vec![0.0; b * NH * HD];
2142            let mut expected = actual.clone();
2143            assert!(crate::gpu::q82_short_rows(
2144                owner,
2145                idx,
2146                &xs,
2147                b,
2148                NH * HD,
2149                HS,
2150                &mut actual
2151            ));
2152            assert!(crate::gpu::mimo_q8_short_scope(false, || {
2153                crate::gpu::q82_short_rows(owner, idx, &xs, b, NH * HD, HS, &mut expected)
2154            }));
2155            assert_eq!(actual, expected, "q82 short/wide panel b={b}");
2156        }
2157        // Distinct global/SWA geometries; neither may overwrite layer zero.
2158        // Pass 300 absolute positions to cover split-K full attention as
2159        // well as many SWA wraps and rejected suffix overwrites.
2160        batch.kv_cache.max_seq_len = 512;
2161        single.kv_cache.max_seq_len = 512;
2162        for li in [2, 3] {
2163            let mut pos = 0;
2164            for b in (0..180).map(|i| i % 4 + 1) {
2165                let positions: Vec<_> = (pos..pos + b).collect();
2166                let xs: Vec<f32> = (0..b * HS)
2167                    .map(|i| ((i + pos * HS) as f32 * 0.017).sin())
2168                    .collect();
2169                let mut ys = xs.clone();
2170                assert!(
2171                    matches!(
2172                        batch.mimo_graph_layer_rows(li, &mut ys, &positions),
2173                        crate::gpu::BatchGraphOutcome::Completed
2174                    ),
2175                    "batch admission li={li}, b={b}"
2176                );
2177                let mut want = xs;
2178                for (row, &p) in want.chunks_exact_mut(HS).zip(&positions) {
2179                    let outcome = crate::gpu::mimo_q8_short_scope(false, || {
2180                        crate::gpu::mimo_attention_scratch_scope(false, || {
2181                            single.mimo_graph_layer_rows(li, row, &[p])
2182                        })
2183                    });
2184                    assert!(matches!(outcome, crate::gpu::BatchGraphOutcome::Completed));
2185                }
2186                assert!(
2187                    rel(&ys, &want) < 2e-5,
2188                    "li={li}, b={b}: {}",
2189                    rel(&ys, &want)
2190                );
2191                pos += b;
2192                assert_eq!(
2193                    crate::gpu::graph_kv_stored(batch.test_graph_kv_id(), li),
2194                    Some(pos)
2195                );
2196                assert_eq!(
2197                    crate::gpu::graph_kv_stored(batch.test_graph_kv_id(), 0),
2198                    None
2199                );
2200                // Rejected suffix must be overwritable on both full and SWA KV.
2201                if b > 1 {
2202                    pos -= 1;
2203                    assert!(crate::gpu::graph_kv_set_stored(
2204                        batch.test_graph_kv_id(),
2205                        li,
2206                        pos
2207                    ));
2208                    assert!(crate::gpu::graph_kv_set_stored(
2209                        single.test_graph_kv_id(),
2210                        li,
2211                        pos
2212                    ));
2213                }
2214            }
2215            assert!(pos > 300);
2216        }
2217        drop(batch);
2218        drop(single);
2219        drop(model);
2220        std::fs::remove_dir_all(dir).unwrap();
2221    }
2222
2223    #[test]
2224    fn hybrid_bank_epoch_advances_at_dynamic_boundary() {
2225        let _g = serial();
2226        if !bank_ready() {
2227            return;
2228        }
2229        let (dir, model, _) = write_model("hybrid-epoch");
2230        let p = Pipeline::from_model(&model, SamplerConfig::default()).unwrap();
2231        let mut slot = bank(&p);
2232        let Slot::On(d) = &mut slot else {
2233            panic!("bank unavailable")
2234        };
2235        // The first MoE layer (1) runs in the graph and never visits the
2236        // bank. Layer 2 must still release last round's eviction pins.
2237        d.dyn_from = 2;
2238        d.placement.mode = MoeMode::Hybrid;
2239        d.placement.prefix_layers = 1;
2240        let bank = d.bank.clone();
2241        let initial = bank.lock().unwrap().tok;
2242        let x: Vec<f32> = (0..HS).map(|i| (i as f32 * 0.13).sin()).collect();
2243        for li in [2, 3] {
2244            let FfnKind::Moe(m) = &p.weights.layers[li].ffn else {
2245                unreachable!()
2246            };
2247            let route = crate::pipeline::moe_ffn_route(m, &x, None, None);
2248            assert!(slot.forward(li, m, &x, &route, None).is_some());
2249            assert_eq!(bank.lock().unwrap().tok, initial + 1, "decode layer {li}");
2250        }
2251        let xs = [x.as_slice(), x.as_slice()].concat();
2252        for li in [2, 3] {
2253            let FfnKind::Moe(m) = &p.weights.layers[li].ffn else {
2254                unreachable!()
2255            };
2256            let routes: Vec<_> = xs.chunks_exact(HS)
2257                .map(|row| crate::pipeline::moe_ffn_route(m, row, None, None))
2258                .collect();
2259            assert!(slot.forward_rows(li, m, &xs, &routes, None).is_some());
2260            assert_eq!(bank.lock().unwrap().tok, initial + 2, "verify layer {li}");
2261        }
2262        drop(p);
2263        std::fs::remove_dir_all(dir).unwrap();
2264    }
2265
2266    #[test]
2267    fn bank_batch_frame_equals_single_token_frames() {
2268        let _g = serial();
2269        if !bank_ready() {
2270            return;
2271        }
2272        let (dir, model, _) = write_model("batch-frames");
2273        let p = Pipeline::from_model(&model, SamplerConfig::default()).unwrap();
2274        let mut slot = bank(&p);
2275        assert_eq!(slot.graph_prefix_end(), Some(1));
2276        let Slot::On(d) = &mut slot else {
2277            panic!("bank unavailable")
2278        };
2279        let bank_arc = d.bank.clone();
2280        {
2281            let mut b = bank_arc.lock().unwrap();
2282            b.next_token();
2283            b.resolve(1, &[0, 1, 2, 3], &d.ids[1]).unwrap();
2284        }
2285        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2286        loop {
2287            let mut b = bank_arc.lock().unwrap();
2288            b.drain();
2289            if b.pending() == 0 {
2290                break;
2291            }
2292            assert!(std::time::Instant::now() < deadline, "bank fills timed out");
2293            drop(b);
2294            std::thread::sleep(std::time::Duration::from_millis(5));
2295        }
2296        let mut b = bank_arc.lock().unwrap();
2297        let remap = b.resolve(1, &[0, 1, 2, 3], &d.ids[1]).unwrap();
2298        assert!((0..4).all(|e| remap[e] != u32::MAX));
2299        for rows in 1..=4 {
2300            let xs: Vec<f32> = (0..rows * HS).map(|i| (i as f32 * 0.13).cos()).collect();
2301            let sel: Vec<u32> = (0..rows * TOPK)
2302                .map(|i| if i % 5 == 0 { u32::MAX } else { remap[i % 4] })
2303                .collect();
2304            let wt: Vec<f32> = (0..sel.len())
2305                .map(|i| 0.1 + 0.03 * (i % 4) as f32)
2306                .collect();
2307            let mut batch = vec![0.0; xs.len()];
2308            assert!(crate::gpu_wgpu::mimo_bank::mimo_bank_rows(
2309                &model, &xs, &sel, &wt, INTER, rows, &mut batch
2310            ));
2311            for row in 0..rows {
2312                let mut one = vec![0.0; HS];
2313                assert!(crate::gpu_wgpu::mimo_bank::mimo_bank_frame(
2314                    &model,
2315                    &xs[row * HS..(row + 1) * HS],
2316                    &sel[row * TOPK..(row + 1) * TOPK],
2317                    &wt[row * TOPK..(row + 1) * TOPK],
2318                    INTER,
2319                    &mut one
2320                ));
2321                assert_eq!(batch[row * HS..(row + 1) * HS], one, "GPU row {row}/{rows}");
2322            }
2323        }
2324        drop(b);
2325        drop(p);
2326        std::fs::remove_dir_all(dir).unwrap();
2327    }
2328
2329    /// Model level: greedy decode of a prompt through the bank equals the
2330    /// pure host walk — logits to summation order, identical tokens.
2331    #[test]
2332    fn bank_decode_equals_host_decode() {
2333        let _g = serial();
2334        if !bank_ready() {
2335            eprintln!("skip: no wgpu adapter with an expert bank");
2336            return;
2337        }
2338        let (dir, model, _) = write_model("decode");
2339        let run = |bank_on: bool| -> (Vec<Vec<f32>>, Vec<u32>) {
2340            let mut p = Pipeline::from_model(&model, SamplerConfig::default()).expect("load");
2341            p.mimo_moe = if bank_on { bank(&p) } else { Slot::Off };
2342            assert_eq!(p.mimo_moe.is_on(), bank_on, "{}", last_decision());
2343            let n = p.num_layers;
2344            let mut ids: Vec<u32> = vec![3, 17, 42, 5, 9, 33, 21, 8, 60, 1, 12];
2345            let prompt = ids.len();
2346            let mut all = Vec::new();
2347            for pos in 0..prompt + 12 {
2348                let id = ids[pos];
2349                let step = |p: &mut Pipeline| {
2350                    let emb = p.embed_id(id);
2351                    let h = p.forward_span(&emb, pos, 0, n - 1, None).unwrap();
2352                    p.logits_from_hidden(&h)
2353                };
2354                let lg = if bank_on {
2355                    step(&mut p)
2356                } else {
2357                    crate::gpu::cpu_scope(|| step(&mut p))
2358                };
2359                if pos + 1 >= prompt {
2360                    let next = lg
2361                        .iter()
2362                        .enumerate()
2363                        .max_by(|a, b| a.1.total_cmp(b.1))
2364                        .map(|(i, _)| i as u32)
2365                        .unwrap();
2366                    ids.push(next);
2367                }
2368                all.push(lg);
2369            }
2370            (all, ids)
2371        };
2372        let s0 = stats();
2373        let (host, host_ids) = run(false);
2374        let s1 = stats();
2375        assert_eq!(s1.calls, s0.calls, "the host run must not touch the bank");
2376        let (dynm, dyn_ids) = run(true);
2377        let s2 = stats();
2378        let worst = host
2379            .iter()
2380            .zip(&dynm)
2381            .map(|(h, d)| rel(d, h))
2382            .fold(0f32, f32::max);
2383        let strict = !crate::qtensor::a8w8_enabled();
2384        eprintln!(
2385            "bank decode check: {} steps, bank calls {} hits {} fills {} cold {}; logits max rel \
2386             {worst:.2e}; greedy {:?} vs host {:?}",
2387            host.len(),
2388            s2.calls - s1.calls,
2389            s2.hits - s1.hits,
2390            s2.fills - s1.fills,
2391            s2.cold - s1.cold,
2392            &dyn_ids[11..],
2393            &host_ids[11..],
2394        );
2395        assert_eq!(
2396            s2.calls - s1.calls,
2397            (host.len() * 3) as u64,
2398            "every MoE call took the bank"
2399        );
2400        assert!(
2401            s2.hits > s1.hits && s2.cold > s1.cold,
2402            "both resident and cold picks ran"
2403        );
2404        if !strict {
2405            // The host arm quantizes activations to int8 (A8W8); on this
2406            // toy that alone moves its logits by percent. Equality is a
2407            // claim about the exact arm: rerun with CMF_SDOT=0.
2408            eprintln!("bank decode check: A8W8 host arm — bounds need CMF_SDOT=0, not asserted");
2409            let _ = std::fs::remove_dir_all(&dir);
2410            return;
2411        }
2412        assert!(worst < 1e-4, "logits bank vs host {worst:.3e} ≥ 1e-4");
2413        // Every step's argmax, prompt positions included (teacher forced),
2414        // not only the generated tail.
2415        let am = |v: &[f32]| {
2416            v.iter()
2417                .enumerate()
2418                .max_by(|a, b| a.1.total_cmp(b.1))
2419                .unwrap()
2420                .0
2421        };
2422        let (a_host, a_dyn): (Vec<usize>, Vec<usize>) =
2423            host.iter().zip(&dynm).map(|(h, d)| (am(h), am(d))).unzip();
2424        assert_eq!(a_dyn, a_host, "per-step argmax differs");
2425        assert_eq!(dyn_ids, host_ids, "greedy tokens differ");
2426        let _ = std::fs::remove_dir_all(&dir);
2427    }
2428}