Skip to content

Keep ops above 6 dims out of XNNPACK and fix quantized ops it leaves out - #24380

Merged
shoumikhin merged 23 commits into
pytorch:mainfrom
shoumikhin:xnnpack-high-rank-permute
Nov 2, 2026
Merged

shoumikhin merged 23 commits into
pytorch:mainfrom
shoumikhin:xnnpack-high-rank-permute

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

What is wrong today

XNNPACK supports tensors with at most 6 dimensions, but the XNNPACK partitioner, which decides which ops run on XNNPACK, does not check this. A permute or another op on a 7-D or 8-D tensor then lowers fine but fails to load:

Failed to define tensor 0 with code: xnn_status_unsupported_parameter

Some vision models hit this in a "patchify" step that cuts an image into patches:

y = x.reshape(2, 6, 8, 2, 16, 8, 2, 16).permute(0, 2, 5, 3, 6, 1, 4, 7)
y = y.reshape(2, 64, -1) * 2

With the 3-D convolution kernel from #24385, a statically quantized Conv3d is worse: it fails to load or, without a fused ReLU, returns one wrong value repeated everywhere, with no error. XNNPACK has no 3-D convolution, so the partitioner leaves the conv out, but only after marking the quantize and dequantize nodes around it as folded into it. Those nodes then land in delegates that skip them, so nothing writes the outputs. A statically quantized Conv2d or Linear whose output is not quantized hits the same bug, even without #24385.

What this change does

The partitioner now leaves out any op with an input or output above 6 dimensions, and it checks every node that a partition takes. A float linear on such an input still keeps its 2-D matrix multiply on XNNPACK.

Quantize and dequantize nodes are now marked as folded only once the op they belong to is really partitioned, so a rejected op keeps them as separate ops.

force_non_static_weights_for_f32_linear no longer changes how a quantized linear is partitioned. With it, a quantized linear decomposed into a matrix multiply (for example with to_backend) used to lose its quantize and dequantize nodes: a static one failed to lower or load, and a dynamic one ran as a float matrix multiply. Now it is partitioned as without the flag.

The XNNPACK quantizer now leaves 3-D convolutions in float. The model file gets larger, but there are no extra quantize and dequantize steps and no quantization error. They run on the CPU kernels, which support 3-D convolution once #24385 lands. Without it, such a model fails with "Expect input tensor to be 3-D or 4-D".

Still not covered: a linear on such an input, quantized with torchao's affine ops. Those ops have no portable kernels, so the model now fails in to_executorch() instead of at load. And a linear on such an input quantized with the XNNPACK quantizer runs outside XNNPACK, because its quantized input has too many dims.

Testing

  • test_permute.py checks which ops are delegated for 6-D and 7-D permutes, and for a linear on an 8-D input: float (also with a ReLU), static and dynamic quantization, each in both partitioner modes.
  • New tests cover a statically quantized Conv2d and Linear with a float output, a statically and a dynamically quantized linear lowered through to_backend with force_non_static_weights_for_f32_linear, and 3-D convolutions that the quantizer leaves unquantized.
  • The per-channel transposed conv test now also runs the convs that XNNPACK rejects. Before this change they returned all zeros.
  • Each new test fails without the part of the change it covers.
  • On macOS arm64, the patchify example and other 7-D and 8-D cases load and match eager PyTorch. A Conv3d model quantized with the XNNPACK quantizer matches eager PyTorch with Support 3-D convolution in the portable convolution kernel #24385, and fails without it.

XNNPACK supports tensors with at most 6 dimensions. The XNNPACK
partitioner did not check this, so it could delegate an op whose input
or output has more dimensions. Lowering then succeeds, but loading the
program fails with:

    Failed to define tensor 0 with code: xnn_status_unsupported_parameter

Some vision models hit this in a "patchify" step, which reshapes an
image into 8 dimensions, permutes it, and reshapes it into a sequence
of patches:

    y = x.reshape(2, 6, 8, 2, 16, 8, 2, 16).permute(0, 2, 5, 3, 6, 1, 4, 7)
    y = y.reshape(2, 64, -1) * 2

This change checks the number of dimensions in the XNNPACK partitioner:

- The common constraint check that op configs run. An op with an input
  or output above XNN_MAX_TENSOR_DIMS is not delegated, and an op such
  as linear is not kept whole for XNNPACK, so it gets decomposed.
- The source partition check for a decomposed linear. Its matrix
  multiply pulls the reshapes around it into its partition, even when
  their own constraint check rejects them. If any of them touches a
  tensor above the limit, the matrix multiply is not delegated either.
  The check runs before the linear's quantize and dequantize nodes are
  marked to be folded into the matrix multiply, so a rejected linear
  leaves them as separate ops.

In the example above, the reshapes and the permute then run outside
XNNPACK, and the multiply is still delegated.

Still not covered: a linear on such an input, quantized with torchao's
affine quantize and dequantize ops. Those ops are kept whole for
XNNPACK and have no portable kernels, so such a model now fails when it
is converted to an ExecuTorch program, instead of when it is loaded.

The quantized test needs the quantized kernels, so the test target now
preloads them.

Tested with new permute tests that lower a linear on an 8-D input
followed by an 8-D permute, in float and with static quantization. They
check that no delegate holds a tensor with more than 6 dims, and compare
the program output with the reference. The tests fail without the
change, and they also fail when the common check or the matrix multiply
check is removed.
@shoumikhin shoumikhin added the release notes: xnnpack Changes to the XNNPack backend delegate label Oct 31, 2026
Copilot AI balanced review requested due to automatic review settings October 31, 2026 08:21
@pytorch-bot

pytorch-bot Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

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

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

✅ No Failures

As of commit a86580f with merge base aed328d (image):
💚 Looks good so far! There are no failures yet. 💚

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

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 31, 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 added a commit to shoumikhin/executorch that referenced this pull request Oct 31, 2026
The check that keeps ops with more than 6 dimensions out of the XNNPACK
delegate is not CUDA work, so it moves to pytorch#24380. This returns
backends/xnnpack to what main has.
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

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


PR Review: #24380

No blockers. The main problems are that the new AddmmConfig rank check looks at nodes the partition doesn't always take, so a 2-D matmul can be rejected for no reason, and that the new tests don't pin the 6-D limit.

Detailed review

Summary

The PR makes the XNNPACK partitioner leave out every op with an input or output above 6 dims. It also leaves out every decomposed linear whose source partition contains such an op. I found no blockers, only the minor issues below. These findings come from reading the code; I didn't run the tests.

Testing

  • backends/xnnpack/test/ops/test_permute.py:94-127: the tests check neither the limit nor the expected partitioning.

    • No boundary test. The new tests use 8-D tensors and the rest of the file uses 4-D. I found no 6-D tensor anywhere under backends/xnnpack/test. So changing > to >= at xnnpack_config.py:163, which would drop every 6-D op from XNNPACK, would still pass. The 5-D and 7-D cases in the description were only run by hand. Using the existing Permute module, add a 6-D permute that must stay delegated and a 7-D one that must not.
    • Weak assertion. assertGreater(len(lowered_modules), 0) (line 114) and the per-node rank check pass no matter what ends up delegated. Nothing checks the outcome the description claims: the mul delegated, the views and the permute outside. The other tests in this file check this with check_count({"torch.ops.higher_order.executorch_call_delegate": ...}), check and check_not.
    • Dynamic quantization is untested. It is the usual XNNPACK linear recipe. On that path, _get_act_deps (gemm_configs.py:258-300) adds choose_qparams/getitem deps, and the dynamic q/dq on the 8-D activation is then left outside the delegate.

    Fix this →

Performance

  • backends/xnnpack/partition/config/gemm_configs.py:565-566: the check covers nodes the partition doesn't always take.

    • Problem: The check runs over all of src_partition.nodes. With force_non_static_weights_for_f32_linear=True, the partition returned is set(deps) & set(src_partition.nodes) (lines 582-586). For FP32, deps contains no source-partition node: see the bias deps (240-246), weight deps (485-491, 610-616) and activation deps (262-263). The partition would therefore be just the 2-D addmm/mm, yet it is rejected because of >6-D views it would never contain. The matmul then falls back to portable, and the comment on line 565 is wrong in this mode.
    • Trigger: XnnpackPartitioner(force_non_static_weights_for_f32_linear=True) on LinearPermute8d.
    • Fix: Check the nodes actually returned. XNNPartitionerConfig.get_partition (xnnpack_config.py:71) is the only caller of get_node_and_deps, so do it there: return [] when any returned node fails _check_tensor_ranks. get_matched_nodes_from_configs already skips empty partitions (config_partitioner.py:229). This enforces "no delegated tensor above 6-D" for every config, not just the anchor plus this one special case that reaches into the base class's private helper.
    • Keep the anchor check in check_common_constraints, because filter_fn (config_partitioner.py:180-186) relies on it to decompose aten.linear.

    Fix this →

  • Default greedy mode, FP32 only (low confidence). An FP32 linear on a >6-D input loses its 2-D matmul to portable only because the flatten/unflatten views around it are above 6-D. This is because ret_deps is the union with src_partition.nodes (line 585). Leaving just those views out of ret_deps, instead of rejecting the partition, could keep the matmul on XNNPACK. Quantized precisions still have to reject, because their activation dq is itself above 6-D. I did not check that ConvertToLinearPass accepts a linear source partition without its views.

  • Follow-up, not a blocker. A >6-D permute that keeps runs of adjacent input dims together is a ≤6-D permute once those dims are merged. For example, permute(0, 1, 3, 5, 2, 4, 6, 7) on an 8-D tensor becomes permute(0, 2, 4, 1, 3, 5) after merging dims 0–1 and 6–7. Each such permute now splits the delegate and runs as a portable permute_copy. A pass that collapses view → permute → view chains to the minimal rank would keep them on XNNPACK. The patchify permutation in the description can't be reduced this way.

Recommendation

Approve

Nothing blocks. Fixing which nodes gemm_configs.py:566 checks, and adding the 6-D and 7-D boundary tests, are cheap changes worth making before merge.

The XNNPACK partitioner marks the quantize and dequantize nodes around a
quantized conv or linear as implicit, which means folded into that op.
The delegate then does not emit them as separate ops. The marks were set
while the partitioner was still checking the op, so they stayed even
when it then rejected the op.

The quantize and dequantize pairs around a rejected op then went into
delegates of their own, and those delegates skipped the marked nodes.
One delegate had no output, and the next one read a value that nothing
wrote. The program returned zeros instead of failing. For example:

- A statically quantized Conv3d or ConvTranspose3d. XNNPACK has no 3-D
  convolution, so the partitioner rejects it, and the output was all
  zeros.
- A quantized Conv2d or Linear whose output is not quantized, for
  example with a quantization config without an output activation.

Now GEMMConfig marks the nodes in get_partition, once a partition is
taken. A rejected op leaves them as separate ops, and their delegates
convert the values as they should.

Tested with a new conv test: a Conv2d with a quantized input and weight
and a float output. Without this change its output does not match the
reference.
The dims check for a decomposed linear looked at every node of its
source partition. The nodes the partition takes can differ from that.
With force_non_static_weights_for_f32_linear the partition keeps only
the deps inside the source partition, so a 2-D matrix multiply was
rejected because of 8-D views it would never hold.

Now XNNPartitionerConfig.get_partition checks every node it returns,
and returns an empty partition if any of them has more than 6 dims.
This covers every config, not only linear. The check of the op itself
stays in check_common_constraints, because the partitioner uses it to
decide which aten ops to keep from being decomposed.

In its default mode, a decomposed float linear now also keeps its 2-D
matrix multiply on XNNPACK. Its source partition leaves out only the
views above the limit, instead of the whole partition being rejected.
A quantized linear on such an input still runs outside XNNPACK, because
its quantized activation has more than 6 dims.

Tested with stronger permute tests that check which ops are delegated.
A 6-D permute is delegated and a 7-D one is not. A float linear on an
8-D input keeps its matrix multiply in XNNPACK, with and without
force_non_static_weights_for_f32_linear, while its views and the 8-D
permute stay outside. Its static and dynamic quantized variants keep the
matrix multiply outside. Each of these breaks at least one test:
removing the get_partition check, keeping the views in the default mode,
putting back the old source partition check, or lowering the limit to
5 dims.
XNNPACK has no 3-D convolution, so the XNNPACK partitioner never
delegates Conv3d or ConvTranspose3d. The XNNPACK quantizer still
quantized them for static quantization. That only added quantize and
dequantize steps around a float convolution that runs on the CPU
kernels anyway.

Now the conv and conv + relu annotators skip convolutions whose weight
has more than 4 dims, the same way the conv annotator already skips the
convolutions that XNNPACK cannot run with dynamic quantization. The
partitioner tells the convolution dims apart by the weight rank too.

Tested with a new conv test that quantizes a Conv3d with a relu followed
by a ConvTranspose3d. It finds quantize and dequantize ops in the graph
without this change, and none with it.
Copilot AI balanced review requested due to automatic review settings October 31, 2026 18:24

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.

The generic XNNPACK op configs, such as add or permute, mark the
quantize and dequantize nodes around a quantized op as implicit, which
means folded into the op, in get_node_and_deps. get_partition can still
reject the partition after that with its dims check. That cannot happen
for these ops today, because their quantize and dequantize nodes have
the same shapes as the op, which check_common_constraints already
checked. But any later check in get_partition would bring back the wrong
outputs that a rejected quantized conv or linear used to give.

Now these configs mark the nodes in get_partition, once the partition is
taken, as the conv and linear configs do. The marked nodes are the same
as before.

Checked by forcing get_partition to reject the partition of a quantized
add: the program matches the reference with this change, and is wrong
without it.
The comment said the weight rank is the only reliable way to tell the
dims of a convolution. That is true in the partitioner, which sees the
generic convolution op, but the quantizer annotators only match the
conv1d, conv2d and conv3d ops and their transposed forms. The comment
now only says why these convolutions are skipped.
Copilot AI balanced review requested due to automatic review settings October 31, 2026 20:54

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.

A float linear on an input with more than 6 dims is decomposed into
views around a 2-D matrix multiply, and the matrix multiply is still
delegated. But a ReLU or Hardtanh after the linear has the same high
rank as its output. The matrix multiply took it as a fused activation,
and the dims check then rejected the whole partition, so the matrix
multiply ran outside XNNPACK.

Now a float matrix multiply fuses an activation only when the
activation fits in XNNPACK. Otherwise the activation runs outside on
its own, and the matrix multiply stays delegated.

Tested by adding a ReLU case to the float linear permute test, in both
partitioner modes. Without this change the matrix multiply is not
delegated in the default mode.
The conv and linear configs and the generic op configs each marked the
quantize and dequantize nodes of a taken partition as implicit, in two
copies of the same loop. Now XNNPartitionerConfig.get_partition does it
for every config, right after its dims check. It marks the q/dq nodes
of the partition except the partitioned node itself, so a standalone
quantize or dequantize partition stays a separate op. No config returns
other q/dq nodes today, so the marked nodes are the same as before.

With one copy, the order of the check and the marking is covered by the
existing tests: marking before the check breaks the static quantized
permute test, and no marking breaks the quantized conv tests.
… test

The test for 3-D convolutions only checked that no quantize or
dequantize ops appear, so it would also pass if the quantizer quantized
nothing at all. It now runs the same model in 2-D too and checks that
that one is quantized. The name now says what it checks.
Copilot AI balanced review requested due to automatic review settings October 31, 2026 22:25

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.

XNNPartitionerConfig.get_partition can now return an empty partition
when a node has more dims than XNNPACK supports, and it marks the
quantize and dequantize nodes of a taken partition as implicit. The
docstring now says so, so that the author of a new config knows that
the q/dq nodes it returns are folded into its op. The comment above the
marking loop now only gives the reason for it.
Copilot AI balanced review requested due to automatic review settings November 1, 2026 00:09
@shoumikhin shoumikhin changed the title Keep ops with more than 6 dimensions out of the XNNPACK delegate Keep ops above 6 dims out of XNNPACK and fix quantized ops it leaves out Nov 1, 2026
The conv annotators now leave 3-D convolutions in float, but
CONV_TRANSPOSE_TARGETS still listed conv_transpose3d. With
set_operator_type(torch.ops.aten.conv_transpose3d.input, config), the
quantizer then quantized the 1-D and 2-D transposed convs of the model
with that config and none of its 3-D ones. The table now lists only the
transposed convs that the annotators quantize, like CONV_TARGETS.
The 2-D half of the test passed as soon as one quantize or dequantize
node appeared, so it would not notice if one of the three convs stopped
being quantized. The test now uses per-channel weights and counts the
weight dequantize nodes: one per conv in 2-D, none in 3-D.
Copilot AI balanced review requested due to automatic review settings November 1, 2026 09:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copilot AI balanced review requested due to automatic review settings November 1, 2026 09:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

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


PR Review: #24380

No blockers. Two of the new tests are weaker or more brittle than the tests around them, and the new dims check may stop delegating reshapes of constants above 6 dims (low confidence).

Detailed review

Summary

The PR keeps every node with an input or output above 6 dims out of XNNPACK partitions. It also marks q/dq nodes as folded only once their partition is taken, limits force_non_static_weights_for_f32_linear to float linears, and makes the XNNPACK quantizer leave 3-D convs in float. I found no blockers, only the minor issues below. These findings come from reading the code; I didn't run the tests.

Testing

  • backends/xnnpack/test/ops/test_conv2d.py:876-893: the rejected transposed-conv cases compare a quantized output with no quantization tolerance.

    • Problem: The module ends in quantize_per_tensor → dequantize_per_tensor at scale 0.12345, so every output is a multiple of 0.12345.
      • The reference runs ATen's conv_transpose2d, and the program runs the portable convolution kernel. If their fp32 sums for one element fall on opposite sides of a rounding midpoint, that element is off by 0.12345, against atol=1e-3.
      • The accepted case in the same loop goes through _test, which passes qtol=2 (line 279). These cases use the default qtol=0.
      • The weights and inputs come from unseeded torch.randint (lines 806-811, 860-862), so every run draws new values.
      • I haven't measured how often this happens (low confidence). Passing qtol=1 removes the risk.
    • Weaker than the new float-output tests: those tests (line 413, test_linear.py:865) assert a delegate count; this case does not. It would keep passing if the per-tensor q/dq stopped being delegated, and that delegation is the path that returned zeros.

    Fix this →

  • backends/xnnpack/test/ops/test_conv2d.py:359-377: test_qs8_quantizer_skips_conv3d counts only the weights' dequantize_per_channel.

    • Problem: The test doesn't check the activations, so it doesn't pin the description's claim that 3-D convs get no extra quantize and dequantize steps. It also stops at .export(), so it only tests annotation.
    • Fix: The quantizer's annotation tests live in backends/xnnpack/test/quantizer/test_xnnpack_quantizer.py. There, test_conv1d and test_conv2d (lines 45-94) call _test_quantizer with a node_occurrence that counts all four q/dq ops. A test_conv3d in that file with every count at 0 would cover both weights and activations.

    Fix this →

Performance

  • backends/xnnpack/partition/config/xnnpack_config.py:178-190: a reshape of a constant above 6 dims may no longer be delegated (low confidence).

    • Problem: _check_tensor_ranks rejects a node if any of its inputs has more than 6 dims. It does this even when that input is a constant and the node's output has 6 dims or fewer.
      • Before this PR: ViewCopyConfig (generic_node_configs.py:546) took such a view. ConstPropPass (backends/xnnpack/_passes/__init__.py:85) then folded it into a constant of 6 dims or fewer during preprocess, so XNNPACK never saw the larger tensor.
      • Now: the view runs on the portable kernel on every call, and its result enters the delegate as a runtime input.
    • Trigger: register_buffer("pe", torch.randn(1, 2, 2, 2, 2, 2, 4)) with forward(x): return x + self.pe.reshape(16, 8). I haven't run this, and the pattern is uncommon.

    Fix this →

Recommendation

Approve

Nothing blocks merging. Passing qtol in the rejected transposed-conv cases is a one-line change worth making first, because otherwise those cases may fail at random in CI.

The rejected cases of the per-channel transposed conv test ran the
program, but did not check that XNNPACK still runs the quantize and
dequantize nodes around the conv. They now count those two delegates,
like the float-output tests do.

They also allow one quantization step of difference, like the accepted
case. The XNNPACK quantize ignores quant_min, so a value that should
clamp to -127 comes out as -128. With the test's own random inputs,
this happened in 6 to 14 percent of runs per case. The per-test seed in
conftest.py hid it in CI, but a runner without that seed would see it.
The test counted only the weight dequantize nodes, so it would still
pass if the quantizer skipped the weights of a 3-D conv but kept
quantizing its input and output. It now counts all four quantize and
dequantize ops: in 2-D, four for the activations and three dequantize
nodes for the weights, and in 3-D, none.
Copilot AI balanced review requested due to automatic review settings November 1, 2026 12: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. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

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


PR Review: #24380

I found no issues in this round and recommend approving. This is based on reading the code only; I couldn't run the tests in this job.

Detailed review

Summary

The PR keeps every node with an input or output above 6 dims out of XNNPACK partitions. It also marks quantize and dequantize nodes as folded only once their op's partition is taken, applies force_non_static_weights_for_f32_linear only to float linears, and makes the XNNPACK quantizer leave 3-D convs in float. I found no issues. These conclusions come from reading the code, because Python and the tests can't run in this job.

Recommendation

Approve

Nothing blocks merging.

@shoumikhin
shoumikhin merged commit 1549787 into pytorch:main Nov 2, 2026
@shoumikhin

Copy link
Copy Markdown
Contributor Author

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

@pytorchbot

Copy link
Copy Markdown
Collaborator

Cherry picking #24380

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

Details for Dev Infra team Raised by workflow job

shoumikhin added a commit to shoumikhin/executorch that referenced this pull request Nov 2, 2026
The check that keeps ops with more than 6 dimensions out of the XNNPACK
delegate is not CUDA work, so it moves to pytorch#24380. This returns
backends/xnnpack to what main has.
shoumikhin added a commit that referenced this pull request Nov 2, 2026
## What is wrong today

A model with `torch.nn.Conv3d` lowers without an error but fails at run
time on CPU, for example with XNNPACK:

```
Check failed (in.dim() == 3 || in.dim() == 4): Expect input tensor to be 3-D or 4-D, but got, 5.
```

XNNPACK does not take 3-D or bfloat16 convolutions, so they run on the
portable convolution kernel, which only supported 1-D and 2-D. This
blocks vision encoders with a `Conv3d` patch embedding, such as Qwen2-VL
and Qwen2.5-VL. It is the failure in #11704.

## What this change does

The portable convolution kernel now runs 3-D convolutions, regular and
transposed. 1-D and 2-D convolutions run as 3-D ones with extra dims of
size 1 and add up their terms in the same order as before, so their
results stay the same (except that, with fused multiply-add, a result of
-0 can now be +0, as in PyTorch). The input and output may now have
different dim orders, because `torch.export` gives a `Conv3d` with a
channels-last input a contiguous output. The backend test suite no
longer skips 3-D convolutions.

It also fixes older bugs in the argument checks, which affect 1-D and
2-D too:

- A bias of the wrong length was read past its end (missing
parentheses), and a Bool bias crashed the kernel.
- `groups = 0` divided by zero, and a huge `groups` could overflow in
the bias check.
- A huge padding or dilation, or an output size too large for the 32-bit
tensor sizes, let a call that should fail run. Stride, padding and
dilation must now fit in 32 bits.
- Like PyTorch, the checks now reject a kernel dim of 0, a regular
convolution's kernel larger than the padded input, and an empty spatial
dim unless the batch or channel dim is empty too.

## Testing

- New tests cover grouped regular and transposed 3-D convolutions with
contiguous, channels-last and mixed layouts, and the checks above. All
but one fail without this change; that one checks that an empty batch or
channel dim still works.
- The portable kernel tests pass with AddressSanitizer and
UndefinedBehaviorSanitizer on Linux Arm64.
- The Cortex-M `conv3d` test, now with several channels, a 2x3x3 kernel,
stride and padding, no longer expects a failure and passes in CI on the
Arm simulator.
- A `Conv3d` patch embedding lowered with XNNPACK, and random
convolutions on the portable kernel, match eager PyTorch in float32 and
bfloat16.
- Speed against main on Arm64 and x86-64: regular 1-D and 2-D
convolutions are 1.5 to 13 times faster with GCC and with Clang at
`-Os`, and 18 percent faster to 11 percent slower with Clang at `-O2`
and `-O3`. Transposed ones are 5 percent faster to 17 percent slower.
- Code size for a Cortex-M55 (Arm GNU toolchain 15.2):
`op_convolution.o` grows from 32,484 to 33,263 bytes at `-Os` and from
48,477 to 61,457 at `-O3`. `kernel_ops_util.o`, which holds the argument
checks, grows from 15,746 to 17,743 bytes at `-Os` and from 25,932 to
28,979 at `-O3`.

## Notes

- Depends on #24380. Without it, a static int8 `Conv3d` lowered with
XNNPACK returns wrong values with no error, or fails to load, and the
backend test suite's static int8 XNNPACK `Conv3d` cases fail. Please
merge #24380 first or together with this change.
- An earlier version of 3-D support was reverted, see #19977.
shoumikhin added a commit that referenced this pull request Nov 2, 2026
… input shapes, masked copies as where, tensor checks dropped before compiling, float64, _reshape_copy, scatter_reduce and varlen_attn, follow-ups (#24326)

## What is wrong today

With `enable_cuda_graph_for_method` set, the CUDA backend runs a method
a few times normally, captures a CUDA graph on the fourth call, and
replays that graph after it. Some calls go wrong:

- **A call that cannot be captured fails.** A mask index, such as
`x[mask]` or `x[mask] = v`, reads a count back to the host, and a
capture cannot record that. The capture call returned an error. An
output that is first strided at the capture call failed the same way. A
`.item()` on a device tensor, such as a `torch.cond` predicate computed
on the GPU, ended the process.
- **A replay can return a wrong result with no error.** Replay checked
only each input's byte count. An input that changes from `(2, 3, 4, 4)`
to `(2, 4, 3, 4)` keeps its byte count, so it replayed the captured
shape and returned the wrong output.

## What this change does

- Replay compares each input's shape with the captured one and refuses a
different shape with `InvalidArgument`. The error names the dim and both
sizes. A captured graph only works for the input shapes of the capture
call, and the code comment on the graph phases now says so.
- When a capture fails, the backend runs the same call again without a
graph and returns its result. Graphs stay off for that method, as
before. The retry is safe because a capture records kernels without
running them, and it happens only if the new graph never started
running. The `.item()` shims now refuse to read inside a capture, as the
mask index does, so a capture that reads a device value is retried too
instead of ending the process.
- A method whose program keeps a constant in host memory, such as a
`torch.cond` flag updated on every call, now runs without a graph. Host
code runs only during the capture, so a graph would keep the branch of
the capture call, and a retry after a failed capture would take the
other one. The log names the constant found in host memory, or says the
constants could not be listed.
- An expected capture failure, such as the mask index refusing to run
inside a capture, is logged at Info instead of Error, since the call
then succeeds. A real failure of the call without a graph is still
logged at Error. AOTInductor's generated code still prints its own line
for the refused call.
- The allocations a captured graph makes are recorded only after its
first launch succeeds. Before, a failed first launch left addresses that
were never allocated, and a later cleanup passed them to `cudaFree`. A
failed capture now cleans up with `CudaGraphState::release()` instead of
a copy of it.
- These CUDA graph changes do not change the exported program, so older
programs get them too. A program that uses the new shims described below
(scatter, scatter_reduce, float64 or float16) needs a runtime that has
them.

Smaller commits:

- The CUDA random shims seed from `std::random_device` instead of the
clock, so two processes started in the same second no longer draw the
same numbers. If `std::random_device` throws, or the copy of the seed to
the GPU fails, the shim logs an error and frees what it allocated, and
the next call tries again.
- The wheel's shared library check now lists the mask index and
`index_put` shims.
- With PyTorch 2.14, a program that writes a one-element buffer, such as
a counter, kept returning its first update, because Inductor folded the
update into a constant. The AOTI backends (CUDA and Metal) now turn off
Inductor's joint-graph constant folding, for the whole program, when a
program writes a buffer. On a small Gemma 4 decoder that writes its KV
cache, folding changes two nodes per graph; with it off, export and
decode times stayed within run-to-run noise and the generated tokens
were identical.
- Unit tests for `aoti_torch_get_size`.
- Comments on the index shims and on `set_output_data_ptr` that
described accumulation, the sort count limit and the output index
wrongly are corrected.
- The mask index tests move to a new `test_index_shims.py`, since they
need the Python runtime. They add a 2-D mask, and `index_put` with
repeated indices in deterministic mode (half and bfloat16 accumulation,
last write wins, an int32 index).
- `test_output_shape_changes_at_capture` ends with a call that only a
live graph rejects, so it fails if graphs are off. The KV cache
recapture test checks that dropping the graph also clears the captured
input shapes.

## Follow-ups to #24314

- Delete `test_align_corners_compiles_with_dynamic_sizes`. It could not
fail: it passed with no resize rewrite at all and with the min and max
in the align_corners ratio replaced by a plain `if`, because
`torch.compile` compiles again for an output size of 1. With torch 2.14
and `CI` set it also failed for an unrelated reason, and it is the one
failing test of the CUDA unit test job on main.
`test_bicubic_antialias_resize` already runs an exported program at an
output height of 1, where a plain `if` breaks.
- The comment on that min and max now gives the real reason: an exported
program keeps the branch it was traced with, so a plain `if` divides by
zero at an output size of 1.
- A backend that keeps its data in the `.pte`, such as Metal, now
refuses a `data_name` compile spec built by hand too, before compiling
anything. Before, only `generate_data_name_compile_spec` refused it, and
a hand-built spec was ignored with no name check.
- The CUDA docs warn that Windows and macOS usually treat data names
that differ only in case as the same file.

## Copies through a boolean mask no longer read the mask count back to
the host

`out[mask] = src[mask]` ran as a mask index and a mask `index_put`. Each
of them read the mask's count back to the host and waited for the GPU,
on every call. Transformers' SmolVLM vision embeddings do this for their
position ids.

When the values are the same mask's picks from a tensor shaped like the
destination, every element goes back to where it came from. So a new
pass in the CUDA backend writes the copy as `torch.where(mask, src,
out)`, which gives the same result, bit for bit. It does so inside
`torch.cond` branches and other subgraphs too. It rewrites only when
`where` would lay out its result like the destination, since `where`
takes its layout from its inputs, the mask first, while `index_put`
keeps the destination's layout. It also drops the index and the checks
on its size when nothing else reads them and each check tests only the
index's count and holds for any mask. A user check on the count, such as
`torch._check(x[mask].shape[0] >= 1)`, keeps the index. The pass
compares the two masks with a work list, so masks with long histories do
not hit Python's recursion limit. On an A100, five calls of the SmolVLM
position id code went from 10 count reads and 10 stream waits to none.

## Tensor checks no longer stop the CUDA export

The argument checks in `torch.distributions` export as
`aten._assert_async.msg`. The CUDA backend compiled each one into a
device assert inside a Triton kernel. While compiling, AOTInductor runs
every kernel once to pick its launch settings, on made-up buffers:
random floats, and zeros for integers and bools. So a check that reads a
bool from an earlier kernel saw false and failed, even though it holds
for every real input. The export then stopped with "device-side assert
triggered". A Gaussian policy head, `MultivariateNormal(mean,
scale_tril=...)` with 14 actions, hits this.

A new pass in the CUDA backend now removes these checks before
compiling, so the CUDA delegate no longer runs them. The rest of
ExecuTorch already removes the same checks before a program is saved. A
failed device assert could not come back as an error anyway: it breaks
the CUDA context for the rest of the process. Checks on sizes
(`_assert_scalar`) stay, since they run on the host.

## float64 weights load in the CUDA backend

A program with a float64 weight lowered and saved, then failed to load
with `Malformed CUDA FQN weight metadata`. This happened with a float64
buffer, and with float64 math that constant propagation folds into one
constant, such as a sinusoidal time embedding. The export wrote the
weight correctly, but the runtime had no float64 dtype: the weight cache
refused it, and the runtime's tensor type and dtype shims had none
either.

- The runtime's tensor type now has `Double`, with its 8 byte size. The
same type wraps a method's inputs and outputs, so a float64 input or
output works too.
- The shims export `aoti_torch_dtype_float64`, which compiled code calls
to name the dtype of a float64 constant.
- The weight cache accepts `Double`, and uses 8 bytes when it checks
that a weight fits its storage.
- The shims that still refused float64 accept it: a float64 `.item()`
(`aoti_torch_item_float64`, which did not exist, so the first execute
ended the process), the single-element read those entry points share,
`index_put` with accumulate, and the sort shim. A float16 `.item()` had
the same problem with `aoti_torch_item_float16` and works now too.
- A float read with `.item()` reached Triton kernels truncated to an
integer, in float32 too, so a scale of 1.5 computed as 1 with no error.
AOTInductor declares every symbolic argument of a kernel's launcher
`int64_t`. A compile-time patch in the CUDA backend now declares it with
the float type the kernel takes, the change that the open
pytorch/pytorch#201882 makes in PyTorch.

## `aten._reshape_copy` lowers to the CUDA backend

Torch-TensorRT turns `aten.view` into `aten._reshape_copy`. A view that
TensorRT leaves to PyTorch reaches the CUDA backend in that form, and
the export failed with `missing fallback kernels: aten::_reshape_copy`,
since Inductor has no lowering for it and torch registers no
decomposition. The CUDA backend now decomposes it the way ATen computes
it: a contiguous copy, viewed to the new shape.

## `varlen_attn` lowers to the CUDA backend

`torch.nn.attention.varlen.varlen_attn` runs attention over several
sequences packed into one tensor. Lowering it failed with `missing
fallback kernels: torch_attn::_varlen_attn`, since it calls Flash or
cuDNN attention, which this backend does not ship. The CUDA backend now
decomposes it into ops Inductor lowers: attention over all the packed
tokens, with a mask that keeps each query in its own sequence. The
output is contiguous, like the op's output for a query in the usual
(tokens, heads, dim) order, so reshaping it before the output projection
works.

The decomposition matches the op for causal masks, sliding windows,
grouped-query attention, and query and key sequences of different
lengths. For a query sequence longer than `max_k + 1`, Flash attention 2
(PyTorch's default) and Flash attention 3 give different results for
some windows, because version 2 changes the window based on `max_k`. So
a window other than `(left, 0)` lowers only when `max_q <= max_k + 1` is
known at export time, as in self-attention, and raises a clear error
otherwise. `seqused_k` and `block_table` (paged KV) raise a clear error
too. It computes the weights with a softmax, which subtracts each row's
largest score first, so the weights still add up to one when scores are
large. Tokens before the first sequence or past the last one, such as
the padding of a fixed-size buffer, do not change any sequence's output,
even when they hold NaN. Unlike in Flash attention, a NaN or inf in one
sequence's values does reach the other sequences. It holds a float32
score per head for every pair of packed tokens and runs much slower than
Flash attention, so it suits packs of a few thousand tokens, such as a
few camera images.

## `scatter_reduce` with amax, amin, prod and mean lowers to the CUDA
backend

Lowering `x.scatter_reduce(dim, index, src, "amax")` failed with
`missing fallback kernels: aoti_torch_cuda_scatter_reduce_two_out`, and
so did "amin", "prod" and "mean". Inductor writes a scatter itself only
when it can add atomically, and leaves the other reductions to an ATen
kernel that this backend did not ship. A sum into int64 or bool, a sum
into bfloat16 on GPUs older than sm_90, and deterministic mode use the
same kernel.

- A new shim next to the index shims implements it the way ATen does:
`include_self`, elements the index does not name, the count and integer
rounding of the mean, NaN in amax and amin, and bool for sum only. Half
and bfloat16 sums add in float, as ATen does in deterministic mode. A
product rounds to the tensor's dtype at every step and a mean rounds its
sum before dividing, as ATen does, so half overflows and integers wrap
where ATen's do. A mean also counts its terms in the tensor's dtype, as
ATen does, so 128 int8 ones average to 1.
- It sorts the writes by the element they land on and reduces each
element's values in index order, so the result is deterministic. It also
works inside a CUDA graph. The sort is now one helper that `index_put`
uses too.
- It is slower than ATen's atomic kernel: 1.5 to 3.2 times on an A100 at
512 output rows, 4096 source rows and 1024 columns, and up to 7 times
when many values land on few elements. A decomposition into ops Inductor
lowers natively was measured first. It has to compare every output
element with every source element, and it was 8 to 89 times slower than
ATen's kernel.
- `aoti_cuda_shims.lib` and the wheel's shared library check list the
new shim. The import library now also lists the shims the DLL exported
but it left out, such as `aoti_torch_dtype_float16` and the
`aoti_torch_item_` getters, so a float16 model lowered for Windows
links.

## CUDA programs load without torch on machines with no libgomp

On Linux, Inductor adds OpenMP (`-fopenmp`, `-lgomp`) to every link.
Unless the compiler passes `--as-needed` to the linker, as Ubuntu's GCC
does by default, the compiled library lists `libgomp.so.1` as a
dependency even when it calls no OpenMP function. PyTorch ships a
libgomp, so this went unnoticed while torch was imported. A process
without torch failed to load the program with `libgomp.so.1: cannot open
shared object file` on any machine with no system libgomp, such as a
minimal container.

The CUDA backend now links with `-Wl,--as-needed`, so the linker keeps
only the libraries the program uses. A program that does call OpenMP
keeps libgomp. PyTorch already does the same for MPS builds on macOS,
and pytorch/pytorch#202902 adds it to Inductor for Linux. The torch-free
tests in `test_pybindings_cuda_dlpack.py` now run without a system
libgomp, and the ones that check which libraries got loaded also check
for libgomp. On an A100, a small MLP and CNN exported with this change
load without torch and match eager where the loader cannot find libgomp.

This patch, and the one that lets Triton templates run on GPUs with few
SMs, now stay in place while any thread lowers. Before, when two threads
lowered at once, the first one to finish removed the patch from the
other, and the rest of that compile ran without it.

## A failed CUDA load no longer leaves its library in the temporary
directory

To load a method, the CUDA backend writes the program's compiled library
to a temporary file, such as `/tmp/executorch_cuda_<pid>_0.so`. Only
`destroy()` removed that file, and `destroy()` never runs when the load
fails, so every load that failed after writing the file left it behind.
A scope guard now removes the file unless the load succeeds.
`test_missing_weights_blob.py` now checks that a load that fails for a
missing weights blob leaves nothing in the temporary directory. Without
the fix, that check fails.

The XNNPACK high-rank permute fix moved to #24380.

## The Triton SDPA leaves keys past the end out of the softmax

With `triton_kernel_mode` on (the default), the CUDA backend runs
`scaled_dot_product_attention` as its Triton kernel, `triton::sdpa`. For
power-of-two head sizes, its main kernel reads keys in tiles of 32, 64
or 128. When the number of keys is not a multiple of the tile, the last
tile reaches past the last key. The kernel loaded those positions as
zeros but never removed them from the softmax, so without an attention
mask or a causal mask each one got a score of 0 and took part of the
attention weight. Calls that pass `kv_len` with neither a mask nor
`is_causal`, such as the DFlash draft model's global attention layers,
had the same problem. On an A100, with the query at 0 and the values at
1, where the right output is 1, one key gave 0.031, 33 keys gave 0.516
and 80 keys gave 0.625. With random inputs and one key, the output was
off by 3.4 when the largest value was 3.67. All of these now match eager
PyTorch.

The kernel now gives every position past the last key, or past `kv_len`,
a score of minus infinity, as the other attention kernels in `sdpa.py`
already do. The first version of the kernel did this, and a later
rewrite dropped it. Checking every tile cost up to 8% where no tile
reaches past the end, so only calls that need the check pay for it. A
mask or a causal bound already drops those keys, so calls with one skip
it. The launcher also passes the largest tile size that divides the
number of keys, and tiles that divide it skip the check at compile time.
So calls with a mask or a causal bound, and tiles that divide a key
count known at export time, compile to the same code as before. Without
a mask or a causal bound, a `kv_len` or a symbolic key count checks
every tile. `test_triton_sdpa.py` drops its note that called this a
kernel limitation, runs its basic test with a single key, and runs every
tile without a mask over 80 and 96 keys and over a `kv_len` of 80. These
fail without the fix.

## `scatter` with a tensor source lowers to the CUDA backend

Lowering `x.scatter(dim, index, src)` failed with a `missing fallback
kernels` error that names `aoti_torch_cuda_scatter_src_out` when `src`
is bfloat16, int64 or bool. Inductor writes a scatter from a tensor
itself only in a dtype it can add atomically, so it leaves int64, bool
and, on GPUs older than sm_90, bfloat16 to an ATen kernel, and every
dtype in deterministic mode. A mixture-of-experts layer that writes its
expert outputs back with `torch.zeros_like(h).scatter(1, index, src)` in
bfloat16 hit this on an A100.

A new shim next to the `scatter_reduce` one implements it with the same
checks, the same sorting of the writes by the element they land on, and
the same kernel. The kernel gets a new mode in which only the last write
to each element stores its value. So when values land on the same
element, the last one in index order wins, as ATen does in deterministic
mode; ATen's default CUDA kernel keeps any one of them. As in that
default kernel, an index out of range, negative ones included, triggers
a device-side assert. In deterministic mode, ATen's scatter counts a
negative index from the end instead. The shim takes float, double, half,
bfloat16, the integer dtypes and bool. Like ATen, both scatter shims now
take an int32 index as well as an int64 one. Mostly because of the sort,
the shim is about 10 to 15 times slower than ATen's default kernel for a
mixture-of-experts style bfloat16 scatter of 4 to 34 million values on
an A100. The Windows import library `aoti_cuda_shims.lib` and the
wheel's shared library check now list the new shim. On an A100 with
PyTorch 2.14 and 2.15, scatters in float32, float16, bfloat16, int64 and
bool lower, run and match eager PyTorch exactly. So does a small
mixture-of-experts layer built the same way.
`test_scatter_with_tensor_source` fails without the fix, and its int32
index case fails when the shims take only int64.

## Review order

Start with the CUDA graph changes: `cuda_backend.cpp` and
`cuda_delegate_handle.h` in `backends/cuda/runtime`, the mask and
`.item()` shims in `runtime/shims/index.cu` and `memory.cpp`, and their
tests in `test_output_stride_rearrange.py`. Then the lowering side: the
passes in `backends/cuda/passes`, the decompositions and compile-time
patches in `backends/cuda/cuda_backend.py`, and the folding switch in
`backends/aoti/aoti_backend.py`. Then the scatter and scatter_reduce
shims in `index.cu` with `test_index_shims.py`. The remaining commits
are small follow-ups that stand on their own.

## Testing

On an A100, with the CUDA backend built from this branch:

- `test_index_shims.py`, `test_output_stride_rearrange.py`,
`test_cuda_export.py`, `test_antialiased_resize.py`,
`test_merge_ptes.py` and `test_cuda_weight_metadata.py` pass.
- With the backend built from main, the new CUDA graph tests fail.
- The replay error names the dim, for example "input 0 has size 4 in dim
1, captured with 3".
- `test_cuda_kv_cache` passes, and fails when `release()` keeps the
captured input shapes.
- With the min and max replaced by a plain `if`,
`test_bicubic_antialias_resize` fails.
- With the new data name refusal removed, or moved back behind the
compile, the data name test fails.
- A `torch.cond` on a host-memory flag matches eager on every call; with
a graph it returned the captured branch.
- A random program run from two processes started together drew the same
numbers in 2 of 3 tries before the seed change, and different ones in 3
of 3 after.
- Mask index programs exported from this branch match eager PyTorch on
every call, both on a runtime built from main with CUDA graphs off and
on this branch's runtime with CUDA graphs on.
- The shared shim unit tests pass in a standalone googletest build.
- `test_replace_masked_copy_with_where.py` passes, and fails when the
pass does nothing, is not registered, or skips its accumulate,
same-mask, boolean mask, shared picks or count check. It writes into a
destination filled with 7, so it fails if `where` loses the values the
mask does not pick. A contiguous destination with a channels_last
source, or with a channels_last mask, keeps its `index_put`; before, the
rewrite failed to lower when a later `view(-1)` needed the destination's
layout. It also fails when the pass walks only the top-level graph,
since a masked copy in a `torch.cond` branch then keeps its index, and
when it takes two draws of a random mask for the same mask. Two masks
computed with over a thousand ops each raised RecursionError before. A
check that reads the index's count but tests another size keeps the
index; before, the pass removed it.
- A masked copy lowered through the CUDA partitioner matches eager for
masks that pick some, none and all positions. The test records the graph
the pass returns, and fails when the pass is not registered or leaves
the index in place.
- A module that adds one to a device buffer on every call and reads a
mask's count returns the call number on every call, so the capture call
that runs again without a graph updates the buffer once. With the retry
made to run the call twice, the capture call returns one too many. With
PyTorch 2.14 it returned one on every call even without a graph, because
Inductor folded the written buffer into a fill; it passes now with 2.14
and with 2.15 and 2.16 nightlies.
- On the tiny Gemma 4 decoder from the Gemma 4 31B example's tests,
which writes a KV cache, with PyTorch 2.14 on an A100: with the folding
off, export took 193 s against 190.5 s with it on, a decode call took
945 µs against 1019 µs (medians of three exports and three runs on a
busy machine), and the 41 generated tokens were the same.
- The mask tests check that the runtime log reports one failed capture,
so they fail when CUDA graphs are off or a failed capture is tried
again. `test_host_read_runs_without_a_graph` also runs a module that
scales by a `.item()` read; without the capture check in the item shims,
the process aborts.
- With the debug check on the phase before the retry turned into a
release check, the mask tests pass.
- `test_distribution_argument_check` passes with PyTorch 2.14 and fails
with "device-side assert triggered" without the new pass. It also checks
that the backend lists the pass, since without the pass the export fails
only when autotuning happens to trip the assert. It writes the
`MultivariateNormal` check as `torch._assert_async`, since PyTorch 2.14
cannot export the distribution itself. It also checks that the pass
drops the check and the math that only fed it. A Gaussian policy head
with hidden sizes 16, 64 and 256 exports, runs, and matches eager
PyTorch.
- A float64 buffer and a folded float64 constant load and match eager
PyTorch. Before, both failed to load. `test_float64_weight_loads` fails
without the fix.
- `test_reshape_copy` in `test_cuda_export.py` fails without the fix.
The one in `test_index_shims.py` runs `_reshape_copy` on a transposed, a
sliced and a contiguous input and matches eager PyTorch.
- `test_varlen_attn` in `test_cuda_export.py` fails without the fix. It
lowers a program that flattens the output, compares the decomposition
with the op on CUDA only (on ROCm, varlen_attn runs AOTriton, which
lines an explicit window up with the start of each sequence, while the
decomposition follows Flash attention on CUDA, which lines it up with
the end), including a window in self-attention and NaN values outside
the key sequences, and checks that a window the Flash attention versions
disagree on raises. It also fails when the output is left
non-contiguous, when the window check is removed or refuses every such
window, and when the values outside the key sequences are not zeroed.
Both `test_varlen_attn` tests include queries and keys filled with 1024,
where every key gets the same weight; computing the weights as
`exp(scores - logsumexp)` fails them, since at that size float32 rounds
the log-sum-exp to a whole number and the weights no longer add up to
one. The `test_varlen_attn` in `test_index_shims.py` lowers and runs
programs that call `varlen_attn` with no window, a causal mask and the
windows (8, 0) and (4, 2), flatten each output, and take `cu_seq_q` and
`cu_seq_k` only at run time. They match eager PyTorch.
- `test_missing_weights_blob.py`, `test_cuda_weight_reuse.py` and
`test_device_placement.py` pass.
- `test_scatter_reduce` now runs the sum too, which for int64 goes to
the new shim. The test that a missing scatter kernel fails at export now
tries to lower a scatter of a scalar in deterministic mode. The backend
still lacks that kernel, so the test still checks the scatter fallback
now that the scatter_reduce and scatter shims exist. It fails when the
check stops watching that fallback.
`test_scatter_mean_counts_in_its_dtype` fails with the old count and
when a count that wraps to zero is not turned into one.
`test_float64_and_float16_values` (a sort and `.item()` in each dtype)
and the float64 masked accumulate fail with the old shims, and all pass
now. The `.item()` scale is 1.5, so that test also fails without the
patch that passes a float to Triton kernels as a float.
- `test_scatter_reduce` and `test_sort_shim.py` pass.
`test_scatter_reduce` fails without the fix, and also when the shim
ignores `include_self`, rounds the integer mean toward zero, or lets a
NaN lose in amax and amin. `test_scatter_strides` scatters along the
middle dim of a 3-D tensor, given as a negative dim, from a strided
source and from transposed operands, and fails when the shims walk only
the last two dims. `test_scatter_reduce_edge_cases` covers a bool sum,
int32 and half products that wrap and overflow, and an empty index.
`test_scatter_reduce_sum_adds_in_float` sums 1000 half and bfloat16
values in deterministic mode, and fails when the sum rounds to the dtype
at every step. `test_scatter_reduce_replays_in_a_graph` captures a
`scatter_reduce` program in a CUDA graph and matches eager on every
replay.
- The weight cache, runtime tensor and shim unit tests pass in a
standalone googletest build. Without the fix, the weight cache test of a
float64 entry fails.

- `test_small_gpu_override_survives_a_thread_that_leaves_first` overlaps
two threads inside the small-GPU patch, and fails with the old save and
restore.

- With the seed copy made to fail once, the random shim logs it, fails
that call, and seeds on the next one; before, it went on with an
unseeded state.

Not tested: a failed first graph launch (it cannot be triggered from a
test), a `std::random_device` that throws, Windows, macOS
case-insensitive file systems, the buffer folding change on Metal, and
the ROCm build. No test covers the seed change, since a test cannot make
two processes start in the same second.








### Checked on real robot policies

On the release/1.6 stack with this change, on A100 and H100:
- SmolVLA on the CUDA backend: one device-to-host copy per action chunk
(only the action download), matching eager within the usual tolerance.
- A Gaussian policy whose `torch.distributions` check used to stop the
CUDA export: exports and runs, 0.76 ms per chunk.
- A float64 weight that used to fail at load with "Malformed CUDA FQN
weight metadata": loads and runs, max difference 0.
- Four processes started together: four different random draws, against
two out of four before.
JacobSzwejbka pushed a commit that referenced this pull request Nov 3, 2026
Merge after #24445. It does not depend on it to build, but #24445 brings
the XNNPACK fix for statically quantized 3-D convolutions, and without
it the backend test suite would report those cases as failing once this
pick removes the 3-D skip.

## What is wrong today

A model with `torch.nn.Conv3d` lowers without an error but fails at run
time on CPU, for example with XNNPACK:

```
Check failed (in.dim() == 3 || in.dim() == 4): Expect input tensor to be 3-D or 4-D, but got, 5.
```

XNNPACK does not take 3-D or bfloat16 convolutions, so they run on the
portable convolution kernel, which only supported 1-D and 2-D. This
blocks vision encoders with a `Conv3d` patch embedding, such as Qwen2-VL
and Qwen2.5-VL. It is the failure in #11704.

## What this change does

The portable convolution kernel now runs 3-D convolutions, regular and
transposed. 1-D and 2-D convolutions run as 3-D ones with extra dims of
size 1 and add up their terms in the same order as before, so their
results stay the same (except that, with fused multiply-add, a result of
-0 can now be +0, as in PyTorch). The input and output may now have
different dim orders, because `torch.export` gives a `Conv3d` with a
channels-last input a contiguous output. The backend test suite no
longer skips 3-D convolutions.

It also fixes older bugs in the argument checks, which affect 1-D and
2-D too:

- A bias of the wrong length was read past its end (missing
parentheses), and a Bool bias crashed the kernel.
- `groups = 0` divided by zero, and a huge `groups` could overflow in
the bias check.
- A huge padding or dilation, or an output size too large for the 32-bit
tensor sizes, let a call that should fail run. Stride, padding and
dilation must now fit in 32 bits.
- Like PyTorch, the checks now reject a kernel dim of 0, a regular
convolution's kernel larger than the padded input, and an empty spatial
dim unless the batch or channel dim is empty too.

## Testing

- New tests cover grouped regular and transposed 3-D convolutions with
contiguous, channels-last and mixed layouts, and the checks above. All
but one fail without this change; that one checks that an empty batch or
channel dim still works.
- The portable kernel tests pass with AddressSanitizer and
UndefinedBehaviorSanitizer on Linux Arm64.
- The Cortex-M `conv3d` test, now with several channels, a 2x3x3 kernel,
stride and padding, no longer expects a failure and passes in CI on the
Arm simulator.
- A `Conv3d` patch embedding lowered with XNNPACK, and random
convolutions on the portable kernel, match eager PyTorch in float32 and
bfloat16.
- Speed against main on Arm64 and x86-64: regular 1-D and 2-D
convolutions are 1.5 to 13 times faster with GCC and with Clang at
`-Os`, and 18 percent faster to 11 percent slower with Clang at `-O2`
and `-O3`. Transposed ones are 5 percent faster to 17 percent slower.
- Code size for a Cortex-M55 (Arm GNU toolchain 15.2):
`op_convolution.o` grows from 32,484 to 33,263 bytes at `-Os` and from
48,477 to 61,457 at `-O3`. `kernel_ops_util.o`, which holds the argument
checks, grows from 15,746 to 17,743 bytes at `-Os` and from 25,932 to
28,979 at `-O3`.

## Notes

- Depends on #24380. Without it, a static int8 `Conv3d` lowered with
XNNPACK returns wrong values with no error, or fails to load, and the
backend test suite's static int8 XNNPACK `Conv3d` cases fail. Please
merge #24380 first or together with this change.
- An earlier version of 3-D support was reverted, see #19977.

Co-authored-by: Anthony Shoumikhin <anthony@shoumikh.in>
shoumikhin added a commit to shoumikhin/lerobot that referenced this pull request Nov 3, 2026
…d drive a robot with the bundle

The export tests compared bundles with their PyTorch policies, mostly
with the PyTorch test backend. Nothing exported the main policies with
every backend that ExecuTorch runs, checked where their programs run, or
ran `lerobot-rollout --bundle.path` on a bundle that a real compiler
wrote.

`tests/export/test_export_backends.py` exports tiny ACT, Diffusion
Policy, pi0, pi0.5, SmolVLA and GR00T checkpoints with
`executorch_xnnpack`, `executorch_cuda` and `executorch_tensorrt`. The
export itself only writes a bundle that reproduces the PyTorch policy's
test case within the bundle's tolerance. Then, for each bundle:

- Each program runs where its backend runs it. XNNPACK bundles hold only
  `XnnpackBackend` delegates. CUDA bundles hold only `CudaBackend`
  delegates, and TensorRT bundles `TensorRTBackend` and possibly
  `CudaBackend` ones, with no operator left on the CPU.
- `lerobot-rollout --bundle.path` drives a recording robot for at least
  12 actions, which takes several chunks, in a process that never loads
  PyTorch.

The recording robot can now show a bundle's test case: with
`--robot.frames=<test case file>` it has a camera for each image of the
test case, so a bundle of a policy with cameras can drive it.

Each backend takes its skip mark from `tests/export/backends.py`, so a
row that a released ExecuTorch or Torch-TensorRT cannot run yet skips
and says which fix it needs.

Two kinds of row fail on every wheel released so far, so they skip up to
the version with the bug and run again on a newer one:

- GR00T with XNNPACK: ExecuTorch 1.6.0's XNNPACK cannot load GR00T's
  vision program, which permutes an 8-D tensor (pytorch/executorch#24380).
- pi0 and pi0.5 with TensorRT: TensorRT 11.4.0.106 cannot build an engine
  for the tiny policies' concatenations. The full-size policies build.

Tested: see the following commit, which runs these tests in CI.
JacobSzwejbka pushed a commit that referenced this pull request Nov 3, 2026
… input shapes, masked copies as where, tensor checks dropped before compiling, float64, _reshape_copy, scatter_reduce and varlen_attn, follow-ups (#24512)

## What is wrong today

With `enable_cuda_graph_for_method` set, the CUDA backend runs a method
a few times normally, captures a CUDA graph on the fourth call, and
replays that graph after it. Some calls go wrong:

- **A call that cannot be captured fails.** A mask index, such as
`x[mask]` or `x[mask] = v`, reads a count back to the host, and a
capture cannot record that. The capture call returned an error. An
output that is first strided at the capture call failed the same way. A
`.item()` on a device tensor, such as a `torch.cond` predicate computed
on the GPU, ended the process.
- **A replay can return a wrong result with no error.** Replay checked
only each input's byte count. An input that changes from `(2, 3, 4, 4)`
to `(2, 4, 3, 4)` keeps its byte count, so it replayed the captured
shape and returned the wrong output.

## What this change does

- Replay compares each input's shape with the captured one and refuses a
different shape with `InvalidArgument`. The error names the dim and both
sizes. A captured graph only works for the input shapes of the capture
call, and the code comment on the graph phases now says so.
- When a capture fails, the backend runs the same call again without a
graph and returns its result. Graphs stay off for that method, as
before. The retry is safe because a capture records kernels without
running them, and it happens only if the new graph never started
running. The `.item()` shims now refuse to read inside a capture, as the
mask index does, so a capture that reads a device value is retried too
instead of ending the process.
- A method whose program keeps a constant in host memory, such as a
`torch.cond` flag updated on every call, now runs without a graph. Host
code runs only during the capture, so a graph would keep the branch of
the capture call, and a retry after a failed capture would take the
other one. The log names the constant found in host memory, or says the
constants could not be listed.
- An expected capture failure, such as the mask index refusing to run
inside a capture, is logged at Info instead of Error, since the call
then succeeds. A real failure of the call without a graph is still
logged at Error. AOTInductor's generated code still prints its own line
for the refused call.
- The allocations a captured graph makes are recorded only after its
first launch succeeds. Before, a failed first launch left addresses that
were never allocated, and a later cleanup passed them to `cudaFree`. A
failed capture now cleans up with `CudaGraphState::release()` instead of
a copy of it.
- These CUDA graph changes do not change the exported program, so older
programs get them too. A program that uses the new shims described below
(scatter, scatter_reduce, float64 or float16) needs a runtime that has
them.

Smaller commits:

- The CUDA random shims seed from `std::random_device` instead of the
clock, so two processes started in the same second no longer draw the
same numbers. If `std::random_device` throws, or the copy of the seed to
the GPU fails, the shim logs an error and frees what it allocated, and
the next call tries again.
- The wheel's shared library check now lists the mask index and
`index_put` shims.
- With PyTorch 2.14, a program that writes a one-element buffer, such as
a counter, kept returning its first update, because Inductor folded the
update into a constant. The AOTI backends (CUDA and Metal) now turn off
Inductor's joint-graph constant folding, for the whole program, when a
program writes a buffer. On a small Gemma 4 decoder that writes its KV
cache, folding changes two nodes per graph; with it off, export and
decode times stayed within run-to-run noise and the generated tokens
were identical.
- Unit tests for `aoti_torch_get_size`.
- Comments on the index shims and on `set_output_data_ptr` that
described accumulation, the sort count limit and the output index
wrongly are corrected.
- The mask index tests move to a new `test_index_shims.py`, since they
need the Python runtime. They add a 2-D mask, and `index_put` with
repeated indices in deterministic mode (half and bfloat16 accumulation,
last write wins, an int32 index).
- `test_output_shape_changes_at_capture` ends with a call that only a
live graph rejects, so it fails if graphs are off. The KV cache
recapture test checks that dropping the graph also clears the captured
input shapes.

## Follow-ups to #24314

- Delete `test_align_corners_compiles_with_dynamic_sizes`. It could not
fail: it passed with no resize rewrite at all and with the min and max
in the align_corners ratio replaced by a plain `if`, because
`torch.compile` compiles again for an output size of 1. With torch 2.14
and `CI` set it also failed for an unrelated reason, and it is the one
failing test of the CUDA unit test job on main.
`test_bicubic_antialias_resize` already runs an exported program at an
output height of 1, where a plain `if` breaks.
- The comment on that min and max now gives the real reason: an exported
program keeps the branch it was traced with, so a plain `if` divides by
zero at an output size of 1.
- A backend that keeps its data in the `.pte`, such as Metal, now
refuses a `data_name` compile spec built by hand too, before compiling
anything. Before, only `generate_data_name_compile_spec` refused it, and
a hand-built spec was ignored with no name check.
- The CUDA docs warn that Windows and macOS usually treat data names
that differ only in case as the same file.

## Copies through a boolean mask no longer read the mask count back to
the host

`out[mask] = src[mask]` ran as a mask index and a mask `index_put`. Each
of them read the mask's count back to the host and waited for the GPU,
on every call. Transformers' SmolVLM vision embeddings do this for their
position ids.

When the values are the same mask's picks from a tensor shaped like the
destination, every element goes back to where it came from. So a new
pass in the CUDA backend writes the copy as `torch.where(mask, src,
out)`, which gives the same result, bit for bit. It does so inside
`torch.cond` branches and other subgraphs too. It rewrites only when
`where` would lay out its result like the destination, since `where`
takes its layout from its inputs, the mask first, while `index_put`
keeps the destination's layout. It also drops the index and the checks
on its size when nothing else reads them and each check tests only the
index's count and holds for any mask. A user check on the count, such as
`torch._check(x[mask].shape[0] >= 1)`, keeps the index. The pass
compares the two masks with a work list, so masks with long histories do
not hit Python's recursion limit. On an A100, five calls of the SmolVLM
position id code went from 10 count reads and 10 stream waits to none.

## Tensor checks no longer stop the CUDA export

The argument checks in `torch.distributions` export as
`aten._assert_async.msg`. The CUDA backend compiled each one into a
device assert inside a Triton kernel. While compiling, AOTInductor runs
every kernel once to pick its launch settings, on made-up buffers:
random floats, and zeros for integers and bools. So a check that reads a
bool from an earlier kernel saw false and failed, even though it holds
for every real input. The export then stopped with "device-side assert
triggered". A Gaussian policy head, `MultivariateNormal(mean,
scale_tril=...)` with 14 actions, hits this.

A new pass in the CUDA backend now removes these checks before
compiling, so the CUDA delegate no longer runs them. The rest of
ExecuTorch already removes the same checks before a program is saved. A
failed device assert could not come back as an error anyway: it breaks
the CUDA context for the rest of the process. Checks on sizes
(`_assert_scalar`) stay, since they run on the host.

## float64 weights load in the CUDA backend

A program with a float64 weight lowered and saved, then failed to load
with `Malformed CUDA FQN weight metadata`. This happened with a float64
buffer, and with float64 math that constant propagation folds into one
constant, such as a sinusoidal time embedding. The export wrote the
weight correctly, but the runtime had no float64 dtype: the weight cache
refused it, and the runtime's tensor type and dtype shims had none
either.

- The runtime's tensor type now has `Double`, with its 8 byte size. The
same type wraps a method's inputs and outputs, so a float64 input or
output works too.
- The shims export `aoti_torch_dtype_float64`, which compiled code calls
to name the dtype of a float64 constant.
- The weight cache accepts `Double`, and uses 8 bytes when it checks
that a weight fits its storage.
- The shims that still refused float64 accept it: a float64 `.item()`
(`aoti_torch_item_float64`, which did not exist, so the first execute
ended the process), the single-element read those entry points share,
`index_put` with accumulate, and the sort shim. A float16 `.item()` had
the same problem with `aoti_torch_item_float16` and works now too.
- A float read with `.item()` reached Triton kernels truncated to an
integer, in float32 too, so a scale of 1.5 computed as 1 with no error.
AOTInductor declares every symbolic argument of a kernel's launcher
`int64_t`. A compile-time patch in the CUDA backend now declares it with
the float type the kernel takes, the change that the open
pytorch/pytorch#201882 makes in PyTorch.

## `aten._reshape_copy` lowers to the CUDA backend

Torch-TensorRT turns `aten.view` into `aten._reshape_copy`. A view that
TensorRT leaves to PyTorch reaches the CUDA backend in that form, and
the export failed with `missing fallback kernels: aten::_reshape_copy`,
since Inductor has no lowering for it and torch registers no
decomposition. The CUDA backend now decomposes it the way ATen computes
it: a contiguous copy, viewed to the new shape.

## `varlen_attn` lowers to the CUDA backend

`torch.nn.attention.varlen.varlen_attn` runs attention over several
sequences packed into one tensor. Lowering it failed with `missing
fallback kernels: torch_attn::_varlen_attn`, since it calls Flash or
cuDNN attention, which this backend does not ship. The CUDA backend now
decomposes it into ops Inductor lowers: attention over all the packed
tokens, with a mask that keeps each query in its own sequence. The
output is contiguous, like the op's output for a query in the usual
(tokens, heads, dim) order, so reshaping it before the output projection
works.

The decomposition matches the op for causal masks, sliding windows,
grouped-query attention, and query and key sequences of different
lengths. For a query sequence longer than `max_k + 1`, Flash attention 2
(PyTorch's default) and Flash attention 3 give different results for
some windows, because version 2 changes the window based on `max_k`. So
a window other than `(left, 0)` lowers only when `max_q <= max_k + 1` is
known at export time, as in self-attention, and raises a clear error
otherwise. `seqused_k` and `block_table` (paged KV) raise a clear error
too. It computes the weights with a softmax, which subtracts each row's
largest score first, so the weights still add up to one when scores are
large. Tokens before the first sequence or past the last one, such as
the padding of a fixed-size buffer, do not change any sequence's output,
even when they hold NaN. Unlike in Flash attention, a NaN or inf in one
sequence's values does reach the other sequences. It holds a float32
score per head for every pair of packed tokens and runs much slower than
Flash attention, so it suits packs of a few thousand tokens, such as a
few camera images.

## `scatter_reduce` with amax, amin, prod and mean lowers to the CUDA
backend

Lowering `x.scatter_reduce(dim, index, src, "amax")` failed with
`missing fallback kernels: aoti_torch_cuda_scatter_reduce_two_out`, and
so did "amin", "prod" and "mean". Inductor writes a scatter itself only
when it can add atomically, and leaves the other reductions to an ATen
kernel that this backend did not ship. A sum into int64 or bool, a sum
into bfloat16 on GPUs older than sm_90, and deterministic mode use the
same kernel.

- A new shim next to the index shims implements it the way ATen does:
`include_self`, elements the index does not name, the count and integer
rounding of the mean, NaN in amax and amin, and bool for sum only. Half
and bfloat16 sums add in float, as ATen does in deterministic mode. A
product rounds to the tensor's dtype at every step and a mean rounds its
sum before dividing, as ATen does, so half overflows and integers wrap
where ATen's do. A mean also counts its terms in the tensor's dtype, as
ATen does, so 128 int8 ones average to 1.
- It sorts the writes by the element they land on and reduces each
element's values in index order, so the result is deterministic. It also
works inside a CUDA graph. The sort is now one helper that `index_put`
uses too.
- It is slower than ATen's atomic kernel: 1.5 to 3.2 times on an A100 at
512 output rows, 4096 source rows and 1024 columns, and up to 7 times
when many values land on few elements. A decomposition into ops Inductor
lowers natively was measured first. It has to compare every output
element with every source element, and it was 8 to 89 times slower than
ATen's kernel.
- `aoti_cuda_shims.lib` and the wheel's shared library check list the
new shim. The import library now also lists the shims the DLL exported
but it left out, such as `aoti_torch_dtype_float16` and the
`aoti_torch_item_` getters, so a float16 model lowered for Windows
links.

## CUDA programs load without torch on machines with no libgomp

On Linux, Inductor adds OpenMP (`-fopenmp`, `-lgomp`) to every link.
Unless the compiler passes `--as-needed` to the linker, as Ubuntu's GCC
does by default, the compiled library lists `libgomp.so.1` as a
dependency even when it calls no OpenMP function. PyTorch ships a
libgomp, so this went unnoticed while torch was imported. A process
without torch failed to load the program with `libgomp.so.1: cannot open
shared object file` on any machine with no system libgomp, such as a
minimal container.

The CUDA backend now links with `-Wl,--as-needed`, so the linker keeps
only the libraries the program uses. A program that does call OpenMP
keeps libgomp. PyTorch already does the same for MPS builds on macOS,
and pytorch/pytorch#202902 adds it to Inductor for Linux. The torch-free
tests in `test_pybindings_cuda_dlpack.py` now run without a system
libgomp, and the ones that check which libraries got loaded also check
for libgomp. On an A100, a small MLP and CNN exported with this change
load without torch and match eager where the loader cannot find libgomp.

This patch, and the one that lets Triton templates run on GPUs with few
SMs, now stay in place while any thread lowers. Before, when two threads
lowered at once, the first one to finish removed the patch from the
other, and the rest of that compile ran without it.

## A failed CUDA load no longer leaves its library in the temporary
directory

To load a method, the CUDA backend writes the program's compiled library
to a temporary file, such as `/tmp/executorch_cuda_<pid>_0.so`. Only
`destroy()` removed that file, and `destroy()` never runs when the load
fails, so every load that failed after writing the file left it behind.
A scope guard now removes the file unless the load succeeds.
`test_missing_weights_blob.py` now checks that a load that fails for a
missing weights blob leaves nothing in the temporary directory. Without
the fix, that check fails.

The XNNPACK high-rank permute fix moved to #24380.

## The Triton SDPA leaves keys past the end out of the softmax

With `triton_kernel_mode` on (the default), the CUDA backend runs
`scaled_dot_product_attention` as its Triton kernel, `triton::sdpa`. For
power-of-two head sizes, its main kernel reads keys in tiles of 32, 64
or 128. When the number of keys is not a multiple of the tile, the last
tile reaches past the last key. The kernel loaded those positions as
zeros but never removed them from the softmax, so without an attention
mask or a causal mask each one got a score of 0 and took part of the
attention weight. Calls that pass `kv_len` with neither a mask nor
`is_causal`, such as the DFlash draft model's global attention layers,
had the same problem. On an A100, with the query at 0 and the values at
1, where the right output is 1, one key gave 0.031, 33 keys gave 0.516
and 80 keys gave 0.625. With random inputs and one key, the output was
off by 3.4 when the largest value was 3.67. All of these now match eager
PyTorch.

The kernel now gives every position past the last key, or past `kv_len`,
a score of minus infinity, as the other attention kernels in `sdpa.py`
already do. The first version of the kernel did this, and a later
rewrite dropped it. Checking every tile cost up to 8% where no tile
reaches past the end, so only calls that need the check pay for it. A
mask or a causal bound already drops those keys, so calls with one skip
it. The launcher also passes the largest tile size that divides the
number of keys, and tiles that divide it skip the check at compile time.
So calls with a mask or a causal bound, and tiles that divide a key
count known at export time, compile to the same code as before. Without
a mask or a causal bound, a `kv_len` or a symbolic key count checks
every tile. `test_triton_sdpa.py` drops its note that called this a
kernel limitation, runs its basic test with a single key, and runs every
tile without a mask over 80 and 96 keys and over a `kv_len` of 80. These
fail without the fix.

## `scatter` with a tensor source lowers to the CUDA backend

Lowering `x.scatter(dim, index, src)` failed with a `missing fallback
kernels` error that names `aoti_torch_cuda_scatter_src_out` when `src`
is bfloat16, int64 or bool. Inductor writes a scatter from a tensor
itself only in a dtype it can add atomically, so it leaves int64, bool
and, on GPUs older than sm_90, bfloat16 to an ATen kernel, and every
dtype in deterministic mode. A mixture-of-experts layer that writes its
expert outputs back with `torch.zeros_like(h).scatter(1, index, src)` in
bfloat16 hit this on an A100.

A new shim next to the `scatter_reduce` one implements it with the same
checks, the same sorting of the writes by the element they land on, and
the same kernel. The kernel gets a new mode in which only the last write
to each element stores its value. So when values land on the same
element, the last one in index order wins, as ATen does in deterministic
mode; ATen's default CUDA kernel keeps any one of them. As in that
default kernel, an index out of range, negative ones included, triggers
a device-side assert. In deterministic mode, ATen's scatter counts a
negative index from the end instead. The shim takes float, double, half,
bfloat16, the integer dtypes and bool. Like ATen, both scatter shims now
take an int32 index as well as an int64 one. Mostly because of the sort,
the shim is about 10 to 15 times slower than ATen's default kernel for a
mixture-of-experts style bfloat16 scatter of 4 to 34 million values on
an A100. The Windows import library `aoti_cuda_shims.lib` and the
wheel's shared library check now list the new shim. On an A100 with
PyTorch 2.14 and 2.15, scatters in float32, float16, bfloat16, int64 and
bool lower, run and match eager PyTorch exactly. So does a small
mixture-of-experts layer built the same way.
`test_scatter_with_tensor_source` fails without the fix, and its int32
index case fails when the shims take only int64.

## Review order

Start with the CUDA graph changes: `cuda_backend.cpp` and
`cuda_delegate_handle.h` in `backends/cuda/runtime`, the mask and
`.item()` shims in `runtime/shims/index.cu` and `memory.cpp`, and their
tests in `test_output_stride_rearrange.py`. Then the lowering side: the
passes in `backends/cuda/passes`, the decompositions and compile-time
patches in `backends/cuda/cuda_backend.py`, and the folding switch in
`backends/aoti/aoti_backend.py`. Then the scatter and scatter_reduce
shims in `index.cu` with `test_index_shims.py`. The remaining commits
are small follow-ups that stand on their own.

## Testing

On an A100, with the CUDA backend built from this branch:

- `test_index_shims.py`, `test_output_stride_rearrange.py`,
`test_cuda_export.py`, `test_antialiased_resize.py`,
`test_merge_ptes.py` and `test_cuda_weight_metadata.py` pass.
- With the backend built from main, the new CUDA graph tests fail.
- The replay error names the dim, for example "input 0 has size 4 in dim
1, captured with 3".
- `test_cuda_kv_cache` passes, and fails when `release()` keeps the
captured input shapes.
- With the min and max replaced by a plain `if`,
`test_bicubic_antialias_resize` fails.
- With the new data name refusal removed, or moved back behind the
compile, the data name test fails.
- A `torch.cond` on a host-memory flag matches eager on every call; with
a graph it returned the captured branch.
- A random program run from two processes started together drew the same
numbers in 2 of 3 tries before the seed change, and different ones in 3
of 3 after.
- Mask index programs exported from this branch match eager PyTorch on
every call, both on a runtime built from main with CUDA graphs off and
on this branch's runtime with CUDA graphs on.
- The shared shim unit tests pass in a standalone googletest build.
- `test_replace_masked_copy_with_where.py` passes, and fails when the
pass does nothing, is not registered, or skips its accumulate,
same-mask, boolean mask, shared picks or count check. It writes into a
destination filled with 7, so it fails if `where` loses the values the
mask does not pick. A contiguous destination with a channels_last
source, or with a channels_last mask, keeps its `index_put`; before, the
rewrite failed to lower when a later `view(-1)` needed the destination's
layout. It also fails when the pass walks only the top-level graph,
since a masked copy in a `torch.cond` branch then keeps its index, and
when it takes two draws of a random mask for the same mask. Two masks
computed with over a thousand ops each raised RecursionError before. A
check that reads the index's count but tests another size keeps the
index; before, the pass removed it.
- A masked copy lowered through the CUDA partitioner matches eager for
masks that pick some, none and all positions. The test records the graph
the pass returns, and fails when the pass is not registered or leaves
the index in place.
- A module that adds one to a device buffer on every call and reads a
mask's count returns the call number on every call, so the capture call
that runs again without a graph updates the buffer once. With the retry
made to run the call twice, the capture call returns one too many. With
PyTorch 2.14 it returned one on every call even without a graph, because
Inductor folded the written buffer into a fill; it passes now with 2.14
and with 2.15 and 2.16 nightlies.
- On the tiny Gemma 4 decoder from the Gemma 4 31B example's tests,
which writes a KV cache, with PyTorch 2.14 on an A100: with the folding
off, export took 193 s against 190.5 s with it on, a decode call took
945 µs against 1019 µs (medians of three exports and three runs on a
busy machine), and the 41 generated tokens were the same.
- The mask tests check that the runtime log reports one failed capture,
so they fail when CUDA graphs are off or a failed capture is tried
again. `test_host_read_runs_without_a_graph` also runs a module that
scales by a `.item()` read; without the capture check in the item shims,
the process aborts.
- With the debug check on the phase before the retry turned into a
release check, the mask tests pass.
- `test_distribution_argument_check` passes with PyTorch 2.14 and fails
with "device-side assert triggered" without the new pass. It also checks
that the backend lists the pass, since without the pass the export fails
only when autotuning happens to trip the assert. It writes the
`MultivariateNormal` check as `torch._assert_async`, since PyTorch 2.14
cannot export the distribution itself. It also checks that the pass
drops the check and the math that only fed it. A Gaussian policy head
with hidden sizes 16, 64 and 256 exports, runs, and matches eager
PyTorch.
- A float64 buffer and a folded float64 constant load and match eager
PyTorch. Before, both failed to load. `test_float64_weight_loads` fails
without the fix.
- `test_reshape_copy` in `test_cuda_export.py` fails without the fix.
The one in `test_index_shims.py` runs `_reshape_copy` on a transposed, a
sliced and a contiguous input and matches eager PyTorch.
- `test_varlen_attn` in `test_cuda_export.py` fails without the fix. It
lowers a program that flattens the output, compares the decomposition
with the op on CUDA only (on ROCm, varlen_attn runs AOTriton, which
lines an explicit window up with the start of each sequence, while the
decomposition follows Flash attention on CUDA, which lines it up with
the end), including a window in self-attention and NaN values outside
the key sequences, and checks that a window the Flash attention versions
disagree on raises. It also fails when the output is left
non-contiguous, when the window check is removed or refuses every such
window, and when the values outside the key sequences are not zeroed.
Both `test_varlen_attn` tests include queries and keys filled with 1024,
where every key gets the same weight; computing the weights as
`exp(scores - logsumexp)` fails them, since at that size float32 rounds
the log-sum-exp to a whole number and the weights no longer add up to
one. The `test_varlen_attn` in `test_index_shims.py` lowers and runs
programs that call `varlen_attn` with no window, a causal mask and the
windows (8, 0) and (4, 2), flatten each output, and take `cu_seq_q` and
`cu_seq_k` only at run time. They match eager PyTorch.
- `test_missing_weights_blob.py`, `test_cuda_weight_reuse.py` and
`test_device_placement.py` pass.
- `test_scatter_reduce` now runs the sum too, which for int64 goes to
the new shim. The test that a missing scatter kernel fails at export now
tries to lower a scatter of a scalar in deterministic mode. The backend
still lacks that kernel, so the test still checks the scatter fallback
now that the scatter_reduce and scatter shims exist. It fails when the
check stops watching that fallback.
`test_scatter_mean_counts_in_its_dtype` fails with the old count and
when a count that wraps to zero is not turned into one.
`test_float64_and_float16_values` (a sort and `.item()` in each dtype)
and the float64 masked accumulate fail with the old shims, and all pass
now. The `.item()` scale is 1.5, so that test also fails without the
patch that passes a float to Triton kernels as a float.
- `test_scatter_reduce` and `test_sort_shim.py` pass.
`test_scatter_reduce` fails without the fix, and also when the shim
ignores `include_self`, rounds the integer mean toward zero, or lets a
NaN lose in amax and amin. `test_scatter_strides` scatters along the
middle dim of a 3-D tensor, given as a negative dim, from a strided
source and from transposed operands, and fails when the shims walk only
the last two dims. `test_scatter_reduce_edge_cases` covers a bool sum,
int32 and half products that wrap and overflow, and an empty index.
`test_scatter_reduce_sum_adds_in_float` sums 1000 half and bfloat16
values in deterministic mode, and fails when the sum rounds to the dtype
at every step. `test_scatter_reduce_replays_in_a_graph` captures a
`scatter_reduce` program in a CUDA graph and matches eager on every
replay.
- The weight cache, runtime tensor and shim unit tests pass in a
standalone googletest build. Without the fix, the weight cache test of a
float64 entry fails.

- `test_small_gpu_override_survives_a_thread_that_leaves_first` overlaps
two threads inside the small-GPU patch, and fails with the old save and
restore.

- With the seed copy made to fail once, the random shim logs it, fails
that call, and seeds on the next one; before, it went on with an
unseeded state.

Not tested: a failed first graph launch (it cannot be triggered from a
test), a `std::random_device` that throws, Windows, macOS
case-insensitive file systems, the buffer folding change on Metal, and
the ROCm build. No test covers the seed change, since a test cannot make
two processes start in the same second.








### Checked on real robot policies

On the release/1.6 stack with this change, on A100 and H100:
- SmolVLA on the CUDA backend: one device-to-host copy per action chunk
(only the action download), matching eager within the usual tolerance.
- A Gaussian policy whose `torch.distributions` check used to stop the
CUDA export: exports and runs, 0.76 ms per chunk.
- A float64 weight that used to fail at load with "Malformed CUDA FQN
weight metadata": loads and runs, max difference 0.
- Four processes started together: four different random draws, against
two out of four before.

---------

Co-authored-by: Anthony Shoumikhin <anthony@shoumikh.in>
Co-authored-by: Anthony Shoumikhin <shoumikhin@meta.com>
shoumikhin added a commit to shoumikhin/lerobot that referenced this pull request Nov 3, 2026
…d drive a robot with the bundle

The export tests compared bundles with their PyTorch policies, mostly
with the PyTorch test backend. Nothing exported the main policies with
every backend that ExecuTorch runs, checked where their programs run, or
ran `lerobot-rollout --bundle.path` on a bundle that a real compiler
wrote.

`tests/export/test_export_backends.py` exports tiny ACT, Diffusion
Policy, pi0, pi0.5, SmolVLA and GR00T checkpoints with
`executorch_xnnpack`, `executorch_cuda` and `executorch_tensorrt`. The
export itself only writes a bundle that reproduces the PyTorch policy's
test case within the bundle's tolerance. Then, for each bundle:

- Each program runs where its backend runs it. XNNPACK bundles hold only
  `XnnpackBackend` delegates. CUDA bundles hold only `CudaBackend`
  delegates, and TensorRT bundles `TensorRTBackend` and possibly
  `CudaBackend` ones, with no operator left on the CPU.
- `lerobot-rollout --bundle.path` drives a recording robot for at least
  12 actions, which takes several chunks, in a process that never loads
  PyTorch.

The recording robot can now show a bundle's test case: with
`--robot.frames=<test case file>` it has a camera for each image of the
test case, so a bundle of a policy with cameras can drive it.

Each backend takes its skip mark from `tests/export/backends.py`, so a
row that a released ExecuTorch or Torch-TensorRT cannot run yet skips
and says which fix it needs.

Two kinds of row fail on every wheel released so far, so they skip up to
the version with the bug and run again on a newer one:

- GR00T with XNNPACK: ExecuTorch 1.6.0's XNNPACK cannot load GR00T's
  vision program, which permutes an 8-D tensor (pytorch/executorch#24380).
- pi0 and pi0.5 with TensorRT: TensorRT 11.4.0.106 cannot build an engine
  for the tiny policies' concatenations. The full-size policies build.

Tested: see the following commit, which runs these tests in CI.
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. release notes: xnnpack Changes to the XNNPack backend delegate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants