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            genome: None,
1884            lineage: Vec::new(),
1885            router: None,
1886            segments: Vec::new(),
1887        };
1888        let dir = std::env::temp_dir().join(format!("cmf-mimo-bank-{}-{tag}", std::process::id()));
1889        let _ = std::fs::remove_dir_all(&dir);
1890        std::fs::create_dir_all(&dir).unwrap();
1891        let path = dir.join("m.cmf");
1892        CmfModel::write(&path, &header, &specs, None, None).unwrap();
1893        (dir, Arc::new(CmfModel::open(&path).unwrap()), experts)
1894    }
1895
1896    fn moe_layers(p: &Pipeline) -> Vec<(usize, &MoeFfn)> {
1897        p.weights
1898            .layers
1899            .iter()
1900            .enumerate()
1901            .filter_map(|(li, lw)| match &lw.ffn {
1902                FfnKind::Moe(m) => Some((li, m)),
1903                _ => None,
1904            })
1905            .collect()
1906    }
1907
1908    fn bank(p: &Pipeline) -> Slot {
1909        Slot::decide_with(
1910            &moe_layers(p),
1911            p.num_layers,
1912            false,
1913            Some(MoeMode::Dynamic),
1914            Some(SLOTS),
1915        )
1916    }
1917
1918    /// max|a−b| / max|b|.
1919    fn rel(a: &[f32], b: &[f32]) -> f32 {
1920        let d = a
1921            .iter()
1922            .zip(b)
1923            .map(|(x, y)| (x - y).abs())
1924            .fold(0f32, f32::max);
1925        d / b.iter().map(|v| v.abs()).fold(1e-30f32, f32::max)
1926    }
1927
1928    /// Σ w·down(silu(gate·x) ⊙ up·x) from the dequantized payloads, in f64.
1929    fn exact_moe(
1930        experts: &HashMap<String, Vec<u8>>,
1931        li: usize,
1932        x: &[f32],
1933        picks: &[usize],
1934        w: &[f32],
1935    ) -> Vec<f32> {
1936        let deq = |n: &str, rows: usize, cols: usize| {
1937            let mut v = vec![0f32; rows * cols];
1938            dequant_q4tp(&experts[n], rows, cols, &mut v);
1939            v
1940        };
1941        let mut out = vec![0f64; HS];
1942        for &e in picks {
1943            let p = format!("model.layers.{li}.mlp.experts.{e}.");
1944            let g = deq(&format!("{p}gate_proj.weight"), INTER, HS);
1945            let u = deq(&format!("{p}up_proj.weight"), INTER, HS);
1946            let d = deq(&format!("{p}down_proj.weight"), HS, INTER);
1947            let mut act = vec![0f64; INTER];
1948            for r in 0..INTER {
1949                let (mut gv, mut uv) = (0f64, 0f64);
1950                for c in 0..HS {
1951                    gv += g[r * HS + c] as f64 * x[c] as f64;
1952                    uv += u[r * HS + c] as f64 * x[c] as f64;
1953                }
1954                act[r] = gv / (1.0 + (-gv).exp()) * uv;
1955            }
1956            for r in 0..HS {
1957                let mut acc = 0f64;
1958                for c in 0..INTER {
1959                    acc += d[r * INTER + c] as f64 * act[c];
1960                }
1961                out[r] += w[e] as f64 * acc;
1962            }
1963        }
1964        out.into_iter().map(|v| v as f32).collect()
1965    }
1966
1967    fn bank_ready() -> bool {
1968        crate::gpu::enabled()
1969            && crate::gpu::wgpu_active()
1970            && crate::gpu_wgpu::dsv4_global_moe_supported()
1971            // Match Slot::decide_with: UMA intentionally keeps experts on
1972            // the host/shared-memory path, even if descriptor arrays exist.
1973            && crate::gpu_wgpu::dsv4_vram_budget().is_some_and(|b| b != u64::MAX)
1974    }
1975
1976    #[test]
1977    fn unified_memory_keeps_the_expert_bank_off() {
1978        let _g = serial();
1979        if !crate::gpu::enabled() || !crate::gpu::wgpu_active()
1980            || crate::gpu_wgpu::dsv4_vram_budget() != Some(u64::MAX)
1981        {
1982            return;
1983        }
1984        let (dir, model, _) = write_model("unified-placement");
1985        let p = Pipeline::from_model(&model, SamplerConfig::default()).unwrap();
1986        assert!(!bank(&p).is_on(), "UMA must retain host-paged expert placement");
1987        drop(p);
1988        drop(model);
1989        std::fs::remove_dir_all(dir).unwrap();
1990    }
1991
1992    fn serial() -> std::sync::MutexGuard<'static, ()> {
1993        static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
1994        GPU.lock().unwrap_or_else(|e| e.into_inner())
1995    }
1996
1997    /// Layer level: every routed call through an 8-slot bank equals the
1998    /// exact f64 expert sum to f32 summation order, with hits, fills and
1999    /// cold host picks all exercised.
2000    #[test]
2001    fn bank_layer_equals_exact_expert_sum() {
2002        layer_check("layer", false);
2003    }
2004
2005    /// The same check through the generic DSV4 bank frame (the fallback
2006    /// when the dedicated kernels cannot be built).
2007    #[test]
2008    fn generic_bank_layer_equals_exact_expert_sum() {
2009        layer_check("layer-generic", true);
2010    }
2011
2012    fn layer_check(tag: &str, generic: bool) {
2013        let _g = serial();
2014        if !bank_ready() {
2015            eprintln!("skip: no wgpu adapter with an expert bank");
2016            return;
2017        }
2018        let (dir, model, experts) = write_model(tag);
2019        let p = Pipeline::from_model(&model, SamplerConfig::default()).expect("load");
2020        let mut slot = bank(&p);
2021        assert!(
2022            slot.is_on(),
2023            "the bank must come up on this adapter: {}",
2024            last_decision()
2025        );
2026        if let Slot::On(d) = &mut slot {
2027            d.fast = generic.then_some(false);
2028        }
2029        let s0 = stats();
2030        let mut rng = Rng(77);
2031        let strict = !crate::qtensor::a8w8_enabled();
2032        let (mut worst_dyn, mut worst_host, mut calls) = (0f32, 0f32, 0usize);
2033        for round in 0..16 {
2034            for (li, m) in moe_layers(&p) {
2035                // Tokens drift slowly, as decode hiddens do: routes repeat
2036                // (hits) and change (fills, cold picks).
2037                let x: Vec<f32> = (0..HS)
2038                    .map(|i| {
2039                        ((i * 7 + li * 3) as f32 * 0.37).sin() + 0.35 * rng.f() * (round % 3) as f32
2040                    })
2041                    .collect();
2042                let r = crate::pipeline::moe_ffn_route(m, &x, None, None);
2043                assert_eq!(r.idx.len(), TOPK);
2044                let mix: Vec<f32> = (0..NE)
2045                    .map(|e| {
2046                        if r.idx.contains(&e) {
2047                            r.p[e] / r.wsum
2048                        } else {
2049                            0.0
2050                        }
2051                    })
2052                    .collect();
2053                let want = exact_moe(&experts, li, &x, &r.idx, &mix);
2054                let jobs: Vec<_> = r.idx.iter().map(|&e| (&m.experts[e], mix[e])).collect();
2055                let host = crate::gpu::cpu_scope(|| {
2056                    crate::pipeline::moe_cold_experts_cpu(&jobs, &x, None)
2057                });
2058                let got = slot
2059                    .forward(li, m, &x, &r, None)
2060                    .expect("the bank served the layer");
2061                worst_dyn = worst_dyn.max(rel(&got, &want));
2062                worst_host = worst_host.max(rel(&host, &want));
2063                calls += 1;
2064            }
2065        }
2066        let s1 = stats();
2067        let (hits, fills, cold) = (s1.hits - s0.hits, s1.fills - s0.fills, s1.cold - s0.cold);
2068        let kernels = match &slot {
2069            Slot::On(d) => d.fast,
2070            _ => None,
2071        };
2072        assert_eq!(
2073            kernels,
2074            Some(!generic),
2075            "expected the {} kernels",
2076            if generic { "generic" } else { "bank" }
2077        );
2078        eprintln!(
2079            "bank layer check ({tag}): {calls} calls, {} picks: hits {hits} fills {fills} cold {cold}; \
2080             max rel |bank−exact| {worst_dyn:.2e}, |host−exact| {worst_host:.2e} (a8w8 {})",
2081            s1.picks - s0.picks,
2082            !strict
2083        );
2084        assert!(
2085            hits > 0 && fills > 0 && cold > 0,
2086            "hits {hits} fills {fills} cold {cold}"
2087        );
2088        // Device f32 against an f64 sum of the same dequantized weights; the
2089        // cold picks ride the host kernels, exact under CMF_SDOT=0 and int8
2090        // activations otherwise.
2091        // Under A8W8 the host picks carry the int8-activation error; the
2092        // bank's result must not be further from exact than the host's.
2093        let bound = if strict { 1e-5 } else { worst_host.max(1e-5) };
2094        assert!(
2095            worst_dyn <= bound,
2096            "bank vs exact {worst_dyn:.3e} > {bound:e}"
2097        );
2098        drop(p);
2099        let _ = std::fs::remove_dir_all(&dir);
2100    }
2101
2102    #[test]
2103    fn cold_batch_rows_equal_single_token_kernels() {
2104        let (dir, model, _) = write_model("cold-rows");
2105        let p = Pipeline::from_model(&model, SamplerConfig::default()).unwrap();
2106        let (_, m) = moe_layers(&p)[0];
2107        let xs: Vec<f32> = (0..4 * HS).map(|i| (i as f32 * 0.17).sin()).collect();
2108        let jobs = vec![
2109            vec![(&m.experts[0], 0.3), (&m.experts[1], 0.7)],
2110            vec![],
2111            vec![(&m.experts[1], 0.2), (&m.experts[0], 0.8)],
2112            vec![(&m.experts[0], 1.0)],
2113        ];
2114        crate::gpu::cpu_scope(|| {
2115            let batch = crate::pipeline::moe_cold_experts_rows_cpu(&jobs, &xs, HS, None);
2116            for (r, jobs) in jobs.iter().enumerate() {
2117                let one =
2118                    crate::pipeline::moe_cold_experts_cpu(jobs, &xs[r * HS..(r + 1) * HS], None);
2119                assert_eq!(batch[r * HS..(r + 1) * HS], one, "cold row {r}");
2120            }
2121        });
2122        drop(p);
2123        std::fs::remove_dir_all(dir).unwrap();
2124    }
2125
2126    #[test]
2127    fn dynamic_attention_graph_batches_preserve_layer_keys_and_rewind() {
2128        let _g = serial();
2129        if !bank_ready() {
2130            return;
2131        }
2132        let (dir, model, _) = write_model("attn-graph");
2133        let mut batch = Pipeline::from_model(&model, SamplerConfig::default()).unwrap();
2134        let mut single = Pipeline::from_model(&model, SamplerConfig::default()).unwrap();
2135        batch.mimo_moe = bank(&batch);
2136        single.mimo_moe = bank(&single);
2137        // The short-panel head/projection kernel must equal the old
2138        // eight-lane panel bit for bit, including every batch width.
2139        let crate::pipeline::AttnKind::Full { wq, .. } = &batch.weights.layers[2].attn else {
2140            unreachable!()
2141        };
2142        let (owner, idx, _, _) = wq.graph_weight().unwrap();
2143        for b in 1..=4 {
2144            let xs: Vec<f32> = (0..b * HS).map(|i| (i as f32 * 0.071).cos()).collect();
2145            let mut actual = vec![0.0; b * NH * HD];
2146            let mut expected = actual.clone();
2147            assert!(crate::gpu::q82_short_rows(
2148                owner,
2149                idx,
2150                &xs,
2151                b,
2152                NH * HD,
2153                HS,
2154                &mut actual
2155            ));
2156            assert!(crate::gpu::mimo_q8_short_scope(false, || {
2157                crate::gpu::q82_short_rows(owner, idx, &xs, b, NH * HD, HS, &mut expected)
2158            }));
2159            assert_eq!(actual, expected, "q82 short/wide panel b={b}");
2160        }
2161        // Distinct global/SWA geometries; neither may overwrite layer zero.
2162        // Pass 300 absolute positions to cover split-K full attention as
2163        // well as many SWA wraps and rejected suffix overwrites.
2164        batch.kv_cache.max_seq_len = 512;
2165        single.kv_cache.max_seq_len = 512;
2166        for li in [2, 3] {
2167            let mut pos = 0;
2168            for b in (0..180).map(|i| i % 4 + 1) {
2169                let positions: Vec<_> = (pos..pos + b).collect();
2170                let xs: Vec<f32> = (0..b * HS)
2171                    .map(|i| ((i + pos * HS) as f32 * 0.017).sin())
2172                    .collect();
2173                let mut ys = xs.clone();
2174                assert!(
2175                    matches!(
2176                        batch.mimo_graph_layer_rows(li, &mut ys, &positions),
2177                        crate::gpu::BatchGraphOutcome::Completed
2178                    ),
2179                    "batch admission li={li}, b={b}"
2180                );
2181                let mut want = xs;
2182                for (row, &p) in want.chunks_exact_mut(HS).zip(&positions) {
2183                    let outcome = crate::gpu::mimo_q8_short_scope(false, || {
2184                        crate::gpu::mimo_attention_scratch_scope(false, || {
2185                            single.mimo_graph_layer_rows(li, row, &[p])
2186                        })
2187                    });
2188                    assert!(matches!(outcome, crate::gpu::BatchGraphOutcome::Completed));
2189                }
2190                assert!(
2191                    rel(&ys, &want) < 2e-5,
2192                    "li={li}, b={b}: {}",
2193                    rel(&ys, &want)
2194                );
2195                pos += b;
2196                assert_eq!(
2197                    crate::gpu::graph_kv_stored(batch.test_graph_kv_id(), li),
2198                    Some(pos)
2199                );
2200                assert_eq!(
2201                    crate::gpu::graph_kv_stored(batch.test_graph_kv_id(), 0),
2202                    None
2203                );
2204                // Rejected suffix must be overwritable on both full and SWA KV.
2205                if b > 1 {
2206                    pos -= 1;
2207                    assert!(crate::gpu::graph_kv_set_stored(
2208                        batch.test_graph_kv_id(),
2209                        li,
2210                        pos
2211                    ));
2212                    assert!(crate::gpu::graph_kv_set_stored(
2213                        single.test_graph_kv_id(),
2214                        li,
2215                        pos
2216                    ));
2217                }
2218            }
2219            assert!(pos > 300);
2220        }
2221        drop(batch);
2222        drop(single);
2223        drop(model);
2224        std::fs::remove_dir_all(dir).unwrap();
2225    }
2226
2227    #[test]
2228    fn hybrid_bank_epoch_advances_at_dynamic_boundary() {
2229        let _g = serial();
2230        if !bank_ready() {
2231            return;
2232        }
2233        let (dir, model, _) = write_model("hybrid-epoch");
2234        let p = Pipeline::from_model(&model, SamplerConfig::default()).unwrap();
2235        let mut slot = bank(&p);
2236        let Slot::On(d) = &mut slot else {
2237            panic!("bank unavailable")
2238        };
2239        // The first MoE layer (1) runs in the graph and never visits the
2240        // bank. Layer 2 must still release last round's eviction pins.
2241        d.dyn_from = 2;
2242        d.placement.mode = MoeMode::Hybrid;
2243        d.placement.prefix_layers = 1;
2244        let bank = d.bank.clone();
2245        let initial = bank.lock().unwrap().tok;
2246        let x: Vec<f32> = (0..HS).map(|i| (i as f32 * 0.13).sin()).collect();
2247        for li in [2, 3] {
2248            let FfnKind::Moe(m) = &p.weights.layers[li].ffn else {
2249                unreachable!()
2250            };
2251            let route = crate::pipeline::moe_ffn_route(m, &x, None, None);
2252            assert!(slot.forward(li, m, &x, &route, None).is_some());
2253            assert_eq!(bank.lock().unwrap().tok, initial + 1, "decode layer {li}");
2254        }
2255        let xs = [x.as_slice(), x.as_slice()].concat();
2256        for li in [2, 3] {
2257            let FfnKind::Moe(m) = &p.weights.layers[li].ffn else {
2258                unreachable!()
2259            };
2260            let routes: Vec<_> = xs.chunks_exact(HS)
2261                .map(|row| crate::pipeline::moe_ffn_route(m, row, None, None))
2262                .collect();
2263            assert!(slot.forward_rows(li, m, &xs, &routes, None).is_some());
2264            assert_eq!(bank.lock().unwrap().tok, initial + 2, "verify layer {li}");
2265        }
2266        drop(p);
2267        std::fs::remove_dir_all(dir).unwrap();
2268    }
2269
2270    #[test]
2271    fn bank_batch_frame_equals_single_token_frames() {
2272        let _g = serial();
2273        if !bank_ready() {
2274            return;
2275        }
2276        let (dir, model, _) = write_model("batch-frames");
2277        let p = Pipeline::from_model(&model, SamplerConfig::default()).unwrap();
2278        let mut slot = bank(&p);
2279        assert_eq!(slot.graph_prefix_end(), Some(1));
2280        let Slot::On(d) = &mut slot else {
2281            panic!("bank unavailable")
2282        };
2283        let bank_arc = d.bank.clone();
2284        {
2285            let mut b = bank_arc.lock().unwrap();
2286            b.next_token();
2287            b.resolve(1, &[0, 1, 2, 3], &d.ids[1]).unwrap();
2288        }
2289        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2290        loop {
2291            let mut b = bank_arc.lock().unwrap();
2292            b.drain();
2293            if b.pending() == 0 {
2294                break;
2295            }
2296            assert!(std::time::Instant::now() < deadline, "bank fills timed out");
2297            drop(b);
2298            std::thread::sleep(std::time::Duration::from_millis(5));
2299        }
2300        let mut b = bank_arc.lock().unwrap();
2301        let remap = b.resolve(1, &[0, 1, 2, 3], &d.ids[1]).unwrap();
2302        assert!((0..4).all(|e| remap[e] != u32::MAX));
2303        for rows in 1..=4 {
2304            let xs: Vec<f32> = (0..rows * HS).map(|i| (i as f32 * 0.13).cos()).collect();
2305            let sel: Vec<u32> = (0..rows * TOPK)
2306                .map(|i| if i % 5 == 0 { u32::MAX } else { remap[i % 4] })
2307                .collect();
2308            let wt: Vec<f32> = (0..sel.len())
2309                .map(|i| 0.1 + 0.03 * (i % 4) as f32)
2310                .collect();
2311            let mut batch = vec![0.0; xs.len()];
2312            assert!(crate::gpu_wgpu::mimo_bank::mimo_bank_rows(
2313                &model, &xs, &sel, &wt, INTER, rows, &mut batch
2314            ));
2315            for row in 0..rows {
2316                let mut one = vec![0.0; HS];
2317                assert!(crate::gpu_wgpu::mimo_bank::mimo_bank_frame(
2318                    &model,
2319                    &xs[row * HS..(row + 1) * HS],
2320                    &sel[row * TOPK..(row + 1) * TOPK],
2321                    &wt[row * TOPK..(row + 1) * TOPK],
2322                    INTER,
2323                    &mut one
2324                ));
2325                assert_eq!(batch[row * HS..(row + 1) * HS], one, "GPU row {row}/{rows}");
2326            }
2327        }
2328        drop(b);
2329        drop(p);
2330        std::fs::remove_dir_all(dir).unwrap();
2331    }
2332
2333    /// Model level: greedy decode of a prompt through the bank equals the
2334    /// pure host walk — logits to summation order, identical tokens.
2335    #[test]
2336    fn bank_decode_equals_host_decode() {
2337        let _g = serial();
2338        if !bank_ready() {
2339            eprintln!("skip: no wgpu adapter with an expert bank");
2340            return;
2341        }
2342        let (dir, model, _) = write_model("decode");
2343        let run = |bank_on: bool| -> (Vec<Vec<f32>>, Vec<u32>) {
2344            let mut p = Pipeline::from_model(&model, SamplerConfig::default()).expect("load");
2345            p.mimo_moe = if bank_on { bank(&p) } else { Slot::Off };
2346            assert_eq!(p.mimo_moe.is_on(), bank_on, "{}", last_decision());
2347            let n = p.num_layers;
2348            let mut ids: Vec<u32> = vec![3, 17, 42, 5, 9, 33, 21, 8, 60, 1, 12];
2349            let prompt = ids.len();
2350            let mut all = Vec::new();
2351            for pos in 0..prompt + 12 {
2352                let id = ids[pos];
2353                let step = |p: &mut Pipeline| {
2354                    let emb = p.embed_id(id);
2355                    let h = p.forward_span(&emb, pos, 0, n - 1, None).unwrap();
2356                    p.logits_from_hidden(&h)
2357                };
2358                let lg = if bank_on {
2359                    step(&mut p)
2360                } else {
2361                    crate::gpu::cpu_scope(|| step(&mut p))
2362                };
2363                if pos + 1 >= prompt {
2364                    let next = lg
2365                        .iter()
2366                        .enumerate()
2367                        .max_by(|a, b| a.1.total_cmp(b.1))
2368                        .map(|(i, _)| i as u32)
2369                        .unwrap();
2370                    ids.push(next);
2371                }
2372                all.push(lg);
2373            }
2374            (all, ids)
2375        };
2376        let s0 = stats();
2377        let (host, host_ids) = run(false);
2378        let s1 = stats();
2379        assert_eq!(s1.calls, s0.calls, "the host run must not touch the bank");
2380        let (dynm, dyn_ids) = run(true);
2381        let s2 = stats();
2382        let worst = host
2383            .iter()
2384            .zip(&dynm)
2385            .map(|(h, d)| rel(d, h))
2386            .fold(0f32, f32::max);
2387        let strict = !crate::qtensor::a8w8_enabled();
2388        eprintln!(
2389            "bank decode check: {} steps, bank calls {} hits {} fills {} cold {}; logits max rel \
2390             {worst:.2e}; greedy {:?} vs host {:?}",
2391            host.len(),
2392            s2.calls - s1.calls,
2393            s2.hits - s1.hits,
2394            s2.fills - s1.fills,
2395            s2.cold - s1.cold,
2396            &dyn_ids[11..],
2397            &host_ids[11..],
2398        );
2399        assert_eq!(
2400            s2.calls - s1.calls,
2401            (host.len() * 3) as u64,
2402            "every MoE call took the bank"
2403        );
2404        assert!(
2405            s2.hits > s1.hits && s2.cold > s1.cold,
2406            "both resident and cold picks ran"
2407        );
2408        if !strict {
2409            // The host arm quantizes activations to int8 (A8W8); on this
2410            // toy that alone moves its logits by percent. Equality is a
2411            // claim about the exact arm: rerun with CMF_SDOT=0.
2412            eprintln!("bank decode check: A8W8 host arm — bounds need CMF_SDOT=0, not asserted");
2413            let _ = std::fs::remove_dir_all(&dir);
2414            return;
2415        }
2416        assert!(worst < 1e-4, "logits bank vs host {worst:.3e} ≥ 1e-4");
2417        // Every step's argmax, prompt positions included (teacher forced),
2418        // not only the generated tail.
2419        let am = |v: &[f32]| {
2420            v.iter()
2421                .enumerate()
2422                .max_by(|a, b| a.1.total_cmp(b.1))
2423                .unwrap()
2424                .0
2425        };
2426        let (a_host, a_dyn): (Vec<usize>, Vec<usize>) =
2427            host.iter().zip(&dynm).map(|(h, d)| (am(h), am(d))).unzip();
2428        assert_eq!(a_dyn, a_host, "per-step argmax differs");
2429        assert_eq!(dyn_ids, host_ids, "greedy tokens differ");
2430        let _ = std::fs::remove_dir_all(&dir);
2431    }
2432}