Skip to content

Fix export of zero-stride constants and inputs, and of constants that delegates share - #24404

Open
shoumikhin wants to merge 11 commits into
pytorch:mainfrom
shoumikhin:constprop-zero-stride
Open

shoumikhin wants to merge 11 commits into
pytorch:mainfrom
shoumikhin:constprop-zero-stride

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

What is wrong

Zero strides. ExecuTorch cannot store a zero stride, and PyTorch's contiguity check ignores the stride of a size-1 dimension:

t = torch.tensor(2.0).expand(1)
t.stride()               # (0,)
t.is_contiguous()        # True
t.contiguous().stride()  # still (0,)

So export fails with 0 in strides is not supported for ExecuTorch. in these cases:

  • constant_prop_pass folds self.scale.expand(1).
  • A parameter or buffer holds such a tensor.
  • A constant method returns one.
  • An example input is expanded.

Constants that ops in different delegates read. to_backend gives each such op its own copy (duplicate_constant_node).

  • An op that read the constant from a list, such as cat, kept the original. Lowering then failed with constant data node ... has user ... which has tag ....
  • tag_mutated_buffer gave a mutated buffer to a delegate even when some readers or writers stayed outside. A reader outside got a stale copy.
  • Lowering failed when the new value was computed outside, read outside, or also returned.
  • A buffer that no op reads, as in self.state.copy_(x + x); return self.state, went to the delegate that writes it, and lowering failed.

What this change does

  • without_zero_strides() in exir/tensor.py copies a tensor with a zero stride, keeping channels-last. constant_prop_pass, convert_constant_dim_order_pass (run by to_edge) and TensorSpec.from_tensor(const=True) use it. That pass also removes a zero stride from method inputs. An input whose zero stride is on a dimension larger than 1 must then be passed contiguous.

  • duplicate_constant_node copies the constant for every op that reads it, including from a list. The graph output keeps the original. It raises instead of copying a mutated buffer.

  • tag_mutated_buffer gives a mutated buffer to a delegate only when these hold:

    • every op that reads it (old or new value) or writes it is in that delegate;
    • its new value is not returned;
    • some op reads it, unless the partitioner already tagged it for its writer.

    Otherwise the buffer stays in the owning program and loses any tag, with a warning.

Testing

  • New tests in test_emit.py and test_tensor.py, and in test_passes.py and test_utils.py under exir/backend/test. Each fails without its fix. The mutated buffer test runs one program twice against eager.
  • Existing tests under exir/emit, exir/tests and exir/backend/test pass as before.
  • Models without zero-stride constants produce byte-identical .pte files.

Tested on Linux with torch 2.14.0 (CPU).

constant_prop_pass folds ops whose inputs are all constant into new
constant tensors. ExecuTorch cannot store a tensor with a zero stride, so
the pass copies a folded tensor that has one. It used contiguous() for
that copy, but PyTorch ignores the stride of a size-1 dim (and every
stride of an empty tensor) when it decides whether a tensor is
contiguous, so contiguous() can return the tensor unchanged. For example,
expand(1) of a 0-d tensor:

    t = torch.tensor(2.0).expand(1)
    t.stride()               # (0,)
    t.is_contiguous()        # True
    t.contiguous().stride()  # still (0,)

The zero stride then reached to_executorch, which failed with
"0 in strides is not supported for ExecuTorch."

Copy with clone(memory_format=torch.contiguous_format) instead, which
always gives standard strides. Folded constants without a zero stride are
not touched, and the ones contiguous() already copied come out the same.
@shoumikhin shoumikhin added the release notes: exir Changes to any dialects and passes on these dialects, such as memory planning label Nov 1, 2026
Copilot AI balanced review requested due to automatic review settings November 1, 2026 13:29
@pytorch-bot

pytorch-bot Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

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

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

❌ 2 New Failures, 1 Unclassified Failure

As of commit 0b16218 with merge base 05092c1 (image):

NEW FAILURES - The following jobs have failed:

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

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

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 1, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

A parameter, buffer, or constant method value made with expand() can
have a zero stride on a dim of size 1, for example:

    t = torch.tensor(2.0).expand(1)
    t.stride()               # (0,)
    t.is_contiguous()        # True
    t.contiguous().stride()  # still (0,)

PyTorch ignores the stride of a size-1 dim, and every stride of an empty
tensor, when it decides whether a tensor is contiguous. So the code that
gives constants a layout ExecuTorch can store left these tensors as they
were, and export failed with "0 in strides is not supported for
ExecuTorch." A parameter or buffer failed in to_edge, and a constant
method value failed in to_executorch.

Add without_zero_strides() to exir/tensor.py. It returns a contiguous
copy of a tensor that has a zero stride, and the tensor itself
otherwise. constant_prop_pass uses it in place of its own copy of the
same check, and TensorSpec.from_tensor(const=True), which constant
methods use, uses it after contiguous().

convert_constant_dim_order_pass now also copies parameters, buffers and
lifted constants that have a zero stride. It keeps a channels-last
tensor channels-last, as it does for every other constant, and does not
log its "neither contiguous nor channels-last" warning for these
tensors, since their layout does not change.

Constants without a zero stride come out exactly as before.
Copilot AI balanced review requested due to automatic review settings November 1, 2026 18:38

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 shoumikhin changed the title Fix constant_prop_pass leaving a zero stride on a folded constant Fix export of constants that have a zero stride on a size-1 dim Nov 1, 2026
test_emit.py now imports convert_constant_dim_order_pass directly. The
emit target skips autodeps, so list the dep by hand.
Copilot AI balanced review requested due to automatic review settings November 1, 2026 19:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

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


No blockers. There are three minor findings: without_zero_strides and _normalize treat channels-last differently, the new test doesn't check the placeholder meta["val"], and constants that skip convert_constant_dim_order_pass (for example, ones added after to_edge) still fail with 0 in strides.

Detailed review

PR Review: #24404

Summary

The PR adds without_zero_strides() and uses it in constant_prop_pass, convert_constant_dim_order_pass and TensorSpec.from_tensor(const=True), so constants with a zero stride on a size-1 dim, or on an empty tensor, can be exported. There are no blockers and three minor findings. All findings come from reading the code. This job is not allowed to run Python, so no tests were run.

Code Quality

  • without_zero_strides and _normalize handle channels-last differently.

    • without_zero_strides always copies to contiguous_format (exir/tensor.py:132). That is why _normalize needs its own channels-last branch (exir/passes/convert_constant_dim_order_pass.py:33-35).
    • constant_prop_pass calls the shared helper (exir/passes/constant_prop_pass.py:296), so it does not get that branch.
    • Example: take the test's image layout (sizes (1, 3, 4, 4), strides (0, 1, 12, 3)). Registered as a buffer, it stays channels-last. Produced by folding self.w.expand(1, 3, 4, 4) on a (3, 4, 4) buffer with strides (1, 12, 3), it is stored contiguous.
    • This is not a regression, because the old .contiguous() did the same.
    • Fix: move the channels-last case into without_zero_strides. Every caller then follows one rule, and _normalize becomes tensor.contiguous() if _should_transform(tensor) else without_zero_strides(tensor). TensorSpec.from_tensor(const=True) does not change, because it calls .contiguous() first.
    • Fix this →
  • Constants that skip convert_constant_dim_order_pass still fail, and the same problem with a nonzero stride is not handled.

    • Root cause: the stride of a size-1 dim, and every stride of an empty tensor, does not change which bytes are read. The .pte Tensor table stores only sizes and dim_order (schema/program.fbs:100-121).
    • Even so, dim_order_from_stride rejects these strides when they are zero (exir/tensor.py:81-82) and sorts by them when they are not (103-117).
    • The checks that run before it use is_contiguous(), which ignores these strides. The PR patches three places that create constants. Two gaps remain:
    • Gap 1, other places that create or emit constants.
      • The emitter's placeholder path (exir/emit/_emitter.py:2128-2141) still has the old is_contiguous()/.contiguous() logic.
      • TensorLayout.from_tensor (exir/tensor_layout.py:28-38, called from NamedDataStore.add_named_data at exir/_serialize/_named_data_store.py:216) has the same check.
      • convert_constant_dim_order_pass only runs inside _generate_edge_program (exir/program/_program.py:833, 889).
      • So these constants still fail with 0 in strides is not supported for ExecuTorch.:
        • a constant added by a pass run through EdgeProgramManager.transform() that calls create_constant_placeholder(..., data=bias.expand(1, C)) (strides (0, 1));
        • a delegate tensor passed to add_named_data.
    • Gap 2, a nonzero stride on a size-1 dim.
      • A buffer torch.randn(1, 3).t() (sizes (3, 1), strides (1, 3)) passes is_contiguous().
      • _normalize returns it unchanged, and dim_order_from_stride gives (1, 0). So the _normalize docstring ("default or channels-last dim order") is not true for it.
      • This gap existed before this PR.
    • Suggested fix: canonicalize the strides of size-1 dims and empty tensors once, in dim_order_from_stride. Every call site above already passes sizes. That would cover both gaps without copying. At minimum, send the emitter's placeholder path through without_zero_strides the way TensorSpec.from_tensor(const=True) does.
    • Low confidence on how often this happens in practice: I found no in-tree pass that creates such a constant.
    • Fix this →

Testing

  • test_emit_user_constants_with_zero_stride checks only the stored tensor, not the placeholder meta["val"] (exir/emit/test/test_emit.py:2718-2719).
    • _update_placeholder_meta normalizes the fake value separately (exir/passes/convert_constant_dim_order_pass.py:66-67).
    • The emitter rebuilds stride/dim_order from the real tensor (exir/emit/_emitter.py:2139-2141).
    • So if a regression left b_image's meta["val"] contiguous, both the stride assert and the runtime comparison would still pass. Partitioners and delegates read that meta["val"] layout.
    • The existing pass tests already check this:
      • test_convert_constant_dim_order_parameter asserts meta["val"].stride() and that the parameter is still an nn.Parameter (exir/tests/test_passes.py:3300, 3305).
      • test_to_edge_makes_transposed_parameter_contiguous covers both _skip_dim_order values (3323).
    • Adding the zero-stride cases there, and the folded case to test_constant_prop_pass_zero_stride_tensors (3041), would follow that pattern. It would also make the new exir/emit/test/BUCK dependency unnecessary.
    • Fix this →

Recommendation

Approve

Nothing blocks this PR. All three findings are minor and none is a regression. The channels-last fix and the meta["val"] assert are small enough to add before merge. The gap for constants added after to_edge can be a follow-up.

When a constant has users in different partitions, or in a partition
and on the CPU, to_backend gives each user its own copy of the
placeholder. duplicate_constant_node only swapped the copy into the
top-level args and kwargs of a user, so an op that reads the constant
from a list, such as cat or the indices of index_put, kept the
original. Lowering then failed with "constant data node ... is tagged
with ... but has user ... which has tag (None)". For example, a Core ML
model whose index buffer is read by an index_put in Core ML and by
another index_put on the CPU failed this way.

The pass now swaps the copy in wherever the user reads the constant. A
mutated buffer keeps its list readers on the original, since a copy
would not see the buffer's updates, so such a model still fails to
lower instead of reading a stale value.
Copilot AI balanced review requested due to automatic review settings November 1, 2026 21:28

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 shoumikhin changed the title Fix export of constants that have a zero stride on a size-1 dim Fix export of zero-stride constants and of shared constants read from a list Nov 1, 2026
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 24m 15s —— View job


One major finding: duplicate_constant_node applies its new mutated-buffer rule only to list readers. A direct reader of a mutated buffer, including the op that computes the update, still gets a copy that won't see the updates. The other four findings are minor.

Detailed review

PR Review: #24404

Summary

This PR does two things:

  • It makes constants exportable when they have a zero stride on a size-1 dim, or are empty tensors with a zero stride.
  • It makes duplicate_constant_node also swap a copy into list arguments, such as those of cat.

There is one major finding, in how duplicate_constant_node handles mutated buffers, and four minor ones. I reviewed all nine changed files. Every finding comes from reading the code: this job can't run Python, so no tests were run.

Code Quality

  • The mutated-buffer rule covers only list readers (major). exir/backend/canonical_partitioners/duplicate_constant_node_pass.py:80-83, 103-132

    • The new comment says a copy of a mutated buffer "would not see its updates". The guard at line 128 keeps list readers on the original.
    • The top-level swap at lines 103-122 has no such guard. It still gives a copy to every node that takes the buffer as a direct argument, including the op that computes the update.
    • The new test locks this in. In case (True, False, 2) (exir/backend/test/test_passes.py:115), the readers of b_const are cat, cat_1 and add. After the pass, add, which computes the new value, reads b_const_copy_1. Nothing reads b_const_copy_0.
    • to_backend can reach this. Partitioners such as Vulkan, QNN and Core ML call tag_mutated_buffer (exir/backend/utils.py:398-430). _maybe_duplicate_constant_nodes (utils.py:224-245) then duplicates a mutated buffer whose readers are in different partitions. The two kinds of reader end up differently:
      • A direct reader in another partition gets a copy. That partition's delegate takes the buffer's value at lowering time (exir/lowered_backend_module.py:653-673), so, as the new comment says, it does not see later updates.
      • A list reader fails at exir/backend/backend_api.py:196-198 with "constant data node … has user … which has tag …". The error does not mention mutation.
    • The direct-reader behavior is not new. But the PR now documents and tests the split as if it were the intended rule.
    • Fix:
      • Check is_mutated_buffer once, before the loop, and raise an error that names the buffer, like the no_copy error at utils.py:232-235. Models that lower today with a copy of a mutated buffer would then fail, so note this in the PR description.
      • The remaining users then need one replace_input_with call each. It already reaches arguments at every depth, so the new_args/new_kwargs comprehensions can go.
    • Fix this →
  • When the graph output reads a constant, the original stays shared with users[0] (minor). duplicate_constant_node_pass.py:84-102, 128

    • The pass makes a copy for the output, but nothing reads it. The original keeps both users[0] and the output.
    • Example: return torch.cat([x, self.const]), self.const, with cat delegated.
      • tag_constant_data gives const the tag of cat, because it ignores untagged users (utils.py:382-395).
      • _assign_new_tag sees the tags {tag, None} on the original and leaves it tagged (utils.py:205).
      • _get_node_list_with_same_tag then raises "… has user (output) which has tag (None)" (backend_api.py:196-198).
    • Case (False, True, 2) (test_passes.py:116) asserts this state.
    • This is not a regression: the old top-level swap never reached into the output tuple either.
    • Fix: decide which users will read a copy before creating any copy. If the output reads the constant, the output keeps the original, and every other user, users[0] included, gets its own copy. Create copies only for those users.
    • Fix this →
  • Two rules for removing a zero stride (minor, unchanged since the previous review). exir/tensor.py:130-132, exir/passes/convert_constant_dim_order_pass.py:33-35, exir/passes/constant_prop_pass.py:295-298

    • without_zero_strides always copies to contiguous_format, so _normalize adds its own channels-last branch. constant_prop_pass calls the helper directly, so it skips that branch.
    • Example: take a buffer w with sizes (3, 4, 4) and strides (1, 12, 3). If w.expand(1, 3, 4, 4) (strides (0, 1, 12, 3)) is folded by constant_prop_pass, it is stored contiguous. The same tensor registered as a buffer stays channels-last.
    • This is not a regression: the old .contiguous() also made it contiguous.
    • Fix: move the channels-last branch into without_zero_strides. _normalize then becomes tensor.contiguous() if _should_transform(tensor) else without_zero_strides(tensor). TensorSpec.from_tensor(const=True) is unaffected, because it calls .contiguous() first.
    • Fix this →

Testing

  • test_emit_folded_constant_with_zero_stride folds before to_edge, where export doesn't need the constant_prop_pass change. exir/emit/test/test_emit.py:2681-2686

    • to_edge runs convert_constant_dim_order_pass (exir/program/_program.py:833, 889), which now fixes the folded constant in exported_program.constants.
    • So the to_executorch and runtime half of the test passes even without the constant_prop_pass.py change. Only the stride assert at line 2682 depends on it.
    • Export needs that change only when constant_prop_pass runs on an edge program, as fold_and_pack_fully_connected_weights does (backends/cadence/aot/weight_packing.py:335). No test covers that path.
    • Fix: fold after to_edge with constant_prop_pass(edge.exported_program()), as exir/tests/test_passes.py:2092 does, then call to_executorch.
    • Fix this →
  • The list-reader fix has no to_backend test. exir/backend/test/test_passes.py:90-133

    • The test calls duplicate_constant_node directly. It counts only the readers of the original, so it doesn't check which node reads each copy.
    • Its program._validate() call repeats one the pass already makes (duplicate_constant_node_pass.py:149).
    • The failure this PR fixes happens in to_backend. test_partitioner_splits_constant_data (exir/backend/test/test_partitioner.py:425) already lowers a constant that is split between a delegate and the owning program. A variant that puts the two cat ops in different partitions would test the fix end to end.
    • Fix this →

Recommendation

Request Changes

duplicate_constant_node should apply one rule to mutated buffers. Otherwise the new comment and test make the current split look intended. The other four findings are minor and none of them is a regression.

A mutable buffer, such as a KV cache, changes on every call. When some of
the ops that read or write it go to a delegate and others stay in the
owning program, tag_mutated_buffer used to tag the buffer for the
delegate anyway, because it ignored untagged readers and never looked at
the op that computes the new value.

- If another op read the buffer outside the delegate, to_backend gave
  that op its own copy. A copy keeps the value from export time, so the
  program lowered and gave wrong results from the second call on. For
  example, with a buffer b = zeros(2), forward doing y = x * b,
  z = x + b, b.add_(1), and only the multiply delegated, the second call
  returned [1, 1] instead of [3, 3].
- If the new value was computed outside the delegate, lowering failed
  with "Buffer output ... does not point to a buffer that exists".

tag_mutated_buffer now tags a mutated buffer only when all of its
readers and writers are in the same partition, as its docstring already
said. Otherwise the buffer stays in the owning program and the delegates
read it as an input. duplicate_constant_node now refuses to copy a
mutated buffer, for direct and list readers alike, with an error that
says how to tag it, instead of making a copy that never sees the
updates.

The existing test_tag_mutated_buffer_detects_indirect_buffer_mutation
computed the new value from a graph input outside the partition, where
the tag it expected made to_backend fail with "Buffer output x does not
point to a buffer that exists". It now computes the value inside the
partition.
…ode copies

When the graph output also returned a constant that ops in two
partitions read, duplicate_constant_node made a copy for the output,
which nothing then read, and left the original with both the first op
and the output. Lowering then failed with "has user (output) which has
tag (None)", for example for a model that returns
torch.cat([x, const]), const with only the cat delegated.

Now the output keeps the original, as the output specs name it, and
every other user reads its own copy. Swapping the constant in for each
user takes one replace_input_with call, which also reaches a constant
inside a list, so the separate argument and keyword rewrites are gone.

A to_backend test now lowers a constant that cat ops in two partitions
read, with and without the output also returning it, and checks which
placeholder each delegate and the output read. It fails without either
the list fix or this one, so it replaces the pass-level list test, which
checked the same cases.
without_zero_strides always copied to the default layout, so
convert_constant_dim_order_pass had its own branch to keep a
channels-last constant channels-last, and constant_prop_pass, which
calls the helper directly, did not. A channels-last tensor with a zero
stride, such as w.expand(1, 3, 4, 4) for a w with strides (1, 12, 3) and size
(3, 4, 4), was then stored channels-last as a buffer but contiguous when
folded.

The helper now keeps a channels-last tensor channels-last, so both
passes follow one rule and the pass loses its extra branch.
TensorSpec.from_tensor is unchanged, since it makes the tensor
contiguous first.
test_emit_folded_constant_with_zero_stride also ran the folded program
on the portable runtime. That part passes even without the
constant_prop_pass fix, because to_edge now removes the zero stride
itself, and test_emit_user_constants_with_zero_stride already runs
zero-stride constants end to end. The test now only checks the stride
of the folded constant, which is what fails without the fix.

It is renamed to test_constant_prop_pass_folds_without_a_zero_stride,
since it no longer emits anything. It stays in test_emit.py because
exir/tests/test_passes.py is not collected by the open source test run.
Copilot AI balanced review requested due to automatic review settings November 2, 2026 02:12
@shoumikhin shoumikhin changed the title Fix export of zero-stride constants and of shared constants read from a list Fix export of zero-stride constants and of constants that delegates share Nov 2, 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 2, 2026 •

Copy link
Copy Markdown

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


One major finding: tag_mutated_buffer now gives a mutated buffer that nothing reads to the delegate that writes it, which main didn't do and which can hit an assert during lowering; the other three findings are minor.

Detailed review

PR Review: #24404

Summary

The PR does three things:

  • It makes constants with a zero stride exportable.
  • It makes duplicate_constant_node reach constants inside list arguments and refuse to copy mutated buffers.
  • It lets a delegate own a mutated buffer only when every op that reads or writes the buffer is in that delegate.

There is one major finding, in tag_mutated_buffer, and three minor ones. All findings come from reading the code. No tests were run.

Backward Compatibility

  • A mutated buffer that nothing reads now goes to the delegate that writes it (major). exir/backend/utils.py:420-425
    • What changed: user_tags now includes the writers' tags. So a buffer placeholder with no users gets the tag of the op that computes its new value. to_edge removes the copy, so that op is the writer, as writer = "aten_mul_tensor_1" in the new test shows.
    • On main: a buffer with no readers was never tagged. It stayed in the owning program, which wrote back the value the delegate returned. The description presents the new rule as a restriction, but here it gives the delegate a buffer it didn't get before.
    • Why it breaks: the delegate-owned path doesn't support a mutated value that is also a graph output. _get_new_signature asserts on that case (exir/lowered_backend_module.py:740-748).
    • Example: self.state.copy_(x * 2); return self.state, with the mul tagged by a partitioner that calls tag_mutated_buffer, such as Core ML with take_over_mutable_buffer. By reading, this lowers on main and hits that assert with this PR. I did not run it.
    • Prior art and coverage: VGF already rejects such a buffer. _validate_mutable_buffers requires the readers' tags to be exactly the writer's tag, and an empty set fails that check (backends/arm/vgf/partitioner.py:87-104). No test in the PR covers a buffer with no readers.
    • Fix: tag only when the buffer has a reader, for example if node.users and len(user_tags) == 1 and None not in user_tags. Add the write-only case to the new to_backend test.
    • Fix this →

API Design

  • tag_mutated_buffer keeps a tag that is already on the buffer (minor). exir/backend/utils.py:424-430
    • Problem: the new docstring and the warning say a buffer read or written outside one partition "stays in the owning program". Neither branch removes a delegation_tag the partitioner already put on the placeholder, so that is not always true.
    • Example: QnnPartitioner.tag_nodes gives every buffer placeholder without users the last partition's tag (backends/qualcomm/partition/qnn_partitioner.py:356-368). With skip_mutable_buffer=False, it then calls tag_mutated_buffer.
      • Take a write-only mutated buffer whose writer runs on CPU. The tags are {None}, so neither branch runs and the QNN tag stays.
      • The delegate then owns a buffer whose new value is computed outside it. The description says this case made lowering fail.
    • Not a regression: main also kept the tag.
    • Fix: call node.meta.pop("delegation_tag", None) when the buffer does not go to a delegate, or say in the docstring that an existing tag is kept.
    • Fix this →

Testing

  • The mutated-buffer tests check the graph, not the values (minor). exir/backend/test/test_utils.py:403-455, exir/backend/test/test_partitioner.py:659-691
    • No runtime check: test_to_backend_keeps_a_mutated_buffer_out_of_a_partial_delegate only checks which owning-program input specs name state. It never calls to_executorch() and never compares two calls with eager.
      • So the stale-copy bug from the description ([1, 1] instead of [3, 3]) has no regression test. It was only checked by hand on XNNPACK.
      • The target already depends on portable_lib, so running the program is cheap.
    • No two-delegate case: the new test never puts the readers in two different delegates. That is the case the removed min(user_tags) branch handled.
    • The edited unit test passes on main too: test_tag_mutated_buffer_detects_indirect_buffer_mutation now tags the reader and the writer. Tagging the reader was already enough on main.
      • Its original model, copy_(x), is the case the new rule changes: the new value is a user input, which is never tagged.
      • Keep that model as a second case that asserts the buffer stays untagged.
    • Fix this →

Code Quality

  • The new rule duplicates Arm's private helper (minor). exir/backend/utils.py:408-430
    • _tag_delegate_owned_buffers (backends/arm/tosa/partitioner.py:69-96) implements the same rule almost line for line:
      • it collects writers from buffers_to_mutate;
      • it takes the tags of [*node.users, writer];
      • it tags the buffer when there is a single tag that is not None, and warns otherwise.
    • Now that the shared util follows that rule, the TOSA partitioner could call tag_mutated_buffer, so the two copies don't drift apart.
    • If the write-only fix above lands, the two copies will differ on that case, so the follow-up has to decide which rule to keep.
    • Fix this →

Recommendation

Request Changes

The write-only buffer change alters main's behavior in a util that eight in-tree partitioners call. By reading, it can break lowering. The fix is one condition plus a test. The other three findings are minor, and the Arm cleanup can be a follow-up.

…wn it

tag_mutated_buffer counts the op that computes a buffer's new value as
a user. So a buffer that no node reads got that op's tag, and the
delegate owned it. That fails in lowering when the new value is also a
user output, for example `self.state.copy_(x + x); return self.state`:
"Constant state was tagged to be consumed by the buffer ...". The same
failure happens when the delegate also reads the buffer, as in
`self.state.add_(x); return self.state`, and that one fails on main too.

Now a buffer is tagged only when every op that reads it (its old value
or its new value) or writes it has the same tag, and no writer is a user
output. Before, an op that read the new value was not counted, so
`self.state.add_(x); return torch.sin(self.state)` with only the add
delegated failed in to_backend, on main too. A buffer that no node reads stays
in the owning program, as on main, unless the partitioner already gave
it to the delegate that writes it (QNN tags such buffers itself).

A buffer that stays also loses any tag the partitioner put on it
before, and a warning says so.

Tests:
- test_to_backend_keeps_a_mutated_buffer_out_of_a_partial_delegate adds
  a case with the new value read outside the delegate, a case with the
  new value returned, write-only cases with and without
  a tag from the partitioner and with the new value returned, and a case
  with the readers in two delegates. It also runs the case with a delegate that reads the
  buffer twice, and compares with eager.
- test_tag_mutated_buffer_detects_indirect_buffer_mutation keeps its
  copy_(x) model, where the new value is a graph input, and checks the
  buffer stays untagged.
An example input can have a zero stride on a size-1 dim, for example
after an expand, and PyTorch still calls it contiguous.
to_executorch then failed with "0 in strides is not supported for
ExecuTorch." when it built the spec of that input.

convert_constant_dim_order_pass, which to_edge runs, already removes
zero strides from constants. It now does the same for every other
placeholder. An input with a zero stride becomes contiguous, or stays
channels-last, as constants do. Other inputs do not change.

Test: test_emit_user_input_with_zero_stride exports with an input of
stride (0, 1), runs the program, and compares with eager.
Copilot AI balanced review requested due to automatic review settings November 2, 2026 13:13

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 shoumikhin changed the title Fix export of zero-stride constants and of constants that delegates share Fix export of zero-stride constants and inputs, and of constants that delegates share Nov 2, 2026
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

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


Approve with five minor findings. The most notable: AOTI's fallback re-tags the mutated buffers that tag_mutated_buffer now leaves in the owning program, so self.state.copy_(x + x); return self.state still fails to lower on CUDA and Metal.

Detailed review

PR Review: #24404

Summary

This PR lets ExecuTorch export constants and user inputs that have a zero stride. It also makes duplicate_constant_node give every reader except the graph output its own copy, and has tag_mutated_buffer leave a mutated buffer in the owning program when a delegate cannot own it. Nothing blocks; the five findings below are all minor. They come from reading the code only: Python could not run in this job, so no tests were run.

Code Quality

  • AOTI, TOSA and VGF still give delegates the mutated buffers that the new tag_mutated_buffer rule keeps in the owning program. This is not a regression, but it means the fix does not reach these backends.

    • backends/aoti/aoti_partitioner.py:108-121: the fallback runs after tag_mutated_buffer (line 104). It tags every unused, untagged buffer placeholder, so it also tags the mutated buffers that tag_mutated_buffer just left in the program. For self.state.copy_(x + x); return self.state the buffer still goes to the delegate, and lowering still hits the len(orig_output_specs) == 1 assertion at exir/lowered_backend_module.py:740. This affects the CUDA and Metal backends.
    • backends/arm/tosa/partitioner.py:69-96: _tag_delegate_owned_buffers only checks [*node.users, writer]. TOSA therefore still gives a delegate a buffer whose new value is returned or read outside the partition.
    • backends/arm/vgf/partitioner.py:87-104: with alias_buffer_mutations=True, _validate_mutable_buffers accepts a buffer whose new value is returned or read in another partition. tag_mutated_buffer at line 135 then leaves that buffer in the program and only logs a warning. The cases the validation does reject fail with an error instead.
    • exir/passes/propagate_device_pass.py:558-563: the error says "tag_mutated_buffer tags the buffer once those ops are there". That is no longer true when the new value is returned or no op reads the old value.

    Fix this →

  • tag_mutated_buffer still gives a delegate two kinds of buffer that lowering can't handle (exir/backend/utils.py:420-443). I have low confidence in both.

    • The returned check only covers USER_OUTPUT, but the assertion at exir/lowered_backend_module.py:740 rejects any second output spec that names the writer (the specs are collected at lines 707-709). The trigger is one value written to two buffers (y = x + self.a + self.b; self.a.copy_(y); self.b.copy_(y)), or to a buffer and a mutated user input. The delegate then gets a buffer that fails lowering. This assumes to_edge removes both copies, so that both specs name y; it does remove the copy for the writer in the new test (aten_add_tensor_2). Counting spec.arg.name over all of sig.output_specs would cover every case.

    • A placeholder writer, as in self.state.copy_(self.init) where a delegated op also reads init and state:

      1. tag_constant_data gives init the delegate's tag, so state gets that tag too (line 439).
      2. _maybe_duplicate_constant_nodes then gives the delegate a copy of init, and the graph output keeps the untagged original (duplicate_constant_node_pass.py:90-94, utils.py:202-207).
      3. The delegate ends up owning state while its new value comes from outside.

      I did not trace lowering past this point. Treating a placeholder writer as outside every partition would avoid this.

    Fix this →

  • without_zero_strides is not guard-safe (exir/tensor.py:131). 0 not in tensor.stride() calls bool() on SymInt == 0, and the pass now calls this function on every placeholder. If an input has an unbacked size that is not known to be non-zero, to_edge raises a data-dependent error. dim_order_from_stride in the same file avoids this with torch._check and guard_or_false (lines 81-101), and its test asserts that no guards are added (exir/tests/test_tensor.py:386). not any(guard_or_false(s == 0) for s in tensor.stride()) would avoid it here too. Low confidence: export rarely gives inputs unbacked sizes.

    Fix this →

Testing

  • test_to_backend_keeps_a_mutated_buffer_out_of_a_partial_delegate can pass without testing the partitions it names, and it compares only one row against eager.

    • exir/backend/test/test_utils.py:436: TagNodes skips any name it does not find, and the names are hard-coded (line 451). If export renames a node, the rows that expect owned_by_program=True still pass, but they test a different partition. Assert that every name in self.tags is in the graph.
    • Line 481: only the {sin: "tag0"} row is compared against eager. BackendWithCompilerDemo supports sin and add, so the row with readers in two delegates ({sin: "tag0", add: "tag1"}) can also run. That row is where a stale copy of the buffer inside a delegate would show up.
    • The case where the new value is a user input (self.state.copy_(x)) is tested only in exir/backend/test/test_partitioner.py:659, and pytest.ini:57 excludes that file from OSS runs.

    Fix this →

API Design

  • An input with a zero stride on a dim larger than 1 now silently changes the layout the method expects (exir/passes/convert_constant_dim_order_pass.py:139-143).

    • Before this PR, exporting with torch.randn(3, 1).expand(3, 4) as the example input failed with "0 in strides is not supported". Now the method expects a contiguous (3, 4) input and nothing is logged. A constant with the same strides does get a warning (lines 65-76).
    • Passing that same example input through pybindings then fails in validate_dense_layout (extension/pybindings/pybindings.cpp:938-945) with "ExecuTorch inputs must have a dense, non-overlapping layout". That message gives no hint that export changed the layout.
    • Log a warning when a dim of size greater than 1 had stride 0, and add a test for that case. test_emit_user_input_with_zero_stride (exir/emit/test/test_emit.py:2722) only uses a size-1 dim, where the layout does not change.

    Fix this →

Recommendation

Approve

Nothing blocks merging. The gaps in the backends and in tag_mutated_buffer are not regressions and can be follow-ups. The node-name assert and the input warning are small enough to add before merging.


  • Gather context (diff, commits, changed files)
  • Run three independent reviewers
  • Consolidate findings (about 10 candidates after deduplication)
  • Fact-check findings (5 kept, all minor)
  • Post review

The Windows unit test job runs pytest against a build that does not
register the demo backend, so the runtime part of the new mutated
buffer test fails there with "Backend BackendWithCompilerDemo is not
registered". Deselect it in pytest-windows.ini, next to the other tests
that run the demo backend.
Copilot AI balanced review requested due to automatic review settings November 2, 2026 16:21

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

Copy link
Copy Markdown

Reviewing PR #24404

  • Gather context (diff, commits, changed files)
  • Run three independent reviewers
  • Consolidate findings
  • Fact-check findings
  • Post review

View job run

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks for the review.

Follow-ups. These are fair. I will do them in a separate PR:

  • The AOTI partitioner's fallback tags unused buffer placeholders after tag_mutated_buffer has run. So a CUDA or Metal buffer that is only written and is also returned still fails to lower, the same as on main. The fix belongs in that fallback, for example running it before tag_mutated_buffer so the shared rule checks it.
  • Update the hint text in propagate_device_pass.
  • Make TagNodes in the test fail when it is given a node name that does not exist. Every row that expects the program to keep the buffer already fails when its part of the fix is removed. Only the sin-only row runs end to end, because the demo backend's add reads fixed argument slots that a two-input delegate does not have.
  • Warn when an input's zero stride is on a dim larger than 1, as constants already do.

VGF already calls tag_mutated_buffer. The Arm TOSA partitioner, which VGF builds on, has its own copy of the rule. Moving it to the shared helper changes Arm lowering, so it belongs in an Arm PR with the Arm tests.

Two buffers with one writer, and a buffer placeholder as the writer. to_edge rejects the first one before any partitioner runs, on main and here. The second fails on main too. It comes from tag_constant_data, which tags a constant that is also a mutation output. That is a separate bug, so I will fix it separately.

Guards from the zero-stride check. With an unbacked size u, a dense tensor's strides are Max(1, u) or products of such terms. So 0 in t.stride() is false without adding a guard. With unbacked sizes, the check added no guards and raised nothing.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

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


Approve with two minor findings: tag_mutated_buffer still gives a delegate a buffer whose new value also updates a user input, which fails lowering, and Windows skips the whole new mutated-buffer test although only one of its nine rows runs a program.

Detailed review

PR Review: #24404

Summary

The PR makes constants and inputs with a zero stride exportable. It also has duplicate_constant_node copy a constant that an op reads from a list and refuse to copy a mutated buffer, and it keeps a mutated buffer in the owning program unless one delegate reads and writes all of it. Nothing blocks. The two findings below are minor, and neither is a regression. They come from reading the code: Python could not run in this job, so no tests were run.

Code Quality

  • The returned check counts only user outputs, but lowering rejects any second output spec on the writer (exir/backend/utils.py:420-424, 443).
    • _get_new_signature groups every top-level output spec by arg.name, whatever its kind (exir/lowered_backend_module.py:630-633, 707-709). For a buffer the delegate owns, it then asserts that the writer has exactly one spec (740-748).
    • So a writer that also backs a USER_INPUT_MUTATION passes the new check. The buffer still goes to the delegate, and lowering hits that assert.
    • Example: def forward(self, x, y): self.state.add_(x); y.copy_(self.state); return x * 2, with the add delegated. to_edge drops the copy, as it does for the writer in the new test (aten_add_tensor_2), so add backs both mutations.
    • The author's reply covers two buffers that share a writer. This case is a buffer and a user input, which the signature keeps in separate dicts (buffers_to_mutate and user_inputs_to_mutate).
    • Main tags this buffer too, so this is not a regression.
    • Low confidence on the trigger: I could not check here that export and to_edge keep both mutations on one node.
    • Fix: count all output specs per name, for example Counter(spec.arg.name for spec in sig.output_specs), and leave the buffer in the program when any writer has more than one. That matches the assert exactly and still covers the USER_OUTPUT case.
    • Fix this →

Testing

  • Windows skips all nine rows of test_to_backend_keeps_a_mutated_buffer_out_of_a_partial_delegate (pytest-windows.ini:109).
    • Only the {sin: "tag0"} row runs a program on the demo backend (exir/backend/test/test_utils.py:481-490). The other eight only check the lowered signature.
    • Fix: move the runtime comparison into its own test and deselect only that test.
    • Fix this →

Recommendation

Approve

Neither finding blocks merging. The returned change is small enough to make before merging. The Windows split is optional.


  • Gather context (diff, commits, changed files)
  • Run three independent reviewers
  • Consolidate findings (about 9 candidates after deduplication)
  • Fact-check findings (2 kept, both minor)
  • Post review

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks. Both points are fair. Neither one breaks anything that works on main today, so I will do them in the same follow-up PR as the items in my earlier reply:

  • Count every output spec that names the writer, not only user outputs, and keep the buffer in the program when the writer has more than one. Then a buffer whose new value also updates a user input stays in the program. First I will check that export really keeps both mutations on one node.
  • Move the run on the demo backend into its own test, and skip only that test on Windows.

@JacobSzwejbka

Copy link
Copy Markdown
Contributor

Why are these 2 conditions?

its new value is not returned;
some op reads it, unless the partitioner already tagged it for its writer.

neither obviously have correctness issues to me

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Good question. Short answer: the first condition is needed for lowering to work. The second is not a correctness fix. It keeps main's behavior.

"Its new value is not returned." If a delegate owns a buffer and the op that writes it is also a user output, to_backend stops at the assert in _get_new_signature ("Constant state was tagged to be consumed by the buffer ... used as other types of outputs which is currently not supported"). Example: self.state.add_(x); return self.state with the add delegated. This fails on main. With this PR it lowers, because the buffer stays in the program. The two returned=True rows in test_to_backend_keeps_a_mutated_buffer_out_of_a_partial_delegate cover it, and both fail without the check.

"Some op reads it, unless the partitioner already tagged it for its writer." You are right, this one is not about correctness. On main, tag_mutated_buffer only looks at node.users, so it never tags a buffer that no op reads. This condition keeps that, so a write-only buffer stays in the program, as it does today. The "unless" part keeps a tag that the partitioner already put on such a buffer, as long as it matches the writer's tag. QNN does this: it gives every placeholder that no op reads the tag of its last partition. The two read=False, returned=False rows cover both cases.

Dropping it would move write-only buffers into the delegate that writes them, whenever their writer is tagged. In the test, that still lowers. But it changes behavior for those backends, so I would keep it out of this fix.

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

Thanks, the fixes look correct and the tests are thorough. Most of my comments are inline. Two general ones:

1. The tag_mutated_buffer change reaches every backend that uses it. On main it's called by Core ML, Core AI, QNN, Vulkan, MLX, Arm VGF, AOTI and native. Before this change, a buffer used by more than one partition went to min(user_tags), and readers outside that delegate got a stale copy. Now it stays in the owning program and is passed to the delegates as an input and output. That's correct, but it can be much slower: a KV cache that used to be delegate state (for example Core ML MLState) is now copied in and out on every call. Since this needs sign-off from those backends' owners and the zero-stride fix is low risk, I'd consider splitting the two.

2. This affects the Core ML change in #24317. That PR adds _check_state_is_used_in_one_place, which raises when Core ML state is used in more than one place, because "each place would use its own copy." With this PR the buffer stays in the program instead, so the stated reason no longer holds. #24317 should decide whether to keep raising (the user did ask Core ML to own the state), warn instead, or rely on this behavior.

Minor: the Arm TOSA partitioner keeps its own copy of the old rule, so for now Arm behaves differently from the shared helper. Deferring that to an Arm PR sounds fine.

The three failing checks look unrelated: the NXP job couldn't fetch a git ref, the Voxtral job got a 429 from Hugging Face, and unittest-cuda failed only test_antialiased_resize, on an Inductor make_fallback(aten.arange) assert.

Comment thread exir/backend/utils.py
continue
had_tag = node.meta.pop("delegation_tag", None) is not None
if had_tag or (node.users and user_tags != {None}):
logging.warning(

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.

This warning is the only sign that a backend has lost ownership of its state, which can be a big slowdown. It lists three possible reasons without saying which one applied. Could it name the actual reason and the ops/tags outside the delegate (or the returned output)? That would make the fallback much easier to diagnose.

Also, the answer you gave Jacob on the two extra conditions (one keeps lowering from hitting an assert, the other keeps main's behavior) would be worth putting in the docstring.

# store either. Constants are already normalized, so this only changes inputs.
for node in exported_program.graph.nodes:
if node.op == "placeholder" and isinstance(node.meta.get("val"), torch.Tensor):
node.meta["val"] = without_zero_strides(node.meta["val"])

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.

This rewrites only the placeholder's meta["val"]. With plain torch, exporting a model whose input has strides (0, 1) leaves clone and alias of that input with stride (0, 1) in their metadata (mul gets (4, 1)).

On the default path the later passes in to_edge probably recompute that metadata from this placeholder. With _skip_dim_order=True, though, this pass runs last, and the comment in _program.py says ops computed from the normalized tensors keep the old strides.

Could you add x.clone() and a _skip_dim_order=True case to test_emit_user_input_with_zero_stride? I haven't run it with this PR applied, so this may be fine.

Comment thread exir/tensor.py
if not tensor.is_contiguous() and tensor.is_contiguous(
memory_format=torch.channels_last
):
return tensor.clone(memory_format=torch.channels_last)

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.

Nit: this keeps channels-last only for 4-D tensors. A 5-D channels_last_3d tensor with a zero stride is copied as contiguous, which changes its dim order. That's rare, but it may be worth handling or noting in the docstring.

users = [user for user in node.users if user.op != "output"]
if len(users) == len(node.users):
users = users[1:]
for ith, user in enumerate(users):

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.

Nit, and already true on main: this still makes one copy per user, even for users that share a tag. Copying once per tag would mean fewer placeholders. Fine to leave for later.

delegate_tag = "test_partition"
for user in state_node.users:
user.meta["delegation_tag"] = delegate_tag
for compute_new_value, expected_tag in ((True, delegate_tag), (False, None)):

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.

The original case here (copy_(x), where the new value is a graph input) used to expect the buffer to be tagged and now expects no tag. The reasoning makes sense, but since this reverses an existing assertion, could you mention it in the PR summary as a behavior change?

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

ciflow/mlx ciflow/trunk 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

Development

Successfully merging this pull request may close these issues.

4 participants