Skip to main content

aisimulate_core/perfmodel/engine/
spec.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `EngineSpec`: the serializable engine wire format.
5//!
6//! `EngineSpec` is what Python's `compile_engine` emits and what the
7//! Rust `Engine` consumes. It bundles the engine identity
8//! ([`EngineConfig`], needed later to load the matching [`PerfDatabase`]) with
9//! the precompiled context / generation op lists. Each op is an
10//! [`OpSpec`] — a public alias for the crate's [`Op`] enum — and the lists
11//! round-trip through bincode, including the recursive `Overlap` / `Fallback`
12//! children.
13//!
14//! ## Vision is never on the wire
15//!
16//! [`Op::Vision`] derives serde with every other variant (it remains part of
17//! the shared session path), but a compiled `EngineSpec` never
18//! contains a `Vision` op: `compile_engine` decomposes the vision encoder
19//! into its child `Gemm` / `EncoderAttention` / `Elementwise` ops, each an
20//! existing variant. The type round-trips soundly (see the test below); the
21//! constraint is purely a producer-side rule, not enforced by the enum.
22//!
23//! [`PerfDatabase`]: crate::perf_database::PerfDatabase
24
25use serde::{Deserialize, Serialize};
26
27use crate::ENGINE_SPEC_SCHEMA_VERSION;
28use crate::common::error::AicError;
29use crate::perfmodel::EngineConfig;
30
31/// Public name for the serializable op. Aliases the crate's [`Op`] enum so
32/// the "OpSpec" surface exists without duplicating the definition.
33pub use crate::operators::op::Op as OpSpec;
34
35/// Serializable compiled engine.
36///
37/// `schema_version` guards forward/backward compatibility; `engine` carries
38/// the identity used to load the matching perf database; `context_ops` and
39/// `generation_ops` are the precompiled op lists the runner iterates.
40///
41/// `EngineSpec` derives serde so JSON / other self-describing formats work
42/// directly. The **bincode** wire format, however, must go through
43/// [`EngineSpec::to_bincode`] / [`EngineSpec::from_bincode`], NOT
44/// `bincode::serialize(&spec)` directly: [`EngineConfig`] uses
45/// `#[serde(flatten)]` (load-bearing for the flat ctypes FFI contract), and
46/// bincode 1.x cannot serialize a flattened struct (it emits a map of unknown
47/// length → `SequenceMustHaveLength`). The helpers sidestep this by
48/// JSON-encoding the `engine` field inside the bincode payload, keeping
49/// `EngineConfig` the single source of truth (no mirror struct, no drift).
50#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
51pub struct EngineSpec {
52    pub schema_version: u32,
53    /// Engine identity (model / system / backend / parallelism / quant).
54    /// Needed to locate and load the `PerfDatabase`.
55    pub engine: EngineConfig,
56    /// Context-phase ops, in execution order. Never contains `OpSpec::Vision`
57    /// (decomposed into child ops at compile time).
58    pub context_ops: Vec<OpSpec>,
59    /// Generation-phase ops, in execution order.
60    pub generation_ops: Vec<OpSpec>,
61}
62
63/// Private bincode payload. `engine` is carried as a JSON string so the
64/// `#[serde(flatten)]` on [`EngineConfig`] never reaches the bincode
65/// serializer (which rejects unknown-length maps). The op lists are plain
66/// `Vec<OpSpec>` (no flatten) and bincode-serialize directly.
67#[derive(Serialize, Deserialize)]
68struct BincodeWire {
69    schema_version: u32,
70    engine_json: String,
71    context_ops: Vec<OpSpec>,
72    generation_ops: Vec<OpSpec>,
73}
74
75impl EngineSpec {
76    /// Build a spec, stamping the current [`ENGINE_SPEC_SCHEMA_VERSION`].
77    pub fn new(
78        engine: EngineConfig,
79        context_ops: Vec<OpSpec>,
80        generation_ops: Vec<OpSpec>,
81    ) -> Self {
82        Self {
83            schema_version: ENGINE_SPEC_SCHEMA_VERSION,
84            engine,
85            context_ops,
86            generation_ops,
87        }
88    }
89
90    /// Serialize to the bincode wire format. The `engine` field is
91    /// JSON-encoded inside the payload (see the struct docs) so bincode never
92    /// sees `EngineConfig`'s flattened layout.
93    pub fn to_bincode(&self) -> Result<Vec<u8>, AicError> {
94        let engine_json = serde_json::to_string(&self.engine)
95            .map_err(|e| AicError::EngineSpec(format!("engine JSON encode: {e}")))?;
96        let wire = BincodeWire {
97            schema_version: self.schema_version,
98            engine_json,
99            context_ops: self.context_ops.clone(),
100            generation_ops: self.generation_ops.clone(),
101        };
102        bincode::serialize(&wire).map_err(|e| AicError::EngineSpec(format!("bincode encode: {e}")))
103    }
104
105    /// Deserialize from the bincode wire format produced by [`Self::to_bincode`].
106    ///
107    /// The `schema_version` prefix is read and validated **before** the
108    /// variable-layout op payloads are decoded. bincode is not self-describing,
109    /// so a producer/consumer op-layout skew (e.g. a newer producer that added
110    /// serialized fields to an `OpSpec`) would otherwise fail deep inside the
111    /// payload with a generic `bincode decode: io error`, masking the real
112    /// cause. Reading the leading version first lets a version mismatch surface
113    /// as a clear [`AicError::UnsupportedSchemaVersion`] instead.
114    pub fn from_bincode(bytes: &[u8]) -> Result<Self, AicError> {
115        // `schema_version` is the first field of `BincodeWire`, so it is the
116        // first value in the byte stream. Decode just it and gate on it before
117        // touching the op lists (which is where a layout skew would fail).
118        let mut cursor = std::io::Cursor::new(bytes);
119        let schema_version: u32 = bincode::deserialize_from(&mut cursor).map_err(|e| {
120            AicError::EngineSpec(format!(
121                "bincode decode of the leading schema_version prefix failed \
122                 (payload is {} bytes; too short or not an EngineSpec wire buffer): {e}",
123                bytes.len()
124            ))
125        })?;
126        if schema_version != ENGINE_SPEC_SCHEMA_VERSION {
127            return Err(AicError::UnsupportedSchemaVersion {
128                kind: "EngineSpec",
129                got: schema_version,
130                expected: ENGINE_SPEC_SCHEMA_VERSION,
131            });
132        }
133        let wire: BincodeWire = bincode::deserialize(bytes).map_err(|e| {
134            AicError::EngineSpec(format!(
135                "bincode decode of the op payloads failed at matching \
136                 schema_version {schema_version} — this indicates op-layout drift \
137                 within the same version (an OpSpec changed without a \
138                 ENGINE_SPEC_SCHEMA_VERSION bump) or a corrupt payload, not a \
139                 version skew: {e}"
140            ))
141        })?;
142        let engine: EngineConfig = serde_json::from_str(&wire.engine_json).map_err(|e| {
143            AicError::EngineSpec(format!(
144                "engine JSON decode failed at schema_version {schema_version}: {e}"
145            ))
146        })?;
147        Ok(Self {
148            schema_version: wire.schema_version,
149            engine,
150            context_ops: wire.context_ops,
151            generation_ops: wire.generation_ops,
152        })
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use std::collections::BTreeMap;
160
161    use crate::common::enums::{
162        BackendKind, CommQuantMode, FmhaQuantMode, GemmQuantMode, KvCacheQuantMode, MoeQuantMode,
163    };
164    use crate::operators::moe_dispatch::DispatchFlavor;
165    use crate::operators::op::{FallbackOp, OverlapOp};
166    use crate::operators::{
167        ContextAttentionOp, ContextMlaOp, CustomAllReduceOp, DsaModuleOp, Dsv4MegaMoeOp,
168        Dsv4ModuleOp, ElementwiseOp, EmbeddingOp, EncoderAttentionOp, GdnOp, GemmOp,
169        GenerationAttentionOp, GenerationMlaOp, KdaOp, Mamba2Op, MhcModuleOp, MlaBmmOp,
170        MlaModuleOp, MoEDispatchOp, MoeAllToAllOp, MoeExpertComputeOp, MoeOp, NcclOp, P2POp,
171        VisionEncoderOp, WideEpContextMlaOp, WideEpGenerationMlaOp,
172    };
173    use crate::perf_database::dsv4::AttnKind;
174    use crate::{
175        DataType, ENGINE_CONFIG_SCHEMA_VERSION, ParallelMapping, QuantizationConfig,
176        SpeculativeConfig,
177    };
178
179    // ---- Representative-value builders for each Op variant ----
180
181    fn gemm() -> GemmOp {
182        GemmOp {
183            name: "qkv_gemm".into(),
184            scale_factor: 2.0,
185            n: 4096,
186            k: 4096,
187            quant_mode: GemmQuantMode::Fp8,
188            scale_num_tokens: 0,
189            low_precision_input: true,
190            seq_split: 1,
191            below_grid_sol: false,
192        }
193    }
194
195    fn embedding() -> EmbeddingOp {
196        EmbeddingOp {
197            name: "embedding".into(),
198            scale_factor: 1.0,
199            vocab_size: 128_256,
200            hidden_size: 4096,
201            quant_mode: GemmQuantMode::Bfloat16,
202            seq_split: 1,
203        }
204    }
205
206    fn elementwise() -> ElementwiseOp {
207        ElementwiseOp {
208            name: "rmsnorm".into(),
209            scale_factor: 1.5,
210            bytes_per_token: 8192.0,
211            scale_num_tokens: 1,
212            seq_split: 1,
213        }
214    }
215
216    fn context_attention() -> ContextAttentionOp {
217        ContextAttentionOp {
218            name: "context_attention".into(),
219            scale_factor: 1.0,
220            n: 32,
221            n_kv: 8,
222            head_size: 128,
223            window_size: 0,
224            kv_cache_dtype: KvCacheQuantMode::Fp8,
225            fmha_quant_mode: FmhaQuantMode::Bfloat16,
226            use_qk_norm: true,
227            cp_size: 1,
228            // Multi-entry so the round-trip proves the whole Vec<String>
229            // survives, not just a single-element degenerate case.
230            lane_order: vec!["trtllm_mha".into(), "flashinfer".into(), "default".into()],
231        }
232    }
233
234    fn generation_attention() -> GenerationAttentionOp {
235        GenerationAttentionOp {
236            name: "generation_attention".into(),
237            scale_factor: 1.0,
238            n: 32,
239            n_kv: 8,
240            head_size: 128,
241            window_size: 4096,
242            kv_cache_dtype: KvCacheQuantMode::Int8,
243            lane_order: vec!["triton".into(), "trtllm_mha".into(), "default".into()],
244        }
245    }
246
247    fn encoder_attention() -> EncoderAttentionOp {
248        EncoderAttentionOp {
249            name: "encoder_attention".into(),
250            scale_factor: 1.0,
251            n: 16,
252            head_size: 80,
253            fmha_quant_mode: FmhaQuantMode::Fp8,
254            partial_rotary_factor: 0.0,
255        }
256    }
257
258    fn context_mla() -> ContextMlaOp {
259        ContextMlaOp {
260            name: "context_mla".into(),
261            scale_factor: 1.0,
262            num_heads: 128,
263            kv_cache_dtype: KvCacheQuantMode::Bfloat16,
264            fmha_quant_mode: FmhaQuantMode::Bfloat16,
265            cp_size: 1,
266        }
267    }
268
269    fn generation_mla() -> GenerationMlaOp {
270        GenerationMlaOp {
271            name: "generation_mla".into(),
272            scale_factor: 1.0,
273            num_heads: 128,
274            kv_cache_dtype: KvCacheQuantMode::Fp8,
275        }
276    }
277
278    fn mla_module() -> MlaModuleOp {
279        MlaModuleOp {
280            name: "context_mla_module".into(),
281            scale_factor: 1.0,
282            num_heads: 128,
283            kv_cache_dtype: KvCacheQuantMode::Fp8,
284            fmha_quant_mode: FmhaQuantMode::Fp8,
285            gemm_quant_mode: GemmQuantMode::Fp8Block,
286            native_num_heads: Some(128),
287        }
288    }
289
290    fn mla_bmm() -> MlaBmmOp {
291        MlaBmmOp {
292            name: "mla_bmm_pre".into(),
293            scale_factor: 1.0,
294            num_heads: 128,
295            quant_mode: GemmQuantMode::Bfloat16,
296            is_pre: true,
297        }
298    }
299
300    fn moe() -> MoeOp {
301        MoeOp {
302            name: "moe".into(),
303            scale_factor: 1.0,
304            hidden_size: 7168,
305            inter_size: 2048,
306            topk: 8,
307            num_experts: 256,
308            moe_tp_size: 1,
309            moe_ep_size: 8,
310            attention_dp_size: 1,
311            quant_mode: MoeQuantMode::Fp8Block,
312            workload_distribution: "power_law_1.2".into(),
313            is_gated: true,
314            moe_backend: None,
315            enable_eplb: false,
316            is_context: false,
317        }
318    }
319
320    fn moe_dispatch() -> MoEDispatchOp {
321        MoEDispatchOp {
322            name: "moe_dispatch".into(),
323            scale_factor: 1.0,
324            hidden_size: 7168,
325            topk: 8,
326            num_experts: 256,
327            moe_tp_size: 1,
328            moe_ep_size: 8,
329            attention_dp_size: 8,
330            pre_dispatch: true,
331            backend: BackendKind::Trtllm,
332            flavor: DispatchFlavor::TrtllmAlltoall,
333            comm_quant: CommQuantMode::Half,
334            moe_quant: MoeQuantMode::Fp8Block,
335            attn_cp_size: 1,
336            is_context: false,
337            sms: 12,
338            scale_num_tokens: 1,
339            attn_ar_modeled: false,
340        }
341    }
342
343    fn custom_all_reduce() -> CustomAllReduceOp {
344        CustomAllReduceOp {
345            name: "custom_all_reduce".into(),
346            scale_factor: 1.0,
347            hidden_size: 4096,
348            tp_size: 8,
349            quant: CommQuantMode::Half,
350            seq_split: 1,
351        }
352    }
353
354    fn nccl() -> NcclOp {
355        NcclOp {
356            name: "nccl_all_reduce".into(),
357            scale_factor: 1.0,
358            hidden_size: 4096.0,
359            num_gpus: 8,
360            dtype: CommQuantMode::Half,
361            operation: "all_reduce".into(),
362            seq_split: 1,
363        }
364    }
365
366    fn p2p() -> P2POp {
367        P2POp {
368            name: "p2p".into(),
369            scale_factor: 1.0,
370            pp_size: 4,
371            hidden_size: 4096,
372            seq_split: 1,
373        }
374    }
375
376    fn vision() -> VisionEncoderOp {
377        VisionEncoderOp {
378            name: "vision_encoder".into(),
379            scale_factor: 1.0,
380            num_layers: 24,
381            num_heads: 16,
382            head_size: 80,
383            hidden_size: 1280,
384            intermediate_size: 5120,
385            fmha_quant: FmhaQuantMode::Bfloat16,
386            gemm_quant: GemmQuantMode::Bfloat16,
387        }
388    }
389
390    fn dsa_module() -> DsaModuleOp {
391        DsaModuleOp {
392            name: "dsa_module".into(),
393            scale_factor: 1.0,
394            num_heads: 128,
395            kv_cache_dtype: KvCacheQuantMode::Fp8,
396            fmha_quant_mode: FmhaQuantMode::Fp8,
397            gemm_quant_mode: GemmQuantMode::Fp8Block,
398            architecture: "DeepseekV32ForCausalLM".into(),
399            index_topk: 2048,
400            cp_size: 1,
401            full_frac: 1.0,
402            attn_projection_quant_modes: None,
403        }
404    }
405
406    fn msa_module() -> crate::operators::MsaModuleOp {
407        crate::operators::MsaModuleOp {
408            name: "context_attention".into(),
409            scale_factor: 62.0,
410            num_heads: 8,
411            num_kv_heads: 1,
412            hidden_size: 7168,
413            head_dim: 128,
414            v_head_dim: 128,
415            index_n_heads: 64,
416            index_head_dim: 128,
417            index_topk: 2048,
418            block_size: 64,
419            kv_cache_dtype: KvCacheQuantMode::Bfloat16,
420            fmha_quant_mode: FmhaQuantMode::Bfloat16,
421            gemm_quant_mode: GemmQuantMode::Fp8Block,
422            dsa_architecture: "GlmMoeDsaForCausalLM".into(),
423            dsa_scale_k: 1.0,
424        }
425    }
426
427    fn dsv4_module() -> Dsv4ModuleOp {
428        Dsv4ModuleOp {
429            name: "dsv4_module".into(),
430            scale_factor: 1.0,
431            attn_kind: AttnKind::Hca,
432            num_heads: 128,
433            native_heads: 128,
434            tp_size: 1,
435            kv_cache_dtype: KvCacheQuantMode::Fp8,
436            fmha_quant_mode: FmhaQuantMode::Fp8,
437            gemm_quant_mode: GemmQuantMode::Fp8Block,
438            architecture: "DeepseekV4ForCausalLM".into(),
439            cp_size: 1,
440            window_size: None,
441            hidden_size: 7168,
442            q_lora_rank: 1536,
443            o_lora_rank: 1024,
444            head_dim: 512,
445            rope_head_dim: 64,
446            index_n_heads: 64,
447            index_head_dim: 128,
448            index_topk: 1024,
449            o_groups: Some(16),
450        }
451    }
452
453    fn dsv4_megamoe() -> Dsv4MegaMoeOp {
454        Dsv4MegaMoeOp {
455            name: "context_megamoe".into(),
456            scale_factor: 61.0,
457            hidden_size: 7168,
458            inter_size: 3072,
459            topk: 6,
460            num_experts: 384,
461            moe_tp_size: 1,
462            moe_ep_size: 8,
463            quant_mode: MoeQuantMode::W4a8Mxfp4Mxfp8,
464            workload_distribution: "balanced".into(),
465            is_context: true,
466            source_policy: "random".into(),
467            pre_dispatch: "sglang_jit".into(),
468            num_fused_shared_experts: 0,
469            kernel_source: "deepgemm_megamoe".into(),
470            kernel_dtype: "fp8_fp4".into(),
471        }
472    }
473
474    fn mhc() -> MhcModuleOp {
475        MhcModuleOp {
476            name: "mhc_module".into(),
477            scale_factor: 1.0,
478            op: "pre".into(),
479            hc_mult: 4,
480            hidden_size: 7168,
481            architecture: "DeepseekV4ForCausalLM".into(),
482            sinkhorn_iters: 20,
483            quant_mode: GemmQuantMode::Bfloat16,
484            seq_split: 1,
485        }
486    }
487
488    fn mamba2() -> Mamba2Op {
489        Mamba2Op {
490            name: "mamba2".into(),
491            scale_factor: 1.0,
492            kernel_source: "mamba_chunk_scan".into(),
493            phase: "context".into(),
494            d_model: 4096,
495            d_state: 128,
496            d_conv: 4,
497            nheads: 128,
498            head_dim: 64,
499            n_groups: 8,
500            chunk_size: 256,
501        }
502    }
503
504    fn gdn() -> GdnOp {
505        GdnOp {
506            name: "gdn".into(),
507            scale_factor: 1.0,
508            kernel_source: "gdn_kernel".into(),
509            phase: "generation".into(),
510            d_model: 4096,
511            d_conv: 4,
512            num_k_heads: 16,
513            head_k_dim: 128,
514            num_v_heads: 32,
515            head_v_dim: 128,
516            // Non-default value so the round-trip notices a
517            // `#[serde(default)]` swallowing the carried field.
518            mamba_ssm_dtype: "bfloat16".into(),
519        }
520    }
521
522    fn kda() -> KdaOp {
523        KdaOp {
524            name: "kda".into(),
525            scale_factor: 1.0,
526            kernel_source: "fused_sigmoid_gating_delta_rule_update".into(),
527            phase: "verify".into(),
528            d_model: 7168,
529            d_conv: 4,
530            num_k_heads: 16,
531            head_k_dim: 128,
532            num_v_heads: 16,
533            head_v_dim: 128,
534            draft_tokens: 4,
535        }
536    }
537
538    fn wideep_context_mla() -> WideEpContextMlaOp {
539        WideEpContextMlaOp {
540            name: "wideep_context_mla".into(),
541            scale_factor: 1.0,
542            num_heads: 128,
543            kv_cache_dtype: KvCacheQuantMode::Fp8,
544            fmha_quant_mode: FmhaQuantMode::Fp8,
545            attn_backend: "flashinfer".into(),
546            cp_size: 1,
547        }
548    }
549
550    fn wideep_generation_mla() -> WideEpGenerationMlaOp {
551        WideEpGenerationMlaOp {
552            name: "wideep_generation_mla".into(),
553            scale_factor: 1.0,
554            num_heads: 128,
555            kv_cache_dtype: KvCacheQuantMode::Fp8,
556            fmha_quant_mode: FmhaQuantMode::Fp8,
557            attn_backend: "flashinfer".into(),
558        }
559    }
560
561    /// Large-EP comm phase with every optional field set to a NON-default
562    /// value, so the round-trip would notice a `#[serde(default)]` swallowing
563    /// a carried field.
564    fn moe_all_to_all() -> MoeAllToAllOp {
565        MoeAllToAllOp {
566            name: "moe_dispatch".into(),
567            scale_factor: 61.0,
568            phase: "dispatch".into(),
569            comm_backend: "deepep_ht".into(),
570            comm_dtype: "fp8_block".into(),
571            hidden_size: 7168,
572            topk: 8,
573            num_experts: 256,
574            moe_ep_size: 16,
575            node_num: 2,
576            sms: 24,
577            attention_tp_size: 2,
578        }
579    }
580
581    /// Large-EP expert compute. `num_slots` / `kernel_source` are `Some(...)`
582    /// here on purpose — the `None` (Python-default) case is what production
583    /// emits, and both encodings must survive the wire.
584    fn moe_expert_compute() -> MoeExpertComputeOp {
585        MoeExpertComputeOp {
586            name: "moe".into(),
587            scale_factor: 61.0,
588            hidden_size: 7168,
589            inter_size: 2048,
590            topk: 8,
591            num_experts: 256,
592            moe_ep_size: 16,
593            quant_mode: MoeQuantMode::Fp8Block,
594            workload_distribution: "power_law_1.2".into(),
595            attention_dp_size: 8,
596            inference_phase: "context".into(),
597            num_slots: Some(288),
598            kernel_source: Some("deepep_moe".into()),
599            is_gated: true,
600            enable_eplb: true,
601        }
602    }
603
604    fn overlap() -> OverlapOp {
605        // Recursive: nested children on both groups.
606        OverlapOp {
607            name: "overlap_attn_moe".into(),
608            group_a: vec![OpSpec::ContextMla(context_mla()), OpSpec::Gemm(gemm())],
609            group_b: vec![OpSpec::Moe(moe()), OpSpec::MoeDispatch(moe_dispatch())],
610        }
611    }
612
613    fn fpm_forward() -> crate::operators::FpmForwardOp {
614        // Recursive like Overlap/Fallback: sol_ops carries the model's
615        // original granular list, so the round-trip must preserve nesting.
616        crate::operators::FpmForwardOp {
617            name: "fpm_forward_prefill".into(),
618            phase: crate::operators::FpmPhase::Prefill,
619            model_path: "org/model-a".into(),
620            match_identity: vec![
621                "nvfp4".into(),
622                "nvfp4".into(),
623                "bfloat16".into(),
624                "half".into(),
625                "fp8".into(),
626                "4".into(),
627                "1".into(),
628                "1".into(),
629                "4".into(),
630                "1".into(),
631                "1".into(),
632            ],
633            weight_bytes: 1.5e10,
634            sol_ops: vec![
635                OpSpec::Gemm(gemm()),
636                OpSpec::ContextAttention(context_attention()),
637            ],
638        }
639    }
640
641    fn fallback() -> FallbackOp {
642        // Recursive: a primary module op with a granular per-kernel fallback
643        // chain that itself contains a nested Overlap.
644        FallbackOp {
645            name: "mla_fallback".into(),
646            primary: Box::new(OpSpec::MlaModuleContext(mla_module())),
647            fallback: vec![
648                OpSpec::Gemm(gemm()),
649                OpSpec::ContextMla(context_mla()),
650                OpSpec::Overlap(overlap()),
651            ],
652        }
653    }
654
655    /// Every `Op` variant, constructed once. The exhaustive `match` below the
656    /// `Vec` build forces the compiler to flag any newly added variant that
657    /// this round-trip suite forgot to cover.
658    fn all_op_variants() -> Vec<OpSpec> {
659        let ops = vec![
660            OpSpec::Gemm(gemm()),
661            OpSpec::Embedding(embedding()),
662            OpSpec::Elementwise(elementwise()),
663            OpSpec::ContextAttention(context_attention()),
664            OpSpec::GenerationAttention(generation_attention()),
665            OpSpec::EncoderAttention(encoder_attention()),
666            OpSpec::ContextMla(context_mla()),
667            OpSpec::GenerationMla(generation_mla()),
668            OpSpec::MlaModuleContext(mla_module()),
669            OpSpec::MlaModuleGeneration(mla_module()),
670            OpSpec::MlaBmm(mla_bmm()),
671            OpSpec::Moe(moe()),
672            OpSpec::MoeDispatch(moe_dispatch()),
673            OpSpec::CustomAllReduce(custom_all_reduce()),
674            OpSpec::Nccl(nccl()),
675            OpSpec::P2P(p2p()),
676            OpSpec::Vision(vision()),
677            OpSpec::DsaContext(dsa_module()),
678            OpSpec::DsaGeneration(dsa_module()),
679            OpSpec::MsaContext(msa_module()),
680            OpSpec::MsaGeneration(msa_module()),
681            OpSpec::Dsv4Context(dsv4_module()),
682            OpSpec::Dsv4Generation(dsv4_module()),
683            OpSpec::Mhc(mhc()),
684            OpSpec::Mamba2(mamba2()),
685            OpSpec::Gdn(gdn()),
686            OpSpec::WideEpContextMla(wideep_context_mla()),
687            OpSpec::WideEpGenerationMla(wideep_generation_mla()),
688            OpSpec::Overlap(overlap()),
689            OpSpec::Fallback(fallback()),
690            // Appended AFTER Fallback (bincode enum indices are positional;
691            // appending shifts nothing, so no ENGINE_SPEC_SCHEMA_VERSION bump).
692            OpSpec::Dsv4MegaMoe(dsv4_megamoe()),
693            // Appended in wire order: Kda, FpmForward, then this PR's
694            // large-EP pair.
695            OpSpec::Kda(kda()),
696            OpSpec::FpmForward(fpm_forward()),
697            OpSpec::MoeAllToAll(moe_all_to_all()),
698            OpSpec::MoeExpertCompute(moe_expert_compute()),
699        ];
700
701        // Exhaustiveness guard: if a variant is added to `Op`, this match
702        // fails to compile until it is also added to `all_op_variants`.
703        for op in &ops {
704            match op {
705                OpSpec::Gemm(_)
706                | OpSpec::Embedding(_)
707                | OpSpec::Elementwise(_)
708                | OpSpec::ContextAttention(_)
709                | OpSpec::GenerationAttention(_)
710                | OpSpec::EncoderAttention(_)
711                | OpSpec::ContextMla(_)
712                | OpSpec::GenerationMla(_)
713                | OpSpec::MlaModuleContext(_)
714                | OpSpec::MlaModuleGeneration(_)
715                | OpSpec::MlaBmm(_)
716                | OpSpec::Moe(_)
717                | OpSpec::MoeDispatch(_)
718                | OpSpec::CustomAllReduce(_)
719                | OpSpec::Nccl(_)
720                | OpSpec::P2P(_)
721                | OpSpec::Vision(_)
722                | OpSpec::DsaContext(_)
723                | OpSpec::DsaGeneration(_)
724                | OpSpec::MsaContext(_)
725                | OpSpec::MsaGeneration(_)
726                | OpSpec::Dsv4Context(_)
727                | OpSpec::Dsv4Generation(_)
728                | OpSpec::Mhc(_)
729                | OpSpec::Mamba2(_)
730                | OpSpec::Gdn(_)
731                | OpSpec::WideEpContextMla(_)
732                | OpSpec::WideEpGenerationMla(_)
733                | OpSpec::FpmForward(_)
734                | OpSpec::Overlap(_)
735                | OpSpec::Fallback(_)
736                | OpSpec::Dsv4MegaMoe(_)
737                | OpSpec::Kda(_)
738                | OpSpec::MoeAllToAll(_)
739                | OpSpec::MoeExpertCompute(_) => {}
740            }
741        }
742        ops
743    }
744
745    fn sample_engine_config() -> EngineConfig {
746        EngineConfig {
747            schema_version: ENGINE_CONFIG_SCHEMA_VERSION,
748            model_name: "deepseek-ai/DeepSeek-V3".into(),
749            system_name: "h200_sxm".into(),
750            systems_path: None,
751            backend: crate::BackendKind::Trtllm,
752            backend_version: Some("1.0.0rc3".into()),
753            forward_model: None,
754            kv_block_size: Some(64),
755            parallel: ParallelMapping {
756                tp_size: 8,
757                pp_size: 1,
758                attention_dp_size: Some(8),
759                moe_tp_size: Some(1),
760                moe_ep_size: Some(8),
761                cp_size: None,
762            },
763            quantization: QuantizationConfig {
764                weight_dtype: Some(DataType::Fp8),
765                moe_dtype: Some(DataType::Fp8),
766                activation_dtype: Some(DataType::Fp8),
767                kv_cache_dtype: Some(DataType::Fp8),
768            },
769            speculative: Some(SpeculativeConfig { nextn: Some(1) }),
770            enable_shared_layer: None,
771            strict_provenance: false,
772            database_mode: Default::default(),
773            tolerate_dirless_version: false,
774            transfer_policy: None,
775            extra: BTreeMap::new(),
776        }
777    }
778
779    /// Pin the bincode POSITIONAL variant index of the first and the two last
780    /// `Op` variants. bincode encodes an enum as a leading 4-byte LE variant
781    /// index, so inserting or removing a variant mid-enum silently reinterprets
782    /// every later variant on the wire.
783    ///
784    /// These pin bincode positional indices; if this test fails you reordered/
785    /// inserted mid-enum — append instead, or bump ENGINE_SPEC_SCHEMA_VERSION
786    /// in lockstep (config.rs + engine.py).
787    #[test]
788    fn op_variant_indices_are_pinned() {
789        const GEMM_INDEX: u32 = 0;
790        // Re-derived after current main's Kda and FpmForward tail variants,
791        // and after retiring the two mid-enum wideEP MoE variants.
792        const MOE_ALL_TO_ALL_INDEX: u32 = 33;
793        const MOE_EXPERT_COMPUTE_INDEX: u32 = 34;
794
795        let index_of = |op: &OpSpec| -> u32 {
796            let bytes = bincode::serialize(op).expect("serialize op");
797            u32::from_le_bytes(bytes[..4].try_into().expect("4-byte variant index prefix"))
798        };
799
800        assert_eq!(
801            index_of(&OpSpec::Gemm(gemm())),
802            GEMM_INDEX,
803            "first variant moved"
804        );
805        assert_eq!(
806            index_of(&OpSpec::MoeAllToAll(moe_all_to_all())),
807            MOE_ALL_TO_ALL_INDEX,
808            "MoeAllToAll index moved"
809        );
810        assert_eq!(
811            index_of(&OpSpec::MoeExpertCompute(moe_expert_compute())),
812            MOE_EXPERT_COMPUTE_INDEX,
813            "MoeExpertCompute index moved"
814        );
815
816        // The two last variants must stay adjacent and terminal: appending is
817        // the only safe growth direction.
818        assert_eq!(MOE_EXPERT_COMPUTE_INDEX, MOE_ALL_TO_ALL_INDEX + 1);
819        assert_eq!(
820            MOE_EXPERT_COMPUTE_INDEX as usize + 1,
821            all_op_variants().len(),
822            "all_op_variants() must cover exactly the pinned variant count"
823        );
824    }
825
826    #[test]
827    fn every_op_variant_round_trips_through_bincode() {
828        for op in all_op_variants() {
829            let bytes = bincode::serialize(&op).expect("serialize op");
830            let decoded: OpSpec = bincode::deserialize(&bytes).expect("deserialize op");
831            assert_eq!(op, decoded, "round-trip mismatch for {:?}", op);
832        }
833    }
834
835    #[test]
836    fn recursive_overlap_round_trips_with_nested_children() {
837        let op = OpSpec::Overlap(overlap());
838        let bytes = bincode::serialize(&op).unwrap();
839        let decoded: OpSpec = bincode::deserialize(&bytes).unwrap();
840        assert_eq!(op, decoded);
841    }
842
843    #[test]
844    fn recursive_fallback_round_trips_with_nested_children() {
845        let op = OpSpec::Fallback(fallback());
846        let bytes = bincode::serialize(&op).unwrap();
847        let decoded: OpSpec = bincode::deserialize(&bytes).unwrap();
848        assert_eq!(op, decoded);
849    }
850
851    #[test]
852    fn mla_module_none_native_round_trips_followed_by_another_op() {
853        // native_num_heads=None must still be serialized (no skip_serializing_if):
854        // bincode decodes positionally, so an omitted Option would desync the
855        // ops decoded after it (#1458 review).
856        let mut none_native = mla_module();
857        none_native.native_num_heads = None;
858        let spec = EngineSpec::new(
859            sample_engine_config(),
860            vec![OpSpec::MlaModuleContext(none_native), OpSpec::Gemm(gemm())],
861            vec![
862                OpSpec::MlaModuleGeneration(mla_module()),
863                OpSpec::Moe(moe()),
864            ],
865        );
866        let bytes = spec.to_bincode().expect("to_bincode");
867        let decoded = EngineSpec::from_bincode(&bytes).expect("from_bincode");
868        assert_eq!(spec, decoded);
869    }
870
871    #[test]
872    fn engine_spec_round_trips_through_bincode() {
873        let spec = EngineSpec::new(
874            sample_engine_config(),
875            vec![
876                OpSpec::Embedding(embedding()),
877                OpSpec::ContextAttention(context_attention()),
878                OpSpec::Overlap(overlap()),
879                OpSpec::Gemm(gemm()),
880            ],
881            vec![
882                OpSpec::GenerationAttention(generation_attention()),
883                OpSpec::Fallback(fallback()),
884                OpSpec::Moe(moe()),
885            ],
886        );
887
888        assert_eq!(spec.schema_version, ENGINE_SPEC_SCHEMA_VERSION);
889
890        let bytes = spec.to_bincode().expect("to_bincode");
891        let decoded = EngineSpec::from_bincode(&bytes).expect("from_bincode");
892        assert_eq!(spec, decoded);
893    }
894
895    /// A version skew combined with an op-layout change must surface as a clear
896    /// [`AicError::UnsupportedSchemaVersion`], NOT a generic bincode I/O error.
897    ///
898    /// This reproduces the cross-version failure mode: a producer at a different
899    /// schema version emits op payloads whose layout the consumer cannot decode.
900    /// We simulate it by stamping a foreign version into the leading prefix and
901    /// truncating the op payload. `from_bincode` must read + reject the version
902    /// *before* it attempts to decode the (now-undecodable) op lists.
903    #[test]
904    fn version_skew_reports_unsupported_before_payload_decode() {
905        let spec = EngineSpec::new(
906            sample_engine_config(),
907            vec![
908                OpSpec::Gemm(gemm()),
909                OpSpec::ContextAttention(context_attention()),
910            ],
911            vec![OpSpec::GenerationAttention(generation_attention())],
912        );
913        let mut bytes = spec.to_bincode().expect("to_bincode");
914
915        // Overwrite the 4-byte little-endian `schema_version` prefix with a
916        // version this consumer does not speak.
917        let foreign = ENGINE_SPEC_SCHEMA_VERSION + 1;
918        bytes[..4].copy_from_slice(&foreign.to_le_bytes());
919        // Corrupt the op payload so a decode-first implementation fails there
920        // with a generic bincode I/O error instead of reaching the version gate.
921        bytes.truncate(bytes.len() - 8);
922
923        match EngineSpec::from_bincode(&bytes) {
924            Err(AicError::UnsupportedSchemaVersion {
925                kind,
926                got,
927                expected,
928            }) => {
929                assert_eq!(kind, "EngineSpec");
930                assert_eq!(got, foreign);
931                assert_eq!(expected, ENGINE_SPEC_SCHEMA_VERSION);
932            }
933            other => {
934                panic!("expected UnsupportedSchemaVersion before payload decode, got {other:?}")
935            }
936        }
937    }
938
939    /// Canonical valid spec used by the handshake tests below.
940    fn handshake_spec() -> EngineSpec {
941        EngineSpec::new(
942            sample_engine_config(),
943            vec![
944                OpSpec::Gemm(gemm()),
945                OpSpec::ContextAttention(context_attention()),
946            ],
947            vec![OpSpec::GenerationAttention(generation_attention())],
948        )
949    }
950
951    /// Round-trip preserves the stamped schema version end to end.
952    #[test]
953    fn from_bincode_round_trips_and_preserves_version() {
954        let spec = handshake_spec();
955        let decoded = EngineSpec::from_bincode(&spec.to_bincode().expect("to_bincode"))
956            .expect("from_bincode");
957        assert_eq!(decoded, spec);
958        assert_eq!(decoded.schema_version, ENGINE_SPEC_SCHEMA_VERSION);
959    }
960
961    /// A buffer too short to even hold the 4-byte version prefix must fail at the
962    /// prefix stage, not deep in the (absent) payload.
963    #[test]
964    fn from_bincode_rejects_empty_buffer() {
965        match EngineSpec::from_bincode(&[]) {
966            Err(AicError::EngineSpec(msg)) => {
967                assert!(
968                    msg.contains("schema_version prefix"),
969                    "message should name the prefix stage, got: {msg}"
970                );
971            }
972            other => panic!("expected EngineSpec prefix error, got {other:?}"),
973        }
974    }
975
976    /// The version gate fires even when the op payload is fully intact — the
977    /// rejection is driven by the version alone, not by a decode failure.
978    #[test]
979    fn from_bincode_version_gate_fires_with_intact_payload() {
980        let mut bytes = handshake_spec().to_bincode().expect("to_bincode");
981        let foreign = ENGINE_SPEC_SCHEMA_VERSION + 7;
982        bytes[..4].copy_from_slice(&foreign.to_le_bytes()); // only the prefix changes
983
984        match EngineSpec::from_bincode(&bytes) {
985            Err(AicError::UnsupportedSchemaVersion {
986                kind,
987                got,
988                expected,
989            }) => {
990                assert_eq!(kind, "EngineSpec");
991                assert_eq!(got, foreign);
992                assert_eq!(expected, ENGINE_SPEC_SCHEMA_VERSION);
993            }
994            other => panic!("expected UnsupportedSchemaVersion, got {other:?}"),
995        }
996    }
997
998    /// v11 -> v12 regression (PR-6): `DsaModuleOp` gained
999    /// `attn_projection_quant_modes`, a positional bincode layout change. A
1000    /// pre-PR v11 producer's DSA payload must be rejected by the VERSION GATE
1001    /// (before any op decoding) as `UnsupportedSchemaVersion` — never reach
1002    /// the op-payload stage where the missing Option tag would surface as an
1003    /// opaque "unexpected end of file".
1004    #[test]
1005    fn from_bincode_rejects_v11_dsa_producer_at_the_version_gate() {
1006        let spec = EngineSpec::new(
1007            sample_engine_config(),
1008            vec![OpSpec::DsaContext(dsa_module())],
1009            vec![],
1010        );
1011        let mut bytes = spec.to_bincode().expect("to_bincode");
1012        bytes[..4].copy_from_slice(&11u32.to_le_bytes()); // a v11 producer's stamp
1013
1014        match EngineSpec::from_bincode(&bytes) {
1015            Err(AicError::UnsupportedSchemaVersion {
1016                kind,
1017                got,
1018                expected,
1019            }) => {
1020                assert_eq!(kind, "EngineSpec");
1021                assert_eq!(got, 11);
1022                assert_eq!(expected, ENGINE_SPEC_SCHEMA_VERSION);
1023            }
1024            other => {
1025                panic!("expected UnsupportedSchemaVersion for a v11 DSA payload, got {other:?}")
1026            }
1027        }
1028    }
1029
1030    /// v13 -> v14 regression (PR #1533): `GdnOp` gained
1031    /// `mamba_ssm_dtype`, a positional bincode layout change. A pre-PR v13
1032    /// producer's GDN payload must be rejected by the version gate before the
1033    /// missing trailing string reaches op decoding as an opaque EOF.
1034    #[test]
1035    fn from_bincode_rejects_v13_gdn_producer_at_the_version_gate() {
1036        let mut legacy_gdn = gdn();
1037        legacy_gdn.mamba_ssm_dtype.clear();
1038        let spec = EngineSpec::new(
1039            sample_engine_config(),
1040            vec![],
1041            vec![OpSpec::Gdn(legacy_gdn)],
1042        );
1043        let mut bytes = spec.to_bincode().expect("to_bincode");
1044
1045        // The GDN op is the final value in the wire payload. An empty String
1046        // is encoded as its 8-byte length, so removing that suffix recreates
1047        // the exact pre-field GdnOp layout emitted by a v13 producer.
1048        assert_eq!(&bytes[bytes.len() - 8..], &[0; 8]);
1049        bytes.truncate(bytes.len() - 8);
1050        bytes[..4].copy_from_slice(&13u32.to_le_bytes());
1051
1052        match EngineSpec::from_bincode(&bytes) {
1053            Err(AicError::UnsupportedSchemaVersion {
1054                kind,
1055                got,
1056                expected,
1057            }) => {
1058                assert_eq!(kind, "EngineSpec");
1059                assert_eq!(got, 13);
1060                assert_eq!(expected, ENGINE_SPEC_SCHEMA_VERSION);
1061            }
1062            other => {
1063                panic!("expected UnsupportedSchemaVersion for a v13 GDN payload, got {other:?}")
1064            }
1065        }
1066    }
1067
1068    /// A correct version but an undecodable op payload is NOT a version skew: it
1069    /// must surface as an op-payload-stage `EngineSpec` error (op-layout drift
1070    /// within a version, or corruption), naming the stage and the version.
1071    #[test]
1072    fn from_bincode_matching_version_corrupt_ops_names_op_payload_stage() {
1073        let mut bytes = handshake_spec().to_bincode().expect("to_bincode");
1074        // Leave the version prefix intact; corrupt the trailing op payload.
1075        bytes.truncate(bytes.len() - 8);
1076
1077        match EngineSpec::from_bincode(&bytes) {
1078            Err(AicError::EngineSpec(msg)) => {
1079                assert!(
1080                    msg.contains("op payloads"),
1081                    "message should name the op-payload stage, got: {msg}"
1082                );
1083                assert!(
1084                    msg.contains(&ENGINE_SPEC_SCHEMA_VERSION.to_string()),
1085                    "message should cite the matching version, got: {msg}"
1086                );
1087            }
1088            other => panic!("expected EngineSpec op-payload error, got {other:?}"),
1089        }
1090    }
1091
1092    /// A well-formed wire buffer whose embedded `engine_json` is not valid JSON
1093    /// must fail at the engine-JSON stage (after the version gate and op decode
1094    /// both pass), naming that stage.
1095    #[test]
1096    fn from_bincode_invalid_engine_json_names_json_stage() {
1097        // Hand-build a wire buffer with the current version, empty op lists, and
1098        // a deliberately malformed `engine_json`.
1099        let wire = BincodeWire {
1100            schema_version: ENGINE_SPEC_SCHEMA_VERSION,
1101            engine_json: "this is not json".to_string(),
1102            context_ops: vec![],
1103            generation_ops: vec![],
1104        };
1105        let bytes = bincode::serialize(&wire).expect("serialize wire");
1106
1107        match EngineSpec::from_bincode(&bytes) {
1108            Err(AicError::EngineSpec(msg)) => {
1109                assert!(
1110                    msg.contains("engine JSON"),
1111                    "message should name the engine-JSON stage, got: {msg}"
1112                );
1113            }
1114            other => panic!("expected EngineSpec engine-JSON error, got {other:?}"),
1115        }
1116    }
1117}