Skip to content

exir and MLX: fix folded-output clones, shared constants, and constant boolean masks - #24481

Open
shoumikhin wants to merge 4 commits into
pytorch:mainfrom
shoumikhin:exir-mlx-constant-fixes
Open

shoumikhin wants to merge 4 commits into
pytorch:mainfrom
shoumikhin:exir-mlx-constant-fixes

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Some LeRobot policies fail with Apple's MLX backend:

  • smolvla, smolvla_libero and pi0 stop inside the MLX partitioner with
    SpecViolationError: Operator '<EdgeOpOverload: aten.clone.default> ...' is not an allowed operator type.
  • vla_jepa (with Fix export of zero-stride constants and inputs, and of constants that delegates share #24404) fails in to_executorch with
    AttributeError: `_prop_tensor_constant551` is not a buffer.
  • vla_jepa and lawam lower to MLX, then fail at run time with
    [Gather] Boolean indices not supported.
  • A policy split into hundreds of delegates spends about an hour in to_backend copying shared constants.

The clone error. constant_prop_pass puts a clone in front of a folded program output. The clone was always an Edge op, even on an ATen program, and the MLX partitioner's ATen check rejects it.

The "not a buffer" error. When ops in a delegate and ops outside it read the same constant, duplicate_constant_node gives every user but one its own copy of the placeholder. A program that lists a lifted constant twice cannot be unlifted, so to_executorch fails.

Fix

  • constant_prop_pass checks the program's dialect. An ATen or training program now gets torch.ops.aten.clone.default, which to_edge converts later.
  • duplicate_constant_node groups the users of a constant by delegation tag. Users with the same tag, or with none, share one placeholder. Behavior change: on a graph without tags it now makes no copies, so callers must tag users first. The MLX builder's workaround for several placeholders of one constant is replaced by an error.
  • to_backend copies the shared constants of all delegates in one pass, with one recompile and validate. The group of a constant's first reader in graph order keeps the original, so the result no longer changes between runs.
  • The MLX index handler replaces a constant boolean mask with its integer positions, since the MLX runtime gathers only with integers. Other boolean indices stay on the CPU.

Testing

  • Each fix has a new test that fails without it.
  • The exir pass and to_backend tests have no new failures.
  • On a synthetic graph with 400 shared constants, copying takes 0.23 s instead of 71 s, with the same result.
  • Small repros of each error fail before and pass after, on MLX and, for the shared constant, Core ML.
  • vla_jepa also needs Fix export of zero-stride constants and inputs, and of constants that delegates share #24404, which fixes a constant read through a list. With both, it gets through MLX lowering and to_executorch.

#24404 also edits duplicate_constant_node; whichever merges second needs a small rebase.

@shoumikhin shoumikhin added the release notes: exir Changes to any dialects and passes on these dialects, such as memory planning label Nov 3, 2026
Copilot AI balanced review requested due to automatic review settings November 3, 2026 04:08
@pytorch-bot

pytorch-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

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

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

⏳ No Failures, 162 Pending

As of commit 5e7f110 with merge base a489dca (image):
💚 Looks good so far! There are no failures yet. 💚

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

Copy link
Copy Markdown

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


Summary: Nothing blocks merging. The findings are three test gaps (the new per-tag grouping in duplicate_constant_node, where the new test lives, and the ATen clone after to_edge), a note for code that calls duplicate_constant_node directly, and one optional simplification.

Detailed review

PR Review: #24481

Summary

The PR does two things:

  • constant_prop_pass now puts an aten.clone.default in front of folded outputs when the program is not Edge yet.
  • duplicate_constant_node now makes one placeholder per delegation-tag group instead of one per user.

Nothing blocks merging. The issues below are test gaps, one note for direct callers, and one optional simplification.

Code Quality

Testing

  • The grouping is tested in only one order.

    • exir/backend/test/test_utils.py:402-453 always has a delegated op as the constant's first user: add (tag0) in the first to_backend and sub (tag1) in the second. So the delegate always keeps the original placeholder.
    • The other order is never run. There, a CPU op comes first, the original stays at top level, _assign_new_tag (exir/backend/utils.py:205) re-tags it None, and the delegate gets _copy_0. A second case with e.g. torch.sin(self.const) first in forward would cover it. The node names in TagNodes would shift.
    • No test checks the new duplicate_constant_node rule itself. The edited tests (exir/backend/test/test_passes.py:41-43, 84-86, backends/transforms/test/test_create_delete_constant_placeholder.py:214-217) give every user its own tag, so they only re-test one copy per user. Worth adding: same-tag users and untagged users get set(), and three groups get _copy_0 and _copy_1.

    Fix this →

  • The new to_backend test sits apart from the tests like it.

    • exir/backend/test/test_utils.py:402: the existing constant-splitting tests, test_partitioner_splits_constant_data and test_partitioner_alert_split_constant_data, are in exir/backend/test/test_partitioner.py. They use the same ExecutorBackend.
    • That BUCK target already depends on executor_backend_preprocess and preloads executor_backend_register.
    • Moved there, the test would not need the new dep at exir/backend/test/BUCK:381. It could also run the lowered program with _load_for_executorch_from_buffer, as test_partitioner_splits_constant_data does, and compare against eager. Right now it only checks that to_executorch() does not raise.

    Fix this →

  • The ATen clone is not followed through to_edge.

    • exir/tests/test_passes.py:1640-1660 checks the clone target and _validate() on the ATen and training programs.
    • It does not check what the description says happens next: that to_edge turns aten.clone.default into the right Edge op. That is the path the MLX export takes after transform_for_pre_decomposition.
    • A case in exir/tests/test_partitioner_pre_decomposition.py would cover the reported flow end to end: transform_for_pre_decomposition calls constant_prop_pass, then to_edge_transform_and_lower runs, and the test asserts _clone_dim_order (with the default EdgeCompileConfig) feeds the folded output.

    Fix this →

Backward Compatibility

  • exir/backend/canonical_partitioners/duplicate_constant_node_pass.py:93: duplicate_constant_node has no underscore, so outside code can call it.
    • When its users are untagged or all share one tag, it now makes no copies and returns set(). Before, every user after the first got a copy.
    • The new docstring line describes this. The only in-tree production caller, _maybe_duplicate_constant_nodes (exir/backend/utils.py:242), runs after tagging, so it is unaffected.
    • Out-of-tree code that calls it before tagging, as the three in-tree tests did before this PR changed them, will now get no copies and no warning.
    • The PR has the release notes: exir label, so mention this in the release note.

Recommendation

Approve. Nothing blocks. The tests above are worth adding in this PR, most of all the case where a CPU op is the first user, because that is the path that re-tags the original placeholder.

I did not run any tests: this job can't run Python. Everything above comes from reading the code.


@shoumikhin
shoumikhin requested a review from metascroy November 3, 2026 04:18
@shoumikhin
shoumikhin force-pushed the exir-mlx-constant-fixes branch from ef572cf to f4ee3c0 Compare November 3, 2026 05:05
@shoumikhin shoumikhin changed the title exir: use an ATen clone for folded outputs before to_edge, and one copy of a shared constant per delegate exir: use an ATen clone for folded outputs before to_edge, and one copy of a shared constant per program Nov 3, 2026
Copilot AI balanced review requested due to automatic review settings November 3, 2026 05:05

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 force-pushed the exir-mlx-constant-fixes branch from f4ee3c0 to 460b697 Compare November 3, 2026 05:05
@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks for the review. The latest push changes 2 tests:

  • The shared-constant test now also runs with the CPU ops reading the constant first. Then the original stays in the top-level program and the delegate gets the copy.
  • The dialect test now checks that to_edge turns the ATen clone into _clone_dim_order.

What I left as is:

  • replace_input_with. It would also rewire a constant read through a list. Fix export of zero-stride constants and inputs, and of constants that delegates share #24404 already makes that change in this function, so I kept it out here, so the 2 PRs do not make the same change.
  • Moving the test to test_partitioner.py. pytest.ini skips that file, so GitHub CI would stop running the test. test_utils.py is run there. The bug is raised in to_executorch, so the test checks up to that point.
  • A separate unit test for the grouping rule. I tried breaking the grouping in a few ways: no grouping, one group for all users, a copy for every group, and only the first user of a group rewired. Each one makes the new test or the edited direct tests fail.

About direct callers: a graph with no delegation tags now gets no copies. The docstring, the commit message and the PR description say this, so it will show up in the release notes. The only non-test caller in the tree runs after tagging.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 17m 26s —— View job


Summary: Nothing blocks merging. There are three minor findings: one subtest of the new to_backend test tags the wrong add, GitHub CI skips the file that holds the new constant_prop_pass test, and the pass's default skip list still matches only Edge ops on the ATen programs this PR now handles (that last one predates the PR).

Detailed review

PR Review: #24481

Summary

The PR makes two changes:

  • constant_prop_pass now puts an ATen clone in front of folded outputs when the program is not Edge yet.
  • duplicate_constant_node now gives each delegation tag one placeholder, instead of one per user.

Nothing blocks merging. The findings are two test gaps and an Edge-only default in the same pass. That default predates the PR.

Code Quality

  • The default skip list only matches Edge ops, so it skips nothing on the ATen programs this PR now handles.

    • exir/passes/constant_prop_pass.py:38-43 builds _DEFAULT_SKIP_TARGETS from exir_ops.edge.aten.full.default and the Edge forms of the quant primitives.
    • EdgeOpOverload (exir/dialects/edge/_ops.py:291) defines no __eq__ and no __hash__. So the check at :262 never matches torch.ops.aten.full.default or the quantized_decomposed ops, and every op with constant inputs gets run and folded (:281-304).
    • What that means for a run before to_edge:
      • It folds aten.full, which is the size growth the comment at :36 warns about.
      • On a PT2E-quantized model it also folds dequantize_per_* of a constant int8 weight, so the weight is stored as float.
    • This predates the PR, so a follow-up is fine.
    • Fix: choose the default by dialect too, i.e. torch.ops.aten.full.default plus _QUANT_PRIMITIVES when the program is not Edge. The custom_skip_targets: Optional[set[EdgeOpOverload]] type hint makes the same assumption.

    Fix this →

Testing

  • With cpu_first=True, the first TagNodes tags the wrong add.

    • exir/backend/test/test_utils.py:411-412 traces torch.sin(self.const) + torch.cos(self.const) first. So that add is named aten_add_tensor, and x + self.const becomes aten_add_tensor_1.
    • :453 then puts the sin+cos add, which does not read the constant, into tag0 along with mul. That delegate reads the constant once, not twice as the comment at :445-446 says, and x + self.const stays on the CPU.
    • The gap is small: the second to_backend (sub and div) still covers a delegate whose two readers share the copy.
    • Fix: return torch.sin(self.const) and torch.cos(self.const) as separate outputs. That removes the second add, and the same tags then work in both subtests.

    Fix this →

  • GitHub CI does not run the new constant_prop_pass test.

    • The test is at exir/tests/test_passes.py:1640. pytest.ini:50 has --ignore=exir/tests/test_passes.py, and no script in .ci/ or .github/ names that file, so only Buck runs it.
    • The reply gave this same reason for keeping the to_backend test out of test_partitioner.py.
    • If the clone fix should also run in GitHub CI, the test needs a file pytest collects. For example, a new exir/tests/test_constant_prop_pass.py with a target in exir/tests/targets.bzl.

    Fix this →

Recommendation

Approve. Nothing blocks. The cpu_first tagging is a quick fix, and the skip-list default can wait for a follow-up.

I did not run any tests, because this job can't run Python. Everything above comes from reading the code.

@shoumikhin
shoumikhin force-pushed the exir-mlx-constant-fixes branch from 460b697 to 82171cc Compare November 3, 2026 05:40
Copilot AI balanced review requested due to automatic review settings November 3, 2026 05:40
@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks, good catch on the test. The latest push fixes it:

  • Wrong add in the CPU-first case. sin and cos are now separate outputs, so there is no second add. In both cases the first delegate now holds add and mul, and the second holds sub and div. Each order also catches a mistake the other misses: copying for tagged groups only, or for the untagged group only.

What I left as is:

  • The constant_prop_pass test runs only in Buck. All the other constant_prop_pass tests are in the same file, so I kept it with them. The difference from the to_backend test is that moving that one would have taken away GitHub coverage it already has. Moving this one into a new file would add coverage for one test only, and that seems better done for the whole group at once.
  • The default skip list only matches Edge ops. I agree it is wrong for ATen and training programs, and it is older than this PR. It changes which ops get folded, which is a separate behavior change, so I would rather fix it in its own PR.

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

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 16m 19s —— View job


Summary: Nothing blocks merging. There are two minor findings: no test has one to_backend call split a shared constant three ways, and the new clone choice relies on the verifier's dialect label, which Vulkan sets to TRAINING on Edge graphs (latent).

Detailed review

PR Review: #24481

Summary

The PR makes constant_prop_pass give folded outputs of non-Edge programs an ATen clone, and makes duplicate_constant_node give each delegation-tag group one placeholder. Nothing blocks merging. There is one test gap and one latent backward-compatibility risk.

Testing

  • No test has one to_backend call split a shared constant three ways.

    • exir/backend/test/test_utils.py:452-459: each TagNodes has one tag. So every duplicate_constant_node call in the test sees exactly two groups: one delegate and the untagged ops. The direct tests this PR edits each give the constant two users, so they also stop at two groups.
    • What never runs is one partitioner returning two partitions that both read the constant, next to CPU users. That happens when an unsupported op splits a graph into several partitions that read the same constant. It is also the only case where one call makes both _copy_0 and _copy_1.
    • So changing list(groups.values())[1:] to [1:2] at exir/backend/canonical_partitioners/duplicate_constant_node_pass.py:46, which copies for the second group only, passes the new test and the edited direct tests. That change is not one of the mutations listed in the reply.
    • Fix: add one more case to the same test, using a single TagNodes({"aten_add_tensor": "tag0", "aten_mul_tensor": "tag0", "aten_sub_tensor": "tag1", "aten_div_tensor": "tag1"}). It should assert [1, 1, 1] and then call to_executorch().

    Fix this →

Backward Compatibility

  • The clone choice relies on the verifier's dialect label, and some Edge graphs carry a different label.

    • exir/passes/constant_prop_pass.py:466 gives an ATen clone to every program whose dialect is not "EDGE". dialect comes from the program's verifier, not from the ops in its graph.
    • Vulkan's preprocess runs each ExportPass with override_verifiers=[_any_op] (backends/vulkan/vulkan_preprocess.py:85). _any_op sets dialect = "TRAINING" only to skip the functional check (:63-65). A plain callable later in the same pass list receives the relabeled program (:91-92).
    • No in-tree code calls constant_prop_pass on such a program, so nothing in the tree changes. But a backend that adds it to a pass list run this way (Vulkan, or an out-of-tree backend using the same pattern) now gets torch.ops.aten.clone.default inside an Edge graph, where it used to get the Edge clone.
    • Keeping the label as the signal is the simple choice. If you keep it, add one line to the constant_prop_pass docstring (:495-505) saying the clone follows exported_program.dialect, so callers can see the dependency.

    Fix this →

Recommendation

Approve. Nothing blocks. The test case is a few lines in the existing test. The dialect risk is latent, so a docstring line is enough.

I did not run any tests, because this job can't run Python. Everything above comes from reading the code.

@shoumikhin
shoumikhin force-pushed the exir-mlx-constant-fixes branch from 82171cc to 1f5b7dd Compare November 3, 2026 06:11
Copilot AI balanced review requested due to automatic review settings November 3, 2026 06:11
@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks. Both points are fixed in the latest push:

  • One partitioner with 2 delegates. The shared-constant test has a new case where one to_backend call takes both delegates. In that case a single call to duplicate_constant_node makes 2 copies. Copying for the second group only now fails that case.
  • The clone follows the dialect label. The constant_prop_pass docstring now says which clone a constant output gets, and that the choice follows exported_program.dialect.

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

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 14m 9s —— View job


Summary: This round found no new issues, and nothing blocks merging.

Detailed review

PR Review: #24481

Summary

The PR does two things:

  • constant_prop_pass now puts an ATen clone in front of a folded output when the program is not Edge yet.
  • duplicate_constant_node now gives each delegation-tag group one placeholder, instead of one per user.

This round found no new issues. Two points from earlier rounds came up again and are left out: choosing the clone by exported_program.dialect, which the docstring now documents, and a separate unit test for the grouping rule. Both were already answered in this thread.

Recommendation

Approve. Nothing blocks.

I did not run any tests, because this job can't run Python. Everything above comes from reading the code.

@claude

claude Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

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


Summary: Nothing blocks merging. There are two minor findings: an MLX builder comment still says shared constants are copied once per user, and the new constant-mask lowering makes one int64 index per mask dimension, so a multi-dimensional mask goes through a gather path that refuses dynamic dimensions.

Detailed review

PR Review: #24481

Summary

The PR makes three changes:

  • constant_prop_pass now puts an ATen clone in front of the folded outputs of a non-Edge program.
  • duplicate_constant_node now gives each delegation-tag group one placeholder.
  • The MLX index handler now replaces a constant boolean mask with its integer positions.

Nothing blocks merging. Both findings are minor, and both involve the MLX side. The mask handler (052ecdd) is new since the last round.

Code Quality

  • A comment in the MLX builder still says shared constants are copied once per user.

    • backends/mlx/builder/program_builder.py:529-530 (not in the diff) says "A constant shared with other partitions is duplicated once per user, so several placeholders here can name the same target." That is the stated reason for the target_to_constant_slot dedupe at :523-537.
    • After this PR, duplicate_constant_node makes one copy per delegation tag (exir/backend/canonical_partitioners/duplicate_constant_node_pass.py:36-46). _assign_new_tag (exir/backend/utils.py:198-206) then gives each copy the tag of its users. So the users of a constant in one partition now share one placeholder, and the comment's reason no longer holds.
    • Fix: either say in the comment what can still give one partition several placeholders with the same target, or remove the dedupe if nothing can. Fix export of zero-stride constants and inputs, and of constants that delegates share #24404 changes how reads through a list are rewired, so it may change that answer.

    Fix this →

Performance

  • A constant mask becomes one int64 index per mask dimension.

    • backends/mlx/ops.py:2448-2451: value.nonzero(as_tuple=True) returns one int64 tensor per mask dimension, and make_or_get_constant (backends/mlx/builder/program_builder.py:260-278) stores each one unchanged. So a d-dimensional mask with K true entries is stored as d·K int64 values. For example, a mostly-true 1024×1024 mask becomes about 16 MB of positions, where the mask itself is 1 MB.
    • With d ≥ 2, the indices go through the multi-index GatherNode path (ops.py:2508-2530). That path raises for any non-indexed dimension with a dynamic size. So a 2-D mask is not lowered when another input dimension is dynamic, for example x[:, mask2d] with a dynamic batch dimension.
    • Fix, if large masks or dynamic shapes are expected: the masked dimensions are static, because they must match the mask. Reshape them into one dimension, then gather once through the single-index TakeNode path with value.flatten().nonzero().squeeze(1) as the index. That stores K values, and that path does not require static sizes.

    Fix this →

Recommendation

Approve. Nothing blocks. The comment is a quick fix. The mask layout can wait unless the target models have large multi-dimensional masks or dynamic shapes.

I did not run any tests, because this job can't run Python. Everything above comes from reading the code.

@shoumikhin
shoumikhin force-pushed the exir-mlx-constant-fixes branch from 052ecdd to e6ca3a1 Compare November 3, 2026 15:20
Copilot AI balanced review requested due to automatic review settings November 3, 2026 15:20

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 for the review. Both points:

Builder comment. You are right that the grouping made it stale. I checked whether anything still gives one delegate two placeholders of the same constant. With the grouping, the shared-slot branch in the MLX builder never runs: not on the MLX partitioner tests, and not on graphs that read a constant several times inside and across delegates. Without the grouping it does run. So the branch and its comment are removed in the grouping commit.

Mask layout. I kept one index per mask dimension here. A multi-dimensional mask with a dynamic dimension outside the mask is refused cleanly, so the index runs on the CPU. Static multi-dimensional masks lower through the gather path, and the tests run them. Merging the masked dimensions first would make the index handler emit its own reshape and gather, which is a bigger change than this one. The masks that motivated this are small and static, so the extra position memory does not matter for them.

The new head also adds a commit that copies the shared constants of all delegates in one pass. It recompiles and validates once, not once per constant.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

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


Summary: Nothing blocks merging, with two minor findings: the original placeholder does not go to the first reader in graph order as the e6ca3a1 message and the new test comment say, and the MLX builder now looks up constants in a list.

Detailed review

PR Review: #24481

Summary

The PR fixes three MLX failures in LeRobot policies:

  • Folded outputs of an ATen program now get an ATen clone.
  • A shared constant gets one placeholder per delegation tag, and the copies for all delegates are now made in one pass.
  • A constant boolean mask becomes integer positions.

Nothing blocks merging. The two findings below are minor, and both are in code changed since the last round.

Code Quality

  • Which delegate keeps the original placeholder does not follow graph order, though the commit message and the new test say it does.

    • exir/backend/canonical_partitioners/duplicate_constant_node_pass.py:45-47: _users_reading_a_copy takes the groups in node.users order. Setting user.args (:144) makes torch.fx remove the user from the users of each of its inputs and add it back at the end. So when an op reads two shared constants and is rewired for the first one, it comes last among the readers of the second.
    • The new test shows this (exir/backend/test/test_utils.py:530-543). self.a - self.b (tag2) reads b before x / self.b (tag3) does, but tag3 keeps b_b. That happens because sub moved to the end of b_b.users when it was rewired to b_a_copy_1.
    • The result is still deterministic. But three places describe a rule the code does not follow: the e6ca3a1 message ("which delegate keeps the original now follows graph order"), the PR description, and the comment at test_utils.py:530. The expected names in the test also depend on how torch.fx orders users.
    • Fix: sort each constant's users by graph position. Build a node-to-index map once in duplicate_constant_nodes, and iterate sorted(node.users, key=...) in _users_reading_a_copy. Then the first reader in graph order keeps the original, and the result no longer depends on the order the constants are processed in. In the test, tag2 would keep b_b and tag3 would get b_b_copy_0. If you keep the code as is, reword those three places so they only say the result is deterministic.

    Fix this →

Performance

  • backends/mlx/builder/program_builder.py:465, 527: the constant lookup is now a list scan. constant_tensors went from a dict to a list, and its only use is node.name in constant_tensors, once per placeholder. That makes _make_io_slots quadratic in the number of constants, and it runs on every support check and every build. A set keeps the lookup constant time.

    Fix this →

Recommendation

Approve. Nothing blocks. Each fix is a few lines.

I did not run any tests, because this job can't run Python. Everything above comes from reading the code.

@shoumikhin
shoumikhin force-pushed the exir-mlx-constant-fixes branch from e6ca3a1 to dc7d498 Compare November 3, 2026 16:19
Copilot AI balanced review requested due to automatic review settings November 3, 2026 16:19

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, both fixed.

Graph order. duplicate_constant_nodes now builds a node-to-index map once, and _users_reading_a_copy walks the users sorted by it. So the group of a constant's first reader in graph order keeps the original, whatever order the constants are copied in. In the test, tag2 now keeps b_b and tag3 gets b_b_copy_0. Graphs where each op reads one constant give the same result as before. Only ops that read two shared constants can now get different copy names. The commit message, the test comment and the description are updated.

Constant lookup. constant_tensors is a set now.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

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


Summary: Nothing blocks merging. There are two minor findings, both in the shared-constant copy: the one-pass copy is still quadratic in the number of constants because each one rebuilds the signature lookups, and _maybe_duplicate_constant_nodes silently copies nothing when called the old way, with one tag string.

Detailed review

PR Review: #24481

Summary

The PR makes constant_prop_pass put an ATen clone in front of the folded outputs of non-Edge programs, gives each delegation tag one placeholder of a shared constant and makes all the copies in one pass, and lowers a constant boolean mask on MLX as integer positions.

Nothing blocks merging. Both findings are minor.

Backward Compatibility

  • _maybe_duplicate_constant_nodes silently copies nothing when given one tag string, which is what its old signature took.

    • exir/backend/utils.py:211 changes the parameter from tag: str to tags: Iterable[str], and :224 runs tags = set(tags).
    • A str is an Iterable[str], so the old call _maybe_duplicate_constant_nodes(ep, "tag0") still runs, and a type checker accepts it. But set("tag0") is {"t", "a", "g", "0"}, so no placeholder matches. The function returns at :240-241 without copying the shared constant, and raises no error.
    • All three in-tree callers are updated (exir/backend/backend_api.py:426 and :754, exir/backend/test/test_utils.py:524), and the helper is private. So this only hits out-of-tree code that kept the old per-tag loop.
    • Fix: add assert not isinstance(tags, str) before set(tags).

    Fix this →

Performance

  • The one-pass copy is still quadratic in the number of constants.

    • exir/backend/canonical_partitioners/duplicate_constant_node_pass.py:105 calls _get_attribute_or_constants (:18-33) once per candidate. That function calls get_param, get_buffer and get_lifted_tensor_constant, which read graph_signature.inputs_to_parameters, inputs_to_buffers and inputs_to_lifted_tensor_constants. Each read rebuilds a dict from every input spec, which is why tag_constant_data caches them (exir/backend/utils.py:352-356).
    • So this step costs about candidates × input specs, several times over. The cost predates the PR, but the old recompile and validate for each constant hid it. It is now the only part of duplicate_constant_nodes that grows as candidates × input specs, and it falls on the models the dc7d498 message targets, the ones with thousands of folded constants.
    • The returned tensor is used only for the None check at :106-109. Fix: check old_input_spec.kind in (InputKind.PARAMETER, InputKind.BUFFER, InputKind.CONSTANT_TENSOR) there instead, and remove _get_attribute_or_constants, whose only caller is :105.

    Fix this →

Recommendation

Approve. Nothing blocks. Each fix is a line or two.

I did not run any tests, because this job can't run Python. Everything above comes from reading the code.

@shoumikhin
shoumikhin force-pushed the exir-mlx-constant-fixes branch from dc7d498 to 2450a5a Compare November 3, 2026 17:05
Copilot AI balanced review requested due to automatic review settings November 3, 2026 17:05

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, both fixed in the copy commit.

One tag string. _maybe_duplicate_constant_nodes now raises TypeError for a str, and the test checks it.

Signature lookups. The candidate check now reads old_input_spec.kind (parameter, buffer or constant tensor), and _get_attribute_or_constants is gone, since this was its only caller. On a synthetic graph with 3200 shared constants and 722 delegates, the copy step takes 1.9 s instead of 5.2 s, with the same output.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

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


Summary: Nothing blocks merging. There are two minor findings: until #24404 lands, an MLX delegate that reads a constant through a list still gets two placeholders with the same target, which is the case the builder branch this PR removes used to handle, and the new mask helper finds and reads the mask in a roundabout way.

Detailed review

PR Review: #24481

Summary

The PR makes four changes:

  • Folded outputs of non-Edge programs now get an ATen clone.
  • A shared constant gets one placeholder per delegation tag.
  • The copies for all delegates are made in one pass.
  • On MLX, a constant boolean mask is lowered as integer positions.

Nothing blocks merging. Both findings are minor and in the MLX code.

Code Quality

  • The builder branch this PR removes is still reached when a delegate reads a constant through a list, until Fix export of zero-stride constants and inputs, and of constants that delegates share #24404 lands.

    • backends/mlx/builder/program_builder.py:523-528: the PR removes the branch that gave placeholders with the same target one constant slot. The reason given is that grouping leaves each delegate one placeholder per constant. But the rewiring loop (exir/backend/canonical_partitioners/duplicate_constant_node_pass.py:115-135) only replaces direct args and kwargs. A user that reads the constant through a list keeps reading the original.
    • Trigger: a CPU op reads a buffer ids first. One MLX delegate then reads ids twice: as ids + 1, and as emb[ids] (inside the aten.index.Tensor index list).
      • The delegate's group gets ids_copy_0. ids + 1 is rewired to the copy, but emb[ids] still reads ids.
      • ids keeps the delegate's tag. tag_constant_data set it (exir/backend/utils.py:396-397), and _assign_new_tag (:205) leaves it because the users of ids now carry two tags.
      • _get_new_signature (exir/lowered_backend_module.py:653) then puts both placeholders into the delegate with the same target.
    • Each placeholder now gets its own constant slot. _build_io_maps keys name_to_slot by target (program_builder.py:947-958), so one slot overwrites the other. The instructions still read the dropped slot, but it gets no NamedSlot and no data. Nothing raises at build time, because _verify_build does not check for this. Before this PR, the removed branch gave both placeholders one slot.
    • A lifted tensor constant read this way still fails in to_executorch with "is not a buffer", as it does on main. From reading torch's unlift code, only lifted tensor constants hit that error when listed twice. So a parameter or buffer read this way goes into the .pte with the broken delegate. I did not check what the MLX runtime does with the unloaded slot.
    • Fix: keep the branch in this PR, with constant_tensors mapping name to target again, and remove it in Fix export of zero-stride constants and inputs, and of constants that delegates share #24404. Or merge Fix export of zero-stride constants and inputs, and of constants that delegates share #24404 first.

    Fix this →

  • _constant_mask_as_positions finds and reads the mask in a roundabout way.

    • backends/mlx/ops.py:2419-2429:
      • It finds the mask with next(...) over a generator.
      • It reads the dtype through getattr(..., "dtype", None).
      • It then reads n.args[1][axis] a second time.
    • The rest of the handler reads meta.get("val") and checks it explicitly (:2466-2467, :2479-2484). CLAUDE.md also asks for no dynamic getattr patterns.
    • :2435 and :2442 look up the same target twice: first get_placeholder_target, then get_placeholder_target_and_tensor. Each call scans all input specs. The second also checks state_dict first, which :2435 has already ruled out.
    • Fix:
      • Loop over non_none with isinstance(val, torch.Tensor) and val.dtype == torch.bool.
      • Call target = P.get_placeholder_target(mask_node) once.
      • Read the mask with value = P.ep.constants[target].

    Fix this →

Recommendation

Approve. Nothing blocks. The builder branch only matters if this PR merges before #24404.

I did not run any tests, because this job can't run Python. Everything above comes from reading the code.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks. Both points:

Constant read through a list. I built your case: a CPU op reads ids first, and one MLX delegate reads it both as ids + 1 and inside emb[ids], for a buffer, a parameter and a lifted constant. All three stop in to_backend with "constant data node (b_ids) is tagged with (mlx_1) but has user ... which has tag (None)", the same error main gives. The list reader keeps the original, so the original ends up with users in two programs, and that check raises before the delegate is built. With #24404, which rewires list readers, the case lowers and the delegate gets one placeholder for ids. So the shared-slot code stays removed.

Mask lookup. Agreed. The cleanup (an explicit isinstance check, one target lookup, and reading P.ep.constants[target]) will go into the next push of this PR, or into a follow-up if this one merges first.

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

Looks good overall. I checked that dialect follows the verifier, so delegate submodules, fake programs, and the programs backends get in preprocess all still report EDGE, and the in-tree constant_prop_pass callers keep getting the Edge clone. The grouping and the one-pass copy also look correct. A few things to address inline.

"""
A pass to duplicate the attributes/constants node (the candidate_node) in the graph. Mostly used for duplicating light-weight data.
If the data is too large, try tag it with "no_copy" to prevent high memory usage and make it as part of the output.
Users with the same delegation tag, or with none, share one placeholder.

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 changes the behavior of a public function: if no user has a delegation tag, duplicate_constant_node now makes no copies and returns set(), where before every user but one got a copy. In-tree only tests call it directly, but it lives in canonical_partitioners, so out-of-tree partitioners may call it. Could you note the change in the PR description, and maybe say in the docstring that the users must be tagged before calling it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, this is a behavior change. I checked whether to keep the old behavior for untagged users. With the code on main, an untagged lifted tensor constant gets a copy, and then ep.module() fails with "c is not a buffer", the same error this PR fixes. It would also bring back a copy for every CPU user but one. So I kept the new behavior. The docstring now says to tag the users before calling it, and the PR description notes the change.

Comment thread backends/mlx/ops.py Outdated

if len(non_none) == 1:
axis, idx = non_none[0]
idx_meta = n.args[1][axis].meta.get("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.

After _constant_mask_as_positions replaces the mask, this line still reads the metadata of the original boolean mask (n.args[1][axis]), not of the positions slot. It is only right because a mask used as the single index is 1-D, like its positions. Could you base the choice between TakeAlongAxisNode and TakeNode on the index actually used, or add an assertion, so a later change to the mask path doesn't silently pick the wrong node?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Each index now carries its own metadata, and a replaced mask carries its positions. So the choice between TakeAlongAxisNode and TakeNode now reads the index that is actually used. The result is the same today, for the reason you gave.

target_to_constant_slot[target] = self.make_or_get_slot(
node, id_space=IdSpace.Constant
)
self.make_or_get_slot(node, id_space=IdSpace.Constant)

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.

Removing the target_to_constant_slot workaround is correct with the grouping. But if anything ever gives one delegate two placeholders for the same target again (a direct duplicate_constant_node call, or a future pass), the result is now a second constant slot, which stores the data twice in the .pte without any warning, instead of sharing one slot. Could you add a cheap check here, for example an assertion that each constant target gets one slot, so that would fail loudly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. _make_io_slots now raises a ValueError when a second placeholder names a constant target that already has a slot. One small correction: without the check, the data would not be stored twice. Constant data is named by target, so one of the two slots would get no data, and the runtime would reject the program when it loads. The new test test_second_placeholder_for_one_constant_raises builds such a program, and it fails when the check is removed.

When ops in different partitions, or in a partition and on the CPU, read
the same constant, duplicate_constant_node gives every user but one its
own copy of the placeholder, all with the original target. Two users in
one partition then bring two placeholders of it into that delegate, and
two users left on the CPU keep two in the owning program.

A program that lists the same lifted tensor constant twice fails to
unlift: ExportedProgram.module() raises "`<name>` is not a buffer". For a
delegate, to_executorch raises it too, because it runs the delegate's
program when it retraces the delegate call. For example, a constant read
by an add and a mul in one delegate and by a sin on the CPU fails this
way. So does a second partitioner that takes the users left on the CPU
into one delegate.

Users with the same delegation tag, or with none, now share one
placeholder, and the first group keeps the original. So copies are made
only for users in different programs. On a graph without tags none are
made, so a caller has to tag the users first. The docstring says so, and
the tests that call duplicate_constant_node directly now tag their users.

The MLX builder gave placeholders that name the same target one constant
slot, to cope with the copy per user. Each delegate now gets one
placeholder per constant, so that code is removed, and a second
placeholder of the same constant now raises a ValueError. Without it,
one of the two slots would get no data, because the data is named by
target, and the program would fail only when it loads.

The new test lowers a constant (a lifted tensor constant, a buffer, and
a parameter) that two delegates each read twice and that ops left in the
owning program also read, through two to_backend calls, once with the
ops left in the owning program reading it first and once last. It also
lowers both delegates through one to_backend call. A direct call on
untagged users makes no copy. It checks that each program takes the
constant once and that to_executorch passes. An MLX test checks that the
builder rejects a second placeholder of one constant.
The MLX partitioner takes aten.index.Tensor whatever the dtype of its
indices. The MLX runtime gathers only with integer indices, so x[mask]
with a boolean mask lowers, then fails when it runs:

    MLX execute failed: [Gather] Boolean indices not supported

The mask is often a constant of the program, for example one that
constant_prop_pass folded before lowering. Its values are known at
lowering time, so the index handler now replaces it with its integer
positions, one index per mask dimension, and the gather stays in the
delegate.

Any other boolean index is refused, so the partitioner leaves it on the
CPU:

- a mask computed in the graph, such as x[x > 0]
- a mask that is a method input, or a buffer the program mutates
- a mask that is a parameter or a persistent buffer, because the
  partitioner sees those as fake tensors and cannot read the mask
- a constant mask used together with other indices
- an all-false mask, because the result is empty and the MLX runtime
  cannot write an empty output

The new tests run constant masks (1-D, 2-D, 3-D, a non-persistent
buffer, and a mask at axis 1), which must stay in one delegate. They
also run a mask computed in the graph, a mask passed as an input, a
constant mask used with another index, a persistent buffer, a buffer
the program mutates, and an all-false mask, none of which may lower the
index. A direct check on the fake program the partitioner sees confirms
that the handler itself refuses the persistent buffer and the all-false
mask, rather than failing on them.
To give each delegate its own placeholder for a constant it shares with
another delegate, to_backend called duplicate_constant_node once per shared
constant, for each partition tag. Every call recompiled the graph module and
validated the whole program, so the cost grew with the number of shared
constants times the size of the graph. A model lowered to many delegates,
with thousands of folded constants, spent about an hour here.

Now to_backend collects the shared constants of all tags first and
copies them in one pass with the new duplicate_constant_nodes, which
recompiles and validates once. _maybe_duplicate_constant_nodes takes all
the tags, and raises a TypeError for a single tag string, which would
otherwise be read as one tag per character. The pass reads the kind of
each constant from its input spec, instead of through get_param,
get_buffer and get_lifted_tensor_constant, which rebuild the signature
lookups on every call. So a buffer whose data is not in the state dict,
which the verifier allows, no longer raises a KeyError here; its copy
needs no data. Each shared constant still gets one copy per other
program. The group of its first user in graph order keeps the original.
Before, when one op read two shared constants, which delegate kept the
original depended on the tag order, on the iteration order of a set of
names, and on the order in which earlier copies had rewired that op, so
it could change from run to run.
@shoumikhin
shoumikhin force-pushed the exir-mlx-constant-fixes branch from 2450a5a to 5e7f110 Compare November 3, 2026 18:35
Copilot AI balanced review requested due to automatic review settings November 3, 2026 18:35

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

Copy link
Copy Markdown

PR Review in progress

  • Gather context (PR description, discussion, inline review replies)
  • Get the diff (the checkout is shallow, so the diff is taken against the PR's parent commit a489dca: 12 files, +539/-83)
  • Run three independent reviews
  • Consolidate and fact-check findings
  • Post the review

View job run

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. release notes: exir Changes to any dialects and passes on these dialects, such as memory planning

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants