Skip to content

Fix nonzero, masked_select, GPU constants and returned external constants, and leave device inputs unplanned - #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

@shoumikhin shoumikhin commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

What is wrong today

  • torch.nonzero silently returns wrong indices: PyTorch lays the result out column by column, but the portable kernel writes row by row. Ops like x[idx[:, 0], idx[:, 1]] then fail.
  • to_edge rejects torch.masked_select as "not in Core ATen opset", although a portable kernel exists.
  • With skip_h2d_for_method_inputs=True and default memory planning, inputs left on the GPU get buffers the runtime cannot fill, so every call fails.
  • A model left on the GPU at export (Torch-TensorRT does this) that returns a buffer makes to_executorch() raise "Constant tensor storage is on device 'cuda:0'".
  • With external_constants=True, Python gets zeros for a returned constant, and later reads of it see zeros too.

What each commit does

  1. The portable nonzero kernel writes through its output strides. gather and scatter read their index through its strides too, so old programs where nonzero feeds them still work.
  2. to_executorch copies the nonzero result into the default layout for later portable ops and callers. Delegates get the result column-major, as declared, and must follow its dim order.
  3. The edge verifier accepts aten.masked_select. PyTorch's export bounds its output size wrongly: an input that broadcasts to a bigger mask can fail at run time, and inputs of 2 elements or fewer fail in to_executorch(), both with an error. Same-shape x[mask] avoids this.
  4. Memory planning never reserves a buffer for an input left on a device, so the method reads the caller's tensor. Host inputs still follow alloc_graph_input.
  5. The emitter stores a GPU constant from a host copy when only delegates, device copies or the output read it. If a CPU kernel reads it, export still raises the Raise InternalError for non-CPU constant storage in the emitter #24202 error, since that op was traced on the GPU.
  6. MethodMeta counts a .ptd constant as memory planned, like an inline one. The bindings stop giving it a zero-filled output buffer, and set_output_data_ptr on it returns InvalidState.

For C++ callers: programs exported before this change or with _skip_dim_order=True return nonzero results column-major, as declared. Read them through their dim order, or re-export without _skip_dim_order.

What was tested

Each commit adds tests that fail without it. Tests for the changed code (kernels, method, exir, pybindings, CUDA) pass on Linux x86_64 with an H100, except failures also seen without this change. Old programs feeding nonzero to gather or scatter match eager.

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

@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.

❌ 1 Pending, 2 Unrelated Failures, 2 Unclassified Failures

As of commit ee9b608 with merge base a84676e (image):

UNCLASSIFIED FAILURES - DrCI could not classify the following jobs because the workflow did not run on the merge base. The failures 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.

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 portable `gather` and `scatter` kernels read their index tensor as if
it were stored row by row. With a `nonzero` result as the index, they
were right only because of the old row-by-row writes. They now read each
index value through the strides of the index tensor, as `index` already
does, so a program where `nonzero` feeds `gather` or `scatter` keeps
working, including programs exported before this change.

A `nonzero` result now has the `(1, 0)` layout that the program
declares. C++ code that reads it row by row, and a delegate that takes
it as an input, must follow its dim order, as the CUDA backend does.

The new kernel tests fail 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 most ops that use
the result fail 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, and the
caller, read it as usual. A delegate that reads the result still gets
the result itself, because the delegate was compiled for its dim order.
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 upper bound of the result.

Like the other copies of this pass, this needs dim order ops, which are
on by default. With `_skip_dim_order=True`, and in programs exported
before this change, there is no copy and the result keeps its `(1, 0)`
layout: `gather`, `scatter` and `index` read it right, the other ops
refuse it with an error as before, and the caller must follow its dim
order. Exporting again without `_skip_dim_order=True` adds the copy.

The new tests fail without this change. One runs a program and compares
it with eager; it also fails without the previous fix to the `nonzero`
kernel, since the copy reads the kernel output. The other checks that a
delegate reads the result itself while a portable op reads the copy.
`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.

Limits remain that come from the output size bound that PyTorch's
fake `masked_select` sets during `torch.export`. A decomposition in
ExecuTorch runs after that, so it does not remove them (it fixes only
inputs of exactly 2 elements):
- The bound is the size of the input, not of the input broadcast to the
  mask. If the input broadcasts to a bigger mask, a call that selects
  more elements than the input has fails at run time.
- An input with 2 elements or fewer gets no bound, so `to_executorch`
  fails with an error about an unbounded size.

These limits stop with an error, never with wrong values. Boolean
indexing such as `x[mask]` (after `torch.broadcast_tensors` if the
shapes differ) avoids them, because export turns it into
`aten.index.Tensor`.

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 it, which it
refuses to do for device memory, so the exported program failed on
every call with "Planned-buffer copy needs host memory on both sides".
MemoryPlanningPass(alloc_graph_input=False) avoided that, but it also
unplans every host input of the method.

PropagateDevicePass now marks such an input, as it marks constants that
it keeps on the device, and memory planning never gives a marked input a
buffer, whatever alloc_graph_input says. The method then reads the
caller's tensor in place. Host inputs still follow alloc_graph_input,
and the memory planning verifier leaves marked inputs out of its check.
The PropagateDeviceConfig docstring and the CUDA and TensorRT pages no
longer ask for alloc_graph_input=False.

Tests: test_inputs_left_on_device_are_not_planned replaces a test that
pinned the old default, and a CUDA test now runs such a program with
the default planning. Both fail without the change. Assertions that
expected a planned buffer for such an input are gone, since that plan
fails on every call.
@shoumikhin
shoumikhin force-pushed the nonzero-masked-select-device-io branch from abe9f62 to ad64232 Compare November 7, 2026 18:04
@shoumikhin shoumikhin changed the title Fix nonzero, masked_select, GPU constants and returned external constants, and refuse device inputs that cannot run Fix nonzero, masked_select, GPU constants and returned external constants, and leave device inputs unplanned Nov 7, 2026
Copilot AI balanced review requested due to automatic review settings November 7, 2026 18:04
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

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.

@claude

claude Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 23m 44s —— View job


PR Review: #24771

Needs discussion: the nonzero kernel fix silently changes the layout that C++ callers read from any program exported with _skip_dim_order=True, a setting many in-tree export paths hard-code; the other five findings are minor.

Detailed review

Summary

This PR makes the portable nonzero kernel write through its output strides and copies the result into the default dim order. It also:

  • accepts masked_select in the edge verifier
  • leaves method inputs that stay on a device unplanned
  • serializes GPU constants from a host copy
  • counts external constants as memory planned

The main problem is that the nonzero layout change affects every program exported with _skip_dim_order=True, not only programs exported before this PR. The other findings are minor:

  • an error message that no longer matches its cause
  • a weaker memory planning check
  • two small issues in the new code
  • masked_select limits with no test

Backward Compatibility

  • kernels/portable/cpu/op_nonzero.cpp:69-83: the new layout reaches every program exported with _skip_dim_order=True, and many in-tree export paths hard-code that setting.
    • LegalizePortableDimOrderPass, which adds the new copy, runs only when _skip_dim_order is false (exir/program/_program.py:807-808). Without the copy, the kernel now writes the (1, 0) output column by column.
    • A C++ caller that reads const_data_ptr<int64_t>() row by row got the right indices before this PR. Now it reads them in the wrong order, with no error. A delegate that ignores dim order has the same problem.
    • The description tells these users to re-export without _skip_dim_order. Many in-tree flows can't do that without a code change, because they hard-code the setting:
      • extension/export_util/utils.py:20-23: the default config of export_to_edge and export_to_exec_prog, which examples/portable/scripts/export.py uses
      • the backend configs in backends/cadence/aot/compiler.py:299, backends/samsung/utils/export_utils.py:27, backends/native/__init__.py:23 and backends/apple/coreai/__init__.py:59
      • examples/xnnpack/aot_compiler.py:114, examples/cuda/scripts/export.py:30, examples/apple/coreml/scripts/export.py:40, and many model export scripts under examples/models/
    • Either add the nonzero copy in this mode too, or announce the change as BC-breaking in the release notes. The copy would have to be _to_dim_order_copy, because the portable _to_copy kernel refuses to change dim order (kernels/portable/cpu/op_to_copy.cpp:50-52).

Code Quality

  • exir/emit/_emitter.py:489-499: the error no longer states why it fires.

    • The message still says that "constants serialized into the program must be in CPU memory". That is no longer the rule, because the emitter now serializes other GPU constants from a host copy.
    • The error now has two causes: an operator outside any delegate reads the constant, or the storage is meta and has no data. The message names neither.
    • It also does not name the operator that reads the constant, so the user can't tell which op to delegate. _read_on_host could return the first such user for the message to name, as PropagateDevicePass does with first_reader.name (exir/passes/propagate_device_pass.py:588-591).

    Fix this →

  • exir/emit/_emitter.py:223: copy ops are matched by namespace through getattr.

    • getattr(user.target, "namespace", None) != "et_copy" is the dynamic getattr pattern that CLAUDE.md asks to avoid.
    • It would also exempt any op added to et_copy later.
    • Compare against torch.ops.et_copy._h2d_copy.out and _d2h_copy.out explicitly, as exir/tests/test_propagate_device_pass.py:95-97 does.

    Fix this →

  • exir/memory_planning.py:286-292: the verifier stops checking host inputs when a device input comes last.

    • In verify_graph_input_output, the last placeholder decides graph_input_allocated.
    • A device input now sets that flag to self.alloc_graph_input, so the final assert compares that value with itself.
    • Take a method shaped like forward(a_host, b_device), the shape test_skip_h2d_partial_with_intermediate_input (exir/tests/test_propagate_device_pass.py:1809) exports. If a is planned wrongly, the verifier no longer catches it.
    • Set the flag from a skipped placeholder only when it is still None.

    Fix this →

  • exir/passes/legalize_portable_dim_order_pass.py:101-104: _nonzero_results is never cleared.

    • The dict holds every copy and its result, with their FX nodes and fake tensors, for as long as the pass instance lives.
    • to_executorch creates a new instance for each method. But edge.transform([LegalizePortableDimOrderPass()]) reuses one instance across all methods of a program.
    • Clear the dict at the start of call().

    Fix this →

Testing

  • exir/program/test/test_program.py:439-458: the two masked_select limits in the description have no test.

    • The description says that two cases fail with an error, never with wrong values:
      • an input that broadcasts to a bigger mask
      • an input of 2 elements or fewer
    • test_masked_select only uses a 2×3 input with a mask of the same shape.
    • A test that asserts each error would catch a PyTorch change to the fake masked_select bound that turns either case into wrong output.

    Fix this →

Recommendation

Needs Discussion

The nonzero layout change should either reach _skip_dim_order=True programs through the copy, or be announced as BC-breaking, before this lands. The other findings are minor and can be fixed here or in a follow-up.


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

I didn't change any code. I didn't build or run tests either, so every finding comes from reading the code. All 27 changed files were reviewed.

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,
or when a delegate reads a constant from outside it. to_executorch()
then raised an InternalError (and crashed before pytorch#24202).

A program stores constants as host bytes whatever device they came
from. So when only delegates, copies between host and device memory,
or the method output read such a constant, the emitter now reads it
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.

The InternalError stays when an operator outside any delegate reads
the constant. That operator was traced on the GPU but runs on a CPU
kernel, which may not match (pytorch#21545): a GPU batch norm lowered with no
backend, for example, gets statistics of a shape the CPU kernel
rejects, and fails on every call. Storage with no data, such as
"meta", still raises too.

Test plan: CUDA tests export a GPU module that returns (x * 2, buffer)
with the CudaPartitioner, and a GPU module whose constant a delegate
reads from outside it. Both run, match eager, and fail without the
change. Another test checks that a GPU Linear lowered with no backend
still raises, and the "meta" test now also covers a buffer that only the
output reads.
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 C++ and Objective-C header
documentation now says that constants count as memory planned.

Callers can see this change 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, and a sum that uses it, from a
program exported with external constants. It checks that the buffer
output reports is_memory_planned() and that both outputs stay right
over 2 runs. A C++ test checks that the external constant attributes of
an existing test program report is_memory_planned().
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@shoumikhin
shoumikhin force-pushed the nonzero-masked-select-device-io branch from ad64232 to ee9b608 Compare November 7, 2026 19:16
Copilot AI balanced review requested due to automatic review settings November 7, 2026 19:16

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.

@claude

claude Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 33m 34s —— View job


PR Review: #24771

Needs discussion: the portable nonzero kernel now writes its result column by column, as its dim order says, so any reader that ignores the dim order gets transposed indices with no error — a delegate in a new export, or gather's self and scatter's src in an older or _skip_dim_order=True program; the other findings are minor.

Detailed review

Summary

The PR makes the portable nonzero kernel write through the output strides and copies its result into the default dim order for portable kernels, lets masked_select through the edge dialect check, serializes a GPU constant from a host copy when no host operator reads it, keeps external constants that a method returns from being overwritten, and leaves method inputs that stay on a device unplanned. The main problem is that readers that ignore the dim order of the nonzero result now read transposed indices with no error. The rest are test gaps, a copy that is often unneeded, and small emitter and verifier problems.

Code Quality

  • exir/emit/_emitter.py:223: _read_on_host finds the copy ops with getattr(user.target, "namespace", None) != "et_copy". CLAUDE.md asks for no dynamic getattr, and this matches any present or future et_copy op. exir/passes/propagate_device_pass.py:251 and :382 compare the et_copy targets explicitly. The emitter sees the out variants, so compare with those, for example torch.ops.et_copy._h2d_copy.out. Fix this →
  • exir/emit/_emitter.py:489-499: the error no longer matches the rule it enforces. It still says constants "must be in CPU memory", but device constants are now accepted when no host operator reads them. For meta storage, "Move them to CPU" is the wrong advice, because there is no data to move. The message also doesn't name the host operator that reads the constant, although _read_on_host finds that operator and then returns only a bool. Fix this →
  • exir/memory_planning.py:288-291: in verify_graph_input_output, a placeholder with no specs left after filtering now sets graph_input_allocated = self.alloc_graph_input. The last placeholder decides the final value. So for forward(a_host, b_device), the check at lines 318-320 compares alloc_graph_input with itself, and a wrong allocation of a_host passes. Set the flag only while it is still None. Low confidence, not run: the old branch also set graph_output_allocated for parameter placeholders. A method with parameters whose output has no tensor specs (for example, one that returns only a SymInt) therefore passed the assert at line 322 by accident, and now fails it with "graph_output_allocated not set". If such methods are supported, this is also a BC break. Fix this →

Testing

  • backends/cuda/tests/test_cuda_export.py:1118-1120: test_buffer_returned_outside_the_delegate returns early in fbcode before edge.to_executorch() (line 1126). So in fbcode it never runs the emitter path this PR changes, although only the runtime is missing there. Call to_executorch() before the guard. Fix this →
  • runtime/executor/test/method_meta_test.cpp:207-220: ExternalConstantAttributesAreMemoryPlanned checks only the attributes of add_mul_program. Nothing tests the case the commit fixes: a returned external constant should report is_memory_planned() in its output meta, and set_output_data_ptr should reject it with InvalidState (runtime/executor/method.cpp:1626-1633). Fix this →
  • exir/program/test/test_program.py:439-458: test_masked_select only uses a 2×3 input with a mask of the same shape. Commit a45ddcd documents two limits, and neither is tested: an input with 2 elements or fewer, which to_executorch() rejects, and an input that broadcasts to a bigger mask and selects more elements than it has, which fails at run time. The commit promises these stop "with an error, never with wrong values", so a test should pin that. Fix this →
  • backends/cuda/tests/test_device_placement.py:125-143: test_gpu_constant_read_by_a_delegate exports through load(), which uses the default device config, so the constant reaches the delegate through _h2d_copy. No test covers the executorch_call_delegate exemption in _read_on_host (exir/emit/_emitter.py:222), where a delegate reads a GPU constant directly. That needs a module still on the GPU, exported with PropagateDeviceConfig(skip_h2d_for_constants=True). Fix this →

Backward Compatibility

  • kernels/portable/cpu/op_nonzero.cpp:69-80: the kernel now writes through the output strides. Export gives nonzero the (1, 0) dim order, so the result is now written column by column, where it used to be row by row. Any reader that ignores the dim order now reads transposed indices, with no error:
    • Delegates in new exports. call_delegate (exir/passes/legalize_portable_dim_order_pass.py:148-153) gives a delegate the original (1, 0) result instead of the copy. It assumes the delegate honours the dim order it was compiled for (docstring, lines 86-89). A delegate that treats its inputs as contiguous was correct before this PR and is silently wrong now. The only delegate test (exir/tests/test_legalize_portable_dim_order_pass.py, with CpuOnlyPartitioner) checks the graph wiring and runs nothing. Consider inserting the copy before partitioning instead, in _generate_edge_program next to convert_constant_dim_order_pass (exir/program/_program.py:831-838), whose comment states the same goal: "so a delegate sees the layout the program stores". Delegates would then compile for the default dim order. That also removes the call_delegate override and the _nonzero_results dict (lines 101-104), which is never cleared. The cost is that partitioners see the copy. Fix this →
    • Programs exported before this PR, and _skip_dim_order=True programs. These get no copy. gather reads self (kernels/portable/cpu/op_gather.cpp:57) and scatter reads src (kernels/portable/cpu/op_scatter.cpp:51) with coordinateToIndex, which is row-major, and neither kernel checks the dim order. So torch.gather(torch.nonzero(x), 1, idx) returned the right values before this PR and returns wrong ones now. Commit 9a878c2 says that in these programs "gather, scatter and index read it right, the other ops refuse it with an error", but that holds only where the result is the index. Read self and src through their strides with linearize_access_indexes, as the kernels now read the index. A tensor_is_default_dim_order check would also reject [N, 1] results, whose bytes are the same in both layouts. Fix this →

Performance

  • exir/passes/legalize_portable_dim_order_pass.py:155-164: the copy follows every nonzero, including when no reader needs it. A reader that uses the result only as an index already follows the strides: index.Tensor, and now the index of gather and scatter. exir/emit/test/test_emit.py:667-668 now expects a copy before index.Tensor. The copy also runs for a 1-D input, whose [N, 1] result has the same bytes in both layouts. Each copy costs an extra kernel call and a second upper-bound buffer of numel × ndim × 8 bytes, live at the same time as the result. That is about 25 MB for a [1, 3, 512, 512] input. Skip the copy in those cases. Fix this →

Recommendation

Needs Discussion

Before merging, decide which readers that ignore the dim order of the nonzero result must keep working. gather's self and scatter's src in programs without the copy have a small, concrete fix that should land in this PR. For delegates that treat their inputs as contiguous, either move the copy before partitioning or state the contract that delegates must honour the dim order. The other findings are minor.

  • Read the diff and the PR context
  • Run three independent reviews
  • Merge duplicate findings
  • Fact-check findings
  • Post the review

I didn't change any code. I didn't build or run tests either, so every finding comes from reading the code. The verifier's output-side point is marked low confidence for that reason. All 27 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