Skip to content

Commit 66967ef

Browse files
rascaniclaude
andauthored
Cortex-M backend: supply a zero bias for grouped convolutions (pytorch#21825)
### Summary CMSIS-NN's grouped convolutions index the bias per group rather than from a fixed base, so they need a real bias pointer: arm_convolve_s8 advances it once per group in every build, and the MVE arm_depthwise_conv_s8_opt offsets it per channel block. Leaving it null means those kernels read from an offset null pointer, and on Corstone-300 the affected output channels come back pinned at the int8 ceiling. Lower a zero bias for convolutions that do not have one. Both paths get it on every target. Strictly only the MVE depthwise kernel needs it, since the DSP and scalar depthwise kernels re-base per row, but which depthwise kernel a convolution reaches depends on channel multiplier, batch, dilation, kernel size and padding, and that is not worth predicting from the graph. It costs nothing on the models in tree, where every convolution already carries a folded bias. The existing conv2d_groups test missed this because its all-positive ramp summed straight to qmax, where a wrong result is indistinguishable from the correct one; it now convolves 3x3 over 8x8 with a signed input. A grouped case that does carry a bias is added next to it, and a structural test asserts the bias slot is populated for both the grouped and depthwise ops, since the numeric cases only cover this on the FVP leg and cannot reach the depthwise channel counts at all. ### Test plan Verified on Corstone-300 for scalar (cortex-m0plus), DSP (cortex-m7) and MVE (cortex-m55). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 88f0c74 commit 66967ef

2 files changed

Lines changed: 61 additions & 2 deletions

File tree

backends/cortex_m/passes/aten_to_cortex_m_pass.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,19 @@ def _get_convolution_replacement(
538538
weight_permuted = param_weight_tensor.permute(0, 2, 3, 1).contiguous()
539539

540540
with node.graph.inserting_after(weight):
541+
if bias is None and groups > 1:
542+
# CMSIS-NN's grouped kernels offset the bias per group, and the MVE
543+
# depthwise one per channel block, so they need a real pointer rather
544+
# than null. Supplied for both paths on every target: which depthwise
545+
# kernel a convolution reaches is not predictable from the graph.
546+
bias = create_constant_placeholder(
547+
exported_program,
548+
node.graph,
549+
node.name + "_zero_bias",
550+
InputKind.PARAMETER,
551+
torch.zeros(out_channels, dtype=torch.int32),
552+
)
553+
541554
weight_nhwc = create_constant_placeholder(
542555
exported_program,
543556
node.graph,

backends/cortex_m/test/ops/test_conv.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
McuTestCase,
1212
ramp_tensor,
1313
)
14+
from executorch.backends.test.harness.stages import StageType
15+
from executorch.exir.dialects._ops import ops as exir_ops
1416

1517

1618
class CortexMConv1D(torch.nn.Module):
@@ -181,10 +183,21 @@ def forward(self, x):
181183
ramp_tensor(0, 10, (3, 1, 8, 8)).to(memory_format=torch.channels_last),
182184
),
183185
),
186+
# A bias-less grouped convolution is what needs a bias synthesized for it, so
187+
# keep this case bias-less, keep groups > 1, and keep the reference output
188+
# away from saturation: the previous 1x1-output version summed an
189+
# all-positive ramp straight to qmax, where a wrong result is
190+
# indistinguishable from the correct one.
184191
"conv2d_groups": McuTestCase(
185-
model=CortexMConv2D(4, 4, 1, groups=2),
192+
model=CortexMConv2D(4, 4, 3, padding=1, groups=2),
186193
example_inputs=(
187-
ramp_tensor(0, 10, (1, 4, 1, 1)).to(memory_format=torch.channels_last),
194+
ramp_tensor(-5, 5, (1, 4, 8, 8)).to(memory_format=torch.channels_last),
195+
),
196+
),
197+
"conv2d_groups_bias": McuTestCase(
198+
model=CortexMConv2DBias(4, 4, 3, padding=1, groups=2),
199+
example_inputs=(
200+
ramp_tensor(-5, 5, (1, 4, 8, 8)).to(memory_format=torch.channels_last),
188201
),
189202
),
190203
"conv2d_bias_ch_out_1": McuTestCase(
@@ -343,3 +356,36 @@ def test_implementation_conv2d(test_case, cortex_m_target):
343356
test_case.model, test_case.example_inputs, target_config=cortex_m_target
344357
)
345358
tester.test_implementation(qtol=2)
359+
360+
361+
@parametrize(
362+
"test_case",
363+
{name: test_cases[name] for name in ("conv2d_groups", "depthwise_conv2d")},
364+
)
365+
def test_grouped_conv2d_bias_is_populated(test_case, cortex_m_target):
366+
"""Assert a bias-less grouped convolution reaches the kernel with a bias.
367+
368+
The numeric cases only cover this on the FVP leg and only while their
369+
reference output stays clear of saturation, and the depthwise kernel
370+
offsets the bias by whole channel blocks, so it takes hundreds of channels
371+
before a missing bias shows up at all -- far more than any case here.
372+
"""
373+
tester = CortexMTester(
374+
test_case.model, test_case.example_inputs, target_config=cortex_m_target
375+
)
376+
tester.quantize()
377+
tester.export()
378+
tester.to_edge()
379+
tester.run_passes()
380+
381+
module = tester.get_artifact(StageType.RUN_PASSES).exported_program().module()
382+
grouped_convs = (
383+
exir_ops.edge.cortex_m.quantized_conv2d.default,
384+
exir_ops.edge.cortex_m.quantized_depthwise_conv2d.default,
385+
)
386+
[conv_node] = [
387+
n
388+
for n in module.graph.nodes
389+
if n.op == "call_function" and n.target in grouped_convs
390+
]
391+
assert conv_node.args[2] is not None

0 commit comments

Comments
 (0)