Skip to content

Refuse ops coremltools cannot convert in the Core ML partitioner, and say why - #24425

Open
shoumikhin wants to merge 4 commits into
pytorch:mainfrom
shoumikhin:coreml-refusals
Open

shoumikhin wants to merge 4 commits into
pytorch:mainfrom
shoumikhin:coreml-refusals

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

What is wrong today

Some ops run in PyTorch but not in coremltools. The Core ML partitioner still hands them to Core ML, so lowering fails with an error from deep inside coremltools, or the op writes to the wrong place:

  • min, max, amin, amax, minimum and maximum of bool tensors raise a ValueError. torch.distributions.MultivariateNormal exports one in its all-true check. A bool next to a float or int tensor works.
  • index_put next to a slice, such as y[:, idx] = v, converts only with single-entry int indices, accumulate=False, static sizes in the other dims, and a non-bool y below iOS 18. For example, idx = torch.tensor([0, 2]) fails with "Input squeeze_0 has rank 1 != other inputs rank 0". With a 0-d index or several indices, coremltools lays out the written part differently from PyTorch, so values land in the wrong place unless they vary only after the last index. Without a slice, several index tensors must have the same shape and no bool mask.

Also, with lower_full_graph=True, the error says Core ML does not support the op even when the partitioner refused only one form of it.

What this change does

  • The partitioner refuses these forms. They run on the portable CPU kernels, and the rest of the graph still goes to Core ML. Forms coremltools handles, such as the single-entry index of a KV cache update, stay in Core ML.
  • The lower_full_graph=True error now gives the reason, for example "aten.index_put.default on a bool tensor with a minimum_deployment_target below iOS 18 is not supported by CoreML."
  • test_int4_weight_only_per_group, which failed on macOS for some random weights on main too, now feeds 16 rows, uses the 1e-2 tolerance of the other int4 and int8 recipe tests, and fixes the seed.

Testing

  • test_coreml_partitioner.py, test_torch_ops.py and test_coreml_recipes.py under backends/apple/coreml/test pass on Linux and on macOS with the Core ML runtime.
  • The new tests check which ops stay on the CPU, for index_put also with a dynamic batch size, check the error reasons, and on macOS 15 compare the static cases with eager PyTorch.
  • Each new test fails when its check is removed.

@shoumikhin shoumikhin added the release notes: apple Changes to the Apple backend delegate label Nov 2, 2026
@shoumikhin
shoumikhin requested a review from metascroy as a code owner November 2, 2026 13:17
Copilot AI balanced review requested due to automatic review settings November 2, 2026 13:17
@pytorch-bot

pytorch-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

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

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

⏳ No Failures, 7 Pending

As of commit 98b4f25 with merge base 822cb60 (image):
💚 Looks good so far! There are no failures yet. 💚

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

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

@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 2, 2026
@claude

claude Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

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


Requesting changes: the new index_put refusal message names the indices as the cause even when the cause is something else, and for a bool tensor below iOS 18 it points to the wrong fix; the new iOS 18 runtime subtest also runs without the macOS 15 check, and the other findings are minor.

Detailed review

PR Review: #24425

Summary

The PR keeps bool min/max and the index_put forms coremltools 9.0 can't convert out of Core ML, adds the refusal reason to the lower_full_graph=True error, and makes the int4 per-group recipe test less flaky. None of the problems breaks correctness. The main ones are the refusal message and the missing macOS 15 check on the iOS 18 subtest. coremltools isn't installed on this runner and Python couldn't be run, so findings that depend on what coremltools converts are marked low confidence.

Code Quality

  • The index_put refusal message gives the wrong cause for most refusals (backends/apple/coreml/partition/coreml_partitioner.py:354-355, raised at 398-402).

    • _is_index_put_coremltools_cannot_convert returns a bool, so every refusal reports "aten.index_put.default with these indices is not supported by CoreML." That includes accumulate=True, a bool y below iOS 18, a dynamic size in a dim that isn't indexed, and a v that torch lays out differently.
    • The new test checks for that text on Scatter3d (test_coreml_partitioner.py:634). There the 0-d index is a supported form, and the refusal comes from the shape of v.
    • For mask[:, idx] = v on a bool mask, the error tells users to set lower_full_graph=False. What keeps the op in Core ML is minimum_deployment_target=ct.target.iOS18, the same hint this backend already gives for quantize_ in CoreML only works on iOS18 and the error message is confusing #13122 (test_torch_ops.py:320-345).
    • Have the helper return an Optional[str] reason, as override_reason now does. Fix this →
  • ios18 is passed only to the checker in partition() (coreml_partitioner.py:510-515 vs 557-561).

    • ops_to_not_decompose builds _OperatorsSupportedForCoreMLBackend without it. With an iOS 18 target, it rejects the bool-y index_put that partition() accepts.
    • Today the only effect is a misleading "treating node as unsupported" log under lower_full_graph=True, because index_put is core ATen and is never decomposed. But with the ios18=False default, any later rule that depends on the target will quietly disagree between the two checkers.
    • Parse the target once in __init__, which already loops over compile_specs (445-449), and pass the Optional[ct.target] to both checkers. That also removes the single-use _targets_ios18 helper (546-551) and a flag named after one OS version. Fix this →
  • The broadcast clause at coreml_partitioner.py:261-264 never changes the result. Take any v that is valid in eager:

    • With no reorder (one index of shape (1,)), torch's indexed shape equals selected, so the clause always passes.
    • With a reorder, lines 265-271 already force every dim of v except the last after to 1, and those last dims are the same in both layouts.
    • The only call in the new test that this clause refuses, [None, self.last] with c of shape (3,), is also refused at 265-271. So deleting the clause leaves every test passing, which contradicts the PR's "Each new test fails when its check is removed".
    • Remove the clause and the phrase "a v that broadcasts to that part" from the docstring, or add the case it guards. Fix this →
  • The index_put check can raise inside partition() instead of refusing the node (coreml_partitioner.py:232, 243, 256, 258).

    • It reads meta["val"] directly. Its neighbours _reads_or_produces_bfloat16 and _is_min_max_of_bool use meta.get("val") with an isinstance check.
    • When every index is None, as in torch.ops.aten.index_put_(y, [None], v), given is empty and max(given) raises ValueError.
    • ops_to_not_decompose catches exceptions from is_node_supported (580-588), but CapabilityBasedPartitioner in partition() doesn't, so lowering crashes. Both triggers are unusual. Fix this →
  • Single-index forms without a slice are never checked (coreml_partitioner.py:234-239, comment at 212-213). Low confidence.

    • That branch returns False for any single index. So two forms stay in Core ML: y[row_mask] = v with a bool mask over only some dims of y, and mask[idx] = v on a bool y below iOS 18.
    • The comment cites coremltools#2141 for "a bool mask", but the code refuses bool masks only next to a slice or together with other indices.
    • From memory, the bool branch in coremltools asserts that the mask shape equals x.shape. If so, a partial mask still fails inside coremltools. A test of the single-mask form would settle it.
  • _is_min_max_of_bool checks only the first input (coreml_partitioner.py:202-203). For the binary forms (minimum, maximum, min.other, max.other), torch.maximum(bool_t, float_t) is refused but torch.maximum(float_t, bool_t) is not. Torch promotes both the same way, so one of the two decisions is wrong. Check every tensor input. Low confidence on which order coremltools rejects.

  • Both new refusals say "No upstream issue yet" (coreml_partitioner.py:190, 212-215). The file asks for upstream links "so future readers can tell when an entry is safe to drop" (37-39). The forms that write to the wrong place are a silent correctness bug that any coremltools user can hit. Filing and linking the issues gives these refusals a clear point at which they can be removed.

Testing

  • The iOS 18 subtest runs on the Core ML runtime without the macOS 15 check (backends/apple/coreml/test/test_coreml_partitioner.py:612, 630-633).

    • _TEST_RUNTIME (line 45) checks only for macOS. This is the first test in the file that runs an iOS 18 program; the iOS 18 tests at 217 and 681 don't run one.
    • On macOS 14 the program fails to load.
    • Gate the comparison on IS_VALID_TEST_RUNTIME from test_coreml_utils.py, which test_coreml_recipes.py already uses. Fix this →
  • test_min_max_of_bool_is_skipped has no non-bool control (test_coreml_partitioner.py:524-556). A check that refused every min and max would still pass, and test_torch_ops.py doesn't lower a float or int min/max either. Add a float amax or maximum and assert that only the bool ones stay outside the delegate. Fix this →

  • test_int4_weight_only_per_group still has no fixed seed (backends/apple/coreml/test/test_coreml_recipes.py:152). The commit message says seeds 0 to 99 all pass, but neither the test nor setUp sets a seed, so the nn.Linear weights and the input change on every run. Add torch.manual_seed with one of the seeds you checked. Fix this →

Backward Compatibility

  • The static-size rule may break lower_full_graph=True exports with dynamic dims (coreml_partitioner.py:260). Low confidence.
    • Enumerated shapes require lower_full_graph=True (445-449). So any slice-form index_put whose y has a dynamic dim outside the indexed one now raises NotImplementedError during lowering. Without lower_full_graph, it moves to the CPU.
    • The only test of this rule (test_coreml_partitioner.py:639-656) checks how many index_put calls stay on the CPU, not that coremltools gets this form wrong. If coremltools 9.0 does handle it, those exports regress.
    • A macOS comparison for the dynamic case, or the coremltools error quoted in the docstring, would settle it.

Recommendation

Request Changes

None of this breaks correctness. But saying why is half of this PR, and the index_put message gives the wrong cause for most refusals and the wrong fix for bool tensors below iOS 18. The iOS 18 subtest also fails on macOS 14 hosts. Both are small fixes.

  • Gather context and fetch the diff against origin/main
  • Run three independent reviewers; all three covered every changed file
  • Consolidate and fact-check the findings
  • Post review
  • Not done: running Python or the tests. coremltools isn't installed on this runner and Python needs approval, so claims about coremltools behaviour come from reading source and are marked low confidence.

coremltools 9.0 cannot compute min, max, amin, amax, minimum or maximum
of bool tensors. It raises a ValueError instead of reporting the op as
unsupported, so lowering fails, for example on the all-true check that
torch.distributions.MultivariateNormal exports. A bool next to a float or
int tensor converts, since the result is then float or int. The
partitioner now leaves the calls with a bool result to the portable CPU
kernels and still delegates the rest.

The new test checks which ops stay on the CPU, and that an amax of a
float tensor and a maximum of a bool and a float tensor stay in Core ML,
and on macOS compares the output with eager.
coremltools converts y[:, idx] = v only when idx has a single entry. With
idx = torch.tensor([0, 2]), lowering fails with "Input squeeze_0 has rank
1 != other inputs rank 0". accumulate=True, a bool tensor next to a slice
below iOS 18, a bool index next to a slice or next to other indices,
index tensors that broadcast, and a size known only at run time next to
the slice fail too. With a 0-d index or several indices next to a slice,
coremltools lays out the written part differently from torch, so values
land in the wrong place unless they vary only in the dims after the last
index.

The partitioner now sends these forms to the portable CPU kernels, and
logs which form it found. Forms coremltools handles, such as a
single-entry index for a cache update, or a bool mask on its own, stay in
Core ML.

The new test counts the index_put calls left on the CPU for each form,
with a static and a dynamic batch size, and on macOS 15 compares the
output with eager.
With lower_full_graph=True, an op that the partitioner refuses failed
with a message saying that Core ML does not support the op, even when
coremltools converts the op and the partitioner refuses only one form of
it. The error now gives the reason, such as "aten.index_put.default on a
bool tensor with a minimum_deployment_target below iOS 18 is not
supported by CoreML. Set lower_full_graph=False in the CoreMLPartitioner
to run it outside of CoreML."

The index_put test checks the reason for some of its forms, including a
bool write that a later deployment target would not fix, which gives its
own reason.
test_int4_weight_only_per_group used random weights and a random input,
and it failed for some of them on macOS, on main too.

- Core ML runs this recipe in float16, so its output differs from the
  eager quantized model by up to about 2e-3, more than the test's 1e-3
  tolerance. The other int4 and int8 recipe tests use 1e-2.
- The SQNR against the float model came from a single input row, which
  gives only eight outputs. For some weights it fell to 12 dB. The eager
  quantized model gives the same SQNR as Core ML there, so this is the
  int4 rounding of those weights seen through eight outputs, not a Core
  ML error.

The test now feeds 16 rows, uses the 1e-2 tolerance, and fixes the seed.
Over seeds 0 to 99 every case passes, with the SQNR at 17 dB or more.
Copilot AI balanced review requested due to automatic review settings November 2, 2026 17:54
@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 2, 2026 •

Copy link
Copy Markdown

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


Requesting changes: the new bool min/max test will probably fail about 1 run in 20 on macOS because Core ML compares x > 0.5 in float16, and the other findings are small fixes to the new error message and the tests.

Detailed review

PR Review: #24425

Summary

The PR keeps bool min/max and the index_put forms coremltools 9.0 gets wrong out of Core ML, adds the refusal reason to the lower_full_graph=True error, and stops the int4 per-group recipe test from failing at random. The main problem is that the new bool min/max test can fail at random too. The rest are small: the new error message, a branch that may still let some forms through, and gaps in the tests. Python and the coremltools source couldn't be read on this runner, so findings that depend on coremltools are marked low confidence.

Code Quality

  • The new lower_full_graph=True error only suggests lower_full_graph=False (backends/apple/coreml/partition/coreml_partitioner.py:411-415).

    • An enumerated-shape compile spec requires lower_full_graph=True (asserted at 458-462), and its enumerated dims are symbolic in the exported program. So y[:, idx] = v on such a tensor now fails with "...with a size known only at run time next to a slice... Set lower_full_graph=False", and doing that trips the assert.
    • The generic message (418-425) also suggested rewriting the model, and sent op-support requests to coremltools rather than ExecuTorch. The new message drops both.
    • For the bool-tensor reason, what keeps the op in Core ML is minimum_deployment_target=ct.target.iOS18, but the message doesn't say how to set it. _IOS18_QUANT_HINT (compiler/torch_ops.py:31-35) already spells out that compile spec.
    • The reason also replaces the generic error when coremltools has no converter for the op at all (is_supported is already False at 402). The PR targets ops that coremltools does convert, so take this branch only when coremltools reported support.
    • Fix this →
  • The no-slice branch accepts any single index (coreml_partitioner.py:245-252). Low confidence.

    • It refuses only when there are several indices. So y[row_mask] = v stays in Core ML even when the bool mask covers only the leading dims of y, for example a (3,) mask on a (3, 4) y.
    • As I recall, coremltools' bool-index path asserts that the mask has the shape of y. If so, this form fails to lower, like the forms this PR fixes. Commit 619cd8e says "a bool mask on its own" stays in Core ML, but no test lowers one.
    • This branch also never checks a bool y against the deployment target, as the slice branch does at 280-283. So mask[idx] = flags reaches coremltools below iOS 18.
    • A test that lowers both forms would show whether they need refusing.
  • Both new refusals say "No upstream issue yet" (coreml_partitioner.py:190, 215-218). The 0-d and several-index forms silently write to the wrong place, and any coremltools user can hit that. _UNSUPPORTED_OP_TARGETS links an upstream issue for each entry (37-39) so readers know when it can be dropped. Filing and linking issues for these forms would do the same.

Testing

  • test_min_max_of_bool_is_skipped will probably fail about 1 run in 20 on macOS (backends/apple/coreml/test/test_coreml_partitioner.py:540, 544, 561).

    • CoreMLPartitioner() compiles in float16 by default (coreml_preprocess.py:188), and x > 0.5 stays in Core ML.
    • Float16 rounds any x in (0.5, 0.5 + 2⁻¹²] down to 0.5. There Core ML gets False where eager gets True, so torch.maximum(x > 0.5, x) is off by about 0.5, far beyond atol=1e-2.
    • x = torch.rand(14, 14) has no seed, and each of its 196 entries lands in that range with probability 2⁻¹². About 4.7% of runs fail.
    • mask = y > 0.5 and a > 0.5 in Scatter (593-594) have the same problem, at about 0.3% per case.
    • Not run here: that the comparison happens in float16 at run time is inferred from the casts coremltools inserts for float16, and from this PR's own int4 commit message ("Core ML runs this recipe in float16").
    • Use inputs that float16 represents exactly, for example torch.rand(14, 14).half().float(), here and for the Scatter inputs. Fix this →
  • Some index_put checks can change without any test failing, though the PR says each new test fails when its check is removed (test_coreml_partitioner.py:572-689).

    • No test keeps a no-slice index_put in Core ML. Scatter's two no-slice writes (589-590) are refused in every case. The only other index_put in the Core ML tests, in test_buffer (206), has a slice. So if the no-slice branch (245-252) refused every call, every test would still pass, yet every y[idx] = v would move to the CPU.
    • No test puts several indices next to a slice, such as index_put_(y, [None, i, j], v). The PR names this as one of the two forms that write to the wrong place, yet dropping len(given) > 1 or from line 270 passes.
    • Add a same-shape multi-index write that stays in Core ML, and a several-index write next to a slice whose values vary before the last index. Fix this →
  • Every runtime comparison in test_index_put_forms_coremltools_gets_wrong requires macOS 15 (test_coreml_partitioner.py:644), though only the iOS 18 case needs it.

    • On macOS 14 this skips the default-target Scatter3d comparison. That comparison is the only check that the 0-d write left in Core ML (609) lands in the right place.
    • The min/max test above it gates on _TEST_RUNTIME.
    • Gate each case instead, for example if _TEST_RUNTIME and (compile_specs is None or IS_VALID_TEST_RUNTIME). Fix this →

Recommendation

Request Changes

This PR fixes one random macOS failure in the recipe tests but adds another in the bool min/max test. Inputs that float16 represents exactly fix it. The other findings are small fixes to the error message and the tests.

  • Gather context and fetch the diff against origin/main
  • Run three independent reviewers; all three covered every changed hunk
  • Consolidate and fact-check the findings
  • Post review
  • Not done: running Python or the tests, and reading the coremltools source. Both need approval on this runner, so claims about what coremltools converts are marked low confidence.

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: apple Changes to the Apple backend delegate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants