Skip to content

Fix crashes, wrong outputs and lowering failures in several backends and in exir - #24298

Open
pytorchbot wants to merge 12 commits into
release/1.6from
cherry-pick-24222-by-pytorch_bot_bot_
Open

pytorchbot wants to merge 12 commits into
release/1.6from
cherry-pick-24222-by-pytorch_bot_bot_

Conversation

@pytorchbot

@pytorchbot pytorchbot commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes found while exporting models to ExecuTorch backends.

Fixes

  • exir: constant_prop_pass keeps tensors that in-place ops write, also through views or in no_grad/autocast blocks. to_backend keeps the output node last. to_edge lowers torch._check_tensor_all and torch.sym_ite. GroupBasedPartitioner no longer makes partitions that depend on each other, unless caller node groups already do. Tensor constants get static shapes; deduplicated constants keep their data.
  • AOTI (AOTInductor) and CUDA: the missing-kernel check catches scatter and index_put fallbacks. detach lowers, also inside torch.cond. Strided outputs are copied. Transposed convolutions (1-D, 2-D, no dilation) and torch._check_tensor_all lower.
  • XNNPACK: the quantizer skips bfloat16 tensors, and cat respects scoped configs. ELU is delegated when XNNPACK can run it.
  • Qualcomm: autocast regions are inlined correctly. Unquantized bfloat16 nodes, data-dependent shapes and HTP partitions without runtime inputs stay on the CPU. Fixes for stacks, 0-d selects, RMSNorm and int64 constants.
  • OpenVINO: model data stays alive while OpenVINO 2026.4 reads it. Partitions are converted with their input types.
  • Core ML: bfloat16 ops, and ops above rank 5 outside static layout chains, stay on the CPU.
  • MLX, Arm, Cortex-M, Vulkan, NXP, portable: dtype checks for MLX tensor lists, one constant slot per weight, slice updates on a constant base; TOSA keeps unused delegate inputs for VGF; MatmulToBmmPass takes a constant operand; Vulkan constants above 4 dims; non-strict programs in the NXP quantizer; cummin.out.

This pick also carries the changes of #24316 (node names stay unique across the QNN and Samsung passes) and #24429 (on HTP, an op QNN keeps whole, such as linear, is decomposed when its constant-only partition would stay on the CPU; without it, to_edge_transform_and_lower fails with was not decomposed or delegated). It must merge after both of them land on main.

Behavior changes

  • A CUDA .pte loads only the weights it was exported with. A .ptd with other weights, for example after fine-tuning, needs a newly exported .pte.
  • CUDA graphs turn off when outputs do not match the ETensor layout. If only the capture call sees such a layout, that call returns an error.
  • GroupBasedPartitioner is about 2x slower on large graphs, and some graphs get a different number of partitions. to_backend keeps the original order of top-level nodes.
  • XNNPACK: weight-only quantized linears, and 8da4w or 8da8w linears (8-bit dynamic activations, 4-bit or 8-bit weights) lowered with to_backend, fail at export instead of giving wrong results. Delegated ELU flushes subnormal values to zero. Cats outside a scoped quantizer config are no longer quantized.
  • Ethos-U raises an error for a delegate with unused inputs instead of returning wrong values.
  • On HTP, an op QNN keeps whole is decomposed when its constant-only partition would stay on the CPU. Ops in delegated partitions stay whole.

We would like to cherry-pick this PR to release/1.6 after it merges. It needs #23915 there first.

Testing

Each fix has a test that fails without it. Run on an A100 (CUDA, Vulkan), an x86 CPU (OpenVINO, QNN SDK), macOS (Core ML, MLX), the TOSA reference model and VGF emulation (Arm), and the eIQ Neutron SDK (NXP). Not run: Metal, ROCm, Windows CUDA.

cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani

…and in exir (#24222)

Fixes found while exporting models to ExecuTorch backends.

## Fixes

- **exir:** `constant_prop_pass` keeps tensors that in-place ops write,
also through views or in `no_grad`/`autocast` blocks. `to_backend` keeps
the output node last. `to_edge` lowers `torch._check_tensor_all` and
`torch.sym_ite`. `GroupBasedPartitioner` no longer makes partitions that
depend on each other, unless caller node groups already do. Tensor
constants get static shapes; deduplicated constants keep their data.
- **AOTI (AOTInductor) and CUDA:** the missing-kernel check catches
scatter and index_put fallbacks. `detach` lowers, also inside
`torch.cond`. Strided outputs are copied. Transposed convolutions (1-D,
2-D, no dilation) and `torch._check_tensor_all` lower.
- **XNNPACK:** the quantizer skips bfloat16 tensors, and `cat` respects
scoped configs. ELU is delegated when XNNPACK can run it.
- **Qualcomm:** autocast regions are inlined correctly. Unquantized
bfloat16 nodes, data-dependent shapes and HTP partitions without runtime
inputs stay on the CPU. Fixes for stacks, 0-d selects, RMSNorm and int64
constants.
- **OpenVINO:** model data stays alive while OpenVINO 2026.4 reads it.
Partitions are converted with their input types.
- **Core ML:** bfloat16 ops, and ops above rank 5 outside static layout
chains, stay on the CPU.
- **MLX, Arm, Cortex-M, Vulkan, NXP, portable:** dtype checks for MLX
tensor lists, one constant slot per weight, slice updates on a constant
base; TOSA keeps unused delegate inputs for VGF; `MatmulToBmmPass` takes
a constant operand; Vulkan constants above 4 dims; non-strict programs
in the NXP quantizer; `cummin.out`.

## Behavior changes

- A CUDA `.pte` loads only the weights it was exported with. A `.ptd`
with other weights, for example after fine-tuning, needs a newly
exported `.pte`.
- CUDA graphs turn off when outputs do not match the ETensor layout. If
only the capture call sees such a layout, that call returns an error.
- `GroupBasedPartitioner` is about 2x slower on large graphs, and some
graphs get a different number of partitions. `to_backend` keeps the
original order of top-level nodes.
- XNNPACK: weight-only quantized linears, and 8da4w or 8da8w linears
(8-bit dynamic activations, 4-bit or 8-bit weights) lowered with
`to_backend`, fail at export instead of giving wrong results. Delegated
ELU flushes subnormal values to zero. Cats outside a scoped quantizer
config are no longer quantized.
- Ethos-U raises an error for a delegate with unused inputs instead of
returning wrong values.

We would like to cherry-pick this PR to release/1.6 after it merges. It
needs #23915 there first.

## Testing

Each fix has a test that fails without it. Run on an A100 (CUDA,
Vulkan), an x86 CPU (OpenVINO, QNN SDK), macOS (Core ML, MLX), the TOSA
reference model and VGF emulation (Arm), and the eIQ Neutron SDK (NXP).
Not run: Metal, ROCm, Windows CUDA.

(cherry picked from commit 054de1f)
@pytorch-bot

pytorch-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

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

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

❌ 42 Pending, 1 Unrelated Failure, 1 Unclassified Failure

As of commit 679565d with merge base 4db778e (image):

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

BROKEN TRUNK - The following job failed but were present on the merge base:

👉 Rebase onto the `viable/strict` branch to avoid these failures

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 29, 2026
@github-actions github-actions Bot added ciflow/trunk module: arm Issues related to arm backend labels Oct 29, 2026
@pytorchbot
pytorchbot deployed to upload-benchmark-results October 29, 2026 17:33 — with GitHub Actions Active
This branch was picked before #24263 and #24295 reached release/1.6, so
three files carried their content only in part. Take release/1.6 and use
the versions a clean pick of 054de1f gives for
backends/qualcomm/partition/qnn_partitioner.py,
backends/qualcomm/tests/test_passes.py and exir/tests/test_pass_infra.py.
… 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.
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.
@shoumikhin
shoumikhin deployed to upload-benchmark-results October 31, 2026 21:52 — with GitHub Actions Active
shoumikhin added a commit that referenced this pull request Nov 1, 2026
This pick is stacked on #24298 (the #24222 pick), which has since gained
follow-up commits for the Qualcomm partitioner and the TensorRT guide.
Taking them keeps this branch equal to #24298 plus #24294, so that
refreshing it from release/1.6 after #24298 merges cannot undo them. The
merge is clean, and the follow-ups touch no OpenVINO files.
… 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.
…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.
…ion test

Brings the HTP decomposition of kept-whole ops in line with its version
for main. No behavior change.
- test_partitioner_keeps_constant_only_subgraph_on_cpu runs against the
  real QNN SDK again, as before.
- The stubbed checks move to their own test, which also patches the SDK
  setup, so it never downloads the SDK, and adds a constant-only linear
  cut off by a data-dependent shape.
- The comments name both ways the estimate can be wrong.
- The Partitioner.ops_to_not_decompose docstring keeps only the two
  stages, ep.dialect, and the rule that a filter applies to the program
  of the same call.
@shoumikhin

Copy link
Copy Markdown
Contributor

@claude /pr-review

@claude

claude Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 53m 0s —— View job


Approve. Nothing blocks this cherry-pick, but CUDA weight keys now include a SHA-256 of the weights, which stops weights-only .ptd updates, so 1.6 needs a release note and a clearer load error. The other findings can be follow-ups on main.

PR Review: #24298

Summary

This cherry-pick brings #24222, with #24316 and #24429, to release/1.6. It fixes crashes, wrong outputs and lowering failures in exir, the portable kernels (a new cummin.out), and the AOTI/CUDA, Qualcomm, Samsung, XNNPACK, Core ML, OpenVINO, Arm, MLX, Cortex-M, Vulkan and NXP backends.

  • The diff was taken against ba053ba, the release/1.6 commit merged into HEAD, because the shallow checkout shares no merge base with origin/release/1.6. It matches the PR's 89 files and +3173/-216.
  • The Arm, Cortex-M, MLX, NXP and Vulkan changes and exir/backend/test/test_group_partitioner.py were only read at the diff level.
  • Problems found:
    • one compatibility break in CUDA weight loading;
    • a CUDA graph capture step that fails a valid call;
    • three possible lowering regressions (low confidence, not run);
    • a quantizer that skips bf16 without a message;
    • four performance follow-ups.

Backward Compatibility

  • backends/cuda/cuda_weight_collector.py:604-616: the key of every CUDA FQN weight now ends in the SHA-256 of its bytes, and the .pte looks the weight up by that key.
    • A .ptd with other weights for the same .pte (fine-tuned, re-quantized, LoRA-merged) therefore no longer loads.
    • Yet docs/source/ptd-file-format.md:144 lists "async updates between program and data" as a use of .ptd files.
    • Loading fails with CUDA FQN storage '<key>:<sha256>' is missing from named data (NotFound, backends/cuda/runtime/cuda_weight_cache.cpp:457-460). The message doesn't say that the .ptd came from a different export.
    • On a release branch this needs a release note, and the error should name the likely cause.
    • If weights-only updates should keep working for CUDA, they need an explicit opt-in, for example a compile spec that keeps plain FQN keys.

Code Quality

  • backends/cuda/runtime/cuda_backend.cpp:1080-1092: suppose an output is not laid out like its ETensor during the capture step but was during warmup. Then execute returns Error::NotSupported for a valid call.
    • Warmup handles the same case by switching to Disabled and copying the output (lines 1184-1192).
    • The capture step can't copy, because the captured work hasn't run yet.
    • Re-running the call eagerly with CUDA graphs disabled would avoid the failure. run() has already taken slim_inputs, so the retry has to rebuild them.
  • backends/qualcomm/_passes/decompose_wrap_with_autocast.py:60-67 (low confidence; by reasoning, not run): make_fx(submod, tracing_mode="fake") traces after dispatch.
    • Inside an enabled region, CompositeImplicitAutograd ops such as aten.linear therefore decompose into t + addmm/mm, bypassing QNN's skip-decomp table.
    • The retraced nodes also lose nn_module_stack and source_fn_stack.
    • test_decompose_wrap_with_autocast_nested_regions (backends/qualcomm/tests/test_passes.py:154) checks only numerics.
    • Try pre_dispatch=True, or assert that the region's linear stays whole.
  • backends/qualcomm/_passes/remove_0d_tensor.py:28-54 (low confidence; not run): a 0-D select_copy of a one-element tensor is now removed only when every user is add, sub, mul or div.
    • Before this PR, every 0-D select_copy was removed.
    • One that feeds where, maximum, minimum, pow or a comparison is just as safe to remove, but now stays 0-D. The pass docstring says QNN can't take 0-D tensors, so a model that lowered before can now fail.
    • Widen the allowlist to the other broadcasting elementwise ops, or add a test that lowers such a model end to end.
  • backends/aoti/passes/replace_view_copy_with_view.py:49-76 (low confidence; not run): slice_copy, select_copy and split_copy inside torch.cond branches now become views. _has_blocking_user doesn't treat the branch output as blocking.
    • A branch can therefore return a view of its own input. torch.cond rejects that aliasing when it traces, and Inductor's Conditional may not handle it.
    • DetachInCondModule (backends/cuda/tests/test_cuda_export.py:345) is the only branch test. Its detach_copy becomes alias_copy, which is a copy, so no test has a branch that returns a view.
    • Treat submodule outputs as blocking, or limit the rewrite in submodules to detach_copy. Either way, add a test with a slice in a branch.
  • backends/xnnpack/quantizer/xnnpack_quantizer.py:404: touches_bfloat16 leaves bf16 nodes unquantized without telling the user. A bf16 model therefore comes out with no quantized ops and no message. Log a warning once.

Performance

  • backends/apple/coreml/partition/coreml_partitioner.py:455-477: one rejected node drops its op from the preserved list everywhere. For example, one unsupported SDPA call decomposes every SDPA call.
    • A per-node filter would keep the supported calls whole. It could re-run op_support.is_node_supported(None, node) (line 461), like QNN's filter_fn.
  • backends/cuda/cuda_backend.py:485-548 (_conv_transpose_as_conv): inserting zeros costs about stride^n times the MACs and input memory of a direct transposed conv.
    • That is 4x for a stride-2 2-D upsampler and 8x for a stride-8 1-D vocoder stage.
    • A phase (sub-pixel) decomposition avoids the zeros.
  • backends/cuda/runtime/utils.h:220-296: on every call with a strided output, the span is copied to the host, rearranged on the CPU and copied back, with a stream sync. This happens even when the destination is on the GPU. A strided copy on the device would avoid the round trip.
  • backends/cuda/runtime/cuda_backend.cpp:95-98: output_is_raw_copyable requires a row-major contiguous output.
    • So a channels_last-dense output whose ETensor is also channels_last turns CUDA graphs off, although its bytes copy as-is.
    • The check would accept it if it tested whether the output is dense in the ETensor's dim order, with strides computed from the output's sizes.

Recommendation

Approve: none of the findings blocks the cherry-pick.

  • Before 1.6 ships, the .ptd key change needs a release note and a clearer load error.
  • The capture-step failure, the low-confidence regressions and the performance items can be follow-ups on main.
Progress
  • Gather context. The shallow checkout has no merge base with origin/release/1.6, so the diff is against ba053ba (89 files, +3173/-216).
  • Run three independent reviews (exir/XNNPACK, QNN/Samsung/Core ML/OpenVINO, CUDA/AOTI/Arm/others). Together they reported 33 candidate findings.
  • Merge duplicate findings. That left about 25 distinct ones.
  • Fact-check findings. 10 remain.
  • Post review.

This branch was successfully deployed

1 active and 1 inactive deployments
upload-benchmark-results — 679565d9 Deployed Nov 2, 2026 by shoumikhin via upload-benchmark-results #21736
cadence — 679565d9 Deployed Nov 2, 2026 by JacobSzwejbka via hifi-op-test / hifi4 #36004
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ciflow/trunk CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. module: arm Issues related to arm backend

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants