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
11pub 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
37pub 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 passes.push(Box::new(MatMulNBitsBiasFusion::new()));
48 passes.push(Box::new(ConvBatchNormActivationFusion::new()));
52 #[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
101pub 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 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 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 if matmul.inputs.get(5).copied().flatten().is_some() {
225 return false;
226 }
227 !graph.outputs.contains(&matmul_output) && graph.consumers(matmul_output) == [add_id]
230 }
231
232 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 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 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
562pub 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 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: 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 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 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 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) = (¶ms[0], ¶ms[1], ¶ms[2], ¶ms[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 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 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 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
829fn 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
842fn 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 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 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 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 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 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 #[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 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 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}