From 5e92fb7faf1f9ba0412a59705cc53ea5c9096caf Mon Sep 17 00:00:00 2001 From: hemanth1999k Date: Sat, 10 Oct 2026 23:25:28 -0500 Subject: [PATCH 1/2] Do not delegate CoreML reflect/replicate pads beyond the last two dims Core ML only supports reflect and replicate padding on the last two dimensions. ReflectionPad3d, ReplicationPad3d, or a reflect pad of just the depth dimension of a 5D input lowered fine, then failed to load: Padding for more than two dimensions only supports `constant` mode The partitioner now overrides support for those pads, so ops_to_not_decompose no longer keeps them whole. They decompose into ops Core ML runs, and the model stays delegated and matches eager. Fixes #11708 Fixes #11709 --- .../coreml/partition/coreml_partitioner.py | 42 +++++++++++++++ .../coreml/test/test_coreml_partitioner.py | 53 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/backends/apple/coreml/partition/coreml_partitioner.py b/backends/apple/coreml/partition/coreml_partitioner.py index b4e550d8479..51da61b38f3 100644 --- a/backends/apple/coreml/partition/coreml_partitioner.py +++ b/backends/apple/coreml/partition/coreml_partitioner.py @@ -83,6 +83,41 @@ def _is_arg_min_max_over_flattened_input(node: torch.fx.Node) -> bool: return dim is None +_PAD_TARGETS = ( + torch.ops.aten.pad.default, + exir_ops.edge.aten.pad.default, +) + +_PAD_3D_TARGETS = ( + torch.ops.aten.reflection_pad3d.default, + torch.ops.aten.replication_pad3d.default, + exir_ops.edge.aten.reflection_pad3d.default, + exir_ops.edge.aten.replication_pad3d.default, +) + + +def _is_pad_unsupported_by_coreml(node: torch.fx.Node) -> bool: + """Non-constant padding that Core ML cannot load. + + Core ML only supports ``reflect`` / ``replicate`` padding on the last two + dimensions; anything further out (e.g. ``ReflectionPad3d``, or padding + just the depth dimension of a 5D input) lowers, then fails to load with + "Padding for more than two dimensions only supports `constant` mode". + Rejecting the op lets it decompose into ops Core ML does run. See + pytorch/executorch#11708 and pytorch/executorch#11709. + """ + if node.target in _PAD_3D_TARGETS: + return True + if node.target not in _PAD_TARGETS: + return False + mode = node.args[2] if len(node.args) >= 3 else node.kwargs.get("mode", "constant") + if mode not in ("reflect", "replicate"): + return False + # ``pad`` lists (before, after) pairs starting from the last dimension. + pad = node.args[1] + return any(p != 0 for p in pad[4:]) + + def _is_view_op(op: torch._ops.OpOverload) -> bool: schema = op._schema if len(schema.arguments) == 0: @@ -159,6 +194,13 @@ def should_override_support(self, node) -> bool: ) return True + if _is_pad_unsupported_by_coreml(node): + self.log_once( + "Non-constant padding outside the last two dimensions is not " + "supported by CoreML. Overriding op support." + ) + return True + # TODO: enable this after bugs in ExecuTorch's partitioner are fixed # # If lower_full_graph=False, do not partition nodes with symbolic args because it can result in symbolic args # # in the placeholders due to partitioning, which CoreML does not support diff --git a/backends/apple/coreml/test/test_coreml_partitioner.py b/backends/apple/coreml/test/test_coreml_partitioner.py index 78dd114eeda..42e05a0b555 100644 --- a/backends/apple/coreml/test/test_coreml_partitioner.py +++ b/backends/apple/coreml/test/test_coreml_partitioner.py @@ -433,6 +433,58 @@ def forward(self, x): self.assertIn("executorch_call_delegate", op_names) self.assertNotIn("aten.argmax.default", op_names) + def test_nonconstant_pad_beyond_last_two_dims_is_decomposed(self): + """ + Regression test for https://github.com/pytorch/executorch/issues/11708 + and https://github.com/pytorch/executorch/issues/11709. + + Core ML only supports reflect / replicate padding on the last two + dimensions. Handing it a wider pad lowers fine but the model fails to + load ("Padding for more than two dimensions only supports `constant` + mode"). The partitioner must not keep such a pad whole, so it + decomposes into ops Core ML can run; 2D reflect padding stays intact. + """ + + class Pad(torch.nn.Module): + def __init__(self, pad, mode): + super().__init__() + self.pad = pad + self.mode = mode + + def forward(self, x): + return torch.nn.functional.pad(x, self.pad, mode=self.mode) + + x5 = torch.randn(1, 3, 6, 6, 6) + cases = [ + (torch.nn.ReflectionPad3d(2), x5), + (torch.nn.ReplicationPad3d(2), x5), + # Pads a single dimension, but not one of the last two. + (Pad((0, 0, 0, 0, 1, 1), "reflect"), x5), + ] + for model, x in cases: + model = model.eval() + ep = torch.export.export(model, (x,), strict=True) + not_decomposed, _ = CoreMLPartitioner().ops_to_not_decompose(ep) + self.assertNotIn(torch.ops.aten.pad.default, not_decomposed, model) + + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner()] + ) + if _TEST_RUNTIME: + et_prog = edge.to_executorch() + program = Runtime.get().load_program(et_prog.buffer) + et_outputs = program.load_method("forward").execute((x,))[0] + self.assertTrue( + torch.allclose(et_outputs, model(x), atol=1e-02, rtol=1e-02), + model, + ) + + ep = torch.export.export( + Pad((1, 1, 1, 1), "reflect").eval(), (torch.randn(1, 3, 6, 6),) + ) + not_decomposed, _ = CoreMLPartitioner().ops_to_not_decompose(ep) + self.assertIn(torch.ops.aten.pad.default, not_decomposed) + def test_tied_embedding_is_quantized_with_its_linear(self): """A weight that is both an embedding table and an output projection lowers. @@ -572,6 +624,7 @@ def forward(self, x): test_runner.test_take_over_constant_data_false() test_runner.test_aten_rand_default_falls_back_to_portable() test_runner.test_aten_randn_is_still_delegated() + test_runner.test_nonconstant_pad_beyond_last_two_dims_is_decomposed() test_runner.test_tied_embedding_is_quantized_with_its_linear() test_runner.test_deprecation_warning_for_to_backend_workflow() test_runner.test_no_warning_for_to_edge_transform_and_lower_workflow() From d12c798271805329b22e3b576f879decab18c2eb Mon Sep 17 00:00:00 2001 From: hemanth1999k Date: Sat, 17 Oct 2026 12:29:26 -0500 Subject: [PATCH 2/2] Check where CoreML pads end up in the lowered graph The pad regression test only checked ops_to_not_decompose and, on macOS, the numerics, so it never showed where the pad ran. Lower every case and assert on the graph: pads Core ML cannot load are decomposed (no pad left in the graph or in the delegate, the index-based decomposition is delegated), and 2D reflect / replicate pads are delegated whole as pad. --- .../coreml/test/test_coreml_partitioner.py | 62 +++++++++++++------ 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/backends/apple/coreml/test/test_coreml_partitioner.py b/backends/apple/coreml/test/test_coreml_partitioner.py index 42e05a0b555..0cd84ee9102 100644 --- a/backends/apple/coreml/test/test_coreml_partitioner.py +++ b/backends/apple/coreml/test/test_coreml_partitioner.py @@ -442,7 +442,9 @@ def test_nonconstant_pad_beyond_last_two_dims_is_decomposed(self): dimensions. Handing it a wider pad lowers fine but the model fails to load ("Padding for more than two dimensions only supports `constant` mode"). The partitioner must not keep such a pad whole, so it - decomposes into ops Core ML can run; 2D reflect padding stays intact. + decomposes into ops Core ML can run (index / arange / clamp) and the + model stays delegated; 2D reflect / replicate padding is delegated + intact as ``pad``. """ class Pad(torch.nn.Module): @@ -454,22 +456,17 @@ def __init__(self, pad, mode): def forward(self, x): return torch.nn.functional.pad(x, self.pad, mode=self.mode) - x5 = torch.randn(1, 3, 6, 6, 6) - cases = [ - (torch.nn.ReflectionPad3d(2), x5), - (torch.nn.ReplicationPad3d(2), x5), - # Pads a single dimension, but not one of the last two. - (Pad((0, 0, 0, 0, 1, 1), "reflect"), x5), - ] - for model, x in cases: - model = model.eval() + def lower(model, x): ep = torch.export.export(model, (x,), strict=True) - not_decomposed, _ = CoreMLPartitioner().ops_to_not_decompose(ep) - self.assertNotIn(torch.ops.aten.pad.default, not_decomposed, model) - edge = executorch.exir.to_edge_transform_and_lower( ep, partitioner=[CoreMLPartitioner()] ) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + delegated = format_delegated_graph(edge.exported_program().graph_module) if _TEST_RUNTIME: et_prog = edge.to_executorch() program = Runtime.get().load_program(et_prog.buffer) @@ -478,12 +475,41 @@ def forward(self, x): torch.allclose(et_outputs, model(x), atol=1e-02, rtol=1e-02), model, ) + return ep, op_names, delegated - ep = torch.export.export( - Pad((1, 1, 1, 1), "reflect").eval(), (torch.randn(1, 3, 6, 6),) - ) - not_decomposed, _ = CoreMLPartitioner().ops_to_not_decompose(ep) - self.assertIn(torch.ops.aten.pad.default, not_decomposed) + x5 = torch.randn(1, 3, 6, 6, 6) + unsupported = [ + (torch.nn.ReflectionPad3d(2), x5), + (torch.nn.ReplicationPad3d(2), x5), + # Pads a single dimension, but not one of the last two. + (Pad((0, 0, 0, 0, 1, 1), "reflect"), x5), + ] + for model, x in unsupported: + model = model.eval() + ep, op_names, delegated = lower(model, x) + not_decomposed, _ = CoreMLPartitioner().ops_to_not_decompose(ep) + self.assertNotIn(torch.ops.aten.pad.default, not_decomposed, model) + # The pad is decomposed, not kept whole: no pad op is left in the + # graph or handed to Core ML, and its decomposition is delegated. + self.assertIn("executorch_call_delegate", op_names, model) + self.assertNotIn("aten.pad.default", op_names, model) + self.assertNotIn("aten.pad.default", delegated, model) + self.assertIn("aten.index.Tensor", delegated, model) + + x4 = torch.randn(1, 3, 6, 6) + supported = [ + Pad((1, 1, 1, 1), "reflect"), + Pad((1, 1, 1, 1), "replicate"), + ] + for model in supported: + model = model.eval() + ep, op_names, delegated = lower(model, x4) + not_decomposed, _ = CoreMLPartitioner().ops_to_not_decompose(ep) + self.assertIn(torch.ops.aten.pad.default, not_decomposed, model) + # The pad itself is handed to Core ML. + self.assertIn("executorch_call_delegate", op_names, model) + self.assertNotIn("aten.pad.default", op_names, model) + self.assertIn("aten.pad.default", delegated, model) def test_tied_embedding_is_quantized_with_its_linear(self): """A weight that is both an embedding table and an output projection lowers.