From 63013ec8708e6999088b9ea2c486fd5566ec5405 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 14:07:50 -0700 Subject: [PATCH 01/21] Qualcomm AI Engine Direct - Check that the constant-only partition test keeps the constants on CPU test_partitioner_keeps_constant_only_subgraph_on_cpu only lowered the model and serialized it. It caught the original crash, but it would also pass if the partitioner dropped every partition, so nothing was delegated at all. Check the lowered graph instead. The constant-only part (table * 2 - 1) must stay in the top-level graph, and the rest (the add that reads x) must still go to a delegate. With the partitioner fix removed, the test still fails as before, when building the context binary. With the fix changed to drop every partition, the test now fails on the delegate check, where it used to pass. Ran test_passes.py with the QNN SDK on Linux x86_64. --- backends/qualcomm/tests/test_passes.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index 58ce89a6e30..5546934faf4 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -967,6 +967,13 @@ def forward(self, x): if "Failed to initialize QNN backend" in str(e): self.skipTest(f"QNN SDK not available: {e}") raise + top_level_ops = { + node.target + for node in edge.exported_program().graph.nodes + if node.op == "call_function" + } + self.assertIn(exir_ops.edge.aten.mul.Tensor, top_level_ops) + self.assertIn(torch.ops.higher_order.executorch_call_delegate, top_level_ops) edge.to_executorch() def test_partitioner_falls_back_on_bfloat16(self): From a12c936f842c1ddca621b5647626e8a7a8874f29 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 09:56:17 -0700 Subject: [PATCH 02/21] Qualcomm AI Engine Direct - Keep node names unique across QNN passes torch.export names a program's inputs after the user's arguments, but it does not record those names in the graph's namespace. So a QNN pass that adds nodes can give a new node the name of an input, and the pass then fails. With an input named t_1 and an autocast region that calls t() twice, DecomposeWrapWithAutocast copies a second transpose also named t_1. With an input named mul, the attention decomposition creates a second mul: RuntimeError: Node redefined name t_1! RuntimeError: Node redefined name mul! LeRobot's vla_jepa and molmoact2 policies hit this when lowering to QNN. The QNN pass manager now records every existing node name in the graph's namespace before it runs a pipeline's passes, so every pass that adds nodes gets fresh names. torch.fx has no public way to reserve a name, so this adds to the namespace's private name set. test_export_pipeline_keeps_node_names_unique runs the export pipeline on both models and compares with eager. Both cases fail without this change. --- backends/qualcomm/_passes/qnn_pass_manager.py | 6 ++++ backends/qualcomm/tests/test_passes.py | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/backends/qualcomm/_passes/qnn_pass_manager.py b/backends/qualcomm/_passes/qnn_pass_manager.py index e8745524512..2bce6d34d5b 100644 --- a/backends/qualcomm/_passes/qnn_pass_manager.py +++ b/backends/qualcomm/_passes/qnn_pass_manager.py @@ -100,6 +100,12 @@ class QnnPassManager(PassManager): def _transform(self, graph_module: GraphModule): + # torch.export names placeholders after the user's arguments without + # recording those names in the graph's namespace, so a pass that adds + # nodes could hand one of them out again. + namespace = graph_module.graph._graph_namespace + for node in graph_module.graph.nodes: + namespace._used_names.add(node.name) return self(graph_module).graph_module def _reset(self): diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index 5546934faf4..30ec4dd5700 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -188,6 +188,34 @@ def forward(self, x): ) torch.testing.assert_close(gm(x), model(x)) + def test_export_pipeline_keeps_node_names_unique(self): + # torch.export names the inputs t_1 and mul, which the autocast and + # attention decompositions would otherwise give to new nodes too. + class Autocast(torch.nn.Module): + def __init__(self): + super().__init__() + self.w = torch.nn.Parameter(torch.randn(8, 8)) + + def forward(self, x, t_1): + with torch.autocast("cpu", dtype=torch.bfloat16): + y = x @ self.w.t() @ self.w.t() + return y.float() + t_1 + + class Attention(torch.nn.Module): + def forward(self, q, k, v, mul): + attention = torch.nn.functional.scaled_dot_product_attention(q, k, v) + return attention + mul + + cases = ( + (Autocast(), (torch.randn(4, 8), torch.randn(4, 8))), + (Attention(), tuple(torch.randn(1, 2, 4, 8) for _ in range(4))), + ) + for model, inputs in cases: + with self.subTest(model=type(model).__name__): + ep = torch.export.export(model.eval(), inputs) + ep = get_qnn_pass_manager_cls()().transform_for_export_pipeline(ep) + torch.testing.assert_close(ep.module()(*inputs), model(*inputs)) + def test_build_op_wrappers_returns_context_binary(self): op_name = "ctx_loader_build" ctx_bin = b"qnn_context_binary" From a1969e49099453623340705668922ec87cb17ca0 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 20:50:52 -0700 Subject: [PATCH 03/21] Qualcomm AI Engine Direct - Mark the node name reservation as a workaround The QNN pass manager writes the graph namespace's private name set to work around torch.export, which assigns input names, and renames other nodes to make room, without recording them. Say so in a TODO at that loop, so it can be removed once torch.export records the names. test_export_pipeline_keeps_node_names_unique only compared outputs. ep.module() copies the graph and renames duplicate names, so that comparison cannot see a collision. It failed without the fix only because the passes that collide here happen to lint the graph. The test now lints the graph the pipeline returns, and first checks that t_1 and mul really are input names, so the test cannot pass by accident. Without the reservation, the Autocast and Attention cases fail with "Node redefined name". Ran test_passes.py with the QNN SDK on Linux x86_64. --- backends/qualcomm/_passes/qnn_pass_manager.py | 10 +++++++--- backends/qualcomm/tests/test_passes.py | 14 +++++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/backends/qualcomm/_passes/qnn_pass_manager.py b/backends/qualcomm/_passes/qnn_pass_manager.py index 2bce6d34d5b..d30f83d7ca1 100644 --- a/backends/qualcomm/_passes/qnn_pass_manager.py +++ b/backends/qualcomm/_passes/qnn_pass_manager.py @@ -100,9 +100,13 @@ class QnnPassManager(PassManager): def _transform(self, graph_module: GraphModule): - # torch.export names placeholders after the user's arguments without - # recording those names in the graph's namespace, so a pass that adds - # nodes could hand one of them out again. + # torch.export names inputs after the user's arguments, and renames + # other nodes to make room, without recording those names in the + # graph's namespace, so a pass that adds nodes could hand one of them + # out again. + # TODO: Remove once torch.export records the names it assigns. torch.fx + # has no public way to reserve a name, so this writes the namespace's + # private name set. namespace = graph_module.graph._graph_namespace for node in graph_module.graph.nodes: namespace._used_names.add(node.name) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index 30ec4dd5700..ed0e3698758 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -207,13 +207,21 @@ def forward(self, q, k, v, mul): return attention + mul cases = ( - (Autocast(), (torch.randn(4, 8), torch.randn(4, 8))), - (Attention(), tuple(torch.randn(1, 2, 4, 8) for _ in range(4))), + (Autocast(), (torch.randn(4, 8), torch.randn(4, 8)), "t_1"), + (Attention(), tuple(torch.randn(1, 2, 4, 8) for _ in range(4)), "mul"), ) - for model, inputs in cases: + for model, inputs, name in cases: with self.subTest(model=type(model).__name__): ep = torch.export.export(model.eval(), inputs) + # Guard against a vacuous test: the colliding name has to be + # an input's. + self.assertIn( + name, [n.name for n in ep.graph.nodes if n.op == "placeholder"] + ) ep = get_qnn_pass_manager_cls()().transform_for_export_pipeline(ep) + # ep.module() copies the graph and renames duplicates, so lint + # the graph the passes produced. + ep.graph_module.graph.lint() torch.testing.assert_close(ep.module()(*inputs), model(*inputs)) def test_build_op_wrappers_returns_context_binary(self): From 55f83d50802d1f1bc084af9694aa9b9cff1ea19d Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 21:04:05 -0700 Subject: [PATCH 04/21] Qualcomm AI Engine Direct - Check every op of the constant-only branch stays on CPU test_partitioner_keeps_constant_only_subgraph_on_cpu checked that the mul of table * 2 - 1 stays in the top-level graph and that something is delegated. It would still pass if the sub of that branch were delegated, so the constant-only branch would be split between the CPU and the delegate. Also check that the sub stays in the top-level graph and that the add that reads x does not. Ran test_passes.py with the QNN SDK on Linux x86_64. --- backends/qualcomm/tests/test_passes.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index ed0e3698758..b8439505b2e 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -1009,6 +1009,8 @@ def forward(self, x): if node.op == "call_function" } self.assertIn(exir_ops.edge.aten.mul.Tensor, top_level_ops) + self.assertIn(exir_ops.edge.aten.sub.Tensor, top_level_ops) + self.assertNotIn(exir_ops.edge.aten.add.Tensor, top_level_ops) self.assertIn(torch.ops.higher_order.executorch_call_delegate, top_level_ops) edge.to_executorch() From e5f40e2edd42b8950ebf7e464c5990650640b460 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 29 Oct 2026 23:42:17 -0700 Subject: [PATCH 05/21] Qualcomm AI Engine Direct - Shorten the node name workaround comment and tighten its test The comment on the node name reservation is now a short TODO that says when it can go and why it writes private torch.fx state. The test now also checks that torch.export leaves the colliding input name unrecorded. Once torch.export records it, the test fails with a message saying the reservation can go, instead of passing without exercising it. It also checks that the autocast and attention decompositions, which add the nodes that would collide, have run. --- backends/qualcomm/_passes/qnn_pass_manager.py | 10 +++------- backends/qualcomm/tests/test_passes.py | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/backends/qualcomm/_passes/qnn_pass_manager.py b/backends/qualcomm/_passes/qnn_pass_manager.py index d30f83d7ca1..64769f54aff 100644 --- a/backends/qualcomm/_passes/qnn_pass_manager.py +++ b/backends/qualcomm/_passes/qnn_pass_manager.py @@ -100,13 +100,9 @@ class QnnPassManager(PassManager): def _transform(self, graph_module: GraphModule): - # torch.export names inputs after the user's arguments, and renames - # other nodes to make room, without recording those names in the - # graph's namespace, so a pass that adds nodes could hand one of them - # out again. - # TODO: Remove once torch.export records the names it assigns. torch.fx - # has no public way to reserve a name, so this writes the namespace's - # private name set. + # TODO: Remove once torch.export records the node names it assigns. + # Until then a pass could reuse one, and since torch.fx cannot reserve + # a name publicly, this writes the namespace's private name set. namespace = graph_module.graph._graph_namespace for node in graph_module.graph.nodes: namespace._used_names.add(node.name) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index b8439505b2e..a7bc2dbfcd8 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -214,11 +214,24 @@ def forward(self, q, k, v, mul): with self.subTest(model=type(model).__name__): ep = torch.export.export(model.eval(), inputs) # Guard against a vacuous test: the colliding name has to be - # an input's. + # an input's, and torch.export must leave it unrecorded. self.assertIn( name, [n.name for n in ep.graph.nodes if n.op == "placeholder"] ) + self.assertNotIn( + name, + ep.graph._graph_namespace._used_names, + "torch.export now records the names it assigns, so the " + "reservation in QnnPassManager._transform can go", + ) ep = get_qnn_pass_manager_cls()().transform_for_export_pipeline(ep) + # The decompositions that add the colliding nodes have run. + sdpa = torch.ops.aten.scaled_dot_product_attention.default + for n in ep.graph.nodes: + self.assertNotIsInstance( + n.target, torch._higher_order_ops.wrap.WrapWithAutocast + ) + self.assertNotEqual(n.target, sdpa) # ep.module() copies the graph and renames duplicates, so lint # the graph the passes produced. ep.graph_module.graph.lint() From 9519425f1fa920f8216bf1da75fec6dd707496f0 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Fri, 30 Oct 2026 17:51:29 -0700 Subject: [PATCH 06/21] Qualcomm AI Engine Direct - Check the node name test's setup before and after the passes The node name test had two weak checks. It only checked that no autocast region or attention op was left after the passes. That is also true when export never produced one, so the test could pass without exercising the decomposition at all. Each case now names the op it expects, checks that export produced it, and checks that the passes removed it. Its guard that export left the colliding name unrecorded ran before the passes. Once torch.export records the name, the test would stop there without checking that the passes keep names unique. The guard now runs after the other checks, and its message says only what it checked: that this case no longer tests the reservation. --- backends/qualcomm/tests/test_passes.py | 48 +++++++++++++++----------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index a7bc2dbfcd8..168cffe5bd8 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -207,35 +207,41 @@ def forward(self, q, k, v, mul): return attention + mul cases = ( - (Autocast(), (torch.randn(4, 8), torch.randn(4, 8)), "t_1"), - (Attention(), tuple(torch.randn(1, 2, 4, 8) for _ in range(4)), "mul"), + ( + Autocast(), + (torch.randn(4, 8), torch.randn(4, 8)), + torch._higher_order_ops.wrap.wrap_with_autocast, + "t_1", + ), + ( + Attention(), + tuple(torch.randn(1, 2, 4, 8) for _ in range(4)), + torch.ops.aten.scaled_dot_product_attention.default, + "mul", + ), ) - for model, inputs, name in cases: + for model, inputs, decomposed_op, reused_name in cases: with self.subTest(model=type(model).__name__): ep = torch.export.export(model.eval(), inputs) - # Guard against a vacuous test: the colliding name has to be - # an input's, and torch.export must leave it unrecorded. - self.assertIn( - name, [n.name for n in ep.graph.nodes if n.op == "placeholder"] - ) - self.assertNotIn( - name, - ep.graph._graph_namespace._used_names, - "torch.export now records the names it assigns, so the " - "reservation in QnnPassManager._transform can go", - ) + self.assertIn(decomposed_op, [n.target for n in ep.graph.nodes]) + unrecorded_names = { + n.name for n in ep.graph.nodes + } - ep.graph._graph_namespace._used_names ep = get_qnn_pass_manager_cls()().transform_for_export_pipeline(ep) - # The decompositions that add the colliding nodes have run. - sdpa = torch.ops.aten.scaled_dot_product_attention.default - for n in ep.graph.nodes: - self.assertNotIsInstance( - n.target, torch._higher_order_ops.wrap.WrapWithAutocast - ) - self.assertNotEqual(n.target, sdpa) + self.assertNotIn(decomposed_op, [n.target for n in ep.graph.nodes]) # ep.module() copies the graph and renames duplicates, so lint # the graph the passes produced. ep.graph_module.graph.lint() torch.testing.assert_close(ep.module()(*inputs), model(*inputs)) + # Checked last so the checks above still run once torch.export + # records the name. + self.assertIn( + reused_name, + unrecorded_names, + f"torch.export no longer leaves {reused_name} unrecorded, so " + "this case no longer tests the reservation in " + "QnnPassManager._transform", + ) def test_build_op_wrappers_returns_context_binary(self): op_name = "ctx_loader_build" From f7545ef6b405b9b1834ff3de6d95f38b8d3c95bf Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Fri, 30 Oct 2026 17:51:30 -0700 Subject: [PATCH 07/21] Qualcomm AI Engine Direct - Test that ops export renamed keep their names torch.export does not only leave input names unrecorded. When an input takes a name like mul, export renames the model's own mul op to mul_1 to make room, and does not record mul_1 either. The attention decomposition then creates a second mul_1. The reservation already covers this because it reserves every node, not only the inputs. But no test showed it, so narrowing the loop to inputs would still have passed. The attention case now computes scaled_dot_product_attention(q * 2, k, v) + mul, which fails with "Node redefined name mul_1" when only input names are reserved. Its guard now checks mul_1, since export records the input name mul in this model. The TODO now says why every node is reserved. --- backends/qualcomm/_passes/qnn_pass_manager.py | 5 +++-- backends/qualcomm/tests/test_passes.py | 14 ++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/backends/qualcomm/_passes/qnn_pass_manager.py b/backends/qualcomm/_passes/qnn_pass_manager.py index 64769f54aff..d94028b3dae 100644 --- a/backends/qualcomm/_passes/qnn_pass_manager.py +++ b/backends/qualcomm/_passes/qnn_pass_manager.py @@ -101,8 +101,9 @@ class QnnPassManager(PassManager): def _transform(self, graph_module: GraphModule): # TODO: Remove once torch.export records the node names it assigns. - # Until then a pass could reuse one, and since torch.fx cannot reserve - # a name publicly, this writes the namespace's private name set. + # Until then a pass could reuse one. That covers input names and ops + # export renamed to make room for them, so every node is reserved. + # torch.fx cannot reserve a name publicly, so this writes its private set. namespace = graph_module.graph._graph_namespace for node in graph_module.graph.nodes: namespace._used_names.add(node.name) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index 168cffe5bd8..d2a6de99f99 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -189,8 +189,9 @@ def forward(self, x): torch.testing.assert_close(gm(x), model(x)) def test_export_pipeline_keeps_node_names_unique(self): - # torch.export names the inputs t_1 and mul, which the autocast and - # attention decompositions would otherwise give to new nodes too. + # torch.export leaves the input name t_1 unrecorded, and renames q * 2 + # to mul_1 to make room for the input mul without recording that + # either. The decompositions would otherwise reuse those names. class Autocast(torch.nn.Module): def __init__(self): super().__init__() @@ -203,8 +204,9 @@ def forward(self, x, t_1): class Attention(torch.nn.Module): def forward(self, q, k, v, mul): - attention = torch.nn.functional.scaled_dot_product_attention(q, k, v) - return attention + mul + return ( + torch.nn.functional.scaled_dot_product_attention(q * 2, k, v) + mul + ) cases = ( ( @@ -217,7 +219,7 @@ def forward(self, q, k, v, mul): Attention(), tuple(torch.randn(1, 2, 4, 8) for _ in range(4)), torch.ops.aten.scaled_dot_product_attention.default, - "mul", + "mul_1", ), ) for model, inputs, decomposed_op, reused_name in cases: @@ -231,7 +233,7 @@ def forward(self, q, k, v, mul): self.assertNotIn(decomposed_op, [n.target for n in ep.graph.nodes]) # ep.module() copies the graph and renames duplicates, so lint # the graph the passes produced. - ep.graph_module.graph.lint() + ep.graph.lint() torch.testing.assert_close(ep.module()(*inputs), model(*inputs)) # Checked last so the checks above still run once torch.export # records the name. From fbc77a5d001cb550b4961386650ea93ac42493d5 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Fri, 30 Oct 2026 19:11:09 -0700 Subject: [PATCH 08/21] Qualcomm AI Engine Direct - Say plainly why every node name is reserved The comment now names what export leaves unrecorded: the input names it assigns and the ops it renames to make room for them. That is why every node is reserved and not only the inputs. Comment only. --- backends/qualcomm/_passes/qnn_pass_manager.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backends/qualcomm/_passes/qnn_pass_manager.py b/backends/qualcomm/_passes/qnn_pass_manager.py index d94028b3dae..68f01a7760b 100644 --- a/backends/qualcomm/_passes/qnn_pass_manager.py +++ b/backends/qualcomm/_passes/qnn_pass_manager.py @@ -101,9 +101,10 @@ class QnnPassManager(PassManager): def _transform(self, graph_module: GraphModule): # TODO: Remove once torch.export records the node names it assigns. - # Until then a pass could reuse one. That covers input names and ops - # export renamed to make room for them, so every node is reserved. - # torch.fx cannot reserve a name publicly, so this writes its private set. + # Until then a pass could reuse one of them. Export assigns input names + # and renames ops to make room for them, so every node is reserved, not + # only the inputs. torch.fx cannot reserve a name publicly, so this + # writes the namespace's private name set. namespace = graph_module.graph._graph_namespace for node in graph_module.graph.nodes: namespace._used_names.add(node.name) From 6f01c2bae5874d73c060fc6399a108fbb97e380a Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 08:34:38 -0700 Subject: [PATCH 09/21] Qualcomm AI Engine Direct - Skip the node name cases once torch.export records the names The node name test ended with an assert that export still leaves the reused names unrecorded. A torch update that fixes export would then turn the QNN passes job red, and with pytest -x it would also stop every test after this one. The check now runs after all cases and skips with a message saying the workaround can go. A comment also says why the autocast model needs both transposes. --- backends/qualcomm/tests/test_passes.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index d2a6de99f99..c6f0b752fcb 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -199,6 +199,8 @@ def __init__(self): def forward(self, x, t_1): with torch.autocast("cpu", dtype=torch.bfloat16): + # The second transpose is named t_1 inside the region, and + # the decomposition copies it next to the input t_1. y = x @ self.w.t() @ self.w.t() return y.float() + t_1 @@ -222,6 +224,7 @@ def forward(self, q, k, v, mul): "mul_1", ), ) + recorded_names = [] for model, inputs, decomposed_op, reused_name in cases: with self.subTest(model=type(model).__name__): ep = torch.export.export(model.eval(), inputs) @@ -235,15 +238,15 @@ def forward(self, q, k, v, mul): # the graph the passes produced. ep.graph.lint() torch.testing.assert_close(ep.module()(*inputs), model(*inputs)) - # Checked last so the checks above still run once torch.export - # records the name. - self.assertIn( - reused_name, - unrecorded_names, - f"torch.export no longer leaves {reused_name} unrecorded, so " - "this case no longer tests the reservation in " - "QnnPassManager._transform", - ) + if reused_name not in unrecorded_names: + recorded_names.append(reused_name) + # A skip, not a failure: a torch update that records these names is + # fine, it only means the reservation in QnnPassManager._transform can go. + if recorded_names: + self.skipTest( + f"torch.export now records {recorded_names}, so these cases no " + "longer test the reservation in QnnPassManager._transform" + ) def test_build_op_wrappers_returns_context_binary(self): op_name = "ctx_loader_build" From 0d25525650ffd4f4277dde313d4716ffd8e5d9b7 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 08:34:40 -0700 Subject: [PATCH 10/21] Qualcomm AI Engine Direct - Shorten the node name TODO Keep only when the workaround can go and why it writes private torch.fx state. Comment only. --- backends/qualcomm/_passes/qnn_pass_manager.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/backends/qualcomm/_passes/qnn_pass_manager.py b/backends/qualcomm/_passes/qnn_pass_manager.py index 68f01a7760b..eae23c3ff9c 100644 --- a/backends/qualcomm/_passes/qnn_pass_manager.py +++ b/backends/qualcomm/_passes/qnn_pass_manager.py @@ -100,11 +100,8 @@ class QnnPassManager(PassManager): def _transform(self, graph_module: GraphModule): - # TODO: Remove once torch.export records the node names it assigns. - # Until then a pass could reuse one of them. Export assigns input names - # and renames ops to make room for them, so every node is reserved, not - # only the inputs. torch.fx cannot reserve a name publicly, so this - # writes the namespace's private name set. + # TODO: Remove once torch.export records the names it gives inputs and + # the ops it renames for them. torch.fx has no public way to reserve one. namespace = graph_module.graph._graph_namespace for node in graph_module.graph.nodes: namespace._used_names.add(node.name) From 4c260acd6c4ff399ce9e99eedd9ae2ee2d1d85fa Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 09:02:27 -0700 Subject: [PATCH 11/21] Qualcomm AI Engine Direct - Decompose ops that read only constants for HTP On HTP, the partitioner keeps a partition that reads nothing at runtime on the CPU, because HTP refuses such a graph. That happens after decomposition, so an op that QNN asks to keep whole, such as linear or hardswish, could land on the CPU still undecomposed, and to_edge_transform_and_lower failed with "was not decomposed or delegated". The filter returned with the ops to keep whole now also leaves out any op that reads only constants, so it is decomposed like other CPU ops. An op that reads a runtime input is still kept whole. The constant-only partition test now has a linear in its constant branch, and fails without this change. --- .../qualcomm/partition/qnn_partitioner.py | 36 ++++++++++++++----- backends/qualcomm/tests/test_passes.py | 7 ++-- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/backends/qualcomm/partition/qnn_partitioner.py b/backends/qualcomm/partition/qnn_partitioner.py index 640d6fbf478..b399e8b52a7 100644 --- a/backends/qualcomm/partition/qnn_partitioner.py +++ b/backends/qualcomm/partition/qnn_partitioner.py @@ -68,6 +68,15 @@ logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) + +def _is_runtime_input( + node: torch.fx.Node, edge_program: torch.export.ExportedProgram +) -> bool: + return not is_parameter(node, edge_program) or is_mutable_buffer_input( + node, edge_program + ) + + # dequantize ops with a CPU out variant; others cannot fall back CPU_DQ_OPS = { exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default, @@ -319,17 +328,11 @@ def _drop_partitions_without_runtime_inputs( only by constants once a CPU op splits it from the rest. Such a partition stays on CPU. """ - - def is_runtime_input(node: torch.fx.Node) -> bool: - return not is_parameter(node, edge_program) or is_mutable_buffer_input( - node, edge_program - ) - return [ partition for partition in partitions if any( - is_runtime_input(arg) + _is_runtime_input(arg, edge_program) for node in partition.nodes for arg in node.all_input_nodes if arg not in partition.nodes @@ -399,4 +402,21 @@ def ops_to_not_decompose( The filter function (`filter_fn`) can be used to further refine which nodes are not decomposed. (advanced use case) """ do_not_decompose = get_skip_decomp_table() - return (do_not_decompose, filter_fn) + if self.backend != QnnExecuTorchBackendType.kHtpBackend: + return (do_not_decompose, filter_fn) + # An op that reads only constants can end up in a partition that + # _drop_partitions_without_runtime_inputs keeps on CPU, so it must be + # decomposed like any other CPU op. + constant_only = set() + for node in ep.graph.nodes: + if node.op == "placeholder": + if not _is_runtime_input(node, ep): + constant_only.add(node) + elif node.op == "call_function" and all( + arg in constant_only for arg in node.all_input_nodes + ): + constant_only.add(node) + return ( + do_not_decompose, + lambda node: node not in constant_only and filter_fn(node), + ) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index c6f0b752fcb..ab69c56cf44 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -1006,16 +1006,19 @@ class ConstantBranch(torch.nn.Module): def __init__(self): super().__init__() self.register_buffer("table", torch.randn(4, 16)) + self.register_buffer("weight", torch.randn(16, 16)) def forward(self, x): - return x + torch.relu(self.table * 2 - 1) + table = torch.nn.functional.linear(self.table, self.weight) + return x + torch.relu(table * 2 - 1) sample_input = (torch.randn(4, 16),) compiler_specs = generate_qnn_executorch_compiler_spec( soc_model=QcomChipset.SM8650, backend_options=generate_htp_compiler_spec(use_fp16=True), ) - # With relu on CPU, table * 2 - 1 reads nothing at runtime. + # With relu on CPU, the branch before it reads nothing at runtime. It + # also holds linear, which QNN otherwise keeps from decomposing. try: edge = to_edge_transform_and_lower_to_qnn( ConstantBranch().eval(), From 375430f72c90ae31c7416de073c28c9540c9f2fc Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 09:36:04 -0700 Subject: [PATCH 12/21] Qualcomm AI Engine Direct - Word the node name skip for every way it can trigger The skip also fires when export stops producing one of these names, so say that export no longer leaves them unrecorded instead of that it records them. --- backends/qualcomm/tests/test_passes.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index ab69c56cf44..24dc18de7a7 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -224,7 +224,7 @@ def forward(self, q, k, v, mul): "mul_1", ), ) - recorded_names = [] + stale_names = [] for model, inputs, decomposed_op, reused_name in cases: with self.subTest(model=type(model).__name__): ep = torch.export.export(model.eval(), inputs) @@ -239,13 +239,12 @@ def forward(self, q, k, v, mul): ep.graph.lint() torch.testing.assert_close(ep.module()(*inputs), model(*inputs)) if reused_name not in unrecorded_names: - recorded_names.append(reused_name) - # A skip, not a failure: a torch update that records these names is - # fine, it only means the reservation in QnnPassManager._transform can go. - if recorded_names: + stale_names.append(reused_name) + # A skip, not a failure: a torch update that records these names is fine. + if stale_names: self.skipTest( - f"torch.export now records {recorded_names}, so these cases no " - "longer test the reservation in QnnPassManager._transform" + f"torch.export no longer leaves {stale_names} unrecorded, so those " + "cases no longer test the reservation in QnnPassManager._transform" ) def test_build_op_wrappers_returns_context_binary(self): From 40cab1c6ef64696d0dfc02e94b1d6f173c0c2530 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 10:12:43 -0700 Subject: [PATCH 13/21] Qualcomm AI Engine Direct - Name both triggers in the node name skip The skip fires when export records the reused name or stops producing it. Say both, in the message and the comment. --- backends/qualcomm/tests/test_passes.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index 24dc18de7a7..5049bfabef0 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -240,11 +240,12 @@ def forward(self, q, k, v, mul): torch.testing.assert_close(ep.module()(*inputs), model(*inputs)) if reused_name not in unrecorded_names: stale_names.append(reused_name) - # A skip, not a failure: a torch update that records these names is fine. + # A skip, not a failure: a torch update that changes these names is fine. if stale_names: self.skipTest( - f"torch.export no longer leaves {stale_names} unrecorded, so those " - "cases no longer test the reservation in QnnPassManager._transform" + f"torch.export now records {stale_names} or no longer produces " + "them, so those cases no longer test the reservation in " + "QnnPassManager._transform" ) def test_build_op_wrappers_returns_context_binary(self): From d9e4f6e7e4459dbc5d4ce878a8c1688bed026e9a Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 10:39:39 -0700 Subject: [PATCH 14/21] Qualcomm AI Engine Direct - Move the node name reservation into a shared helper The reservation repairs what torch.export returns, so it is not specific to QNN. It now lives in backends/transforms/utils.py as reserve_node_names, which QnnPassManager._transform calls. Other backends can call it too, and there is one place to delete once torch.export records the names. No behavior change. --- backends/qualcomm/_passes/BUCK | 1 + backends/qualcomm/_passes/qnn_pass_manager.py | 7 ++----- backends/transforms/utils.py | 12 ++++++++++++ 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/backends/qualcomm/_passes/BUCK b/backends/qualcomm/_passes/BUCK index f5e635c739a..6d46f1972e1 100644 --- a/backends/qualcomm/_passes/BUCK +++ b/backends/qualcomm/_passes/BUCK @@ -14,6 +14,7 @@ fbcode_target(_kind = runtime.python_library, "//executorch/backends/transforms:addmm_mm_to_linear", "//executorch/backends/transforms:decompose_sdpa", "//executorch/backends/transforms:fuse_batch_norm_with_conv", + "//executorch/backends/transforms:utils", "//executorch/exir/backend:backend_details", "//executorch/exir/backend:compile_spec_schema", "//executorch/backends/qualcomm/quantizer:quantizer", diff --git a/backends/qualcomm/_passes/qnn_pass_manager.py b/backends/qualcomm/_passes/qnn_pass_manager.py index eae23c3ff9c..4c8d7e0cd8e 100644 --- a/backends/qualcomm/_passes/qnn_pass_manager.py +++ b/backends/qualcomm/_passes/qnn_pass_manager.py @@ -87,6 +87,7 @@ from executorch.backends.transforms.decompose_sdpa import ( DecomposeScaledDotProductAttention, ) +from executorch.backends.transforms.utils import reserve_node_names from executorch.exir import ExportedProgram from executorch.exir.pass_manager import PassManager from executorch.exir.program._program import ( @@ -100,11 +101,7 @@ class QnnPassManager(PassManager): def _transform(self, graph_module: GraphModule): - # TODO: Remove once torch.export records the names it gives inputs and - # the ops it renames for them. torch.fx has no public way to reserve one. - namespace = graph_module.graph._graph_namespace - for node in graph_module.graph.nodes: - namespace._used_names.add(node.name) + reserve_node_names(graph_module) return self(graph_module).graph_module def _reset(self): diff --git a/backends/transforms/utils.py b/backends/transforms/utils.py index 92794364cb0..803e9c0d73b 100644 --- a/backends/transforms/utils.py +++ b/backends/transforms/utils.py @@ -59,6 +59,18 @@ def _get_fake_tensor_mode(graph: torch.fx.Graph, data: torch.Tensor) -> torch.Te return FakeTensorConverter().from_real_tensor(example_fake_tensor.fake_mode, t=data) +def reserve_node_names(graph_module: torch.fx.GraphModule) -> None: + """ + Records every node name in the graph's namespace, so that nodes a pass adds + later get names that are not already taken. + """ + # TODO: Remove once torch.export records the names it gives inputs and the + # ops it renames for them. torch.fx has no public way to reserve a name. + namespace = graph_module.graph._graph_namespace + for node in graph_module.graph.nodes: + namespace._used_names.add(node.name) + + def is_get_attr_node(node: torch.fx.Node) -> bool: """ Returns true if the given node is a get attr node for a tensor of the model From 55614b8cb65b5ea37ee94985f46d093e9bc413a2 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 10:40:09 -0700 Subject: [PATCH 15/21] Qualcomm AI Engine Direct - Check the node name collision itself in the test The node name test read the private torch.fx name set to see whether export still leaves the reused names unrecorded. It now runs each case again with reserve_node_names patched out and expects "Node redefined name". If that no longer happens, a torch update removed the collision, and the test skips instead of failing. This checks what the reservation protects against, and the test no longer reads torch.fx internals. --- backends/qualcomm/tests/test_passes.py | 34 +++++++++++++++----------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index 5049bfabef0..94d88560aec 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -215,37 +215,43 @@ def forward(self, q, k, v, mul): Autocast(), (torch.randn(4, 8), torch.randn(4, 8)), torch._higher_order_ops.wrap.wrap_with_autocast, - "t_1", ), ( Attention(), tuple(torch.randn(1, 2, 4, 8) for _ in range(4)), torch.ops.aten.scaled_dot_product_attention.default, - "mul_1", ), ) - stale_names = [] - for model, inputs, decomposed_op, reused_name in cases: + no_collision = [] + for model, inputs, decomposed_op in cases: with self.subTest(model=type(model).__name__): ep = torch.export.export(model.eval(), inputs) self.assertIn(decomposed_op, [n.target for n in ep.graph.nodes]) - unrecorded_names = { - n.name for n in ep.graph.nodes - } - ep.graph._graph_namespace._used_names ep = get_qnn_pass_manager_cls()().transform_for_export_pipeline(ep) self.assertNotIn(decomposed_op, [n.target for n in ep.graph.nodes]) # ep.module() copies the graph and renames duplicates, so lint # the graph the passes produced. ep.graph.lint() torch.testing.assert_close(ep.module()(*inputs), model(*inputs)) - if reused_name not in unrecorded_names: - stale_names.append(reused_name) - # A skip, not a failure: a torch update that changes these names is fine. - if stale_names: + ep = torch.export.export(model.eval(), inputs) + with patch( + "executorch.backends.qualcomm._passes.qnn_pass_manager." + "reserve_node_names" + ): + try: + get_qnn_pass_manager_cls()().transform_for_export_pipeline( + ep + ).graph.lint() + except Exception as e: + # The pass manager wraps the lint error in its own. + self.assertIn("Node redefined name", str(e.__cause__ or e)) + else: + no_collision.append(type(model).__name__) + # A skip, not a failure: a torch update that removes the collision is fine. + if no_collision: self.skipTest( - f"torch.export now records {stale_names} or no longer produces " - "them, so those cases no longer test the reservation in " - "QnnPassManager._transform" + f"{no_collision} no longer collide without reserve_node_names, so " + "those cases no longer test it" ) def test_build_op_wrappers_returns_context_binary(self): From 96da5d62bc536be51c9e8bd2fb2156e559e49f97 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 10:51:20 -0700 Subject: [PATCH 16/21] Samsung Exynos AI LiteCore - Reserve existing node names before the Samsung passes torch.export leaves some node names unrecorded, so a pass that adds nodes can reuse one. With an input named cat, a model that calls torch.cat and then torch.roll failed in DecomposeRoll with a "Node redefined name" error. EnnPassManager._transform now calls reserve_node_names first, the same helper the Qualcomm pass manager uses. --- backends/samsung/_passes/enn_pass_manager.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backends/samsung/_passes/enn_pass_manager.py b/backends/samsung/_passes/enn_pass_manager.py index 874aa7115c9..d148c0a57d5 100644 --- a/backends/samsung/_passes/enn_pass_manager.py +++ b/backends/samsung/_passes/enn_pass_manager.py @@ -33,6 +33,7 @@ ) from executorch.backends.transforms.remove_clone_ops import RemoveCloneOpsTransform from executorch.backends.transforms.remove_getitem_op import RemoveGetItemPass +from executorch.backends.transforms.utils import reserve_node_names from executorch.exir import ExportedProgram from executorch.exir.pass_manager import PassManager @@ -44,6 +45,7 @@ def __init__(self) -> None: super().__init__() def _transform(self, graph_module: GraphModule): + reserve_node_names(graph_module) return self(graph_module).graph_module # before annotator, for quant models From eb8232af13db045636b8d25ff29b47536a2e2b39 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 10:57:36 -0700 Subject: [PATCH 17/21] Qualcomm AI Engine Direct - Say that HTP decomposes every constant-only op The comment said ops that read only constants are decomposed like other CPU ops. Partitions are not known when the filter is built, so the rule also decomposes such ops whose partition stays delegated. The comment and the ops_to_not_decompose docstring now say so. Comment only. --- backends/qualcomm/partition/qnn_partitioner.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backends/qualcomm/partition/qnn_partitioner.py b/backends/qualcomm/partition/qnn_partitioner.py index b399e8b52a7..c40e0e60627 100644 --- a/backends/qualcomm/partition/qnn_partitioner.py +++ b/backends/qualcomm/partition/qnn_partitioner.py @@ -399,14 +399,17 @@ def ops_to_not_decompose( """ Determines which op should not be decomposed during partitioning. The list of operators is obtained from `get_skip_decomp_table()`. - The filter function (`filter_fn`) can be used to further refine which nodes are not decomposed. (advanced use case) + The filter refines which nodes are not decomposed: `filter_fn`, and on + HTP it also leaves out ops that read only constants. """ do_not_decompose = get_skip_decomp_table() if self.backend != QnnExecuTorchBackendType.kHtpBackend: return (do_not_decompose, filter_fn) # An op that reads only constants can end up in a partition that - # _drop_partitions_without_runtime_inputs keeps on CPU, so it must be - # decomposed like any other CPU op. + # _drop_partitions_without_runtime_inputs keeps on CPU after + # decomposition, so every such op is decomposed. Partitions are not + # known yet, so this also decomposes ones whose partition stays + # delegated; folding constant-only subgraphs first would avoid that. constant_only = set() for node in ep.graph.nodes: if node.op == "placeholder": From 0e6bba37b5f28203375afb09a8ff3d09032b93d2 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 11:54:31 -0700 Subject: [PATCH 18/21] Qualcomm AI Engine Direct - Check that the node name test patches the call it means to If the patch missed the reserve_node_names call, the run without it would not collide and the test would skip silently. Assert the patched helper was called. --- backends/qualcomm/tests/test_passes.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index 94d88560aec..bf628db3777 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -237,7 +237,7 @@ def forward(self, q, k, v, mul): with patch( "executorch.backends.qualcomm._passes.qnn_pass_manager." "reserve_node_names" - ): + ) as reserve: try: get_qnn_pass_manager_cls()().transform_for_export_pipeline( ep @@ -247,6 +247,7 @@ def forward(self, q, k, v, mul): self.assertIn("Node redefined name", str(e.__cause__ or e)) else: no_collision.append(type(model).__name__) + reserve.assert_called_once() # A skip, not a failure: a torch update that removes the collision is fine. if no_collision: self.skipTest( From c53d2fe02d8702264f33ef4caf9718df6c954a58 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 11:54:32 -0700 Subject: [PATCH 19/21] Qualcomm AI Engine Direct - Clarify two docstrings Say plainly that HTP also decomposes ops that read only constants, and that reserve_node_names covers the top-level graph only. Docstrings only. --- backends/qualcomm/partition/qnn_partitioner.py | 4 ++-- backends/transforms/utils.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backends/qualcomm/partition/qnn_partitioner.py b/backends/qualcomm/partition/qnn_partitioner.py index c40e0e60627..1594c23d366 100644 --- a/backends/qualcomm/partition/qnn_partitioner.py +++ b/backends/qualcomm/partition/qnn_partitioner.py @@ -399,8 +399,8 @@ def ops_to_not_decompose( """ Determines which op should not be decomposed during partitioning. The list of operators is obtained from `get_skip_decomp_table()`. - The filter refines which nodes are not decomposed: `filter_fn`, and on - HTP it also leaves out ops that read only constants. + The filter is `filter_fn`. On HTP, ops that read only constants are also + decomposed. """ do_not_decompose = get_skip_decomp_table() if self.backend != QnnExecuTorchBackendType.kHtpBackend: diff --git a/backends/transforms/utils.py b/backends/transforms/utils.py index 803e9c0d73b..51e583effcf 100644 --- a/backends/transforms/utils.py +++ b/backends/transforms/utils.py @@ -61,8 +61,8 @@ def _get_fake_tensor_mode(graph: torch.fx.Graph, data: torch.Tensor) -> torch.Te def reserve_node_names(graph_module: torch.fx.GraphModule) -> None: """ - Records every node name in the graph's namespace, so that nodes a pass adds - later get names that are not already taken. + Records every node name of the top-level graph in its namespace, so that + nodes a pass adds there later get names that are not already taken. """ # TODO: Remove once torch.export records the names it gives inputs and the # ops it renames for them. torch.fx has no public way to reserve a name. From bcf34605bf32ba790fe33a08d3584e9aca706246 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 13:37:56 -0700 Subject: [PATCH 20/21] Qualcomm AI Engine Direct - Keep quantize and dequantize ops whole on HTP The HTP rule that decomposes ops reading only constants also covered the quantize and dequantize ops in the skip table, such as dequantize_per_channel_group on a frozen weight. QNN builds the weight encoding from those ops. With today's torch they are not decomposed anyway, so nothing changed in practice, but the filter should not ask for it. They are now kept whole, and ops after them still count as reading only constants. A new test checks the filter itself and needs no QNN SDK, so it runs in CI: a constant-only linear is decomposed, a linear on an input and a constant-only group-wise dequantize are kept whole. The constant-only partition test also checks that no linear is left in the top-level graph. --- .../qualcomm/partition/qnn_partitioner.py | 12 +++- backends/qualcomm/tests/test_passes.py | 56 ++++++++++++++++++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/backends/qualcomm/partition/qnn_partitioner.py b/backends/qualcomm/partition/qnn_partitioner.py index 1594c23d366..6d3b32e5a1e 100644 --- a/backends/qualcomm/partition/qnn_partitioner.py +++ b/backends/qualcomm/partition/qnn_partitioner.py @@ -400,7 +400,7 @@ def ops_to_not_decompose( Determines which op should not be decomposed during partitioning. The list of operators is obtained from `get_skip_decomp_table()`. The filter is `filter_fn`. On HTP, ops that read only constants are also - decomposed. + decomposed, except quantize and dequantize ops. """ do_not_decompose = get_skip_decomp_table() if self.backend != QnnExecuTorchBackendType.kHtpBackend: @@ -419,7 +419,15 @@ def ops_to_not_decompose( arg in constant_only for arg in node.all_input_nodes ): constant_only.add(node) + # Quantize and dequantize ops stay whole: QNN builds weight encodings + # from them. + quant_ops = { + op + for op in do_not_decompose + if op.namespace in ("quantized_decomposed", "torchao") + } return ( do_not_decompose, - lambda node: node not in constant_only and filter_fn(node), + lambda node: (node not in constant_only or node.target in quant_ops) + and filter_fn(node), ) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index bf628db3777..a379dae218c 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -31,7 +31,10 @@ ) from executorch.backends.qualcomm.builders.qnn_constants import OpContextLoader from executorch.backends.qualcomm.builders.utils import is_parameter -from executorch.backends.qualcomm.partition.qnn_partitioner import QnnOperatorSupport +from executorch.backends.qualcomm.partition.qnn_partitioner import ( + QnnOperatorSupport, + QnnPartitioner, +) from executorch.backends.qualcomm.qnn_preprocess import QnnBackend from executorch.backends.qualcomm.quantizer.quantizer import QnnQuantizer, QuantDtype from executorch.backends.qualcomm.serialization.qc_schema import ( @@ -1045,9 +1048,60 @@ def forward(self, x): self.assertIn(exir_ops.edge.aten.mul.Tensor, top_level_ops) self.assertIn(exir_ops.edge.aten.sub.Tensor, top_level_ops) self.assertNotIn(exir_ops.edge.aten.add.Tensor, top_level_ops) + self.assertNotIn(exir_ops.edge.aten.linear.default, top_level_ops) self.assertIn(torch.ops.higher_order.executorch_call_delegate, top_level_ops) edge.to_executorch() + def test_htp_keeps_whole_only_ops_that_read_a_runtime_input(self): + class Model(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("table", torch.randn(4, 16)) + self.register_buffer("weight", torch.randn(16, 16)) + self.register_buffer( + "qweight", torch.randint(-8, 8, (16, 32), dtype=torch.int8) + ) + self.register_buffer("scales", torch.rand(16, 2) + 0.1) + self.register_buffer("zero_points", torch.zeros(16, 2).long()) + + def forward(self, x, y): + weight = torch.ops.quantized_decomposed.dequantize_per_channel_group( + self.qweight, + self.scales, + self.zero_points, + -8, + 7, + torch.int8, + 16, + torch.float32, + ) + table = torch.nn.functional.linear(self.table, self.weight) + return torch.nn.functional.linear(x, self.weight) + table, ( + torch.nn.functional.linear(y, weight) + ) + + ep = torch.export.export(Model(), (torch.randn(4, 16), torch.randn(4, 32))) + compiler_specs = generate_qnn_executorch_compiler_spec( + soc_model=QcomChipset.SM8650, + backend_options=generate_htp_compiler_spec(use_fp16=True), + ) + _, keep_whole = QnnPartitioner(compiler_specs).ops_to_not_decompose(ep) + linears = { + node.args[0].name: node + for node in ep.graph.nodes + if node.target == torch.ops.aten.linear.default + } + placeholders = {b: p for p, b in ep.graph_signature.inputs_to_buffers.items()} + self.assertFalse(keep_whole(linears[placeholders["table"]])) + self.assertTrue(keep_whole(linears["x"])) + (dequantize,) = ( + node + for node in ep.graph.nodes + if node.target + == torch.ops.quantized_decomposed.dequantize_per_channel_group.default + ) + self.assertTrue(keep_whole(dequantize)) + def test_partitioner_falls_back_on_bfloat16(self): class Bf16Region(torch.nn.Module): def __init__(self): From 6b617efc7a47a39a91612484048f984162a2a6d6 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 13:37:57 -0700 Subject: [PATCH 21/21] Qualcomm AI Engine Direct - Keep reporting kept-whole ops left on CPU After lowering, exir asks the partitioner for its filter again and reports any kept-whole op still on the CPU. On HTP the filter now let constant-only ops through, so that report went quiet. For example RecomposeRmsNorm rebuilt a constant-only rms_norm that then stayed on the CPU, and the program failed later in to_executorch with "Missing out variants" instead of the clear error from before. On the lowered (edge) program the filter is plain filter_fn again, so these cases report as they did before the HTP change. The filter test checks this too. --- backends/qualcomm/partition/qnn_partitioner.py | 4 +++- backends/qualcomm/tests/test_passes.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/backends/qualcomm/partition/qnn_partitioner.py b/backends/qualcomm/partition/qnn_partitioner.py index 6d3b32e5a1e..6cb4b1ca864 100644 --- a/backends/qualcomm/partition/qnn_partitioner.py +++ b/backends/qualcomm/partition/qnn_partitioner.py @@ -403,7 +403,9 @@ def ops_to_not_decompose( decomposed, except quantize and dequantize ops. """ do_not_decompose = get_skip_decomp_table() - if self.backend != QnnExecuTorchBackendType.kHtpBackend: + # On the lowered program this filter tells exir which kept-whole ops + # may stay on CPU, and none may, so it stays plain filter_fn there. + if self.backend != QnnExecuTorchBackendType.kHtpBackend or ep.dialect == "EDGE": return (do_not_decompose, filter_fn) # An op that reads only constants can end up in a partition that # _drop_partitions_without_runtime_inputs keeps on CPU after diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index a379dae218c..83c423d357b 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -1101,6 +1101,24 @@ def forward(self, x, y): == torch.ops.quantized_decomposed.dequantize_per_channel_group.default ) self.assertTrue(keep_whole(dequantize)) + # After lowering, exir uses the filter to report a kept-whole op left + # on CPU, so there it must not let a constant-only one through. + edge = to_edge( + ep, + compile_config=EdgeCompileConfig( + _check_ir_validity=False, + preserve_ops=[torch.ops.aten.linear.default], + ), + ).exported_program() + _, keep_whole = QnnPartitioner(compiler_specs).ops_to_not_decompose(edge) + placeholders = {b: p for p, b in edge.graph_signature.inputs_to_buffers.items()} + (constant_linear,) = ( + node + for node in edge.graph.nodes + if node.target == exir_ops.edge.aten.linear.default + and node.args[0].name == placeholders["table"] + ) + self.assertTrue(keep_whole(constant_linear)) def test_partitioner_falls_back_on_bfloat16(self): class Bf16Region(torch.nn.Module):