Skip to content
C.W.K.
Stream
Lesson 05 of 06 · published

The Core ML Path and the Neural Engine

~16 min · journey, core-ml, neural-engine, compute-units, bandwidth-bound, measured

Level 0Spec-Sheet Skimmer
0 XP0/91 lessons0/19 achievements
0/100 XP to next level100 XP to go0% complete
"The Neural Engine is a compute engine with a narrow door to the pool. Give it a picture and it shines. Give it a language model's decode and it reads the weights at two per cent of the bus."

The Third Door, and the Fourth Unit

Core ML is Apple's own inference path: a model compiled once into a form the operating system schedules across the CPU, the GPU and the Neural Engine, with the caller choosing a compute units setting and the framework choosing, op by op, where each runs. It is the only path in this track that can reach the Neural Engine at all — the CPU track's fixed-function unit that ioreg lists and nothing else in the quest has touched. The household's one Core ML user is the voice sibling, through WhisperKit, which runs a Whisper encoder and decoder as Core ML models with the Neural Engine in play; nothing in the family calls Core ML directly, and nothing uses Apple's Foundation Models framework. So this lesson measures the unit itself, with models built straight from Core ML's intermediate language, and asks the planner what it decided.

Two Shapes of Work, Four Settings

Two models, on office. A conv stack — eight 3×3 convolutions over a 224-pixel image, 104 GFLOP — is the Neural Engine's home shape. A single fp16 matrix-vector product over a 537 MB matrix is a language model's decode step in miniature: read every weight once, do almost nothing with it. Each ran under all four settings, twenty times after warm-up, and the compute plan reported the unit the framework preferred for each op.

Model, office, coremltools 8.1CPU onlyCPU+GPUCPU+NEALL (planner's choice)Evidence
conv stack, 104 GFLOP12.9 ms (8.0 TFLOP/s)4.65 ms (22.3) — GPU6.15 ms (17.0)6.12 ms — Neural Engine, 17 of 17 opsmeasured
matvec, 537 MB fp167.6–14.9 ms (36–71 GB/s)1.71 ms (314 GB/s) — GPU30.2 ms (17.8 GB/s)30.7 ms — Neural Enginemeasured
matvec, 134 MB fp162.15 ms2.18 ms — CPU2.56 ms2.10 ms — Neural Enginemeasured
matvec, 34 MB fp160.65 ms0.66 ms — CPU0.62 ms0.63 ms — CPUmeasured

Read the conv row first: the Neural Engine runs the image at 17 TFLOP/s, within a quarter of the 80-core GPU, on a unit a fraction of its size — that is the fixed-function argument from the CPU track, made in numbers. Now the 537 MB row. The same unit reads a language model's weights at 17.8 GB/s — eighteen times slower than the GPU on the same bytes, two per cent of the pool's 819 — and under the default setting the planner chose it anyway, and spent 24 seconds compiling for it on every load. Apple wrote this down in 2022, in the article on deploying transformers to the unit: "many Transformer configurations become bandwidth-bound on the ANE when the sequence length is relatively short … large parameter tensors are being fetched from memory, only to be applied on too few inputs." That is the decode ceiling, from the unit's side.

What Apple Runs Where

Apple's own two answers are consistent with the table. For an 8B model on a Mac, its Core ML article says the quiet part outright: "specifically target the GPU, as the models like the Llama-3.1-8B-Instruct are usually constrained by memory bandwidth, and the GPU offers the best combination of compute FLOPS and memory bandwidth" — and reports about 33 tokens per second for a 4-bit build on an M1 Max, which at 4.2 GB against 400 GB/s is 35% of that chip's ceiling, a fraction the lab's MLX ladder beat with the 9B and the 27B on every Mac. For the ~3 billion parameter on-device foundation model, Apple describes "efficient Key-Value (KV) cache update on our neural engines" and 30 tokens per second on an iPhone 15 Pro "before employing token speculation techniques" — a model sized and laid out for the narrow door, on a device where the door's energy cost matters more than the bus. Big models to the GPU, a small one to the Neural Engine, speculation on top: the vendor's split matches the physics track's, and the Ollama lesson's trick appears in Apple's sentence too.

Code

coreml_units.py — two shapes of work on every compute unit, and what the planner picked·python
#!/usr/bin/env python3
"""Build two Core ML models straight from MIL (no PyTorch needed), then time each
on every compute-unit setting and ask the compute plan which unit ran each op.
Model A: a decode-shaped matvec over a large fp16 matrix (bandwidth-bound).
Model B: a conv stack on an image (the Neural Engine's home turf)."""
import time, os, sys
import numpy as np
import coremltools as ct
from coremltools.converters.mil import Builder as mb
from coremltools.converters.mil.mil import types

OUT = os.path.expanduser("~/silicon-lab/coreml")
os.makedirs(OUT, exist_ok=True)
UNITS = {"CPU_ONLY": ct.ComputeUnit.CPU_ONLY, "CPU_AND_GPU": ct.ComputeUnit.CPU_AND_GPU,
         "CPU_AND_NE": ct.ComputeUnit.CPU_AND_NE, "ALL": ct.ComputeUnit.ALL}


def build_matvec(n: int):
    W = (np.random.randn(n, n) * 0.02).astype(np.float16)

    @mb.program(input_specs=[mb.TensorSpec(shape=(1, n), dtype=types.fp16)], opset_version=ct.target.iOS17)
    def prog(x):
        return mb.matmul(x=x, y=W, name="y")
    return ct.convert(prog, convert_to="mlprogram", minimum_deployment_target=ct.target.macOS14,
                      compute_precision=ct.precision.FLOAT16), {"x": np.random.randn(1, n).astype(np.float16)}, W.nbytes


def build_conv(c: int = 128, layers: int = 8, hw: int = 224):
    Ws = [(np.random.randn(c, c if i else 3, 3, 3) * 0.05).astype(np.float16) for i in range(layers)]

    @mb.program(input_specs=[mb.TensorSpec(shape=(1, 3, hw, hw), dtype=types.fp16)], opset_version=ct.target.iOS17)
    def prog(x):
        h = x
        for i, W in enumerate(Ws):
            h = mb.conv(x=h, weight=W, pad_type="same", name=f"conv{i}")
            h = mb.relu(x=h, name=f"relu{i}")
        return mb.reduce_mean(x=h, axes=[2, 3], name="y")
    flops = sum(2 * hw * hw * c * (c if i else 3) * 9 for i in range(layers))
    return ct.convert(prog, convert_to="mlprogram", minimum_deployment_target=ct.target.macOS14,
                      compute_precision=ct.precision.FLOAT16), {"x": np.random.randn(1, 3, hw, hw).astype(np.float16)}, flops


def plan_devices(path, unit):
    keep = ct.models.MLModel(path, compute_units=unit)          # the plan wants the .mlmodelc, which lives as long as this object
    plan = ct.models.compute_plan.MLComputePlan.load_from_path(path=keep.get_compiled_model_path(), compute_units=unit)
    used = {}
    for op in plan.model_structure.program.functions["main"].block.operations:
        du = plan.get_compute_device_usage_for_mlprogram_operation(op)
        if du is not None:
            k = type(du.preferred_compute_device).__name__.replace("ML", "").replace("ComputeDevice", "")
            used[k] = used.get(k, 0) + 1
    return used


def bench(name, model, inputs, reps=20):
    path = os.path.join(OUT, f"{name}.mlpackage")
    model.save(path)
    print(f"\n== {name}")
    for label, unit in UNITS.items():
        t = time.perf_counter(); m = ct.models.MLModel(path, compute_units=unit); load = time.perf_counter() - t
        for _ in range(3):
            m.predict(inputs)                                        # warm-up (the ANE compile happens here)
        t = time.perf_counter()
        for _ in range(reps):
            m.predict(inputs)
        print(f"{label:12} load {load:5.2f}s  predict {(time.perf_counter() - t) / reps * 1e3:8.2f} ms")
    print("planner, ALL:", plan_devices(path, ct.ComputeUnit.ALL), " CPU_AND_GPU:", plan_devices(path, ct.ComputeUnit.CPU_AND_GPU))


if __name__ == "__main__":
    which = sys.argv[1] if len(sys.argv) > 1 else "both"
    if which in ("matvec", "both"):
        for n in (4096, 8192, 16384):
            model, inputs, nbytes = build_matvec(n)
            print(f"\nmatvec {n}x{n} fp16 = {nbytes/1e6:.0f} MB of weights")
            bench(f"matvec{n}", model, inputs)
    if which in ("conv", "both"):
        model, inputs, flops = build_conv()
        print(f"\nconv stack: {flops/1e9:.1f} GFLOP per image")
        bench("conv8", model, inputs)

# office, M3 Ultra, coremltools 8.1, macOS 26.6.2, 2026-09-15 (predict ms; planner's preferred unit):
# matvec 16384^2 (537 MB): CPU_ONLY 8.04  CPU_AND_GPU 1.71  CPU_AND_NE 30.18  ALL 30.74   ALL -> NeuralEngine (load 24 s), CPU_AND_GPU -> GPU
# matvec  8192^2 (134 MB): CPU_ONLY 2.15  CPU_AND_GPU 2.18  CPU_AND_NE  2.56  ALL  2.10   ALL -> NeuralEngine,            CPU_AND_GPU -> CPU
# matvec  4096^2  (34 MB): CPU_ONLY 0.65  CPU_AND_GPU 0.66  CPU_AND_NE  0.62  ALL  0.63   ALL -> CPU,                     CPU_AND_GPU -> CPU
# conv8 (104 GFLOP):       CPU_ONLY 12.95 CPU_AND_GPU 4.65  CPU_AND_NE  6.15  ALL  6.12   ALL -> NeuralEngine x17,        CPU_AND_GPU -> GPU x17

External links

Exercise

Run coreml_units.py on your Mac. Fill a four-column row on your card for each model: the unit the ALL planner chose, and the milliseconds under CPU only, CPU+GPU and CPU+NE. Then compute the bandwidth the Neural Engine pulled on the 537 MB matvec and write it as a fraction of your chip's spec. If your Mac has less than 16 GB, use the 8192 size and say so on the card.
Hint
GB/s = bytes ÷ seconds; the matvec reads the whole matrix once per predict. If the Neural Engine's number is a few per cent of the spec while the GPU's is thirty or more, you have reproduced the lesson. If ALL chose the GPU on your Mac, note the macOS build — the planner's rules are Apple's and change.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.