Skip to content

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

Open
shoumikhin wants to merge 8 commits into
pytorch:mainfrom
shoumikhin:coreml-converter-fixes
Open

shoumikhin wants to merge 8 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, sigmoid, sin and similar ops fail to lower on an int or bool tensor, which PyTorch computes in float. floor, ceil, round, relu, relu6 and hardtanh fail the same way 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 it int or bool, so a later float-only op fails to lower.
  • Fill ops such as full, zeros_like and scalar_tensor get the wrong dtype. For example, 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, since coremltools ignores the keyword that torch.export passes.
  • nn.RMSNorm(eps=None) fails to lower: coremltools requires eps.
  • A Core ML program that returns one of its inputs, the same value twice, or a same-shape expand of another output can return one of them as zeros or as another output's bytes.
  • y[:, i] = v with i = -1 writes to an empty slice: lowering fails for a constant i, and for an input i Core ML raises at run time below iOS 18 and silently skips the write from iOS 18.

What this change does

  • compiler/torch_ops.py overrides the coremltools converters of these ops, so 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, and the dtype of y for a value copied into it. 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, because the fill fix lets an int fill reach those ops.
  • compiler/coreml_preprocess.py copies an output of the converted Core ML program that repeats an input or another output, also through a same-shape tile.

Testing

  • The Core ML tests (test_torch_ops.py, test_coreml_partitioner.py, test_coreml_recipes.py) pass on Linux. On macOS with the Core ML runtime, where the new tests also compare with eager PyTorch, they passed at an earlier revision.
  • 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 New Failures, 2 Pending, 1 Unrelated Failure

As of commit d5e114a with merge base ec56cfa (image):

NEW FAILURES - The following jobs have failed:

BROKEN TRUNK - The following job 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.

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

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. A narrower int, which Core ML
has from iOS 17, still fails for relu, relu6 and hardtanh, since Core
ML's maximum takes int32 but no narrower int.

The new test lowers each op on an int input, and hardtanh with int
bounds on a float16 input, checks that everything goes to Core ML, and
on macOS compares the output with eager.
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 a float arange, 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.
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(), or a copy
between it and a float tensor, such as y[:, 0] = torch.full((2,), 7) on
a float y. The three 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 int input tests now also
lower int fills read by sqrt and by floor, and copied to and from a
float tensor.
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 treats such a tile as its input,
and returns an input or a value that is already an output through a
copy. It 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 stays a tile.

The tests check the outputs of the converted program, 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 as zeros 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
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

This branch has not been deployed

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

Labels

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