Skip to content

Commit 95f33f4

Browse files
claudetake-cheeze
authored andcommitted
Add fuse_mul_into_conv pass
Fold a per-output-channel (or scalar) constant Mul that follows a Conv into the convolution weights: for `Z = Conv(X, W[, B]) * S`, rewrite to `Conv(X, W*S[, B*S])`. The scale is applied to the constant weights via Mul nodes that the constant folder then materialises, so the standalone multiply disappears; the trailing bias Add is subsequently absorbed by fuse_add_bias_into_conv, collapsing a `Conv -> Mul -> Add` affine tail (e.g. a BatchNorm exported as Mul/Add) back into the Conv. Only per-channel scales aligned to the Conv output channel axis (or a scalar) are fused; other shapes are left untouched. Only Conv (not ConvTranspose, whose weight layout differs) is handled. Bias, when present, must be constant so it can be scaled. On FasterRCNN-10 this removes 53 `Conv -> Mul -> Add` chains (106 nodes) that were previously left unfused. Adds tests covering per-channel and scalar scales (with and without bias) and a non-per-channel negative case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Piyb2V1gQuMBKfYdZeqqZy Signed-off-by: take-cheeze <takechi101010@gmail.com>
1 parent 96ddf59 commit 95f33f4

3 files changed

Lines changed: 285 additions & 0 deletions

File tree

onnxoptimizer/pass_registry.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
#include "onnxoptimizer/passes/fuse_consecutive_squeezes.h"
4949
#include "onnxoptimizer/passes/fuse_consecutive_transposes.h"
5050
#include "onnxoptimizer/passes/fuse_matmul_add_bias_into_gemm.h"
51+
#include "onnxoptimizer/passes/fuse_mul_into_conv.h"
5152
#include "onnxoptimizer/passes/fuse_pad_into_conv.h"
5253
#include "onnxoptimizer/passes/fuse_pad_into_pool.h"
5354
#include "onnxoptimizer/passes/fuse_transpose_into_gemm.h"
@@ -102,6 +103,7 @@ struct GlobalPassRegistry {
102103
registerPass<FuseConsecutiveSqueezeUnsqueeze>();
103104
registerPass<FuseConsecutiveTransposes>();
104105
registerPass<FuseMatMulAddBiasIntoGemm>();
106+
registerPass<FuseMulIntoConv>();
105107
registerPass<FusePadIntoConv>();
106108
registerPass<FusePadIntoPool>();
107109
registerPass<FuseTransposeIntoGemm>();
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
// Copyright (c) ONNX Project Contributors
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
// ATTENTION: The code in this file is highly EXPERIMENTAL.
6+
// Adventurous users should note that the APIs will probably change.
7+
8+
#pragma once
9+
10+
// Before:
11+
// Y = Conv(X, W[, B])
12+
// Z = Y * S
13+
// After:
14+
// Z = Conv(X, W', [B']) with W' = W * S, B' = B * S
15+
//
16+
// S must be a constant that scales the Conv output per output channel: either a
17+
// single element (scalar broadcast) or a tensor whose only non-unit dimension
18+
// is the channel axis of the Conv output (axis 1), e.g. [C], [C, 1, 1] or [1,
19+
// C, 1, 1]. The scale is folded into the convolution weights (and bias, if
20+
// present) by creating Mul nodes on the constant weights, which the constant
21+
// folder then materialises; the leftover multiplicative op disappears.
22+
//
23+
// This recovers the fusion of an affine scale that a framework exported as a
24+
// standalone Mul -- most commonly the multiplicative half of a BatchNorm
25+
// written as ``Conv -> Mul -> Add``. Once the Mul is folded in, the following
26+
// bias Add is picked up by fuse_add_bias_into_conv, collapsing the whole affine
27+
// tail into the Conv. Only Conv (not ConvTranspose) is handled, because its
28+
// weight layout puts the output channel on axis 0.
29+
30+
#include <numeric>
31+
#include <vector>
32+
33+
#include "onnx/common/assertions.h"
34+
#include "onnxoptimizer/pass.h"
35+
#include "onnxoptimizer/passes/pass_util.h"
36+
37+
namespace ONNX_NAMESPACE {
38+
namespace optimization {
39+
40+
struct FuseMulIntoConv final : public PredicateBasedPass {
41+
explicit FuseMulIntoConv()
42+
: PredicateBasedPass(PassType::Fuse, PassEfficiency::Complete,
43+
PassOptimizationType::Compute) {}
44+
std::string getPassName() const override {
45+
return "fuse_mul_into_conv";
46+
}
47+
48+
// Index (0 or 1) of the Mul operand that is a foldable Conv output -- a Conv
49+
// used only by this Mul and whose weight is constant -- or -1 if neither is.
50+
static int ConvOperandIndex(Node* mul) {
51+
for (int i = 0; i < 2; ++i) {
52+
Value* v = mul->inputs()[i];
53+
if (v->node()->kind() == kConv && v->uses().size() == 1 &&
54+
IsConstantTensor(v->node(), 1)) {
55+
return i;
56+
}
57+
}
58+
return -1;
59+
}
60+
61+
bool patternMatchPredicate(Node* n) override {
62+
if (n->kind() != kMul || n->inputs().size() != 2) {
63+
return false;
64+
}
65+
const int conv_idx = ConvOperandIndex(n);
66+
if (conv_idx < 0) {
67+
return false;
68+
}
69+
return IsConstantTensor(n->inputs()[1 - conv_idx]);
70+
}
71+
72+
bool runTransform(Node* n, Graph& graph,
73+
NodeDestroyType& destroy_current) override {
74+
destroy_current = NodeDestroyType::DestroyZero;
75+
const int conv_idx = ConvOperandIndex(n);
76+
if (conv_idx < 0) {
77+
return false;
78+
}
79+
Value* conv_out = n->inputs()[conv_idx];
80+
Value* scale = n->inputs()[1 - conv_idx];
81+
Node* conv = conv_out->node();
82+
Value* weight = conv->inputs()[1];
83+
84+
const Tensor* weight_t = FetchConstantTensor(weight);
85+
const Tensor* scale_t = FetchConstantTensor(scale);
86+
if (weight_t == nullptr || scale_t == nullptr) {
87+
return false;
88+
}
89+
// The scale is multiplied against the constant weight, so the element types
90+
// must agree.
91+
if (weight_t->elem_type() != scale_t->elem_type()) {
92+
return false;
93+
}
94+
95+
// Conv weight is [C_out, C_in / groups, k...]; the output channel is axis
96+
// 0.
97+
const auto& w_sizes = weight_t->sizes();
98+
if (w_sizes.size() < 2) {
99+
return false;
100+
}
101+
const int64_t C = w_sizes[0];
102+
const int64_t w_rank = static_cast<int64_t>(w_sizes.size());
103+
104+
// Validate that `scale` scales the Conv output per output channel: a scalar
105+
// (single element) or a tensor whose only non-unit axis equals C and, when
106+
// right-aligned against the Conv output rank (== w_rank), lands on the
107+
// channel axis (index 1).
108+
const auto& s_sizes = scale_t->sizes();
109+
int64_t s_numel = 1;
110+
for (const int64_t d : s_sizes) {
111+
s_numel *= d;
112+
}
113+
bool per_channel;
114+
if (s_numel == 1) {
115+
per_channel = false; // scalar: broadcasts to every channel
116+
} else if (s_numel == C) {
117+
const int64_t s_rank = static_cast<int64_t>(s_sizes.size());
118+
int64_t non_unit_axis = -1;
119+
for (int64_t i = 0; i < s_rank; ++i) {
120+
if (s_sizes[i] != 1) {
121+
if (non_unit_axis != -1) {
122+
return false; // more than one non-unit axis
123+
}
124+
non_unit_axis = i;
125+
}
126+
}
127+
if (non_unit_axis < 0 || w_rank - s_rank + non_unit_axis != 1) {
128+
return false; // the C-sized axis is not the channel axis
129+
}
130+
per_channel = true;
131+
} else {
132+
return false;
133+
}
134+
135+
// If the Conv has a bias it must be constant too, so it can be scaled.
136+
const bool has_bias = conv->inputs().size() == 3;
137+
if (has_bias) {
138+
Value* bias = conv->inputs()[2];
139+
const Tensor* bias_t = FetchConstantTensor(bias);
140+
if (bias_t == nullptr || bias_t->elem_type() != scale_t->elem_type()) {
141+
return false;
142+
}
143+
}
144+
145+
// --- All checks passed; build the scaled weight (and bias). ---
146+
147+
// `bias_scale` is the [C] (or scalar) scale used for the bias;
148+
// `weight_scale` is that scale broadcast to the weight rank [C, 1, ...] for
149+
// the weight.
150+
Value* bias_scale = scale;
151+
Value* weight_scale = scale;
152+
if (per_channel) {
153+
// Flatten the scale to [C].
154+
Value* scale_1d = scale;
155+
if (static_cast<int64_t>(s_sizes.size()) != 1) {
156+
Node* reshape = graph.create(kReshape, 1);
157+
reshape->addInput(scale);
158+
Tensor shape_t;
159+
shape_t.elem_type() = TensorProto_DataType_INT64;
160+
shape_t.sizes().push_back(1);
161+
shape_t.int64s().push_back(C);
162+
reshape->addInput(graph.addInitializerAndCreateValue(shape_t));
163+
reshape->insertBefore(conv);
164+
scale_1d = reshape->output();
165+
}
166+
bias_scale = scale_1d;
167+
// Broadcast [C] -> [C, 1, ..., 1] so it multiplies the weight per
168+
// channel.
169+
weight_scale = scale_1d;
170+
if (w_rank > 1) {
171+
std::vector<int64_t> axes(w_rank - 1);
172+
std::iota(axes.begin(), axes.end(), 1);
173+
Node* unsqueeze = graph.create(kUnsqueeze, 1);
174+
unsqueeze->addInput(scale_1d);
175+
const int opset = getOpsetVersion(graph);
176+
if (opset >= 13 || opset == 0) {
177+
Tensor axes_t;
178+
axes_t.elem_type() = TensorProto_DataType_INT64;
179+
axes_t.sizes().push_back(static_cast<int64_t>(axes.size()));
180+
axes_t.int64s() = axes;
181+
unsqueeze->addInput(graph.addInitializerAndCreateValue(axes_t));
182+
} else {
183+
unsqueeze->is_(kaxes, std::move(axes));
184+
}
185+
unsqueeze->insertBefore(conv);
186+
weight_scale = unsqueeze->output();
187+
}
188+
}
189+
190+
Node* mul_w = graph.create(kMul, 1);
191+
mul_w->addInput(weight);
192+
mul_w->addInput(weight_scale);
193+
mul_w->insertBefore(conv);
194+
conv->replaceInput(1, mul_w->output());
195+
196+
if (has_bias) {
197+
Value* bias = conv->inputs()[2];
198+
Node* mul_b = graph.create(kMul, 1);
199+
mul_b->addInput(bias);
200+
mul_b->addInput(bias_scale);
201+
mul_b->insertBefore(conv);
202+
conv->replaceInput(2, mul_b->output());
203+
}
204+
205+
// The scale does not change the Conv output shape/type; carry the Mul's
206+
// inferred metadata onto the Conv output.
207+
if (conv_out->sizes().size() == 0 && n->output()->sizes().size() > 0) {
208+
conv_out->setSizes(n->output()->sizes());
209+
}
210+
if (n->output()->elemType() != TensorProto_DataType_UNDEFINED) {
211+
conv_out->setElemType(n->output()->elemType());
212+
}
213+
214+
const bool replacing_success = tryReplacingAllUsesWith(n, conv);
215+
if (!replacing_success) {
216+
return false;
217+
}
218+
destroy_current = NodeDestroyType::DestroyOne;
219+
return true;
220+
}
221+
};
222+
223+
} // namespace optimization
224+
} // namespace ONNX_NAMESPACE

onnxoptimizer/test/optimizer_test.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,6 +1269,65 @@ def test_fuse_transpose_into_gemm(self): # type: () -> None
12691269
assert len(optimized_model.graph.node[3].attribute[0].g.node) == 1
12701270
assert optimized_model.graph.node[3].attribute[0].g.node[0].op_type == "Gemm"
12711271

1272+
def _conv_mul_graph(self, scale_shape, with_bias):
1273+
# Conv(X, W[, B]) -> Mul(., S), with constant W/B/S.
1274+
C_out, C_in, k = 4, 3, 3
1275+
W = np.random.rand(C_out, C_in, k, k).astype(np.float32)
1276+
S = np.random.rand(*scale_shape).astype(np.float32)
1277+
inits = [
1278+
numpy_helper.from_array(W, "W"),
1279+
numpy_helper.from_array(S, "S"),
1280+
]
1281+
conv_inputs = ["X", "W"]
1282+
if with_bias:
1283+
B = np.random.rand(C_out).astype(np.float32)
1284+
inits.append(numpy_helper.from_array(B, "B"))
1285+
conv_inputs.append("B")
1286+
nodes = [
1287+
helper.make_node("Conv", conv_inputs, ["Z"], pads=[1, 1, 1, 1]),
1288+
helper.make_node("Mul", ["Z", "S"], ["Y"]),
1289+
]
1290+
return helper.make_graph(
1291+
nodes,
1292+
"test",
1293+
[helper.make_tensor_value_info("X", TensorProto.FLOAT, (1, C_in, 8, 8))],
1294+
[helper.make_tensor_value_info("Y", TensorProto.FLOAT, (1, C_out, 8, 8))],
1295+
initializer=inits,
1296+
)
1297+
1298+
def _conv_output_feeds_mul(self, model):
1299+
conv_outputs = {
1300+
o for n in model.graph.node if n.op_type == "Conv" for o in n.output
1301+
}
1302+
return any(
1303+
n.op_type == "Mul" and any(i in conv_outputs for i in n.input)
1304+
for n in model.graph.node
1305+
)
1306+
1307+
def test_fuse_mul_into_conv_per_channel(self): # type: () -> None
1308+
# A per-output-channel scale (shape [1, C, 1, 1]) is folded into the Conv
1309+
# weights, so no Mul consumes the Conv output any more. _optimized also
1310+
# checks numerical equivalence via onnxruntime.
1311+
for with_bias in (True, False):
1312+
graph = self._conv_mul_graph((1, 4, 1, 1), with_bias)
1313+
optimized_model = self._optimized(graph, ["fuse_mul_into_conv"])
1314+
assert (
1315+
sum(n.op_type == "Conv" for n in optimized_model.graph.node) == 1
1316+
)
1317+
assert not self._conv_output_feeds_mul(optimized_model)
1318+
1319+
def test_fuse_mul_into_conv_scalar(self): # type: () -> None
1320+
graph = self._conv_mul_graph((1,), True)
1321+
optimized_model = self._optimized(graph, ["fuse_mul_into_conv"])
1322+
assert not self._conv_output_feeds_mul(optimized_model)
1323+
1324+
def test_fuse_mul_into_conv_no_fuse_non_channel_scale(self): # type: () -> None
1325+
# A per-pixel scale ([1, 1, H, W]) is not a per-channel scale and must not
1326+
# be folded into the weights.
1327+
graph = self._conv_mul_graph((1, 1, 8, 8), False)
1328+
optimized_model = self._optimized(graph, ["fuse_mul_into_conv"])
1329+
assert self._conv_output_feeds_mul(optimized_model)
1330+
12721331
def test_fuse_add_bias_into_conv_with_scalar_bias(self): # type: () -> None
12731332
nodes = [
12741333
helper.make_node("Conv", ["X", "Y"], ["Z"]),

0 commit comments

Comments
 (0)