Skip to content

Qualcomm AI Engine Direct - Keep node names unique across the QNN and Samsung passes - #24316

Merged
JacobSzwejbka merged 1 commit into
pytorch:mainfrom
shoumikhin:qnn-node-names
Nov 2, 2026
Merged

JacobSzwejbka merged 1 commit into
pytorch:mainfrom
shoumikhin:qnn-node-names

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

What is wrong today

torch.export does not record some of the node names it assigns: the names of inputs, and the names of ops it renamed to make room for an input. With an input called mul, the model's own input * 2 becomes mul_1, and nothing marks mul_1 as taken. A later pass that adds a node can pick one of those names again, and the graph breaks:

RuntimeError: Node redefined name t_1!

QNN models with autocast regions or attention hit this in the QNN export passes. The Samsung passes hit it too: with an input named cat, torch.cat followed by torch.roll fails in DecomposeRoll.

What this change does

reserve_node_names in backends/transforms/utils.py records the name of every node in the top-level graph, so nodes added later get names that are not taken. The QNN and Samsung pass managers call it at the start of _transform.

torch.fx has no public way to reserve a name, so the helper adds the names to the graph's name set directly. Copying the graph would register the names through public API, but it also renames inputs named after Python builtins (input becomes input_1) while the graph signature keeps the old names. The real fix belongs in torch.export (pytorch/pytorch#202927), and a TODO says to drop the helper once that lands.

Tests

test_export_pipeline_keeps_node_names_unique runs the QNN export passes on an autocast model and on an attention model whose inputs are named input and mul. It checks that the graph lints, keeps the input names the graph signature uses, and matches eager, and that the collision really happens without the helper. If a torch update removes the collision, the test skips with a pointer to the torch issue instead of failing.

On Linux x86_64 with torch 2.14 and a 2.16 nightly: the test fails with Node redefined name t_1! and Node redefined name mul_1! when the call is removed, and it fails on the input case when the helper copies the graph instead. The Samsung case above fails without the helper and passes with it (torch only). test_passes.py passes, except two tests that need torchtune or the quantized CPU kernels.

Earlier versions of this PR also carried an HTP change that decomposes constant-only ops left on the CPU, and an RMSNorm dtype fix. Both now have their own PRs:

RMSNorm dtype fix: #24428
HTP constant-only decomposition: #24429

cc @cbilgin @psiddh

@shoumikhin shoumikhin added the release notes: qualcomm Changes to the Qualcomm backend delegate label Oct 30, 2026
Copilot AI balanced review requested due to automatic review settings October 30, 2026 02:51
@shoumikhin
shoumikhin requested a review from psiddh as a code owner October 30, 2026 02:51
@shoumikhin shoumikhin added the release notes: qualcomm Changes to the Qualcomm backend delegate label Oct 30, 2026
@pytorch-bot

pytorch-bot Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24316

Note: Links to docs will display an error until the docs builds have been completed.

✅ No Failures

As of commit 3218fe7 with merge base 05092c1 (image):
💚 Looks good so far! There are no failures yet. 💚

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 30, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 19m 11s —— View job


The reservation fix is correct for QNN's export pipeline, but it patches an upstream torch.export bug by writing private torch.fx state with no TODO, and both changed tests check less than they claim.

Detailed review

PR Review: #24316

Summary

QnnPassManager._transform now reserves every existing node name in the top-level graph's FX namespace, so nodes added by passes can't take a name torch.export gave a placeholder. A second commit tightens the constant-only partition test. The fix resolves the reported Node redefined name failures. The problems:

  • It works around a torch.export bug inside one backend by writing private torch.fx state, and it has no exit plan.
  • The two tests assert less than their names and commit messages say.

This review did not run the QNN tests (there is no QNN SDK on the runner). Claims about torch internals come from reading code, not from running it.

Code Quality

  • backends/qualcomm/_passes/qnn_pass_manager.py:103-108: the workaround writes private torch.fx state in one backend, with no TODO
    • Root cause: in torch.export. placeholder_naming_pass assigns node.name directly and never registers the name.
    • Private state: the fix writes graph._graph_namespace._used_names, two levels of private torch.fx state. The other two uses in the repo only read it (backends/transforms/fuse_rms_norm.py:375, backends/transforms/fuse_batch_norm_with_conv.py:119).
    • Consequence: if a torch pin bump changes _Namespace, every QNN annotation, export and preprocess pipeline fails (lines 470, 481, 520), not just models that would collide.
    • Same bug elsewhere: EnnPassManager._transform (backends/samsung/_passes/enn_pass_manager.py:46-47) is identical to QNN's pre-fix body. Its transform_for_export_pass (:60-64) runs DecomposeRoll and DecomposeRemainder in place on a raw torch.export.export(...) program (backends/samsung/utils/export_utils.py:76-77). A model with an input named after a node those passes insert (e.g. mul) would likely fail the same way. I did not reproduce this.
    • Ask: file the upstream issue and add a TODO(<link>) at line 103.
      • The TODO should say this is a private-API workaround. The commit message says it ("torch.fx has no public way to reserve a name"); the code comment and PR description don't.
      • Optionally, move the reservation into a shared helper (e.g. backends/transforms/utils.py) that both pass managers call. Fix this →
  • Low confidence: qnn_pass_manager.py:107 reserves names only in the top-level graph
    • Gap: when an outer autocast region is disabled, DecomposeWrapWithAutocast._replace(submod) (decompose_wrap_with_autocast.py:71) inlines nested regions into the submodule's own graph. This loop never touches that graph's namespace.
    • Condition: this matters only if the pinned torch also assigns placeholder names directly inside autocast subgraphs. I couldn't check the torch source here.
    • Fix: iterating every GraphModule in graph_module.modules() covers it at little cost. Fix this →

Testing

  • backends/qualcomm/tests/test_passes.py:215-217: the new test never checks that names are unique
    • Why assert_close can't catch it: ep.module() deep-copies the graph, and the copy renames duplicate names.
    • Why it fails without the fix today: DecomposeWrapWithAutocast (decompose_wrap_with_autocast.py:94) and the SDPA decomposition (backends/transforms/decompose_sdpa.py:47) happen to run DCE, which lints the graph. A collision created by any pass that doesn't run DCE afterwards would pass this test.
    • Ask:
      • Call ep.graph_module.graph.lint() after transform_for_export_pipeline.
      • Before running the pipeline, assert that the placeholders really are named t_1 / mul. This follows the file's "Guard against a vacuous test" pattern (:926). Fix this →
  • test_passes.py:1003-1004: the constant-only partition check is weaker than its commit message
    • Claim vs. check: the commit message says all of table * 2 - 1 must stay top-level and the add must be delegated. The test only asserts mul.Tensor and that some delegate exists.
    • Missed regression: a partitioner whose constant-only check is non-transitive would keep mul (reads only table) on CPU. It would then delegate sub (reads mul's output) as a separate partition that reads nothing at runtime. The test would still pass.
    • Ask: also assert that the sub is in top_level_ops and that exir_ops.edge.aten.add.Tensor is not. Fix this →

Recommendation

Approve

Nothing blocks. The reservation fixes the reported export-pipeline failures. Add the upstream TODO before merging. The shared-helper move and the test hardening can come later.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@haowhsu-quic @shewu-quic @winskuo-quic could you take a look? This changes the Qualcomm backend.

Copilot AI balanced review requested due to automatic review settings October 30, 2026 03:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

Copilot AI balanced review requested due to automatic review settings October 30, 2026 04:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 20m 42s —— View job


The node name reservation fixes the reported Node redefined name export failures and nothing blocks merging, but the TODO still links no upstream issue, the private-state write has a public alternative, and the new test can pass without exercising the fix.

Detailed review

PR Review: #24316

Summary

QnnPassManager._transform now adds every existing top-level node name to the graph's FX namespace before the passes run. Nodes the passes add therefore can't reuse names that torch.export assigned directly. Two test-only commits also tighten the constant-only partition test.

All findings are minor:

  • The TODO gives no way to tell when the workaround can be removed.
  • A public graph rebuild would avoid the private write.
  • The workaround exists only in the QNN backend.
  • Nested autocast graphs may not be covered (low confidence).
  • The new test's guard checks only part of what the test depends on.

This review could not run Python or the QNN tests on this runner. Claims about torch internals come from reading code, not from running it.

Code Quality

  • backends/qualcomm/_passes/qnn_pass_manager.py:103-109: the TODO links no upstream issue
    • "Remove once torch.export records the names it assigns" names no pytorch/pytorch issue, so nothing will signal when the workaround can go.
    • The comment is seven lines over a three-line loop, and the first block and the TODO both explain the cause.
    • Ask: replace it with one TODO(<issue link>) of two or three lines. Fix this →
  • qnn_pass_manager.py:110-112: a public API does what the private write does
    • torch.fx has no public way to reserve a name. But graph_module.graph = copy.deepcopy(graph_module.graph) rebuilds the graph through Graph.graph_copy → create_node, which records every name.
    • This is the same copy that makes ep.module() rename duplicates, as the test comment at test_passes.py:222 notes.
    • Why it matters:
      • This is the repo's first write to _graph_namespace._used_names. The two other uses, backends/transforms/fuse_rms_norm.py:375 and fuse_batch_norm_with_conv.py:119, only read it.
      • _transform feeds the annotation, export and preprocess pipelines (lines 474, 485, 524). A torch pin bump that changes _Namespace would break all three at once.
    • Caveat: the rebuild replaces every Node object, so no code may hold node references across _transform. This review did not run it. Fix this →
  • qnn_pass_manager.py:102-113: the workaround exists only in QNN, but the bug comes from torch.export's output
    • EnnPassManager._transform (backends/samsung/_passes/enn_pass_manager.py:46-47) is the same as QNN's body before this fix.
    • Its transform_for_export_pass (:60-65) runs passes that add nodes (ComputeConstAttrs, DecomposeRoll, DecomposeRemainder). They modify a raw torch.export.export(...) program in place (backends/samsung/utils/export_utils.py:76-77), so the Samsung backend is likely exposed the same way. This review did not reproduce it.
    • Ask: add a shared helper in backends/transforms/utils.py and call it from both pass managers. That also leaves one place to delete when the TODO is resolved. Fix this →
  • Low confidence: qnn_pass_manager.py:110-112 reserves names only in the top-level graph
    • How it would fail:
      1. For a disabled autocast region, DecomposeWrapWithAutocast._replace(submod) (decompose_wrap_with_autocast.py:71) inlines nested enabled regions into the submodule's own graph, whose names were never reserved.
      2. graph.erase_node(input_submod) (:90) leaves that submodule attached to the module.
      3. dead_code_elimination_pass (exir/passes/__init__.py:502-505) runs eliminate_dead_code(), which lints, on every GraphModule in modules(). A duplicate name in the submodule raises the same Node redefined name error.
    • Condition: this only happens if the pinned torch also assigns names directly inside wrap_with_autocast subgraphs, for example if _name_hoo_subgraph_placeholders handles that HOP. The torch source was not available here to check.
    • Fix: reserve names in every GraphModule in graph_module.modules(). Fix this →

Testing

  • backends/qualcomm/tests/test_passes.py:216-220: the guard against a vacuous test only checks that an input has the colliding name
    • The test also relies on two conditions it never asserts:
      • torch.export does not record that name in the namespace;
      • the pipeline still decomposes the autocast HOP or SDPA node, which is what creates the node that would take the name.
    • If either changes, both subtests pass without exercising the reservation, and nothing flags the workaround as dead.
    • Ask:
      • Before the pipeline runs, assert name not in ep.graph._graph_namespace._used_names. This assertion also starts failing once torch.export records the names, which tells you the TODO can be resolved.
      • After the pipeline, assert that no WrapWithAutocast or aten.scaled_dot_product_attention.default node is left, as lines 181-188 do. Fix this →

Recommendation

Approve

Nothing blocks: the reservation fixes the reported export failures. Link the upstream issue in the TODO before merging. The other changes can come later.

Copilot AI balanced review requested due to automatic review settings October 30, 2026 07:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 24m 36s —— View job


The node name reservation fixes the reported Node redefined name export failures and nothing blocks merging, but the TODO still links no upstream issue, the new canary assertion would fail CI once torch.export is fixed, and Samsung's pass manager has the same exposure.

Detailed review

PR Review: #24316

Summary

QnnPassManager._transform now adds every top-level node name to the graph's FX namespace before the annotation, export and preprocess pipelines run their passes. New nodes therefore can't reuse a name that torch.export assigned without recording it. Two test-only commits also tighten the constant-only partition test. There are five findings below, all minor; none blocks.

This review ran nothing. Python can't run on this runner and the torch source isn't available, so claims about torch behavior come from reading ExecuTorch code.

Code Quality

  • backends/qualcomm/_passes/qnn_pass_manager.py:103: the TODO still links no upstream issue
    • The workaround can only go once torch.export is fixed, but the TODO, the PR description and the commits link no pytorch/pytorch issue. If nobody reports the bug upstream, the fix may never land and the private write stays.
    • Ask: file the issue against torch.export's placeholder naming and turn the comment into TODO(<issue link>). Fix this →
  • backends/qualcomm/tests/test_passes.py:221-226: the canary assertion will fail CI once torch.export is fixed
    • Once torch.export records the names, the reservation does nothing and nothing is broken, yet this assertNotIn fails.
    • test-qnn-passes-linux runs on every PR (.github/workflows/pull.yml:1290) as pytest -xvs backends/qualcomm/tests/test_passes.py (:1323).
      • On the torch pin bump that brings the fix, pytest stops at this failure. The file's remaining tests then don't run on the PR most likely to break them.
      • Whoever bumps the pin has to change QNN code to land the bump.
    • An earlier automated review asked for this assertion without considering -x.
    • Ask: drop it and track removal through the issue linked in the TODO. The lint at :237 is still a valid check after torch is fixed. Fix this →
  • qnn_pass_manager.py:102-109: the workaround covers only QNN, but Samsung's pass manager has the same exposure
    • EnnPassManager._transform (backends/samsung/_passes/enn_pass_manager.py:46-47) is the same code QNN had before this PR.
    • backends/samsung/utils/export_utils.py:76-77 passes the raw torch.export.export(...) program to transform_for_export_pass, which runs DecomposeRemainder. That pass creates floor_divide, mul and sub nodes with default names (decompose_remainder.py:92-105), then lints the graph through graph.eliminate_dead_code() (:124).
    • Trigger: a model with an integer torch.remainder and an input named mul would likely fail with Node redefined name mul!. This review did not reproduce it.
    • Ask: move the reservation into a helper in backends/transforms/utils.py and call it from both pass managers' _transform. Then there is one place to delete when the TODO is resolved. Fix this →
  • test_passes.py:1019-1027: unrelated partitioner-test changes are bundled in
    • Commits 63013ec and 55f83d5 tighten test_partitioner_keeps_constant_only_subgraph_on_cpu, which has nothing to do with node names.
    • With "Squash and merge" (CONTRIBUTING.md:444-446), they land under the title "Keep node names unique across QNN passes".
      • That title won't lead anyone to them in git log (:395-396).
      • Reverting the node name fix would revert them too.
    • Ask: move them to their own PR.

Testing

  • test_passes.py:228-234: the check that the decompositions ran doesn't show they had anything to decompose
    • Commit e5f40e2 says the test checks that the autocast and attention decompositions ran. It only checks that no WrapWithAutocast or aten.scaled_dot_product_attention.default node is left afterwards, not that one was there before.
    • Trigger: suppose torch.export starts inlining autocast regions or decomposing SDPA itself. The passes then add no nodes and nothing can collide, so both subtests pass even with the reservation deleted. The name checks at :218-226 pass as well.
    • Ask: before transform_for_export_pipeline, assert that ep.graph has a WrapWithAutocast node (Autocast case) or an SDPA node (Attention case). Fix this →

Recommendation

Approve

Nothing blocks: the reservation fixes the reported failures, and CI is green. Link the upstream issue in the TODO before merging. The other changes can follow.

@psiddh psiddh added the module: qnn Issues related to Qualcomm's QNN delegate and code under backends/qualcomm/ label Oct 30, 2026
@@ -100,6 +100,12 @@
class QnnPassManager(PassManager):

def _transform(self, graph_module: GraphModule):

@psiddh psiddh Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transform_for_to_edge_pipeline doesn't call this, so the name reservation doesn't cover it. Should we call the same reservation there as well ? (in a follow up PR)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for checking. I looked at this, and I think that pipeline is already safe. It always runs on an edge program, and to_edge rebuilds the graph with its own passes, so every node name is recorded again. I tested it with a model whose input is named "mul". Right after torch.export, the model's own mul node gets the name mul_1, and that name is not recorded. After to_edge, all names are recorded, and a new node named "mul" correctly becomes "mul_1". So I left it out, to keep the reservation only where it is needed.

@psiddh

psiddh commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

cc @qti-horodnic @winskuo-quic

Copilot AI balanced review requested due to automatic review settings October 31, 2026 01:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

Copilot AI balanced review requested due to automatic review settings October 31, 2026 02:11
@shoumikhin shoumikhin changed the title Qualcomm AI Engine Direct - Keep node names unique across QNN passes, and decompose constant-only ops on HTP Qualcomm AI Engine Direct - Keep node names unique across the QNN and Samsung passes, and decompose constant-only ops on HTP Oct 31, 2026
Copilot AI balanced review requested due to automatic review settings October 31, 2026 17:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

Copilot AI balanced review requested due to automatic review settings October 31, 2026 18:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 30m 34s —— View job


The node name changes look ready. But on HTP, the new constant-only exemption also covers the group-wise quantize/dequantize ops that QNN has to keep whole, so pre-quantized group-wise weights would likely be decomposed. That should be fixed before merging.

Detailed review

PR Review: #24316

Summary

The PR reserves every existing node name before the QNN and Samsung passes run, using a shared reserve_node_names helper. On HTP, it also decomposes skip-table ops that read only constants. The reviewers found no problems in the node name changes. The HTP change has three:

  • The exemption reaches the group-wise quantize/dequantize ops.
  • It also stops the post-lowering check from reporting constant-only ops left on CPU.
  • No test of it runs in CI.

This review ran no code. Python and the QNN SDK aren't available on this runner, and torch isn't installed. What it says about torch's decomposition table comes from ExecuTorch code, not torch source.

Backward Compatibility

  • backends/qualcomm/partition/qnn_partitioner.py:413-425: on HTP, group-wise weight quantize/dequantize ops are now decomposed
    • Scope: the exemption applies to every target in get_skip_decomp_table(). That list includes quantized_decomposed.quantize_per_channel_group and dequantize_per_channel_group (partition/utils.py:271-272).
    • Why they count as constant-only: a weight dequantize reads the frozen weight, the scales and the zero points. is_parameter (builders/utils.py:39-47) treats all three as constants, so the node goes into constant_only and the lambda returns False for it.
    • What changed: before this PR, filter_fn returned True for these nodes, because all their dtypes are in QNN_TENSOR_TYPE_MAP.
    • How it gets decomposed:
      1. With False, _replace_aten_ops_with_transformed_ops doesn't swap the node for its preserved form (exir/program/_program.py:921-929).
      2. The second run_decompositions(_default_decomposition_table()) (:1250) then decomposes it.
      3. That table pops only _QUANT_PRIMITIVES (exir/tracer.py:671-674), and the two group-wise ops aren't in that list (exir/operator/util.py:47-66).
    • Result: QNN gets the decomposed ops instead of the dequantize it builds the per-block encoding from (builders/node_visitor.py:86-91). The PER_CHANNEL_GROUP_ENCODING arch gate (qnn_partitioner.py:143-157) then has no node to check.
    • Trigger: a model with pre-quantized group-wise weights, lowered for HTP with to_edge_transform_and_lower_to_qnn. The partitioner comment at :143-145 names this as a supported input. LPBQ from the QNN quantizer is not affected: it emits torchao affine ops, which are in _QUANT_PRIMITIVES.
    • Confidence: the ExecuTorch path above is confirmed in the code. That torch's default table decomposes these two ops was inferred, not checked: they are custom ops with a CompositeImplicitAutograd implementation, which is why QNN lists them in its skip table.
    • Fix: in the lambda, keep the skip table's quantize/dequantize targets whole even when they are constant-only. Still propagate constant_only through them, so the ops after them are decomposed. Fix this →

Code Quality

  • qnn_partitioner.py:422-425: the post-lowering check no longer reports constant-only ops left on CPU
    • Mechanism (confirmed): after lowering, to_edge_transform_and_lower calls ops_to_not_decompose again on the lowered program. It passes the filter to _sanity_check_graph_for_non_decomp_ops (_program.py:1418-1436, :1019-1033). The lambda is recomputed on that graph. So a kept-whole op that is still on CPU and reads only constants no longer raises was not decomposed or delegated.
    • How such an op can remain (low confidence): RecomposeRmsNorm and RecomposePixelUnshuffle are active by default (qnn_pass_manager.py:164-165). They run as transform_passes (utils/utils.py:525, _program.py:1397-1398) after decomposition and before partitioning. Both rebuild skip-table ops (partition/utils.py:255, 259).
    • Trigger (not reproduced): the test model, with F.rms_norm(self.table, [16], self.w) in place of linear, lowered for HTP. If the patterns at recompose_rms_norm.py:100-105 match torch's decomposition of rms_norm:
      1. The PR decomposes rms_norm, and RecomposeRmsNorm rebuilds it.
      2. Its partition is dropped.
      3. Lowering succeeds with an undecomposed rms_norm on CPU. Before this PR, lowering failed here with a clear error.
    • Ask: if the patterns match, one option is to make the two recompose passes skip matches whose inputs are all constants on HTP. Fix this →

Testing

  • backends/qualcomm/tests/test_passes.py:1011-1049: no test of the HTP filter runs in CI, and the existing test checks only part of it
    • Not run in CI: this is the only test that reaches the new branch. It skips without the QNN SDK, and the PR description says CI doesn't have the SDK.
    • The SDK isn't needed to test the filter: QnnPartitioner.__init__ only builds options from the compile specs (qnn_partitioner.py:256-259), and the test already builds those outside the try. So QnnPartitioner(compiler_specs).ops_to_not_decompose(torch.export.export(...)) can be tested directly.
    • Gaps in the existing test:
      • It never checks that linear is gone. Because of the previous finding, an undecomposed constant-only linear left on CPU no longer fails lowering, and to_executorch() doesn't check for CPU kernels.
      • It never checks that ops reading a runtime input are still kept whole. A filter that decomposed everything on HTP would pass.
    • Ask:
      • Add an SDK-free test of the filter. It should be False for linear(self.table, self.weight) and True for linear(x, self.weight). After the fix above, it should also be True for a constant-only dequantize_per_channel_group.
      • In the existing test, add assertNotIn(exir_ops.edge.aten.linear.default, top_level_ops).
      • Fix this →

Recommendation

Request Changes

On HTP, the constant-only exemption likely breaks models with pre-quantized group-wise weights. Fixing it takes a small change to the lambda and a test that runs in CI.

Copilot AI balanced review requested due to automatic review settings October 31, 2026 20:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks, all three are done.

  1. The test no longer reads private torch.fx state. Each case now runs again with reserve_node_names patched out and expects Node redefined name. If a torch update removes the collision, the test skips instead of failing and does not stop the later tests.
  2. The reservation is now reserve_node_names in backends/transforms/utils.py, and the Samsung pass manager calls it too. A model with an input named cat that calls torch.cat and then torch.roll failed in Samsung's DecomposeRoll before, and passes now.
  3. I kept the partition test changes here, and the description covers them, so the squash message does too.

I could not find an upstream PyTorch issue for the export naming bug yet.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks, all three are addressed.

  1. Group-wise quantize and dequantize ops: I ran it. With today's torch, a constant-only dequantize_per_channel_group (and torchao dequantize_affine) is not decomposed and still reaches QNN whole. The filter now keeps those ops whole anyway, so it no longer asks for it.
  2. The post-lowering check: confirmed. A constant-only rms_norm was rebuilt by RecomposeRmsNorm and stayed on the CPU without a report. On the lowered program the filter is plain filter_fn again, so this reports as before.
  3. A new filter test needs no SDK and runs in CI. The partition test also checks that no linear is left.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 38m 37s —— View job


Needs discussion: on HTP this PR decomposes constant-only kept-whole ops even when their partition stays delegated, and in quantized models such as the in-repo Swin-v2-T example the ops that replace a constant-only linear get no quantization encoding, so they will probably fall back to CPU; the other findings are minor.

Detailed review

PR Review: #24316

Summary

This PR records existing node names before the QNN and Samsung pass managers run, so passes no longer raise Node redefined name. On HTP it also decomposes kept-whole ops that read only constants. I found no problem in the name reservation. The main risk is how the HTP decomposition behaves on quantized models; the rest is minor.

This review ran no code. Python, torch and the QNN SDK are not available on this runner, so what it says about HTP and torch behavior comes from reading the code. Claims that reading could not confirm are marked as suspected.

Backward Compatibility

  • backends/qualcomm/partition/qnn_partitioner.py:410-435: in quantized HTP models, a constant-only linear that used to be delegated probably ends up on CPU (all 3 reviewers)
    • Confirmed by reading the code: the filter decomposes every constant-only kept-whole op, even when its partition stays delegated. The comment at 410-414 and the description both say so.

      • In a quantized model this decomposition runs after convert_pt2e, so the ops it creates have no q/dq around them. For a linear that is a permute_copy on the dequantized weight followed by mm or addmm.
      • AnnotateQuantAttrs gives an encoding only to the producer of a q node and to the parameter behind a dq (annotate_quant_attrs.py:46-56, 120-134). So the permute_copy, and any other new op before the last one, gets none.
      • Before this PR the linear stayed whole, and both of its inputs came straight from dq nodes that carry encodings.
    • Suspected, not run: HTP rejects these unencoded float ops in a quantized graph. They then fall back to CPU and split the partition that used to hold them.

    • Where it would show up: examples/qualcomm/oss_scripts/swin_v2_t.py lowers torchvision's swin_v2_t with an 8a8w HTP quantizer, through build_executorch_binary and to_edge_transform_and_lower_to_qnn (backends/qualcomm/export_utils.py:677). Every attention block in torchvision's model computes self.cpb_mlp(self.relative_coords_table), which is two nn.Linears on a buffer. That comes from torchvision's source and was not checked here.

    • Also suspected: skip_node_id_set is matched on node.name (line 180). The decomposed linears disappear from the graph, which probably also renumbers the linears after them, so existing entries can silently point at different nodes.

    • Ask: do one of these:

      • Lower the Swin-v2-T example, or another quantized model with a constant-only linear, before and after this change, and compare how many ops are delegated.
      • Add a quantized test where a constant-only linear sits in a partition that stays delegated.

      If it regresses, decompose only the constant-only ops whose partition would stay on CPU. One way is to fold constant-only subgraphs first, as the comment at 414 suggests.

    • Fix this →

API Design

  • backends/qualcomm/partition/qnn_partitioner.py:406-409: the HTP filter depends on how exir calls ops_to_not_decompose, and exir does not document that (all 3 reviewers, minor)
    • to_edge_transform_and_lower calls it twice:
      • on the ATen program, to choose what to decompose (exir/program/_program.py:911, 1193);
      • on the lowered EDGE program, for the post-lowering check (_program.py:1418-1436).
    • QNN tells the two calls apart by ep.dialect and answers them differently.
    • The base class docstring (exir/backend/partitioner.py:102-114) describes only the first call. If exir changes either call, the HTP filter silently changes what it does: constant-only kept-whole ops left on CPU would stop being reported, or they would be kept whole again.
    • Ask: document both calls, and the dialect each one gets, in Partitioner.ops_to_not_decompose.
    • Fix this →

Testing

  • backends/qualcomm/tests/test_passes.py:1015-1122: no CI test runs the HTP filter through to_edge_transform_and_lower (2 of 3 reviewers, minor)
    • test_htp_keeps_whole_only_ops_that_read_a_runtime_input calls ops_to_not_decompose directly (lines 1089 and 1114).
    • test_partitioner_keeps_constant_only_subgraph_on_cpu runs the whole flow, but CI skips it without the QNN SDK libraries (line 1041). The description says so too.
    • So CI never checks that to_edge_transform_and_lower actually decomposes the constant-only linear and that its post-lowering check then passes. exir takes the filter from a second call on a program that has already been decomposed once (_program.py:911), and the direct test does not reproduce that.
    • Ask: commit the version the description says was run with the QNN op check, manager and compile stubbed out, so it runs in CI. It would also pin the exir behavior described under API Design.
    • Fix this →

Code Quality

  • The HTP change does not decompose every constant-only kept-whole op, and the description does not say which ones it misses (all 3 reviewers, minor)
    • rms_norm and pixel_unshuffle: the filter lets them decompose, but RecomposeRmsNorm and RecomposePixelUnshuffle rebuild them in the to-edge passes. Both are on by default (backends/qualcomm/_passes/qnn_pass_manager.py:164-165). So a constant-only one that ends up on CPU still fails with was not decomposed or delegated.
    • Low confidence: scatter.src, scatter_add, scatter_reduce.two, reflection_pad1d and reflection_pad2d may stay whole too. That happens if the default decomposition table has nothing for them, which could not be checked without torch. No QNN pass decomposes them.
    • Ask: list the ops this does not cover under "Not covered" in the description.
  • backends/qualcomm/partition/qnn_partitioner.py:426-430: the quantize/dequantize exemption is chosen by namespace string (2 of 3 reviewers, minor)
    • The other op groups in this file are explicit sets (CPU_DQ_OPS at line 81, PER_CHANNEL_GROUP_ENCODING imported at line 14).
    • Matching on op.namespace also exempts any op from those two namespaces that is added to get_skip_decomp_table() later, whether or not it is a quantize or dequantize op.
    • Ask: use a named module-level set of those ops, which would match the rest of the file.
    • Fix this →

Recommendation

Needs Discussion

The Backward Compatibility finding should be settled before merging. This PR decomposes constant-only ops in partitions that stay delegated, on purpose, and on quantized HTP models that probably moves them to CPU. Please either confirm the behavior on a quantized model with a constant-only linear, such as the Swin-v2-T example, or narrow the decomposition. The other findings are minor.

torch.export does not record some of the names it assigns: the names of
inputs, and the names of ops it renamed to make room for an input. With
an input called mul, the model's own input * 2 becomes mul_1, and
nothing marks mul_1 as taken. A later pass that creates a node can then
pick one of those names again, and the graph fails with "Node redefined
name t_1!". QNN models with autocast regions or attention hit it, and so
does the Samsung DecomposeRoll pass on a model with an input named cat.

reserve_node_names in backends/transforms/utils.py records the name of
every node of the top-level graph before the passes run. The QNN and
Samsung pass managers call it at the start of _transform. torch.fx has
no public way to reserve a name: copying the graph would register the
names, but it also renames inputs named after Python builtins (input
becomes input_1) while the graph signature keeps the old names. So the
helper adds the names to the graph's name set directly, with a TODO to
drop it once torch.export records the names
(pytorch/pytorch#202927).

The node name test runs the QNN export passes on an autocast model and
on an attention model whose inputs are named input and mul. It checks
the graph lints, keeps the input names of the graph signature, and
matches eager, and that the collision is real without the helper.
Copilot AI balanced review requested due to automatic review settings November 2, 2026 09:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin shoumikhin changed the title Qualcomm AI Engine Direct - Keep node names unique across the QNN and Samsung passes, and decompose constant-only ops on HTP Qualcomm AI Engine Direct - Keep node names unique across the QNN and Samsung passes Nov 2, 2026
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 20m 58s —— View job


Nothing blocks merging. There are two minor findings: RemoveUnusedConstantsPass already shows how to record the names without writing private torch.fx state, and the shared helper is tested only through one QNN pipeline test, with nothing testing the Samsung call.

Detailed review

PR Review: #24316

Summary

The PR adds reserve_node_names, which records every top-level node name in the graph's FX namespace. That namespace is the set of names torch.fx checks before it names a new node. The QNN and Samsung pass managers call the helper at the start of _transform, so nodes their passes add can't reuse names that torch.export assigned without recording them. There are two minor findings, and nothing blocks.

This review ran no code. Python can't run on this runner and torch isn't installed, so what it says about torch.fx comes from reading ExecuTorch code and the PR description.

Code Quality

  • backends/transforms/utils.py:70-72: the copy approach the description rejects already works in the same directory (2 of 3 reviewers)
    • Why it matters:
      • This is the only write to graph._graph_namespace._used_names in the repo. The other two uses only read it (fuse_batch_norm_with_conv.py:119, fuse_rms_norm.py:375).
      • The write runs in every _transform. That includes the preprocess pipelines that every QNN and Samsung lowering goes through (qnn_preprocess.py:103, enn_preprocess.py:48).
      • So a torch change to _Namespace would break all QNN and Samsung lowering, not just the models that collide.
    • Copying works with one extra step:
      • The description rejects copying the graph because the copy renames input to input_1.
      • RemoveUnusedConstantsPass deep-copies the graph and then sets each copied node's name back to the original (backends/transforms/remove_unused_constants_pass.py:57-60). Its comment names the same input case.
      • The copy records every name through create_node, as the description says. A restored name like input stays unrecorded. That is harmless, because torch.fx never gives a new node a builtin name, which is why the copy renamed it.
    • Caveat (not checked): the copy replaces every Node, and the passes are constructed before _transform runs (for example qnn_pass_manager.py:472-477). The approach only works if no pass keeps node references from its constructor.
    • Ask: in reserve_node_names, copy the graph, restore the names, and assign the copy back with graph_module.graph = graph. If that doesn't work here, say why in the TODO.
    • Fix this →

Testing

  • backends/qualcomm/tests/test_passes.py:191-264: the shared helper is tested only through this QNN pipeline test, and nothing tests the Samsung call (3 of 3 reviewers)
    • Samsung: deleting the call at enn_pass_manager.py:48 still passes every test, because the only test patches the QNN module's reference to the helper (test_passes.py:246). The cat + torch.roll reproducer from the description isn't checked in.
    • The QNN test can go stale without any torch change: each case collides only because a pass happens to pick a name that torch.export left unrecorded. The autocast retrace names the second transpose t_1, and DecomposeScaledDotProductAttention copies in a mul node (backends/transforms/decompose_sdpa.py:140). If either pass starts naming its nodes differently:
      • that case stops colliding, and the whole method reports SKIPPED (:259-264), even if the other case still exercises the helper;
      • the skip message points at the torch issue, although torch didn't change.
    • Ask: add a direct test in backends/transforms/test/. pytest collects backends/transforms by default (pytest.ini:94), and no vendor SDK is needed.
      • Give a node a suffixed name by assigning node.name directly, as torch.export does. Then call reserve_node_names, add a node whose default name would collide, and lint the graph.
      • This test doesn't depend on how any pass names its nodes. It needs no skip, and it covers the helper for both backends.
      • If you take the copy approach above, also check that a node named input keeps its name.
    • Fix this →

Recommendation

Approve

Nothing blocks. Both findings are minor and can be handled in this PR or a follow-up.

shoumikhin added a commit that referenced this pull request Nov 2, 2026
… Samsung passes

torch.export does not record some of the names it assigns: the names of
inputs, and the names of ops it renamed to make room for an input. With
an input called mul, the model's own input * 2 becomes mul_1, and
nothing marks mul_1 as taken. A later pass that creates a node can then
pick one of those names again, and the graph fails with "Node redefined
name t_1!". QNN models with autocast regions or attention hit it, and so
does the Samsung DecomposeRoll pass on a model with an input named cat.

reserve_node_names in backends/transforms/utils.py records the name of
every node of the top-level graph before the passes run. The QNN and
Samsung pass managers call it at the start of _transform. torch.fx has
no public way to reserve a name: copying the graph would register the
names, but it also renames inputs named after Python builtins (input
becomes input_1) while the graph signature keeps the old names. So the
helper adds the names to the graph's name set directly, with a TODO to
drop it once torch.export records the names
(pytorch/pytorch#202927).

The node name test runs the QNN export passes on an autocast model and
on an attention model whose inputs are named input and mul. It checks
the graph lints, keeps the input names of the graph signature, and
matches eager, and that the collision is real without the helper.

Same change as #24316 on main.
@JacobSzwejbka
JacobSzwejbka merged commit cb3f24e into pytorch:main Nov 2, 2026
234 of 237 checks passed

@qti-horodnic qti-horodnic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @shoumikhin, I see this pr has been merged already but it I reviewed it and lgtm. I left a few minor comments, feel free to take them up as part of the other prs you have open

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If only one case stops colliding, the whole test is skipped, but the other case still tests the helper. We can skip only when len(no_collision) == len(cases), or call skipTest inside each subTest.

return FakeTensorConverter().from_real_tensor(example_fake_tensor.fake_mode, t=data)


def reserve_node_names(graph_module: torch.fx.GraphModule) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a torch-only unit test in backends/transforms/test/? E.g., export forward(self, x, mul): return x * 2 + mul, call reserve_node_names, add a node with create_node(..., name="mul_1"), and call graph.lint(). This will also cover the Samsung path, which has no test

get_qnn_pass_manager_cls()().transform_for_export_pipeline(
ep
).graph.lint()
except Exception as e:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a pass fails for a different reason, this shows as an assertIn failure, and we lose the original traceback. I suggest doing if "Node redefined name" not in str(e.__cause__ or e): raise instead

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks for the review! All 3 are addressed in #24429:

  • The name collision test now skips only when no case collides.
  • It re-raises any error that is not the name collision, so the original traceback is kept.
  • backends/transforms/test/test_reserve_node_names.py tests reserve_node_names with torch only, using your x * 2 + mul example.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. module: qnn Issues related to Qualcomm's QNN delegate and code under backends/qualcomm/ release notes: qualcomm Changes to the Qualcomm backend delegate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants