Repository navigation
CUDA backend: CUDA graph fixes for mask indexing and changed input shapes, masked copies as where, tensor checks dropped before compiling, float64, _reshape_copy, scatter_reduce and varlen_attn, follow-ups - #24326
shoumikhin wants to merge 71 commits into
Conversation
A CUDA graph replays the shapes it captured. Before each replay, the backend copied the new input into the captured input buffer, and it checked only that the byte count matched. An input can change shape without changing its byte count, for example from (2, 3, 4, 4) to (2, 4, 3, 4). Such a call replayed the captured shape and returned an output with the old shape and wrong values, with no error. The backend now records each input's shape at capture, and a replay with a different shape fails with InvalidArgument, the same error a different byte count already gave. test_output_stride_rearrange.py captures with (2, 3, 4, 4) inputs, then calls with (2, 4, 3, 4), for contiguous and channels-last outputs. Without this change those calls return the wrong shape.
The wheel check names every kernel shim that libaoti_cuda_shims must define, so a library built without some of its CUDA sources fails the check. The mask index and index_put shims were missing from that list.
Every getter in the shared AOTI shims has a unit test except aoti_torch_get_size, so its handling of negative dims and of dims out of range never ran. The new tests cover both, on CPU and CUDA, and the null argument tests now include it.
The CUDA index shims run mask indexing and index_put. Their tests covered only 1-D masks and int64 accumulation, and the test of a capture call with a new output shape passed even when CUDA graphs were off. test_mask_index_and_index_put moves from test_cuda_export.py to the new test_index_shims.py. It runs programs through the Python runtime, and test_cuda_export.py is also a Buck test target that does not depend on that runtime. The new file adds a 2-D mask, and index_put with repeated indices in deterministic mode, which sends half and bfloat16 accumulation, the last-write-wins case and an int32 index to the shim. test_output_shape_changes_at_capture now ends with a call at the warmup batch size. A live graph rejects it with InvalidArgument, because a replay needs the captured input shape, so the test fails if graphs are off.
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24326
Note: Links to docs will display an error until the docs builds have been completed. This comment was automatically generated by Dr. CI and updates every 15 minutes. |
d3a670f to
a5c0e8b
Compare
With enable_cuda_graph_for_method set, a method that has a mask index, such as x[mask] or x[mask] = v, fails its fourth call, the one that captures the CUDA graph. The index shim has to read the number of selected elements back to the host, which it cannot do inside a capture, so the call returns an error. Only then does the backend turn graphs off, and the later calls run without one. Now, when the 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 same retry also handles other capture failures, such as an output that is first strided at the capture call. The retry is safe because a capture records kernels without running them, so until the new graph runs, the failed attempt has not touched the inputs or any state the program updates. A failure after the graph started running is not retried and fails the call, as before. The off-graph KV cache step is still prepared once and committed once. Nothing changes in the exported program, so programs load on older runtimes as before, and programs exported before this change get the fix too. test_output_stride_rearrange.py runs a mask index model and a mask index_put model with CUDA graphs requested for six calls, and compares each call with eager PyTorch. Without this change the fourth call fails. test_strided_output_first_seen_at_capture now expects that call to match eager PyTorch too, instead of failing.
a5c0e8b to
fdbc332
Compare
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 24m 41s —— View job This PR makes CUDA graph replay reject changed input shapes and reruns a failed capture call without a graph; I found no blockers, only minor issues: an undocumented replay contract, Error logs on calls that now succeed, two PR Review: #24326SummaryReplay now compares input shapes instead of byte counts. When a capture fails before its graph launches, the same call runs again without a graph. Three independent reviewers found no blockers. Everything below is minor, and the first item is a design question. API Design
Code Quality
Testing
RecommendationApprove. Nothing blocks merging. The replay contract needs a decision, plus a comment documenting it if the hard error stays. The rest are small cleanups. |
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 27m 4s —— View job This PR makes CUDA graph replay reject changed input shapes and runs a failed capture call again without a graph; nothing blocks merging, but the hard error on a new input shape after capture should be documented or replaced with a fallback, and there are smaller logging, structure and test issues. PR Review: #24326SummaryReplay now checks input shapes instead of byte counts. When a capture fails before its graph launches, the same call runs again without a graph. Three independent reviewers covered all 8 changed files and found no blockers. The first item below is a design question and the rest are minor. This was a static review: there's no GPU on this runner, so nothing was built or run. API Design
Code Quality
Testing
RecommendationApprove. Nothing blocks merging. The replay shape rule needs a decision, plus a comment documenting it if the hard error stays. The rest are small cleanups. |
The replay check only said that an input had a different shape than at capture. It now names the dim and both sizes, or both dim counts, so the caller can see what changed. Also write down in the phase comment that a captured graph only works for the input shapes of the capture call, and that other shapes fail with InvalidArgument.
A mask index refuses to run inside a CUDA graph capture, and the backend then runs that call without a graph, which succeeds. But the refusal and the failed run were both logged at Error, so every method with a mask index printed two ExecuTorch error lines on its fourth call although nothing failed. Log both at Info during the capture. The call that runs without a graph still logs a real failure at Error. AOTInductor's generated code still prints its own line for the refused call, which the backend does not control. The layout and resize messages no longer say "running without a graph" themselves, because the backend already says that once for every failed capture.
The backend recorded the allocations a captured graph makes before it launched the graph for the first time. A graph only allocates that memory when it launches, so if that first launch failed, a later cleanup called cudaFree on memory that was never allocated. Record them after the launch succeeds. A failed capture now cleans up with CudaGraphState::release() instead of a copy of it, so it also frees the memory a successful first launch allocated before a later step failed.
The KV cache recapture test now also checks that dropping the graph clears the captured input shapes, so a recapture cannot replay against the first capture's shapes. The replay tests match the execute() error, so a set_inputs() failure with the same code no longer passes them.
test_align_corners_compiles_with_dynamic_sizes could not fail. It passed with no rewrite at all, since torch.compile then calls ATen's kernel, and it passed with the min and max in the align_corners ratio replaced by a plain if, since torch.compile treats an output size of 1 as a constant and compiles again. With torch 2.14 and CI set it also failed for a reason unrelated to the resize. test_bicubic_antialias_resize already runs an exported program at an output height of 1, which is where a plain if breaks.
The comment on the min and max in the align_corners ratio blamed torch.compile, which handles a conditional by compiling again. The real reason is export: an exported program keeps the branch it was traced with, so a plain if divides by zero at an output size of 1 when the program was traced with a larger one.
The comment on sequence_ends in the CUDA backend's varlen_attn decomposition called every distance negative. The clamp measures the tokens past cu[-1] from cu[-1] instead, so theirs are 0 or more. The comment on the contiguous output said the op's output is contiguous. The op lays it out in the query's dimension order, so it is contiguous for a query in the usual (tokens, heads, dim) order, even a slice of a packed tensor. Only comments change.
|
@claude /pr-review |
|
Approve, with no blockers. Three things to settle: a constant-folding switch that now applies to every program with mutable buffers and has no performance data; a Detailed reviewPR Review: #24326SummaryThis PR changes the CUDA backend in four ways:
No blockers. The issues found are:
Code Quality
Testing
Performance
RecommendationApprove Nothing blocks merging. Before merging, I'd measure or narrow the folding switch, since it changes how every KV-cache program on CUDA and Metal compiles. SS-JIA's approval (2026-10-30T14:37Z) came before the commits that add
|
…d away small terms The comment said half and bfloat16 values summed in float lose no small terms. Float still rounds, so a term far smaller than the running sum can vanish, for example many 2^-24 terms added one at a time to 1.0. Comment only.
The runtime.cuda docstring and the pybindings README said that loading a method's CUDA weights waits for the whole GPU. Only weights an older export keeps in one blob do that (cudaDeviceSynchronize). Current exports copy each weight with a plain cudaMemcpy, which waits for the legacy default stream and blocking streams but not for a non-blocking stream. Measured with a 2 s kernel on a non-blocking stream: load_method took 0.01 s. Docs only.
No test passed a negative or int32 index to the CUDA index shim, so a shim that dropped the wrap or read int32 indices as int64 still passed. The mask test's column index is now -4 as int32, which eager PyTorch treats as column 2.
Summing half and bfloat16 in float keeps more precision, but a term far smaller than the running sum can still vanish, as the index_put comment already says. Comment only.
A CUDA backend program carries a shared library that AOTInductor
compiles. Inductor puts OpenMP (`-fopenmp`, `-lgomp`) on every Linux
link. Unless the compiler asks the linker to link as needed, as Ubuntu's
GCC does by default, the linker records `libgomp.so.1` as a dependency
even when nothing in the library calls OpenMP. Programs exported with
GCC on Red Hat based systems, or with Clang, carry it. PyTorch ships its
own libgomp, which hides this while torch is imported. A process
without torch then fails to load the program on any machine that has no
system libgomp, such as a minimal container:
Failed to load /tmp/executorch_cuda_<pid>_0.so with error:
libgomp.so.1: cannot open shared object file: No such file or
directory
Link with `-Wl,--as-needed` while the CUDA backend compiles. The linker
then records only the libraries the program references, so an unused
libgomp is dropped, while a program that does call OpenMP keeps it.
PyTorch already does the same for MPS builds on macOS. The flag applies
only to ahead-of-time links on Linux that neither link libtorch nor
cross-compile for Windows, and only in threads inside the backend's
compile context.
The torch-free tests in `test_pybindings_cuda_dlpack.py` no longer probe
for libgomp before they run. The ones that check which libraries got
loaded now also require that libgomp is not among them, so they fail on
any Linux host if the dependency comes back. That check now matches
library file names only. The pybindings README no longer asks users to
install libgomp.
Test plan, on Linux x86_64 (CentOS Stream 9) with an A100:
- Exported a small MLP and CNN before and after this change. Before, the
compiled library lists `libgomp.so.1` and references no OpenMP symbol.
After, `libgomp` and the unused `libm` are gone, and `libcuda`,
`libstdc++`, `libgcc_s` and `libc` remain.
- Ran both programs from Python without torch, where the loader cannot
find libgomp. Before fails with the error above. After loads, runs,
and matches eager.
- With the same kernels, only the link line differs, and timing showed
no change beyond noise.
- A graph whose CPU part Inductor parallelizes with OpenMP still records
libgomp under the flag.
- The torch-free tests pass in both output modes. With the backend
change reverted, the ones that check loaded libraries fail at the
libgomp check, and without a system libgomp all of them fail to load.
The CUDA backend swaps scaled_dot_product_attention for its Triton kernel, triton::sdpa, when triton_kernel_mode is on, which is the default. The 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 loads those positions as zeros, but it never gives them a score of minus infinity. Without an attention mask or a causal mask, each one enters the softmax with a score of 0 and a value of 0, and takes weight away from the real keys. With every real score equal, where the right output is 1, one key gave 0.031 and 80 keys gave 0.625. Passing kv_len without a mask has the same problem. Set the score of every position at or past kv_len to minus infinity, as the other attention kernels in this file already do. The first version of this kernel did this, and a later rewrite dropped it. Checking every tile made some shapes up to 8% slower, so the check only runs where it can matter. 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 every call that was already right compiles to the same code as before. A kv_len, or a symbolic key count, is only known at run time, so those calls check every tile. The test file called this a kernel limitation, and its basic test only used key lengths of 64 and up. Drop that note and run the basic test with a single key too. A new test runs every tile without a mask over 80 and 96 keys and over a kv_len of 80. On 96 keys only the 32-key tiles skip the check. Without the fix, these cases are off by up to 3.6.
To load a method, the CUDA backend writes the compiled library from the program to a temporary file, such as `/tmp/executorch_cuda_<pid>_0.so`, and loads it from there. Only `destroy()` removes that file, and `destroy()` only runs for a handle that `init()` returned. When `init()` fails, for example because the library cannot be loaded or the weights blob is missing, it returns no handle, so the file stays behind. Every load that fails after writing the file leaves it in the temporary directory. Remove the file with a scope guard unless `init()` succeeds, so every failure path removes it. A method that loads keeps its file until `destroy()`, as before. Nothing else changes on a failure: the handle of a late failure is still not freed, and, as in `destroy()`, a library that was already loaded stays loaded. `test_missing_weights_blob.py` now also checks that a load that fails for a missing weights blob leaves nothing in the temporary directory. Test plan, on Linux x86_64 with an A100: - The extended test fails without this change, with the library left in the temporary directory, and passes with it. - A load that fails because the library itself cannot be loaded also leaves no file now. - The other tests in `test_missing_weights_blob.py` still pass.
The CUDA backend adds `-Wl,--as-needed` to its AOTInductor links until Inductor does it itself. pytorch/pytorch#202902 makes that change in Inductor, so the TODO now names it: the hook can go once the pinned PyTorch includes it.
Lowering x.scatter(dim, index, src) to the CUDA backend failed for a
bfloat16, int64 or bool src:
Method forward missing fallback kernels (1 total):
- aoti_torch_cuda_scatter_src_out
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 fallback kernel, and in deterministic mode every
dtype. This backend shipped the scatter_reduce shim but not this one. A
mixture-of-experts layer that writes its expert outputs back with
torch.zeros_like(h).scatter(1, index, src) in bfloat16 hits it on an
A100.
Add the shim next to the scatter_reduce shim, sharing its checks, its
sort of the writes by the element they land on, and its kernel: a
scatter is one more reduction, Assign, where each write replaces the one
before it. So repeated writes to an element keep the last one in index
order, as ATen does in deterministic mode; ATen's default CUDA kernel
keeps any one of them. As in ATen, an index out of range triggers a
device-side assert. It takes every dtype the scatter_reduce shim takes,
bool included.
Also allow it in the backend's list of fallback kernels, add the name to
aoti_cuda_shims.lib, the import library that lowering for Windows links
against, regenerated with mingw binutils 2.41 as its README describes,
and to the wheel's shared library symbol check.
Test: test_scatter_with_tensor_source lowers a scatter with repeated
indices and a column it never writes, in bfloat16, int64 and bool, runs
it and compares with eager CUDA in deterministic mode. Without this
change it fails at lowering. The test that a missing scatter kernel
fails at export lowered an int64 scatter from a tensor, which now works,
so it lowers a scatter of a scalar in deterministic mode instead, whose
kernel the backend still lacks.
What is wrong today
With
enable_cuda_graph_for_methodset, 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:x[mask]orx[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.(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
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.torch.condpredicate on the GPU, still aborts, as before.torch.condflag 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.cudaFree. A failed capture now cleans up withCudaGraphState::release()instead of a copy of it.Smaller commits:
std::random_deviceinstead of the clock, so two processes started in the same second no longer draw the same numbers. Ifstd::random_devicethrows, 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.index_putshims.aoti_torch_get_size.set_output_data_ptrthat described accumulation, the sort count limit and the output index wrongly are corrected.test_index_shims.py, since they need the Python runtime. They add a 2-D mask, andindex_putwith repeated indices in deterministic mode (half and bfloat16 accumulation, last write wins, an int32 index).test_output_shape_changes_at_captureends 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
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 plainif, becausetorch.compilecompiles again for an output size of 1. With torch 2.14 andCIset 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_resizealready runs an exported program at an output height of 1, where a plainifbreaks.ifdivides by zero at an output size of 1..pte, such as Metal, now refuses adata_namecompile spec built by hand too, before compiling anything. Before, onlygenerate_data_name_compile_specrefused it, and a hand-built spec was ignored with no name check.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 maskindex_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 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 astorch._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.distributionsexport asaten._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.Double, with its 8 byte size. The same type wraps a method's inputs and outputs, so a float64 input or output works too.aoti_torch_dtype_float64, which compiled code calls to name the dtype of a float64 constant.Double, and uses 8 bytes when it checks that a weight fits its storage..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_putwith accumulate, and the sort shim. A float16.item()had the same problem withaoti_torch_item_float16and works now too.aten._reshape_copylowers to the CUDA backendTorch-TensorRT turns
aten.viewintoaten._reshape_copy. A view that TensorRT leaves to PyTorch reaches the CUDA backend in that form, and the export failed withmissing 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_attnlowers to the CUDA backendtorch.nn.attention.varlen.varlen_attnruns attention over several sequences packed into one tensor. Lowering it failed withmissing 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 onmax_k. So a window other than(left, 0)lowers only whenmax_q <= max_k + 1is known at export time, as in self-attention, and raises a clear error otherwise.seqused_kandblock_table(paged KV) raise a clear error too. 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_reducewith amax, amin, prod and mean lowers to the CUDA backendLowering
x.scatter_reduce(dim, index, src, "amax")failed withmissing 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.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.index_putuses too.aoti_cuda_shims.liband 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 asaoti_torch_dtype_float16and theaoti_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-neededto the linker, as Ubuntu's GCC does by default, the compiled library listslibgomp.so.1as 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 withlibgomp.so.1: cannot open shared object fileon 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 intest_pybindings_cuda_dlpack.pynow 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.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. Onlydestroy()removed that file, anddestroy()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.pynow 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_modeon (the default), the CUDA backend runsscaled_dot_product_attentionas 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 passkv_lenwith neither a mask noris_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 insdpa.pyalready 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, akv_lenor a symbolic key count checks every tile.test_triton_sdpa.pydrops 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 akv_lenof 80. These fail without the fix.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.pyandtest_cuda_weight_metadata.pypass.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_cachepasses, and fails whenrelease()keeps the captured input shapes.With the min and max replaced by a plain
if,test_bicubic_antialias_resizefails.With the new data name refusal removed, or moved back behind the compile, the data name test fails.
A
torch.condon 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.pypasses, and fails when the pass does nothing, is not registered, or skips its accumulate, same-mask, boolean mask, shared picks or count check. 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.
The mask tests check the runtime log for the failed capture, so they fail when CUDA graphs are off.
With the debug check on the phase before the retry turned into a release check, the mask tests pass.
test_distribution_argument_checkpasses with PyTorch 2.14 and fails with "device-side assert triggered" without the new pass. It writes theMultivariateNormalcheck astorch._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_loadsfails without the fix.test_reshape_copyfails without the fix. Programs that call_reshape_copyon transposed, sliced and contiguous inputs match eager PyTorch.test_varlen_attnfails without the fix. It lowers a program that flattens the output, compares the decomposition with the op, 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. Programs that callvarlen_attnwith no window, a causal mask, a window(8, 0)and a window(4, 2), then reshape the output, withcu_seq_qandcu_seq_kgiven only at run time, lower, run and match eager PyTorch. Withmax_qandmax_kfrom.item(), the decomposed program compiles with AOTInductor and the backend's options, calls no fallback kernel, and matches the op, also for windows(4, 4)and(8, -1).test_missing_weights_blob.py,test_cuda_weight_reuse.pyandtest_device_placement.pypass.test_scatter_reducenow runs the sum too, which for int64 goes to the new shim. The test that a missing scatter kernel fails at export now lowers an int64 scatter without a reduction, whose kernel the backend still lacks, so it still checks the scatter fallback now that the scatter_reduce shim exists. It fails when the check stops watching that fallback.test_scatter_mean_counts_in_its_dtypefails 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 a whole number because of an AOTInductor bug: its Triton launcher takes a float read with.item()as an integer, so a fraction is lost.test_scatter_reduceandtest_sort_shim.pypass.test_scatter_reducefails without the fix, and also when the shim ignoresinclude_self, rounds the integer mean toward zero, or lets a NaN lose in amax and amin. Programs with random float32, float16 and bfloat16 data, 3-D tensors, a negative dim, a stridedsrc, overflowing int8, int32 and half values, half and bfloat16 sums of 1000 values in deterministic mode, bool sums and an empty index match eager CUDA. With CUDA graphs on, ascatter_reduceprogram 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.
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_devicethat throws, Windows, macOS case-insensitive file systems, and the buffer folding change on Metal. 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:
torch.distributionscheck used to stop the CUDA export: exports and runs, 0.76 ms per chunk.