Skip to content

Commit 0b3d26d

Browse files
authored
Materialize zero bias for conv replacement (pytorch#23354)
Summary: `aten::convolution` declares `Tensor? bias`, so a conv built with `bias=False` lowers to a node whose bias argument is `None`. `cadence::conv1d/conv2d/conv3d` declare a non-optional `Tensor bias`, with no way to express absence. Replacing a biasless convolution therefore yields a node that strict Edge validation rejects for schema mismatch. Materialize an explicit zero bias via `edge.aten.full.default`, sized to the convolution's output channels and typed from the weight, so the replacement is schema-valid. Adding zeros is numerically a no-op. If the Cadence kernels gain optional-bias support, as discussed on the diff, this method can be deleted outright. Reviewed By: DrJessop Differential Revision: D110672307 Pull Request resolved: pytorch#23354
1 parent fbf91c2 commit 0b3d26d

2 files changed

Lines changed: 210 additions & 5 deletions

File tree

‎backends/cadence/aot/replace_ops.py‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,35 @@ class ReplaceAtenConvolutionWithCadenceConvolutionPass(RemoveOrReplacePassInterf
722722
def targets(self) -> list[EdgeOpOverload]:
723723
return [exir_ops.edge.aten.convolution.default]
724724

725+
def materialize_zero_bias(
726+
self,
727+
node: torch.fx.Node,
728+
weight: torch.fx.Node,
729+
) -> torch.fx.Node:
730+
out_val = node.meta["val"]
731+
weight_val = weight.meta["val"]
732+
out_shape = out_val.shape
733+
if len(out_shape) < 2:
734+
raise ValueError(
735+
f"Expected convolution output rank >= 2, got shape {out_shape}"
736+
)
737+
# edge.aten.full accepts SymInt[] sizes, so retain symbolic channels.
738+
bias_size = out_shape[1]
739+
bias_dtype = weight_val.dtype
740+
with node.graph.inserting_before(node):
741+
zero_bias = node.graph.call_function(
742+
exir_ops.edge.aten.full.default,
743+
args=([bias_size], 0.0),
744+
kwargs={"dtype": bias_dtype},
745+
)
746+
# Populate per-field rather than aliasing node.meta: "val" has to
747+
# describe this bias, and "stack_trace" is the convolution's provenance,
748+
# so copying it would misattribute the synthesized constant.
749+
zero_bias.meta["val"] = weight_val.new_zeros((bias_size,), dtype=bias_dtype)
750+
if "debug_handle" in node.meta:
751+
zero_bias.meta["debug_handle"] = node.meta["debug_handle"]
752+
return zero_bias
753+
725754
def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool:
726755
# There must be 9 total args.
727756
if len(node.args) != 9:
@@ -770,6 +799,16 @@ def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool:
770799
else: # len(stride) == 3
771800
target = exir_ops.edge.cadence.conv3d.default
772801

802+
# Must reject before the bias is materialized below; bailing out after
803+
# would strand the synthesized full node in the graph. Transposed
804+
# convolutions accept non-zero output_padding, hence the guard.
805+
if not transposed and not all(x == 0 for x in output_padding):
806+
return False
807+
808+
if bias is None:
809+
assert isinstance(weight, torch.fx.Node)
810+
bias = self.materialize_zero_bias(node, weight)
811+
773812
with node.graph.inserting_before(node):
774813
if transposed:
775814
# Flip the height and width dimensions of weight, since we apply a
@@ -804,10 +843,6 @@ def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool:
804843
False,
805844
)
806845
else:
807-
# Verify that output_padding is 0.
808-
if not all(x == 0 for x in output_padding):
809-
return False
810-
811846
# Keep the original stride to maintain correct output dimensions
812847
new_stride = stride
813848

‎backends/cadence/aot/tests/test_replace_ops_passes.py‎

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,8 @@ def test_replace_aten_conv_with_cadence_conv(
520520
groups,
521521
),
522522
)
523+
if not bias_enabled:
524+
convolution.node.meta["debug_handle"] = 1234
523525
builder.output([convolution])
524526
original_gm = builder.get_graph_module()
525527

@@ -550,13 +552,171 @@ def test_replace_aten_conv_with_cadence_conv(
550552
count_node(graph_after_passes, exir_ops.edge.cadence.conv1d.default),
551553
1,
552554
)
555+
for node in graph_after_passes.graph.nodes:
556+
if node.target == exir_ops.edge.cadence.conv1d.default:
557+
self.assertIsNotNone(node.args[2])
558+
if not bias_enabled:
559+
bias_node = node.args[2]
560+
self.assertIsInstance(bias_node, torch.fx.Node)
561+
self.assertEqual(
562+
exir_ops.edge.aten.full.default,
563+
cast(torch.fx.Node, bias_node).target,
564+
)
565+
self.assertEqual(
566+
1234, cast(torch.fx.Node, bias_node).meta["debug_handle"]
567+
)
568+
self.assertEqual(
569+
(out_channels,),
570+
tuple(cast(torch.fx.Node, bias_node).meta["val"].shape),
571+
)
553572
self.assertEqual(
554573
count_node(
555574
graph_after_passes, exir_ops.edge.cadence.transposed_convolution.default
556575
),
557576
0,
558577
)
559578

579+
@expand([[2], [3]])
580+
@torch.no_grad()
581+
def test_replace_aten_multidimensional_conv_materializes_zero_bias(
582+
self,
583+
spatial_dims: int,
584+
) -> None:
585+
in_channels = 4
586+
out_channels = 6
587+
builder = GraphBuilder()
588+
x_tensor = torch.randn(
589+
1,
590+
in_channels,
591+
*([8] * spatial_dims),
592+
dtype=torch.float32,
593+
)
594+
weights_tensor = torch.randn(
595+
out_channels,
596+
in_channels,
597+
*([3] * spatial_dims),
598+
dtype=torch.float32,
599+
)
600+
x = builder.placeholder("x", x_tensor)
601+
weights = builder.placeholder("weights", weights_tensor)
602+
convolution = builder.call_operator(
603+
op=exir_ops.edge.aten.convolution.default,
604+
args=(
605+
x,
606+
weights,
607+
None,
608+
[1] * spatial_dims,
609+
[0] * spatial_dims,
610+
[1] * spatial_dims,
611+
False,
612+
[0] * spatial_dims,
613+
1,
614+
),
615+
)
616+
convolution.node.meta["debug_handle"] = 1234
617+
convolution.node.meta["stack_trace"] = "convolution-only provenance"
618+
builder.output([convolution])
619+
original_gm = builder.get_graph_module()
620+
gm_before = copy.deepcopy(original_gm)
621+
622+
result = cast(
623+
PassResult,
624+
ReplaceAtenConvolutionWithCadenceConvolutionPass()(original_gm),
625+
)
626+
self.assertTrue(result.modified)
627+
graph_after_passes = result.graph_module
628+
validate(
629+
gm_before,
630+
graph_after_passes,
631+
(x_tensor, weights_tensor),
632+
"ReplaceAtenConvolutionWithCadenceConvolutionPass",
633+
)
634+
635+
target = (
636+
exir_ops.edge.cadence.conv2d.default
637+
if spatial_dims == 2
638+
else exir_ops.edge.cadence.conv3d.default
639+
)
640+
replacement = next(
641+
node for node in graph_after_passes.graph.nodes if node.target == target
642+
)
643+
bias_node = replacement.args[2]
644+
self.assertIsInstance(bias_node, torch.fx.Node)
645+
assert isinstance(bias_node, torch.fx.Node)
646+
self.assertEqual(exir_ops.edge.aten.full.default, bias_node.target)
647+
self.assertEqual((out_channels,), tuple(bias_node.meta["val"].shape))
648+
self.assertEqual(1234, bias_node.meta["debug_handle"])
649+
self.assertNotIn("stack_trace", bias_node.meta)
650+
651+
@torch.no_grad()
652+
def test_materialized_zero_bias_metadata_matches_runtime_value(self) -> None:
653+
for weight_dtype, bias_dtype in (
654+
(torch.float32, torch.float32),
655+
(torch.float64, torch.float64),
656+
):
657+
with self.subTest(weight_dtype=weight_dtype):
658+
graph = torch.fx.Graph()
659+
input_node = graph.placeholder("x")
660+
weight = graph.placeholder("weight")
661+
weight.meta["val"] = torch.ones(6, 2, 3, dtype=weight_dtype)
662+
source = graph.call_function(
663+
exir_ops.edge.aten.relu.default,
664+
args=(input_node,),
665+
)
666+
source.meta["val"] = torch.ones(2, 6, 4, dtype=torch.float32)
667+
source.meta["debug_handle"] = 1234
668+
669+
zero_bias = ReplaceAtenConvolutionWithCadenceConvolutionPass().materialize_zero_bias(
670+
source,
671+
weight,
672+
)
673+
674+
self.assertEqual(exir_ops.edge.aten.full.default, zero_bias.target)
675+
self.assertEqual(([6], 0.0), zero_bias.args)
676+
self.assertEqual({"dtype": bias_dtype}, zero_bias.kwargs)
677+
torch.testing.assert_close(
678+
zero_bias.meta["val"],
679+
weight.meta["val"].new_zeros(6, dtype=bias_dtype),
680+
)
681+
self.assertEqual(bias_dtype, zero_bias.meta["val"].dtype)
682+
self.assertEqual(
683+
weight.meta["val"].device,
684+
zero_bias.meta["val"].device,
685+
)
686+
self.assertEqual(1234, zero_bias.meta["debug_handle"])
687+
self.assertLess(
688+
list(graph.nodes).index(zero_bias),
689+
list(graph.nodes).index(source),
690+
)
691+
692+
@torch.no_grad()
693+
def test_rejected_convolution_does_not_materialize_zero_bias(self) -> None:
694+
builder = GraphBuilder()
695+
x = builder.placeholder("x", torch.randn(1, 4, 8))
696+
weights = builder.placeholder("weights", torch.randn(6, 4, 3))
697+
convolution = builder.call_operator(
698+
op=exir_ops.edge.aten.convolution.default,
699+
args=(x, weights, None, [1], [0], [1], False, [1], 1),
700+
)
701+
builder.output([convolution])
702+
703+
result = cast(
704+
PassResult,
705+
ReplaceAtenConvolutionWithCadenceConvolutionPass()(
706+
builder.get_graph_module()
707+
),
708+
)
709+
710+
self.assertFalse(result.modified)
711+
self.assertEqual(
712+
1,
713+
count_node(result.graph_module, exir_ops.edge.aten.convolution.default),
714+
)
715+
self.assertEqual(
716+
0,
717+
count_node(result.graph_module, exir_ops.edge.aten.full.default),
718+
)
719+
560720
@expand(
561721
[
562722
[(1, 8, 16), 8, 16, 3],
@@ -649,6 +809,16 @@ def test_replace_aten_transposed_conv_with_cadence_transposed_conv(
649809
),
650810
1,
651811
)
812+
for node in graph_after_passes.graph.nodes:
813+
if node.target == exir_ops.edge.cadence.transposed_convolution.default:
814+
self.assertIsNotNone(node.args[2])
815+
if not bias_enabled:
816+
bias_node = node.args[2]
817+
self.assertIsInstance(bias_node, torch.fx.Node)
818+
self.assertEqual(
819+
exir_ops.edge.aten.full.default,
820+
cast(torch.fx.Node, bias_node).target,
821+
)
652822

653823
@expand(
654824
[
@@ -947,7 +1117,7 @@ def test_replace_repeat_with_cat(self) -> None:
9471117
def test_replace_masked_scalar_tensor_with_full(
9481118
self,
9491119
shape: Tuple[int],
950-
mask_shape: Union[Tuple[int, ...], None] = None,
1120+
mask_shape: Optional[Tuple[int, ...]] = None,
9511121
) -> None:
9521122
builder = GraphBuilder()
9531123
x = builder.placeholder("x", torch.randn(*shape, dtype=torch.float32))

0 commit comments

Comments
 (0)