Skip to main content

aisimulate_core/perfmodel/engine/
runtime.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `Engine`: the compiled-spec execution core.
5//!
6//! Mirrors `aiconfigurator.sdk.backends.base_backend`'s static orchestration
7//! (`run_static` / `run_static_latency_only` / `_run_static_breakdown` /
8//! `_run_context_phase` / `_run_generation_phase`) but executes a precompiled
9//! [`EngineSpec`] — Python no longer walks the op list per call. The per-phase
10//! op iteration is the shared logic in [`crate::session`]
11//! ([`run_context_ops`] / [`run_generation_ops_step`]); the `Engine` wraps the
12//! stride quadrature and the `(nextn + 1)` decode-batch multiplier around it.
13//!
14//! The `Engine` is pure-Rust internals; its PyO3 bindings (`run_static`,
15//! `predict_*_latency`, `mixed_step_latency`, `decode_step_latency`) and the
16//! embedded [`crate::AicEngineBuilder`] live in [`crate::py`]. The agg sweep is
17//! orchestrated in Python — there is no Rust `run_agg`.
18
19use std::sync::Arc;
20
21use crate::common::enums::{DatabaseMode, TransferPolicy};
22use crate::common::error::AicError;
23use crate::operators::base::PerformanceResult;
24use crate::operators::{FpmForwardOp, FpmPhase, Op};
25use crate::perf_database::PerfDatabase;
26use crate::perfmodel::engine::spec::EngineSpec;
27use crate::session::{
28    ContextOpFilter, get_mix_step_ops, query_context_op, query_generation_op, run_context_ops,
29    run_context_ops_with, run_generation_ops_step, run_generation_ops_step_beamed_with,
30};
31use crate::{ForwardPassMetrics, validate_forward_pass_metrics};
32
33/// Per-call runtime inputs. Field-for-field mirror of the Python
34/// `sdk/config.RuntimeConfig`.
35///
36/// The imbalance-correction scales thread into the per-op queries exactly
37/// where Python applies them (`base_backend.py:331,372`): context-attention
38/// ops multiply by `seq_imbalance_correction_scale`, generation-attention ops
39/// by `gen_seq_imbalance_correction_scale`. (The FPM telemetry path has no
40/// scale concept and keeps 1.0.)
41#[derive(Clone, Copy, Debug, PartialEq)]
42pub struct RuntimeConfig {
43    pub batch_size: u32,
44    /// Beam width. The generation phase queries token-major ops at
45    /// `x = batch_size * beam_width` (Python `_run_generation_phase`);
46    /// attention ops key on the raw decode batch.
47    pub beam_width: u32,
48    pub isl: u32,
49    pub osl: u32,
50    /// Cached tokens already in the KV cache (context phase only).
51    pub prefix: u32,
52    /// Context-attention sequence-imbalance correction (default 1.0).
53    pub seq_imbalance_correction_scale: f64,
54    /// Generation-attention sequence-imbalance correction (default 1.0).
55    pub gen_seq_imbalance_correction_scale: f64,
56}
57
58impl Default for RuntimeConfig {
59    fn default() -> Self {
60        Self {
61            batch_size: 1,
62            beam_width: 1,
63            isl: 1,
64            osl: 1,
65            prefix: 0,
66            seq_imbalance_correction_scale: 1.0,
67            gen_seq_imbalance_correction_scale: 1.0,
68        }
69    }
70}
71
72/// Static-inference mode. Mirrors Python's `mode` string in
73/// `_run_static_breakdown`: `"static_ctx"` / `"static_gen"` / `"static"`.
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75pub enum StaticMode {
76    /// Python `mode="static_ctx"`: context (prefill) phase only.
77    Context,
78    /// Python `mode="static_gen"`: generation (decode) phase only.
79    Generation,
80    /// Python `mode="static"`: both phases.
81    Both,
82}
83
84/// Result of [`Engine::run_static`]. Mirrors the latency portion of Python's
85/// `run_static_latency_only` (`base_backend.py:322`): per-phase latency plus
86/// the total. The latencies are **pre-`latency_correction_scale`** — that param
87/// is intentionally dropped from the `run_static(runtime, mode, stride)`
88/// signature; it is a flat post-multiply the Python bridge applies downstream.
89#[derive(Clone, Debug, PartialEq)]
90pub struct StaticResult {
91    /// Context-phase latency in ms (0.0 for `StaticMode::Generation`).
92    pub context_ms: f64,
93    /// Generation-phase latency in ms (0.0 for `StaticMode::Context`).
94    pub generation_ms: f64,
95    /// `context_ms + generation_ms`. Equals Python `run_static_latency_only`.
96    pub total_ms: f64,
97}
98
99/// Default decode-quadrature stride. Mirrors Python's `stride=32` default in
100/// `run_static` / `_run_generation_phase` (the `DEFAULT_STATIC_STRIDE`).
101pub const DEFAULT_STATIC_STRIDE: u32 = 32;
102
103/// Executed MoE communication fallback as it crosses the private FFI:
104/// `(inference_phase, comm_backend, requested_ep, requested_nodes,
105/// measurement_ep, measurement_nodes)`.
106pub(crate) type MoeCommFallbackValue = (&'static str, &'static str, u32, u32, u32, u32);
107
108/// Inline-first fallback metadata for one name-folded op. The first record
109/// lives inline; `additional` allocates only when a second distinct record is
110/// inserted.
111pub(crate) type MoeCommFallbackValues = (MoeCommFallbackValue, Vec<MoeCommFallbackValue>);
112
113/// One evaluated op as it crosses the FFI: `(name, latency_ms, energy_wms,
114/// source)`. Entries are NAME-FOLDED before crossing — repeated names
115/// accumulate with `+=` and sources merge to `"mixed"` on mismatch, the
116/// exact accumulation semantics of Python's phase dicts (addition is
117/// commutative, so folding here instead of in Python changes nothing) —
118/// because streaming the raw ops × stride-steps tuples through pyo3
119/// measurably slowed the engine step on per-block puzzle nets (hundreds of
120/// String allocations + Python tuple constructions per call). `source` is
121/// the provenance tag (`silicon|empirical|sol|estimated|mixed`).
122pub type PerOpValue = (String, f64, f64, &'static str);
123
124/// Internal per-op value used by the provenance-aware engine walk. The fifth
125/// field is `None` or `(first_record, additional_records)` in deterministic
126/// encounter order; public Rust and Python methods strip it and retain their
127/// documented four-tuple contract.
128pub(crate) type PerOpValueWithMetadata = (
129    String,
130    f64,
131    f64,
132    &'static str,
133    Option<MoeCommFallbackValues>,
134);
135
136/// Per-op values for the shared, context-attention, and decode-attention
137/// buckets returned by the metadata-bearing mixed-step evaluation.
138pub(crate) type MixedStepPerOpValuesWithMetadata = (
139    Vec<PerOpValueWithMetadata>,
140    Vec<PerOpValueWithMetadata>,
141    Vec<PerOpValueWithMetadata>,
142);
143
144/// One SOL-decomposed per-op value: `(name, sol_time_ms, sol_math_ms,
145/// sol_mem_ms)`, mirroring Python's SOL_FULL triple `(sol_time, sol_math,
146/// sol_mem)` per query. `sol_time` is the op's SOL-mode latency (scale
147/// factors and correction scales applied — for a single leaf query it is
148/// exactly the Python triple's `sol_time = max(sol_math, sol_mem)`);
149/// `sol_math`/`sol_mem` are the compute-/memory-bound components composed
150/// the same way. NAME-FOLDED like [`PerOpValue`] (`+=` on all three).
151pub type PerOpSolValue = (String, f64, f64, f64);
152
153/// Name-folding accumulator for [`PerOpValue`] streams. First-encounter
154/// order is preserved (mirrors Python dict insertion order). Linear scan on
155/// purpose: unique-name counts are a few dozen (per-block families repeat
156/// names), far below where a map would win.
157struct PerOpFold {
158    inference_phase: &'static str,
159    entries: Vec<PerOpValueWithMetadata>,
160}
161
162fn insert_per_op_fallback(
163    fallbacks: &mut Option<MoeCommFallbackValues>,
164    fallback: MoeCommFallbackValue,
165) {
166    match fallbacks {
167        None => *fallbacks = Some((fallback, Vec::new())),
168        Some((first, additional)) if *first == fallback || additional.contains(&fallback) => {}
169        Some((_first, additional)) => additional.push(fallback),
170    }
171}
172
173fn extend_per_op_fallbacks(
174    fallbacks: &mut Option<MoeCommFallbackValues>,
175    other: Option<MoeCommFallbackValues>,
176) {
177    let Some((first, additional)) = other else {
178        return;
179    };
180    insert_per_op_fallback(fallbacks, first);
181    for fallback in additional {
182        insert_per_op_fallback(fallbacks, fallback);
183    }
184}
185
186impl PerOpFold {
187    fn new(inference_phase: &'static str) -> Self {
188        Self {
189            inference_phase,
190            entries: Vec::new(),
191        }
192    }
193
194    fn add(&mut self, op: &Op, r: PerformanceResult) {
195        let name = op.name();
196        let source = r.source.as_str();
197        let mut fallbacks = None;
198        for fallback in r.moe_comm_fallbacks.iter() {
199            insert_per_op_fallback(
200                &mut fallbacks,
201                (
202                    self.inference_phase,
203                    fallback.comm_backend,
204                    fallback.requested_ep_size,
205                    fallback.requested_node_num,
206                    fallback.measurement_ep_size,
207                    fallback.measurement_node_num,
208                ),
209            );
210        }
211        if let Some(entry) = self.entries.iter_mut().find(|e| e.0 == name) {
212            entry.1 += r.latency_ms;
213            entry.2 += r.energy_wms;
214            if entry.3 != source {
215                entry.3 = "mixed";
216            }
217            extend_per_op_fallbacks(&mut entry.4, fallbacks);
218            return;
219        }
220        self.entries.push((
221            name.to_string(),
222            r.latency_ms,
223            r.energy_wms,
224            source,
225            fallbacks,
226        ));
227    }
228
229    fn into_values(self) -> Vec<PerOpValueWithMetadata> {
230        self.entries
231    }
232}
233
234fn strip_per_op_metadata(entries: Vec<PerOpValueWithMetadata>) -> Vec<PerOpValue> {
235    entries
236        .into_iter()
237        .map(|(name, latency_ms, energy_wms, source, _fallbacks)| {
238            (name, latency_ms, energy_wms, source)
239        })
240        .collect()
241}
242
243/// Name-folding accumulator for [`PerOpSolValue`] streams (fold semantics of
244/// [`PerOpFold`]: first-encounter order, `+=` accumulation, linear scan).
245#[derive(Default)]
246struct PerOpSolFold {
247    entries: Vec<PerOpSolValue>,
248}
249
250impl PerOpSolFold {
251    fn add(&mut self, op: &Op, r: PerformanceResult) -> Result<(), AicError> {
252        let (sol_math, sol_mem) = match r.sol {
253            Some(c) => (c.math_ms, c.mem_ms),
254            // No-op short-circuits (tp_size=1 allreduce, pp_size=1 P2P)
255            // return plain zero results without a decomposition: a zero
256            // contribution is exact, not a coverage gap.
257            None if r.latency_ms == 0.0 && r.energy_wms == 0.0 => (0.0, 0.0),
258            None => {
259                return Err(AicError::SolNotImplemented(format!(
260                    "evaluate_ops_sol_json: op '{}' has no SOL decomposition \
261                     (family not exported yet — see PerformanceResult::sol)",
262                    op.name()
263                )));
264            }
265        };
266        if let Some(entry) = self.entries.iter_mut().find(|e| e.0 == op.name()) {
267            entry.1 += r.latency_ms;
268            entry.2 += sol_math;
269            entry.3 += sol_mem;
270            return Ok(());
271        }
272        self.entries
273            .push((op.name().to_string(), r.latency_ms, sol_math, sol_mem));
274        Ok(())
275    }
276
277    fn into_values(self) -> Vec<PerOpSolValue> {
278        self.entries
279    }
280}
281
282/// Which of the three mixed-step passes produced a sinked per-op value.
283#[derive(Clone, Copy, Debug, PartialEq, Eq)]
284enum MixedPass {
285    SharedNonAttention,
286    ContextAttention,
287    DecodeAttention,
288}
289
290/// Compiled engine: precompiled op lists + the matching perf database.
291///
292/// Built from an [`EngineSpec`] (Python's `compile_engine` output) plus a
293/// loaded [`PerfDatabase`]. Holds only the scalars the static composition
294/// reads: the two op lists and `nextn` (the MTP decode-batch multiplier).
295/// Parallelism / quant scalars do not enter the latency sum — they drive
296/// throughput and memory, which `StaticResult` omits — so they are not stored.
297pub struct Engine {
298    /// Context-phase ops in execution order (from `spec.context_ops`).
299    context_ops: Vec<Op>,
300    /// Generation-phase ops in execution order (from `spec.generation_ops`).
301    generation_ops: Vec<Op>,
302    /// Loaded perf database. `Arc` so the `AicEngine` can share it with the
303    /// capacity API; free fns take `&PerfDatabase`, so deref works either way.
304    db: Arc<PerfDatabase>,
305    /// MTP speculative-decoding depth. The decode batch is scaled by
306    /// `(nextn + 1)` exactly as Python `_run_generation_phase:200`
307    /// (`batch_size = batch_size * (model._nextn + 1)`). 0 disables scaling.
308    nextn: u32,
309}
310
311impl std::fmt::Debug for Engine {
312    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313        f.debug_struct("Engine")
314            .field("context_ops", &self.context_ops.len())
315            .field("generation_ops", &self.generation_ops.len())
316            .field("nextn", &self.nextn)
317            .finish_non_exhaustive()
318    }
319}
320
321impl Engine {
322    /// Build an `Engine` from a spec and a pre-loaded database.
323    ///
324    /// Extracts the op lists and the `nextn` scalar from `spec.engine`. The
325    /// caller (`AicEngineBuilder` / `from_spec_bytes`) is responsible for
326    /// having loaded the matching `PerfDatabase` from `spec.engine`'s identity.
327    pub fn build(spec: EngineSpec, db: Arc<PerfDatabase>) -> Result<Engine, AicError> {
328        let nextn = spec
329            .engine
330            .speculative
331            .as_ref()
332            .and_then(|s| s.nextn)
333            .unwrap_or(0);
334        // FPM whole-model specs must be exactly one op per phase (the Python
335        // rewrite guarantees this shape) and never carry MTP: the Python model
336        // builder rejects `nextn > 0` for forward_model="fpm" (commit
337        // ad93e75f) and the collected data has no speculative points. Guarding
338        // here keeps a hand-built or skewed spec from silently mis-composing.
339        // The scan is RECURSIVE: an FpmForward nested inside Overlap/Fallback
340        // (never produced by the Python rewrite, but expressible in a
341        // hand-built spec) would evade a top-level check and ride the
342        // name-filtered mix-step passes with the wrong workload shape — and
343        // FallbackOp swallows the op's PerfDatabase-class misses silently.
344        fn contains_fpm(ops: &[Op]) -> bool {
345            ops.iter().any(|op| match op {
346                Op::FpmForward(_) => true,
347                Op::Overlap(o) => contains_fpm(&o.group_a) || contains_fpm(&o.group_b),
348                Op::Fallback(o) => {
349                    contains_fpm(std::slice::from_ref(&o.primary)) || contains_fpm(&o.fallback)
350                }
351                _ => false,
352            })
353        }
354        let any_fpm = contains_fpm(&spec.context_ops) || contains_fpm(&spec.generation_ops);
355        if any_fpm {
356            let shape_ok = matches!(
357                spec.context_ops.as_slice(),
358                [Op::FpmForward(p)] if p.phase == FpmPhase::Prefill
359            ) && matches!(
360                spec.generation_ops.as_slice(),
361                [Op::FpmForward(d)] if d.phase == FpmPhase::Decode
362            );
363            if !shape_ok {
364                return Err(AicError::InvalidEngineConfig(
365                    "forward_model='fpm' spec must contain exactly one FpmForward op per phase \
366                     (prefill in context_ops, decode in generation_ops)"
367                        .to_string(),
368                ));
369            }
370            if nextn > 0 {
371                return Err(AicError::InvalidEngineConfig(format!(
372                    "forward_model='fpm' does not support MTP speculative decoding (nextn={nextn})"
373                )));
374            }
375        }
376        Ok(Engine {
377            context_ops: spec.context_ops,
378            generation_ops: spec.generation_ops,
379            db,
380            nextn,
381        })
382    }
383
384    /// FPM whole-model engine: both phase lists are exactly one `FpmForward`
385    /// (validated in [`Engine::build`]). Returns `(prefill_op, decode_op)`.
386    fn fpm_ops(&self) -> Option<(&FpmForwardOp, &FpmForwardOp)> {
387        match (self.context_ops.as_slice(), self.generation_ops.as_slice()) {
388            ([Op::FpmForward(p)], [Op::FpmForward(d)]) => Some((p, d)),
389            _ => None,
390        }
391    }
392
393    /// Convenience constructor: deserialize a bincode `EngineSpec` and load the
394    /// matching `PerfDatabase` from its identity, then [`Engine::build`].
395    ///
396    /// Runs the `Engine::from_spec_bytes(bytes) + PerfDatabase::load`
397    /// flow. `systems_root` points at `python/aisimulate/src/aiconfigurator_core/systems` and is used
398    /// only as a fallback: when the decoded `spec.engine.systems_path` is
399    /// `Some`, that path is authoritative and overrides the `systems_root`
400    /// argument.
401    pub fn from_spec_bytes(
402        bytes: &[u8],
403        systems_root: &std::path::Path,
404    ) -> Result<Engine, AicError> {
405        let spec = EngineSpec::from_bincode(bytes)?;
406        let version = spec.engine.backend_version.as_deref().ok_or_else(|| {
407            AicError::InvalidEngineConfig(
408                "backend_version is required to load the perf database".to_string(),
409            )
410        })?;
411        // The spec's own `systems_path` wins when present; otherwise fall back
412        // to the `systems_root` argument.
413        let systems_root = spec.engine.systems_path.as_deref().unwrap_or(systems_root);
414        let transfer_policy = TransferPolicy::from_wire(spec.engine.transfer_policy.as_deref())
415            .map_err(AicError::InvalidEngineConfig)?;
416        // The shared variant reuses already-parsed perf tables across engines
417        // with the same DB identity: a sweep compiles one engine per
418        // model/parallelism/quant point, and without sharing each of those
419        // engines would lazily re-parse the same parquet files on its first
420        // query (~0.5s per engine on data-rich systems). Mode/policy, memo
421        // caches, and the provenance accumulator stay per-engine.
422        let db = PerfDatabase::load_resolved_shared(
423            systems_root,
424            &spec.engine.system_name,
425            spec.engine.backend.as_str(),
426            version,
427            // Shared-layer inheritance: explicit override when the spec
428            // carries one (Python's `shared_layer=` kwarg), else derived
429            // from the query mode exactly like Python `_shared_layer_enabled`
430            // (SILICON/HYBRID = on).
431            spec.engine.enable_shared_layer.unwrap_or(matches!(
432                spec.engine.database_mode,
433                DatabaseMode::Silicon | DatabaseMode::Hybrid
434            )),
435            spec.engine.strict_provenance,
436            // Estimate-only systems (a spec yaml with no collected data) may
437            // back a SOL view: every SOL answer is analytic from the system
438            // spec, so tolerate a missing perf-data directory under SOL and
439            // let table-backed lookups miss lazily. A directory-less
440            // fleet-`next` spec (validated by the Python slot resolver, which
441            // loaded the same identity through backward fill) also skips the
442            // gate — the source resolver serves every table from sibling
443            // versions. All other loads keep the loud gate.
444            spec.engine.database_mode == DatabaseMode::Sol || spec.engine.tolerate_dirless_version,
445        )?
446        .with_mode(spec.engine.database_mode, transfer_policy);
447        Engine::build(spec, Arc::new(db))
448    }
449
450    /// Shared perf database handle.
451    pub fn database(&self) -> &Arc<PerfDatabase> {
452        &self.db
453    }
454
455    /// Clear the empirical-provenance accumulator (start of a run). The PyO3
456    /// boundary calls this at the top of every compute method so
457    /// [`Self::last_provenance`] carries per-call semantics, mirroring
458    /// Python's `capture_provenance()` scope. Deliberately NOT called inside
459    /// `run_static` itself: `mixed_step_latency` composes multiple internal
460    /// passes whose tiers must accumulate into one answer.
461    pub fn reset_provenance(&self) {
462        self.db.reset_provenance();
463    }
464
465    /// The least-confident empirical tier fired since the last
466    /// [`Self::reset_provenance`], as the Python tag string; `None` when the
467    /// run was answered purely from silicon tables (nothing to note — Python's
468    /// `note_provenance` is skipped for silicon too).
469    pub fn last_provenance(&self) -> Option<&'static str> {
470        match self.db.worst_provenance() {
471            crate::operators::util_empirical::ProvenanceTier::Silicon => None,
472            tier => Some(tier.as_str()),
473        }
474    }
475
476    /// Test-only accessor for the context op list (the field is private, but
477    /// `fpm`'s `#[cfg(test)]` parity tests compare `forward_pass_time_ms`
478    /// against the shared session free fns over these exact ops).
479    #[cfg(test)]
480    pub(crate) fn context_ops_for_test(&self) -> &[Op] {
481        &self.context_ops
482    }
483
484    /// Test-only accessor for the generation op list. See
485    /// [`Self::context_ops_for_test`].
486    #[cfg(test)]
487    pub(crate) fn generation_ops_for_test(&self) -> &[Op] {
488        &self.generation_ops
489    }
490
491    /// Python `run_static` / `run_static_latency_only` (`base_backend.py:347`,
492    /// `:322`) restricted to the latency breakdown. Dispatches on `mode` the
493    /// way `_run_static_breakdown` does and sums context + generation.
494    pub fn run_static(
495        &self,
496        runtime: &RuntimeConfig,
497        mode: StaticMode,
498        stride: u32,
499    ) -> Result<StaticResult, AicError> {
500        let context_ms = match mode {
501            StaticMode::Context | StaticMode::Both => self.run_context_phase(runtime)?,
502            StaticMode::Generation => 0.0,
503        };
504        let generation_ms = match mode {
505            StaticMode::Generation | StaticMode::Both => {
506                self.run_generation_phase(runtime, stride)?
507            }
508            StaticMode::Context => 0.0,
509        };
510        Ok(StaticResult {
511            context_ms,
512            generation_ms,
513            total_ms: context_ms + generation_ms,
514        })
515    }
516
517    /// Python `_run_context_phase` (`base_backend.py:144`): `effective_isl =
518    /// isl - prefix`, validate `> 0`, then one full pass over `context_ops`.
519    fn run_context_phase(&self, runtime: &RuntimeConfig) -> Result<f64, AicError> {
520        // Python raises `ValueError` when `effective_isl <= 0`; mirror that.
521        if runtime.prefix >= runtime.isl {
522            return Err(AicError::InvalidEngineConfig(format!(
523                "isl must be greater than 0 after removing prefix, but got {}",
524                runtime.isl as i64 - runtime.prefix as i64
525            )));
526        }
527        let effective_isl = runtime.isl - runtime.prefix;
528        run_context_ops(
529            &self.context_ops,
530            &self.db,
531            runtime.batch_size,
532            effective_isl,
533            runtime.prefix,
534            runtime.seq_imbalance_correction_scale,
535            ContextOpFilter::All,
536        )
537    }
538
539    /// Python `_run_generation_phase` (`base_backend.py:185`): scale the decode
540    /// batch by `(nextn + 1)`, then integrate over the decode trajectory with
541    /// the stride quadrature.
542    ///
543    /// ```text
544    /// bs = batch_size * (nextn + 1)
545    /// for i in range(0, osl - 1, stride):
546    ///     step = Σ generation_ops  with  batch_size=bs, s = isl + i + 1
547    ///     repeat_count = min(stride, osl - 1 - i)
548    ///     generation += step * repeat_count
549    /// ```
550    ///
551    /// `osl <= 1` yields an empty loop and 0.0 (matches Python).
552    fn run_generation_phase(&self, runtime: &RuntimeConfig, stride: u32) -> Result<f64, AicError> {
553        self.run_generation_phase_with(runtime, stride, |_, _| {})
554    }
555
556    /// [`Self::run_generation_phase`] with a per-op sink. Python builds a
557    /// per-iteration dict (folding same-name results), THEN multiplies the
558    /// folded values by the stride `repeat_count` and merges them into the
559    /// trajectory dicts (`base_backend.py:378-405`) — so the sink here
560    /// observes ONE per-step-folded result per op name, already weighted by
561    /// `repeat_count`, in that exact order: `(r1 + r2) * k`, not
562    /// `r1*k + r2*k` (bit-identical for repeated-name model families).
563    fn run_generation_phase_with(
564        &self,
565        runtime: &RuntimeConfig,
566        stride: u32,
567        mut on_op: impl FnMut(&Op, PerformanceResult),
568    ) -> Result<f64, AicError> {
569        let bs = runtime
570            .batch_size
571            .saturating_mul(self.nextn.saturating_add(1));
572        let stride = stride.max(1);
573        let mut total = 0.0_f64;
574        if runtime.osl <= 1 {
575            return Ok(0.0);
576        }
577        let upper = runtime.osl - 1; // exclusive, matches Python `range(0, osl-1, stride)`
578        let mut i = 0u32;
579        while i < upper {
580            // Python `s = isl + i + 1`. NOTE the `+1` — distinct from the FPM
581            // bridge's `context_length = isl + i` packing convention.
582            let s = runtime.isl + i + 1;
583            let repeat_count = stride.min(upper - i);
584            // Per-step name fold FIRST (Python's per-iteration dict), with
585            // the phase-dict source merge (mismatch -> Mixed, no
586            // zero-identity — mirrors `base_backend.py:391-393`).
587            let mut step_fold: Vec<(&Op, PerformanceResult)> = Vec::new();
588            let step = run_generation_ops_step_beamed_with(
589                &self.generation_ops,
590                &self.db,
591                bs,
592                runtime.beam_width,
593                s,
594                runtime.gen_seq_imbalance_correction_scale,
595                false,
596                |op, r| {
597                    if let Some(entry) = step_fold.iter_mut().find(|(e, _)| e.name() == op.name()) {
598                        entry.1.latency_ms += r.latency_ms;
599                        entry.1.energy_wms += r.energy_wms;
600                        if entry.1.source != r.source {
601                            entry.1.source = crate::operators::base::Source::Mixed;
602                        }
603                        entry.1.moe_comm_fallbacks.extend(r.moe_comm_fallbacks);
604                    } else {
605                        step_fold.push((op, r));
606                    }
607                },
608            )?;
609            for (op, folded) in step_fold {
610                on_op(op, folded.scaled(repeat_count as f64));
611            }
612            total += step * repeat_count as f64;
613            i += stride;
614        }
615        Ok(total)
616    }
617
618    /// Mocker H1: prefill-step latency in ms. Pure-Rust inherent method (no
619    /// PyO3 `py` token), so the Mocker hot path runs without acquiring the GIL.
620    /// Thin shim over [`Self::run_static`] with `mode=Context` (osl is
621    /// irrelevant for the context phase, so it is fixed at 1).
622    pub fn predict_prefill_latency(&self, bs: u32, isl: u32, prefix: u32) -> Result<f64, AicError> {
623        let rt = RuntimeConfig {
624            batch_size: bs,
625            isl,
626            osl: 1,
627            prefix,
628            ..Default::default()
629        };
630        Ok(self
631            .run_static(&rt, StaticMode::Context, DEFAULT_STATIC_STRIDE)?
632            .total_ms)
633    }
634
635    /// Mocker H2: decode-step latency in ms. Pure-Rust inherent method (no
636    /// PyO3 `py` token). Thin shim over [`Self::run_static`] with
637    /// `mode=Generation`. Mocker passes `osl=2` (one decode step at
638    /// `s = isl + 1`).
639    pub fn predict_decode_latency(&self, bs: u32, isl: u32, osl: u32) -> Result<f64, AicError> {
640        let rt = RuntimeConfig {
641            batch_size: bs,
642            isl,
643            osl,
644            ..Default::default()
645        };
646        Ok(self
647            .run_static(&rt, StaticMode::Generation, DEFAULT_STATIC_STRIDE)?
648            .total_ms)
649    }
650
651    /// Predict one decode step from exact FPM iteration totals.
652    ///
653    /// `total_past_kv_tokens` excludes the one current token processed by each
654    /// decode request, matching the collector's `total_kv_read_tokens` axis.
655    pub fn predict_decode_latency_total(
656        &self,
657        batch_size: u32,
658        total_past_kv_tokens: u32,
659    ) -> Result<f64, AicError> {
660        self.forward_pass_time_ms(&[ForwardPassMetrics {
661            scheduled_requests: crate::ScheduledRequestMetrics {
662                num_decode_requests: batch_size,
663                sum_decode_kv_tokens: total_past_kv_tokens,
664                ..Default::default()
665            },
666            ..Default::default()
667        }])
668    }
669
670    /// Highest decode KV-read total covered by a compiled FPM engine.
671    /// Op-level engines return `None`.
672    pub fn fpm_decode_kv_ceiling(&self) -> Result<Option<u32>, AicError> {
673        let Some((_prefill, decode)) = self.fpm_ops() else {
674            return Ok(None);
675        };
676        decode.decode_kv_ceiling(&self.db)
677    }
678
679    /// One mixed (chunked-prefill + decode) step latency. LITERAL mirror of
680    /// Python `_get_mix_step_latency` / `run_mixed`, which composes three
681    /// filtered phase passes (`_run_context_phase` / `_run_generation_phase`
682    /// with `op_filter`) that query ONLY the ops each pass consumes — the
683    /// same name-keyed sets the `ContextOpFilter` /
684    /// `only_generation_attention` walks below visit (issue #1498
685    /// follow-through: Python used to run the full lists and discard, so a
686    /// raise in a discarded query was a one-sided error surface):
687    ///
688    /// ```text
689    /// // Pass 1 — combined non-attention work:
690    /// //   run_static(batch=1, isl=ctx+gen, osl=1,
691    /// //              prefix=prefix*floor(ctx/isl), mode=static_ctx)
692    /// //   sum every op EXCEPT "context_attention"
693    /// // Pass 2 — context attention at the prefill shape:
694    /// //   run_static(batch=ceil(ctx/isl), isl=isl, osl=1, prefix=prefix)
695    /// //   take ONLY "context_attention", divide by ceil(isl/ctx)
696    /// // Pass 3 — decode attention (only when gen_tokens > 0):
697    /// //   run_static(batch=gen, isl=isl+osl//2, osl=2, mode=static_gen)
698    /// //   -> one step at s = isl + osl//2 + 1 with the (nextn+1) batch
699    /// //   take ONLY "generation_attention"
700    /// ```
701    ///
702    /// Note the Python conventions this deliberately preserves (they differed
703    /// from the pre-rewrite FPM packing): pass 1 uses
704    /// `ctx + gen * (nextn + 1)` tokens (the speculative-progress model —
705    /// every decode request verifies one target plus all drafts in the
706    /// combined pass, mirroring Python `run_mixed`'s `decode_query_tokens`),
707    /// the cached prefix multiplier is `floor(ctx/isl)` (not ceil), and the
708    /// pass-3 kv position carries `_run_generation_phase`'s `+1`.
709    ///
710    /// The imbalance-correction scales mirror the `RuntimeConfig` fields
711    /// Python threads into each pass (`base_backend.py:950-1043`).
712    pub fn mixed_step_latency(
713        &self,
714        ctx_tokens: u32,
715        gen_tokens: u32,
716        isl: u32,
717        osl: u32,
718        prefix: u32,
719        seq_imbalance_correction_scale: f64,
720        gen_seq_imbalance_correction_scale: f64,
721    ) -> Result<f64, AicError> {
722        Ok(self.mixed_step_breakdown(
723            ctx_tokens,
724            gen_tokens,
725            isl,
726            osl,
727            prefix,
728            seq_imbalance_correction_scale,
729            gen_seq_imbalance_correction_scale,
730        )?[0])
731    }
732
733    /// Return ``[total, shared_non_attention, context_attention,
734    /// decode_attention]`` for one mixed engine iteration — the three passes
735    /// of the `_get_mix_step_latency` composition reported separately: pass 1
736    /// is the shared non-attention work, pass 2 the context-attention slice
737    /// (already divided by `ceil(isl/ctx)`), pass 3 the decode-attention
738    /// slice. [`Engine::mixed_step_latency`] is their sum; the agg
739    /// speculative scheduler consumes the components.
740    pub fn mixed_step_breakdown(
741        &self,
742        ctx_tokens: u32,
743        gen_tokens: u32,
744        isl: u32,
745        osl: u32,
746        prefix: u32,
747        seq_imbalance_correction_scale: f64,
748        gen_seq_imbalance_correction_scale: f64,
749    ) -> Result<[f64; 4], AicError> {
750        self.mixed_step_breakdown_with(
751            ctx_tokens,
752            gen_tokens,
753            isl,
754            osl,
755            prefix,
756            seq_imbalance_correction_scale,
757            gen_seq_imbalance_correction_scale,
758            |_, _, _| {},
759        )
760    }
761
762    /// [`Self::mixed_step_breakdown`] with a per-op sink. The sink observes
763    /// `(pass, op, result)` for every queried op with RAW (undivided) pass-2
764    /// values; the per-op wrapper applies the `ceil(isl/ctx)` division to the
765    /// FOLDED entries (fold-then-divide, matching Python and the scalar
766    /// bucket bit-for-bit).
767    #[allow(clippy::too_many_arguments)]
768    fn mixed_step_breakdown_with(
769        &self,
770        ctx_tokens: u32,
771        gen_tokens: u32,
772        isl: u32,
773        osl: u32,
774        prefix: u32,
775        seq_imbalance_correction_scale: f64,
776        gen_seq_imbalance_correction_scale: f64,
777        mut on_op: impl FnMut(MixedPass, &Op, PerformanceResult),
778    ) -> Result<[f64; 4], AicError> {
779        if ctx_tokens == 0 && gen_tokens == 0 {
780            return Ok([0.0; 4]);
781        }
782        // Whole-model FPM ops must never reach the name-filtered three-pass
783        // composition below (they match neither attention filter and would
784        // ride pass 1 with the wrong workload shape). Python branches the
785        // same way at `_get_mix_step_latency` -> `_get_fpm_mix_step_latency`.
786        // Component mapping: FPM has no non-attention/attention split, so the
787        // breakdown reports [total, prefill_component, 0, marginal_decode].
788        // The component consumers (speculative agg scheduling) only read the
789        // split under MTP, which FPM rejects at build time.
790        if let Some((prefill_op, decode_op)) = self.fpm_ops() {
791            let (prefill_ms, marginal_decode_ms) = self.fpm_mixed_step_components(
792                prefill_op,
793                decode_op,
794                ctx_tokens,
795                gen_tokens,
796                isl.max(1),
797                osl.max(1),
798                prefix,
799            )?;
800            return Ok([
801                prefill_ms + marginal_decode_ms,
802                prefill_ms,
803                0.0,
804                marginal_decode_ms,
805            ]);
806        }
807        // Python divides by `isl` (`floor(ctx/isl)`, `ceil(ctx/isl)`) without
808        // a guard — callers always pass isl >= 1. Clamp to avoid a Rust
809        // div-by-zero panic on degenerate input Python would crash on.
810        let isl = isl.max(1);
811
812        // ---- Pass 1: combined non-attention work ----
813        // Speculative progress model: every decode request verifies one
814        // target token plus all scheduled drafts, so the combined pass sees
815        // `gen * (nextn + 1)` decode tokens (mirrors Python `run_mixed`'s
816        // `decode_query_tokens`). Acceptance does not reduce this
817        // current-iteration work.
818        let decode_query_tokens = gen_tokens.saturating_mul(self.nextn.saturating_add(1));
819        let combined = ctx_tokens + decode_query_tokens;
820        let prefix1 = prefix * (ctx_tokens / isl); // prefix * floor(ctx/isl)
821        if prefix1 >= combined {
822            return Err(AicError::InvalidEngineConfig(format!(
823                "isl must be greater than 0 after removing prefix, but got {}",
824                combined as i64 - prefix1 as i64
825            )));
826        }
827        let shared_non_attention = run_context_ops_with(
828            &self.context_ops,
829            &self.db,
830            1,
831            combined - prefix1,
832            prefix1,
833            seq_imbalance_correction_scale,
834            ContextOpFilter::SkipContextAttention,
835            |op, r| on_op(MixedPass::SharedNonAttention, op, r),
836        )?;
837
838        // ---- Pass 2: context attention at the prefill shape ----
839        // Python: batch = ceil(ctx/isl), effective_isl = isl - prefix, then
840        // latency["context_attention"] / ceil(isl/ctx). With ctx_tokens == 0
841        // Python's `np.ceil(isl/0)` is +inf and the division yields 0 — skip.
842        let mut context_attention = 0.0_f64;
843        if ctx_tokens > 0 {
844            if prefix >= isl {
845                return Err(AicError::InvalidEngineConfig(format!(
846                    "isl must be greater than 0 after removing prefix, but got {}",
847                    isl as i64 - prefix as i64
848                )));
849            }
850            let batch2 = ctx_tokens.div_ceil(isl);
851            let scale2 = isl.div_ceil(ctx_tokens) as f64;
852            let attn = run_context_ops_with(
853                &self.context_ops,
854                &self.db,
855                batch2,
856                isl - prefix,
857                prefix,
858                seq_imbalance_correction_scale,
859                ContextOpFilter::OnlyContextAttention,
860                // RAW results to the sink; the per-op wrapper divides the
861                // FOLDED values by scale2 with one true division per name
862                // (Python folds `context_attention` into one key, then
863                // `latency_dict["context_attention"] / scale_factor` —
864                // fold-then-divide, `base_backend.py:1244-1246`).
865                |op, r| on_op(MixedPass::ContextAttention, op, r),
866            )?;
867            context_attention = attn / scale2;
868        }
869
870        // ---- Pass 3: decode attention ----
871        let mut decode_attention = 0.0_f64;
872        if gen_tokens > 0 {
873            let bs = gen_tokens.saturating_mul(self.nextn.saturating_add(1));
874            // `_run_generation_phase` queries at s = isl_pass3 + i + 1 with
875            // isl_pass3 = isl + osl//2 and a single step (osl=2, i=0).
876            let s = isl + osl / 2 + 1;
877            decode_attention = run_generation_ops_step_beamed_with(
878                &self.generation_ops,
879                &self.db,
880                bs,
881                1,
882                s,
883                gen_seq_imbalance_correction_scale,
884                true,
885                |op, r| on_op(MixedPass::DecodeAttention, op, r),
886            )?;
887        }
888
889        Ok([
890            shared_non_attention + context_attention + decode_attention,
891            shared_non_attention,
892            context_attention,
893            decode_attention,
894        ])
895    }
896
897    /// One generation-only step latency. LITERAL mirror of Python
898    /// `_get_genonly_step_latency` (`base_backend.py:1040-1100`):
899    /// `run_static(batch=gen_tokens, isl=isl+osl//2, osl=2, mode=static_gen)`
900    /// summed over the FULL generation op list — one step at
901    /// `s = isl + osl//2 + 1` (note `_run_generation_phase`'s `+1`) with the
902    /// decode batch scaled by `(nextn + 1)`.
903    pub fn decode_step_latency(
904        &self,
905        gen_tokens: u32,
906        isl: u32,
907        osl: u32,
908        gen_seq_imbalance_correction_scale: f64,
909    ) -> Result<f64, AicError> {
910        if gen_tokens == 0 {
911            return Ok(0.0);
912        }
913        // FPM keeps the PYTHON static-path convention `s = isl + osl/2 + 1`
914        // (via `run_generation_phase`), not this method's op-level
915        // `isl + osl/2` packing — a documented divergence the FPM port must
916        // not inherit (its parity target is the Python FPM branch, which
917        // routes through `run_static(mode="static_gen")`).
918        if self.fpm_ops().is_some() {
919            let rt = RuntimeConfig {
920                batch_size: gen_tokens,
921                isl: isl.saturating_add(osl / 2),
922                osl: 2,
923                ..Default::default()
924            };
925            return self.run_generation_phase(&rt, DEFAULT_STATIC_STRIDE);
926        }
927        let effective_batch = gen_tokens.saturating_mul(self.nextn.saturating_add(1));
928        let s = isl.max(1).saturating_add(osl.max(1) / 2).saturating_add(1);
929        run_generation_ops_step(
930            &self.generation_ops,
931            &self.db,
932            effective_batch,
933            s,
934            gen_seq_imbalance_correction_scale,
935            false,
936        )
937    }
938
939    /// Mixed-step composition, mirroring Python
940    /// `_get_fpm_mix_step_latency` exactly: the prefill component prices the
941    /// iteration's REAL scheduled totals (chunk + decode tokens — the count
942    /// the engine picks its CUDA-graph/eager regime and GEMM width from) via
943    /// `query_totals`; chunked requests are priced per chunk at their own
944    /// `(chunk + gen, past_kv)` coordinates and averaged. The decode
945    /// component stays the pass-baseline marginal. Correct only when the
946    /// deployed engine configuration (especially the CUDA-graph capture
947    /// surface) matches the collection — the cliffs live in the data.
948    fn fpm_mixed_step_components(
949        &self,
950        prefill_op: &FpmForwardOp,
951        decode_op: &FpmForwardOp,
952        ctx_tokens: u32,
953        gen_tokens: u32,
954        isl: u32,
955        osl: u32,
956        prefix: u32,
957    ) -> Result<(f64, f64), AicError> {
958        let mut prefill_component = 0.0_f64;
959        if ctx_tokens > 0 {
960            let new_tokens = isl.saturating_sub(prefix);
961            if new_tokens == 0 {
962                return Err(AicError::PerfDatabase(format!(
963                    "isl must be greater than prefix, got isl={isl} prefix={prefix}"
964                )));
965            }
966            if ctx_tokens >= new_tokens {
967                // Whole prefills this iteration: the scheduled total picks
968                // the regime row.
969                let batch = ctx_tokens.div_ceil(new_tokens);
970                prefill_component = prefill_op
971                    .query_totals(
972                        &self.db,
973                        &[
974                            batch as f64,
975                            (ctx_tokens + gen_tokens) as f64,
976                            (batch * prefix) as f64,
977                        ],
978                    )?
979                    .latency_ms;
980            } else {
981                // Chunked prefill: per-chunk totals, per-iteration average.
982                let mut total = 0.0_f64;
983                let mut chunks = 0u32;
984                let mut done = 0u32;
985                while done < new_tokens {
986                    let chunk = ctx_tokens.min(new_tokens - done);
987                    total += prefill_op
988                        .query_totals(
989                            &self.db,
990                            &[1.0, (chunk + gen_tokens) as f64, (prefix + done) as f64],
991                        )?
992                        .latency_ms;
993                    done += chunk;
994                    chunks += 1;
995                }
996                prefill_component = total / chunks as f64;
997            }
998        }
999        let mut marginal_decode = 0.0_f64;
1000        if gen_tokens > 0 {
1001            let rt = RuntimeConfig {
1002                batch_size: gen_tokens,
1003                isl: isl.saturating_add(osl / 2),
1004                osl: 2,
1005                ..Default::default()
1006            };
1007            let gen_ms = self.run_generation_phase(&rt, DEFAULT_STATIC_STRIDE)?;
1008            let baseline_ms = if ctx_tokens > 0 {
1009                // run_generation_phase scaled the batch by (nextn + 1) and
1010                // sampled its single step at `s = rt.isl + 1`, so the decode
1011                // query above landed on `(bs, bs * s)`. The baseline must be
1012                // taken at that SAME coordinate: it selects its bracket rows
1013                // by KV coverage, and a different KV can select different
1014                // rows than the query used.
1015                let baseline_batch = gen_tokens.saturating_mul(self.nextn.saturating_add(1));
1016                let baseline_kv = baseline_batch as f64 * (rt.isl as f64 + 1.0);
1017                decode_op
1018                    .query_pass_baseline(&self.db, baseline_batch, baseline_kv)?
1019                    .latency_ms
1020            } else {
1021                0.0
1022            };
1023            marginal_decode = (gen_ms - baseline_ms).max(0.0);
1024        }
1025        Ok((prefill_component, marginal_decode))
1026    }
1027
1028    /// [`Self::run_static`] with the per-op values kept instead of summed:
1029    /// `(context, generation)` lists of `(name, latency_ms, energy_wms,
1030    /// source)`, NAME-FOLDED (see [`PerOpValue`]): each name crosses once,
1031    /// pre-accumulated with Python's phase-dict semantics. Generation values
1032    /// are per-step-folded, then weighted by the stride `repeat_count`.
1033    pub fn run_static_per_op(
1034        &self,
1035        runtime: &RuntimeConfig,
1036        mode: StaticMode,
1037        stride: u32,
1038    ) -> Result<(Vec<PerOpValue>, Vec<PerOpValue>), AicError> {
1039        let (context, generation) = self.run_static_per_op_impl(runtime, mode, stride)?;
1040        Ok((
1041            strip_per_op_metadata(context),
1042            strip_per_op_metadata(generation),
1043        ))
1044    }
1045
1046    /// Metadata-bearing counterpart used only by the private PyO3 provenance
1047    /// endpoint. Evaluation stays in this single implementation so the value
1048    /// and its fallback records always come from the same query.
1049    pub(crate) fn run_static_per_op_with_metadata(
1050        &self,
1051        runtime: &RuntimeConfig,
1052        mode: StaticMode,
1053        stride: u32,
1054    ) -> Result<(Vec<PerOpValueWithMetadata>, Vec<PerOpValueWithMetadata>), AicError> {
1055        self.run_static_per_op_impl(runtime, mode, stride)
1056    }
1057
1058    fn run_static_per_op_impl(
1059        &self,
1060        runtime: &RuntimeConfig,
1061        mode: StaticMode,
1062        stride: u32,
1063    ) -> Result<(Vec<PerOpValueWithMetadata>, Vec<PerOpValueWithMetadata>), AicError> {
1064        let mut context = PerOpFold::new("context");
1065        if matches!(mode, StaticMode::Context | StaticMode::Both) {
1066            if runtime.prefix >= runtime.isl {
1067                return Err(AicError::InvalidEngineConfig(format!(
1068                    "isl must be greater than 0 after removing prefix, but got {}",
1069                    runtime.isl as i64 - runtime.prefix as i64
1070                )));
1071            }
1072            run_context_ops_with(
1073                &self.context_ops,
1074                &self.db,
1075                runtime.batch_size,
1076                runtime.isl - runtime.prefix,
1077                runtime.prefix,
1078                runtime.seq_imbalance_correction_scale,
1079                ContextOpFilter::All,
1080                |op, r| context.add(op, r),
1081            )?;
1082        }
1083        let mut generation = PerOpFold::new("generation");
1084        if matches!(mode, StaticMode::Generation | StaticMode::Both) {
1085            self.run_generation_phase_with(runtime, stride, |op, r| generation.add(op, r))?;
1086        }
1087        Ok((context.into_values(), generation.into_values()))
1088    }
1089
1090    /// [`Self::mixed_step_breakdown`] with the per-op values kept:
1091    /// `(shared_non_attention, context_attention, decode_attention)` lists of
1092    /// `(name, latency_ms, energy_wms, source)`. Context-attention entries
1093    /// arrive already divided by the `ceil(isl/ctx)` scale.
1094    #[allow(clippy::too_many_arguments)]
1095    pub fn mixed_step_breakdown_per_op(
1096        &self,
1097        ctx_tokens: u32,
1098        gen_tokens: u32,
1099        isl: u32,
1100        osl: u32,
1101        prefix: u32,
1102        seq_imbalance_correction_scale: f64,
1103        gen_seq_imbalance_correction_scale: f64,
1104    ) -> Result<(Vec<PerOpValue>, Vec<PerOpValue>, Vec<PerOpValue>), AicError> {
1105        let (shared, context_attention, decode_attention) = self.mixed_step_breakdown_per_op_impl(
1106            ctx_tokens,
1107            gen_tokens,
1108            isl,
1109            osl,
1110            prefix,
1111            seq_imbalance_correction_scale,
1112            gen_seq_imbalance_correction_scale,
1113        )?;
1114        Ok((
1115            strip_per_op_metadata(shared),
1116            strip_per_op_metadata(context_attention),
1117            strip_per_op_metadata(decode_attention),
1118        ))
1119    }
1120
1121    /// Metadata-bearing counterpart used only by the private PyO3 provenance
1122    /// endpoint. See [`Self::mixed_step_breakdown_per_op`].
1123    #[allow(clippy::too_many_arguments)]
1124    pub(crate) fn mixed_step_breakdown_per_op_with_metadata(
1125        &self,
1126        ctx_tokens: u32,
1127        gen_tokens: u32,
1128        isl: u32,
1129        osl: u32,
1130        prefix: u32,
1131        seq_imbalance_correction_scale: f64,
1132        gen_seq_imbalance_correction_scale: f64,
1133    ) -> Result<MixedStepPerOpValuesWithMetadata, AicError> {
1134        self.mixed_step_breakdown_per_op_impl(
1135            ctx_tokens,
1136            gen_tokens,
1137            isl,
1138            osl,
1139            prefix,
1140            seq_imbalance_correction_scale,
1141            gen_seq_imbalance_correction_scale,
1142        )
1143    }
1144
1145    #[allow(clippy::too_many_arguments)]
1146    fn mixed_step_breakdown_per_op_impl(
1147        &self,
1148        ctx_tokens: u32,
1149        gen_tokens: u32,
1150        isl: u32,
1151        osl: u32,
1152        prefix: u32,
1153        seq_imbalance_correction_scale: f64,
1154        gen_seq_imbalance_correction_scale: f64,
1155    ) -> Result<MixedStepPerOpValuesWithMetadata, AicError> {
1156        // Whole-model FPM: never the name-filtered three-pass split (see
1157        // mixed_step_breakdown_with). Report the scalar path's component
1158        // mapping as per-op entries — the prefill component under the
1159        // prefill op's name in the shared bucket, the decode marginal under
1160        // the decode op's name — so the Python fold sees the same keys as
1161        // its own FPM branch.
1162        if let Some((prefill_op, decode_op)) = self.fpm_ops() {
1163            let (prefill_ms, marginal_decode_ms) = self.fpm_mixed_step_components(
1164                prefill_op,
1165                decode_op,
1166                ctx_tokens,
1167                gen_tokens,
1168                isl.max(1),
1169                osl.max(1),
1170                prefix,
1171            )?;
1172            let mut shared: Vec<PerOpValueWithMetadata> = Vec::new();
1173            if ctx_tokens > 0 {
1174                shared.push((prefill_op.name.clone(), prefill_ms, 0.0, "silicon", None));
1175            }
1176            let mut dec_attn: Vec<PerOpValueWithMetadata> = Vec::new();
1177            if gen_tokens > 0 {
1178                dec_attn.push((
1179                    decode_op.name.clone(),
1180                    marginal_decode_ms,
1181                    0.0,
1182                    "silicon",
1183                    None,
1184                ));
1185            }
1186            return Ok((shared, Vec::new(), dec_attn));
1187        }
1188        let mut shared = PerOpFold::new("context");
1189        let mut ctx_attn = PerOpFold::new("context");
1190        let mut dec_attn = PerOpFold::new("generation");
1191        self.mixed_step_breakdown_with(
1192            ctx_tokens,
1193            gen_tokens,
1194            isl,
1195            osl,
1196            prefix,
1197            seq_imbalance_correction_scale,
1198            gen_seq_imbalance_correction_scale,
1199            |pass, op, r| {
1200                let out = match pass {
1201                    MixedPass::SharedNonAttention => &mut shared,
1202                    MixedPass::ContextAttention => &mut ctx_attn,
1203                    MixedPass::DecodeAttention => &mut dec_attn,
1204                };
1205                out.add(op, r);
1206            },
1207        )?;
1208        let mut ctx_attn = ctx_attn.into_values();
1209        if ctx_tokens > 0 {
1210            // Mirror the scalar bucket and Python's fold-then-single-true-
1211            // division (`base_backend.py:1244-1246`): one `/ scale2` per
1212            // folded name, never a per-entry reciprocal multiply.
1213            let scale2 = isl.max(1).div_ceil(ctx_tokens) as f64;
1214            for entry in &mut ctx_attn {
1215                entry.1 /= scale2;
1216                entry.2 /= scale2;
1217            }
1218        }
1219        Ok((shared.into_values(), ctx_attn, dec_attn.into_values()))
1220    }
1221
1222    /// [`Self::decode_step_latency`] with the per-op values kept.
1223    pub fn decode_step_per_op(
1224        &self,
1225        gen_tokens: u32,
1226        isl: u32,
1227        osl: u32,
1228        gen_seq_imbalance_correction_scale: f64,
1229    ) -> Result<Vec<PerOpValue>, AicError> {
1230        self.decode_step_per_op_impl(gen_tokens, isl, osl, gen_seq_imbalance_correction_scale)
1231            .map(strip_per_op_metadata)
1232    }
1233
1234    /// Metadata-bearing counterpart used only by the private PyO3 provenance
1235    /// endpoint. See [`Self::decode_step_per_op`].
1236    pub(crate) fn decode_step_per_op_with_metadata(
1237        &self,
1238        gen_tokens: u32,
1239        isl: u32,
1240        osl: u32,
1241        gen_seq_imbalance_correction_scale: f64,
1242    ) -> Result<Vec<PerOpValueWithMetadata>, AicError> {
1243        self.decode_step_per_op_impl(gen_tokens, isl, osl, gen_seq_imbalance_correction_scale)
1244    }
1245
1246    fn decode_step_per_op_impl(
1247        &self,
1248        gen_tokens: u32,
1249        isl: u32,
1250        osl: u32,
1251        gen_seq_imbalance_correction_scale: f64,
1252    ) -> Result<Vec<PerOpValueWithMetadata>, AicError> {
1253        let mut out = PerOpFold::new("generation");
1254        if gen_tokens == 0 {
1255            return Ok(out.into_values());
1256        }
1257        let effective_batch = gen_tokens.saturating_mul(self.nextn.saturating_add(1));
1258        let s = isl.max(1).saturating_add(osl.max(1) / 2).saturating_add(1);
1259        run_generation_ops_step_beamed_with(
1260            &self.generation_ops,
1261            &self.db,
1262            effective_batch,
1263            1,
1264            s,
1265            gen_seq_imbalance_correction_scale,
1266            false,
1267            |op, r| out.add(op, r),
1268        )?;
1269        Ok(out.into_values())
1270    }
1271
1272    /// Evaluate an index-addressed sublist of the compiled CONTEXT op list at
1273    /// the context-phase shape (the thin op-list evaluation FFI — Python-side
1274    /// orchestration like AFD partitions the compiled list and sources per-op
1275    /// values here instead of walking `Operation.query()`).
1276    #[allow(clippy::too_many_arguments)]
1277    pub fn evaluate_context_ops(
1278        &self,
1279        indices: &[usize],
1280        batch_size: u32,
1281        s: u32,
1282        prefix: u32,
1283        seq_imbalance_correction_scale: f64,
1284        x_override: Option<u32>,
1285    ) -> Result<Vec<PerOpValue>, AicError> {
1286        let mut out = PerOpFold::new("context");
1287        for &i in indices {
1288            let op = self.context_ops.get(i).ok_or_else(|| {
1289                AicError::InvalidEngineConfig(format!(
1290                    "evaluate_context_ops: index {i} out of range ({} context ops)",
1291                    self.context_ops.len()
1292                ))
1293            })?;
1294            let r = query_context_op(
1295                op,
1296                &self.db,
1297                batch_size,
1298                s,
1299                prefix,
1300                seq_imbalance_correction_scale,
1301                x_override,
1302            )?;
1303            out.add(op, r);
1304        }
1305        Ok(strip_per_op_metadata(out.into_values()))
1306    }
1307
1308    /// Evaluate an index-addressed sublist of the compiled GENERATION op list
1309    /// at the decode-step shape (see [`Self::evaluate_context_ops`]).
1310    #[allow(clippy::too_many_arguments)]
1311    pub fn evaluate_generation_ops(
1312        &self,
1313        indices: &[usize],
1314        batch_size: u32,
1315        s: u32,
1316        gen_seq_imbalance_correction_scale: f64,
1317        prefix: u32,
1318        x_override: Option<u32>,
1319    ) -> Result<Vec<PerOpValue>, AicError> {
1320        let mut out = PerOpFold::new("generation");
1321        for &i in indices {
1322            let op = self.generation_ops.get(i).ok_or_else(|| {
1323                AicError::InvalidEngineConfig(format!(
1324                    "evaluate_generation_ops: index {i} out of range ({} generation ops)",
1325                    self.generation_ops.len()
1326                ))
1327            })?;
1328            let r = query_generation_op(
1329                op,
1330                &self.db,
1331                batch_size,
1332                1,
1333                s,
1334                gen_seq_imbalance_correction_scale,
1335                prefix,
1336                x_override,
1337            )?;
1338            out.add(op, r);
1339        }
1340        Ok(strip_per_op_metadata(out.into_values()))
1341    }
1342
1343    /// Evaluate an ad-hoc op list (a JSON array of `OpSpec` objects, the same
1344    /// externally-tagged encoding `EngineSpec` uses) against this engine's
1345    /// database. Serves op lists that are deliberately NOT in the compiled
1346    /// spec — the VL encoder phase — while the shape math stays Python-side.
1347    #[allow(clippy::too_many_arguments)]
1348    pub fn evaluate_ops_json(
1349        &self,
1350        ops_json: &str,
1351        is_context: bool,
1352        batch_size: u32,
1353        s: u32,
1354        prefix: u32,
1355        imbalance_correction_scale: f64,
1356        x_override: Option<u32>,
1357    ) -> Result<Vec<PerOpValue>, AicError> {
1358        let ops: Vec<Op> = serde_json::from_str(ops_json).map_err(|e| {
1359            AicError::InvalidEngineConfig(format!("evaluate_ops_json: invalid op list JSON: {e}"))
1360        })?;
1361        let mut out = PerOpFold::new(if is_context { "context" } else { "generation" });
1362        for op in &ops {
1363            let r = if is_context {
1364                query_context_op(
1365                    op,
1366                    &self.db,
1367                    batch_size,
1368                    s,
1369                    prefix,
1370                    imbalance_correction_scale,
1371                    x_override,
1372                )?
1373            } else {
1374                query_generation_op(
1375                    op,
1376                    &self.db,
1377                    batch_size,
1378                    1,
1379                    s,
1380                    imbalance_correction_scale,
1381                    prefix,
1382                    x_override,
1383                )?
1384            };
1385            out.add(op, r);
1386        }
1387        Ok(strip_per_op_metadata(out.into_values()))
1388    }
1389
1390    /// [`Self::evaluate_ops_json`] under the SOL_FULL view: evaluate an
1391    /// ad-hoc op list (JSON array of `OpSpec` objects) with every operator
1392    /// forced onto its analytic SOL branch, and keep the roofline
1393    /// decomposition. Returns `(name, sol_time_ms, sol_math_ms, sol_mem_ms)`
1394    /// per op (see [`PerOpSolValue`]) — the compiled-engine replacement for
1395    /// Python's per-call `query_*(..., database_mode=SOL_FULL)` triples.
1396    /// Errors when an op's family does not export its decomposition yet.
1397    #[allow(clippy::too_many_arguments)]
1398    pub fn evaluate_ops_sol_json(
1399        &self,
1400        ops_json: &str,
1401        is_context: bool,
1402        batch_size: u32,
1403        s: u32,
1404        prefix: u32,
1405        imbalance_correction_scale: f64,
1406        x_override: Option<u32>,
1407    ) -> Result<Vec<PerOpSolValue>, AicError> {
1408        let ops: Vec<Op> = serde_json::from_str(ops_json).map_err(|e| {
1409            AicError::InvalidEngineConfig(format!(
1410                "evaluate_ops_sol_json: invalid op list JSON: {e}"
1411            ))
1412        })?;
1413        let sol_db = self.db.sol_full_view();
1414        let mut out = PerOpSolFold::default();
1415        for op in &ops {
1416            let r = if is_context {
1417                query_context_op(
1418                    op,
1419                    &sol_db,
1420                    batch_size,
1421                    s,
1422                    prefix,
1423                    imbalance_correction_scale,
1424                    x_override,
1425                )?
1426            } else {
1427                query_generation_op(
1428                    op,
1429                    &sol_db,
1430                    batch_size,
1431                    1,
1432                    s,
1433                    imbalance_correction_scale,
1434                    prefix,
1435                    x_override,
1436                )?
1437            };
1438            out.add(op, r)?;
1439        }
1440        Ok(out.into_values())
1441    }
1442
1443    /// Compute one forward-pass latency from a list of per-rank FPM entries.
1444    ///
1445    /// Re-platformed from the (deleted) `SessionEstimator::forward_pass_time_ms`
1446    /// (commit 520dcfff `session.rs:289`): validate every rank, dispatch each
1447    /// rank on its scheduled workload via [`Self::rank_latency_ms`], and take the
1448    /// max across ranks (attention-DP ranks run in lockstep, so the slowest rank
1449    /// gates the iteration).
1450    ///
1451    /// Unlike [`Self::mixed_step_latency`] / [`Self::decode_step_latency`], this
1452    /// consumes ALREADY-PACKED telemetry: the FPM fields are the observed
1453    /// per-iteration counts, so the `(nextn + 1)` MTP multiplier is NOT applied
1454    /// here (it is already baked into the scheduled-decode counts the engine
1455    /// emitted). The dispatch reuses the shared [`run_context_ops`] /
1456    /// [`run_generation_ops_step`] / [`get_mix_step_ops`] free fns so this path
1457    /// and the live engine-step path stay numerically identical.
1458    pub fn forward_pass_time_ms(
1459        &self,
1460        metrics_by_rank: &[ForwardPassMetrics],
1461    ) -> Result<f64, AicError> {
1462        if metrics_by_rank.is_empty() {
1463            return Err(AicError::InvalidForwardPassMetrics(
1464                "at least one attention-DP rank metric required".to_string(),
1465            ));
1466        }
1467        for metrics in metrics_by_rank {
1468            validate_forward_pass_metrics(metrics)?;
1469        }
1470        let mut max_latency = 0.0_f64;
1471        for metrics in metrics_by_rank {
1472            let rank_latency = self.rank_latency_ms(metrics)?;
1473            if rank_latency > max_latency {
1474                max_latency = rank_latency;
1475            }
1476        }
1477        Ok(max_latency)
1478    }
1479
1480    /// Dispatch one rank's FPM on its scheduled workload. Literal port of
1481    /// `SessionEstimator::rank_latency_ms` (520dcfff `session.rs:308`):
1482    /// prefill+decode -> mix step ([`get_mix_step_ops`]); prefill-only ->
1483    /// [`run_context_ops`]; decode-only -> [`run_generation_ops_step`]. The FPM
1484    /// counts pass through unscaled (no `nextn` multiplier — see
1485    /// [`Self::forward_pass_time_ms`]).
1486    fn rank_latency_ms(&self, metrics: &ForwardPassMetrics) -> Result<f64, AicError> {
1487        let sched = &metrics.scheduled_requests;
1488        // Token-based dispatch, aligned with `IterationFeatures` (fpm/model.rs):
1489        // a fully prefix-cached payload can retain prefill request/KV metadata
1490        // (`num_prefill_requests = 1, sum_prefill_tokens = 0`) while scheduling
1491        // no fresh prefill compute — that iteration is decode-only. A count
1492        // check would query prefill at zero tokens (outside the FPM domain)
1493        // and price decode as marginal work riding a pass that does not exist.
1494        let has_prefill = sched.sum_prefill_tokens > 0;
1495        let has_decode = sched.num_decode_requests > 0 || sched.sum_decode_kv_tokens > 0;
1496
1497        // FPM engines never enter the three-pass mix composition (its op-name
1498        // filters cannot see a whole-model op). Prefill-only and decode-only
1499        // dispatch through the same shared free fns as op-level (the FpmForward
1500        // op consumes batch/s/prefix from the RuntimeContext naturally); a
1501        // mixed rank composes prefill + marginal decode, mirroring
1502        // `_get_fpm_mix_step_latency` at the telemetry counts (already packed,
1503        // so no `(nextn + 1)` anywhere — and FPM engines enforce nextn == 0).
1504        if let Some((prefill_op, decode_op)) = self.fpm_ops() {
1505            // The telemetry sums ARE the fpm_forward tables' native coordinate
1506            // system (per-rank iteration totals) — query them via
1507            // `query_totals` instead of the op-level per-request-average
1508            // convention, which loses up to (n - 1) tokens to integer
1509            // division on each axis.
1510            let mut total = 0.0_f64;
1511            if has_prefill {
1512                total += prefill_op
1513                    .query_totals(
1514                        &self.db,
1515                        &[
1516                            sched.num_prefill_requests as f64,
1517                            sched.sum_prefill_tokens as f64,
1518                            sched.sum_prefill_kv_tokens as f64,
1519                        ],
1520                    )?
1521                    .latency_ms;
1522            }
1523            if has_decode {
1524                let decode_ms = decode_op
1525                    .query_totals(
1526                        &self.db,
1527                        &[
1528                            sched.num_decode_requests as f64,
1529                            sched.sum_decode_kv_tokens as f64,
1530                        ],
1531                    )?
1532                    .latency_ms;
1533                if has_prefill {
1534                    // Mixed rank: marginal-decode composition, mirroring
1535                    // `_get_fpm_mix_step_latency` (counts already packed, no
1536                    // `(nextn + 1)` — FPM engines enforce nextn == 0).
1537                    let baseline_ms = decode_op
1538                        .query_pass_baseline(
1539                            &self.db,
1540                            sched.num_decode_requests,
1541                            sched.sum_decode_kv_tokens as f64,
1542                        )?
1543                        .latency_ms;
1544                    total += (decode_ms - baseline_ms).max(0.0);
1545                } else {
1546                    total += decode_ms;
1547                }
1548            }
1549            return Ok(total);
1550        }
1551
1552        if has_prefill && has_decode {
1553            // Mix step (continuous batching): compose like Python's
1554            // `_get_mix_step_latency`. `sum_prefill_kv_tokens` is exactly the
1555            // combined-prefix value the pass-1 non-attention call needs; pass
1556            // it through unchanged.
1557            let n_prefill = sched.num_prefill_requests.max(1);
1558            let new_tokens_per_req = sched.sum_prefill_tokens / n_prefill;
1559            let prefix_per_req = sched.sum_prefill_kv_tokens / n_prefill;
1560            let n_decode = sched.num_decode_requests.max(1);
1561            let kv_per_req = sched.sum_decode_kv_tokens / n_decode;
1562            let ctx_tokens = sched.sum_prefill_tokens;
1563            let gen_tokens = sched.num_decode_requests;
1564            return get_mix_step_ops(
1565                &self.context_ops,
1566                &self.generation_ops,
1567                &self.db,
1568                ctx_tokens,
1569                gen_tokens,
1570                new_tokens_per_req.max(1),
1571                prefix_per_req,
1572                sched.sum_prefill_kv_tokens,
1573                kv_per_req,
1574                n_decode,
1575            );
1576        }
1577
1578        let mut total = 0.0_f64;
1579
1580        if has_prefill {
1581            let n_prefill = sched.num_prefill_requests.max(1);
1582            let new_tokens_per_req = sched.sum_prefill_tokens / n_prefill;
1583            let prefix_per_req = sched.sum_prefill_kv_tokens / n_prefill;
1584            total += run_context_ops(
1585                &self.context_ops,
1586                &self.db,
1587                n_prefill,
1588                new_tokens_per_req,
1589                prefix_per_req,
1590                1.0,
1591                ContextOpFilter::All,
1592            )?;
1593        }
1594
1595        if has_decode {
1596            let n_decode = sched.num_decode_requests.max(1);
1597            let kv_per_req = sched.sum_decode_kv_tokens / n_decode;
1598            total += run_generation_ops_step(
1599                &self.generation_ops,
1600                &self.db,
1601                n_decode,
1602                kv_per_req,
1603                1.0,
1604                false,
1605            )?;
1606        }
1607
1608        Ok(total)
1609    }
1610}
1611
1612#[cfg(test)]
1613mod tests {
1614    use super::*;
1615    use std::collections::BTreeMap;
1616    use std::path::PathBuf;
1617
1618    use crate::common::enums::{FmhaQuantMode, GemmQuantMode, KvCacheQuantMode};
1619    use crate::operators::op::Op;
1620    use crate::operators::{
1621        ContextAttentionOp, ElementwiseOp, GemmOp, GenerationAttentionOp, MoeAllToAllOp,
1622    };
1623    use crate::perfmodel::EngineConfig;
1624    use crate::perfmodel::engine::spec::EngineSpec;
1625    use crate::{BackendKind, ParallelMapping, QuantizationConfig};
1626
1627    fn systems_root() -> PathBuf {
1628        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1629            .join("../../python/aisimulate/src/aiconfigurator_core/systems")
1630    }
1631
1632    const TEST_MODEL: &str = "MiniMaxAI/MiniMax-M2.5";
1633
1634    /// Hand-built context op list against the b200_sxm/vllm/0.24.0 perf tables.
1635    /// `Elementwise` is DB-free (pure mem-bandwidth SOL); `Gemm` and
1636    /// `ContextAttention` hit existing perf tables. The (deleted) model layer
1637    /// previously sourced these lists from the HF config.
1638    fn context_ops() -> Vec<Op> {
1639        vec![
1640            Op::Elementwise(ElementwiseOp {
1641                name: "rmsnorm".into(),
1642                scale_factor: 1.0,
1643                bytes_per_token: 8192.0,
1644                scale_num_tokens: 1,
1645                seq_split: 1,
1646            }),
1647            Op::Gemm(GemmOp {
1648                name: "qkv_gemm".into(),
1649                scale_factor: 1.0,
1650                n: 4096,
1651                k: 4096,
1652                quant_mode: GemmQuantMode::Fp8Block,
1653                scale_num_tokens: 0,
1654                low_precision_input: false,
1655                seq_split: 1,
1656                below_grid_sol: false,
1657            }),
1658            Op::ContextAttention(ContextAttentionOp {
1659                name: "context_attention".into(),
1660                scale_factor: 1.0,
1661                n: 32,
1662                n_kv: 8,
1663                head_size: 128,
1664                window_size: 0,
1665                kv_cache_dtype: KvCacheQuantMode::Fp8,
1666                fmha_quant_mode: FmhaQuantMode::Bfloat16,
1667                use_qk_norm: false,
1668                cp_size: 1,
1669                lane_order: crate::operators::attention::b200_vllm_context_lane_order(),
1670            }),
1671        ]
1672    }
1673
1674    fn generation_ops() -> Vec<Op> {
1675        vec![
1676            Op::Elementwise(ElementwiseOp {
1677                name: "rmsnorm".into(),
1678                scale_factor: 1.0,
1679                bytes_per_token: 8192.0,
1680                scale_num_tokens: 1,
1681                seq_split: 1,
1682            }),
1683            Op::GenerationAttention(GenerationAttentionOp {
1684                name: "generation_attention".into(),
1685                scale_factor: 1.0,
1686                n: 32,
1687                n_kv: 8,
1688                head_size: 128,
1689                window_size: 0,
1690                kv_cache_dtype: KvCacheQuantMode::Fp8,
1691                lane_order: crate::operators::attention::b200_vllm_generation_lane_order(),
1692            }),
1693        ]
1694    }
1695
1696    fn fixture_engine_config(nextn: Option<u32>) -> EngineConfig {
1697        EngineConfig {
1698            schema_version: crate::ENGINE_CONFIG_SCHEMA_VERSION,
1699            model_name: TEST_MODEL.to_string(),
1700            system_name: "b200_sxm".to_string(),
1701            systems_path: None,
1702            backend: BackendKind::Vllm,
1703            backend_version: Some("0.24.0".to_string()),
1704            forward_model: None,
1705            kv_block_size: None,
1706            parallel: ParallelMapping {
1707                tp_size: 8,
1708                pp_size: 1,
1709                attention_dp_size: Some(1),
1710                moe_tp_size: Some(1),
1711                moe_ep_size: Some(8),
1712                cp_size: None,
1713            },
1714            quantization: QuantizationConfig {
1715                weight_dtype: None,
1716                moe_dtype: None,
1717                activation_dtype: None,
1718                kv_cache_dtype: None,
1719            },
1720            speculative: nextn.map(|n| crate::SpeculativeConfig { nextn: Some(n) }),
1721            enable_shared_layer: None,
1722            strict_provenance: false,
1723            tolerate_dirless_version: false,
1724            database_mode: Default::default(),
1725            transfer_policy: None,
1726            extra: BTreeMap::new(),
1727        }
1728    }
1729
1730    /// Build an `Engine` from the hand-built op lists over the real fixture DB.
1731    fn build_engine(nextn: Option<u32>) -> Engine {
1732        let db = PerfDatabase::load(&systems_root(), "b200_sxm", "vllm", "0.24.0").unwrap();
1733        let spec = EngineSpec::new(
1734            fixture_engine_config(nextn),
1735            context_ops(),
1736            generation_ops(),
1737        );
1738        Engine::build(spec, Arc::new(db)).unwrap()
1739    }
1740
1741    fn runtime(batch_size: u32, isl: u32, osl: u32) -> RuntimeConfig {
1742        RuntimeConfig {
1743            batch_size,
1744            isl,
1745            osl,
1746            ..Default::default()
1747        }
1748    }
1749
1750    #[test]
1751    fn per_op_fold_attaches_the_inference_phase_only_to_executed_fallbacks() {
1752        use crate::operators::base::{MoeCommFallback, Source};
1753
1754        let op = context_ops().remove(0);
1755        let fallback = MoeCommFallback {
1756            comm_backend: "deepep_ht",
1757            requested_ep_size: 32,
1758            requested_node_num: 8,
1759            measurement_ep_size: 8,
1760            measurement_node_num: 1,
1761        };
1762        for inference_phase in ["context", "generation"] {
1763            let mut fold = PerOpFold::new(inference_phase);
1764            fold.add(
1765                &op,
1766                PerformanceResult::new(1.0, Source::Estimated).with_moe_comm_fallback(fallback),
1767            );
1768            assert_eq!(
1769                fold.into_values()[0].4,
1770                Some(((inference_phase, "deepep_ht", 32, 8, 8, 1), vec![]))
1771            );
1772        }
1773
1774        let mut repeated_name = PerOpFold::new("context");
1775        repeated_name.add(
1776            &op,
1777            PerformanceResult::new(1.0, Source::Estimated).with_moe_comm_fallback(fallback),
1778        );
1779        repeated_name.add(
1780            &op,
1781            PerformanceResult::new(1.0, Source::Estimated).with_moe_comm_fallback(
1782                MoeCommFallback {
1783                    comm_backend: "deepep_ll",
1784                    ..fallback
1785                },
1786            ),
1787        );
1788        assert_eq!(
1789            repeated_name.into_values()[0].4,
1790            Some((
1791                ("context", "deepep_ht", 32, 8, 8, 1),
1792                vec![("context", "deepep_ll", 32, 8, 8, 1)],
1793            ))
1794        );
1795
1796        let mut exact = PerOpFold::new("context");
1797        exact.add(&op, PerformanceResult::new(1.0, Source::Silicon));
1798        assert_eq!(exact.into_values()[0].4, None);
1799    }
1800
1801    #[test]
1802    fn per_op_fold_allocates_additional_storage_only_for_distinct_fallbacks_after_the_first() {
1803        use crate::operators::base::{MoeCommFallback, Source};
1804
1805        let op = context_ops().remove(0);
1806        let ht = MoeCommFallback {
1807            comm_backend: "deepep_ht",
1808            requested_ep_size: 32,
1809            requested_node_num: 8,
1810            measurement_ep_size: 8,
1811            measurement_node_num: 1,
1812        };
1813        let ll = MoeCommFallback {
1814            comm_backend: "deepep_ll",
1815            ..ht
1816        };
1817
1818        let mut empty = PerOpFold::new("context");
1819        empty.add(&op, PerformanceResult::new(1.0, Source::Silicon));
1820        assert!(empty.into_values().pop().unwrap().4.is_none());
1821
1822        let mut single = PerOpFold::new("context");
1823        single.add(
1824            &op,
1825            PerformanceResult::new(1.0, Source::Estimated).with_moe_comm_fallback(ht),
1826        );
1827        let (first, additional) = single.into_values().pop().unwrap().4.unwrap();
1828        assert_eq!(first, ("context", "deepep_ht", 32, 8, 8, 1));
1829        assert_eq!(additional.capacity(), 0);
1830
1831        let mut multiple = PerOpFold::new("generation");
1832        for fallback in [ht, ht, ll, ll] {
1833            multiple.add(
1834                &op,
1835                PerformanceResult::new(1.0, Source::Estimated).with_moe_comm_fallback(fallback),
1836            );
1837        }
1838        let (first, additional) = multiple.into_values().pop().unwrap().4.unwrap();
1839        assert_eq!(first, ("generation", "deepep_ht", 32, 8, 8, 1));
1840        assert_eq!(additional, vec![("generation", "deepep_ll", 32, 8, 8, 1)]);
1841    }
1842
1843    #[test]
1844    fn generation_step_preserves_distinct_same_name_deepep_fallbacks() {
1845        let mut config = fixture_engine_config(None);
1846        config.system_name = "gb200".to_string();
1847        config.backend = BackendKind::Sglang;
1848        config.backend_version = Some("0.5.16".to_string());
1849
1850        let a2a = |moe_ep_size, node_num| {
1851            Op::MoeAllToAll(MoeAllToAllOp {
1852                name: "generation_moe_dispatch".to_string(),
1853                scale_factor: 1.0,
1854                phase: "dispatch".to_string(),
1855                comm_backend: "deepep_ll".to_string(),
1856                comm_dtype: "default".to_string(),
1857                hidden_size: 7168,
1858                topk: 8,
1859                num_experts: 256,
1860                moe_ep_size,
1861                node_num,
1862                sms: 0,
1863                attention_tp_size: 1,
1864            })
1865        };
1866        let spec = EngineSpec::new(config, Vec::new(), vec![a2a(32, 8), a2a(64, 16)]);
1867        let engine = Engine::from_spec_bytes(&spec.to_bincode().unwrap(), &systems_root())
1868            .expect("shipped GB200 SGLang DeepEP data must load");
1869        let runtime = RuntimeConfig {
1870            batch_size: 1,
1871            isl: 1024,
1872            osl: 2,
1873            ..Default::default()
1874        };
1875
1876        let (_, generation) = engine
1877            .run_static_per_op_with_metadata(&runtime, StaticMode::Generation, 32)
1878            .unwrap();
1879        assert_eq!(generation.len(), 1, "same-name ops must remain name-folded");
1880        assert_eq!(
1881            generation[0].4,
1882            Some((
1883                ("generation", "deepep_ll", 32, 8, 8, 1),
1884                vec![("generation", "deepep_ll", 64, 16, 8, 1)],
1885            ))
1886        );
1887    }
1888
1889    #[test]
1890    fn from_spec_bytes_shares_parsed_tables_across_engines() {
1891        use crate::operators::util_empirical::ProvenanceTier;
1892
1893        // Two DIFFERENT engine identities (nextn differs) over the SAME db
1894        // identity: the sweep pattern that motivates the shared-tables memo.
1895        let spec1 = EngineSpec::new(fixture_engine_config(None), context_ops(), generation_ops());
1896        let spec2 = EngineSpec::new(
1897            fixture_engine_config(Some(1)),
1898            context_ops(),
1899            generation_ops(),
1900        );
1901        let e1 = Engine::from_spec_bytes(&spec1.to_bincode().unwrap(), &systems_root()).unwrap();
1902        let e2 = Engine::from_spec_bytes(&spec2.to_bincode().unwrap(), &systems_root()).unwrap();
1903        assert!(
1904            std::sync::Arc::ptr_eq(e1.database().tables_arc(), e2.database().tables_arc()),
1905            "engines over the same db identity must share parsed tables"
1906        );
1907        // ... while their run state stays per-engine: provenance noted through
1908        // one engine's database must not appear on the other's accumulator.
1909        e1.database().note_provenance(ProvenanceTier::Empirical);
1910        assert_eq!(e2.database().worst_provenance(), ProvenanceTier::Silicon);
1911    }
1912
1913    #[test]
1914    fn both_equals_context_plus_generation() {
1915        let engine = build_engine(None);
1916        let rt = runtime(1, 1024, 8);
1917        let both = engine.run_static(&rt, StaticMode::Both, 32).unwrap();
1918        let ctx = engine.run_static(&rt, StaticMode::Context, 32).unwrap();
1919        let generation = engine.run_static(&rt, StaticMode::Generation, 32).unwrap();
1920
1921        assert!((both.context_ms - ctx.context_ms).abs() < 1e-9);
1922        assert!((both.generation_ms - generation.generation_ms).abs() < 1e-9);
1923        assert!((both.total_ms - (ctx.context_ms + generation.generation_ms)).abs() < 1e-9);
1924        // total of `Both` is the sum of the two single-phase totals.
1925        assert!((both.total_ms - (ctx.total_ms + generation.total_ms)).abs() < 1e-9);
1926    }
1927
1928    #[test]
1929    fn context_mode_has_zero_generation() {
1930        let engine = build_engine(None);
1931        let rt = runtime(1, 1024, 8);
1932        let ctx = engine.run_static(&rt, StaticMode::Context, 32).unwrap();
1933        assert!(ctx.context_ms > 0.0, "context latency must be non-trivial");
1934        assert_eq!(ctx.generation_ms, 0.0);
1935        assert_eq!(ctx.total_ms, ctx.context_ms);
1936    }
1937
1938    #[test]
1939    fn generation_mode_has_zero_context() {
1940        let engine = build_engine(None);
1941        let rt = runtime(1, 1024, 8);
1942        let generation = engine.run_static(&rt, StaticMode::Generation, 32).unwrap();
1943        assert!(
1944            generation.generation_ms > 0.0,
1945            "generation latency must be non-trivial"
1946        );
1947        assert_eq!(generation.context_ms, 0.0);
1948        assert_eq!(generation.total_ms, generation.generation_ms);
1949    }
1950
1951    #[test]
1952    fn stride_honored() {
1953        let engine = build_engine(None);
1954        // osl=9 → range(0,8,stride). stride=1 visits i=0..7 (8 steps each
1955        // repeat_count=1); stride=32 visits only i=0 (repeat_count=8). The
1956        // per-step latency grows with the decode position (s = isl+i+1), so
1957        // the fine-grained integration differs from the single-sample one.
1958        let rt = runtime(1, 1024, 9);
1959        let fine = engine.run_static(&rt, StaticMode::Generation, 1).unwrap();
1960        let coarse = engine.run_static(&rt, StaticMode::Generation, 32).unwrap();
1961        assert!(fine.generation_ms > 0.0 && coarse.generation_ms > 0.0);
1962        assert!(
1963            (fine.generation_ms - coarse.generation_ms).abs() > 1e-9,
1964            "stride=1 ({}) and stride=32 ({}) must differ for osl=9",
1965            fine.generation_ms,
1966            coarse.generation_ms
1967        );
1968
1969        // Hand-rolled expected sum for stride=32, osl=9: one step at i=0
1970        // (s = isl + 1), repeat_count = min(32, 8) = 8.
1971        let one_step = run_generation_ops_step(
1972            &engine.generation_ops,
1973            engine.database(),
1974            1, // batch_size * (nextn+1), nextn=0
1975            1024 + 0 + 1,
1976            1.0,
1977            false,
1978        )
1979        .unwrap();
1980        assert!((coarse.generation_ms - one_step * 8.0).abs() < 1e-6);
1981    }
1982
1983    #[test]
1984    fn osl_one_yields_zero_generation() {
1985        let engine = build_engine(None);
1986        let rt = runtime(1, 1024, 1);
1987        let generation = engine.run_static(&rt, StaticMode::Generation, 32).unwrap();
1988        assert_eq!(generation.generation_ms, 0.0);
1989    }
1990
1991    #[test]
1992    fn prefix_ge_isl_errors() {
1993        let engine = build_engine(None);
1994        let rt = RuntimeConfig {
1995            batch_size: 1,
1996            isl: 512,
1997            osl: 2,
1998            prefix: 512,
1999            ..Default::default()
2000        };
2001        assert!(engine.run_static(&rt, StaticMode::Context, 32).is_err());
2002    }
2003
2004    #[test]
2005    fn mixed_step_empty_is_zero() {
2006        let engine = build_engine(None);
2007        assert_eq!(
2008            engine
2009                .mixed_step_latency(0, 0, 1024, 8, 0, 1.0, 1.0)
2010                .unwrap(),
2011            0.0
2012        );
2013    }
2014
2015    #[test]
2016    fn mixed_step_nonempty_is_positive() {
2017        // The full three-pass composition (non-attention + context-attn +
2018        // gen-attn) over the hand-built fixture must produce a real latency.
2019        // End-to-end parity is covered by the mixed-step parity cases; this is
2020        // the fast pure-Rust smoke that the composition actually computes.
2021        let engine = build_engine(None);
2022        let ms = engine
2023            .mixed_step_latency(1024, 2, 1024, 8, 0, 1.0, 1.0)
2024            .unwrap();
2025        assert!(
2026            ms > 0.0 && ms.is_finite(),
2027            "mixed-step latency must be > 0, got {ms}"
2028        );
2029        let breakdown = engine
2030            .mixed_step_breakdown(1024, 2, 1024, 8, 0, 1.0, 1.0)
2031            .unwrap();
2032        assert_eq!(breakdown[0], breakdown[1] + breakdown[2] + breakdown[3]);
2033        assert_eq!(ms, breakdown[0]);
2034    }
2035
2036    // ---- FPM whole-model engine branches ----
2037
2038    /// FPM engine over the synthetic pair fixture: context = [FpmForward
2039    /// prefill], generation = [FpmForward decode], empty sol_ops (grid-exact
2040    /// queries never call SOL).
2041    fn build_fpm_engine(tmp: &std::path::Path, nextn: Option<u32>) -> Result<Engine, AicError> {
2042        use crate::perf_database::fpm_forward::tests::{
2043            default_identity, default_rows, write_pair,
2044        };
2045        write_pair(tmp, &default_rows());
2046        let mut db = PerfDatabase::load(&systems_root(), "b200_sxm", "vllm", "0.24.0").unwrap();
2047        db.set_fpm_forward_for_test(crate::perf_database::FpmForwardTable::new(
2048            tmp.to_path_buf(),
2049            "b200_sxm",
2050            "vllm",
2051            "0.25.1",
2052        ));
2053        let fpm_op = |phase: FpmPhase| {
2054            Op::FpmForward(FpmForwardOp {
2055                name: format!("fpm_forward_{}", phase.as_str()),
2056                phase,
2057                model_path: "org/model-a".to_string(),
2058                match_identity: default_identity(4),
2059                weight_bytes: 0.0,
2060                sol_ops: vec![],
2061            })
2062        };
2063        let spec = EngineSpec::new(
2064            fixture_engine_config(nextn),
2065            vec![fpm_op(FpmPhase::Prefill)],
2066            vec![fpm_op(FpmPhase::Decode)],
2067        );
2068        Engine::build(spec, Arc::new(db))
2069    }
2070
2071    #[test]
2072    fn fpm_build_rejects_mtp_and_bad_shape() {
2073        let tmp = tempfile::tempdir().unwrap();
2074        let err = build_fpm_engine(tmp.path(), Some(1)).unwrap_err();
2075        assert!(err.to_string().contains("MTP"), "{err}");
2076
2077        // Mixed granular + FPM list is invalid.
2078        use crate::perf_database::fpm_forward::tests::default_identity;
2079        let db = PerfDatabase::load(&systems_root(), "b200_sxm", "vllm", "0.24.0").unwrap();
2080        let fpm_op = Op::FpmForward(FpmForwardOp {
2081            name: "fpm_forward_prefill".into(),
2082            phase: FpmPhase::Prefill,
2083            model_path: "org/model-a".into(),
2084            match_identity: default_identity(4),
2085            weight_bytes: 0.0,
2086            sol_ops: vec![],
2087        });
2088        let spec = EngineSpec::new(
2089            fixture_engine_config(None),
2090            vec![fpm_op, context_ops().remove(0)],
2091            generation_ops(),
2092        );
2093        let err = Engine::build(spec, Arc::new(db)).unwrap_err();
2094        assert!(err.to_string().contains("exactly one FpmForward"), "{err}");
2095    }
2096
2097    /// The marginal-decode mixed composition, exact arithmetic over the
2098    /// fixture rows: the prefill component prices the step's SCHEDULED TOTAL
2099    /// (ctx + gen tokens) on the prefill curve; decode is the in-curve lerp
2100    /// minus the (8, 8) -> 6.0 baseline floor.
2101    #[test]
2102    fn fpm_mixed_step_is_prefill_plus_marginal_decode() {
2103        let tmp = tempfile::tempdir().unwrap();
2104        let engine = build_fpm_engine(tmp.path(), None).unwrap();
2105        // ctx: 2048 tokens / isl 2048 -> batch 1, totals (1, 2048+8, 0):
2106        // in-curve lerp between (1,2048)->20.0 and (1,4096)->40.0.
2107        // gen: batch 8; osl=0 clamps to 1 -> isl' = 2048, one step at
2108        // s = 2049 -> kv = 8*2049 = 16392: lerp between (8,4096)->7.0 and
2109        // (8,65536)->9.0, minus baseline (8, kv_floor=8) -> 6.0.
2110        let ms = engine
2111            .mixed_step_latency(2048, 8, 2048, 0, 0, 1.0, 1.0)
2112            .unwrap();
2113        let pre = 20.0 + (40.0 - 20.0) * (2056.0 - 2048.0) / (4096.0 - 2048.0);
2114        let w = (16392.0 - 4096.0) / (65536.0 - 4096.0);
2115        let decode = 7.0 + (9.0 - 7.0) * w;
2116        let expected = pre + (decode - 6.0);
2117        assert!((ms - expected).abs() < 1e-9, "got {ms}, want {expected}");
2118    }
2119
2120    /// FPM engine over CUSTOM rows (cliff pair + chunk coordinates); same
2121    /// wiring as [`build_fpm_engine`].
2122    fn build_fpm_engine_with_rows(
2123        tmp: &std::path::Path,
2124        rows: &[crate::perf_database::fpm_forward::tests::RowSpec],
2125    ) -> Result<Engine, AicError> {
2126        use crate::perf_database::fpm_forward::tests::{default_identity, write_pair};
2127        write_pair(tmp, rows);
2128        let mut db = PerfDatabase::load(&systems_root(), "b200_sxm", "vllm", "0.24.0").unwrap();
2129        db.set_fpm_forward_for_test(crate::perf_database::FpmForwardTable::new(
2130            tmp.to_path_buf(),
2131            "b200_sxm",
2132            "vllm",
2133            "0.25.1",
2134        ));
2135        let fpm_op = |phase: FpmPhase| {
2136            Op::FpmForward(FpmForwardOp {
2137                name: format!("fpm_forward_{}", phase.as_str()),
2138                phase,
2139                model_path: "org/model-a".to_string(),
2140                match_identity: default_identity(4),
2141                weight_bytes: 0.0,
2142                sol_ops: vec![],
2143            })
2144        };
2145        let spec = EngineSpec::new(
2146            fixture_engine_config(None),
2147            vec![fpm_op(FpmPhase::Prefill)],
2148            vec![fpm_op(FpmPhase::Decode)],
2149        );
2150        Engine::build(spec, Arc::new(db))
2151    }
2152
2153    fn cliff_rows() -> Vec<crate::perf_database::fpm_forward::tests::RowSpec> {
2154        use crate::perf_database::fpm_forward::tests::RowSpec;
2155        let mk = |kind: &'static str, batch: u32, prefill: u32, kv: u32, lat: f64| RowSpec {
2156            workload_kind: kind,
2157            batch_size: batch,
2158            total_prefill_tokens: prefill,
2159            total_kv_read_tokens: kv,
2160            latency_ms: lat,
2161            ..RowSpec::default()
2162        };
2163        vec![
2164            // CUDA-graph cliff pair at capture=2048, plus the eager plateau.
2165            mk("prefill", 1, 2048, 0, 47.0),
2166            mk("prefill", 1, 2049, 0, 99.0),
2167            mk("prefill", 1, 4096, 0, 99.0),
2168            // Chunk coordinates for the multi-chunk average test.
2169            mk("prefill", 1, 1032, 0, 10.0),
2170            mk("prefill", 1, 1032, 1024, 14.0),
2171            mk("decode", 8, 0, 8, 6.0),
2172            mk("decode", 8, 0, 4096, 7.0),
2173            mk("decode", 8, 0, 65536, 9.0),
2174        ]
2175    }
2176
2177    /// Spec test 1+2: the step's total (ctx + gen) picks the regime side.
2178    /// ctx=2048 alone sits ON the capture boundary (graph side, 47 ms); the
2179    /// same chunk with ANY decode riders crosses it and must price eager.
2180    #[test]
2181    fn fpm_mixed_step_total_crosses_the_graph_cliff() {
2182        let tmp = tempfile::tempdir().unwrap();
2183        let engine = build_fpm_engine_with_rows(tmp.path(), &cliff_rows()).unwrap();
2184        // In-graph: pure prefill step, totals (1, 2048, 0) -> exact 47.0.
2185        let graph = engine
2186            .mixed_step_breakdown(2048, 0, 2048, 0, 0, 1.0, 1.0)
2187            .unwrap();
2188        assert!(
2189            (graph[1] - 47.0).abs() < 1e-9,
2190            "graph-side prefill {}",
2191            graph[1]
2192        );
2193        // Crossing: 8 decode riders push the total to 2056 -> eager plateau.
2194        let eager = engine
2195            .mixed_step_breakdown(2048, 8, 2048, 0, 0, 1.0, 1.0)
2196            .unwrap();
2197        assert!(
2198            (eager[1] - 99.0).abs() < 1e-9,
2199            "eager-side prefill {}",
2200            eager[1]
2201        );
2202        assert!(eager[1] > graph[1] * 2.0 - 1e-9);
2203    }
2204
2205    /// Spec test 4: chunked requests price each chunk at its own
2206    /// (chunk + gen, past_kv) coordinates; the component is their average.
2207    #[test]
2208    fn fpm_mixed_step_chunks_average_exact_coordinates() {
2209        let tmp = tempfile::tempdir().unwrap();
2210        let engine = build_fpm_engine_with_rows(tmp.path(), &cliff_rows()).unwrap();
2211        // ctx=1024 of isl=2048: chunk 1 -> (1, 1032, 0) = 10.0,
2212        // chunk 2 -> (1, 1032, 1024) = 14.0; average 12.0.
2213        let parts = engine
2214            .mixed_step_breakdown(1024, 8, 2048, 0, 0, 1.0, 1.0)
2215            .unwrap();
2216        assert!((parts[1] - 12.0).abs() < 1e-9, "chunk average {}", parts[1]);
2217    }
2218
2219    /// A generation-only step keeps the FULL decode latency (no pass to ride
2220    /// on) and uses the Python static-path convention s = isl + osl/2 + 1.
2221    #[test]
2222    fn fpm_genonly_step_keeps_full_decode() {
2223        let tmp = tempfile::tempdir().unwrap();
2224        let engine = build_fpm_engine(tmp.path(), None).unwrap();
2225        // gen_tokens=8, isl=511, osl=0 -> isl'=511, one step at s=512 ->
2226        // kv = 8*512 = 4096: exact decode row -> 7.0, NOT 7.0 - 6.0.
2227        let ms = engine.decode_step_latency(8, 511, 0, 1.0).unwrap();
2228        assert!((ms - 7.0).abs() < 1e-12, "got {ms}");
2229        // mixed with ctx_tokens=0 must agree with the genonly convention
2230        let mixed = engine
2231            .mixed_step_latency(0, 8, 511, 0, 0, 1.0, 1.0)
2232            .unwrap();
2233        assert!((mixed - 7.0).abs() < 1e-12, "got {mixed}");
2234        assert_eq!(engine.decode_step_latency(0, 511, 0, 1.0).unwrap(), 0.0);
2235    }
2236
2237    /// A fully prefix-cached payload retains prefill request/KV metadata
2238    /// while scheduling no fresh prefill compute: dispatch must be
2239    /// token-based (aligned with `IterationFeatures`) — a count-based check
2240    /// would query prefill at zero tokens (outside the FPM domain) and
2241    /// price decode as marginal work riding a pass that does not exist.
2242    #[test]
2243    fn fpm_rank_prefix_cached_payload_is_decode_only() {
2244        use crate::fpm::{ForwardPassMetrics, ScheduledRequestMetrics};
2245        let tmp = tempfile::tempdir().unwrap();
2246        let engine = build_fpm_engine(tmp.path(), None).unwrap();
2247        let metrics = ForwardPassMetrics {
2248            scheduled_requests: ScheduledRequestMetrics {
2249                num_prefill_requests: 1,
2250                sum_prefill_tokens: 0,
2251                sum_prefill_kv_tokens: 4096,
2252                num_decode_requests: 8,
2253                sum_decode_kv_tokens: 4096, // exact decode row -> 7.0
2254                ..Default::default()
2255            },
2256            ..Default::default()
2257        };
2258        // FULL decode latency (decode-only), not the marginal composition.
2259        let ms = engine.forward_pass_time_ms(&[metrics]).unwrap();
2260        assert!((ms - 7.0).abs() < 1e-12, "{ms}");
2261    }
2262
2263    /// Telemetry dispatch: single-workload FPM ranks flow through the shared
2264    /// free fns; a mixed rank composes prefill + marginal decode.
2265    #[test]
2266    fn fpm_rank_latency_marginal_composition() {
2267        use crate::fpm::{ForwardPassMetrics, ScheduledRequestMetrics};
2268        let tmp = tempfile::tempdir().unwrap();
2269        let engine = build_fpm_engine(tmp.path(), None).unwrap();
2270
2271        let mixed = ForwardPassMetrics {
2272            scheduled_requests: ScheduledRequestMetrics {
2273                num_prefill_requests: 2,
2274                sum_prefill_tokens: 2 * 1024,
2275                sum_prefill_kv_tokens: 0,
2276                num_decode_requests: 8,
2277                sum_decode_kv_tokens: 8 * 4096,
2278                ..Default::default()
2279            },
2280            ..Default::default()
2281        };
2282        // prefill: totals coords (2, 2048, 0) -> exact 21.0. decode: totals
2283        // coords (8, 32768): lerp between (8,4096)->7.0 and (8,65536)->9.0,
2284        // minus baseline (8, 8) -> 6.0.
2285        let w = (32768.0 - 4096.0) / (65536.0 - 4096.0);
2286        let decode = 7.0 + (9.0 - 7.0) * w;
2287        let expected = 21.0 + (decode - 6.0);
2288        let got = engine.forward_pass_time_ms(&[mixed]).unwrap();
2289        assert!((got - expected).abs() < 1e-9, "got {got}, want {expected}");
2290    }
2291
2292    /// Mixed telemetry may request a synthetic decode baseline below the KV
2293    /// floor of its padded bracket rows. Only that baseline holds each row at
2294    /// its measured floor; the actual decode query remains in-range and strict.
2295    #[test]
2296    fn fpm_rank_mixed_baseline_holds_bracket_curve_floors() {
2297        use crate::fpm::{ForwardPassMetrics, ScheduledRequestMetrics};
2298        use crate::perf_database::fpm_forward::tests::RowSpec;
2299        let mk = |kind: &'static str, batch: u32, prefill: u32, kv: u32, lat: f64| RowSpec {
2300            workload_kind: kind,
2301            batch_size: batch,
2302            total_prefill_tokens: prefill,
2303            total_kv_read_tokens: kv,
2304            latency_ms: lat,
2305            ..RowSpec::default()
2306        };
2307        let rows = vec![
2308            mk("prefill", 1, 2048, 0, 20.0),
2309            mk("decode", 1, 0, 2, 2.0),
2310            mk("decode", 1, 0, 64, 3.0),
2311            mk("decode", 2, 0, 4, 2.5),
2312            mk("decode", 2, 0, 64, 3.5),
2313            mk("decode", 8, 0, 16, 4.0),
2314            mk("decode", 8, 0, 64, 5.0),
2315            mk("decode", 9, 0, 18, 5.0),
2316            mk("decode", 9, 0, 64, 6.0),
2317            mk("decode", 16, 0, 32, 9.0),
2318            mk("decode", 16, 0, 64, 10.0),
2319            mk("decode", 17, 0, 34, 10.0),
2320            mk("decode", 17, 0, 64, 11.0),
2321        ];
2322        let tmp = tempfile::tempdir().unwrap();
2323        let engine = build_fpm_engine_with_rows(tmp.path(), &rows).unwrap();
2324        let mixed = ForwardPassMetrics {
2325            scheduled_requests: ScheduledRequestMetrics {
2326                num_prefill_requests: 1,
2327                sum_prefill_tokens: 2048,
2328                num_decode_requests: 15,
2329                sum_decode_kv_tokens: 64,
2330                ..Default::default()
2331            },
2332            ..Default::default()
2333        };
2334
2335        let weight = (15.0 - 9.0) / (16.0 - 9.0);
2336        let decode = 6.0 + (10.0 - 6.0) * weight;
2337        let baseline = 5.0 + (9.0 - 5.0) * weight;
2338        let expected = 20.0 + decode - baseline;
2339        let got = engine.forward_pass_time_ms(&[mixed]).unwrap();
2340        assert!((got - expected).abs() < 1e-9, "got {got}, want {expected}");
2341    }
2342
2343    /// Both mixed-step paths must sample the baseline at the SAME
2344    /// (batch, total-KV) coordinate the decode query used, so a KV only one
2345    /// bracket row covers drops that row from both sides. Blending the
2346    /// uncovered row's floor leaves the shared-pass cost inside the marginal.
2347    #[test]
2348    fn fpm_mixed_baseline_follows_the_query_off_a_ragged_bracket_row() {
2349        use crate::fpm::{ForwardPassMetrics, ScheduledRequestMetrics};
2350        use crate::perf_database::fpm_forward::tests::RowSpec;
2351        let mk = |kind: &'static str, batch: u32, prefill: u32, kv: u32, lat: f64| RowSpec {
2352            workload_kind: kind,
2353            batch_size: batch,
2354            total_prefill_tokens: prefill,
2355            total_kv_read_tokens: kv,
2356            latency_ms: lat,
2357            ..RowSpec::default()
2358        };
2359        // Bracket (9, 16) with ragged curves: row 9 stops at kv=64, row 16
2360        // starts at kv=32 and runs to 96.
2361        let rows = vec![
2362            mk("prefill", 1, 16, 0, 20.0),
2363            mk("prefill", 1, 32, 0, 40.0),
2364            mk("decode", 1, 0, 2, 2.0),
2365            mk("decode", 1, 0, 96, 3.0),
2366            mk("decode", 2, 0, 4, 2.5),
2367            mk("decode", 2, 0, 96, 3.5),
2368            mk("decode", 8, 0, 16, 4.0),
2369            mk("decode", 8, 0, 96, 5.0),
2370            mk("decode", 9, 0, 18, 5.0),
2371            mk("decode", 9, 0, 64, 6.0),
2372            mk("decode", 16, 0, 32, 9.0),
2373            mk("decode", 16, 0, 96, 10.0),
2374            mk("decode", 17, 0, 34, 10.0),
2375            mk("decode", 17, 0, 96, 11.0),
2376        ];
2377        let tmp = tempfile::tempdir().unwrap();
2378        let engine = build_fpm_engine_with_rows(tmp.path(), &rows).unwrap();
2379
2380        // ctx 5 tokens / isl 5 -> prefill batch 1, totals (1, 5 + 15, 0).
2381        // gen: batch 15, osl clamps to 1 -> isl' = 5, one step at s = 6 ->
2382        // kv = 15 * 6 = 90, which ONLY row 16 covers.
2383        let ms = engine.mixed_step_latency(5, 15, 5, 0, 0, 1.0, 1.0).unwrap();
2384        let prefill = 20.0 + (40.0 - 20.0) * (20.0 - 16.0) / (32.0 - 16.0);
2385        let decode = 9.0 + (10.0 - 9.0) * (90.0 - 32.0) / (96.0 - 32.0);
2386        let expected = prefill + (decode - 9.0);
2387        assert!((ms - expected).abs() < 1e-9, "got {ms}, want {expected}");
2388
2389        // ForwardPassMetrics carries raw totals. Its mixed-rank path must
2390        // pass sum_decode_kv_tokens=80 to the same baseline selector; only
2391        // row 16 covers this coordinate too.
2392        let mixed = ForwardPassMetrics {
2393            scheduled_requests: ScheduledRequestMetrics {
2394                num_prefill_requests: 1,
2395                sum_prefill_tokens: 20,
2396                num_decode_requests: 15,
2397                sum_decode_kv_tokens: 80,
2398                ..Default::default()
2399            },
2400            ..Default::default()
2401        };
2402        let decode = 9.0 + (10.0 - 9.0) * (80.0 - 32.0) / (96.0 - 32.0);
2403        let expected = prefill + (decode - 9.0);
2404        let ms = engine.forward_pass_time_ms(&[mixed]).unwrap();
2405        assert!((ms - expected).abs() < 1e-9, "got {ms}, want {expected}");
2406    }
2407
2408    /// The FPM rank dispatch queries RAW iteration totals — the tables'
2409    /// native coordinate system — not the op-level per-request averages,
2410    /// which floor-divide away up to (n - 1) tokens per axis.
2411    #[test]
2412    fn fpm_rank_uses_iteration_totals_not_averages() {
2413        use crate::fpm::{ForwardPassMetrics, ScheduledRequestMetrics};
2414        let tmp = tempfile::tempdir().unwrap();
2415        let engine = build_fpm_engine(tmp.path(), None).unwrap();
2416
2417        // 8 decode requests, 32,773 total KV: NOT divisible by 8. Totals
2418        // convention queries (8, 32773); the old average convention floored
2419        // to kv_per_req = 4096 -> (8, 32768).
2420        let decode_only = ForwardPassMetrics {
2421            scheduled_requests: ScheduledRequestMetrics {
2422                num_decode_requests: 8,
2423                sum_decode_kv_tokens: 32_773,
2424                ..Default::default()
2425            },
2426            ..Default::default()
2427        };
2428        let w = (32_773.0 - 4096.0) / (65_536.0 - 4096.0);
2429        let expected = 7.0 + (9.0 - 7.0) * w;
2430        let got = engine.forward_pass_time_ms(&[decode_only]).unwrap();
2431        assert!((got - expected).abs() < 1e-9, "got {got}, want {expected}");
2432    }
2433
2434    /// The FPM shape guard must see through Overlap/Fallback nesting: a
2435    /// hand-built spec hiding an FpmForward inside a composite would
2436    /// otherwise ride the name-filtered mix-step passes with the wrong
2437    /// workload shape (and FallbackOp swallows its PerfDatabase misses).
2438    #[test]
2439    fn nested_fpm_op_is_rejected_at_build() {
2440        use crate::perf_database::fpm_forward::tests::default_identity;
2441        let db = PerfDatabase::load(&systems_root(), "b200_sxm", "vllm", "0.24.0").unwrap();
2442        let hidden = Op::Overlap(crate::operators::OverlapOp::new(
2443            "hidden",
2444            vec![Op::FpmForward(FpmForwardOp {
2445                name: "fpm_forward_prefill".into(),
2446                phase: FpmPhase::Prefill,
2447                model_path: "org/model-a".into(),
2448                match_identity: default_identity(4),
2449                weight_bytes: 0.0,
2450                sol_ops: vec![],
2451            })],
2452            vec![],
2453        ));
2454        let spec = EngineSpec::new(fixture_engine_config(None), vec![hidden], generation_ops());
2455        let err = Engine::build(spec, Arc::new(db)).unwrap_err();
2456        assert!(
2457            err.to_string()
2458                .contains("exactly one FpmForward op per phase"),
2459            "{err}"
2460        );
2461    }
2462
2463    /// Lock the one piece of orchestration that lives ONLY in the Engine: the
2464    /// `(nextn + 1)` decode-batch multiplier (Python `_run_generation_phase:200`).
2465    /// Builds an Engine with `nextn=1` over the hand-built ops and asserts the
2466    /// generation phase queries the perf-DB at the doubled decode batch — i.e.
2467    /// it equals the shared `run_generation_ops_step` free fn at `2 *
2468    /// batch_size`. Proves `nextn` threads from `spec.engine.speculative` into
2469    /// the gen batch (the one behavior genuinely unique to the Engine layer).
2470    #[test]
2471    fn nextn_scales_decode_batch() {
2472        let engine_nextn1 = build_engine(Some(1));
2473        assert_eq!(engine_nextn1.nextn, 1);
2474
2475        // osl=2 → one decode step at s = isl + 1. With nextn=1 the engine must
2476        // query at batch_size * 2; mirror that with the free fn at 2*batch.
2477        let rt = runtime(1, 1024, 2);
2478        let generation = engine_nextn1
2479            .run_static(&rt, StaticMode::Generation, 32)
2480            .unwrap();
2481        let doubled = run_generation_ops_step(
2482            &engine_nextn1.generation_ops,
2483            engine_nextn1.database(),
2484            2,
2485            1024 + 1,
2486            1.0,
2487            false,
2488        )
2489        .unwrap();
2490        assert!(
2491            (generation.generation_ms - doubled).abs() < 1e-9,
2492            "nextn=1 gen ({}) must equal the gen-step at 2*batch ({})",
2493            generation.generation_ms,
2494            doubled
2495        );
2496    }
2497
2498    /// The SOL-decomposition FFI must agree with a Sol-view evaluation of
2499    /// the same ops: each entry's `sol_time` IS the op's Sol-mode latency
2500    /// (shared query path, shared shape math), and for single-leaf ops the
2501    /// leaf identity `sol_time = max(sol_math, sol_mem)` holds. The GEMM
2502    /// triple is additionally pinned to its closed-form roofline — the
2503    /// Python SOL_FULL `get_sol` verbatim.
2504    #[test]
2505    fn evaluate_ops_sol_json_matches_sol_view() {
2506        use crate::perf_database::gemm::quant_tc_flops;
2507        use crate::session::query_context_op;
2508
2509        let engine = build_engine(None);
2510        let ops = context_ops();
2511        let ops_json = serde_json::to_string(&ops).unwrap();
2512        let (batch, s) = (4u32, 512u32);
2513        let sol = engine
2514            .evaluate_ops_sol_json(&ops_json, true, batch, s, 0, 1.0, None)
2515            .unwrap();
2516        assert_eq!(sol.len(), ops.len());
2517
2518        let sol_db = engine.database().sol_full_view();
2519        for (op, entry) in ops.iter().zip(&sol) {
2520            let r = query_context_op(op, &sol_db, batch, s, 0, 1.0, None).unwrap();
2521            assert_eq!(entry.0, op.name());
2522            assert!(
2523                (entry.1 - r.latency_ms).abs() < 1e-12,
2524                "{}: sol_time {} != Sol-view latency {}",
2525                entry.0,
2526                entry.1,
2527                r.latency_ms
2528            );
2529        }
2530
2531        // Single-leaf ops: sol_time = max(sol_math, sol_mem). (Composed ops
2532        // like context attention add fused-extras leaves AFTER the max, so
2533        // the identity intentionally does not hold there.)
2534        for entry in sol.iter().take(2) {
2535            assert!(
2536                (entry.1 - entry.2.max(entry.3)).abs() < 1e-12,
2537                "{}: leaf max identity broken: {:?}",
2538                entry.0,
2539                entry
2540            );
2541        }
2542
2543        // GEMM triple == the closed-form roofline at m = batch * s
2544        // (Python `GEMM._query_gemm_table::get_sol`).
2545        let spec = &engine.database().system_spec;
2546        let quant = GemmQuantMode::Fp8Block;
2547        let tc_flops = quant_tc_flops(spec, quant.mapping()).unwrap();
2548        let (m, n, k) = ((batch * s) as f64, 4096.0, 4096.0);
2549        let math = 2.0 * m * n * k / tc_flops * 1000.0;
2550        let mem = quant.mapping().memory * (m * n + m * k + n * k) / spec.gpu.mem_bw * 1000.0;
2551        let gemm = &sol[1];
2552        assert!(
2553            (gemm.2 - math).abs() < 1e-12,
2554            "sol_math {} != {math}",
2555            gemm.2
2556        );
2557        assert!((gemm.3 - mem).abs() < 1e-12, "sol_mem {} != {mem}", gemm.3);
2558    }
2559
2560    /// GLM-5.2 DSA full/skip amortization (`full_frac < 1`) must blend the
2561    /// SOL decomposition componentwise alongside the latency — the blended
2562    /// result reaches `PerOpSolFold::add` with components, and each component
2563    /// equals `w*full + (1-w)*skip` of the closed-form rooflines.
2564    #[test]
2565    fn evaluate_ops_sol_json_blends_dsa_full_skip() {
2566        use crate::common::enums::{FmhaQuantMode, KvCacheQuantMode};
2567        use crate::operators::DsaModuleOp;
2568        use crate::perf_database::dsa::{dsa_context_sol, dsa_context_sol_flops, dsa_dims};
2569
2570        let engine = build_engine(None);
2571        let spec = &engine.database().system_spec;
2572        let mut op = DsaModuleOp::new(
2573            "dsa_context",
2574            128,
2575            KvCacheQuantMode::Bfloat16,
2576            FmhaQuantMode::Bfloat16,
2577            GemmQuantMode::Bfloat16,
2578            "DeepseekV32ForCausalLM",
2579            2048,
2580        );
2581        let w = 0.5;
2582        op.full_frac = w;
2583        let (b, s) = (1u32, 4096u32);
2584        let ops_json = serde_json::to_string(&vec![Op::DsaContext(op.clone())]).unwrap();
2585        let sol = engine
2586            .evaluate_ops_sol_json(&ops_json, true, b, s, 0, 1.0, None)
2587            .unwrap();
2588        assert_eq!(sol.len(), 1);
2589
2590        let dims = dsa_dims(&op.architecture);
2591        let flops = dsa_context_sol_flops(spec, op.gemm_quant_mode, op.fmha_quant_mode).unwrap();
2592        let leaf = |skip: bool| {
2593            dsa_context_sol(
2594                spec,
2595                dims,
2596                op.index_topk as i64,
2597                op.kv_cache_dtype,
2598                op.fmha_quant_mode,
2599                op.gemm_quant_mode,
2600                b as i64,
2601                s as i64,
2602                0,
2603                op.num_heads as i64,
2604                skip,
2605                flops,
2606            )
2607        };
2608        let (full, skip) = (leaf(false), leaf(true));
2609        let expected_math = w * full.math_ms + (1.0 - w) * skip.math_ms;
2610        let expected_mem = w * full.mem_ms + (1.0 - w) * skip.mem_ms;
2611        let expected_time = w * full.time_ms() + (1.0 - w) * skip.time_ms();
2612        let (_, sol_time, sol_math, sol_mem) = &sol[0];
2613        assert!(
2614            (sol_time - expected_time).abs() < 1e-12,
2615            "{sol_time} vs {expected_time}"
2616        );
2617        assert!(
2618            (sol_math - expected_math).abs() < 1e-12,
2619            "{sol_math} vs {expected_math}"
2620        );
2621        assert!(
2622            (sol_mem - expected_mem).abs() < 1e-12,
2623            "{sol_mem} vs {expected_mem}"
2624        );
2625        // The skip leaf must actually differ from the full leaf, or this
2626        // test would pass vacuously on a broken blend.
2627        assert!(skip.time_ms() < full.time_ms());
2628    }
2629
2630    /// CP DSA currently composes latency-only sparse MQA/top-k deltas, so the
2631    /// SOL_FULL API must reject that configuration at the DSA boundary. It
2632    /// must not run the composition and fail later with PerOpSolFold's generic
2633    /// `no SOL decomposition` error. The adjacent non-CP blend test pins the
2634    /// supported `cp_size=1` contract.
2635    #[test]
2636    fn evaluate_ops_sol_json_rejects_cp_dsa_explicitly() {
2637        use crate::operators::DsaModuleOp;
2638
2639        let engine = build_engine(None);
2640        let mut op = DsaModuleOp::new(
2641            "dsa_context",
2642            64,
2643            KvCacheQuantMode::Bfloat16,
2644            FmhaQuantMode::Bfloat16,
2645            GemmQuantMode::Bfloat16,
2646            "GlmMoeDsaForCausalLM",
2647            2048,
2648        );
2649        op.cp_size = 2;
2650        op.full_frac = 0.5;
2651        let ops_json = serde_json::to_string(&vec![Op::DsaContext(op)]).unwrap();
2652        let err = engine
2653            .evaluate_ops_sol_json(&ops_json, true, 1, 4096, 0, 1.0, None)
2654            .unwrap_err();
2655
2656        match err {
2657            AicError::InvalidEngineConfig(message) => {
2658                assert!(
2659                    message.contains("DSA context SOL_FULL decomposition is not supported")
2660                        && message.contains("cp_size=2")
2661                        && message.contains("sparse MQA/top-k deltas are latency-only"),
2662                    "unexpected message: {message}"
2663                );
2664            }
2665            other => panic!("expected explicit CP DSA configuration error, got {other}"),
2666        }
2667    }
2668
2669    /// Op families whose SOL branch does not export its decomposition yet
2670    /// must error loudly (never silently chart a wrong breakdown).
2671    #[test]
2672    fn evaluate_ops_sol_json_rejects_unexported_families() {
2673        let engine = build_engine(None);
2674        let ops = vec![Op::Mamba2(crate::operators::Mamba2Op {
2675            name: "mamba2".into(),
2676            scale_factor: 1.0,
2677            kernel_source: "causal_conv1d_fn".into(),
2678            phase: "context".into(),
2679            d_model: 4096,
2680            d_state: 128,
2681            d_conv: 4,
2682            nheads: 128,
2683            head_dim: 64,
2684            n_groups: 8,
2685            chunk_size: 256,
2686        })];
2687        let ops_json = serde_json::to_string(&ops).unwrap();
2688        let err = engine
2689            .evaluate_ops_sol_json(&ops_json, true, 1, 128, 0, 1.0, None)
2690            .unwrap_err();
2691        assert!(matches!(&err, AicError::SolNotImplemented(_)));
2692        assert!(
2693            err.to_string().contains("no SOL decomposition"),
2694            "unexpected error: {err}"
2695        );
2696    }
2697}