Repository navigation
Core ML: run ops that read uint8 and int8 inputs on the CPU, refuse ops coremltools gets wrong, and fix fills, tanh GELU, RMSNorm and repeated outputs - #24317
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24317
Note: Links to docs will display an error until the docs builds have been completed. ❌ 6 Cancelled Jobs, 1 PendingAs of commit 3efab3a with merge base 681e493 ( CANCELLED JOBS - The following jobs were cancelled. Please retry:
This comment was automatically generated by Dr. CI and updates every 15 minutes. |
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 28m 3s —— View job Summary: This fixes the reported uint8 camera-frame case, but some uint8/int8 inputs still fail and the new tests leave most of the claimed behaviour unchecked. A Detailed reviewPR Review: #24317SummaryThe PR keeps ops that read a uint8/int8 tensor from outside their Core ML partition on the CPU.
The problems:
Nothing was run for this review, because this job is not allowed to run Code Quality
Backward Compatibility
Testing
RecommendationRequest Changes
|
|
@metascroy @cymbalrush @YifanShenSZ could you take a look? This changes the Core ML backend. |
245afe3 to
8d3480d
Compare
8d3480d to
61600ea
Compare
61600ea to
0863813
Compare
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 42m 31s —— View job This keeps ops that read uint8/int8 tensors on the CPU as intended, but at the default deployment target it can also move a state update to the CPU while a Core ML delegate keeps reading a frozen copy of that state, so the recommendation is Request Changes. Detailed reviewPR Review: #24317SummaryThe PR makes the Core ML partitioner run on the CPU two kinds of ops: those that read a uint8/int8 tensor from outside their partition, and those in any partition this leaves with no tensor input. It also makes Code Quality
Testing
Performance
RecommendationRequest Changes The stale-state case gives wrong results with no error. It happens with a uint8/int8 input, state and the default deployment target, a combination that crashed with a
|
0863813 to
fb99778
Compare
|
@claude /pr-review |
to_edge_transform_and_lower asks the partitioner twice in a row for the ops to keep whole, with the same program. When the program has an op Core ML rejects that has a decomposition, such as an int zeros or a bfloat16 linear, each answer decomposes the whole program in a dry run, which is slow for a large model. The Core ML partitioner now keeps its last answer and returns it again for the same program, as the MLX partitioner does. It holds the program through a weak reference, so it never keeps a program alive.
The partitioner raised for every state buffer whose readers and update
were in different places. But tag_mutated_buffer only moves a buffer
into Core ML when an op in a partition reads it. When only CPU ops read
it, for example an op Core ML rejects or skips, the buffer stays a
program buffer and the delegate returns its new value, which worked
before the check. Such a model, like
y = torch.acosh(self.state)
self.state.copy_(x * 2)
failed to lower with "used in more than one place".
The check now skips a buffer that no partition reads.
coremltools makes full without a dtype, zeros, new_zeros and, from iOS 16, ones_like as float, zeros_like with the dtype of its input, and full_like, new_full, fill and scalar_tensor with the type of their fill value, instead of with the dtype torch gives the result. So an int or bool tensor came out as float: index math on it failed to lower, or lost integers above 2048 at float16 precision. A float tensor could come out as int or bool too: the zeros_like(mask, dtype=float) that nn.MultiheadAttention makes from a bool mask stayed bool, and attention came out wrong. The partitioner refused the int and bool cases, which moved them to the CPU, and with lower_full_graph=True it failed models that worked before, such as x + torch.full((1, 16), 0). The backend now converts these ops with the dtype in the node's metadata, and the partitioner no longer refuses them. A TorchScript node, which has no such metadata, and a fill value known only at run time keep the coremltools converter.
The partitioner refuses some ops that coremltools converts, such as an index_put form coremltools gets wrong, or an op on a bfloat16 tensor. With lower_full_graph=True, lowering then failed with "CoreML does not support the op index_put" and advice to ask coremltools for the op, although coremltools has it. The real reason only went to the info log. Lowering now fails with the reason, for example "aten.index_put.default with these indices is not supported by CoreML. Set lower_full_graph=False in the CoreMLPartitioner to run it outside of CoreML." The info log keeps the same reasons, now with op names instead of their schemas.
The dry run in ops_to_not_decompose drops kept ops that end up on the CPU, and runs again, since a dropped op gets decomposed and can change the program. A dropped op with no decomposition, such as the cast of a uint8 input, changes nothing, so the next round repeated the same decomposition and search only to find nothing more to drop. The dry run now stops there, and builds the decomposition table once. Its docstring also says that it does not see transform passes, or the ops an earlier partitioner takes.
When ops in more than one place use a buffer that Core ML takes over, lowering fails, since each place would use its own copy. The error said only that the buffer is used in more than one place. For a model whose cache update runs on the CPU, such as an index_put Core ML cannot convert, it did not say which op that is. The error now lists each op that uses the buffer and where it runs, and the docs for take_over_mutable_buffer mention the error.
This reverts commit 6ba764d, which made duplicate_constant_node also replace a constant that an op reads from a list. duplicate_constant_node also copies a mutated buffer that a delegate owns, for a CPU op that reads it. A list reader such as cat used to keep the original buffer, so lowering failed. With the change it reads a copy that never sees the delegate's updates. That affects every backend that shares state, so the change needs its own review, apart from Core ML.
The Core ML fill converters gave every floating fill a float32 value, so a float16 zeros_like, ones_like or empty_like, which coremltools made float16 before, came out float32. The ops that read it then promote to float32, which adds casts, and with FLOAT32 compute precision makes float32 results where torch has float16. A float16 fill is now made as float16.
The index_put check keeps a whole-slice form with a 0-d index in Core ML when the values vary only in the dims after the index. The tests only had forms that the check refuses, so a rule that refused too much, or let a wrong form through, went unnoticed. The 3-D case now also writes such values, which must stay in Core ML and match eager.
The Core ML docs pointed a model with a uint8 lookup table that it casts to take_over_constant_data=False. That setting passes every weight to Core ML as an input, and runs the dequantize of int8 or uint8 weights on the CPU. The docs now suggest storing such a table as float16 or int32 first, and say what the setting costs. The note on take_over_mutable_buffer said lowering fails whenever Core ML and the CPU both use a buffer. It fails only for a buffer that Core ML takes over, which a buffer that only CPU ops read is not.
…m a list From iOS 18, `mask[:, col] = True` stays in Core ML while an accumulate write at the same `col` runs on the CPU, so both read the `col` buffer from their index lists. Lowering then failed with "constant data node (b_col) is tagged with (tag1) but has user (aten_index_put_default_2) which has tag (None)": to_backend gives each place its own copy of a shared constant, but swaps in the copy only where an op takes the constant directly. The partitioner now leaves such a constant untagged, so Core ML takes it as an input, as it does for every constant with take_over_constant_data=False. Mutable buffers are not touched. The fills and index_put table test also lowers Scatter at iOS 18, which fails without this change.
Lowering turns empty, new_empty and empty_like into _empty_dim_order unless dim order ops are skipped, and coremltools converts that op with its dtype. Say so where the fill converters take these names, so the aliases do not look unused.
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 50m 52s —— View job Needs discussion: this PR works around an exir bug (shared constants read from a list) inside the Core ML partitioner, its int8-output rule may be covering a missing case in this repo's Core ML delegate, and it has a wrong comment, missing upstream links and two fixes that only macOS tests check. Detailed reviewPR Review: #24317SummaryThe PR runs ops on the CPU when they read uint8 or int8 inputs, or make int8 outputs that are used outside Core ML. It refuses the Code Quality
Testing
RecommendationNeeds Discussion Nothing here breaks the cases the tests cover. Two decisions need settling first:
The comment fix, the upstream links and the two Linux-side test checks are small.
|
|
I split two parts out of this PR so each can be reviewed on its own:
The rest stays here. It depends on #24404, so I will update this PR once that lands. |
executorch_coreml lowers each part with ExecuTorch's Core ML partitioner,
so a bundle runs through Apple's Core ML on a Mac, and later on an iPhone
or iPad. It is one config class on ExecuTorchConfig, and its registration.
ExecuTorch loads its programs, so the runtime, the engine and
lerobot-rollout are unchanged. The ExecuTorch wheel for macOS already has
the Core ML backend and brings coremltools, so it needs only:
pip install "executorch>=1.6.0,<1.7.0"
Like the other ExecuTorch backends, it reads programs traced by PyTorch
2.15 or newer. Like XNNPACK, it folds the values a program computes from
constants alone before lowering: Core ML has no bucketize, which SmolVLM's
vision model uses to make its position ids from constants.
Two options: --backend.compute_units picks the hardware Core ML may use
(all, cpu_only, cpu_and_gpu or cpu_and_ne), and --backend.compute_precision
picks float32 or float16. The default is all hardware, in float32: on ACT
the actions then match PyTorch's to about 1e-6, on the CPU and on the GPU.
The Neural Engine runs only float16, which Core ML's CPU path computes
several times less precisely than PyTorch does in float16.
Tested on an Apple Silicon Mac: the new test compiles a small program
and runs it through Core ML. With ExecuTorch's fix for uint8 inputs
(pytorch/executorch#24317, not merged yet), ACT exported, passed its
check, and ran with lerobot-rollout on a fake robot, on the CPU and on
the GPU. The Neural Engine was not tested.
executorch_coreml lowers each part with ExecuTorch's Core ML partitioner,
so a bundle runs through Apple's Core ML on a Mac, and later on an iPhone
or iPad. It is one config class on ExecuTorchConfig, and its registration.
ExecuTorch loads its programs, so the runtime, the engine and
lerobot-rollout are unchanged. The ExecuTorch wheel for macOS already has
the Core ML backend and brings coremltools, so it needs only:
pip install "executorch>=1.6.0,<1.7.0"
Like the other ExecuTorch backends, it reads programs traced by PyTorch
2.15 or newer. Like XNNPACK, it folds the values a program computes from
constants alone before lowering: Core ML has no bucketize, which SmolVLM's
vision model uses to make its position ids from constants.
Two options: --backend.compute_units picks the hardware Core ML may use
(all, cpu_only, cpu_and_gpu or cpu_and_ne), and --backend.compute_precision
picks float32 or float16. The default is all hardware, in float32: on ACT
the actions then match PyTorch's to about 1e-6, on the CPU and on the GPU.
The Neural Engine runs only float16, which Core ML's CPU path computes
several times less precisely than PyTorch does in float16.
Tested on an Apple Silicon Mac: the new test compiles a small program
and runs it through Core ML. With ExecuTorch's fix for uint8 inputs
(pytorch/executorch#24317, not merged yet), ACT exported, passed its
check, and ran with lerobot-rollout on a fake robot, on the CPU and on
the GPU. The Neural Engine was not tested.
|
Question about the uint8 part, for the camera-frame case (#11686 and the LeRobot policies): would exporting the model with a float input, and converting the uint8 frame with the image processor in
One caveat: If that covers your use cases, most of the uint8 partitioning and the decomposition dry run could become a clear partitioner error for uint8/int8 inputs that points to the image processor. The general fixes here (the state check and the int8-output rule) could then land as their own change. Are there cases it doesn't cover, for example a uint8 input that isn't an image, or a policy that has to keep its eager uint8 signature? |
|
Thanks. That is a good fit for apps that read camera frames in C++, and it keeps the whole graph in Core ML. It does not cover everything this PR handles, though:
So I would keep the general handling, and the next push adds a line to the Core ML docs that points camera apps to |
Core ML lowering crashes, or gives wrong values, for several common patterns. This change fixes them in the Core ML partitioner and backend.
Problems and fixes
Lowering crashes, or the program fails to load:
KeyError: torch.uint8(issue CoreML partitioner errors out with 8-bit integer types #11686). Ops that read such an input now run on the CPU up to the conversion to float, and the ops after it still go to Core ML. So does an op that makes an int8 tensor used outside Core ML, which the Core ML runtime cannot return, and a partition left with only size inputs, or no inputs below iOS 18. Withlower_full_graph=True, lowering fails and names the tensor.take_over_mutable_buffer=False, instead of leaving one side with a stale copy. State that only CPU ops read stays on the CPU, also when Core ML computes its new value.min,max,amin,amax,minimumormaximumover a bool tensor, such as the checktorch.distributions.MultivariateNormalexports, crashes lowering. These ops now run on the portable CPU kernels.matmulkeepsaten.matmulwhole, a bfloat16lineardecomposes into anothermatmulthat also stays whole, on the CPU, and loading fails with "kernel 'aten::matmul.out' not found". The partitioner now decomposes a copy of the program the way lowering will and runs the same search there, so an op that ends up on the CPU is not kept whole (an op skipped by name still is). This dry run only happens with a uint8 or int8 tensor, or when an op Core ML rejects gets decomposed, and once per program. On a 48-block transformer it took 0.06 s when it did not need to decompose and 1.45 s when it did, against 11.5 s for the whole lowering.fullwithout a dtype,zerosandnew_zerosas float,ones_likeas float from iOS 16,zeros_likewith the dtype of its input, andfull_like,new_full,fillandscalar_tensorwith the type of their fill value. Sotorch.full((1, 16), 0)(int64 in torch) is made as float, and index math on it fails to lower, as in the SmolVLM vision embeddings, or loses integers above 2048 at float16 precision. The backend now converts these ops, andempty,new_emptyandempty_like, with the dtype torch gives them, so they stay in Core ML. A fill value known only at run time keeps the coremltools conversion.torch.nn.RMSNorm(root mean square normalization) leaves out eps (the small number added before the square root) by default, and coremltools requires it: "got 3 input(s), expected [4]". The backend now passes the float32 machine epsilon, which is what PyTorch uses. A model whose tensors are float16 still fails inside coremltools, with or without eps.y[:, idx] = vonly whenidxhas a single entry. Withidx = torch.tensor([0, 2]), as in the X-VLA policy, lowering fails with "Input squeeze_0 has rank 1 != other inputs rank 0".accumulate=True, a bool mask, or, below iOS 18, a bool tensor next to a slice fail too, and so do index tensors that broadcast. These forms now run on the portable kernels, while forms coremltools handles, such as a single-entry index for a cache update, stay in Core ML. So a cache update at several positions, as in a prefill, now lowers, with the update on the CPU. A negative index next to a slice still stays in Core ML, since the partitioner cannot see index values: it fails, or, for an index computed at run time, can give wrong values. An index buffer that such anindex_putshares with a Core ML op goes to Core ML as an input, sinceto_backenddoes not copy a constant that an op reads from a list.Wrong values without any error:
gelu(approximate="tanh")ran as the exact GELU (Gaussian Error Linear Unit), because torch.export passesapproximateas a keyword and coremltools reads it only from the positional arguments. The two differ by up to 4.7e-4, which in pi0 and pi0.5 was the whole float32 error of the policy. The backend now reads the keyword.cloneinto nothing, so a partition can return its own input, as inreturn x.clone(), x * 2, or one value twice. Next to other outputs, such an output came back as zeros or as the bytes of another output. The backend now gives each one a copy of its own.torch.zeros_like(mask, dtype=...), which coremltools kept bool, so attention came out wrong. The fill fix above covers this too.With
lower_full_graph=True, an op the partitioner refuses now fails with the reason, such as "aten.index_put.default with these indices is not supported by CoreML. Set lower_full_graph=False in the CoreMLPartitioner to run it outside of CoreML.", instead of saying that Core ML does not support the op.A uint8 buffer that the model casts still fails with the default settings. The docs now suggest storing it as float16 or int32, or setting
take_over_constant_data=False, and say what that setting costs.How to review
Start with
coreml_partitioner.py: the uint8 and int8 search in_propose_partitions, the state check, and the dry run in_drop_ops_left_outside. The refusals are small predicates near the top of the file, one per problem. The fixes intorch_ops.pyandcoreml_preprocess.pydo not depend on the partitioner changes.Tests
With coremltools 9.0, the tests below cover these cases. On macOS 15 or later they also run the program with the Core ML runtime and compare it with eager. With the Core ML code of main, the fill,
index_put, state and compiler tests fail (the nn.MultiheadAttention case only with the runtime). The cases:zeros_like,masked_fill, a channel swap,max(dim=1)orsplit, an int8 frame read by a permute, and a roll with a dynamic height.min/amax/maximum, and a bfloat16 linear next to a float32 matmul.index_putwith a two-entry index, broadcast index tensors,accumulate=Trueor a bool tensor written next to a slice, and 0-d indices whose values do not fit or would land in the wrong place, next to a 0-d index whose values vary only after it, which stays in Core ML. From iOS 18, a bool write that stays in Core ML shares its index buffer with a write on the CPU. Withlower_full_graph=True, the error gives the reason.test_coreml_partitioner.py,test_torch_ops.pyandtest_coreml_recipes.pypass with the Core ML runtime on macOS. For a transformer block and conv models, with and without skipped ops, the partitions match main.LeRobot policies, measured on an earlier version of this change with wheels built from the 1.6 release branch: without it, ACT, Diffusion, VQ-BeT, Multi-Task DiT and pi0.5 (bfloat16) fail to lower with
KeyError: torch.uint8. With it, ACT, Diffusion, VQ-BeT and Multi-Task DiT export, run without loading torch, and match eager within 4.2e-06 over 16 frames. pi0.5 exports and passes its one-chunk check, but runs very slowly, since its bfloat16 linears run on the portable CPU kernels.Not tested: Core ML state at run time, and a model that builds
torch.distributions.MultivariateNormalitself (the test copies its bool check).