Skip to content

CUDA backend: per-program weights file names and antialiased resize - #24314

Merged
shoumikhin merged 2 commits into
pytorch:mainfrom
shoumikhin:cuda-names-resize
Oct 30, 2026
Merged

shoumikhin merged 2 commits into
pytorch:mainfrom
shoumikhin:cuda-names-resize

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

This change fixes several problems in the CUDA backend. Each fix is its own commit.

Name the weights file of each program

The backend saves every program's weights to aoti_cuda_blob.ptd. Exporting several programs into one folder overwrites the weights, and the earlier programs then fail to load. Renaming each file after export works, but is easy to miss. A new option names the file:

partitioner = CudaPartitioner([
    CudaBackend.generate_method_name_compile_spec("forward"),
    CudaBackend.generate_data_name_compile_spec("encoder"),
])  # writes encoder.ptd

Without the option nothing changes. The name is checked: up to 200 ASCII letters, digits, _, - and ., not starting with . or -, not ending with .ptd, and not a Windows device name such as NUL. All methods of a program must share it. A backend that keeps its data inside the .pte, such as Metal, refuses it.

Lower antialiased resizes

F.interpolate(x, mode="bilinear" or "bicubic", antialias=True) failed to lower with "missing fallback kernels". The backend now rewrites it into plain tensor math, with PyTorch's window and weight formulas, for dynamic sizes and float32, float16 and bfloat16. The results are not bit-identical: in float32 they stay within about 2e-5 of PyTorch's kernel for inputs between 0 and 1.

Tests

Ran on an A100 GPU (Linux x86_64): test_cuda_export.py, test_antialiased_resize.py, test_missing_weights_blob.py, test_merge_ptes.py and test_cuda_weight_metadata.py. Each test added for a fix fails when that fix is removed.

Copilot AI balanced review requested due to automatic review settings October 29, 2026 23:30
@pytorch-bot

pytorch-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

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

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

❌ 3 Cancelled Jobs, 1 Unrelated Failure

As of commit c46538e with merge base e62fd44 (image):

CANCELLED JOBS - The following jobs were cancelled. Please retry:

FLAKY - The following job failed but was likely due to flakiness present on trunk:

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

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 29, 2026

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.

Copilot AI balanced review requested due to automatic review settings October 30, 2026 00:40

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.

Copilot AI balanced review requested due to automatic review settings October 30, 2026 02:31

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.

```
This generates `.pte` and `.ptd` files that can be executed on CUDA devices.

The weights go to a data file named `aoti_cuda_blob.ptd`. To export several programs into one folder, give each program its own

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.

Its tech debt that we use .ptd at all we should just use the named data map inside the .pte.

Copilot AI balanced review requested due to automatic review settings October 30, 2026 02: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.

The CUDA backend writes every program's weights to a file named
aoti_cuda_blob.ptd. Exporting two programs into one folder therefore
makes the second overwrite the first one's weights. Since the weights
are keyed by their content, the first program then fails to load
instead of running with the wrong weights. Renaming each file right
after its export works, because the program does not store the file
name, but it is an extra step that is easy to miss.

Add a data name option. A program lowered with
CudaPartitioner([..., CudaBackend.generate_data_name_compile_spec("encoder")])
writes its weights to encoder.ptd. Without the option the file is still
aoti_cuda_blob.ptd, so existing exports, scripts and runners are
unchanged, and existing files load as before.

The name becomes a file name, so it may use only up to 200 ASCII
letters, digits, '_', '-' and '.'. It may not start with '.' or '-'
(a leading '-' reads as an option, for example in merge_ptes
--input-ptd), may not be a Windows device name such as NUL, and may not
end with '.ptd'. One of the two data file writers adds '.ptd' only when
it is missing, so "encoder" and "encoder.ptd" would write the same
file. The check also runs where the name is used, so a hand-written
compile spec cannot point outside the output folder. All methods of one
program share one weights file, so they must use the same name. A
backend that keeps its data in the .pte, such as Metal, refuses the
option instead of ignoring it. merge_ptes ignores the name when it
compares variants, like cuda_include_ptx, so variants exported with
different names still merge.

The CUDA backend docs describe the option, its rules, and how to load
a program with its data file. The TensorRT page no longer says the
name is fixed or that a clash gives wrong answers, and its tip on
sharing the CUDA data file with the engines covers a custom name.

A new test in test_missing_weights_blob.py exports two programs with
their own names, and one with no constants, into one folder, checks
the data file names, then loads and runs the first two against eager
PyTorch. test_cuda_export.py checks the name rules, including
non-ASCII letters, path separators, a leading '-' and a '.ptd' ending,
checks that good names and the default are accepted, that a backend
which keeps its data in the .pte refuses a name, and the one name per
program rule. The existing merge test now merges variants with
different names.
Copilot AI balanced review requested due to automatic review settings October 30, 2026 03:15

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.

Copilot AI balanced review requested due to automatic review settings October 30, 2026 03:22

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.

Copilot AI balanced review requested due to automatic review settings October 30, 2026 03: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.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 32m 19s —— View job


Needs discussion: I found no problems in the per-program weights file names or the antialiased-resize rewrite, but the new data_dependent_sizes flag from commit 2448940 (missing from the PR description) stops older runtimes from loading new programs with data-dependent sizes, and it still misses a mask index_put.

Detailed review

PR Review: #24314

Summary

The PR adds a data_name compile spec so each CUDA program can name its own .ptd file. It also rewrites antialiased bilinear and bicubic resizes into plain tensor math for Inductor. Two commits that the description doesn't cover (2448940 and 3aec81d) add a metadata bit that makes the runtime skip CUDA graphs for methods with data-dependent sizes.

The data-name and resize changes raised no findings. The metadata bit stops older runtimes from loading these programs, and it misses some methods it is meant to cover.

Three independent reviewers read all 16 files, and I re-checked each finding below against the code. Nothing was run, because this job has no GPU and could not run Python.

Backward Compatibility

  • Older runtimes reject new programs that have data-dependent sizes, even when CUDA graphs are never used. backends/cuda/cuda_backend.py:798-801 sets bit 1 of the variant flags for any method with an unbacked size, such as a mask index or nonzero.

    • The failure: a runtime built before this PR fails such a program in init, because CudaWeightCache::parse checks (flags & ~1U) != 0. It logs "Malformed CUDA FQN weight metadata" and returns InvalidProgram. Nothing tells the user that the program is newer than the runtime.
    • Why it seems avoidable: the bit is only a hint, since a runtime that ignores it behaves as it does today. But it sits in a field that older runtimes must fully understand. CUDA graphs are a runtime opt-in (enable_cuda_graph_for_method), so the break also hits users who never turn them on.
    • Not surfaced: the 2448940 commit message accepts the break. The PR title and description don't mention it, and the PR is labeled "release notes: backends".
    • Old programs: programs exported before this PR still fail their capture call on the new runtime.

    Two options to discuss:

    • Put the hint somewhere older runtimes ignore, such as a per-partition compile spec set by the partitioner. AotiPartitioner already gives each tag a DelegationSpec, and init skips unknown keys (backends/cuda/runtime/cuda_backend.cpp:399-411).
    • Handle it at runtime: when a capture fails, run that same call without a graph, then keep graphs off (CaptureGuard already turns them off). This needs no format change, and it also covers older programs and the misses below. I have not checked that re-running a call after a failed capture is safe.

    If the break stays, please call it out in the description and the release notes.

Code Quality

  • The check misses methods that still fail their capture call. backends/cuda/cuda_backend.py:798 only looks for unbacked symbols in the meta["val"] of top-level nodes.

    • Mask index_put: an index_put with a bool or uint8 mask returns a tensor the size of self, so it has no unbacked symbol. Inductor still sends it to aoti_torch_index_put_out, which reaches mask_positions through plan_indexing (backends/cuda/runtime/shims/index.cu:974, then :463). mask_positions refuses to run inside a capture (index.cu:336-340). With CUDA graphs requested, the Accumulate model in test_index_shims.py:105-114 should reproduce this; I have not run it.
    • torch.cond branches: walking only edge_program.graph.nodes skips the branches, which the CUDA backend lowers, so a mask index inside a branch is missed.
    • Tests: test_data_dependent_sizes_run_without_a_graph covers only x[mask].

    Fix this →

  • Low confidence: the scan may crash preprocess. As far as I know, torch's free_unbacked_symbols raises AssertionError on values it cannot walk. One example is the dict in a triton_kernel_wrapper_functional node's meta["val"], which appears when a model calls wrap_triton directly. I could not read the torch sources here to confirm this. Before this PR, nothing walked these values. Passing only the tensor and SymInt/SymFloat/SymBool leaves of pytree.tree_leaves(val) avoids the crash. Fix this →

  • The PR description is out of date. It covers only the first two commits and leaves out three things: the new metadata bit, the runtime change in cuda_backend.cpp, and the new test_index_shims.py. It also says the resize tests are in test_cuda_export.py, but they are in the new test_antialiased_resize.py. Please update it, since the compatibility change above is in the commits it leaves out.

Recommendation

Needs Discussion

Before merging, decide where the data_dependent_sizes hint should live: the current export-time flag, a field older runtimes ignore, or a runtime fallback. Also update the description. Moving 2448940 to its own PR would let the data-name and resize commits land first.

What was done: three independent reviewers read all 16 changed files, and I fact-checked each finding against the code. What was not done: nothing was built or run (this job has no GPU and could not run Python), and no code was changed or pushed.

F.interpolate(x, mode="bilinear" or "bicubic", antialias=True) becomes
aten._upsample_bilinear2d_aa or aten._upsample_bicubic2d_aa. Inductor
has no lowering for either, so it calls ATen's kernel, and AOTInductor
has no C shim for them. Lowering to the CUDA backend failed with
"missing fallback kernels". Image preprocessing often does this.

The backend already rewrites ops like this in get_decomposition_table
(conv1d as conv2d, for example), so this adds the bilinear and bicubic
antialiased resizes there, for float32, float16 and bfloat16 inputs.
The backend has no float64, and ATen's CUDA kernel has no integer
version.

An antialiased resize is separable, so the rewrite resizes the width,
then the height. Along each, it computes every output's window and
weights with ATen's formulas: the filter widened by the downscale
factor, cut to its support, and normalized. Then it sums the window's
inputs with those weights. Taps outside the window have zero weight and
are masked out before the sum, so a NaN or inf reaches only the outputs
whose window holds it, and the work grows with the window size, not the
input size. Where a window edge falls exactly on an input, float
rounding can put that input in or out of the window differently from
ATen; its weight there is zero or close to it.

Symbolic shape math keeps dynamic input and output sizes working. The
number of taps per output is a shape, so it is computed with integers:
in float32 the generated kernel can round a ratio such as 21 / 7 just
above 3 and count more taps than the host allocated. For align_corners,
the ratio for an output size of 1 comes from min and max, not a
conditional, because torch.compile cannot handle a conditional size
expression. The result keeps the input's memory format. The .vec
overloads reuse torch's decomposition.

The rewrite computes positions and weights in float32 with its own
rounding, so its float32 results are not bit-identical to ATen's
kernel: for inputs in [0, 1) they stayed within about 2e-5 of it on the sizes
tried. Half and bfloat16 inputs are computed in float and rounded once
at the end, so they can differ slightly from ATen, which keeps its
weights in the input type.

The tests are in a new file, test_antialiased_resize.py, because they
run programs through the Python runtime. They lower bilinear and
bicubic resizes whose input and output heights are dynamic, run them
for heights that shrink, grow, go to 1, and have whole ratios, with a
NaN in the input, and compare with eager CUDA. Other cases cover an
uneven scale_factor, float16 and bfloat16 with a channels_last input,
the rewrite's memory format, and torch.compile of an align_corners
resize with dynamic sizes.
Copilot AI balanced review requested due to automatic review settings October 30, 2026 03:35
@shoumikhin
shoumikhin merged commit 7586c16 into pytorch:main Oct 30, 2026
257 of 258 checks passed
@shoumikhin

Copy link
Copy Markdown
Contributor Author

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

@pytorchbot

Copy link
Copy Markdown
Collaborator

Cherry picking #24314

The cherry pick PR is at #24324 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 that referenced this pull request Oct 30, 2026
…24314)

This change fixes several problems in the CUDA backend. Each fix is its
own commit.

## Name the weights file of each program

The backend saves every program's weights to `aoti_cuda_blob.ptd`.
Exporting several programs into one folder overwrites the weights, and
the earlier programs then fail to load. Renaming each file after export
works, but is easy to miss. A new option names the file:

```python
partitioner = CudaPartitioner([
    CudaBackend.generate_method_name_compile_spec("forward"),
    CudaBackend.generate_data_name_compile_spec("encoder"),
])  # writes encoder.ptd
```

Without the option nothing changes. The name is checked: up to 200 ASCII
letters, digits, `_`, `-` and `.`, not starting with `.` or `-`, not
ending with `.ptd`, and not a Windows device name such as `NUL`. All
methods of a program must share it. A backend that keeps its data inside
the `.pte`, such as Metal, refuses it.

## Lower antialiased resizes

`F.interpolate(x, mode="bilinear" or "bicubic", antialias=True)` failed
to lower with "missing fallback kernels". The backend now rewrites it
into plain tensor math, with PyTorch's window and weight formulas, for
dynamic sizes and float32, float16 and bfloat16. The results are not
bit-identical: in float32 they stay within about 2e-5 of PyTorch's
kernel for inputs between 0 and 1.

## Tests

Ran on an A100 GPU (Linux x86_64): `test_cuda_export.py`,
`test_antialiased_resize.py`, `test_missing_weights_blob.py`,
`test_merge_ptes.py` and `test_cuda_weight_metadata.py`. Each test added
for a fix fails when that fix is removed.
shoumikhin added a commit that referenced this pull request Oct 30, 2026
The pick was made before #24298 and #24320 were on release/1.6, so it
carried partial copies of earlier changes. This stacks it on #24320's
branch so that, on top of #24320, the change is exactly #24314
(7586c16).
shoumikhin added a commit to shoumikhin/lerobot that referenced this pull request Nov 2, 2026
…ckend

An operator Torch-TensorRT cannot convert used to fall back to a portable CPU kernel, with copies to and
from the GPU around it. The executorch_tensorrt backend now hands those operators to ExecuTorch's CUDA
backend, so a program runs entirely on the GPU.

The CUDA backend writes its weights into a data file beside the program. Each part names that file after
itself, through the backend's data-name compile spec, so parts saved into one bundle folder keep their own
weights, and the file is recorded as the program's data file. The data-name compile spec comes from
pytorch/executorch#24314, which ExecuTorch 1.6 gets with pytorch/executorch#24324 (in review).

Tested on an NVIDIA GPU: a model with an operator TensorRT rejects compiles to TensorRT and CUDA delegates
with no operator left on the CPU and its input and output in GPU memory, and matches PyTorch when run
without PyTorch.
shoumikhin added a commit to shoumikhin/lerobot that referenced this pull request Nov 2, 2026
executorch_cuda lowers each part with ExecuTorch's own CUDA backend, so a
bundle runs on an NVIDIA GPU without TensorRT. ExecuTorch compiles the
kernels ahead of time for the GPU that exports. It is one config class on
ExecuTorchConfig, and its registration. ExecuTorch loads its programs, so
the runtime, the engine and lerobot-rollout are unchanged.

The CUDA backend keeps a program's kernels and weights in a separate data
file, named after the part, as with executorch_tensorrt, so two parts
never overwrite each other's weights and the bundle names one data file
per program. Naming that file needs pytorch/executorch#24314, which
ExecuTorch 1.6 gets with pytorch/executorch#24324 (in review). Inputs and outputs stay in GPU memory too, so a program reads
an earlier one's outputs in place. A CUDA bundle records the GPU that
built it, and is refused on another.

The kernels are compiled in full float32, as lerobot-export compiles for
every backend. PyTorch lets convolutions use TF32 by default, and the
convolution kernels the CUDA backend generates follow that flag. On ACT on
an H100 they then drifted about seven times further from PyTorch on the
CPU than PyTorch's own GPU kernels do. In full float32 the actions match to
about 1e-6, and a chunk still runs twice as fast as eager PyTorch on the
GPU.

It needs the CUDA build of ExecuTorch, and the CUDA compiler (nvcc) at
export. Like the other ExecuTorch backends, it reads programs traced by
PyTorch 2.15 or newer.

Like the backends that take only part of a program, it folds the values a
program computes from constants alone before lowering. SmolVLA's vision
encoder picks its position ids with a boolean mask made from constants.
Left in the program, that indexing makes the GPU read a size back to the
host on every run; folded, it is gone. Folding has one cost today:
SmolVLA's time embedding, computed in float64 from constants, becomes a
float64 weight, and ExecuTorch's CUDA backend cannot load float64
weights yet, so SmolVLA waits for that fix on this backend.

Tested on an H100 and on a Jetson AGX Thor: exported ACT, checked the
bundle, and ran it with lerobot-rollout on a fake robot. On the H100, pi0
exported too, and its actions matched eager PyTorch to about 1e-5 on ten
frames; in float32 it runs a little slower than eager. The check that a bundle runs without PyTorch now
also runs a CUDA bundle.
shoumikhin added a commit to shoumikhin/lerobot that referenced this pull request Nov 2, 2026
…ckend

An operator Torch-TensorRT cannot convert used to fall back to a portable CPU kernel, with copies to and
from the GPU around it. The executorch_tensorrt backend now hands those operators to ExecuTorch's CUDA
backend, so a program runs entirely on the GPU.

The CUDA backend writes its weights into a data file beside the program. Each part names that file after
itself, through the backend's data-name compile spec, so parts saved into one bundle folder keep their own
weights, and the file is recorded as the program's data file. The data-name compile spec comes from
pytorch/executorch#24314, which ExecuTorch 1.6 gets with pytorch/executorch#24324 (in review).

Tested on an NVIDIA GPU: a model with an operator TensorRT rejects compiles to TensorRT and CUDA delegates
with no operator left on the CPU and its input and output in GPU memory, and matches PyTorch when run
without PyTorch.
shoumikhin added a commit to shoumikhin/lerobot that referenced this pull request Nov 2, 2026
executorch_cuda lowers each part with ExecuTorch's own CUDA backend, so a
bundle runs on an NVIDIA GPU without TensorRT. ExecuTorch compiles the
kernels ahead of time for the GPU that exports. It is one config class on
ExecuTorchConfig, and its registration. ExecuTorch loads its programs, so
the runtime, the engine and lerobot-rollout are unchanged.

The CUDA backend keeps a program's kernels and weights in a separate data
file, named after the part, as with executorch_tensorrt, so two parts
never overwrite each other's weights and the bundle names one data file
per program. Naming that file needs pytorch/executorch#24314, which
ExecuTorch 1.6 gets with pytorch/executorch#24324 (in review). Inputs and outputs stay in GPU memory too, so a program reads
an earlier one's outputs in place. A CUDA bundle records the GPU that
built it, and is refused on another.

The kernels are compiled in full float32, as lerobot-export compiles for
every backend. PyTorch lets convolutions use TF32 by default, and the
convolution kernels the CUDA backend generates follow that flag. On ACT on
an H100 they then drifted about seven times further from PyTorch on the
CPU than PyTorch's own GPU kernels do. In full float32 the actions match to
about 1e-6, and a chunk still runs twice as fast as eager PyTorch on the
GPU.

It needs the CUDA build of ExecuTorch, and the CUDA compiler (nvcc) at
export. Like the other ExecuTorch backends, it reads programs traced by
PyTorch 2.15 or newer.

Like the backends that take only part of a program, it folds the values a
program computes from constants alone before lowering. SmolVLA's vision
encoder picks its position ids with a boolean mask made from constants.
Left in the program, that indexing makes the GPU read a size back to the
host on every run; folded, it is gone. Folding has one cost today:
SmolVLA's time embedding, computed in float64 from constants, becomes a
float64 weight, and ExecuTorch's CUDA backend cannot load float64
weights yet, so SmolVLA waits for that fix on this backend.

Tested on an H100 and on a Jetson AGX Thor: exported ACT, checked the
bundle, and ran it with lerobot-rollout on a fake robot. On the H100, pi0
exported too, and its actions matched eager PyTorch to about 1e-5 on ten
frames; in float32 it runs a little slower than eager. The check that a bundle runs without PyTorch now
also runs a CUDA bundle.
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.
shoumikhin added a commit to shoumikhin/lerobot that referenced this pull request Nov 3, 2026
…ckend

An operator Torch-TensorRT cannot convert used to fall back to a portable CPU kernel, with copies to and
from the GPU around it. The executorch_tensorrt backend now hands those operators to ExecuTorch's CUDA
backend, so a program runs entirely on the GPU.

The CUDA backend writes its weights into a data file beside the program. Each part names that file after
itself, through the backend's data-name compile spec, so parts saved into one bundle folder keep their own
weights, and the file is recorded as the program's data file. The data-name compile spec comes from
pytorch/executorch#24314, which ExecuTorch 1.6 gets with pytorch/executorch#24324 (in review).

Tested on an NVIDIA GPU: a model with an operator TensorRT rejects compiles to TensorRT and CUDA delegates
with no operator left on the CPU and its input and output in GPU memory, and matches PyTorch when run
without PyTorch.
shoumikhin added a commit to shoumikhin/lerobot that referenced this pull request Nov 3, 2026
executorch_cuda lowers each part with ExecuTorch's own CUDA backend, so a
bundle runs on an NVIDIA GPU without TensorRT. ExecuTorch compiles the
kernels ahead of time for the GPU that exports. It is one config class on
ExecuTorchConfig, and its registration. ExecuTorch loads its programs, so
the runtime, the engine and lerobot-rollout are unchanged.

The CUDA backend keeps a program's kernels and weights in a separate data
file, named after the part, as with executorch_tensorrt, so two parts
never overwrite each other's weights and the bundle names one data file
per program. Naming that file needs pytorch/executorch#24314, which
ExecuTorch 1.6 gets with pytorch/executorch#24324 (in review). Inputs and outputs stay in GPU memory too, so a program reads
an earlier one's outputs in place. A CUDA bundle records the GPU that
built it, and is refused on another.

The kernels are compiled in full float32, as lerobot-export compiles for
every backend. PyTorch lets convolutions use TF32 by default, and the
convolution kernels the CUDA backend generates follow that flag. On ACT on
an H100 they then drifted about seven times further from PyTorch on the
CPU than PyTorch's own GPU kernels do. In full float32 the actions match to
about 1e-6, and a chunk still runs twice as fast as eager PyTorch on the
GPU.

It needs the CUDA build of ExecuTorch, and the CUDA compiler (nvcc) at
export. Like the other ExecuTorch backends, it reads programs traced by
PyTorch 2.15 or newer.

Like the backends that take only part of a program, it folds the values a
program computes from constants alone before lowering. SmolVLA's vision
encoder picks its position ids with a boolean mask made from constants.
Left in the program, that indexing makes the GPU read a size back to the
host on every run; folded, it is gone. Folding has one cost today:
SmolVLA's time embedding, computed in float64 from constants, becomes a
float64 weight, and ExecuTorch's CUDA backend cannot load float64
weights yet, so SmolVLA waits for that fix on this backend.

Tested on an H100 and on a Jetson AGX Thor: exported ACT, checked the
bundle, and ran it with lerobot-rollout on a fake robot. On the H100, pi0
exported too, and its actions matched eager PyTorch to about 1e-5 on ten
frames; in float32 it runs a little slower than eager. The check that a bundle runs without PyTorch now
also runs a CUDA bundle.
shoumikhin added a commit to shoumikhin/lerobot that referenced this pull request Nov 3, 2026
… part's weights file

The executorch_cuda and executorch_tensorrt backends name the weights file
of each program part after the part, so the parts of a bundle do not
share one file name. That takes the CUDA backend's
`generate_data_name_compile_spec` (pytorch/executorch#24314). The
ExecuTorch 1.6.0 release candidate does not have it yet, and an export
there stopped deep in the compile step with
`AttributeError: type object 'CudaBackend' has no attribute
'generate_data_name_compile_spec'`.

The export now refuses such an ExecuTorch with an error that names the
installed version and the missing change. Like the refusal of a
Torch-TensorRT without `fallback_pytorch_plugin_ops`, it is a ValueError,
so the export does not add a "pip install" hint for a package that is
already installed. The TensorRT backend asks for the CUDA partitioner
before TensorRT builds the part's engines, so it refuses at once instead
of after the build. `cuda_backend_names_its_weights()` answers the same
question for tests that need the CUDA backend.

Tested: a unit test refuses the CUDA partitioner of such an ExecuTorch
with ExecuTorch 1.6.0 (release candidate) installed, and another shows
the TensorRT backend refuses before calling Torch-TensorRT's compile;
without the change it reaches the compile.
shoumikhin added a commit to shoumikhin/lerobot that referenced this pull request Nov 3, 2026
…d the installed versions

Three test files each built their own skip marks for the ExecuTorch
backends, and they checked different things. None of them checked that
the CUDA backend can name each part's weights file, so with the
ExecuTorch 1.6.0 release candidate the `executorch_cuda` tests failed
instead of skipping. A skipped test also said only "needs ExecuTorch and
a CUDA GPU", which does not tell a reader which package is too old.

`tests/export/backends.py` now holds one mark per backend:

- `needs_executorch`: ExecuTorch 1.6 and PyTorch 2.15.
- `needs_executorch_cuda`: also a CUDA GPU and an ExecuTorch whose CUDA
  backend names each part's weights file (pytorch/executorch#24314).
- `needs_executorch_tensorrt`: also a Torch-TensorRT with
  `fallback_pytorch_plugin_ops` (pytorch/TensorRT#4953) and
  torch-tensorrt-executorch-runtime.

Each reason names these fixes and the installed versions of PyTorch,
ExecuTorch, Torch-TensorRT and its runtime. The reason always names
ExecuTorch, and a CUDA GPU for a GPU backend, so `tests/conftest.py`
marks the test `executorch` and `gpu` whether it skips or not.
`test_export.py`, `test_export_history.py` and
`test_executorch_tensorrt.py` use these marks.

Tested: with the ExecuTorch 1.6.0 and Torch-TensorRT 2.15.0 release
candidates on a GPU, every GPU export test skips and names
pytorch/executorch#24314 or pytorch/TensorRT#4953; with ExecuTorch and
Torch-TensorRT builds that have both fixes, the same tests run and pass.
shoumikhin added a commit to shoumikhin/lerobot that referenced this pull request Nov 3, 2026
…he GPU backends after approval

No CI job installed ExecuTorch, so every test that exports with a real
backend skipped, and nothing checked that `lerobot-export` and
`lerobot-rollout --bundle.path` still work with XNNPACK, ExecuTorch's
CUDA backend or TensorRT.

Two jobs now install the compilers over the locked environment and run
the tests marked `executorch` (`tests/conftest.py` adds the mark to every
test that skips without ExecuTorch):

- Fast Export Tests, in Fast Tests, on every pull request, forks
  included: `ubuntu-latest`, the extras of the exported policies (`pi`,
  `smolvla`, `groot`, `diffusion`), then ExecuTorch's CPU build, then
  `pytest tests -m executorch`. The GPU tests among them skip there.
- GPU Export Tests, in Full Tests, next to GPU Tests and on the same
  image and runner: ExecuTorch's and Torch-TensorRT's CUDA 13.2 builds,
  which match PyPI's torch 2.15.0, and `nvidia-cuda-nvcc` 13.2, the CUDA
  compiler the CUDA backend runs at export (`CUDA_HOME` points at it).
  Then `pytest tests -m "executorch and gpu"`. It runs when GPU Tests do:
  after approval, on main and by hand, never for a fork.

ExecuTorch and Torch-TensorRT stay out of `uv.lock`, for the reasons the
`export` note in `pyproject.toml` gives, so each job pins the exact
wheels in one environment variable. The ExecuTorch 1.6 and
Torch-TensorRT 2.15 releases are not on PyPI yet, so the variables point
at their release candidates on PyTorch's test index; they become
`executorch==1.6.0` and `torch-tensorrt==2.15.0` once those are
published. The candidates lack two changes the GPU backends need
(pytorch/executorch#24314 and pytorch/TensorRT#4953), and no
torch-tensorrt-executorch-runtime takes ExecuTorch 1.6.0 yet, so today
the GPU tests skip and say which change each one needs. They run as soon
as released wheels have them.

CONTRIBUTING.md and AGENTS.md say where these tests run and which mark a
new export test takes.

Tested: ran each job's commands in fresh environments installed only
from the package indexes, with the locked export extras in place of the
CI image.

- Fast Export Tests (4 CPUs, a busy machine): 20 passed in 46 minutes.
  GR00T with XNNPACK failed as the previous commit describes; it skips
  now.
- GPU Export Tests on an A100: all 34 GPU tests skip with their reasons,
  57 seconds.
- Not from the indexes: the same GPU tests with ExecuTorch and
  Torch-TensorRT built from source with the two changes, on an H100 with cold caches: 29 passed, 5 skipped (tiny
  pi0 and pi0.5 with TensorRT, and one test that downloads CLIP), 42
  minutes.

Not tested: the L4 runner and the CI image themselves.
JacobSzwejbka added a commit that referenced this pull request Nov 3, 2026
Supersedes #24324 with a clean release pick from #24314.

Adds validated per-program CUDA weights filenames and lowerings for
antialiased bilinear/bicubic resize. Retains the data-name validation,
resize-test removal and documentation follow-ups already carried by
#24324.

Preserves the release behavior that mismatched CUDA weights fail to
load. The resize rewrite follows the source PR tolerances rather than
promising bit-identical floating-point results.

## Scope and landing order

Land after #24488 in the
verified landing order.

This branch starts directly at release/1.6 commit
`dfb78b9ecd933a5fee33ebc94f69ed4b71cbb1e7`. Its GitHub diff contains 10
files for this pick; it does not carry duplicate #24298 history or the
excluded unmerged QNN work. Dependencies are documented here rather than
embedded as other cherry-pick commits.

## Verification

All changed Python files compile and pass Ruff F821 checks. All seven
replacements merge cleanly in the documented sequence, including
simulated squash merges. No QNN files are changed.

Native CUDA, OpenVINO and portable C++ suites were not run locally on
this macOS host; PR CI remains required before landing.

Prepared with OpenAI Codex.
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
…ckend

An operator Torch-TensorRT cannot convert used to fall back to a portable CPU kernel, with copies to and
from the GPU around it. The executorch_tensorrt backend now hands those operators to ExecuTorch's CUDA
backend, so a program runs entirely on the GPU.

The CUDA backend writes its weights into a data file beside the program. Each part names that file after
itself, through the backend's data-name compile spec, so parts saved into one bundle folder keep their own
weights, and the file is recorded as the program's data file. The data-name compile spec comes from
pytorch/executorch#24314, which ExecuTorch 1.6 gets with pytorch/executorch#24324 (in review).

Tested on an NVIDIA GPU: a model with an operator TensorRT rejects compiles to TensorRT and CUDA delegates
with no operator left on the CPU and its input and output in GPU memory, and matches PyTorch when run
without PyTorch.
shoumikhin added a commit to shoumikhin/lerobot that referenced this pull request Nov 3, 2026
executorch_cuda lowers each part with ExecuTorch's own CUDA backend, so a
bundle runs on an NVIDIA GPU without TensorRT. ExecuTorch compiles the
kernels ahead of time for the GPU that exports. It is one config class on
ExecuTorchConfig, and its registration. ExecuTorch loads its programs, so
the runtime, the engine and lerobot-rollout are unchanged.

The CUDA backend keeps a program's kernels and weights in a separate data
file, named after the part, as with executorch_tensorrt, so two parts
never overwrite each other's weights and the bundle names one data file
per program. Naming that file needs pytorch/executorch#24314, which
ExecuTorch 1.6 gets with pytorch/executorch#24324 (in review). Inputs and outputs stay in GPU memory too, so a program reads
an earlier one's outputs in place. A CUDA bundle records the GPU that
built it, and is refused on another.

The kernels are compiled in full float32, as lerobot-export compiles for
every backend. PyTorch lets convolutions use TF32 by default, and the
convolution kernels the CUDA backend generates follow that flag. On ACT on
an H100 they then drifted about seven times further from PyTorch on the
CPU than PyTorch's own GPU kernels do. In full float32 the actions match to
about 1e-6, and a chunk still runs twice as fast as eager PyTorch on the
GPU.

It needs the CUDA build of ExecuTorch, and the CUDA compiler (nvcc) at
export. Like the other ExecuTorch backends, it reads programs traced by
PyTorch 2.15 or newer.

Like the backends that take only part of a program, it folds the values a
program computes from constants alone before lowering. SmolVLA's vision
encoder picks its position ids with a boolean mask made from constants.
Left in the program, that indexing makes the GPU read a size back to the
host on every run; folded, it is gone. Folding has one cost today:
SmolVLA's time embedding, computed in float64 from constants, becomes a
float64 weight, and ExecuTorch's CUDA backend cannot load float64
weights yet, so SmolVLA waits for that fix on this backend.

Tested on an H100 and on a Jetson AGX Thor: exported ACT, checked the
bundle, and ran it with lerobot-rollout on a fake robot. On the H100, pi0
exported too, and its actions matched eager PyTorch to about 1e-5 on ten
frames; in float32 it runs a little slower than eager. The check that a bundle runs without PyTorch now
also runs a CUDA bundle.
shoumikhin added a commit to shoumikhin/lerobot that referenced this pull request Nov 3, 2026
… part's weights file

The executorch_cuda and executorch_tensorrt backends name the weights file
of each program part after the part, so the parts of a bundle do not
share one file name. That takes the CUDA backend's
`generate_data_name_compile_spec` (pytorch/executorch#24314). The
ExecuTorch 1.6.0 release candidate does not have it yet, and an export
there stopped deep in the compile step with
`AttributeError: type object 'CudaBackend' has no attribute
'generate_data_name_compile_spec'`.

The export now refuses such an ExecuTorch with an error that names the
installed version and the missing change. Like the refusal of a
Torch-TensorRT without `fallback_pytorch_plugin_ops`, it is a ValueError,
so the export does not add a "pip install" hint for a package that is
already installed. The TensorRT backend asks for the CUDA partitioner
before TensorRT builds the part's engines, so it refuses at once instead
of after the build. `cuda_backend_names_its_weights()` answers the same
question for tests that need the CUDA backend.

Tested: a unit test refuses the CUDA partitioner of such an ExecuTorch
with ExecuTorch 1.6.0 (release candidate) installed, and another shows
the TensorRT backend refuses before calling Torch-TensorRT's compile;
without the change it reaches the compile.
shoumikhin added a commit to shoumikhin/lerobot that referenced this pull request Nov 3, 2026
…d the installed versions

Three test files each built their own skip marks for the ExecuTorch
backends, and they checked different things. None of them checked that
the CUDA backend can name each part's weights file, so with the
ExecuTorch 1.6.0 release candidate the `executorch_cuda` tests failed
instead of skipping. A skipped test also said only "needs ExecuTorch and
a CUDA GPU", which does not tell a reader which package is too old.

`tests/export/backends.py` now holds one mark per backend:

- `needs_executorch`: ExecuTorch 1.6 and PyTorch 2.15.
- `needs_executorch_cuda`: also a CUDA GPU and an ExecuTorch whose CUDA
  backend names each part's weights file (pytorch/executorch#24314).
- `needs_executorch_tensorrt`: also a Torch-TensorRT with
  `fallback_pytorch_plugin_ops` (pytorch/TensorRT#4953) and
  torch-tensorrt-executorch-runtime.

Each reason names these fixes and the installed versions of PyTorch,
ExecuTorch, Torch-TensorRT and its runtime. The reason always names
ExecuTorch, and a CUDA GPU for a GPU backend, so `tests/conftest.py`
marks the test `executorch` and `gpu` whether it skips or not.
`test_export.py`, `test_export_history.py` and
`test_executorch_tensorrt.py` use these marks.

Tested: with the ExecuTorch 1.6.0 and Torch-TensorRT 2.15.0 release
candidates on a GPU, every GPU export test skips and names
pytorch/executorch#24314 or pytorch/TensorRT#4953; with ExecuTorch and
Torch-TensorRT builds that have both fixes, the same tests run and pass.
shoumikhin added a commit to shoumikhin/lerobot that referenced this pull request Nov 3, 2026
…he GPU backends after approval

No CI job installed ExecuTorch, so every test that exports with a real
backend skipped, and nothing checked that `lerobot-export` and
`lerobot-rollout --bundle.path` still work with XNNPACK, ExecuTorch's
CUDA backend or TensorRT.

Two jobs now install the compilers over the locked environment and run
the tests marked `executorch` (`tests/conftest.py` adds the mark to every
test that skips without ExecuTorch):

- Fast Export Tests, in Fast Tests, on every pull request, forks
  included: `ubuntu-latest`, the extras of the exported policies (`pi`,
  `smolvla`, `groot`, `diffusion`), then ExecuTorch's CPU build, then
  `pytest tests -m executorch`. The GPU tests among them skip there.
- GPU Export Tests, in Full Tests, next to GPU Tests and on the same
  image and runner: ExecuTorch's and Torch-TensorRT's CUDA 13.2 builds,
  which match PyPI's torch 2.15.0, and `nvidia-cuda-nvcc` 13.2, the CUDA
  compiler the CUDA backend runs at export (`CUDA_HOME` points at it).
  Then `pytest tests -m "executorch and gpu"`. It runs when GPU Tests do:
  after approval, on main and by hand, never for a fork.

ExecuTorch and Torch-TensorRT stay out of `uv.lock`, for the reasons the
`export` note in `pyproject.toml` gives, so each job pins the exact
wheels in one environment variable. The ExecuTorch 1.6 and
Torch-TensorRT 2.15 releases are not on PyPI yet, so the variables point
at their release candidates on PyTorch's test index; they become
`executorch==1.6.0` and `torch-tensorrt==2.15.0` once those are
published. The candidates lack two changes the GPU backends need
(pytorch/executorch#24314 and pytorch/TensorRT#4953), and no
torch-tensorrt-executorch-runtime takes ExecuTorch 1.6.0 yet, so today
the GPU tests skip and say which change each one needs. They run as soon
as released wheels have them.

CONTRIBUTING.md and AGENTS.md say where these tests run and which mark a
new export test takes.

Tested: ran each job's commands in fresh environments installed only
from the package indexes, with the locked export extras in place of the
CI image.

- Fast Export Tests (4 CPUs, a busy machine): 20 passed in 46 minutes.
  GR00T with XNNPACK failed as the previous commit describes; it skips
  now.
- GPU Export Tests on an A100: all 34 GPU tests skip with their reasons,
  57 seconds.
- Not from the indexes: the same GPU tests with ExecuTorch and
  Torch-TensorRT built from source with the two changes, on an H100 with cold caches: 29 passed, 5 skipped (tiny
  pi0 and pi0.5 with TensorRT, and one test that downloads CLIP), 42
  minutes.

Not tested: the L4 runner and the CI image themselves.
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: backends

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants