Skip to content

Fix wrong dtypes and values in Core ML conversion - #24424

Merged
shoumikhin merged 9 commits into
pytorch:mainfrom
shoumikhin:coreml-converter-fixes
Nov 3, 2026
Merged

shoumikhin merged 9 commits into
pytorch:mainfrom
shoumikhin:coreml-converter-fixes

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

What is wrong today

coremltools converts some ops differently from PyTorch, so lowering fails or outputs are wrong:

  • sqrt, exp, sin and similar ops fail to lower on an int or bool tensor, which PyTorch computes in float. So do floor, ceil, round, relu, relu6 and hardtanh on an int tensor, though PyTorch keeps their result int.
  • Copying an int or bool tensor into a float one, as y[:, i] = v does, keeps its dtype, so a later float-only op fails to lower.
  • clamp_min and clamp_max fail to lower when the tensor and the bound differ in dtype, as in x.clamp_min(0) on a float x.
  • Fill ops such as full and zeros_like get the wrong dtype: torch.full((1, 16), 0) is int64 in PyTorch and float in Core ML, so later ops fail or lose precision. nn.MultiheadAttention hits this through its bool mask.
  • gelu(approximate="tanh") runs as the exact GELU: coremltools ignores the keyword torch.export passes.
  • nn.RMSNorm(eps=None) fails to lower: coremltools requires eps.
  • A Core ML program that returns an input, one value twice, or a same-shape expand of another output can silently return one of them wrong.
  • y[:, i] = v with i = -1 writes to an empty slice: lowering fails for a constant i, and for an input i Core ML fails at run time or, from iOS 18, silently skips the write.

What this change does

  • compiler/torch_ops.py overrides the converters of these ops; each override can go once coremltools fixes its bug. Inputs get the dtype PyTorch computes in: float for the ops that need it, int kept for the rounding and clipping ops, the dtype of y for a value copied into it, and the result's dtype for clamp_min and clamp_max. Fills get PyTorch's dtype, gelu reads its keyword, rms_norm gets PyTorch's default eps, and a negative index next to a slice gets the dim size added. The input fixes come first, since the fill fix sends int fills to them.
  • compiler/coreml_preprocess.py turns each same-shape tile output into a copy of its input, and copies an output that repeats an input or another output.

Testing

  • The Core ML tests (test_torch_ops.py, test_coreml_partitioner.py, test_coreml_recipes.py) pass on Linux and on macOS, where the new tests also compare the Core ML output with eager PyTorch.
  • Each new test fails when its fix is removed.

@shoumikhin shoumikhin added the release notes: apple Changes to the Apple backend delegate label Nov 2, 2026
Copilot AI balanced review requested due to automatic review settings November 2, 2026 13:16
@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:16
@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@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/24424

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

❌ 2 Cancelled Jobs, 63 Pending, 2 Unrelated Failures

As of commit ab6ba2b with merge base ee4b85b (image):

CANCELLED JOBS - The following jobs were cancelled. Please retry:

BROKEN TRUNK - The following jobs failed but were present on the merge base:

👉 Rebase onto the `viable/strict` branch to avoid these failures

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

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 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.

@claude

claude Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

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


Review summary: No confirmed blockers, but torch.ones and new_ones may still lower with the wrong dtype, the new fill overrides can fail on some inputs that used to convert, and some new tests can't catch the failures they target.

Detailed review

PR Review: #24424

Summary

The PR overrides coremltools' torch converters for fills, gelu, rms_norm and index_put, and adds a MIL pass that gives each Core ML output its own value. Nothing here is a confirmed blocker. Still open: torch.ones and new_ones may still get the wrong dtype, the fill overrides can fail on some inputs that used to convert, and the new tests miss some of the failures they target. Python and pip were blocked in this job, so I could not run the tests or read the coremltools source. Findings that depend on coremltools internals are marked low confidence.

Code Quality

  1. ones and new_ones are not covered by the fill fix

    • Where: backends/apple/coreml/compiler/torch_ops.py:94-162
    • Why they reach coremltools: ops_to_not_decompose (backends/apple/coreml/partition/coreml_partitioner.py:456-477) keeps every op coremltools supports whole. So aten.ones and aten.new_ones are not decomposed to full, and they go to coremltools' own converters.
    • Problem: as I recall coremltools' ones, it marks the dtype input unused and emits mb.fill(..., value=1.0). If so, torch.ones(L, L, dtype=torch.bool).tril(), a common causal mask, still comes out fp32.
    • Neither op is in the commit message's list of affected converters or in Fills.
    • Fix: override both with _add_fill(..., 1) and add them to Fills, or note in the commit message that coremltools 9.0 already handles them.
    • Low confidence on coremltools' ones. The partitioner path is confirmed. Fix this →
  2. _CopyReusedOutputs only looks through tile

    • Where: backends/apple/coreml/compiler/coreml_preprocess.py:90-95
    • Problem: the docstring says coremltools keeps a no-op tile only when it is an output. coremltools' no-op elimination guards other ops the same way, such as a same-shape reshape or a full slice.
    • Example: return y, y.view(y.shape) would give outputs [mul, reshape(mul)]. These are two distinct vars, so the pass leaves them alone.
    • Fix: add a same-shape view case to test_same_shape_expand_output (CPU_ONLY, FLOAT32). If it reproduces, also unwrap a same-shape reshape. For transpose and slices, an equal sym_type does not prove a no-op, so they need op-specific checks.
    • Low confidence: needs a Mac to check.
  3. The output-name rule the runtime relies on is only checked in a test

    • Where: coreml_preprocess.py:469-476
    • Why it matters: the runtime puts output names into an NSOrderedSet (backends/apple/coreml/runtime/delegate/ETCoreMLModelManager.mm:48-53, 321, ETCoreMLModelLoader.mm:45), so duplicate names collapse.
    • Problem: test__clone_dim_order_contiguous (test_torch_ops.py:329-330) asserts that output names are unique and differ from the input names, but lowering never checks this. As I understand coremltools' _apply_graph_pass, linear_quantize_weights (:663) reloads the spec and reruns coremltools' default pipeline without executorch::copy_reused_outputs. No test covers a quantized model with a repeated output.
    • Fix: move the test's two assertions into model_metadata_from_spec, next to the no-outputs check. A regression would then fail at lowering instead of producing a bad .pte.
    • Low confidence that the quantization pipeline undoes the copies. Fix this →
  4. New overrides don't link an upstream coremltools PR

    • Rule: torch_ops.py:8-9 says each op registered there should link to a coremltools PR.
    • Where it's missing: the fill overrides other than zeros_like (:96-97) and index_put (:166-167) say no upstream issue exists yet.
    • Why it matters: the PR description says each override can go once coremltools fixes the bug, but nothing tracks that.
    • Fix: file the coremltools issues and link them.
  5. rms_norm overwrites node.inputs in place

    • Where: torch_ops.py:226-231. It also turns the inputs into a list.
    • Mismatch: index_put in the same file first copies the node (node = copy.copy(node), :193).
    • Fix: copy the node here too. Fix this →

Testing

  1. test_fills_keep_their_dtype never lowers or runs anything

    • Where: backends/apple/coreml/test/test_torch_ops.py:414-464
    • Problem: it only calls ct.convert(..., convert_to="milinternal") on run_decompositions({}). So it never covers the edge program the backend converts, or the values _add_fill writes.
    • The Attention case: this is the user-facing symptom. Today it only checks MIL ops whose names start with zeros_like (:462). Its output is float, so it can go through to_edge_transform_and_lower and _compare_outputs like the other new tests. Fix this →
  2. The constant-index write in test_negative_index_next_to_slice can't be seen in the output

    • Where: test_torch_ops.py:515-516
    • Problem: y[:, self.last] = v and y[:, i] = v * 2 both write column -1. A constant-index write that is silently skipped still passes _compare_outputs.
    • Fix: use torch.tensor([-2]) for the buffer. Fix this →
  3. The dynamic subtest only runs at the export width

    • Where: test_torch_ops.py:553-571
    • Problem: with width dynamic, _compare_outputs still runs on the width-4 example_inputs. That run goes through the is_symbolic branch (torch_ops.py:183-184), but the run-time size equals the static one. A wrap that used the export-time size would pass.
    • Fix: also run the dynamic program with, for example, an x of shape (3, 6). Fix this →

Backward Compatibility

  1. Integer fills outside the int32 range now fail to lower

    • Where: torch_ops.py:80-91, and scalar_tensor at :158-162
    • Problem: these cast with np.int32(value). Under NumPy 2 that raises OverflowError; under NumPy 1.x it wraps silently.
    • Trigger: torch.full((n,), torch.iinfo(torch.int64).max), an int64 sentinel. The PR description says coremltools lowered this kind of fill as fp32 before.
    • Fix: clamp to np.iinfo(np.int32) before the cast. Fix this →
  2. The fill alias falls back to coremltools' full_like, not its fill

    • Where: torch_ops.py:117-122
    • Problem: with override=True, torch_alias=["fill"] replaces coremltools' fill registration, which the commit message describes as a separate converter. For a TorchScript node or a fill value known only at run time, the fallback calls _ct_ops.full_like.
    • Trigger: a TorchScript fill_ has 2 inputs, but full_like's TorchScript parsing expects 7. So ct.convert of a traced model that uses fill_ would fail in any process that imported this module. The comment at :99-100 says TorchScript nodes keep the coremltools converter.
    • Fix: give fill its own override that falls back to _ct_ops.fill, and add a TorchScript check like the one for index_put.
    • Low confidence: I could not check the coremltools registry. Fix this →

Recommendation

Request Changes

Two items block approval:

  • 1: decide whether the fill fix is complete. Either override ones and new_ones, or confirm that coremltools 9.0 handles them.
  • 9: a confirmed new lowering failure with a one-line fix.

The rest are minor or need a Mac to confirm.

@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 57m 36s —— View job


Review summary: I found no confirmed bugs, but the new output-copy pass only looks through tile, the fill alias may break TorchScript fill_ conversion, and the Fills test model is never lowered or run on Core ML.

Detailed review

PR Review: #24424

Summary

The PR overrides coremltools' torch converters for fills, gelu, rms_norm and index_put, and adds a MIL pass that gives each Core ML output its own value. None of the findings is a confirmed bug. The open items:

  • the pass may miss no-ops other than tile;
  • lowering doesn't check the output-name rule the runtime depends on;
  • the fill fallback may break TorchScript conversion;
  • int fills may now fail in ops that only take float;
  • the fill and index_put tests skip some of the cases they target.

Python, pip and git fetch were blocked in this job, and coremltools isn't installed. So I couldn't run the tests or read the coremltools source. Findings that depend on coremltools internals are marked low confidence.

Code Quality

  1. _CopyReusedOutputs only looks through tile

    • Where: backends/apple/coreml/compiler/coreml_preprocess.py:90-95
    • Problem: the docstring says coremltools keeps a tile that changes nothing only when that tile is an output. As I recall, coremltools' noop_elimination keeps its other no-ops for the same reason: a same-shape reshape, an identity transpose, or a full slice. Each of these would sit on top of another output, as the tile does. The pass leaves them alone because they are different vars.
    • Trigger: with the CPU_ONLY and FLOAT32 specs from test_same_shape_expand_output, y = x * 2; return y, y.view(y.shape) would give the outputs [mul, reshape(mul)] and no copy.
    • Fix: add a same-shape view subtest to test_same_shape_expand_output. If Core ML returns that output wrong, also unwrap a same-shape reshape. An equal sym_type is enough to show a reshape is a no-op. A transpose also needs its perm checked, and a slice its range.
    • Low confidence: depends on coremltools internals and needs a Mac to confirm. Fix this →
  2. Lowering doesn't check the output-name rule the runtime depends on

    • Where: coreml_preprocess.py:469-484 (model_metadata_from_spec) and the multifunction metadata at :941-946
    • Why it matters: the runtime matches outputs by name. It puts the output names in an NSOrderedSet (runtime/delegate/ETCoreMLModelManager.mm:48, :321) and keys the output buffers by name (:113). So output names must be unique and must not match an input name.
    • Problem: only test__clone_dim_order_contiguous (test_torch_ops.py:329-330) checks this rule; lowering doesn't. Suppose a later rewrite, such as linear_quantize_weights (:663) or save_multifunction (:904), drops one of the copies. The .pte would then return wrong outputs instead of failing at lowering. No test covers a quantized or multimethod model that repeats an output.
    • Fix: in both places, next to the existing no-outputs check, raise when output names repeat or match an input name. Fix this →
  3. Most new overrides don't link an upstream coremltools issue

    • Where: the header at backends/apple/coreml/compiler/torch_ops.py:7-9, compared with :94-97 (fills) and :165-167 (index_put)
    • Problem: the header says each op registered here should link the coremltools PR that adds it. But index_put and every fill except zeros_like say no upstream issue exists yet. These override=True registrations replace the upstream converters for the whole process, and nothing tracks when they can be removed. The header also still describes only ops that coremltools lacks.
    • Fix: file the coremltools issues and link them. Update the header to cover overrides too.

Testing

  1. test_fills_keep_their_dtype never lowers or runs Fills

    • Where: backends/apple/coreml/test/test_torch_ops.py:441-453

    • Problem: Fills only goes through ct.convert(..., convert_to="milinternal") on run_decompositions({}), and the test checks only its output dtypes. Only Attention is lowered and run. So nothing compares these values with eager:

      • what _add_fill writes for full, new_full, fill, zeros, new_zeros and ones_like;
      • the scalar_tensor const.
    • Also: Fills doesn't use torch.ones. The commit message says ones needs no override because coremltools casts it to its dtype, but no test checks that.

    • Fix:

      • lower Fills with to_edge_transform_and_lower and check it with _compare_outputs;
      • add torch.ones((2, 3), dtype=torch.long) and a bool torch.ones to Fills.

      Fix this →

  2. The index_put fix only runs on Core ML at iOS 18

    • Where: test_torch_ops.py:575
    • Problem: self._coreml_partitioner() defaults to iOS 18 (:32), so the lowered Write model only runs at iOS 18. Below iOS 18, an input index used to make Core ML raise at run time. For that case the test only counts the select ops in the MIL program (:556).
    • Fix: also lower and compare the model with minimum_deployment_target=ct.target.iOS17. Fix this →

Backward Compatibility

  1. A TorchScript fill_ may now go to coremltools' full_like converter

    • Where: backends/apple/coreml/compiler/torch_ops.py:117-122
    • Problem: torch_alias=["fill"] with override=True replaces coremltools' own fill converter. The test docstring (test_torch_ops.py:418) lists fill separately from full_like. A node without a dtype now falls back to _ct_ops.full_like instead of that fill converter. Yet the comment at :98-100 says TorchScript nodes keep the coremltools converter.
    • Trigger: ct.convert(torch.jit.trace(m, ...)) where m calls x.fill_(0.0). This happens in any process that has imported the Core ML backend, since the converter registry is global. A TorchScript fill_ has 2 inputs, and as I recall full_like expects 7 from TorchScript, so this conversion would now fail.
    • Fix: give fill its own override that falls back to _ct_ops.fill. Convert a TorchScript fill_ next to the TorchScript index_put check (test_torch_ops.py:557-567).
    • Low confidence: I couldn't read coremltools to confirm that it has a separate fill converter. Fix this →
  2. Int fills may now fail to lower into ops that only take float

    • Where: torch_ops.py:80-91
    • Problem: fills that coremltools made fp32 now come out int32, to match torch's int64.
      • Torch promotes an int input to float for ops such as sqrt, rsqrt, exp and log.
      • coremltools' converters for those ops may pass an int32 input straight to MIL ops that only take fp16 or fp32. If they do, a model that lowered before now fails.
      • The new tests only feed fills into +, *, where and the attention mask.
    • Trigger: torch.full((1, 4), 4).sqrt().
    • Fix: lower a case like this. If it fails, either cast the int input to fp32 in those converters, as torch does, or call out the change in the PR description.
    • Low confidence: depends on coremltools' unary converters. Fix this →

Recommendation

Approve

Nothing here is a confirmed bug. Before merging, check 1 and 6 on a Mac:

  • 1: no-ops other than tile may still return wrong outputs, the same bug this PR fixes.
  • 6: if coremltools has its own fill converter, a TorchScript conversion that works today would break.

@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, this looks good. Each override falls back to the coremltools converter when it doesn't have what it needs, and each fix has a test. I didn't find regressions in the cases I tried against coremltools 9.0: 0-d and out-of-int32-range fills, negative indices on 3-D tensors, and a bool mask next to a slice, which fails the same way with or without this PR. Small comments are inline.

Did you run these tests locally on a MacBook? The end-to-end comparisons in _compare_outputs only run when IS_VALID_TEST_RUNTIME is true, so on Linux the new tests mostly check the converted program. The test-coreml (coreml, models) job is timing out at 3h (it does on main too), so CI doesn't give much signal here. If you ran them on macOS, could you say which macOS version and compute units? That matters most for test_same_shape_expand_output, which depends on CPU_ONLY vs ALL behavior.

Nit: the tests call private APIs (CoreMLBackend._convert_to_mlmodel, mlmodel._mil_program), and the pass uses function._copy_scope_info. They all exist in coremltools 8.3 (fbcode) and 9.0, so this is fine for now, but they could break when coremltools is upgraded.

Some things I checked that look right: torch's decomposition uses the float32 eps for a float16 rms_norm (torch/_decomp/decompositions.py), and the identity copies survive quantization. op_linear_quantizer_config reruns the default pipeline, but noop_elimination skips ops that produce a program output.

_add_fill(context, node, inputs[1], inputs[2].val)


@register_torch_op(torch_alias=["fill"], override=True)

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.

With the fill alias, a TorchScript fill node (no dtype) now falls back to _ct_ops.full_like instead of coremltools' own fill converter. The result is equivalent, and a TorchScript fill_ model I tried fails the same way with or without this PR. Still, a separate fill override that falls back to _ct_ops.fill would leave the TorchScript path untouched.

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.

Good catch. In the next push, fill has its own override. It falls back to coremltools' own fill converter when the node has no dtype or the fill value is known only at run time, so the TorchScript path is the same as before.

return shared


@register_pass(namespace="executorch", name="copy_reused_outputs")

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: without override=True, coremltools raises KeyError: Pass executorch::copy_reused_outputs already registered if this module is imported twice (e.g. a reload in a notebook). The torch op overrides in torch_ops.py already pass override=True.

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 in the next push: the pass now registers with override=True, like the op overrides.

and output.op.op_type == "tile"
and output.op.x.sym_type == output.sym_type
):
output = output.op.x

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.

When the tile's input isn't otherwise reused, the output becomes output.op.x and the tile op is left with no users. Since this pass runs last, nothing removes it, so it ends up in the serialized program. Harmless, but you could remove it here (or run dead_code_elimination after this pass).

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.

Thanks. With the default pass list, coremltools already removes it: after this pass it runs its backend_mlprogram pipeline, which includes dead_code_elimination. But a custom pass list without dead_code_elimination skips that pass in the backend pipeline too, and then the tile stays. So the next push removes the replaced tile in this pass, and test_same_shape_expand_output checks that with such a list.

Fills(), (torch.arange(6).reshape(2, 3), torch.rand(2, 3).half())
)
program = ct.convert(
fills.run_decompositions({}),

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.

Fills is only checked through ct.convert(..., convert_to="milinternal"); only the MultiheadAttention model is lowered and run. Could you also lower Fills through to_edge_transform_and_lower and compare it with _compare_outputs? That covers the ExecuTorch path, which is where node.meta["val"] comes from, and checks the output dtypes and values on the Core ML runtime.

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 in the next push. The test now also lowers Fills with the partitioner, checks that every op goes to Core ML, and on macOS compares the outputs with eager through _compare_outputs.

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.

The latest push also changes how the test checks the dtypes. _compare_outputs cannot catch a wrong dtype here. The runtime casts each Core ML output to the dtype ExecuTorch expects (copy_value in multiarray.mm), and float32 stores every value Fills returns exactly. So instead of converting the exported program directly, the test now converts lowered_module_0.original_module, the program the delegate itself converts, with convert_to="milinternal", and checks that the outputs are int32, bool and float16. I also replaced one zeros_like with full_like, since no test covered the full_like override.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks for the review.

Mac runs. Yes. I ran the Core ML test files on macOS 27.0.1 on an M3 Max, with the Core ML runtime, so the comparisons with eager ran too. At the head you reviewed, run together with #24425, test_torch_ops.py, test_coreml_partitioner.py and test_coreml_recipes.py all passed. The comparisons use the default compute units (ALL), except test_same_shape_expand_output, which sets CPU_ONLY and float32 on purpose, because that setting shows the bug in this small model: without the fix, the size 1 case returns zeros for y. The next push also adds fixes for int and bool inputs of float-only ops, rounding and clipping ops, and copies. In their final form, those and the changes for your comments have run only on Linux so far.

Private APIs. Agreed. The tests use them to look at the converted program. The pass needs _copy_scope_info because set_outputs does not copy scopes the way a var replacement does, and I did not find a public way to do that in 9.0. If an upgrade removes them, lowering or the tests fail with an error, so we will see it.

@shoumikhin
shoumikhin force-pushed the coreml-converter-fixes branch from dae80e0 to d5e114a Compare November 3, 2026 02:23
Copilot AI balanced review requested due to automatic review settings November 3, 2026 02:23
@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 58m 13s —— View job


Request Changes: the new copy_reused_outputs pass likely fails to lower a model that returns one same-shape tile twice, and the hardtanh override silently gives wrong values for a float tensor that coremltools holds as int32; the other findings are minor or low confidence.

Detailed review

PR Review: #24424

Summary

This PR makes the Core ML converter give fills, copies, and the int or bool inputs of elementwise ops the dtypes torch gives them. It also reads gelu's approximate keyword and RMSNorm's default eps, wraps negative index_put indices, and adds a MIL pass so each output gets a value of its own. Two problems need fixing before merge:

  • a likely lowering failure in the new pass;
  • silently wrong hardtanh output in a case where lowering used to fail.

The rest are minor or low confidence. Python and the coremltools source weren't available in this job, so I couldn't run anything. Findings that depend on coremltools internals are marked unverified.

Code Quality

  • copy_reused_outputs queues a tile once per output, so a tile returned twice gets removed twice (coreml_preprocess.py:95, :108).
    • Trigger: use the compile specs of test_same_shape_expand_output (float32, so the tile itself is the output). Then e = (x * 2).expand(1, -1, -1); return e, e.clone() returns the tile's var twice, because the clone converts to nothing.
    • What happens: tiles holds the tile twice, and function.remove_ops([tile, tile]) runs. As far as I remember, Block.remove_ops looks each op up with list.index. So the repeated op is reported as not found in block and lowering fails (unverified).
    • Fix: append output.op only if it isn't already in tiles, and add this model to test_same_shape_expand_output. Fix this →
  • hardtanh picks its int path from the Core ML dtype of its input (torch_ops.py:508).
    • Why it's wrong: the PR's own copy comment (lines 524-525) and test (lines 211-213) say coremltools gives a float arange int32.
    • Trigger: F.hardtanh(torch.arange(4, dtype=torch.float32), 0.5, 2.5) clips at int(0.5) = 0 and int(2.5) = 2. It gives [0, 1, 2, 2] in int32, where torch gives [0.5, 1, 2, 2.5].
    • Regression: before this PR, clip rejected the int32 input and lowering failed. Now the result is silently wrong.
    • Fix: take the dtype from _torch_dtype(node) when the node carries it, as _copy_into_dtype_of_self does, and cast x to fp32 when torch says float. Fix this →
  • The new overrides don't link an upstream coremltools PR, which the file header requires (lines 7-9). Lines 98, 176, 417-418, 462, 504 and 521 say no upstream issue or change exists. These are override=True, so they will keep shadowing coremltools after it fixes these ops. Please file the upstream issues and link them, or relax the header.
  • Nit: _int_relu and _int_relu6 (lines 477-482) are one-line, single-use helpers, which CLAUDE.md discourages. Lambdas in the table at lines 485-490 would do.

Testing

  • test_int_input_of_float_ops doesn't assert that every op was delegated (test_torch_ops.py:145-150), unlike its siblings at lines 184-191 and 225-232. Without that assertion, the test still passes if these ops fall out of the Core ML partition: lowering succeeds, and the portable kernels compute the same values. On Linux, _compare_outputs returns early anyway. Fix this →

Backward Compatibility

  • The copy override changes TorchScript conversion for the whole process (torch_ops.py:526-536).
    • Scope: the registration applies to every ct.convert of a TorchScript model in a process that imports this backend.
    • What happens: a TorchScript node carries no torch dtype, so src is cast to the Core ML dtype of self. For a tensor that coremltools holds in another dtype, such as a float arange, that drops the fractions. c13c5e5 says it avoids exactly this, and the stock converter keeps the dtype of src.
    • Inconsistency: the fill and index_put overrides keep TorchScript nodes on the coremltools converter.
    • Fix: if _torch_dtype(node) is None: return _ct_ops.copy(context, node). Fix this →
  • hardtanh still requires all three inputs (torch_ops.py:507).
    • Trigger: an exported hardtanh leaves out trailing bounds that are ints equal to the schema defaults -1 and 1. So nn.Hardtanh(0, 1) gives a node with two inputs, nn.Hardtanh(-1, 1) gives a node with one, and expected=3 rejects both. XNNPACK handles the short form (backends/xnnpack/operators/op_hardtanh.py:39-43).
    • Unverified: I couldn't check whether coremltools 9.0's converter accepts these forms. If it does, models that lowered before now fail.
    • Fix: use min_expected=1, with the missing bounds defaulting to -1 and 1. Fix this →
  • _core_ml_type makes every int dtype int32 (torch_ops.py:88), though c50fdf8 notes that Core ML has narrower ints from iOS 17.
    • What happens: a copy from int8 into int8 now gets a cast to int32, and zeros_like of an int8 tensor comes out int32 rather than int8. An int8 y[:, i] = v that lowered before could then fail in the following select_scatter, which needs matching dtypes.
    • Low confidence: I couldn't check which narrow ints coremltools keeps.
    • Fix: for the copy, cast only when src and torch's dtype for self differ in kind (bool, int, float).
  • Import-time _ct_ops reads may break the backend import in fbcode (lines 434-457, 485-495 and 539-541, plus _make_fill_op at line 23).
    • What happens: these read _ct_ops.expm1, log2, log10, _round, relu6, copy and others outside the is_fbcode() guard. fbcode is still on a coremltools older than 9.0 (TODO at line 245). If any of these names is missing there, torch_ops fails to import, and coreml_preprocess with it.
    • Low confidence: please confirm with the internal import, or move these under the guard.

Recommendation

Request Changes: fix the repeated tile in copy_reused_outputs, and take hardtanh's int path from torch's dtype. Both are small changes. The rest can be follow-ups.


  • Gather context (diff, commits, repo conventions)
  • Run three independent reviews
  • Consolidate findings
  • Fact-check findings
  • Post review

@shoumikhin
shoumikhin force-pushed the coreml-converter-fixes branch from d5e114a to c812e63 Compare November 3, 2026 06:39
Copilot AI balanced review requested due to automatic review settings November 3, 2026 06:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks for the review.

Fixed in the latest push

  • hardtanh now clips in the dtype torch gives its input. coremltools gives torch.arange(6, dtype=torch.float32) the dtype int32, so hardtanh with bounds 0.5 and 2.5 clipped it at 0 and 2. Now it clips at 0.5 and 2.5. A new test checks the clip bounds in the converted program, so it fails on Linux too without the fix. A TorchScript node has no torch dtype, so it keeps the coremltools converter, as on main.
  • test_int_input_of_float_ops now checks that every op goes to Core ML.
  • The int relu and relu6 helpers are now lambdas in the table.

Not changed

  • Tile returned twice: in coremltools 9.0 and 8.3.0, Block.remove_ops accepts the same op twice and removes it once. So return e, e.clone() lowers, and its second output becomes an identity copy of the first.
  • TorchScript copy: coremltools' own copy converter asserts that it never runs for TorchScript. An earlier pass rewrites or rejects copy_ first. So this override cannot change TorchScript conversion.
  • hardtanh with bounds left out: this fails on main too. nn.Hardtanh(-1, 1) exports with only the input, and coremltools' own converter also needs all 3 inputs.
  • Int8: you are right that the copy and zeros_like now give int32 for int8. But in the int8 copies, writes and zeros_like I tried, at the default target and iOS 16 to 18, nothing that lowered on main fails now.
  • Older coremltools: every name these converters read also exists in coremltools 8.3.0, and the backend imports and lowers with it.
  • Upstream links: no upstream issue covers these cases yet, so there is nothing to link. Each comment gives an example, so an issue can be filed from it. The links will be added once the issues are filed.

Testing: the Core ML tests pass on Linux. They have not run on macOS for this push yet.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

PR Review: #24424

  • Gather context (diff, commits, repo conventions)
  • Run three independent reviews
  • Consolidate findings
  • Fact-check findings (dropped 6 that didn't hold up or were too speculative)
  • Post review (writing it up)

Note: Python and file access outside the repo are blocked in this job, and the coremltools source isn't on disk. So I can't run the tests, and any finding that depends on coremltools internals will be marked low confidence.

View job run

@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 53m 56s —— View job


Needs Discussion: int fills are now int32, so they probably go through coremltools' clamp, which would silently return 0 instead of 0.5 for torch.full((2, 3), 0).clamp(0.5, 2.5) (low confidence, since coremltools could not be run in this job); the other three findings are minor.

Detailed review

PR Review: #24424

Summary

The PR makes the Core ML converter match torch's dtypes and values for int inputs of float-only, rounding and clipping ops, for copy_ into another dtype, fills, tanh gelu, RMSNorm's default eps and negative index_put indices, and adds a MIL pass that gives each output its own value. Int fills are now int32, and coremltools' clamp probably gives them wrong values. The output pass handles only tile and copies scopes from the wrong op, and no test covers the new empty aliases. coremltools was not installed in this job and Python could not be run, so findings that depend on coremltools internals are marked low confidence.

Code Quality

  • _CopyReusedOutputs unwraps only tile (coreml_preprocess.py:90-96; low confidence, a question). noop_elimination also keeps other no-op ops when they are block outputs, such as a same-shape reshape. So return y, y.view(y.shape) probably leaves the outputs [mul, reshape(mul)], and the pass treats them as two separate values. If Core ML returns one of them wrong, as it does for the tile in test_same_shape_expand_output, the check at line 92 should cover those ops too. Could you try this case on a Mac?
  • The identity that replaces a tile gets its scopes from the tile's input (coreml_preprocess.py:96-102). Line 96 rebinds output to the tile's input. For the model in test_same_shape_expand_output, line 102 therefore copies the scopes of the mul, not of the expand. After line 108 removes the tile, no op in the debug handle map belongs to the expand node, and the mul's handle points to both the mul and the identity. Tools that read the map then count the copy as part of the mul. Keep the original output var before unwrapping it, and pass that var to _copy_scope_info. Fix this →

Testing

  • No test exercises the new empty, empty.memory_format, new_empty and empty_like aliases (torch_ops.py:137, :144, :151). The comment at lines 135-136 says they matter only when dim order ops are skipped, but no Core ML test lowers with _skip_dim_order=True. The torch.empty_like(ids) at test_torch_ops.py:622 can't catch a wrong dtype either, because fill_ uses only its shape. Either add a test that lowers these with EdgeCompileConfig(_skip_dim_order=True) and checks the dtype, or drop the aliases. Fix this →

Backward Compatibility

  • Int fills now go through coremltools' clamp, which probably gets float bounds wrong (torch_ops.py:102-107 and the other new fill overrides; low confidence). Before this PR, coremltools made int fills fp32, as the PR's comment at lines 98-100 says. Now they are int32, as in torch. The PR adds no clamp override, so coremltools' own converter handles clamp. As I recall that converter, it casts an int input to fp32, clips it, and casts the result back to int32. Torch instead promotes an int tensor clamped with float bounds to float. So torch.full((2, 3), 0).clamp(0.5, 2.5) gives 0.5 in eager mode and on main, but probably 0 with this PR. Nothing raises an error, because the runtime casts the int32 output to the float32 ExecuTorch expects. An int model input clamped this way already hit this bug; the PR extends it to fills, which used to come out right. Please check this against coremltools 9.0. If it holds, add a clamp override that casts x to _core_ml_type(node) when the dtypes differ, as hardtanh does at lines 505-507, then calls _ct_ops.clamp. Add the case to the tests as well. Fix this →

Recommendation

Needs Discussion

None of the findings is a confirmed blocker. If the clamp finding holds on coremltools 9.0, though, it is a silent wrong-value regression for int fills, so please fix it or rule it out before merging. The PR discussion also says the latest push has not run on macOS. The unittest-macos job does run backends/apple/coreml/test, but _compare_outputs compares against eager only on macOS 15 or later (test_coreml_utils.py:15-19). Please confirm that a run on macOS 15 or later passes at c812e63.


  • Gather PR context and diff
  • Run three independent reviews
  • Consolidate findings
  • Fact-check findings
  • Post review

@shoumikhin
shoumikhin force-pushed the coreml-converter-fixes branch from c812e63 to 8935822 Compare November 3, 2026 15:42
Copilot AI balanced review requested due to automatic review settings November 3, 2026 15:42

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.

Fixed

  • Debug info: every same-shape tile output becomes a copy that keeps the tile's scopes, so the debug handle map keeps the expand. test_same_shape_expand_output checks the debug handle of each output, in both orders.
  • Empty aliases: test_empty_keeps_its_dtype lowers int torch.empty, new_empty and empty_like with dim order ops skipped and checks that they come out int32. It fails if any one of the three aliases is removed. The bare empty alias is gone, since an exported torch.empty is always empty.memory_format. The old comment was also wrong: new_empty and empty_like reach Core ML with dim order ops on too.
  • clamp_min and clamp_max: unlike clamp, coremltools requires the tensor and the bound to share a dtype. So torch.full((2, 3), 0).clamp_min(0.5) failed to lower at the commit you reviewed. A new commit casts both to the dtype of the result. That also fixes x.clamp_min(0) on a float x, which fails on main too.

Not changed

  • clamp after an int fill: coremltools' clamp promotes x, min and max to one dtype (in 9.0), so float bounds clip in float. On macOS, torch.full((2, 3), 0).clamp(0.5, 2.5) gives 0.5 as in eager, and so do three similar cases, on main and with this change.
  • Other no-op outputs: coremltools keeps a same-shape reshape, transpose, pad, y * 1 or y + 0 when it makes an output. On macOS, Core ML returns each one right on main too. I checked float32 on the CPU and the GPU, before or after y, and the default compute units. In these runs, only the tile, a value returned twice and a returned input came back wrong, and the pass handles those.

Testing: the Core ML tests pass on Linux and on macOS at the latest push.

@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 58m 23s —— View job


Approve: no blockers, with two minor follow-ups (a float arange still comes out int32 through relu, floor, ceil and round, and the empty-dtype test skips the default dim-order flow) and one comment nit.

Detailed review

PR Review: #24424

Summary

The PR overrides coremltools converters so that fills, empties, scalar_tensor, int inputs of float-only and int-keeping ops, clamp and hardtanh bounds, copy, gelu's tanh mode, RMSNorm's default eps and negative index_put indices convert with the dtypes and values torch gives them, and adds a MIL pass that gives each Core ML output a value of its own. There are no blockers; two minor gaps and a nit follow. This job couldn't run Python and the coremltools source isn't in the checkout, so nothing was run, and what coremltools does is taken from the PR's own comments and tests.

Code Quality

  • A float arange still comes out int32 through _with_int_input. The PR says coremltools gives torch.arange(6, dtype=torch.float32) the dtype int32 (torch_ops.py:496-498, :552-553), and hardtanh (:500-516), clamp_min/clamp_max (:524-544) and copy (:554-564) take the dtype from torch, which covers it. _with_int_input decides on the Core ML dtype instead (types.is_int(x.dtype) at :469), so relu, floor, ceil and round of such an arange take the int branch and stay int32. A float-only op after them, such as F.gelu(torch.arange(6, dtype=torch.float32).reshape(2, 3).relu()), should then still fail with the type-domain error quoted at :550-551 (not run here). This isn't a regression: the relu failed to lower before this PR. An arange override that casts the coremltools result to _core_ml_type(node) when the node carries a dtype would fix every consumer at once. Fix this →
  • Nit: the comment at coreml_preprocess.py:98 repeats :102-104. Both say the copy takes the scopes of the output it replaces, and :98 sits above an if that also covers returned inputs and repeated outputs, not only tiles. Keep one, per CLAUDE.md's minimal-comments rule. Fix this →

Testing

  • test_empty_keeps_its_dtype (test_torch_ops.py:743-778) covers only EdgeCompileConfig(_skip_dim_order=True). With the default config, MemoryFormatOpsPass turns aten.empty.memory_format into dim_order_ops._empty_dim_order (exir/passes/dim_order_ops_registry.py:83, exir/program/_program.py:854-855). The alias at torch_ops.py:227-229 hands that op to coremltools' own _empty_dim_order, which this PR doesn't override, so the new zeros override may never see torch.empty in the default flow. This is low confidence, since whether coremltools' _empty_dim_order already keeps torch's dtype couldn't be checked here. Lowering Empties with the default config as well would settle it. If its outputs aren't int32, convert _empty_dim_order with _add_fill when the node carries a dtype, as zeros does. Fix this →

Recommendation

Approve: nothing blocks; the items above are minor follow-ups.

Torch computes sqrt, exp, sigmoid, sin and similar ops in float when
their input is an int or bool tensor. coremltools passes such an input on
to the Core ML op, which takes only float, so lowering fails. For
example, x.long().sqrt() fails with "expects tensor or scalar of dtype
from type domain ['fp16', 'fp32'] but got tensor[2,3,int32]".
coremltools casts an int input for log and log1p, but not a bool one.

The backend now wraps the coremltools converters of these ops, log and
log1p included, so that an int or bool input is first cast to fp32, as
torch does.

The new test lowers each op on an int or bool input, checks that
everything goes to Core ML, and on macOS compares the output with eager.
For an int input, torch returns floor, ceil and round of it unchanged
and computes relu, relu6 and hardtanh in int. torch.nn.ReLU6 and
torch.nn.Hardtanh export as hardtanh. coremltools passes the input on to
the Core ML op, which takes only float, so lowering fails. For example,
x.long().floor() fails with "expects tensor or scalar of dtype from type
domain ['fp16', 'fp32'] but got tensor[2,3,int32]". hardtanh also fails
on a float input with int bounds, which F.relu6(x, inplace=True) passes,
or on a float16 input, since coremltools hands the bounds to Core ML's
clip as they are, and clip needs them in the dtype of the input. It also
fails on torch.arange(4, dtype=torch.float32), which coremltools makes
int32.

The backend now converts these ops for an int input as torch does: the
input itself for floor, ceil and round, and the input clipped in int for
relu, relu6 and hardtanh. Torch drops the fraction of each hardtanh
bound, so hardtanh(x, -1.5, 2.7) clips an int x at -1 and 2. For a float
input, hardtanh passes its bounds to clip in the dtype of the input, as
apple/coremltools#2894 does upstream, and the
other ops keep the coremltools conversion. hardtanh clips in the dtype
torch gives its input, not in the one coremltools gives it, so
hardtanh(torch.arange(4, dtype=torch.float32), 0.5, 2.5) clips at 0.5
and 2.5. A TorchScript node carries no dtype, so it keeps the
coremltools converter. A narrower int, which Core ML has from iOS 17,
still fails for relu and relu6, since Core ML's maximum takes int32 but
no narrower int. hardtanh works there, since it casts such an input to
int32 first.

The new tests lower each op on an int input, hardtanh with int bounds on
a float16 input, and hardtanh of a float arange, check that everything
goes to Core ML, and on macOS compare the output with eager. The arange
test also checks the bounds of the clip in the converted program, and
converts a TorchScript hardtanh of a float input.
Torch's copy writes src in the dtype of self, so copying an int or bool
tensor into a float one gives float values, and copying a float into an
int one drops the fraction. coremltools' copy keeps the dtype of src.
Lowering turns y[:, i] = v into a copy as well. So an int or bool v
written into a float y stays int or bool in Core ML, and the next op
that takes only float fails to lower. For example,
F.gelu(x.clone().copy_(x.long())) fails with "expects tensor or scalar
of dtype from type domain ['fp16', 'fp32'] but got tensor[2,3,int32]",
and y[:, 0] = x[:, 1].long() on a float y fails in the select_scatter
that follows. A float v written into an int y fails there the same way.

The backend now casts src to the dtype torch gives self before
coremltools converts the copy. It reads that dtype from the node, not
from self in Core ML, since coremltools gives some tensors another
dtype, such as int32 to torch.arange(4, dtype=torch.float32), and
casting to it would drop the fraction of the copied values.

The new test lowers int and bool values written into a float tensor, an
int tensor copied into a float one and read by gelu, a float written
into an int tensor, and a float copied into a float arange and read by
softmax, checks that everything goes to Core ML, and on macOS compares
the output with eager.
Torch promotes the tensor and the bound of clamp_min and clamp_max to
one dtype, while coremltools requires the two to have the same dtype. So
x.clamp_min(0) of a float x fails to lower with an AssertionError, and
so does a float bound on an int tensor, such as x.long().clamp_max(2.5).

The backend now casts the tensor and the bound to the dtype torch gives
the result, then takes their maximum or minimum as coremltools does. A
TorchScript node, which carries no dtype, keeps the coremltools
converter.

The new test lowers both ops with a bound of the other dtype, as a
number and as a tensor, on a float and on an int tensor, checks that
everything goes to Core ML, and on macOS compares the output with eager.
coremltools makes full without a dtype, zeros and new_zeros as float,
ones_like as float from iOS 16, zeros_like with the dtype of its input,
and full_like, new_full, fill and scalar_tensor with the type of their
fill value. So torch.full((1, 16), 0), which is int64 in torch, comes out
as float. Index math on it then fails to lower, or loses integers above
2048 at float16 precision. nn.MultiheadAttention turns a bool mask into a
float one with zeros_like(mask, dtype=...), which coremltools kept bool,
so attention came out wrong.

The backend now converts these ops, and empty, new_empty and empty_like,
with the dtype torch gives them. A fill value known only at run time, or
a node without a dtype, keeps the coremltools conversion. torch.ones
needs nothing, since coremltools casts it to its dtype, and new_ones has
no coremltools converter, so lowering decomposes it into full.

An int fill can now reach a Core ML op that takes only float, such as
torch.full((1, 4), 4).sqrt() or torch.full((1, 4), 4).floor(), a float
bound of clamp_min or clamp_max, such as
torch.full((1, 4), 0).clamp_min(0.5), or a copy between it and a float
tensor, such as y[:, 0] = torch.full((2,), 7) on a float y. The four
commits before keep these models lowering.

The new test lowers the fills and the attention with every op in Core
ML, checks the dtypes Core ML gives the fills and the attention mask,
and on macOS compares both with eager. The tests of the four commits
before now also lower int fills read by sqrt, floor and clamp_min, and
copied to and from a float tensor. Another lowers empty, new_empty and
empty_like with dim order ops skipped and checks their dtypes.
torch.export passes approximate="tanh" to gelu as a keyword, and
coremltools reads it only from the positional arguments, so
gelu(approximate="tanh") ran as the exact GELU. The two differ by up to
4.7e-4. The backend now reads the keyword.

The new test checks the mode in the converted program, and on macOS
compares the output with eager.
torch.nn.RMSNorm leaves eps out by default, and coremltools 9.0 requires
it: "got 3 input(s), expected [4]". Torch then uses the float32 machine
epsilon, also for float16 inputs, so the backend passes that.

The new test checks the eps in the converted program, and on macOS
compares the output with eager.
coremltools turns ops such as clone into nothing, so a partition can
return its own input, as in return x.clone(), x * 2, or one value twice.
It also keeps a same-shape expand as a tile that only passes its input
through, as for the scale_tril that torch.distributions.MultivariateNormal
returns next to its expand. Core ML then returns one of those outputs as
zeros or as the bytes of another output. For the expand, this happens in
float32 on the CPU and GPU.

A pass at the end of the conversion now replaces such a tile with a copy
of its input, and returns an input or a value that is already an output
through a copy too. Each copy carries the debug info of the output it
replaces, such as the expand. The pass removes the tile it replaces,
since a custom pass list may leave out the dead code elimination that
removes it otherwise. An expand that changes the shape, or whose shape
is known only at run time, stays a tile.

The tests check the outputs of the converted program and the debug
handle each one carries, for an expand before and after the value it
expands, also that no replaced tile is left with a pass list that has no
dead code elimination, and on macOS run it: the expand on the CPU in
float32, where the value came back wrong without this change.
coremltools writes y[:, i] = v to y[:, i:i + 1] without first wrapping a
negative i, so for i = -1 it writes to an empty slice. With a constant i,
lowering fails with "The update tensor should have shape [3, 0]. Got
(3, 1)". With i as an input, Core ML raises at run time below iOS 18, and
from iOS 18 it skips the write, so the output is silently wrong.

The Core ML converter for index_put now adds the size of the dim to each
negative int index next to a slice, then hands the op to the coremltools
converter. A constant index is wrapped while converting, so it adds
nothing at run time. A write without a slice goes to coremltools
unchanged, and so does a TorchScript node.

The new test checks that only an input index next to a slice gets a
select at run time, that a constant -1 and an input -1 lower into Core ML
with a static and a dynamic width, and on macOS compares the output with
eager, also at a second width for the dynamic one. It also converts a
TorchScript index_put.
@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks for the review.

Duplicate comment
Agreed, the comment above the copy repeats the one below it. I will remove it in the next push, or in a follow-up if this merges first.

Not changed here

  • Float arange: confirmed. coremltools ignores the dtype of arange, so torch.arange(6, dtype=torch.float32) comes out int32. On main, relu, floor, ceil, round and relu6 of it fail to lower. With this PR they lower, but their result stays int32, so some float-only ops after them, such as gelu, fail instead. So it is not a regression. The fix belongs in the arange converter, which should cast to the dtype torch gives it. This PR is already large, so that fits a change of its own.
  • empty with the default config: measured. With the default EdgeCompileConfig, all 4 outputs of Empties are int32 at this head. On main they are int32, float32, float32 and int32. empty becomes _empty_dim_order, which coremltools already converts with the dtype torch gives it. new_empty and empty_like reach this PR's new_zeros and zeros_like overrides in both flows. So the default flow runs no code from this PR that the test misses. The test keeps _skip_dim_order=True, where empty reaches the zeros override.

@shoumikhin
shoumikhin force-pushed the coreml-converter-fixes branch from 8935822 to ab6ba2b Compare November 3, 2026 19:23
Copilot AI balanced review requested due to automatic review settings November 3, 2026 19:23

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 53m 4s —— View job


Approve: no confirmed bugs. Two low-confidence findings, since coremltools couldn't be run or read in this job: the *_like fill overrides may store activation-sized constants in models at iOS 16 and later, and exp2 of an int fill may stop lowering.

Detailed review

PR Review: #24424

Summary

The PR overrides coremltools converters so that these convert the way torch computes them:

  • int and bool inputs, copies and clamp bounds;
  • fills;
  • gelu's tanh mode and RMSNorm's default eps;
  • negative index_put indices.

It also adds a MIL pass that gives each Core ML output a value of its own.

There are no blockers. The two low-confidence findings depend on coremltools: the *_like fill overrides may fold into activation-sized constants, and exp2 of an int fill may stop lowering. There is also a nit about the default rms_norm eps for float64. Python was blocked in this job and the coremltools source isn't on disk, so nothing was run.

Code Quality

  • Nit: rms_norm uses float32's eps for every dtype (torch_ops.py:239).

    • What torch does: it takes the eps of the computation dtype, which is float64's (2.2e-16) for a float64 input.
    • Example: for the one-hot row in test_rms_norm_without_eps, F.rms_norm(x.double(), (65536,)) gives 256 in eager and about 255 here.
    • How often: Core ML computes float64 in fp32 anyway, so this only shows when the mean square is close to float32's eps.
    • Fix: use np.finfo(np.float64).eps when _torch_dtype(node) is float64, and say so in the comment at :231-234.

    Fix this →

Backward Compatibility

  • exp2 isn't in the float-input list, and an int fill now reaches it as int32 (torch_ops.py:434-456; low confidence).

    • Why it fails: torch computes exp2 in float for an int input, like the 21 ops in the list. As I recall, coremltools converts it as mb.exp2(x=x), and Core ML's exp2 takes only fp16 or fp32. The partitioner keeps every op coremltools supports whole (coreml_partitioner.py:469-474), so aten.exp2 reaches that converter.
    • Trigger: torch.exp2(torch.full((2, 3), 2)).
      • On main the fill was fp32, so this lowered.
      • With this PR the fill is int32, so lowering should fail with the type-domain error quoted at :419-420.
      • torch.exp2(x.long()) fails both on main and with this PR.
    • Fix:
      • Add _ct_ops.exp2 to the list. The list is read at import time, outside the is_fbcode() guard, so first check that fbcode's coremltools has exp2 too. That version is older than 9.0 (:245).
      • Add i.exp2() and torch.full((2, 3), 2).exp2() to test_int_input_of_float_ops.

    Fix this →

Performance

  • The *_like fill overrides may store activation-sized constants in the model (torch_ops.py:118-122, :151-156, :159-164; low confidence).

    • On main: as I recall, from iOS 16 coremltools converts ones_like as fill_like(ref_tensor=x, value=1.0), which would explain "ones_like as float from iOS 16" in the test docstring. I believe it converts zeros_like and full_like the same way. A fill_like stays a run-time op.
    • With this PR: the overrides emit fill(shape=shape(x)) instead. When x has a static shape, shape(x) has a value, so MIL value inference computes the fill with np.full. const_elimination then turns it into a const that is stored in the weights.
    • Trigger: torch.ones_like(x), torch.zeros_like(x) or torch.full_like(x, -1e4) for a static x of shape (1, 2048, 4096), lowered at iOS 18.
      • On main this should be one fill_like op.
      • With this PR it should be an 8M-element const, which takes 16 MB in fp16 and a 32 MB array at conversion.
      • The zeros_like(mask, dtype=...) in nn.MultiheadAttention would fold the same way, into a const the size of the mask.
    • Scope: fills with an explicit size, such as full and zeros, already go through fill with a constant shape on main, so only the *_like ops change.
    • Fix: from iOS 16, emit mb.fill_like(ref_tensor=x, value=_core_ml_type(node)(value), name=node.name), as coremltools does. Its output dtype follows value, so the dtype fix stays. To settle it, add a test that converts a large static ones_like and checks for a fill_like and no large const.

    Fix this →

Recommendation

Approve: nothing confirmed blocks. Please check the *_like fill finding before merging. If it holds, a model with a large static ones_like, zeros_like or full_like grows by the size of that tensor at iOS 16 and later. The fix is small.


  • Gather context (diff, commits, repo conventions)
  • Run three independent reviews
  • Consolidate findings (dropped points already settled by the author's macOS runs or commit messages)
  • Fact-check findings
  • Post review

@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 good: the separate fill override, override=True on the pass, removing the dead tile, and lowering Fills and checking dtypes in the delegate's own converted program. Approving.

Before landing, could you run the Core ML tests on macOS at this head (ab6ba2b0)? As you mentioned, the newer fixes (int and bool inputs of float-only ops, rounding and clipping ops, copies, clamp_min/clamp_max and hardtanh) have only run on Linux, and the runtime comparisons only run on macOS. A pass of test_torch_ops.py, test_coreml_partitioner.py and test_coreml_recipes.py with the Core ML runtime would be enough. Please post the result here.

@shoumikhin
shoumikhin merged commit 3bc5222 into pytorch:main Nov 3, 2026
435 of 453 checks passed
shoumikhin added a commit to shoumikhin/executorch that referenced this pull request Nov 3, 2026
pytorch#24424 gives ones_like, zeros_like, full_like and fill the dtype torch
gives them by making each a fill of the shape of its input. When that
shape is known at conversion, coremltools turns such a fill into a
constant as big as the input, so a fill like a 1x256x512 activation adds
256 KB of float16 numbers to the model. From iOS 16, coremltools
converted ones_like and full_like to fill_like, which makes the tensor
at run time instead.

All four now use fill_like from iOS 16, still with the dtype torch gives
them. Below iOS 16, where Core ML has no fill_like, they stay fills of
the input's shape.

The new test checks that these fills become fill_like at iOS 16, and on
macOS compares the output with eager.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants