Skip to content

CUDA backend: lower complex rotary embeddings and free repeated outputs once - #24403

Open
shoumikhin wants to merge 2 commits into
pytorch:mainfrom
shoumikhin:cuda-complex-ops
Open

shoumikhin wants to merge 2 commits into
pytorch:mainfrom
shoumikhin:cuda-complex-ops

Conversation

@shoumikhin

Copy link
Copy Markdown
Contributor

What is wrong today

Rotary position embeddings are often written with complex numbers (torch.polar, torch.view_as_complex, a product, torch.view_as_real). Such a model exports, but the CUDA backend cannot lower it:

RuntimeError: Method forward missing fallback kernels (5 total):
  - aoti_torch_cuda_mul_Tensor
  - aoti_torch_cuda_polar
  - aten::view_as_complex_copy
  - aten::view_as_real_copy
  - prims::convert_element_type

Inductor generates no code for complex numbers. It calls an ATen kernel for every op on a complex tensor, and this backend ships none of those kernels. Torch-TensorRT leaves polar and view_as_real to the CUDA backend, so its programs stop at the same error.

What this change does

Complex numbers as pairs of real numbers. A new pass, ReplaceComplexWithRealPass, stores each complex tensor as a real tensor with a last dim of size 2 (the layout of view_as_real) and rewrites the math before Inductor runs:

view_as_complex(x)  ->  x
view_as_real(z)     ->  z
polar(r, theta)     ->  stack([r * cos(theta), r * sin(theta)], -1)
z * w               ->  stack([zr * wr - zi * wi, zr * wi + zi * wr], -1)

Views, indexing, splits, concatenation and casts keep their op, with the dims and dtype of the pair. A complex constant is stored as its real view. A decomposition table cannot do this, because a decomposition must keep the dtype of the op it replaces.

The pass changes a program only when it can rewrite every complex op in it. Otherwise (a complex add, for example) it leaves the program as it is and logs why.

Complex128 becomes float64. A program that keeps float64 values in memory also needs the float64 support in #24326, so this should land after it.

Outputs that are the same tensor. When two outputs are the same tensor, AOTI puts one handle in both output slots, and the backend deleted it twice. Programs that return y.detach(), y.detach() crashed this way. With only the complex pass, so would programs that return view_as_real of one complex tensor twice, as Torch-TensorRT programs can. Now each repeated slot gets its own handle on the shared storage.

What was tested

With torch 2.14, the GPU tests on an A100. Each new test fails without its change:

  • test_complex_rotary_embedding runs a lowered rotary embedding against eager in float32 and bfloat16.
  • test_complex_rewrite and test_complex_rewrite_leaves_unsupported_programs check the pass on the CPU.
  • test_same_tensor_returned_twice runs a program that returns one tensor twice.

Rotary embeddings written as in Llama and LingBot-VA, and a Wan-style one in complex64, match eager. The Llama-style one also matches with a dynamic sequence length. The LingBot-VA ones also match through Torch-TensorRT (with torch 2.15), and Wan's complex128 one with #24326. The other backends/cuda tests that CI runs in the same pytest call still pass.

When two outputs of a program are the same tensor, for example two views
of one buffer, AOTI puts the same handle in both output slots:

    output_handles[0] = buf0.release();
    output_handles[1] = output_handles[0];

The CUDA backend copied each output into its ExecuTorch tensor and then
deleted its handle, so the second copy read a freed handle and the
handle was deleted twice. The process crashed:

    SlimTensor storage_offset must not be negative, got -9007650542493611503
    Failed to D2D copy GPU output 1 into ETensor
    free(): double free detected in tcache 2

PyTorch's own AOTI runner handles this case. Now the CUDA backend gives
each repeated slot its own handle on the shared storage right after the
run, the same way aoti_torch_new_tensor_handle does, so no handle is
deleted twice.

The comment in ReplaceViewCopyWithViewPass no longer says that alias_copy
keeps two outputs on separate handles: two detached views of one tensor
become two alias_copy outputs, and they share one handle.

The new test returns two detached views of one tensor from a CUDA
program and compares both outputs with eager. It crashes without this
change.
Rotary position embeddings are often written with complex numbers:
torch.polar builds the rotations, torch.view_as_complex turns pairs of
values into complex numbers, they are multiplied, and torch.view_as_real
turns the result back. Such a model exports, but the CUDA backend could
not lower it:

    RuntimeError: Method forward missing fallback kernels (5 total):
      - aoti_torch_cuda_mul_Tensor
      - aoti_torch_cuda_polar
      - aten::view_as_complex_copy
      - aten::view_as_real_copy
      - prims::convert_element_type

Inductor generates no code for complex numbers. It calls an ATen kernel
for every op that reads or writes a complex tensor, and this backend
ships none of those kernels. Torch-TensorRT leaves polar and view_as_real
to the CUDA backend, so its programs stop at the same error.

A new pass, ReplaceComplexWithRealPass, keeps each complex tensor as a
real tensor with a last dim of size 2, the layout of view_as_real, and
rewrites the ops on it as real math before Inductor compiles the graph.
polar becomes cosine and sine times the magnitude, a complex product
becomes four real products, and view_as_complex and view_as_real go
away. Views, slices, splits, indexing, repeats, transposes, unbinding,
concatenation, stacking and casts keep their op, with the dims and dtype
of the pair. A cast from real to complex gets zero imaginary parts, and
a complex constant, buffer or parameter is stored as its real view.

A decomposition table cannot do this, because a decomposition returns
the dtype of the op it replaces.

The pass rewrites a program only when it can rewrite every op that
reads or writes a complex tensor in it. Otherwise, for example for a
complex add, a complex program input, complex32 or complex values inside
a torch.cond branch, it leaves the program as it is and logs the first op
it cannot rewrite.

Complex128 math becomes float64 math, so a program that keeps float64
values in memory also needs float64 support in the runtime.

test_complex_rotary_embedding lowers a rotary embedding with a complex
constant, polar, views, a split, a concatenation and a view_as_real
output, runs it with the runtime and compares it with eager, in float32
and bfloat16. On the CPU, with the program prepared the way the CUDA
backend's passes get it, test_complex_rewrite checks a rotary embedding
with a complex constant and parameter, a gather by position, views,
splits and joins of many kinds and a dynamic length against eager, after
the retrace that the backend runs when its decomposition table applies,
and test_complex_rewrite_leaves_unsupported_programs checks that the
programs listed above, a channels-last copy, a cast to real and a
mutated complex buffer stay as they are. They fail without this change.
Copilot AI balanced review requested due to automatic review settings November 1, 2026 10:34
@pytorch-bot

pytorch-bot Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

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

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

❌ 1 Unclassified Failure

As of commit 9181a61 with merge base 05092c1 (image):

UNCLASSIFIED FAILURE - DrCI could not classify the following job because the workflow did not run on the merge base. The failure may be pre-existing on trunk or introduced by this PR:

  • Test CUDA Builds / unittest-cuda (system) / linux-job (gh) (this job did not run on the merge base, so DrCI cannot tell whether the failure is pre-existing)
    backends/cuda/tests/test_antialiased_resize.py::TestAntialiasedResizeDecomposition::test_align_corners_compiles_with_dynamic_sizes

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 Nov 1, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 27m 56s —— View job


This PR lowers complex-number rotary embeddings in the CUDA backend and stops repeated AOTI output handles from being deleted twice. Nothing blocks merging, but there are six minor issues: a handle leak in the CUDA-graph capture step, rewritten constants missing the autotune out-of-memory offload, helper code that duplicates backends/transforms/utils.py, and three test issues (no GPU test of dynamic shapes, a weak check in the unsupported-program test, and test placement).

Detailed review

PR Review: #24403

Summary

The PR adds ReplaceComplexWithRealPass, which stores each complex tensor as a real tensor with a trailing dim of 2 before Inductor runs. It also makes the CUDA runtime give each repeated output slot its own handle. All findings below are minor and none blocks merging. Three reviewers covered all 7 changed files.

Code Quality

  • The CUDA-graph capture step leaks the new handles. backends/cuda/runtime/cuda_backend.cpp:1034 creates an ExecuTorch-owned SlimTensor for each repeated output slot. The capture branch releases outputs by setting the slots to nullptr (lines 1074, 1089, 1119). That is right for AOTI's own handles, because the graph owns them, but the copies do not belong to AOTI.

    • So each capture of a method that returns one tensor twice leaks one SlimTensor, plus one reference to its storage, per repeated slot. The leak is small and happens once per capture.
    • Deleting the copies at those points only lowers a reference count.
    • No test covers this path. test_same_tensor_returned_twice (test_cuda_export.py:909) runs without CUDA graphs. TestStridedOutputWithCudaGraph._run_with_cuda_graph (backends/cuda/tests/test_output_stride_rearrange.py:304) already runs warmup, capture and replay, but it returns only the first output.

    Fix this →

  • _constant_name and _store_as_real duplicate existing helpers. backends/cuda/passes/replace_complex_with_real.py:278-306 looks up parameters, buffers and lifted constants by hand, and writes to the private exported_program._constants / _state_dict.

    • backends/transforms/utils.py:69-128 already has is_param_node, get_param_tensor and set_param_tensor, and the cuda_passes BUCK target already depends on //executorch/backends/transforms:utils.
    • The buffers_to_mutate exclusion (lines 288-290) is also redundant. A mutated complex buffer always passes a complex value to the output node, and _can_rewrite rejects that node at line 256. The test's MutatedBuffer case is therefore declined either way.

    Fix this →

Infrastructure

  • Rewritten constants miss the autotune out-of-memory offload. This offload moves the program's GPU tensors to the CPU when an autotune capture runs out of memory.

    • CudaBackend.move_program_to_device records weak references to the program's CUDA tensors (_track_moved_tensors, called at backends/cuda/cuda_backend.py:1283). autotune_launch_params and cuda_graph_timing offload exactly those tensors (lines 1218, 1226).
    • The custom passes run after that step (backends/aoti/aoti_backend.py:296-311). _store_as_real (replace_complex_with_real.py:302-305) then replaces each complex constant with a new .clone().
    • As a result, the tracked tensor drops out and the new real tensor is never tracked. A large complex buffer, such as a long-context frequency table, stays on the GPU when the offload runs.
    • Fix: track the program's tensors again after the custom passes run.

    Fix this →

Testing

  • Two behaviors the description reports have no GPU test.

    • Dynamic sequence length. test_complex_rewrite (test_cuda_export.py:319) exports with Dim("length"), but only runs the eager program.module() on the CPU after run_decompositions({}) (line 366). test_complex_rotary_embedding (line 935) uses static shapes. The _VIEWS comment (replace_complex_with_real.py:121-124) says dynamic sizes are where the fake kernels of view copies break, so CI should send dynamic sizes through Inductor/AOTI.
    • view_as_real of one complex tensor returned twice. The pass maps both outputs to one node, and the description gives this as one of the two cases the runtime fix exists for. test_same_tensor_returned_twice only covers y.detach() returned twice.

    Fix this →

  • The unsupported-program test cannot detect a partly edited graph. test_complex_rewrite_leaves_unsupported_programs compares program.graph_module.code (test_cuda_export.py:423, 426).

    • FX regenerates that string only in recompile(), and on this path the pass returns before recompile() (replace_complex_with_real.py:384). A regression that edits nodes and then bails out would still pass.
    • The test also does not check placeholder meta["val"], which _store_as_real changes at line 306.
    • Fix: compare str(program.graph) and the placeholder values instead.

    Fix this →

  • The pass tests do not follow the per-pass test layout. _program_for_cuda_passes, test_complex_rewrite and test_complex_rewrite_leaves_unsupported_programs (test_cuda_export.py:30, 319, 377) sit in TestCudaBackendCompileOptions.

    • Each sibling pass has its own module in backends/cuda/passes/tests/, for example test_replace_int64_floordiv.py, which has a separate registration test at line 51. CI runs that directory in the same pytest call (.github/workflows/cuda.yml:622).
    • Here the registration check is appended to the end of the numerics test (lines 370-375), so a numerics failure hides it.

    Fix this →

Recommendation

Approve

Nothing here blocks merging. The fixes most worth making before merge are the handle leak in the CUDA-graph capture step and GPU tests for dynamic shapes and for repeated outputs with CUDA graphs.

This branch has not been deployed

No deployments
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.

2 participants