Skip to content

Qualcomm AI Engine Direct - Decompose kept-whole ops that HTP leaves on CPU - #24429

Open
shoumikhin wants to merge 6 commits into
pytorch:mainfrom
shoumikhin:qnn-htp-constant-only
Open

shoumikhin wants to merge 6 commits into
pytorch:mainfrom
shoumikhin:qnn-htp-constant-only

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

What is wrong today

On HTP, a partition that reads nothing at runtime stays on the CPU, because HTP refuses a graph without runtime inputs. That decision happens after decomposition. So a constant-only op that QNN keeps whole, such as linear, can land in such a partition undecomposed, and lowering fails:

RuntimeError: Node aten_linear_default ... was not decomposed or delegated.

What this change does

Before the kept-whole ops are decomposed, the HTP filter of ops_to_not_decompose estimates which ops HTP will leave on the CPU, and decomposes only the kept-whole ops among them. An op counts as a CPU op only when its data types or shapes rule QNN out (the checks filter_fn already makes, now shared as must_stay_on_cpu), and every getitem counts as a QNN op, since QNN's op check accepts it. Ops that the estimate expects to be delegated stay whole and keep their quantization encodings.

Only a partition whose ops read no runtime value is dropped, so the estimate looks only at those ops. It leaves such an op on the CPU when no chain of QNN ops links it to an op that reads a runtime value, or when it reaches a QNN op through a CPU op without passing an op that reads a runtime value, because the partitioner may then split it off to avoid a cycle. The estimate is a few passes over the graph, each visiting every node and edge once. Running the partitioner here instead would be far too slow on large models, since its time grows with the square of the graph size (about 4 times slower for each doubling, measured from 2,000 to 32,000 nodes).

The caller's skip_node_op_set and skip_node_id_set are left out of this estimate on purpose. Decomposing adds nodes that take the names of later nodes, so a skip that changed what is decomposed could rename the node a skip entry means.

The Partitioner.ops_to_not_decompose docstring now describes both stages.

What it covers

Covered: kept-whole ops cut off from the model's inputs by data types (such as bfloat16) or data-dependent shapes.

Not covered, and failing as before:

  • a kept-whole op cut off only by a skip or by an op that QNN rejects while partitioning;
  • a constant-only op that QNN's own passes rebuild after decomposition, such as rms_norm or pixel_unshuffle;
  • scatter.src, scatter_add and scatter_reduce (no default decomposition);
  • kept-whole ops inside control-flow bodies, since QNN only partitions the top-level graph.

The estimate can also err the other way. The partitioner also merges partitions that share no data, so a constant-only kept-whole op that nothing links to a runtime value, or one with a CPU detour, can still end up delegated. Then it is delegated in pieces.

Tests

test_htp_decomposes_kept_whole_ops_left_on_cpu stubs the QNN SDK and the compile. It checks that:

  • linears cut off by bfloat16 casts or by a masked select are decomposed;
  • a linear that feeds a delegated add both directly and through CPU casts is decomposed;
  • a linear that feeds a delegated add and a CPU split, whose getitems QNN takes, is decomposed;
  • a linear in a model whose input reaches only a CPU op is decomposed;
  • a constant-only linear in a delegated partition stays whole, even when its add reaches another QNN op through CPU casts;
  • the lowered program gets plain filter_fn, so exir still reports kept-whole ops left on the CPU;
  • a node ID skip still names the same add when an op skip is present.

test_htp_kept_whole_estimate_is_fast_on_large_graphs times the estimate on a 6,000-node chain. It takes well under 1 s, where running the partitioner on the same machine takes about 10 s.

Each check fails when the rule it covers is removed, and the tests pass with torch 2.14 and 2.16 nightly on Linux x86_64. test_partitioner_keeps_constant_only_subgraph_on_cpu still runs against the real SDK. With the QNN SDK (SM8650), Swin-v2-T lowers as on main in 8a8w and fp16: one partition, nothing on the CPU. On a 43,000-node vision-language-action model the estimate takes under 4 s.

Follow-ups from the review of #24316

  • test_export_pipeline_keeps_node_names_unique now skips only when no case collides any more, and it re-raises any error that is not the name collision, so the original traceback is kept.
  • A new torch-only test_reserve_node_names checks that a node added after reserve_node_names does not take a name that export already used. It tests the helper directly, so it needs no backend.

cc @cbilgin @psiddh

Copilot AI balanced review requested due to automatic review settings November 2, 2026 13:55
@shoumikhin shoumikhin added the release notes: qualcomm Changes to the Qualcomm backend delegate label Nov 2, 2026
@pytorch-bot

pytorch-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

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

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

❌ 12 Pending, 2 Unclassified Failures

As of commit 78cdf65 with merge base 893d815 (image):

UNCLASSIFIED FAILURES - DrCI could not classify the following jobs because the workflow did not run on the merge base. The failures may be pre-existing on trunk or introduced by this PR:

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 Nov 2, 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. Try requesting a review at a lower effort level.

@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 34m 51s —— View job


This PR makes the HTP ops_to_not_decompose filter decompose kept-whole ops that a pre-decomposition run of the drop rule would leave on CPU. I'm requesting changes for three reasons: that estimate can also decompose ops that main delegates whole, the new exir Partitioner docstring misstates exir's behavior, and the rewritten test removes the only real-HTP CI coverage of the drop rule.

Detailed review

PR Review: #24429

Summary

On HTP, QnnPartitioner.ops_to_not_decompose now partitions the program before the kept-whole ops are decomposed, and decomposes the ones that land in partitions with no runtime input. The PR also documents the two call stages on exir's Partitioner. Problems found:

  • The estimate can decompose more than it should.
  • The base-class docstring is wrong in places.
  • The test no longer runs against a real SDK and leaves several claimed cases untested.

All four changed files were reviewed.

Backward Compatibility

  • The estimate can decompose kept-whole ops that main delegates whole (backends/qualcomm/partition/qnn_partitioner.py:419-432, with is_runtime_input at :325-328).
    • Why it happens: the drop rule counts any input that is not a parameter as a runtime input, including the output of an op running on CPU. The estimate ignores skips and every QnnOperatorSupport rejection (no visitor, to_be_implemented_operator, QCOM_FALLBACK_NODE, IsNodeSupportedByBackend).
      • In the estimate, a skipped or rejected feeder that reads only constants gets merged into the kept-whole op's partition. That partition then reads nothing at runtime, so the op is decomposed.
      • In the real partitioning, the feeder runs on CPU, its output counts as a runtime input, and the op's partition is delegated.
    • Trigger (fp16 HTP): x + F.linear(self.table.abs(), self.weight).to(torch.bfloat16).float() with skip_node_op_set={"aten.abs.default"}.
      • On main, {linear} reads the output of abs on CPU and is delegated whole.
      • With this PR, the estimate sees {abs, linear} fed only by buffers, so linear is decomposed and its pieces are delegated.
    • Consequences:
      • The description's claim that "every other kept-whole op stays whole" does not hold.
      • Node names after the decomposed op shift relative to main. A skip_node_id_set that works on main can then silently name a different node, which is the hazard the PR gives for leaving skips out of the estimate.
      • By the PR's own reasoning, the delegated pieces may lose the op's quantization encodings. I have not verified this.
    • The trigger is narrow, but the comment at :413-418 and the "Not covered" list only describe the opposite direction. At minimum, document this direction too and pin it with a test. Fix this →

API Design

  • The new Partitioner.ops_to_not_decompose docstring misstates exir, and turns temporary exir internals into a contract for every backend (exir/backend/partitioner.py:108-121).
    • :120-121: "so those entries are never kept whole" is wrong.
      • _remove_invalid_ops_for_not_decompose allow-lists torchao.choose_qparams_affine.default, even though it aliases an output (exir/program/_program.py:1063-1074).
      • Blocklisted ops with no default decomposition (add/sub/mul/div.Tensor) stay whole anyway.
      • exir's own log wording is the accurate one: the request "is ignored", meaning the op is neither preserved nor checked after lowering.
    • :117-118: the post-lowering exemption covers every edge variant in _QUANT_PRIMITIVES (_program.py:1018-1024), not only the quantize and dequantize ops. That includes convert_element_type.no_fuse and choose_qparams.tensor (exir/operator/util.py:47-66).
    • Scope: naming a private symbol and the drop rules in the public base class locks in code that exir itself calls "a band-aid" and "a temp fix" (_program.py:1134-1139).
      • QNN relies only on the paragraphs about the two stages, ep.dialect, and the filter applying to the same ep. Consider keeping only those.
      • Every backend reads this contract, so this hunk should get sign-off from the exir owners. Fix this →

Testing

  • The rewritten test always mocks HTP, which drops the only real-HTP CI coverage of the drop rule (backends/qualcomm/tests/test_passes.py:983-994).
    • What CI loses:
      • The only CI job that runs this file, test-qnn-passes-linux (.github/workflows/pull.yml:1290-1323), uses the QNN SDK image with QNN_SDK_ROOT set. On main, this test ran the real op-support check and the real HTP compile there.
      • Now IsNodeSupportedByBackend returns a truthy MagicMock, and preprocess_multimethod returns fake bytes. CI can no longer catch HTP refusing a partition that the drop rule keeps.
      • The original scenario, a constant-only subgraph cut off by a skipped relu, is no longer tested.
      • The neighbouring partitioner tests (:889, :1041, :1109) run a real lowering and call skipTest when the backend can't initialize.
    • The comment "Runs without the QNN SDK" (:983) is inaccurate. to_edge_transform_and_lower_to_qnn calls setup_qnn_sdk() itself (backends/qualcomm/utils/utils.py:418), and the test does not patch it. On Linux x86_64 with QNN_SDK_ROOT unset, the test downloads the SDK, or raises "Failed to set up QNN SDK" when offline (backends/qualcomm/utils/qnn_sdk_setup.py:68-110).
    • Fix: keep the original case as a real-SDK test. In the mocked test, also patch executorch.backends.qualcomm.utils.utils.setup_qnn_sdk, the way test_qnn_delegate.py:12349 patches it for qnn_manager_lifecycle. Fix this →
  • Several claims in the PR description have no test (test_passes.py:951-1039):
    • Data-dependent shapes: the description lists cut-offs by data-dependent shapes as covered, but no test has one. test_partitioner_falls_back_on_data_dependent_shape (:1109) indexes the runtime input x, so it never forms a partition with only constant inputs.
    • Quantization: there is no quantized case, although keeping encodings is the stated reason for not decomposing everything.
      • In a quantized ATen graph, the estimate relies on _unsupported_dtypes_are_quantized.
      • The bf16 test next to this one already loops over 8a8w, fp16 and weight-only (:1053-1057).
    • assertIs(keep_whole, filter_fn) (:1023) checks function identity rather than the behavior its comment describes. A direct check would lower a constant-only scatter_add cut off by bf16 casts and assert that it still raises "was not decomposed or delegated". scatter_add works for this because it is kept whole and has no default decomposition.

Code Quality

  • The filter silently depends on the order of exir's calls (qnn_partitioner.py:411-439).
    • How it works now: left_on_cpu() is computed lazily over the captured ep and matched by node identity. The filter exir actually uses comes from the call at exir/program/_program.py:911. That call runs on the program produced by the first run_decompositions at :1237, which the comments at :413 and :434 describe as "before decomposition".
    • Failure mode: if a node from any other program reaches the filter, every lookup misses. The filter then quietly becomes plain filter_fn, and the original error comes back with no hint why. One plausible way this happens is an exir cleanup that reuses the result fetched at :1193.
    • Fix: the new docstring states the contract, but nothing enforces it. Raising on a node that is outside ep.graph_module and its control-flow submodules would make a violation loud.
    • Comment at :406-407: it inverts the meaning at the EDGE stage. There, True marks a node that must not stay on CPU, and filter_fn does let must_stay_on_cpu nodes stay. Fix this →
  • Kept-whole ops inside control-flow bodies are not handled, and the description doesn't list them as a gap (qnn_partitioner.py:419-425).
    • Why they fail: the estimate, like generate_partitions (:271-274), partitions only the top-level graph. exir also filters and checks the nodes of control-flow submodules (_program.py:931-940, 1034-1049). So a kept-whole op inside a torch.cond branch still fails on HTP.
    • Fix: QNN never tags submodule nodes, so adding every node from get_control_flow_submodules(ep.graph_module) to left_on_cpu() closes this cheaply.
    • Otherwise: add control flow to "Not covered". Add matmul there too: the opt-in ConvertBmmToMatmul pass (_passes/qnn_pass_manager.py:135) rebuilds it after decomposition, the same kind of failure as the rms_norm/pixel_unshuffle cases. Fix this →

Recommendation

Request Changes

What blocks approval:

  • The wrong statements in the docstring need fixing.
  • The lost real-HTP test coverage needs restoring.
  • The over-decomposition side of the estimate should at least be documented and tested, if not fixed.
  • The exir docstring hunk also needs sign-off from an exir owner.

shoumikhin added a commit that referenced this pull request Nov 2, 2026
…on CPU

On HTP, a partition that reads nothing at runtime stays on the CPU,
because HTP refuses a graph without runtime inputs. That is decided after
decomposition. So a constant-only op that QNN keeps whole, such as
linear, could land in such a partition undecomposed, and lowering failed
with "was not decomposed or delegated".

The HTP filter of ops_to_not_decompose now runs the same drop rule before
decomposition and decomposes only the kept-whole ops it would leave on
the CPU. The real partitions are not known yet, so it partitions the
graph as if QNN took every op except the ones whose data types or shapes
rule it out (the checks filter_fn already makes, now shared as
must_stay_on_cpu). Ops in partitions that stay delegated keep their
whole form, and with it their quantization encodings.

The caller's skip_node_op_set and skip_node_id_set are left out of that
estimate on purpose. Decomposing adds nodes, and the new nodes take names
that the later nodes had before. If a skip could change what is
decomposed, adding one skip could rename the node that a
skip_node_id_set entry means, and the wrong node would be skipped. Ops
that QNN only rejects while partitioning are not known yet either. So a
kept-whole op that only a skip or such an op cuts off still fails as
before.

exir calls ops_to_not_decompose several times before decomposition and
uses only one of the filters, so the estimate runs on the filter's first
use. On a 10,240-node graph the partition search alone takes about 10 s.

On the lowered program the filter stays plain filter_fn, so exir still
reports kept-whole ops left on the CPU.

The constant-only partition test now stubs the QNN manager and the
compile, so it runs without the QNN SDK. It checks that a constant-only
linear cut off by bfloat16 casts is decomposed, that a constant-only
linear in a delegated partition stays whole, that the filter is plain
filter_fn on the lowered program, and that a node ID skip still names
the same add when an op skip and an ID-skipped feeder are present.

Same change as #24429 on main, including
its Partitioner.ops_to_not_decompose docstring. It replaces the earlier version
carried here, which decomposed every op that reads only constants.
@shoumikhin
shoumikhin force-pushed the qnn-htp-constant-only branch from 8e4fe06 to 5dab5e9 Compare November 2, 2026 17:17
Copilot AI balanced review requested due to automatic review settings November 2, 2026 17:17
@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks for the review. The latest push changes this:

  • Decomposing too much: you are right that the estimate can also decompose a kept-whole op that ends up delegated, when a skipped or rejected op that reads only constants feeds it. The code comment and the description now say so. I did not add a test for it, because it would lock in a known gap, not a wanted behavior. Fixing it needs the real partitions before decomposition, which is a bigger change.
  • Docstring: trimmed. It now keeps only the two stages, ep.dialect, and the rule that a filter applies to the ep of the same call. The drop rules and _QUANT_PRIMITIVES are gone. An exir owner should still look at this part.
  • Real SDK: test_partitioner_keeps_constant_only_subgraph_on_cpu is back as it is on main, so CI runs it against the real SDK again. The stubbed checks are a separate test, which also patches setup_qnn_sdk, so it no longer tries to download the SDK.
  • Data-dependent shapes: the stubbed test now has a constant-only linear cut off by a masked select.
  • Quantized case: not added. For quantized graphs the estimate uses the same must_stay_on_cpu check as filter_fn. With the real SDK, Swin-v2-T in 8a8w lowers into one partition with nothing on the CPU, the same as on main.
  • assertIs(keep_whole, filter_fn): kept. On the lowered program the contract is exactly "return plain filter_fn". A scatter_add test would also depend on scatter_add having no default decomposition, which can change.
  • Nodes from another program: no check added. If that ever happens, the lookup misses, the op stays whole, and exir's check after lowering fails with "was not decomposed or delegated", the same error as today. So it cannot go wrong silently.
  • Comments: reworded. The lowered-program comment no longer inverts the filter's meaning, and the comments and the description now say the estimate runs before the kept-whole ops are decomposed, not "before decomposition".
  • Control flow: added to "Not covered". The estimate only sees the top-level graph, so ops inside control-flow bodies get plain filter_fn, as on main. A matmul rebuilt by ConvertBmmToMatmul falls under the "ops that QNN's own passes rebuild" item.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

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.

@claude

claude Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 26m 55s —— View job


I recommend approval: I found no correctness problems in this revision, and two minor requests remain: run the new scenario against the real QNN SDK in CI, and keep the HTP-only drop rule in one place.

Detailed review

PR Review: #24429

Summary

On HTP, QnnPartitioner.ops_to_not_decompose now decomposes kept-whole ops that the drop rule would leave on CPU. It estimates this on the largest possible partitions, before those ops are decomposed. The PR also updates exir's Partitioner docstring to describe the two stages at which exir calls this method.

I found no correctness problems beyond the limits the description already lists. Two minor problems remain:

  • The fix is tested only with HTP stubbed out.
  • The rule for which backends drop partitions without runtime inputs is now written in two places.

All four changed files were reviewed.

Code Quality

  • The HTP-only drop rule is now written in two places (backends/qualcomm/partition/qnn_partitioner.py:409, :431).
    • Where: generate_partitions runs _drop_partitions_without_runtime_inputs only on HTP (:276-281; the comment there says why LPAI and GPU are left out). ops_to_not_decompose repeats the backend check at :409 and calls the drop rule itself at :431.
    • Why it matters: nothing keeps the two in step. The new test only builds HTP specs, so no test notices if they drift:
      • If the drop at :278 is extended to another backend but :409 is not, that backend fails again with "was not decomposed or delegated".
      • If :409 is loosened, GPU starts decomposing constant-only kept-whole ops that it delegates whole today, because GPU accepts graphs without inputs.
    • Fix: decide once whether the backend drops these partitions and use that in both places. One option is an attribute set in __init__ next to self.backend, with the comment from :276-277 moved there. Fix this →

Testing

  • The fix only runs with HTP stubbed out, although the CI job for this file has the real SDK (backends/qualcomm/tests/test_passes.py:1014-1027).
    • What the stubs hide: get_or_create_qnn_manager returns a MagicMock. As a result, IsNodeSupportedByBackend (qnn_partitioner.py:217-222) accepts every op that reaches it, and the stubbed preprocess_multimethod compiles nothing. The test cannot see real HTP reject, or fail to compile, anything the estimate counts as delegated.
      • Example: the test expects the constant-only linear(self.table, self.weight) to stay whole inside the delegated partition. If real HTP refused that linear, it would stay whole on CPU and lowering would fail with the original error. This test would still pass.
    • Why existing coverage doesn't catch it:
      • test-qnn-passes-linux (.github/workflows/pull.yml:1290-1323) builds the SDK and runs this file.
      • No test in this file that runs against the real SDK has a kept-whole op cut off from the inputs. test_partitioner_keeps_constant_only_subgraph_on_cpu (:951-977) has no kept-whole op. The kept-whole ops in the bfloat16 and data-dependent-shape tests (:1074, :1142) read x.
    • Fix: keep the stubbed test, and also lower ConstantBranches without the patches, under the file's "Failed to initialize QNN backend" → skipTest guard (:966-976). Assert the same top-level and delegated ops, then call to_executorch(). Fix this →

Recommendation

Approve

Nothing I found blocks approval. The real-SDK test is the more important of the two requests, because it is the only way CI can catch the estimate disagreeing with the real HTP checks. The exir/backend/partitioner.py change still needs review from its code owner.

JacobSzwejbka pushed a commit that referenced this pull request Nov 2, 2026
… Samsung passes (#24316)

## 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
@psiddh psiddh added the module: qnn Issues related to Qualcomm's QNN delegate and code under backends/qualcomm/ label Nov 2, 2026
…on CPU

On HTP, a partition that reads nothing at runtime stays on the CPU,
because HTP refuses a graph without runtime inputs. That is decided after
decomposition. So a constant-only op that QNN keeps whole, such as
linear, could land in such a partition undecomposed, and lowering failed
with "was not decomposed or delegated".

The HTP filter of ops_to_not_decompose now runs the same drop rule before
the kept-whole ops are decomposed, and decomposes only the ones it would
leave on the CPU. The real partitions are not known yet, so it partitions the
graph as if QNN took every op except the ones whose data types or shapes
rule it out (the checks filter_fn already makes, now shared as
must_stay_on_cpu). Ops in partitions that stay delegated keep their
whole form, and with it their quantization encodings.

The caller's skip_node_op_set and skip_node_id_set are left out of that
estimate on purpose. Decomposing adds nodes, and the new nodes take names
that the later nodes had before. If a skip could change what is
decomposed, adding one skip could rename the node that a
skip_node_id_set entry means, and the wrong node would be skipped. Ops
that QNN only rejects while partitioning are not known yet either. So a
kept-whole op that only a skip or such an op cuts off still fails as
before. And when such an op reads only constants and feeds a kept-whole
op, the estimate decomposes the kept-whole op although it ends up
delegated, so it is delegated in pieces.

exir calls ops_to_not_decompose several times on the ATen program and
uses only one of the filters, so the estimate runs on the filter's first
use. On a 10,240-node graph the partition search alone takes about 10 s.

On the lowered program the filter stays plain filter_fn, so exir still
reports kept-whole ops left on the CPU.

A new test stubs the QNN SDK setup, the QNN manager and the compile, so
it runs without the SDK. It checks that constant-only linears cut off by
bfloat16 casts or by a masked select are decomposed, that a constant-only
linear in a delegated partition stays whole, that the filter is plain
filter_fn on the lowered program, and that a node ID skip still names
the same add when an op skip and an ID-skipped feeder are present. The
existing constant-only partition test still runs against the real SDK.
to_edge_transform_and_lower calls ops_to_not_decompose during decomposition,
on the ATen program, to choose which nodes to keep whole, and after
lowering, on the EDGE program, to report kept-whole nodes that were neither
decomposed nor delegated. The docstring only described the first use.
QnnPartitioner tells the two apart by ep.dialect, and its HTP filter only
works on the nodes of the program it was given, so the docstring now names
both stages, their dialects, and that rule. It also says that the list
checked after lowering includes the lists of earlier partitioners for the
same method. Docstring only.
To decide which kept-whole ops to decompose, the HTP filter of
ops_to_not_decompose estimated which ops HTP leaves on the CPU by running
the capability-based partitioner. That partitioner's time grows with the
square of the graph size: it was about 4 times slower for each doubling,
from 2,000 to 32,000 nodes. On a large vision-language-action model, with
about 43,000 nodes, the export sat in its cycle search for more than 2
hours.

Only a partition whose ops read no runtime value is dropped, so the
estimate now looks only at those ops. Only ops whose data types or shapes
rule QNN out count as CPU ops, and every getitem counts as a QNN op, since
QNN's op check accepts it. One pass over the graph in order and one in
reverse find, for every op QNN could take:
- whether it reads a runtime value, directly or through other QNN ops;
- whether it reaches a QNN op through a CPU op, without passing an op
  that reads a runtime value. The partitioner may split such an op off
  to avoid a cycle.
A walk from the ops that read a runtime value then finds the ops linked
to them by QNN ops alone. An op that reads no runtime value is left on
the CPU when nothing links it to one, or when it has such a CPU detour.
Each pass visits every node and edge once.

Skips and ops that QNN rejects while partitioning are still not known
here, as before. Apart from those, the estimate errs toward decomposing.
The partitioner also merges partitions that share no data, so a
constant-only op that nothing links to a runtime value, or one with a CPU
detour, can still end up delegated. Then it is delegated in pieces.

The constant-only linear that stays whole in the HTP test now also feeds
an add that reaches another QNN op through CPU casts, so the test checks
that such a path does not decompose it.

New cases in the HTP test, where a constant-only linear must be
decomposed: one feeds a delegated add both directly and through CPU
casts, one feeds a delegated add and a CPU split whose getitems QNN takes,
and one sits in a model whose input reaches only a CPU op.

A new test times the estimate on a 6,000-node chain: well under 1 s
now, about 10 s with the partitioner on the same machine.
reserve_node_names was only tested through the Qualcomm pass manager,
and only in a test that needs the Qualcomm backend. The Samsung pass
manager calls it too. A torch-only test now exports a model whose input
takes the name "mul", so export renames the multiply to "mul_1". After
reserve_node_names, a new node asked to be named "mul_1" gets another
name, and the graph lints.
When the pass manager failed for a reason other than the name
collision, the test reported an assertion about the message, and the
real error showed up only as the context of that assertion. The test
now re-raises any error that is not the collision, with its own
traceback.
The test skips when a torch update removes the name collision, since
the cases then no longer test reserve_node_names. It skipped as soon as
any case stopped colliding, which also hid the cases that still did.
It now skips only when none of them collide.
@shoumikhin
shoumikhin force-pushed the qnn-htp-constant-only branch from 5dab5e9 to 78cdf65 Compare November 2, 2026 22:48
Copilot AI balanced review requested due to automatic review settings November 2, 2026 22:48
@shoumikhin

Copy link
Copy Markdown
Contributor Author

Pushed 4 new commits, rebased on main:

  • To pick which kept-whole ops to decompose, the HTP filter used to run the partitioner on the ATen graph. The partitioner's time grows with the square of the graph size, and on a model with about 43,000 nodes the export sat there for more than 2 hours. The estimate is now a few passes over the graph and takes under 4 s on that model. New test cases cover linears that the partitioner splits off to avoid a cycle, and a model whose input reaches only a CPU op.
  • The 3 follow-ups from the review of Qualcomm AI Engine Direct - Keep node names unique across the QNN and Samsung passes #24316.

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 2, 2026 •

Copy link
Copy Markdown

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


Requesting changes: the PR's new HTP estimate of which kept-whole ops end up on CPU counts export's runtime-assert nodes as QNN ops, so a masked select makes it decompose linears that main delegates whole, and its cost grows quadratically with the number of constants.

Detailed review

PR Review: #24429

Summary

The PR adds an HTP-only estimate to QnnPartitioner.ops_to_not_decompose so that exir decomposes the kept-whole ops that _drop_partitions_without_runtime_inputs would leave on CPU, and adds tests and a docstring covering the two exir stages of Partitioner.ops_to_not_decompose. The estimate gets wrong the data-dependent case it targets, and it is not linear-time as the description says; the other findings are minor.

Code Quality

  • The estimate is a 55-line closure, unlike the class's other graph analyses (backends/qualcomm/partition/qnn_partitioner.py:414-469).

    • _drop_orphaned_parameter_dequants (:284) and _drop_partitions_without_runtime_inputs (:323) are static methods that take the program. This one is folded into the stage handling of ops_to_not_decompose.
    • As a result, tests can only see its result through a full lowering, or through the returned filter, where it is mixed with filter_fn.
    • Fix: move the body into a @staticmethod that takes ep and returns the set, and keep only the functools.cache wrapper in ops_to_not_decompose. Tests can then check the set directly.

    Fix this →

Testing

  • The setup_qnn_sdk patch doesn't cover building the compiler specs, so both new HTP tests still set up the real SDK (backends/qualcomm/tests/test_passes.py:1115-1123, :1192-1195).

    • generate_qnn_executorch_compiler_spec calls setup_qnn_sdk() itself (backends/qualcomm/utils/utils.py:1283).
    • The first test calls it before entering the with patch(...) block. The timing test patches nothing.
    • On Linux x86_64 with QNN_SDK_ROOT unset, both tests try to download the SDK and raise "Failed to set up QNN SDK" if that fails (backends/qualcomm/utils/qnn_sdk_setup.py:55-110). That contradicts the comment at :1120. CI has the SDK, so it doesn't catch this.
    • Fix: build compiler_specs inside the with block, and wrap the timing test's spec generation in the same patch.

    Fix this →

  • test_reserve_node_names never checks that its premise holds (backends/transforms/test/test_reserve_node_names.py:19-25).

    Fix this →

API Design

  • The EDGE-stage bullet promises an error that exir skips for ops it does not preserve (exir/backend/partitioner.py:114-117).

    • When a partitioner returns a filter, exir drops mutable, aliasing and blocklisted ops from its list before the check (exir/program/_program.py:1426-1427, blocklist at :1107-1121). For example, a listed add.Tensor left on CPU raises nothing.
    • Fix: add "ops exir does not preserve" next to "some quantization ops" in the exceptions. That keeps the bullet accurate without naming exir internals.

    Fix this →

Backward Compatibility

  • Nodes without a tensor output count as QNN ops, so a masked select can make the estimate decompose ops that main delegates whole (backends/qualcomm/partition/qnn_partitioner.py:435-440).

    • Cause: must_stay_on_cpu only inspects tensor values (backends/qualcomm/partition/utils.py:155-166, :193-199).
      • For a masked select's unbacked size, export inserts runtime-assert nodes (sym_constrain_range_for_size, _assert_scalar and the comparisons they check). These land in qnn.
      • HTP's export passes keep them in the ATen program the estimate reads.
      • None of them is ever delegated: the edge passes remove _assert_scalar and the dead comparisons (backends/qualcomm/_passes/remove_redundancy.py:34), and sym_constrain_range_for_size has no node visitor (qnn_partitioner.py:200).
    • Effect on the estimate: the CPU index appears to reach a QNN op through the CPU sym_size.int. So every constant-only op feeding that index lands in before_cpu (:452-457), which overrides linked (:465-469).
    • Trigger:
      def forward(self, x, mask):
          k = F.linear(self.table, self.weight)  # both buffers
          return x + k, k[mask].sum()
      • On main, {linear, add} is delegated as one partition. With this PR, the linear is decomposed and delegated in pieces. The mask can be an input or a buffer.
      • The added nodes can shift the names of later nodes of the same ops, which :427-428 and the SkippedFeeder test guard against. So a skip_node_id_set written against main can silently name a different node.
    • Fix: count as QNN only call_function nodes with a tensor in meta["val"], and add the trigger to the stubbed test.
      • Also excluding torch._ops.HigherOrderOperator targets closes one gap the comment at :429-433 accepts. Today, a constant-only linear feeding a top-level torch.cond still fails.

    Fix this →

Performance

  • The estimate is quadratic in the number of constants, not linear as described (backends/qualcomm/partition/qnn_partitioner.py:444).

    • Cause: _is_runtime_input runs for every (QNN node, non-QNN input) pair until a runtime input turns up.
      • Each call goes through is_parameter and is_mutable_buffer_input (backends/qualcomm/builders/utils.py:39-47, :106-117).
      • Torch's ExportGraphSignature rebuilds inputs_to_parameters, inputs_to_buffers, inputs_to_lifted_tensor_constants and buffers_to_mutate from all specs on every access.
    • Cost: the dequantize of a PT2E-quantized weight reads only constants: the weight, plus scales and zero points when quantized per channel. So it never stops early.
      • A model with N such weights costs on the order of N² spec visits. The description claims "each visiting every node and edge once".
    • Test gap: test_htp_kept_whole_estimate_is_fast_on_large_graphs can't detect this, since Chain has no constants.
    • Fix: build the set of non-mutated constant placeholder names once per estimate from ep.graph_signature.input_specs, and test membership against it.
      • Give the timing test a few thousand constants that each feed an op, e.g. x = x + p * 2 over a torch.nn.ParameterList.

    Fix this →

Recommendation

Request Changes

Two problems block approval:

  • The assert nodes make the estimate decompose ops that main delegates whole, in the data-dependent case this PR targets.
  • The quadratic cost undercuts the reason for estimating instead of partitioning.

Both are small fixes inside left_on_cpu. The other findings are minor.

  • Gather context
  • Run three independent reviews (all six changed files covered)
  • Consolidate findings
  • Fact-check findings
  • Post review

This branch has not been deployed

No deployments
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.

3 participants