Skip to content

Make nonzero and the Vulkan, OpenVINO and WebGPU delegates follow the declared dim order - #24771

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

shoumikhin wants to merge 8 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

Programs declare a dim order (memory layout) per tensor, but:

  • The portable nonzero kernel writes its result row by row, while the program declares it column by column (dim order (1, 0)), as PyTorch returns it. So the indices are wrong.
  • The Vulkan, OpenVINO and WebGPU delegates copy inputs and outputs as flat bytes, so channels-last or column-major tensors at a delegate boundary get wrong values. This hits callers that follow the method's dim order, like Python.

What this change does

  1. Vulkan, OpenVINO and WebGPU convert inputs and outputs in another dim order through shared runtime helpers.
  2. nonzero, gather, scatter, unsqueeze_copy and narrow_copy follow strides, so they read any input in its declared dim order, including the (1, 0) result of older or _skip_dim_order=True programs. scatter and scatter_add with a 0-dim index no longer read uninitialized memory.
  3. Export copies the nonzero result into the default dim order before partitioning, also through exir.capture, and the Qualcomm backend keeps that copy, so every op and delegate is compiled for the layout it reads. idx.float() and idx / 2 now export. as_strided of the result itself still reads the original.

Behavior change

  • Who: a C++, Java or Objective-C caller that, at one of these delegates or kernels, passes NCHW bytes in a default tensor for an input in a non-default dim order, or reads such an output as NCHW. Also code that reads a returned nonzero result row by row from older or _skip_dim_order=True programs.
  • Before: these delegates and kernels ignored the dim order, which gave that caller right values.
  • After: data follows its declared dim order.
  • What to do: pass data in the method's dim order (from MethodMeta, via make_tensor_ptr with a dim order, or Tensor.fromBlob with dimOrder on Android), or export with a contiguous example input.
  • Older runtimes can silently return wrong indices for programs exported with this change that use nonzero. Update the runtime first.

What was tested

Each new test fails without its fix. On Linux x86_64, C++ kernel and util tests and exir, Vulkan, OpenVINO, WebGPU, CUDA and Python bindings tests pass, except tests that also fail on main. Apart from the callers above, programs exported on main that gave right values still do. Vulkan and WebGPU ran on a software driver, OpenVINO on its CPU plugin.

Fixes #24763

@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 Cancelled Job, 4 Unrelated Failures

As of commit cf1f16d with merge base e0015e1 (image):

CANCELLED JOB - The following job was cancelled. Please retry:

BROKEN TRUNK - The following jobs failed but were 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.

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

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

@shoumikhin
shoumikhin force-pushed the nonzero-masked-select-device-io branch from ee9b608 to 1fd85e6 Compare November 8, 2026 00:39
Copilot AI balanced review requested due to automatic review settings November 8, 2026 00:39

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

Thanks. Changes:

  • Kernels: gather and scatter read self and src through strides, and scatter copies self in its output's dim order. unsqueeze_copy had the same bug and nonzero misread its own input, so both follow strides. Old and _skip_dim_order=True programs where nonzero feeds them work; narrow_copy refuses such input.
  • Delegates: the copy stays in to_executorch. Moving it does not help runtimes that ignore input dim order (Vulkan claims and drops it), and Qualcomm's to_edge transform would delete it before partitioning. The description states the contract; a CUDA test runs a delegate reading the result. The dict is per pass instance, one per method.
  • Verifier: the input flag is set only while unset; a method returning only .item() now passes.
  • Emitter: explicit et_copy targets; separate messages for meta storage and a named host op.
  • Tests: masked_select limits, the delegate case with skip_h2d_for_constants, to_executorch() before the is_fbcode() return.

Not changed: a C++ test for the returned external constant (same predicate as the Python test; needs a new generated program), and skipping the copy for 1-D or index-only readers (kernels compare dim orders, not bytes, so [N, 1] results need it; index-only use is rare).

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

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


PR Review: #24771

Approve with one follow-up: Vulkan, WebGPU and OpenVINO delegates that take a host nonzero result as input now return wrong results with no error, and the other three findings are minor.

Detailed review

Summary

The PR makes the portable nonzero kernel write the (1, 0) dim order the program declares, and copies the result to the default dim order for portable kernels. It also lets masked_select pass the edge dialect check, serializes GPU constants from a host copy, leaves device method inputs unplanned, and stops overwriting external constants that a method returns. There is one backward-compatibility regression in in-tree delegates and three minor issues. All 37 changed files were reviewed.

Backward Compatibility

  • Delegates that ignore dim order now silently misread a host nonzero result.
    • op_nonzero.cpp:70-84 now writes column by column, as the (1, 0) dim order says, and LegalizePortableDimOrderPass.call_delegate (legalize_portable_dim_order_pass.py:148) gives a delegate the raw result, not the copy.
    • The d6facc1 commit message says the Vulkan, WebGPU and OpenVINO runtimes read every input in the default dim order, so they "now misread such an input". For example, VulkanBackend.cpp:855-859 copies numel() elements from const_data_ptr() without checking the dim order.
    • Before this PR the kernel wrote row by row, so those delegates got the right answer by accident. Now they get wrong indices and report no error.
    • The same silent change hits C++ callers that read a returned nonzero result row by row, in programs exported earlier or with _skip_dim_order=True.
    • This is separate from where the copy lives, which the thread settled. The ask is to make the misread fail loudly. Those runtimes could return an error when tensor_is_default_dim_order (runtime/core/exec_aten/util/tensor_util.h:1283) is false for an input, or their partitioners could decline such inputs. At minimum, link tracking issues for the three backends from the PR. Fix this →

Performance

  • nonzero now does ndim multiply-adds per input element, even when the input is contiguous. op_nonzero.cpp:77-81 rebuilds in_index from every stride on each iteration, where the old loop read in_data[i]. For the usual contiguous mask in_index == i, so the extra work is pure overhead on a scan of the whole input. Read in_data[i] when tensor_is_default_dim_order(input) holds, and keep the strided read for other dim orders. Fix this →

Code Quality

  • In the recommended config, a method that returns its device input turns off the memory planning check.
    • The PropagateDeviceConfig docstring now recommends MemoryPlanningPass(alloc_graph_output=False) with the default alloc_graph_input=True (propagate_device_config.py:41).
    • With skip_d2h_for_method_outputs, the output reads a device input placeholder directly (propagate_device_pass.py:583). Its spec is then in get_graph_output_tensors, and also in get_graph_input_tensors, which skips device-resident constants (memory_planning.py:457) but not device inputs.
    • verify_graph_input_output then returns early at memory_planning.py:249-253 and checks none of the method's inputs or outputs.
    • Skip meta["device_input"] placeholders in get_graph_input_tensors too. collect_specs_from_nodes already skips their specs at line 561, so planning doesn't change. Fix this →
  • The _d2h_copy.out exemption in _host_reader (_emitter.py:230) can never match. PropagateDevicePass creates a _d2h_copy only for non-constant method inputs (propagate_device_pass.py:425-434, 598-601) and for delegate outputs (_insert_d2h_for_getitem, 641-642). No _d2h_copy ever reads a constant, so the entry describes a reader that doesn't exist. Drop it. Fix this →

Recommendation

Approve

Nothing blocks. The Vulkan, WebGPU and OpenVINO misread comes from a dim-order gap those runtimes already have, which this PR exposes for nonzero results. It shouldn't stay silent, so add a guard or tracking issues. The other findings are minor.

Progress

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

Review only: no code was changed, and nothing was built or run.

A delegate whose graph reads and writes every tensor in the default dim
order has to copy an input stored in another dim order, such as channels
last or the column-major result of `nonzero`, into a buffer in the
default order, and copy an output from such a buffer back into its own
dim order. The next changes make the Vulkan, OpenVINO and WebGPU
runtimes do this, so this adds the shared code first.

`tensor_data_is_in_default_dim_order()` tells whether the elements of a
tensor already sit where the default dim order puts them. Like
`output_is_raw_copyable` in the CUDA runtime, it ignores the strides of
dims of size 1, so a channels-last tensor with a height and a width of 1
needs no copy.

`copy_to_default_dim_order()` and `copy_from_default_dim_order()` copy
between a tensor and such a buffer. They visit the elements in the
default dim order, with a loop over the last dim and an odometer over
the other dims that tracks the offset in the data, so they need no
division per element. Elements of 1, 2, 4 or 8 bytes are copied with a
fixed-size copy. A tensor whose data is already in the default order is
copied with one memcpy.

The new tests copy a [2, 3, 4] tensor stored in each of its dim orders
to and from a buffer, for elements of 1, 2, 4 and 8 bytes and with the
element size known only at run time. They also cover scalars, empty
tensors and dim orders that differ only in dims of size 1.
The Vulkan graph reads and writes every tensor as if it were stored in
the default dim order, but the runtime copied inputs and outputs as flat
bytes. So an input in another dim order, such as a column-major `[N, 2]`
index, was read in the wrong places, and an output declared in another
dim order, such as channels-last, got its data in the default order,
with no error.

The runtime now copies such an input into the default order before the
upload, and such an output from the default order into its own after the
download, with `copy_to_default_dim_order()` and
`copy_from_default_dim_order()`. A tensor whose data already sits in the
default order, such as any tensor in the default dim order, takes the
same path as before, with no extra copy. Before, a channels-last input
and output came out right only for elementwise ops on tensors of one
shape, because both were copied flat. Now the op does not matter.

This also applies to method inputs and outputs that go straight to the
delegate. A caller that passes a tensor in the default dim order for
such an input has its data treated as already in the method's dim order,
as portable kernels always did. Such an output now holds its data in its
declared dim order. So a caller that relied on the flat copies must now
pass and read the data in the method's dim order.

The portable `nonzero` kernel still writes its result row by row,
although the program declares dim order `(1, 0)` for it. A Vulkan
delegate that reads such a result was right only because neither side
followed the dim order, so it reads that result wrong until the kernel
writes in its declared order.

The new tests pass a column-major index to a delegated `gather`, and
scale the channels of a channels-last input. Both fail without the fix.
OpenVINO reads and writes every buffer as if it were stored in the
default dim order, but the runtime handed it each input and output
buffer with only its sizes. So an input in another dim order, such as a
column-major `[N, 2]` index, was read in the wrong places, and an output
declared in another dim order, such as channels-last, got its data in
the default order, with no error.

The runtime now copies such an input into a buffer that OpenVINO owns,
in the default order, with `copy_to_default_dim_order()`. Such an output
goes to a buffer that OpenVINO owns, as an output whose size can change
already does, and is copied into its own dim order after inference with
`copy_from_default_dim_order()`. A tensor whose data already sits in the
default order, such as any tensor in the default dim order, takes the
same path as before. Before, a channels-last input and output came out
right only for elementwise ops on tensors of one shape, because both
were handed over as they were. Now the op does not matter.

This also applies to method inputs and outputs that go straight to the
delegate. A caller that passes a tensor in the default dim order for
such an input has its data treated as already in the method's dim order,
as portable kernels always did. Such an output now holds its data in its
declared dim order. So a caller that relied on the flat copies must now
pass and read the data in the method's dim order.

The portable `nonzero` kernel still writes its result row by row,
although the program declares dim order `(1, 0)` for it. An OpenVINO
delegate that reads such a result was right only because neither side
followed the dim order, so it reads that result wrong until the kernel
writes in its declared order.

The OpenVINO guide and README no longer say that a returned copy made
with `.to()`, a copy in another dim order, or a copy of a value computed
inside the partition can come back wrong, since each comes back right
now. For the same reason, the partitioner now keeps a copy of a constant
in the partition also when the copy is in another dim order, as it
already did for the other copies of constants, so the program stores the
constant once. Its docstring no longer gives the old limits as reasons.

The new tests pass a column-major index to a delegated `index_select`,
scale the channels of a channels-last input, and return a channels-last
copy of a buffer that stays in the partition. They fail without the fix.
The WebGPU graph reads and writes every tensor as if it were stored in
the default dim order, but the runtime uploaded inputs and read back
outputs as flat bytes. So an input in another dim order, such as a
column-major `[N, 2]` index, was read in the wrong places, and an output
declared in another dim order, such as channels-last, got its data in
the default order, with no error.

The runtime now copies such an input into the default order before the
upload, with `copy_to_default_dim_order()`. It reads such an output back
into a buffer in the default order and copies it into its own dim order
with `copy_from_default_dim_order()`. A tensor whose data already sits
in the default order, such as any tensor in the default dim order, takes
the same path as before, with no extra copy. Before, a channels-last
input and output came out right only for elementwise ops on tensors of
one shape, because both were copied flat. Now the op does not matter.

This also applies to method inputs and outputs that go straight to the
delegate. A caller that passes a tensor in the default dim order for
such an input has its data treated as already in the method's dim order,
as portable kernels always did. Such an output now holds its data in its
declared dim order. So a caller that relied on the flat copies must now
pass and read the data in the method's dim order.

The new native tests feed a column-major index to a delegated `gather`,
and scale the channels of a channels-last input. Both fail without the
fix. A Python test checks that both models are delegated, so the native
tests cannot pass on portable kernels instead.
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 kernel also reads its input
through its strides now. A `nonzero` that reads another `nonzero` result
stays right this way. Before, it read its input row by row, which
matched only the old row-by-row writes. This also fixes the `nonzero` of
an input whose data really is in another dim order, such as a
channels-last mask.

The portable `gather` and `scatter` kernels read their tensors as if
they were stored row by row, and `scatter` copied `self` into its output
byte by byte. With a `nonzero` result as the index, as the `self` of
either op, or as the `src` of `scatter`, they were right only because of
the old row-by-row writes. They now read the index, `self` and `src`
through their strides, as `index` already does for its indices, and
`scatter` copies `self` into its output through the strides of both. A
contiguous index is still read in order, as before. `unsqueeze_copy`
copied its input into its contiguous output byte by byte, so it had the
same problem. It now copies an input in another dim order element by
element, in logical order, and refuses an output that is not in the
default dim order, as `view_copy` already does. `narrow_copy` read its
input row by row too. The slice copy behind it now copies element by
element, through the strides of both tensors, unless its block copy is
right for them: both tensors have the same dim order, and it starts with
dims 0 to dim, in order. That holds for any two contiguous tensors and
for two channels-last tensors sliced along dim 0, so those keep the
block copy. Two channels-last tensors sliced along another dim used to
take the block copy too, so `slice_copy` and `narrow_copy` of a
channels-last tensor along C, H or W gave wrong values, and so did a
`narrow_copy` into an output in another dim order. They take the strided
copy now. The portable and Cadence HiFi `slice_copy` refuse mixed dim
orders before they reach that copy, as before. The Cadence Fusion G3
`slice_copy` checks dim orders only when built with OP_ARG_CHECK, so for
the dtypes it does not pass to `xa_nn_slice`, such as Long, it now gets
the strided copy too, or an error when its output does not have the
shape of the slice. So a program where `nonzero` feeds `gather`,
`scatter`, `unsqueeze_copy` or `narrow_copy` keeps working, including
programs exported before this change and programs exported with
`_skip_dim_order=True`.

A `nonzero` result now has the `(1, 0)` layout that the program
declares. C++, Java or Objective-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, Core ML, Vulkan, WebGPU and OpenVINO backends do.

These kernels read any input in its declared dim order now, not only a
`nonzero` result. So an input that declares another dim order but holds
its data in the default order is read differently than before. A
delegate that writes an output in the default order, whatever dim order
the output declares, produces such an input.

The new kernel tests fail without the fix.
`scatter.src` and `scatter_add` accept a 0-dim index with a 1-dim src,
as PyTorch does:

```
torch.scatter(torch.zeros(3), 0, torch.tensor(1), torch.tensor([5., 6., 7.]))
# tensor([0., 5., 0.])
```

The portable kernels find the src element of each index element from the
coordinate of that index element. A 0-dim index has no coordinate, so
they read the offset in src from uninitialized memory and gave wrong
values or crashed, depending on what the stack held. For `scatter.src`
this happens with a 0-dim or a 1-dim self, and for `scatter_add` with a
1-dim self. `gather` already handles a 0-dim index on its own, and
`scatter.src` and `scatter_add` now do the same: they write, or add, the
first element of src at the position the index holds. `scatter.value`
does not need this, since it reads no src and its output then has at
most one dim.

Without this change the new tests read the uninitialized coordinate, so
whether they fail depends on the stack; valgrind reports the read.
@shoumikhin
shoumikhin force-pushed the nonzero-masked-select-device-io branch from 1fd85e6 to 70d8556 Compare November 8, 2026 11:17
@shoumikhin
shoumikhin requested a review from SS-JIA as a code owner November 8, 2026 11:17
Copilot AI balanced review requested due to automatic review settings November 8, 2026 11:17
@shoumikhin shoumikhin added release notes: backends and removed release notes: exir Changes to any dialects and passes on these dialects, such as memory planning labels Nov 8, 2026
@shoumikhin shoumikhin changed the title Fix nonzero, masked_select, GPU constants and returned external constants, and leave device inputs unplanned Make nonzero and the Vulkan, OpenVINO and WebGPU delegates follow the declared dim order Nov 8, 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.

@pytorch-bot pytorch-bot Bot added the module: vulkan Issues related to the Vulkan delegate and code under backends/vulkan/ label Nov 8, 2026
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 50m 47s —— View job


PR Review: #24771

Request Changes: QNN's default flow and the legacy to_edge path drop the new nonzero copy, so new exports from them silently return nonzero results column by column where main returned them row by row; the other findings are minor.

Detailed review

Summary

The PR changes four things:

  • The portable nonzero kernel now writes through its declared (1, 0) strides.
  • The kernels that read its result follow tensor dim order.
  • The Vulkan, OpenVINO and WebGPU delegates follow tensor dim order.
  • A new NonzeroDimOrderPass copies the result into the default dim order before partitioning.

There is one major problem: QNN's default flow and the legacy to_edge export without that copy. The other findings are minor.

Backward Compatibility

  • QNN's default flow and the legacy to_edge export without the nonzero copy (major).

    • QNN: RemoveRedundancy removes every _clone_dim_order (backends/qualcomm/_passes/remove_redundancy.py:22, where _default_condition at :54-55 always returns True).
      • It is on by default (backends/qualcomm/_passes/qnn_pass_manager.py:167).
      • It runs as a to_edge_transform_and_lower transform pass, after _generate_edge_program adds the copy (exir/program/_program.py:859) and before partitioning.
      • QNN has no nonzero op, so nonzero runs on the portable kernel.
    • Legacy: _to_edge (exir/program/_program.py:705-706) runs MemoryFormatOpsPass without NonzeroDimOrderPass. ExirExportedProgram.to_edge and QNN's capture_program both go through it.
    • Effect: op_nonzero.cpp now writes through the declared (1, 0) strides. A method exported through either path that returns torch.nonzero(x) now returns column-major data, where main returned row-major data. The same holds for every reader that ignores dim order.
    • Why it matters: these are new exports. The PR body says the copy makes every op and delegate read the layout it was compiled for. Its Behavior change section lists only older programs and _skip_dim_order=True ones. Your earlier reply already expected QNN's transform to delete a copy placed before partitioning.
    • Fix:
      • Add NonzeroDimOrderPass() before MemoryFormatOpsPass() in _to_edge.
      • Make RemoveRedundancy keep a _clone_dim_order that reads aten.nonzero. A general dim-order-change check like RemoveCloneOpsTransform._is_non_identity_clone would also keep the channels_last clones that QNN drops today.
      • Add a test for each path.

    Fix this →

  • Callers hit by the delegate dim-order change get no signal at run time (minor).

    • Vulkan, OpenVINO and WebGPU now read each input in its declared dim order. A caller that passes NCHW bytes in a default tensor for a channels_last input now gets wrong results. The PR body documents this.
    • The only trace is the pre-existing ET_LOG(Debug, ...) in validate_input_tensor_layout (runtime/executor/method.cpp:843-851). ET_MIN_LOG_LEVEL defaults to Info (runtime/platform/log.h:25), so default builds compile that log out. An app that upgrades gets wrong outputs with no message.
    • Logging it at Info would make the break visible.

    Fix this →

API Design

  • The OpenVINO docs drop warnings for cases this PR does not fix (minor).

    • The removed paragraph listed three returned copies that are not handled. It sat before backends/openvino/README.md:138, and the same text is in docs/source/build-run-openvino.md. This PR fixes only the dim-order case, through the runtime reorder.
    • _COPY_OPS (backends/openvino/partitioner.py:33-37) still lacks _to_dim_order_copy, which extended_support_dict (:154) accepts.
      • So a returned x.to(memory_format=torch.channels_last) or x.to(copy=True) of a partition input stays in the partition.
      • It can still hit the hazard that the _remove_copies_of_inputs docstring describes: the output is not written, and the input can be overwritten. I could not run OpenVINO, so this consequence is low confidence.
    • test_copy.py has no case for this. It also has none for a returned copy of a value computed inside the partition, whose warning was dropped too.
    • The first paragraph (README.md:112-113) now says a returned clone() of a partition input is handled in any dim order. The kept text at :134-136 says a channels_last CPU clone fails at export.

    Fix this →

Testing

  • The new element-by-element slice path has no strided test (minor).

    • compute_slice (kernels/portable/cpu/util/slice_util.cpp:198-231) now handles every channels-last slice along C, H or W.
    • The only new slice_copy test, ChannelsLastSliceAlongChannels, uses step 1.
    • No test reaches the new "out does not have the shape of the slice." check.
    • A channels-last x[:, :, ::2, ::2] would cover the start + coord[dim] * step mapping.

    Fix this →

Performance

  • Channels-last slices along C, H or W now copy one element at a time on one thread (kernels/portable/cpu/util/slice_util.cpp:194-231, minor).

    • Each element costs an indexToCoordinate call and two stride sums, and the multithreaded block copy is skipped.
    • These slices were wrong on main, so this is the cost of the fix, not a regression.
    • When in and out share a dim order, the block copy is still correct if leading_dims and trailing_dims are products over the dims before and after dim's position in that dim order. Then only differing dim orders, such as a nonzero result read by narrow_copy, need the element loop.

    Fix this →

  • The delegates allocate a tensor-sized buffer on every execute for each tensor in another dim order (minor).

    • Where:
      • Vulkan input_scratch and output_scratch (backends/vulkan/runtime/VulkanBackend.cpp:861,966, resized at :543,977)
      • WebGPU reordered_inputs and reordered_outputs (backends/webgpu/runtime/WebGPUBackend.cpp:194,251)
      • OpenVINO ov_.tensor_create in create_ov_tensor (backends/openvino/runtime/OpenvinoBackend.cpp:777-789)
    • Sizing a std::vector also zero-fills it before it is overwritten.
    • For a channels_last camera input, that is one frame-sized allocation and memset per frame.
    • Keeping the buffers in the handle, as OpenVINO already does for owned_outputs, avoids this.

    Fix this →

Code Quality

  • The as_strided redirect covers only as_strided of the result itself (exir/passes/memory_format_ops_pass.py:88-95, minor).

    • In eager, torch.nonzero(x).float() and torch.nonzero(x) + 1 keep the (1, N) strides.
    • In the Edge program they now read the contiguous copy, so torch.as_strided(torch.nonzero(x).float(), size, stride) reads row-major data.
    • On main this failed to export. Now it exports and differs from eager with no error.
    • The PR body's "as_strided still reads the original" promises more than the pass does.

    Fix this →

  • scatter writes out in two layouts (kernels/portable/cpu/op_scatter.cpp:28-37 against :94 and :144, minor).

    • copy_self_to_out now writes self through out's strides.
    • The scatter loops still write at coordinateToIndex(out, ...), which is a contiguous offset. With out in another dim order, the copied elements and the scattered ones disagree.
    • gather makes the same contiguous write at kernels/portable/cpu/op_gather.cpp:64.
    • ATen gives both ops contiguous outputs, so this is latent.
    • Computing out_ix through out.strides(), as the reads now do, makes both kernels consistent. A dim-order check instead would also reject size-1 cases that work today.

    Fix this →

  • unsqueeze_copy reimplements a helper this PR adds (kernels/portable/cpu/op_unsqueeze_copy.cpp:65-89, minor).

    • out is checked to be in the default dim order, so one call to copy_to_default_dim_order(self, out.mutable_data_ptr()) from tensor_util.h can replace the nbytes check, the memcpy and the BroadcastIndexesRange loop.
    • The helper takes the memcpy path itself, and it avoids one memcpy call per element.

    Fix this →

Recommendation

Request Changes.

  • The legacy _to_edge gap is a one-line fix. The QNN gap needs a condition in RemoveRedundancy.
  • Without both, new QNN and exir.capture exports silently change the layout of returned nonzero results, which the PR body says the copy prevents.
  • Everything else is minor.

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

No code was changed, and nothing was built or run. At least one reviewer read each of the 35 changed files.

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
```

A copy that keeps the layout of the result, such as `idx.float()`,
`idx.clone()` or `idx / 2`, fails earlier, in `to_edge`, because the dim
order ops cannot express `(1, 0)`:

```
Failed to map a given dim_order: [1, 0] to a torch.memory_format
```

`to_edge` and `to_edge_transform_and_lower` now copy the result of
`nonzero` into the default dim order, before `MemoryFormatOpsPass` and
before partitioning. The older `ExirExportedProgram.to_edge`, which
`exir.capture` uses, does the same. So every op after it reads the
default dim order, every delegate is compiled for the layout it reads at
run time, and a caller that reads a returned result row by row reads it
right. `as_strided` of the result still reads the result itself, because
its strides refer to the layout that ATen gave the result. So a delegate
that follows strides, such as OpenVINO, reads it right, and the portable
`as_strided_copy`, which reads only the default dim order, still stops
with an error, as before. `as_strided` of a value computed from the
result, such as `idx.float()`, reads that value in the default dim
order, where eager keeps the layout of `idx`.

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, unless a delegate takes it into its
partition. A graph without `nonzero` is left as it is, so its export
does not pay for retracing the graph.

Like `MemoryFormatOpsPass`, this runs only with 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. Ops that follow strides, such as `gather`, `scatter`,
`unsqueeze_copy`, `narrow_copy` and `index` (as an index), read it
right. Most other ops refuse it with an error, as before. The caller
must follow its dim order. Exporting again without
`_skip_dim_order=True` adds the copy.

The new tests fail without this change, except the `as_strided` test,
which fails if `as_strided` reads the copy. One runs a program that
reads the result through `select_copy` and through `.float()`, and
compares it with eager. It also fails without the previous fix to the
`nonzero` kernel, since the copy reads the kernel output. It also checks
that the copy does not bind the size that depends on the data, as
`nonzero` does: a CUDA delegate that takes the copy without `nonzero`
fails to lower otherwise. Another checks that a graph without `nonzero`
is not retraced. Another exports through `ExirExportedProgram.to_edge`
and checks that the returned result is copied and contiguous. Another
checks that `as_strided` reads the result itself. A CUDA test runs a
program where a CUDA `add` reads the result, another CUDA `add` reads a
`mul` of it, and a portable `select` reads it, and compares it with
eager. `test_optional_tensor_list` now expects the copy before `index`.
The portable `nonzero` kernel now writes its result in its declared dim
order, `(1, 0)`, column by column, and export copies that result into
the default dim order. The Qualcomm backend runs `RemoveRedundancy`
before partitioning, and it removed every `_clone_dim_order`, so it also
removed that copy. Qualcomm has no `nonzero` op, and its partitioner
keeps every op that reads a size that depends on the data on the CPU, so
`nonzero` and the ops that read its result run on the CPU. Without the
copy, such an op, like `idx + 1` or `x[idx[:, 0], idx[:, 1]]`, fails at
run time because its inputs have different dim orders, as on main. And a
returned result keeps dim order `(1, 0)`, so a caller that reads it row
by row, as it could before, reads it wrong.

`RemoveRedundancy` now keeps a `_clone_dim_order` that reads a `nonzero`
result. It still removes the others, as before.

The new test fails without this change.
@shoumikhin
shoumikhin force-pushed the nonzero-masked-select-device-io branch from 70d8556 to cf1f16d Compare November 8, 2026 16:27
@shoumikhin
shoumikhin requested a review from psiddh as a code owner November 8, 2026 16:27
Copilot AI balanced review requested due to automatic review settings November 8, 2026 16:27

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

Thanks. Changes:

  • Older to_edge and Qualcomm: ExirExportedProgram.to_edge, which exir.capture uses, now adds the nonzero copy too. Qualcomm's RemoveRedundancy now keeps a _clone_dim_order that reads nonzero, so its default flow and capture_program keep the copy. A returned nonzero result from these flows is row by row again, as on main, and idx + 1 after it now runs in the Qualcomm flow. Each fix has a test that fails without it.
  • as_strided: the description and the commit message now say that only an as_strided of the result itself reads the original layout. An as_strided of a value computed from it, such as idx.float(), reads the default dim order, unlike eager. On main such a program failed to export.

Not changed here:

  • Info log in validate_input_tensor_layout: a default tensor for an input in another dim order is a supported way to pass data that is already in the method's layout. An Info log there would print on every run for callers that do this right.
  • OpenVINO docs: I ran the copies the removed text warned about: .to(copy=True) and .to(memory_format=torch.channels_last) of an input and of a value computed in the partition, and clones of computed values, including one the partition also reads. All match eager now. On main only the 2 channels-last .to() cases failed. The kept sentence about a channels-last clone failing at export is about dim order off.
  • scatter and gather writes: export always gives these ops an out in the default dim order, and then both paths write the same layout. An out in another dim order was already wrong on main.
  • Slice speed and extra slice tests, per-run scratch buffers, and the unsqueeze_copy helper: they change speed, coverage or code shape, not results.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 31m 52s —— View job


PR Review: #24771

Approve. There are no blockers. The one minor finding is that the "Behavior change" section should warn that programs re-exported with nonzero return wrong indices, with no error, on runtimes built before this PR.

Detailed review

Summary

The PR makes nonzero, the portable kernels that read its result, and the Vulkan, OpenVINO and WebGPU delegates follow the declared dim order, and it copies each nonzero result into the default dim order before partitioning. There are no blockers and one minor finding.

Backward Compatibility

  • Programs re-exported with nonzero return wrong indices, with no error, on runtimes built before this PR. The "Behavior change" section lists only callers of the new runtime.
    • exir/passes/memory_format_ops_pass.py:111 adds a _clone_dim_order after the nonzero output, which is declared in dim order (1, 0). The copy reads its input through strides (1, N) (kernels/portable/cpu/util/copy_ops_util.h:95-100).
    • The kernel on main ignores those strides and writes the result row by row (out_data[out_idx++] = index[j], kernels/portable/cpu/op_nonzero.cpp:75). The copy therefore scrambles the indices.
    • Example: take x = [[0, 1, 2], [3, 0, 0]] from the new tests. An older runtime returns [[0, 2], [1, 1], [0, 0]] instead of [[0, 1], [0, 2], [1, 0]], and every op that reads the copy gets these values.
    • runtime/COMPATIBILITY.md gives no forward-compatibility guarantee, so a release note is enough. It should tell users to update the runtime before they deploy re-exported programs that use nonzero. Fix this →

Recommendation

Approve

Nothing blocks approval. The finding above only needs a note in the PR description.

  • Read the diff and the PR context (8 commits, 38 files)
  • Run three independent reviews
  • Merge duplicate findings
  • Fact-check findings
  • Post the review

No code was changed, and nothing was built or tested.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks. I reproduced it with your example: a runtime built before this change returns [[0, 2], [1, 1], [0, 0]] for a program exported with it. I added a note to Behavior change that says to update the runtime first.

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. module: vulkan Issues related to the Vulkan delegate and code under backends/vulkan/ release notes: backends

Projects

None yet

Development

Successfully merging this pull request may close these issues.

torch.nonzero returns wrong indices, and indexing with its result fails at run time

2 participants