Repository navigation
Keep ops above 6 dims out of XNNPACK and fix quantized ops it leaves out - #24380
Conversation
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.
🔗 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 FailuresAs of commit a86580f with merge base aed328d ( This comment was automatically generated by Dr. CI and updates every 15 minutes. |
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.
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 20m 37s —— View job PR Review: #24380No blockers. The main problems are that the new Detailed reviewSummaryThe 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
Performance
RecommendationApprove Nothing blocks. Fixing which nodes |
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.
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.
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.
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.
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.
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 25m 26s —— View job PR Review: #24380No 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 reviewSummaryThe 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 Testing
Performance
RecommendationApprove Nothing blocks merging. Passing |
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.
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 26m 8s —— View job PR Review: #24380I 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 reviewSummaryThe 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 RecommendationApprove Nothing blocks merging. |
|
@pytorchbot cherry-pick --onto release/1.6 -c critical |
Cherry picking #24380The cherry pick PR is at #24445 and it is recommended to link a critical cherry pick PR with an issue. Details for Dev Infra teamRaised by workflow job |
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.
## 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.
… 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.
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>
…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.
… 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>
…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.
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:
Some vision models hit this in a "patchify" step that cuts an image into patches:
With the 3-D convolution kernel from #24385, a statically quantized
Conv3dis 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 quantizedConv2dorLinearwhose 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_linearno longer changes how a quantized linear is partitioned. With it, a quantized linear decomposed into a matrix multiply (for example withto_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.pychecks 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.to_backendwithforce_non_static_weights_for_f32_linear, and 3-D convolutions that the quantizer leaves unquantized.