Skip to content

Fix nonzero, masked_select, GPU constants and returned external constants, and refuse device inputs that cannot run - #24771

Open
shoumikhin wants to merge 6 commits into
pytorch:mainfrom
shoumikhin:nonzero-masked-select-device-io
Open

shoumikhin wants to merge 6 commits into
pytorch:mainfrom
shoumikhin:nonzero-masked-select-device-io

Conversation

@shoumikhin

Copy link
Copy Markdown
Contributor

What is wrong today

  • torch.nonzero returns wrong indices with no error. PyTorch lays its result out column by column, and the portable kernel writes row by row. Ops that read the result, like x[idx[:, 0], idx[:, 1]], also fail at run time.
  • to_edge rejects a direct torch.masked_select call as "not in Core ATen opset", although a portable kernel exists.
  • With skip_h2d_for_method_inputs=True, memory planning still reserves a GPU buffer by default for each input left on the GPU. The runtime cannot copy the caller's GPU tensor into it, so every call fails.
  • A model still on the GPU at export (as with Torch-TensorRT) that returns a buffer no delegate takes makes to_executorch() raise "Constant tensor storage is on device 'cuda:0'".
  • A method that returns a buffer or parameter exported with external_constants=True returns zeros, and later reads of that constant see zeros too.

What each commit does

  1. The portable nonzero kernel writes each index through the strides of its output, so it is right for both layouts.
  2. LegalizePortableDimOrderPass copies the nonzero result into the default layout, so later ops and callers read it as usual. The copy needs memory as large as the result.
  3. The edge verifier accepts aten.masked_select. Inputs with 2 elements or fewer still fail in to_executorch(), and an input that broadcasts to a bigger shape can fail at run time, both with an error.
  4. MemoryPlanningPass refuses a planned buffer for a method input left on a device. Such a program used to export and fail on every call; now to_executorch() raises a ValueError that names the input and the fix, MemoryPlanningPass(alloc_graph_input=False).
  5. The emitter reads a constant stored on a device through a host copy. This also changes the case from Raise InternalError for non-CPU constant storage in the emitter #24202: a GPU model lowered with no backend now exports, with the same program as a CPU export.
  6. MethodMeta counts a constant stored in the .ptd file as memory planned, like an inline constant. So the bindings no longer give it an empty output buffer, is_memory_planned() is true for it, and set_output_data_ptr on such an output returns InvalidState instead of overwriting the constant.

What was tested

Each commit adds a test that fails without it. The kernel, exir, emitter, program, MethodMeta, pybindings and CUDA backend tests pass on Linux x86_64 with an H100, except failures that main also has.

Fixes #24763
Fixes #24764
Related: #21580, #21545

ATen returns the result of `torch.nonzero` column by column: for a result
of shape `[N, ndim]`, the strides are `(1, N)`. Exported programs keep
that layout, so the output tensor of `nonzero.out` has dim order `(1, 0)`.
The portable kernel ignored this and always wrote row by row, so the
runtime read the indices back in the wrong places, with no error:

```
x = [[0, 1, 2], [3, 0, 0]]
eager:      [[0, 1], [0, 2], [1, 0]]
ExecuTorch: [[0, 2], [1, 1], [0, 0]]
```

The kernel now writes each index through the strides of the output, so
it is right for both layouts. A resized dynamic output gets its strides
recomputed from its dim order, so this also holds for the upper-bounded
outputs that exported programs use.

The new kernel test fails without the fix.
ATen returns the result of `torch.nonzero` column by column, and
exported programs keep that dim order, `(1, 0)`. Most portable kernels
read only the default dim order or channels last, so any op that uses
the result fails at run time. For example `x[idx[:, 0], idx[:, 1]]` with
`idx = torch.nonzero(x)`:

```
Check failed (all_contiguous || all_channels_last): 2 input tensors have
different dim orders
KernelCall failed at instruction 0:1 in operator aten::select_copy.int_out
```

`LegalizePortableDimOrderPass` already inserts dim order copies for
portable kernels during `to_executorch`. It now also copies the result
of `nonzero` into the default dim order, so the ops after it read it as
usual. The copy moves `N * ndim` integers, which is small next to the
`nonzero` scan of the whole input, but it needs its own planned memory,
as large as the result.

The check uses the dim order the program gets. For a 1-D input the
result has a single column, and there `torch.Tensor.dim_order()` reports
the default order while the program gets `(1, 0)`.

The new test fails without this change, and also without the previous
fix to the `nonzero` kernel, since the copy reads the kernel output.
`to_edge` rejects a direct `torch.masked_select` call:

```
SpecViolationError: Operator torch._ops.aten.masked_select.default is not
in Core ATen opset
```

`masked_select` is not a core ATen op, and PyTorch has no decomposition
for it, so it stays in the graph. ExecuTorch already has a portable
kernel for `masked_select.out`, so the op can run as it is. Plain
`x[x > 0]` is not affected, because export turns it into
`aten.index.Tensor`, but tools that emit `masked_select` directly hit
this error.

This lists the op as an exception in the edge verifier, as the
antialiased resize ops are.

The new `to_edge` test fails without the change.
With skip_h2d_for_method_inputs, a method input that a device delegate
reads stays on the device, and the caller passes their own device
tensor. Memory planning still reserved a buffer for that input by
default. The runtime fills a planned input by copying into host memory,
which it refuses for device memory, so the exported program failed on
every call with "Planned-buffer copy needs host memory on both sides".

MemoryPlanningPass now checks its result: a method input on a device
that got a planned buffer makes to_executorch raise a
ValueError that names the input and the fix,
MemoryPlanningPass(alloc_graph_input=False). The check reads the actual
plan, so host inputs, planned outputs, constants kept on the device and
an input that is also an unplanned output are accepted. It only runs
with device-aware memory planning, which the skip flags require.

Tests that exported this layout only to inspect the graph now set
alloc_graph_input=False and no longer expect a planned input buffer.
The config docstring and the CUDA and TensorRT pages now say the export
raises.

Test plan: test_skipping_copies_requires_unallocated_graph_io expects
the ValueError and fails without the change. Ran the propagate device
pass, memory planning, program, emitter and pass tests.
When a module is still on the GPU at export, as in flows that compile
it there first such as Torch-TensorRT, a constant that no delegate
takes stays in the program with its storage on the GPU. That happens
when forward() returns a buffer, alone or next to a computed result:
there is no operator for the CUDA partitioner to take. to_executorch()
then raised an InternalError (and crashed before pytorch#24202).

A program stores constants as host bytes whatever device they came
from. So the emitter now reads a constant whose storage is not on the
CPU from a host copy instead of raising. The constant keeps its place
and device information in the program, so the runtime treats it as it
would the same constant exported from the CPU. Storage with no data,
such as "meta", still raises an InternalError.

This also lets a GPU module lowered with no backend at all export,
which pytorch#24202 made raise: it now gives the same program as exporting the
module from the CPU.

Test plan: a CUDA test exports a GPU module that returns (x * 2, buffer)
with the CudaPartitioner, runs it, and compares with eager. It fails
without the change. The meta test is renamed and matches the new error.
A model that returns one of its constants (a buffer or a parameter)
gives zeros at run time when it is exported with
external_constants=True. There is no error. The same model with its
constants kept inside the .pte file returns the right values.

MethodMeta decides whether an output is memory planned by looking for
an allocation or an inline data buffer. A constant stored in a .ptd
file has neither, so it looks like an output with no storage. The
Python bindings then give that output a new zero-filled buffer, and
set_output_data_ptr replaces the constant's data pointer with it. From
then on every read of the constant sees zeros, including operators that
use it to compute other outputs.

Treat a tensor whose data lives in an external file as memory planned,
the same way inline constants already are. One helper now computes this
for inputs, outputs and attributes, and the header documentation now
says that constants count as memory planned.

This changes 2 things that callers can see, for external constants
only. Inline constants already behaved this way:
- TensorInfo::is_memory_planned() now returns true for an output or an
  attribute that is an external constant. The Python, Swift and
  JavaScript bindings show this value.
- Method::set_output_data_ptr and Module::set_output on such an output
  now return InvalidState, instead of silently replacing the constant's
  data.

Add a Python test that returns a buffer from a program exported with
external constants, and a C++ test that checks the external constant
attributes of an existing test program report is_memory_planned().
@shoumikhin shoumikhin added the release notes: exir Changes to any dialects and passes on these dialects, such as memory planning label Nov 7, 2026
Copilot AI balanced review requested due to automatic review settings November 7, 2026 05:38
@pytorch-bot

pytorch-bot Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

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

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

❌ 3 Pending, 2 Unrelated Failures, 1 Unclassified Failure

As of commit abe9f62 with merge base 75af78c (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:

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

BROKEN TRUNK - The following job failed but was present on the merge base:

👉 Rebase onto the `viable/strict` branch to avoid these failures

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 7, 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 7, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 29m 48s —— View job


PR Review: #24771

Needs discussion: MemoryPlanningPass now raises for device inputs that it could leave unplanned, the nonzero kernel fix silently changes what existing .pte files return to callers that ignore dim order, and several behaviors that the PR promises have no test.

Detailed review

Summary

This PR fixes the layout of the nonzero output, lets masked_select through the edge verifier, refuses planned device inputs at export, serializes GPU constants from a host copy, and treats external constants that a method returns as memory planned. It has three main problems:

  • The new device-input check raises where planning could just skip those inputs.
  • The nonzero kernel fix changes what existing .pte files return to callers that ignore dim order.
  • Several claimed behaviors have no test.

Code Quality

  • exir/emit/_emitter.py:473-475: a constant with no host data now fails with PyTorch's error instead of ExecuTorch's. The removed guard raised an ExecuTorch message that named the device and the fix. Now a meta constant fails inside storage.cpu() with PyTorch's own copy error, wrapped in InternalError. test_emit_meta_constant_raises (exir/emit/test/test_emit.py:1862) matches PyTorch's wording ("meta tensor"), so it breaks if PyTorch rewords that error. Keep an explicit check for storage.device.type == "meta" that raises an ExecuTorch message, and assert on that message. Fix this →

Infrastructure

  • exir/passes/BUCK:240-254: the memory_planning_pass target is missing //executorch/exir:schema. memory_planning_pass.py:32 now imports DeviceType from executorch.exir.schema, which resolves only through //executorch/exir:tensor. Commit 2 (Copy the nonzero result into the default dim order for portable kernels) adds a direct dep for its own new import, so add one here too. Fix this →

Testing

  • Commit 6 (Stop overwriting external constants that a method returns): the new InvalidState return has no test. The commit message lists two changes that callers can see:

    • set_output_data_ptr now returns InvalidState for an output that is an external constant, through the existing check at runtime/executor/method.cpp:1626-1633.
    • is_memory_planned() is now true for such an output.

    The tests cover these only in part:

    • ExternalConstantAttributesAreMemoryPlanned (runtime/executor/test/method_meta_test.cpp:207-220) only checks attribute_tensor_meta, and its ModuleAddMul program returns no constant. No C++ test checks output_tensor_meta(i).is_memory_planned() or the InvalidState return.
    • test_program_data_separation_returns_constant (extension/pybindings/test/test_pybindings.py:3075-3091) checks the output change only through the bindings. It runs once and returns only the constant. So it misses the rest of the bug the commit message describes: "every read of the constant sees zeros, including operators that use it to compute other outputs". A model that returns self.c, x + self.c and runs twice would cover it. Fix this →
  • The two masked_select limitations in the description have no test. The description says: "Inputs with 2 elements or fewer still fail in to_executorch(), and an input that broadcasts to a bigger shape can fail at run time, both with an error." test_masked_select (exir/program/test/test_program.py:439-458) only uses a 2×3 input with a mask of the same shape, so neither error is checked. When shapes are static, the export could also refuse the broadcast case outright. Fix this →

  • The description's claim about GPU models with no backend has no test. It says that such a model "now exports, with the same program as a CPU export". The only new test (backends/cuda/tests/test_cuda_export.py:1091-1118) lowers through the CUDA partitioner. To cover the claim, add a CUDA-only test that exports the GPU and CPU versions of one model, with no partitioner, and compares their to_executorch().buffer. Fix this →

API Design

  • exir/passes/memory_planning_pass.py:143-167 (called at 338-339): the pass raises for device inputs that it could leave unplanned. The pass knows which method inputs stay on a device, and the runtime refuses to fill a planned input in device memory. Even so, it raises and asks for MemoryPlanningPass(alloc_graph_input=False), which applies to the whole method:

    • CPU inputs stop being planned too, and the method then uses the caller's memory for them. test_skip_h2d_partial_with_intermediate_input (exir/tests/test_propagate_device_pass.py:1815) had to drop its check that the CPU input a is planned in CPU memory, because that setup can no longer export.
    • Users still have to pair the two settings by hand. This PR had to change alloc_graph_input in ten test configs in that file.

    Memory planning already special-cases device-resident constants (exir/memory_planning.py:442 and :553). When non-CPU planning is on, collect_specs_from_nodes could likewise skip the specs of method inputs whose device != DeviceType.CPU. The default MemoryPlanningPass() would then work with skip_h2d_for_method_inputs=True. Verifier.verify_graph_input_output (exir/memory_planning.py:300-315) compares input allocation with alloc_graph_input, so it would need the same exception. Fix this →

Backward Compatibility

  • Commit 1 (Write nonzero indices in the dim order of the output tensor) changes what existing programs return to callers that ignore dim order. Programs without the new copy record dim order (1, 0) for the nonzero output. The kernel now writes that output column by column (kernels/portable/cpu/op_nonzero.cpp:69-79). For inputs with 2 or more dims, this layout differs from the row-by-row data the kernel wrote before. Two groups of programs have no copy:

    • every .pte exported before this PR
    • new exports with _skip_dim_order=True, where LegalizePortableDimOrderPass does not run (exir/program/_program.py:807-808)

    Consumers that read through the strides get the fix. index.Tensor is one of them. A C++ caller that reads the output's const_data_ptr<int64_t>() row by row without checking dim_order() is in the opposite position. It used to get the right indices by accident, and it now gets transposed ones with no error. Call this out in the release notes.

Performance

  • exir/passes/legalize_portable_dim_order_pass.py:143-155: the copy is added even when the consumer reads through strides. In test_optional_tensor_list (exir/emit/test/test_emit.py:667-668), the program now copies between nonzero and index.Tensor. But index.Tensor reads its indices through their strides (kernels/portable/cpu/util/advanced_index_util.cpp:139-140). Each copy adds a kernel call and a second buffer the size of nonzero's upper bound (numel × ndim × 8 bytes), and both buffers are live at the same time. Commit 1 already makes the kernel follow the output strides, so giving the nonzero output itself the default dim order might remove the copy. Low confidence: a later retrace may bring back ATen's strides, which may be why you chose the copy.

Recommendation

Needs Discussion

The main open question is whether MemoryPlanningPass should leave device inputs unplanned instead of raising (see API Design). That choice sets the user-facing contract for skip_h2d_for_method_inputs, so it is best settled before this lands. The nonzero kernel change for existing .pte files needs at least a release note. The other findings are minor and could go in a follow-up.


  • Gather context (diff, commits, CLAUDE.md, checklist)
  • Run three independent reviewers
  • Consolidate findings
  • Fact-check findings
  • Post review

No code was changed. I did not build or run tests, because running Python here needs approval. Every finding comes from reading the code. All 19 changed files were reviewed.

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: exir Changes to any dialects and passes on these dialects, such as memory planning

Projects

None yet

2 participants