Skip to main content

onnx_runtime_ep_cpu/
optimizer.rs

1use onnx_runtime_ir::{
2    Attribute, DataType, Dim, Graph, Node, NodeId, TensorData, ValueId, WeightRef, static_shape,
3};
4use onnx_runtime_optimizer::{
5    OptimizationPass, OptimizerError, PassContext, Result as OptimizerResult,
6};
7
8const PROJECTION_FUSION_ENV: &str = "ONNX_GENAI_PROJECTION_FUSION";
9const MICROSOFT_DOMAIN: &str = "com.microsoft";
10
11/// CPU-only gate/up `MatMulNBits` fusion.
12///
13/// The environment gate is captured once when the pass is constructed, so a
14/// session's optimization policy cannot change midway through graph rewriting.
15pub struct ProjectionFusion {
16    enabled: bool,
17}
18
19impl ProjectionFusion {
20    pub fn new() -> Self {
21        Self {
22            enabled: std::env::var_os(PROJECTION_FUSION_ENV).is_some_and(|value| value == "1"),
23        }
24    }
25
26    pub fn enabled(&self) -> bool {
27        self.enabled
28    }
29}
30
31impl Default for ProjectionFusion {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37/// The ordered CPU EP optimization registry.
38pub fn cpu_optimization_passes() -> Vec<Box<dyn OptimizationPass>> {
39    let mut passes: Vec<Box<dyn OptimizationPass>> = Vec::new();
40    let projection_fusion = ProjectionFusion::new();
41    if projection_fusion.enabled() {
42        passes.push(Box::new(projection_fusion));
43    }
44    // Always-on, byte-identical bias fold: `Add(MatMulNBits, [N]-bias)` becomes
45    // the `MatMulNBits` optional bias input, which the kernel adds inside the
46    // MLAS GEMV epilogue instead of paying for a standalone element-wise `Add`.
47    passes.push(Box::new(MatMulNBitsBiasFusion::new()));
48    // Always-on Conv+BatchNormalization(+Relu) fold: pushes the inference-time BN
49    // affine transform back into the Conv weight/bias and folds a trailing Relu
50    // into the Conv activation epilogue, eliminating standalone BN/Relu kernels.
51    passes.push(Box::new(ConvBatchNormActivationFusion::new()));
52    // Always-on NCHWc layout propagation: keeps CNN backbones in the MLAS
53    // channels-blocked layout end-to-end so per-Conv NCHW<->NCHWc reorders are
54    // eliminated (mirrors ORT's NchwcTransformer). Runs after BN/Relu folding so
55    // interior activations are already fused where possible. Gated on `mlas`:
56    // the NCHWc kernels are MLAS-backed and unregistered without the feature, so
57    // without MLAS plain Conv kernels run instead.
58    #[cfg(feature = "mlas")]
59    passes.push(Box::new(crate::nchwc_layout::NchwcLayoutPropagation::new()));
60    passes
61}
62
63impl OptimizationPass for ProjectionFusion {
64    fn name(&self) -> &str {
65        "CpuProjectionFusion"
66    }
67
68    fn run(&self, graph: &mut Graph, ctx: &PassContext) -> OptimizerResult<()> {
69        if !self.enabled {
70            return Ok(());
71        }
72
73        let silu_nodes: Vec<NodeId> = graph
74            .nodes
75            .iter()
76            .filter_map(|(id, node)| {
77                (node.op_type == "Silu"
78                    && node.domain == MICROSOFT_DOMAIN
79                    && node.inputs.len() == 1
80                    && node.outputs.len() == 1)
81                    .then_some(id)
82            })
83            .collect();
84
85        let mut changed = false;
86        for silu_id in silu_nodes {
87            let Some(candidate) = GateUpCandidate::match_from_silu(graph, silu_id, ctx) else {
88                continue;
89            };
90            candidate.apply(graph);
91            changed = true;
92        }
93
94        if changed {
95            graph.validate().map_err(OptimizerError::from)?;
96        }
97        Ok(())
98    }
99}
100
101/// Folds a standalone bias `Add` into the preceding `MatMulNBits`.
102///
103/// Recognizes the generic pattern `Add(MatMulNBits(A, ...), bias)` where `bias`
104/// is a rank-1 `[N]` tensor broadcast over the `MatMulNBits` output's last (`N`)
105/// dimension, and rewrites it to `MatMulNBits(A, ..., bias)` using the op's
106/// optional bias input (ONNX contrib input index 5). The kernel adds the bias
107/// inside the MLAS GEMV epilogue, so the result is byte-identical to running the
108/// `MatMul` and `Add` separately while eliminating a full-tensor read/write and a
109/// separate kernel launch per decode step (`docs/ORT2.md` §15.1, RULE 2.1).
110///
111/// The match is purely structural — it never inspects model identity, and it
112/// falls back cleanly (no rewrite) whenever the shapes, dtypes, or fan-out do
113/// not fit the pattern.
114pub struct MatMulNBitsBiasFusion;
115
116impl MatMulNBitsBiasFusion {
117    pub fn new() -> Self {
118        Self
119    }
120}
121
122impl Default for MatMulNBitsBiasFusion {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128impl OptimizationPass for MatMulNBitsBiasFusion {
129    fn name(&self) -> &str {
130        "CpuMatMulNBitsBiasFusion"
131    }
132
133    fn run(&self, graph: &mut Graph, _ctx: &PassContext) -> OptimizerResult<()> {
134        let add_nodes: Vec<NodeId> = graph
135            .nodes
136            .iter()
137            .filter_map(|(id, node)| {
138                (node.op_type == "Add"
139                    && matches!(node.domain.as_str(), "" | "ai.onnx")
140                    && node.inputs.len() == 2
141                    && node.outputs.len() == 1)
142                    .then_some(id)
143            })
144            .collect();
145
146        let mut changed = false;
147        for add_id in add_nodes {
148            if let Some(fusion) = MatMulNBitsBias::match_from_add(graph, add_id) {
149                fusion.apply(graph);
150                changed = true;
151            }
152        }
153
154        if changed {
155            graph.validate().map_err(OptimizerError::from)?;
156        }
157        Ok(())
158    }
159}
160
161struct MatMulNBitsBias {
162    matmul_id: NodeId,
163    add_id: NodeId,
164    matmul_output: ValueId,
165    add_output: ValueId,
166    bias: ValueId,
167}
168
169impl MatMulNBitsBias {
170    fn match_from_add(graph: &Graph, add_id: NodeId) -> Option<Self> {
171        let add = graph.try_node(add_id)?;
172        let lhs = add.inputs[0]?;
173        let rhs = add.inputs[1]?;
174        let add_output = add.outputs[0];
175
176        // Try each operand as the MatMulNBits side; the other is the bias.
177        for (matmul_output, bias) in [(lhs, rhs), (rhs, lhs)] {
178            if matmul_output == bias {
179                continue;
180            }
181            let Some(matmul_id) = graph.value(matmul_output).producer else {
182                continue;
183            };
184            if !Self::matmul_is_biasable(graph, matmul_id, matmul_output, add_id) {
185                continue;
186            }
187            let n = match graph.value(matmul_output).shape.last() {
188                Some(&Dim::Static(n)) => n,
189                _ => continue,
190            };
191            if !Self::bias_is_row_vector(graph, bias, n) {
192                continue;
193            }
194            return Some(Self {
195                matmul_id,
196                add_id,
197                matmul_output,
198                add_output,
199                bias,
200            });
201        }
202        None
203    }
204
205    /// The producer is a bias-free `MatMulNBits` whose sole consumer is this
206    /// `Add` and whose output is not exposed as a graph output.
207    fn matmul_is_biasable(
208        graph: &Graph,
209        matmul_id: NodeId,
210        matmul_output: ValueId,
211        add_id: NodeId,
212    ) -> bool {
213        let Some(matmul) = graph.try_node(matmul_id) else {
214            return false;
215        };
216        if matmul.op_type != "MatMulNBits"
217            || matmul.domain != MICROSOFT_DOMAIN
218            || matmul.outputs.len() != 1
219            || matmul.outputs[0] != matmul_output
220        {
221            return false;
222        }
223        // A bias input (contrib index 5) already present means nothing to fold.
224        if matmul.inputs.get(5).copied().flatten().is_some() {
225            return false;
226        }
227        // The intermediate MatMulNBits result must be private to this Add so it
228        // can be dropped once the bias is folded in.
229        !graph.outputs.contains(&matmul_output) && graph.consumers(matmul_output) == [add_id]
230    }
231
232    /// The bias must be a rank-1 `[N]` float tensor so it broadcasts over the
233    /// `MatMulNBits` output's last dimension exactly as the kernel's bias add.
234    fn bias_is_row_vector(graph: &Graph, bias: ValueId, n: usize) -> bool {
235        let value = graph.value(bias);
236        matches!(
237            value.dtype,
238            DataType::Float32 | DataType::Float16 | DataType::BFloat16
239        ) && value.shape.as_slice() == [Dim::Static(n)]
240    }
241
242    fn apply(self, graph: &mut Graph) {
243        let mut fused = graph.node(self.matmul_id).clone();
244        // Grow the input list to the contrib bias slot, leaving the optional
245        // zero_points / g_idx slots disconnected when the source op omitted them.
246        if fused.inputs.len() < 6 {
247            fused.inputs.resize(6, None);
248        }
249        fused.inputs[5] = Some(self.bias);
250        graph.replace_node(self.matmul_id, fused);
251
252        // Route the Add's consumers (and any graph-output slot) onto the fused
253        // MatMulNBits output, then drop the now-dead Add and its output value.
254        graph.replace_all_uses(self.add_output, self.matmul_output);
255        graph.remove_node(self.add_id);
256    }
257}
258
259struct Projection {
260    node_id: NodeId,
261    activation: ValueId,
262    output: ValueId,
263    weight: ValueId,
264    scales: ValueId,
265    k: usize,
266    n: usize,
267    bits: usize,
268    block_size: usize,
269    accuracy_level: i64,
270    weight_bytes: Vec<u8>,
271    scale_bytes: Vec<u8>,
272}
273
274impl Projection {
275    fn parse(graph: &Graph, node_id: NodeId, ctx: &PassContext) -> Option<Self> {
276        let node = graph.try_node(node_id)?;
277        if node.op_type != "MatMulNBits"
278            || node.domain != MICROSOFT_DOMAIN
279            || node.outputs.len() != 1
280            || !(3..=6).contains(&node.inputs.len())
281            || node.inputs.iter().skip(3).any(Option::is_some)
282        {
283            return None;
284        }
285        const ALLOWED_ATTRIBUTES: &[&str] = &[
286            "K",
287            "N",
288            "bits",
289            "block_size",
290            "accuracy_level",
291            "weight_prepacked",
292        ];
293        if node
294            .attributes
295            .keys()
296            .any(|attribute| !ALLOWED_ATTRIBUTES.contains(&attribute.as_str()))
297        {
298            return None;
299        }
300
301        let activation = node.inputs[0]?;
302        let weight = node.inputs[1]?;
303        let scales = node.inputs[2]?;
304        let output = node.outputs[0];
305        let k = positive_attr(node, "K")?;
306        let n = positive_attr(node, "N")?;
307        let bits = optional_nonnegative_attr(node, "bits", 4)?;
308        let block_size = positive_attr(node, "block_size")?;
309        let accuracy_level = node
310            .attr("accuracy_level")
311            .and_then(Attribute::as_int)
312            .unwrap_or(0);
313        let weight_prepacked = optional_nonnegative_attr(node, "weight_prepacked", 0)?;
314        if bits != 4
315            || weight_prepacked != 0
316            || block_size < 16
317            || !block_size.is_power_of_two()
318            || graph.value(activation).dtype != DataType::Float32
319            || graph.value(activation).shape.last() != Some(&Dim::Static(k))
320            || graph.value(output).dtype != DataType::Float32
321        {
322            return None;
323        }
324
325        let output_shape = &graph.value(output).shape;
326        if output_shape.last() != Some(&Dim::Static(n)) {
327            return None;
328        }
329
330        let k_blocks = k.div_ceil(block_size);
331        let packed_block_bytes = block_size.checked_mul(bits)?.checked_div(8)?;
332        let expected_weight_bytes = n.checked_mul(k_blocks)?.checked_mul(packed_block_bytes)?;
333        let expected_scale_values = n.checked_mul(k_blocks)?;
334        let expected_scale_bytes =
335            expected_scale_values.checked_mul(DataType::Float32.byte_size())?;
336
337        let weight_ref = graph.initializers.get(&weight)?;
338        if weight_ref.dtype() != DataType::Uint8
339            || weight_ref.dims() != [n, k_blocks, packed_block_bytes]
340        {
341            return None;
342        }
343        let scale_ref = graph.initializers.get(&scales)?;
344        if scale_ref.dtype() != DataType::Float32
345            || !(scale_ref.dims() == [n, k_blocks] || scale_ref.dims() == [expected_scale_values])
346        {
347            return None;
348        }
349
350        let weight_bytes = ctx.initializer_bytes(weight_ref)?;
351        let scale_bytes = ctx.initializer_bytes(scale_ref)?;
352        if weight_bytes.len() != expected_weight_bytes || scale_bytes.len() != expected_scale_bytes
353        {
354            return None;
355        }
356
357        Some(Self {
358            node_id,
359            activation,
360            output,
361            weight,
362            scales,
363            k,
364            n,
365            bits,
366            block_size,
367            accuracy_level,
368            weight_bytes: weight_bytes.to_vec(),
369            scale_bytes: scale_bytes.to_vec(),
370        })
371    }
372
373    fn compatible_with(&self, other: &Self, graph: &Graph) -> bool {
374        self.activation == other.activation
375            && self.k == other.k
376            && self.bits == other.bits
377            && self.block_size == other.block_size
378            && self.accuracy_level == other.accuracy_level
379            && graph.value(self.output).shape[..graph.value(self.output).shape.len() - 1]
380                == graph.value(other.output).shape[..graph.value(other.output).shape.len() - 1]
381    }
382}
383
384struct GateUpCandidate {
385    gate: Projection,
386    up: Projection,
387    total_n: usize,
388    fused_weight: Vec<u8>,
389    fused_scales: Vec<u8>,
390}
391
392impl GateUpCandidate {
393    fn match_from_silu(graph: &Graph, silu_id: NodeId, ctx: &PassContext) -> Option<Self> {
394        let silu = graph.try_node(silu_id)?;
395        let gate_output = silu.inputs[0]?;
396        let silu_output = silu.outputs[0];
397        if graph.outputs.contains(&gate_output)
398            || graph.outputs.contains(&silu_output)
399            || graph.consumers(gate_output) != [silu_id]
400        {
401            return None;
402        }
403
404        let silu_consumers = graph.consumers(silu_output);
405        if silu_consumers.len() != 1 {
406            return None;
407        }
408        let mul_id = silu_consumers[0];
409        let mul = graph.try_node(mul_id)?;
410        if mul.op_type != "Mul"
411            || !matches!(mul.domain.as_str(), "" | "ai.onnx")
412            || mul.inputs.len() != 2
413            || mul.outputs.len() != 1
414        {
415            return None;
416        }
417        let up_output = if mul.inputs[0] == Some(silu_output) {
418            mul.inputs[1]?
419        } else if mul.inputs[1] == Some(silu_output) {
420            mul.inputs[0]?
421        } else {
422            return None;
423        };
424        if up_output == gate_output
425            || graph.outputs.contains(&up_output)
426            || graph.consumers(up_output) != [mul_id]
427        {
428            return None;
429        }
430
431        let gate_id = graph.value(gate_output).producer?;
432        let up_id = graph.value(up_output).producer?;
433        if gate_id == up_id {
434            return None;
435        }
436        let mut gate = Projection::parse(graph, gate_id, ctx)?;
437        let mut up = Projection::parse(graph, up_id, ctx)?;
438        if gate.output != gate_output || up.output != up_output || !gate.compatible_with(&up, graph)
439        {
440            return None;
441        }
442        let total_n = gate.n.checked_add(up.n)?;
443        i64::try_from(total_n).ok()?;
444        let fused_weight_len =
445            checked_combined_initializer_len(gate.weight_bytes.len(), up.weight_bytes.len())?;
446        let fused_scale_len =
447            checked_combined_initializer_len(gate.scale_bytes.len(), up.scale_bytes.len())?;
448        let fused_weight = concatenate_initializer_bytes(
449            std::mem::take(&mut gate.weight_bytes),
450            std::mem::take(&mut up.weight_bytes),
451            fused_weight_len,
452        )?;
453        let fused_scales = concatenate_initializer_bytes(
454            std::mem::take(&mut gate.scale_bytes),
455            std::mem::take(&mut up.scale_bytes),
456            fused_scale_len,
457        )?;
458
459        Some(Self {
460            gate,
461            up,
462            total_n,
463            fused_weight,
464            fused_scales,
465        })
466    }
467
468    fn apply(self, graph: &mut Graph) {
469        let first_id = self.gate.node_id.0;
470        let k_blocks = self.gate.k.div_ceil(self.gate.block_size);
471        let packed_block_bytes = self.gate.block_size * self.gate.bits / 8;
472        let total_n = self.total_n;
473
474        let fused_weight = self.fused_weight;
475        let fused_scales = self.fused_scales;
476
477        let weight_name = format!("__nxrt_fused_projection_{first_id}_weight");
478        let fused_weight_value = graph.create_named_value(
479            weight_name,
480            DataType::Uint8,
481            static_shape([total_n, k_blocks, packed_block_bytes]),
482        );
483        graph.set_initializer(
484            fused_weight_value,
485            WeightRef::Inline(TensorData::from_raw(
486                DataType::Uint8,
487                vec![total_n, k_blocks, packed_block_bytes],
488                fused_weight,
489            )),
490        );
491
492        let scales_name = format!("__nxrt_fused_projection_{first_id}_scales");
493        let fused_scale_value = graph.create_named_value(
494            scales_name,
495            DataType::Float32,
496            static_shape([total_n, k_blocks]),
497        );
498        graph.set_initializer(
499            fused_scale_value,
500            WeightRef::Inline(TensorData::from_raw(
501                DataType::Float32,
502                vec![total_n, k_blocks],
503                fused_scales,
504            )),
505        );
506
507        let split_name = format!("__nxrt_fused_projection_{first_id}_split");
508        let split_value = graph.create_named_value(split_name, DataType::Int64, static_shape([2]));
509        let mut split_bytes = Vec::with_capacity(2 * std::mem::size_of::<i64>());
510        split_bytes.extend_from_slice(&(self.gate.n as i64).to_le_bytes());
511        split_bytes.extend_from_slice(&(self.up.n as i64).to_le_bytes());
512        graph.set_initializer(
513            split_value,
514            WeightRef::Inline(TensorData::from_raw(DataType::Int64, vec![2], split_bytes)),
515        );
516
517        let mut fused_shape = graph.value(self.gate.output).shape.clone();
518        *fused_shape
519            .last_mut()
520            .expect("MatMulNBits output has a last dimension") = Dim::Static(total_n);
521        let fused_output = graph.create_named_value(
522            format!("__nxrt_fused_projection_{first_id}_output"),
523            DataType::Float32,
524            fused_shape,
525        );
526
527        let mut fused_node = graph.node(self.gate.node_id).clone();
528        fused_node.name = format!("fused_projection_gate_up_{first_id}");
529        fused_node.inputs = vec![
530            Some(self.gate.activation),
531            Some(fused_weight_value),
532            Some(fused_scale_value),
533        ];
534        fused_node.outputs = vec![fused_output];
535        fused_node
536            .attributes
537            .insert("N".to_string(), Attribute::Int(total_n as i64));
538
539        graph.remove_node(self.gate.node_id);
540        graph.remove_node(self.up.node_id);
541        graph.insert_node(fused_node);
542
543        let mut split = Node::new(
544            NodeId(0),
545            "Split",
546            vec![Some(fused_output), Some(split_value)],
547            vec![self.gate.output, self.up.output],
548        );
549        split.name = format!("fused_projection_split_{first_id}");
550        split
551            .attributes
552            .insert("axis".to_string(), Attribute::Int(-1));
553        graph.insert_node(split);
554
555        remove_orphan_initializer(graph, self.gate.weight);
556        remove_orphan_initializer(graph, self.gate.scales);
557        remove_orphan_initializer(graph, self.up.weight);
558        remove_orphan_initializer(graph, self.up.scales);
559    }
560}
561
562/// Folds `Conv -> BatchNormalization` (and an optional trailing `Relu`) into a
563/// single `Conv` with adjusted weight/bias initializers and a fused activation.
564///
565/// Batch normalization at inference is an affine per-output-channel transform
566/// `y = a[c]*x + b[c]` with `a[c] = scale[c]/sqrt(var[c]+eps)` and
567/// `b[c] = beta[c] - a[c]*mean[c]`. Because a convolution is linear in its
568/// weights and bias, that transform can be pushed back into the Conv:
569/// `W'[c] = a[c]*W[c]` and `B'[c] = a[c]*(B[c]-mean[c]) + beta[c]`. ORT folds BN
570/// this way in its `nchwc_transformer`, eliminating a full-tensor BN kernel per
571/// convolution (which profiling showed dominates ResNet/MobileNet inference on
572/// the native EP). A trailing `Relu` is folded into the Conv's `activation`
573/// attribute, which the MLAS NCHWc epilogue (and the im2col fallback) applies for
574/// free.
575///
576/// The match is purely structural and only fires when BN's scale/B/mean/var (and
577/// the Conv weight/bias) are constant `Float32` initializers, so it is a general
578/// graph rewrite with no model-specific knowledge.
579pub struct ConvBatchNormActivationFusion;
580
581impl ConvBatchNormActivationFusion {
582    pub fn new() -> Self {
583        Self
584    }
585}
586
587impl Default for ConvBatchNormActivationFusion {
588    fn default() -> Self {
589        Self::new()
590    }
591}
592
593impl OptimizationPass for ConvBatchNormActivationFusion {
594    fn name(&self) -> &str {
595        "CpuConvBatchNormActivationFusion"
596    }
597
598    fn run(&self, graph: &mut Graph, ctx: &PassContext) -> OptimizerResult<()> {
599        let bn_nodes: Vec<NodeId> = graph
600            .nodes
601            .iter()
602            .filter_map(|(id, node)| {
603                (node.op_type == "BatchNormalization"
604                    && matches!(node.domain.as_str(), "" | "ai.onnx"))
605                .then_some(id)
606            })
607            .collect();
608
609        let mut changed = false;
610        for bn_id in bn_nodes {
611            if let Some(fusion) = ConvBnFusion::match_from_bn(graph, bn_id, ctx) {
612                fusion.apply(graph);
613                changed = true;
614            }
615        }
616
617        if changed {
618            graph.validate().map_err(OptimizerError::from)?;
619        }
620        Ok(())
621    }
622}
623
624struct ConvBnFusion {
625    conv_id: NodeId,
626    bn_id: NodeId,
627    /// The shared value produced by Conv and consumed by BN.
628    conv_output: ValueId,
629    bn_output: ValueId,
630    old_weight: ValueId,
631    old_bias: Option<ValueId>,
632    bn_params: Vec<ValueId>,
633    weight_dims: Vec<usize>,
634    new_weight: Vec<f32>,
635    new_bias: Vec<f32>,
636    /// `(relu_node, relu_output)` when a trailing `Relu` is folded in.
637    relu: Option<(NodeId, ValueId)>,
638}
639
640impl ConvBnFusion {
641    fn match_from_bn(graph: &Graph, bn_id: NodeId, ctx: &PassContext) -> Option<Self> {
642        let bn = graph.try_node(bn_id)?;
643        // Inference BatchNormalization: X, scale, B, mean, var -> Y only. Skip the
644        // training form (which also emits running-stat outputs).
645        if bn.inputs.len() != 5 || bn.outputs.len() != 1 {
646            return None;
647        }
648        let conv_output = bn.inputs[0]?;
649        let bn_output = bn.outputs[0];
650
651        let conv_id = graph.value(conv_output).producer?;
652        let conv = graph.try_node(conv_id)?;
653        if conv.op_type != "Conv"
654            || !matches!(conv.domain.as_str(), "" | "ai.onnx")
655            || conv.outputs.len() != 1
656            || conv.outputs[0] != conv_output
657            || conv.attr("activation").is_some()
658        {
659            return None;
660        }
661        // The Conv result must be private to this BN so it can be rewritten away.
662        if graph.outputs.contains(&conv_output) || graph.consumers(conv_output) != [bn_id] {
663            return None;
664        }
665
666        let weight = conv.inputs.get(1).copied().flatten()?;
667        let weight_ref = graph.initializers.get(&weight)?;
668        if weight_ref.dtype() != DataType::Float32 || weight_ref.dims().len() != 4 {
669            return None;
670        }
671        let weight_dims = weight_ref.dims().to_vec();
672        let out_ch = weight_dims[0];
673        if out_ch == 0 {
674            return None;
675        }
676        let weight_vals = bytes_to_f32(ctx.initializer_bytes(weight_ref)?)?;
677        if !weight_vals.len().is_multiple_of(out_ch) {
678            return None;
679        }
680
681        let old_bias = conv.inputs.get(2).copied().flatten();
682        let bias_vals = match old_bias {
683            Some(bias) => {
684                let bias_ref = graph.initializers.get(&bias)?;
685                if bias_ref.dtype() != DataType::Float32 || bias_ref.dims() != [out_ch] {
686                    return None;
687                }
688                bytes_to_f32(ctx.initializer_bytes(bias_ref)?)?
689            }
690            None => vec![0.0f32; out_ch],
691        };
692
693        // scale, B (beta), mean, var — all per-output-channel constants.
694        let mut bn_params: Vec<ValueId> = Vec::with_capacity(4);
695        let mut params: Vec<Vec<f32>> = Vec::with_capacity(4);
696        for slot in 0..4 {
697            let value = bn.inputs[slot + 1]?;
698            let param_ref = graph.initializers.get(&value)?;
699            if param_ref.dtype() != DataType::Float32 || param_ref.dims() != [out_ch] {
700                return None;
701            }
702            bn_params.push(value);
703            params.push(bytes_to_f32(ctx.initializer_bytes(param_ref)?)?);
704        }
705        let (scale, beta, mean, var) = (&params[0], &params[1], &params[2], &params[3]);
706        let epsilon = bn
707            .attr("epsilon")
708            .and_then(Attribute::as_float)
709            .unwrap_or(1e-5);
710
711        let mut a = vec![0.0f32; out_ch];
712        for c in 0..out_ch {
713            let denom = (var[c] + epsilon).sqrt();
714            if !denom.is_finite() || denom <= 0.0 {
715                return None;
716            }
717            a[c] = scale[c] / denom;
718        }
719
720        let per_filter = weight_vals.len() / out_ch;
721        let mut new_weight = weight_vals;
722        for c in 0..out_ch {
723            let factor = a[c];
724            for value in &mut new_weight[c * per_filter..(c + 1) * per_filter] {
725                *value *= factor;
726            }
727        }
728        let mut new_bias = vec![0.0f32; out_ch];
729        for c in 0..out_ch {
730            new_bias[c] = (bias_vals[c] - mean[c]) * a[c] + beta[c];
731        }
732
733        // Optionally fold a trailing Relu when BN feeds exactly one Relu.
734        let relu = (!graph.outputs.contains(&bn_output))
735            .then(|| {
736                let consumers = graph.consumers(bn_output);
737                let relu_id = *consumers.first()?;
738                if consumers.len() != 1 {
739                    return None;
740                }
741                let relu = graph.try_node(relu_id)?;
742                (relu.op_type == "Relu"
743                    && matches!(relu.domain.as_str(), "" | "ai.onnx")
744                    && relu.inputs.len() == 1
745                    && relu.outputs.len() == 1)
746                    .then_some((relu_id, relu.outputs[0]))
747            })
748            .flatten();
749
750        Some(Self {
751            conv_id,
752            bn_id,
753            conv_output,
754            bn_output,
755            old_weight: weight,
756            old_bias,
757            bn_params,
758            weight_dims,
759            new_weight,
760            new_bias,
761            relu,
762        })
763    }
764
765    fn apply(self, graph: &mut Graph) {
766        let conv_index = self.conv_id.0;
767        let out_ch = self.weight_dims[0];
768
769        let weight_value = graph.create_named_value(
770            format!("__nxrt_convbn_{conv_index}_weight"),
771            DataType::Float32,
772            static_shape(self.weight_dims.iter().copied()),
773        );
774        graph.set_initializer(
775            weight_value,
776            WeightRef::Inline(TensorData::from_raw(
777                DataType::Float32,
778                self.weight_dims.clone(),
779                f32_to_bytes(&self.new_weight),
780            )),
781        );
782
783        let bias_value = graph.create_named_value(
784            format!("__nxrt_convbn_{conv_index}_bias"),
785            DataType::Float32,
786            static_shape([out_ch]),
787        );
788        graph.set_initializer(
789            bias_value,
790            WeightRef::Inline(TensorData::from_raw(
791                DataType::Float32,
792                vec![out_ch],
793                f32_to_bytes(&self.new_bias),
794            )),
795        );
796
797        let mut conv = graph.node(self.conv_id).clone();
798        if conv.inputs.len() < 3 {
799            conv.inputs.resize(3, None);
800        }
801        conv.inputs[1] = Some(weight_value);
802        conv.inputs[2] = Some(bias_value);
803        if self.relu.is_some() {
804            conv.attributes
805                .insert("activation".into(), Attribute::String(b"Relu".to_vec()));
806        }
807        graph.replace_node(self.conv_id, conv);
808
809        // Route BN's (and any trailing Relu's) consumers back onto the Conv
810        // output, then drop the now-dead BN / Relu nodes.
811        graph.replace_all_uses(self.bn_output, self.conv_output);
812        graph.remove_node(self.bn_id);
813        if let Some((relu_id, relu_output)) = self.relu {
814            graph.replace_all_uses(relu_output, self.conv_output);
815            graph.remove_node(relu_id);
816        }
817
818        // Drop initializers left orphaned by the rewrite.
819        remove_orphan_initializer(graph, self.old_weight);
820        if let Some(bias) = self.old_bias {
821            remove_orphan_initializer(graph, bias);
822        }
823        for param in self.bn_params {
824            remove_orphan_initializer(graph, param);
825        }
826    }
827}
828
829/// Reinterpret a little-endian `Float32` initializer blob as `f32` values.
830fn bytes_to_f32(bytes: &[u8]) -> Option<Vec<f32>> {
831    if !bytes.len().is_multiple_of(4) {
832        return None;
833    }
834    Some(
835        bytes
836            .chunks_exact(4)
837            .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
838            .collect(),
839    )
840}
841
842/// Serialize `f32` values into a little-endian byte blob for an initializer.
843fn f32_to_bytes(values: &[f32]) -> Vec<u8> {
844    let mut bytes = Vec::with_capacity(values.len() * 4);
845    for value in values {
846        bytes.extend_from_slice(&value.to_le_bytes());
847    }
848    bytes
849}
850
851fn positive_attr(node: &Node, name: &str) -> Option<usize> {
852    usize::try_from(node.attr(name)?.as_int()?)
853        .ok()
854        .filter(|&v| v > 0)
855}
856
857fn optional_nonnegative_attr(node: &Node, name: &str, default: usize) -> Option<usize> {
858    match node.attr(name) {
859        Some(value) => usize::try_from(value.as_int()?).ok(),
860        None => Some(default),
861    }
862}
863
864fn checked_combined_initializer_len(first: usize, second: usize) -> Option<usize> {
865    first
866        .checked_add(second)
867        .filter(|&combined| combined <= isize::MAX as usize)
868}
869
870fn concatenate_initializer_bytes(
871    mut first: Vec<u8>,
872    second: Vec<u8>,
873    combined_len: usize,
874) -> Option<Vec<u8>> {
875    first.try_reserve_exact(second.len()).ok()?;
876    first.extend_from_slice(&second);
877    debug_assert_eq!(first.len(), combined_len);
878    Some(first)
879}
880
881fn remove_orphan_initializer(graph: &mut Graph, value: ValueId) {
882    if !graph.has_uses(value) && !graph.inputs.contains(&value) && !graph.outputs.contains(&value) {
883        graph.initializers.remove(&value);
884    }
885}
886
887#[cfg(test)]
888mod tests {
889    use super::checked_combined_initializer_len;
890    use super::{MICROSOFT_DOMAIN, MatMulNBitsBiasFusion};
891    use onnx_runtime_ir::{Attribute, DataType, Dim, Graph, Node, NodeId, ValueId, static_shape};
892    use onnx_runtime_optimizer::{OptimizationPass, PassContext};
893
894    #[test]
895    fn fused_initializer_capacity_rejects_overflow_and_isize_excess() {
896        assert_eq!(checked_combined_initializer_len(usize::MAX, 1), None);
897        assert_eq!(
898            checked_combined_initializer_len(isize::MAX as usize, 1),
899            None
900        );
901        assert_eq!(
902            checked_combined_initializer_len(0, isize::MAX as usize),
903            Some(isize::MAX as usize)
904        );
905    }
906
907    const K: usize = 8;
908    const N: usize = 4;
909
910    /// Builds `add = Add(MatMulNBits(a, w, s), bias)` and returns the graph plus
911    /// the MatMulNBits node id and the bias value id. `bias_shape` lets tests
912    /// exercise the row-vector requirement; `swap_add_inputs` puts the bias on
913    /// the first Add operand to check operand-order independence.
914    fn matmul_bias_graph(bias_shape: &[Dim], swap_add_inputs: bool) -> (Graph, NodeId, ValueId) {
915        let mut graph = Graph::new();
916        graph.opset_imports.insert(String::new(), 17);
917        graph.opset_imports.insert(MICROSOFT_DOMAIN.to_string(), 1);
918
919        let a = graph.create_named_value("a", DataType::Float32, static_shape([1, K]));
920        let weight = graph.create_named_value("w", DataType::Uint8, static_shape([N, 1, 4]));
921        let scales = graph.create_named_value("s", DataType::Float32, static_shape([N, 1]));
922        let bias = graph.create_named_value("bias", DataType::Float32, bias_shape.to_vec());
923        for value in [a, weight, scales, bias] {
924            graph.add_input(value);
925        }
926
927        let mm_out = graph.create_named_value("mm", DataType::Float32, static_shape([1, N]));
928        let mut mm = Node::new(
929            NodeId(0),
930            "MatMulNBits",
931            vec![Some(a), Some(weight), Some(scales)],
932            vec![mm_out],
933        );
934        mm.domain = MICROSOFT_DOMAIN.to_string();
935        mm.attributes.insert("K".into(), Attribute::Int(K as i64));
936        mm.attributes.insert("N".into(), Attribute::Int(N as i64));
937        mm.attributes.insert("bits".into(), Attribute::Int(4));
938        mm.attributes
939            .insert("block_size".into(), Attribute::Int(K as i64));
940        let mm_id = graph.insert_node(mm);
941
942        let add_out = graph.create_named_value("y", DataType::Float32, static_shape([1, N]));
943        let add_inputs = if swap_add_inputs {
944            vec![Some(bias), Some(mm_out)]
945        } else {
946            vec![Some(mm_out), Some(bias)]
947        };
948        let add = Node::new(NodeId(0), "Add", add_inputs, vec![add_out]);
949        graph.insert_node(add);
950        graph.add_output(add_out);
951
952        (graph, mm_id, bias)
953    }
954
955    fn count(graph: &Graph, op_type: &str) -> usize {
956        graph
957            .nodes
958            .values()
959            .filter(|node| node.op_type == op_type)
960            .count()
961    }
962
963    fn run_pass(graph: &mut Graph) {
964        MatMulNBitsBiasFusion::new()
965            .run(graph, &PassContext::new())
966            .expect("bias fusion pass");
967    }
968
969    #[test]
970    fn folds_bias_add_into_matmul_nbits() {
971        let (mut graph, mm_id, bias) = matmul_bias_graph(&static_shape([N]), false);
972        run_pass(&mut graph);
973
974        assert_eq!(count(&graph, "Add"), 0, "standalone Add should be folded");
975        assert_eq!(count(&graph, "MatMulNBits"), 1);
976        let mm = graph.node(mm_id);
977        assert_eq!(mm.inputs.len(), 6, "bias occupies contrib input slot 5");
978        assert_eq!(mm.inputs[3], None);
979        assert_eq!(mm.inputs[4], None);
980        assert_eq!(mm.inputs[5], Some(bias));
981        // The fused MatMulNBits output now feeds the graph output directly.
982        assert_eq!(graph.outputs, vec![mm.outputs[0]]);
983        assert!(graph.validate().is_ok());
984    }
985
986    #[test]
987    fn folds_bias_add_regardless_of_operand_order() {
988        let (mut graph, mm_id, bias) = matmul_bias_graph(&static_shape([N]), true);
989        run_pass(&mut graph);
990
991        assert_eq!(count(&graph, "Add"), 0);
992        assert_eq!(graph.node(mm_id).inputs[5], Some(bias));
993    }
994
995    #[test]
996    fn keeps_add_when_matmul_nbits_already_has_bias() {
997        let (mut graph, mm_id, add_bias) = matmul_bias_graph(&static_shape([N]), false);
998        let existing_bias =
999            graph.create_named_value("existing_bias", DataType::Float32, static_shape([N]));
1000        graph.add_input(existing_bias);
1001        graph.node_mut(mm_id).inputs.resize(6, None);
1002        graph.replace_input(mm_id, 5, Some(existing_bias));
1003
1004        run_pass(&mut graph);
1005
1006        assert_eq!(
1007            count(&graph, "Add"),
1008            1,
1009            "already-biased MatMulNBits must not fold"
1010        );
1011        assert_eq!(graph.node(mm_id).inputs[5], Some(existing_bias));
1012        assert_ne!(graph.node(mm_id).inputs[5], Some(add_bias));
1013        assert!(graph.validate().is_ok());
1014    }
1015
1016    #[test]
1017    fn keeps_add_when_bias_is_not_a_row_vector() {
1018        // A rank-2 [1, N] bias is not the `[N]` shape the kernel accepts.
1019        let (mut graph, mm_id, _) = matmul_bias_graph(&static_shape([1, N]), false);
1020        run_pass(&mut graph);
1021
1022        assert_eq!(count(&graph, "Add"), 1, "non-row bias must not be folded");
1023        assert_eq!(graph.node(mm_id).inputs.len(), 3);
1024    }
1025
1026    #[test]
1027    fn keeps_add_when_matmul_output_has_another_consumer() {
1028        let (mut graph, mm_id, _) = matmul_bias_graph(&static_shape([N]), false);
1029        // Add a second consumer of the MatMulNBits output so it is no longer
1030        // private to the Add and cannot be dropped.
1031        let mm_out = graph.node(mm_id).outputs[0];
1032        let other = graph.create_named_value("other", DataType::Float32, static_shape([1, N]));
1033        graph.insert_node(Node::new(
1034            NodeId(0),
1035            "Relu",
1036            vec![Some(mm_out)],
1037            vec![other],
1038        ));
1039        graph.add_output(other);
1040
1041        run_pass(&mut graph);
1042
1043        assert_eq!(count(&graph, "Add"), 1, "shared output blocks the fold");
1044        assert_eq!(graph.node(mm_id).inputs.len(), 3);
1045    }
1046
1047    #[test]
1048    fn keeps_add_when_matmul_output_is_a_graph_output() {
1049        let (mut graph, mm_id, _) = matmul_bias_graph(&static_shape([N]), false);
1050        // Expose the intermediate MatMulNBits result as a graph output; folding
1051        // would remove an observable value.
1052        let mm_out = graph.node(mm_id).outputs[0];
1053        let mut outputs = graph.outputs.clone();
1054        outputs.push(mm_out);
1055        graph.set_outputs(outputs);
1056
1057        run_pass(&mut graph);
1058
1059        assert_eq!(count(&graph, "Add"), 1);
1060        assert_eq!(graph.node(mm_id).inputs.len(), 3);
1061    }
1062
1063    use super::{ConvBatchNormActivationFusion, f32_to_bytes};
1064    use onnx_runtime_ir::{TensorData, WeightRef};
1065
1066    fn set_f32_initializer(graph: &mut Graph, name: &str, dims: &[usize], data: &[f32]) -> ValueId {
1067        let value = graph.create_named_value(name, DataType::Float32, static_shape(dims.to_vec()));
1068        graph.set_initializer(
1069            value,
1070            WeightRef::Inline(TensorData::from_raw(
1071                DataType::Float32,
1072                dims.to_vec(),
1073                f32_to_bytes(data),
1074            )),
1075        );
1076        value
1077    }
1078
1079    fn read_f32_initializer(graph: &Graph, value: ValueId) -> Vec<f32> {
1080        let weight = graph.initializers.get(&value).expect("initializer present");
1081        let WeightRef::Inline(tensor) = weight else {
1082            panic!("expected inline initializer");
1083        };
1084        tensor
1085            .data
1086            .chunks_exact(4)
1087            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
1088            .collect()
1089    }
1090
1091    /// Builds `Conv(x, w[, b]) -> BatchNormalization -> [Relu]` with constant BN
1092    /// parameters and returns the graph plus the Conv node id.
1093    #[allow(clippy::too_many_arguments)]
1094    fn conv_bn_graph(
1095        weight: &[f32],
1096        weight_dims: &[usize],
1097        bias: Option<&[f32]>,
1098        scale: &[f32],
1099        beta: &[f32],
1100        mean: &[f32],
1101        var: &[f32],
1102        epsilon: f32,
1103        with_relu: bool,
1104    ) -> (Graph, NodeId) {
1105        let mut graph = Graph::new();
1106        graph.opset_imports.insert(String::new(), 17);
1107        let out_ch = weight_dims[0];
1108
1109        let x = graph.create_named_value("x", DataType::Float32, static_shape([1, 3, 8, 8]));
1110        graph.add_input(x);
1111        let w = set_f32_initializer(&mut graph, "w", weight_dims, weight);
1112        let mut conv_inputs = vec![Some(x), Some(w)];
1113        if let Some(bias) = bias {
1114            conv_inputs.push(Some(set_f32_initializer(&mut graph, "b", &[out_ch], bias)));
1115        }
1116
1117        let conv_out = graph.create_named_value(
1118            "conv_out",
1119            DataType::Float32,
1120            static_shape([1, out_ch, 6, 6]),
1121        );
1122        let conv = Node::new(NodeId(0), "Conv", conv_inputs, vec![conv_out]);
1123        let conv_id = graph.insert_node(conv);
1124
1125        let scale_v = set_f32_initializer(&mut graph, "scale", &[out_ch], scale);
1126        let beta_v = set_f32_initializer(&mut graph, "beta", &[out_ch], beta);
1127        let mean_v = set_f32_initializer(&mut graph, "mean", &[out_ch], mean);
1128        let var_v = set_f32_initializer(&mut graph, "var", &[out_ch], var);
1129        let bn_out =
1130            graph.create_named_value("bn_out", DataType::Float32, static_shape([1, out_ch, 6, 6]));
1131        let mut bn = Node::new(
1132            NodeId(0),
1133            "BatchNormalization",
1134            vec![
1135                Some(conv_out),
1136                Some(scale_v),
1137                Some(beta_v),
1138                Some(mean_v),
1139                Some(var_v),
1140            ],
1141            vec![bn_out],
1142        );
1143        bn.attributes
1144            .insert("epsilon".into(), Attribute::Float(epsilon));
1145        graph.insert_node(bn);
1146
1147        let final_out = if with_relu {
1148            let relu_out = graph.create_named_value(
1149                "relu_out",
1150                DataType::Float32,
1151                static_shape([1, out_ch, 6, 6]),
1152            );
1153            graph.insert_node(Node::new(
1154                NodeId(0),
1155                "Relu",
1156                vec![Some(bn_out)],
1157                vec![relu_out],
1158            ));
1159            relu_out
1160        } else {
1161            bn_out
1162        };
1163        graph.add_output(final_out);
1164        (graph, conv_id)
1165    }
1166
1167    fn run_conv_bn_pass(graph: &mut Graph) {
1168        ConvBatchNormActivationFusion::new()
1169            .run(graph, &PassContext::new())
1170            .expect("conv-bn fusion pass");
1171    }
1172
1173    #[test]
1174    fn folds_conv_bn_relu_into_conv() {
1175        // Two output channels, 3 input channels, 3x3 kernel.
1176        let out_ch = 2usize;
1177        let per_filter = 3 * 3 * 3;
1178        let weight: Vec<f32> = (0..out_ch * per_filter)
1179            .map(|i| (i as f32) * 0.01)
1180            .collect();
1181        let bias = [0.5f32, -0.25];
1182        let scale = [1.5f32, 0.75];
1183        let beta = [0.1f32, -0.2];
1184        let mean = [0.3f32, 0.6];
1185        let var = [4.0f32, 9.0];
1186        let epsilon = 1e-5f32;
1187
1188        let (mut graph, conv_id) = conv_bn_graph(
1189            &weight,
1190            &[out_ch, 3, 3, 3],
1191            Some(&bias),
1192            &scale,
1193            &beta,
1194            &mean,
1195            &var,
1196            epsilon,
1197            true,
1198        );
1199        run_conv_bn_pass(&mut graph);
1200
1201        assert_eq!(count(&graph, "BatchNormalization"), 0, "BN folded away");
1202        assert_eq!(count(&graph, "Relu"), 0, "Relu folded into activation");
1203        assert_eq!(count(&graph, "Conv"), 1);
1204
1205        let conv = graph.node(conv_id);
1206        assert_eq!(
1207            conv.attr("activation").and_then(Attribute::as_str),
1208            Some("Relu")
1209        );
1210        let folded_w = read_f32_initializer(&graph, conv.inputs[1].unwrap());
1211        let folded_b = read_f32_initializer(&graph, conv.inputs[2].unwrap());
1212
1213        for c in 0..out_ch {
1214            let a = scale[c] / (var[c] + epsilon).sqrt();
1215            for j in 0..per_filter {
1216                let expected = weight[c * per_filter + j] * a;
1217                assert!((folded_w[c * per_filter + j] - expected).abs() < 1e-6);
1218            }
1219            let expected_b = (bias[c] - mean[c]) * a + beta[c];
1220            assert!((folded_b[c] - expected_b).abs() < 1e-6);
1221        }
1222        assert!(graph.validate().is_ok());
1223    }
1224
1225    #[test]
1226    fn folds_conv_bn_without_bias_or_relu() {
1227        let out_ch = 2usize;
1228        let per_filter = 3 * 3 * 3;
1229        let weight: Vec<f32> = (0..out_ch * per_filter)
1230            .map(|i| (i as f32) * 0.02)
1231            .collect();
1232        let scale = [2.0f32, 0.5];
1233        let beta = [0.0f32, 1.0];
1234        let mean = [1.0f32, -1.0];
1235        let var = [1.0f32, 4.0];
1236        let epsilon = 1e-5f32;
1237
1238        let (mut graph, conv_id) = conv_bn_graph(
1239            &weight,
1240            &[out_ch, 3, 3, 3],
1241            None,
1242            &scale,
1243            &beta,
1244            &mean,
1245            &var,
1246            epsilon,
1247            false,
1248        );
1249        run_conv_bn_pass(&mut graph);
1250
1251        assert_eq!(count(&graph, "BatchNormalization"), 0);
1252        let conv = graph.node(conv_id);
1253        assert!(conv.attr("activation").is_none(), "no Relu to fold");
1254        let folded_b = read_f32_initializer(&graph, conv.inputs[2].unwrap());
1255        for c in 0..out_ch {
1256            let a = scale[c] / (var[c] + epsilon).sqrt();
1257            let expected_b = (0.0 - mean[c]) * a + beta[c];
1258            assert!((folded_b[c] - expected_b).abs() < 1e-6);
1259        }
1260        assert!(graph.validate().is_ok());
1261    }
1262
1263    #[test]
1264    fn skips_fusion_when_conv_output_is_shared() {
1265        let out_ch = 2usize;
1266        let per_filter = 3 * 3 * 3;
1267        let weight = vec![0.01f32; out_ch * per_filter];
1268        let (mut graph, conv_id) = conv_bn_graph(
1269            &weight,
1270            &[out_ch, 3, 3, 3],
1271            None,
1272            &[1.0, 1.0],
1273            &[0.0, 0.0],
1274            &[0.0, 0.0],
1275            &[1.0, 1.0],
1276            1e-5,
1277            false,
1278        );
1279        // Expose the Conv output as a graph output so folding would drop an
1280        // observable value.
1281        let conv_out = graph.node(conv_id).outputs[0];
1282        let mut outputs = graph.outputs.clone();
1283        outputs.push(conv_out);
1284        graph.set_outputs(outputs);
1285
1286        run_conv_bn_pass(&mut graph);
1287        assert_eq!(
1288            count(&graph, "BatchNormalization"),
1289            1,
1290            "shared Conv output blocks the fold"
1291        );
1292    }
1293}