Skip to content

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

Merged
shoumikhin merged 37 commits into
pytorch:mainfrom
shoumikhin:fix-lerobot-export-bugs
Oct 29, 2026
Merged

shoumikhin merged 37 commits into
pytorch:mainfrom
shoumikhin:fix-lerobot-export-bugs

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

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.

Copilot AI balanced review requested due to automatic review settings October 28, 2026 03:40
@pytorch-bot

pytorch-bot Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

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

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

❗ 1 Active SEVs

There are 1 currently active SEVs. If your PR is affected, please view them below:

❌ 1 New Failure, 70 Pending

As of commit 204bbc8 with merge base 254635c (image):

NEW FAILURE - The following job has failed:

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

Copy link
Copy Markdown

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


Approve: nothing blocks. The main open point is that on OpenVINO before 2026.4, the delegate blob now stays in memory next to OpenVINO's own copy for the whole life of the method. Three smaller robustness and test gaps are noted below.

PR Review: #24222

Summary

The PR does two things:

  • AOTI lowering now reports missing scatter and index_put fallback kernels.
  • OpenvinoBackend::init no longer frees a blob that the compiled model may still read.

Nothing blocks merging. The OpenVINO memory cost on versions before 2026.4 is worth settling, and three smaller points follow. The PyTorch Inductor and OpenVINO sources are not in this checkout, so I mark statements about their internals as low confidence. I ran no tests.

Code Quality

  • backends/aoti/aoti_backend.py:208-237: the scatter check rebuilds Inductor's private symbol name instead of checking what Inductor writes.

    • The other three wrappers check a name that Inductor passes in. This one copies the naming logic inside CppWrapperCpu._generate_scatter_fallback (get_c_shim_func_name(...).replace("__", "_") + "_out", lines 220-223). It also spells out all 8 of that method's parameters, so a signature change turns every scatter fallback into a TypeError.
    • The test asserts the same rebuilt string (backends/cuda/tests/test_cuda_export.py:580).
      • If a torch bump changes the name Inductor emits, CI stays green, but the check reports a name the .wrapper.so never calls.
      • Once someone adds that name to get_supported_fallback_kernels, lowering passes and the program crashes on its first run again.
    • Comparing against what the original method writes would catch this drift at lowering time. For example, wrap self.writeline for the duration of the call, and fail if the emitted call is not kernel.
    • Low confidence: one check over the aoti_torch_* calls in the generated wrapper would also cover direct-emission paths that nothing watches yet. The CUDA shim defines no aoti_torch_item_float16 or aoti_torch_item_float64 (backends/cuda/runtime/shims/memory.cpp:451-458), and I believe Inductor's codegen_tensor_item emits those calls directly.

    Fix this →

  • backends/openvino/tests/ops/base_openvino_op_test.py:71-80: the mapped .pte is released only on success.

    • If load_method, execute, or the assert raises, the traceback keeps program and method alive while TemporaryDirectory cleans up.
    • On Windows (the case the comment on line 79 is about), the cleanup then raises PermissionError on top of the real failure.
    • tempfile.TemporaryDirectory(ignore_cleanup_errors=True) avoids this; pyproject.toml requires Python 3.11 or newer, so it is available.

    Fix this →

Testing

  • backends/aoti/aoti_backend.py:251-256: the Metal part of fix 1 has no test.

    • The PR says Metal was not run, and nothing under backends/apple/metal/tests/ uses scatter, index_put, or the missing-kernel error.
    • The patch is on CppWrapperCpu. The CUDA test shows that the CUDA wrapper picks it up, but nothing shows that the MPS wrapper does not override _generate_scatter_fallback or _generate_index_put_fallback. This part is low confidence, since I could not read Inductor.
    • If the MPS wrapper does override them, Metal programs still lower and then crash on their first run, because the .so is opened with RTLD_LAZY (backends/apple/metal/runtime/metal_backend.cpp:347).
    • Adding the Int64Accumulate case to backends/apple/metal/tests/test_metal_backend.py, built with export_model_to_metal (test_modules.py:1218), would cover this.

    Fix this →

Performance

  • backends/openvino/runtime/OpenvinoBackend.cpp:336-337: on OpenVINO before 2026.4, the blob now stays alive next to OpenVINO's own copy for the life of the method.

    • The PR description accepts this cost, but neither the code comment nor the docs mention it.
    • 2025.1 to 2026.3 are still advertised as supported. docs/source/build-run-openvino.md:64 and the runtime-not-found error at OpenvinoBackend.cpp:215 both say openvino>=2025.1.0,<2027.0.0.
    • What the extra copy costs depends on the loader:
      • With the default Module load mode, LoadMode::File (extension/module/module.h:100), it is a heap buffer the size of the compiled model.
      • With the LLM runner's default, MmapUseMlockIgnoreErrors (extension/llm/runner/llm_runner_helper.h:112), the pages stay locked in RAM whenever mlock succeeds.
    • For the examples/openvino/llama and qwen2_5 exports, the blob is the whole quantized model.
    • Freeing only on versions before 2026.4 gives those versions back exactly the behavior they had before this PR. This needs ov_get_openvino_version and ov_version_free declared in OpenvinoApi.h and loaded with the other LOAD_SYMs. At minimum, state the cost in the comment and on the docs page.

    Fix this →

Recommendation

Approve

Nothing here blocks the merge. The one point to settle is the OpenVINO memory cost: either free the blob on versions before 2026.4, or document the cost. That can happen here or in a follow-up.


  • Gather context and fetch the diff (the checkout is shallow, so I compared against the merge base 8ecf0fb)
  • Run three independent reviews, then consolidate and fact-check them
  • Post review (I ran no tests)

@shoumikhin
shoumikhin force-pushed the fix-lerobot-export-bugs branch from 35f7db1 to 55b4840 Compare October 28, 2026 09:44
@shoumikhin
shoumikhin requested a review from psiddh as a code owner October 28, 2026 09:44
Copilot AI balanced review requested due to automatic review settings October 28, 2026 09:44
@shoumikhin shoumikhin changed the title Fix two backend crashes that show up only when a program runs Fix crashes and lowering failures in the AOTI, OpenVINO and Qualcomm backends Oct 28, 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

Thanks for the review. Points 1, 2 and 4 are fixed in the commits that own the code. Point 3 has no code change, see below.

  1. Scatter and index_put check: it no longer rebuilds the kernel name or spells out the method's arguments. It reads the kernel name from the line the fallback writes, so it checks what the generated code really calls. The item path you mention, and view_dtype, also write their calls directly. I left them for a follow-up that checks the built library's symbols instead of hooking one path at a time.
  2. OpenVINO test cleanup: the temporary folder now ignores cleanup errors, so a failing test on Windows reports only its own error.
  3. Metal: the Metal tests need torchao's MPS kernels, which my setup does not have, so I did not add a Metal test. I checked instead that the MPS wrapper does not override the two hooked methods, and that an MPS compile with this check reports both kernels as missing.
  4. OpenVINO memory: init now frees the blob as before when the OpenVINO runtime is older than 2026.4, because those versions copy it during the import. With newer versions the blob is kept until the delegate is destroyed.

@shoumikhin shoumikhin changed the title Fix crashes and lowering failures in the AOTI, OpenVINO and Qualcomm backends Fix crashes and lowering failures in the AOTI, OpenVINO and Qualcomm backends and in constant_prop_pass Oct 28, 2026
@shoumikhin
shoumikhin force-pushed the fix-lerobot-export-bugs branch from 55b4840 to 52e8dcf Compare October 28, 2026 15:04
@shoumikhin
shoumikhin force-pushed the fix-lerobot-export-bugs branch from 52e8dcf to c39d57f Compare October 28, 2026 19:42
Copilot AI balanced review requested due to automatic review settings October 28, 2026 19:42
@shoumikhin shoumikhin changed the title Fix crashes and lowering failures in the AOTI, OpenVINO and Qualcomm backends and in constant_prop_pass Fix crashes, wrong outputs and lowering failures in several backends and in constant_prop_pass Oct 28, 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.

@github-actions github-actions Bot added ciflow/trunk module: arm Issues related to arm backend labels Oct 28, 2026
@shoumikhin
shoumikhin force-pushed the fix-lerobot-export-bugs branch from c39d57f to d11bd4b Compare October 28, 2026 21:47
XNNPACKQuantizer annotated ops that read or produce bfloat16 tensors, such
as linears in a model that casts its activations to bfloat16. The quantize,
dequantize and choose_qparams nodes it produced then ran on bfloat16
tensors. XNNPACK does not take those patterns, so they stayed on the
portable quantized kernels, which do not take bfloat16 and abort the
process at run time ("Expected input to be Float tensor received: 15").

Skip nodes that read or produce a bfloat16 tensor, both in the filter that
every annotator applies (cat included, since the previous commit) and when
annotations are propagated to ops that share their input's quantization
parameters. Such ops stay unquantized and run correctly. Float32 and
float16 models quantize as before; the test checks that a float32 linear
feeding a bfloat16 op is still quantized.
to_backend could fail with "Expected output node, got op=call_function"
when a runtime check outside a partition reads a value the partition
computes, for example torch._check(t.all().item()) where the tensor ops
are delegated and the item() and the check are not.

After fusing the partition, create_submodule_from_nodes re-sorted the graph
with legalize_graph. That sort places a node as soon as its inputs are
ready, so the output node could be placed before check nodes that have no
users. Later code calls graph.output_node(), which requires the output node
to be last.

Use stable_topological_sort instead. It keeps the original order wherever
the dependencies allow it, so the output node stays last. This changes the
order of the remaining top-level nodes of every program that to_backend
touches: they now keep their original order instead of the order the old
sort produced.
A model that uses a tensor-wide check, for example torch._check_tensor_all
or transformers' torch_compilable_check, could not go through to_edge. Two
things stopped it:

1. torch.export turns the check into aten._is_all_true, which is not a core
   ATen op and has no decomposition, so the ATen verifier rejected it.
2. The check then reads the result with item() and compares it through
   torch.sym_ite. The Edge verifier rejected every Python symbolic function
   (torch.sym_ite, sym_max, sym_min, sym_not, sym_float, sym_int), on
   data-dependent values and on shapes alike, even though ExecuTorch lowers
   each of them to its own executorch_prim op, which has a runtime kernel.

Like other runtime asserts, the check is removed later by to_executorch
unless a backend takes it into a delegate; the problem was that the
program never got that far.

Decompose aten._is_all_true to aten.all in the default decomposition table,
and let the Edge verifier accept the Python symbolic functions that
ExecuTorch maps to prim ops, the same way it already accepts those prim ops.
The runtime's sym_max and sym_min kernels only take ints, so the verifier
still rejects them, also inside control flow branches, when an argument
is a float or a bool.
A program lowered with the CUDA backend could pass lowering and then kill
the process on its first run:

  undefined symbol: aoti_torch_as_strided

Inductor's C++ wrapper calls aoti_torch_as_strided when it allocates a
buffer with a larger shape than its logical shape and then gives it the
logical shape. ExecuTorch's CUDA shim library did not provide it, so the
compiled model could not be loaded.

Add aoti_torch_as_strided with the PyTorch shim's signature. It returns a
view with the given sizes and strides and the tensor's own storage offset,
built on the existing aoti_torch__reinterpret_tensor.

Also regenerate the checked-in Windows import library,
backends/cuda/runtime/aoti_cuda_shims.lib, as its README describes, so a
wrapper lowered for a Windows target can link against the new shim. Its
symbol list is the old one plus aoti_torch_as_strided, and its members
carry no timestamps.
… other

Lowering a dynamically quantized pi0 model to XNNPACK failed with "Invalid
partition, found dependency cycles".

Before merging two partitions, GroupBasedPartitioner checks that no node
outside them is both downstream and upstream of the merged partition. That
check looks at single nodes. But every other partition also becomes a
single node once it is fused. So a path can leave the merged partition,
enter another partition at one node, leave it from a different node that
the first one does not reach, and come back. The two partitions then
depend on each other, and fusing them fails.

Before accepting a merge, also follow paths through the other partitions:
whenever the merged partition reaches a node of another partition, add
everything that partition reaches as a whole, and reject the merge as soon
as that reaches something upstream of it. Only partitions that will be
returned count, so partitions that are later filtered out (for example
ones without enough compute nodes) do not block a merge. Their compute
node count now comes from one helper shared with the filter.

To stay fast on large graphs, the check only visits the partitions it
actually reaches, and caches what each partition reaches; a merge only
drops the cached entries that reach the merged nodes. On synthetic graphs
of 4k to 16k nodes the partitioner takes about twice as long as before.
The merge loop itself is unchanged and still tries every remaining pair,
so a graph where the check rejects most merges now keeps many partitions
and pays for all their pairs (about 2 s for 1600 partitions on a 4k-node
chain of groups); before, such graphs had few partitions only because
merges that cannot be fused were accepted. If caller-provided node groups
already depend on each other, what a partition reaches is found by walking
around that cycle directly. Two partitions whose nodes all already lie on
one cycle through such groups can still be merged, since every cycle
through the result already existed. A partition that will be fused counts
as one node there; any other partition counts as its separate nodes, so a
group that is later filtered out (for example an add with a getitem) can
still merge to remove a cycle. Other merges that would leave the merged
partition on a cycle are refused.
Because a merge that would need a later merge to become valid is now
rejected, a few graphs end up with a different number of partitions.
The merge loop is still a single greedy pass, so caller node_groups that
depend on each other through a plain node can still give partitions that
cannot be fused, as before this change.
MatmulToBmmPass read the operand shapes with ProxyValue.to_tensor(). When
an operand of the matmul is a constant (a parameter, a buffer or a tensor
attribute), ExportPass passes it to call_operator as a plain torch.Tensor,
so CortexMQuantizer crashed in prepare_pt2e for every such matmul:

    AttributeError: 'Tensor' object has no attribute 'to_tensor'

For example softmax(x) @ codebook, where codebook is a tensor attribute.

Read the shapes through .data, which works for both kinds of operand. A
constant operand of a shape the pass rewrites, such as a batched rank-3
codebook used as the right-hand side, now becomes bmm like any other operand
and lowers to cortex_m.quantized_batch_matmul, which already supports a
constant right-hand side. Shapes the pass does not rewrite, such as
rank-3 @ rank-2, no longer crash but stay matmul.
When ExportPass meets a real tensor, for example a get_attr constant, it
makes a fake tensor for the node metadata. Only parameters were faked with
static_shapes=True. Any other tensor that the fake mode had not seen before
got a fresh symbol for every dimension when the fake mode has a ShapeEnv,
which is the case for exported programs.

Such tensors are common: constant folding (for example the Arm quantizer's
ConstantFoldingPass) creates new constant tensors, and the next ExportPass
gives them symbolic sizes like (s45, s96) instead of (9, 2). Later passes
then see symbolic shapes where everything is static. With the LeRobot
diffusion policy, the Arm quantizer then failed in DecomposeVarPass:

    AssertionError: a.node.constant should not be None

A real tensor is constant data with known sizes, so fake all of them with
static shapes, as the existing TODO suggested.
aten.slice_scatter is functional: it returns a new tensor and leaves its
base alone. The MLX builder lowers it to a SliceUpdateNode whose output
slot differs from its base slot, and the runtime handles that case without
writing the base.

Still, exec_slice_update always fetched the base through tensor_ref(),
which is the accessor for tensors an op writes, and which refuses
constants. So a slice_scatter whose base is a buffer or another constant
failed at run time with "tensor_ref: cannot mutate constant tensor".

Read the base with const_tensor_ref(), and use tensor_ref() only for the
in-place form, which is the one that writes it.
The partitioner keeps aten.stack from being decomposed so that it can
lower to the QNN Pack op. HTP's Pack takes inputs of rank 1 to 4 (QNN
HTP op definition), so its output has 2 to 5 dims. A stack of 0-d or
5-D tensors is rejected at op validation. The node then stays on CPU,
but because it was kept from decomposition, to_edge_transform_and_lower
fails with "aten.stack ... was not decomposed or delegated".

Let filter_fn decompose a stack whose output rank is outside 2 to 5,
the same way it already does for integer stacks. The unsqueeze and cat
it decomposes into are checked by the partitioner like any other op.
When an op stays on CPU, the ops before it can form a partition that is
fed only by constants (parameters, buffers, lifted constants), or by
nothing at all, for example a lone aten.full or a chain of casts of
lifted scalars. HTP refuses such a graph ("No graph inputs present for
graph", or "Graph has zero node"), so context binary generation failed
and the whole lowering stopped.

For HTP, drop a partition when none of its inputs is a runtime value,
so its ops run on CPU. A mutable buffer counts as a runtime input, since
QNN reads it as a graph input in the partition that updates it.
tag_nodes no longer assumes at least one partition is left, which
dropping can now cause.

Other backends keep their partitions. GPU accepts graphs without
inputs, and LPAI places its quantize and dequantize boundaries for CPU
fallback before partitioning, so dropping a partition there afterwards
would hand a float tensor to a delegate that expects a quantized one.
LPAI refuses graphs without inputs too ("Graph has no input tensors"),
so such a partition still fails there as before. Fixing that needs
the decision to move before LPAI places its boundaries.

Two HTP cases still fail as before: a dropped partition that holds an
op the partitioner keeps from decomposition, such as a linear on a
buffer (that op cannot run on CPU undecomposed, so lowering now fails
with "was not decomposed or delegated"), and a partition whose only
runtime input is a mutable buffer that a different partition updates.
…an replace it

Remove0DTensor drops every select that returns a 0-d tensor and feeds
its users the whole input instead. That is only right when the input
holds just the selected element and every user broadcasts it, so that a
one-element tensor gives the same result as the 0-d element. The pass
was written for that case (torch.zeros(1)[0] added to a tensor).
Otherwise:

- For a lookup such as table[t] on a longer buffer, the users got the
  whole table: lowering failed later with a broadcast error, or, when
  the shapes happened to broadcast, the program silently computed the
  wrong values.
- For a one-element input, a user that depends on the rank, such as
  torch.stack, got a tensor of shape (1,) instead of a 0-d one and
  produced a different shape.

Only drop the select when its input is known to have one element and
every user, looking through quantize and dequantize, is an add, sub,
mul or div whose output has at least one dimension. Other 0-d selects
are left in the graph and run on CPU. That includes a select used by
another op such as sqrt, or by a dtype cast (x * int_buffer[0] with a
float x): the result is still right, but some ops run on CPU instead of in the
delegate. Casts are not looked through, because a dim-order cast keeps
the 0-d layout in its own arguments and fails at runtime on CPU when
given a one-element tensor.

Two kinds of graph then lower only with other changes in place. A stack
of such selects needs the partitioner to decompose stacks that HTP Pack
cannot take. A select and cast left on CPU can leave an HTP partition
fed only by constants, which has to stay on CPU as well. So this change
lands after both.

The pass's own test in tests/rework selected from a four-element tensor
and checked that the select was removed, so it asserted the wrong
behavior; it now uses a one-element input.
…with its weight

RecomposeRmsNorm takes any constant operand of the last multiply as the
RMSNorm weight, uses its shape as normalized_shape, and never looks at
how the mean reduces. Two things go wrong:

- When a norm is followed by a multiply with a 0-d constant (a scalar
  scale), it builds rms_norm(x, normalized_shape=[]), which fails the
  next time the graph is traced: "Expected normalized_shape to be at
  least 1-dimensional".
- When the mean reduces a dim other than the last one, several dims, or
  drops the reduced dim (keepdim=False), it still builds an rms_norm
  over the last dim, so the program silently computes different values.

The backend's RmsNorm builder normalizes the last dim with a weight of
that size, so only recompose that form: the mean must reduce exactly
the last dim and keep it (a 1-D input may reduce all dims or drop it),
and the constant must have the shape of that dim. Anything else is left
as the decomposed ops.
The Qualcomm backend has no bfloat16 tensor type (QNN_TENSOR_TYPE_MAP
has no entry for it), but nothing checked data types before a node
visitor was asked to define the node's tensors. An unquantized
bfloat16 node (for example an explicit x.to(torch.bfloat16) region in a
float model) therefore crashed lowering with KeyError: torch.bfloat16
in NodeVisitor.get_data_type instead of staying on CPU.

Add has_unsupported_dtype, true when the node's output or one of its
inputs is an unquantized tensor of a data type the backend cannot
represent, and use it in two places:

- The partitioner skips such a node, so it falls back to CPU like any
  other unsupported node.
- filter_fn does not keep such a node from decomposition unless every
  such tensor is quantized: each such input comes from a dequantize op
  and the output feeds a quantize op. Otherwise an op the backend
  normally lowers whole, such as a bfloat16 linear, would stay on CPU
  undecomposed. That fails with "was not decomposed or delegated", or,
  for a linear whose weight alone is dequantized (weight-only
  quantization), needs a linear kernel that the portable CPU kernels do
  not have.

A quantized tensor takes its QNN type from its encoding, so quantized
bfloat16 regions are still delegated as before. A bfloat16 graph input
or output of a quantized model is still not supported.

The test covers a quantized, an fp16 and a weight-only quantized
bfloat16 linear. It lowers the quantized model first, because some QNN
SDK versions crash on a quantized lowering that follows an fp16 one in
the same process.
… CPU

QNN needs a size for every tensor dimension. For a dynamic dimension
the node visitor passes the SymInt's hint, but a data-dependent size
(for example the result of boolean mask indexing, x[mask]) has no
hint. Nothing checked for that before a node visitor was asked to
define such a tensor, and lowering crashed with a TypeError from the
TensorWrapper constructor instead of leaving the node on CPU.

Add has_data_dependent_shape, true when the node's output or one of its
inputs has a dimension without a hint, and use it in two places:

- The partitioner skips such a node, so it falls back to CPU like any
  other unsupported node.
- filter_fn does not keep such a node from decomposition. Otherwise an
  op the backend normally lowers whole, such as a stack of masked
  tensors, would stay on CPU undecomposed and fail with "was not
  decomposed or delegated".

A linear on masked rows also needs partitions without a runtime input
to stay on CPU on HTP: once the linear is decomposed on CPU, its weight
permute is fed only by the weight. That is handled by "Keep partitions
with no runtime input on CPU", so this change lands after it.
… its user

Passes that build a scalar constant with create_const_node (for example
DecomposeRemainder for i % 3 on an int64 tensor) insert its get_attr at
the very top of the graph, above the placeholders. I64toI32 then put
the int32 cast right after that get_attr, also above the placeholders.
When lift_constant_tensor_pass later turned the get_attr into a
placeholder, it placed it with the other placeholders, below the cast,
and lowering failed: "Argument _lifted_tensor_constant0 of Node
aten__to_copy_default was used before it has been defined".

Insert the cast for a get_attr right before its first user instead,
which is below the placeholders no matter where the get_attr sits.
Placeholders keep the cast right after them, as before.
The XNNPACK partitioner never delegated aten.elu, so ELU ran on the
portable kernel even though XNNPACK has an ELU node and ExecuTorch already
has its partitioner config and serializer. The config was commented out of
the partitioner's config list in 2024 while waiting for a PyTorch pin
update, and was never turned back on.

Turn the config on. XNNPACK's ELU only has alpha, while aten.elu also has
scale and input_scale, and SELU decomposes to elu with scale != 1. XNNPACK
also stores alpha in the compute dtype: an alpha that overflows it gives
NaN, and on x86 an infinite alpha gives NaN and a zero or subnormal alpha
gives +0 where eager gives -0. So the config keeps elu nodes on the CPU
kernel unless alpha is a positive normal number in the compute dtype and
scale and input_scale are 1. The config and the serializer read these
values from positional or keyword arguments, and the serializer handles an
elu call that leaves alpha at its default.

As with other XNNPACK ops, delegated results can still differ from eager for
values near zero: subnormal inputs and results are flushed to zero, and the
fp16 kernel returns +0 for an input of -0.

The config already listed static quantization, so in a statically
quantized model an elu between quantized ops is now delegated as a
quantized XNNPACK ELU as well (checked to match the reference on a
conv, elu, conv model).

Enable the existing fp32 and fp16 ELU tests. The fp32 test also checks that
SELU, an input_scale of 2 and an infinite alpha stay out of XNNPACK and
that an elu without alpha is delegated, and the fp16 test checks that an
alpha too large for fp16 stays out, all matching eager.
A linear quantized with torchao's IntxWeightOnlyConfig gave wrong
outputs after lowering to XNNPACK. With int4 weights in groups of 32
(the llama "4w" mode) the largest error on a small MLP against the eager
quantized model was over 4000, and int8 per-channel weights were just as
wrong.

The quantized weight is a tensor subclass, so its dequantize_affine runs
inside the linear call and lands in the linear's source partition. When
the linear is decomposed to mm or addmm, the partitioner finds the
weight by walking back from the gemm until it leaves the source
partition. It walked straight through the dequant to the int8 data, saw
a plain parameter, and delegated the gemm as fp32. ConvertToLinearPass
does the same walk when it rebuilds the linear inside the delegate, so
the int8 values were used as float weights, with the scales dropped.

Stop both walks at a dequant node. With float activations the gemm is
then seen as quantized, and since the XNNPACK partitioner has no path
for float activations with quantized weights, it stays out of the
delegate. Lowering such a model now fails loudly at to_executorch (no
kernel for dequantize_affine) instead of running with wrong weights.
With dynamically quantized activations the gemm is delegated and keeps
its weight scales. Dynamically quantized linears from
to_edge_transform_and_lower (8da4w and 8da8w: 8-bit dynamic activations
with 4-bit or 8-bit weights) are unchanged, because the linear itself is
delegated and is not decomposed. With the older to_edge() and
to_backend() flow, 8da4w and 8da8w linears that used to be delegated
with wrong weights now fail loudly the same way.

Add a test with an int4 weight-only linear, with and without bias: with
float activations its gemm stays out of the delegate, and with
dynamically quantized activations it is delegated and matches eager.
torch.cummin has no ExecuTorch kernel. It is not part of the Core ATen opset
and has no decomposition, so to_edge rejects it by default; a program that
keeps it anyway (with cummin in _core_aten_ops_exception_list, or with IR
validity checks turned off) then fails to load with "kernel
'aten::cummin.out' not found". This happens whenever cummin is left to the
CPU, for example in a model lowered with XNNPACK or OpenVINO, or a TensorRT
program whose cummin falls outside the engine.

Add cummin.out to the portable kernels, following cumsum. For each position
it writes the running minimum along dim and the index where that minimum
was found. It matches PyTorch: on a tie the index moves to the later
element, once a NaN appears it stays the minimum (with the index of the
latest NaN), and a zero-dimensional input works with dim 0 or -1. All real
types, Bool, Half and BFloat16 are supported.

Test: op_cummin_test.cpp checks values and indices along both dims for every
real type, Bool, NaN propagation, a zero-dimensional input, and that an
invalid dim fails.
The OpenVINO backend converted each partition with openvino_compile, a
helper written for torch.compile. That helper converts the graph first
and sets the input types only afterwards. Two things break because of
that:

1. Indexing with a boolean mask, like x[mask] or x[mask] = v, is lowered
   wrong. OpenVINO's converter looks at the index type to choose the
   boolean-mask lowering, but during conversion the type is still
   unknown, so the mask is treated as integer indices. A read gives
   wrong results, and a write fails with "Check 'args_et.is_dynamic() ||
   is_supported_et' failed" once the boolean type is set.
2. A bfloat16 partition input fails with "KeyError: torch.bfloat16",
   because the helper's type table has no bfloat16 entry.

Now the backend builds OpenVINO's PyTorch FX decoder with the partition
input types and converts it with the same PyTorch frontend calls the
helper used, then compiles the result with openvino.compile_model on the
device from the compile specs. The converter sees the real types from the
start, and its own type table already covers bfloat16. The helper's other
options (model caching and an extra config dictionary) cannot be reached
from ExecuTorch compile specs, so nothing else changes.

A partition input can also be a SymInt, such as a data-dependent size.
It has no dtype, so it is given to the converter as an int64 scalar, the
form OpenVINO's FX translators expect. The runtime passes such an input
as an int64 tensor of shape [1], so a PrePostProcessor step declares it
that way and squeezes it back to a scalar. The runtime takes no other
non-tensor input, so a float or bool one now fails at preprocess with a
clear error instead of when the program runs.

The new tests write through a boolean mask with float32 and bfloat16
data, and slice and view a tensor by a data-dependent size. They fail
before this change and pass after it.
Exporting a model with ConvTranspose1d or ConvTranspose2d to the CUDA
backend fails at the end of the AOTInductor compile with "missing fallback
kernels: aoti_torch_cuda_convolution". This backend lets Inductor use only
Triton kernels, and on CUDA Inductor has no Triton template for transposed
convolution (it has none for 1-D, and the 2-D one is disabled on CUDA), so
it falls back to ATen's convolution, which the ExecuTorch CUDA runtime does
not have. The diffusion policy in LeRobot (ConditionalUnet1D upsampling)
hits this.

Add aten.conv_transpose1d and aten.conv_transpose2d to the backend's
decomposition table, next to the existing conv1d entry. For finite weights,
a transposed convolution equals a stride 1 convolution with the flipped
kernel (input and output channels swapped per group) over the input with
stride - 1 zeros inserted between elements and padded on each side by
dilation * (kernel - 1) minus padding (plus output_padding at the end).
Inductor generates Triton code for the zero insertion, the padding and the
resulting convolution. The inserted zeros cost extra work (about stride
times the multiply-adds per spatial dimension), which is the price of having
a Triton path at all.

On ROCm, Inductor still uses its Triton template for 2-D transposed
convolution, so the 2-D entry is only added for CUDA. A transposed
convolution with dilation > 1 becomes a dilated convolution, which has no
Triton template, so on CUDA it still reports the missing kernel, as a
regular dilated convolution does today.

Test: test_conv_transpose lowers a ConvTranspose1d to the CUDA backend and
checks the decompositions against conv_transpose1d and conv_transpose2d with
stride, padding, output_padding, groups and dilation set, an unbatched
input, and padding that crops more than the input.
When a constant is used by more than one partition, duplicate_constant_node
gives every extra user of the constant its own placeholder, and every copy
keeps the target of the original. So several placeholders can point at the
same state_dict or constants entry, and two of them can end up in the same
partition. exir already handles this when it removes delegated constants (it
skips targets that are still in use).

delete_constant_placeholder deleted the entry on the first call. That left
the other placeholders pointing at a missing tensor, and deleting a second
placeholder with the same target failed with a KeyError. The Arm
FuseEqualPlaceholdersPass hits this when it fuses such copies, for example
for a weight reused around an op that stays on the CPU:

    KeyError: 'lin.bias'

Now the entry is removed only when no remaining placeholder refers to its
target, the same rule exir uses when it removes delegated constants.

duplicate_constant_node also stored a deep copy of the tensor under each
copy's node name (for example p_weight_copy_0). No input spec refers to
that key, so the data was never read, and it stayed in the program after
every placeholder was deleted. Stop storing it.
A model with a tensor-wide check, for example torch._check_tensor_all or
transformers' torch_compilable_check, could not be lowered to the CUDA
backend:

  Method forward missing fallback kernels: aten::_is_all_true

torch.export turns the check into aten._is_all_true. The AOTI partitioner
keeps every op for Inductor to handle, but Inductor has no lowering for
this one, and ExecuTorch's CUDA shim has no fallback kernel for it.

Add it to the CUDA backend's decomposition table as aten.all, which
Inductor lowers, next to the existing conv1d entry.
Every CUDA program writes its weights to a file named aoti_cuda_blob.ptd,
and keys each weight by its parameter name. When two programs with the
same parameter names are exported into one folder, the second export
overwrites the first program's weights file. The first program still
loads, finds every key it asks for in the other program's file, and
silently runs with the other program's weights. For example, a Linear
layer exported twice with different seeds: the first program then
returned the second one's outputs.

Add each weight's SHA-256, which the exporter already computes while it
writes the weight, to the weight's key. A program then finds only keys
written for its own weights, and loading it with another program's file
fails with "CUDA FQN storage ... is missing from named data". The bytes
are not hashed again at load, so this catches files that belong to
another program, not files corrupted in place.
Identical weights still get the same key, so methods that share a
weight still store it once. Within one export, a later method that
stores different bytes under the same name is still rejected.

The file name stays aoti_cuda_blob.ptd, because runners and scripts look
for it. Two programs still need their own folders or file names, but a
mix-up now fails at load instead of giving wrong results. This also means
a .ptd with different weights, for example from a fine-tuned model, needs
the .pte exported with it.

test_missing_weights_blob.py exports two programs with different weights
into one folder and checks that the first one refuses the second one's
file. Without this change it loads. Tests that read the stored keys now
take them from the delegate's manifest, which is what the runtime reads.
@shoumikhin
shoumikhin force-pushed the fix-lerobot-export-bugs branch from 9136f25 to 204bbc8 Compare October 29, 2026 15:09
Copilot AI balanced review requested due to automatic review settings October 29, 2026 15:09

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
shoumikhin merged commit 054de1f into pytorch:main Oct 29, 2026
541 of 550 checks passed
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@pytorchbot cherry-pick --onto release/1.6 -c critical

@pytorchbot

Copy link
Copy Markdown
Collaborator

Cherry picking #24222

The cherry pick PR is at #24298 and it is recommended to link a critical cherry pick PR with an issue.

Details for Dev Infra team Raised by workflow job

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 release notes: backends

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants