Logbook: what the Neural Engine reaches, and what it holds

RETRACTED THE SAME DAY: “the Neural Engine has no total-size ceiling”. It has one, at exactly 2 GiB. The claim came from stopping a sweep at 1.7 GiB and from reading a shape control too broadly: the control disproved a 1.5 GiB total limit, and it was written up as showing no total limit existed. Continuing the sweep with small tensors and more depth finds the wall at 2^31 bytes. The per-tensor cap near 224 MiB stands unchanged.

Question: the plan is sequenced around an 8 GiB, 40 TOPS INT4 USB part. Can the Mac already in the room run the same work, and at what precision.

Apparatus: scripts/ane_bench.py, subcommands selftest, ceiling, tops, ladder, footprint. One machine: Apple M2 Pro, 32 GiB unified, 16-core ANE (ane,t8020, arch h14g, ANEVersion 128.1), macOS 26.5.2, coremltools 9.0, torch 2.11.0 CPU. Environment is pixi.toml’s ane feature, which is new: this repository’s gates had a requirements.txt and no environment to install it into.

Every Hailo figure below is DERIVED from rfd1122-plan.usda and none was observed. No UGen300 is attached to this machine. RFD 1130 exists to replace those rows with measurements and this entry does not do that.

The measurement that had to come first

logbook-edge-npu-and-the-anny-forward.md records CoreML at 1685.1 ms against the CPU’s 476.4 at 576, 0.28x, outside the port’s 4.2e-03 bound, and concludes the Neural Engine is “REACHABLE … and not useful for this model”. That run went through ONNX Runtime’s CoreML provider, which partitions and falls back per operator, and it logged no placement.

So the figure is consistent with two opposite worlds: the device ran the graph slowly, or the graph never reached the device. MLComputePlan distinguishes them per operation, and the first thing built here was the control that proves the distinction is being read:

positive (ComputeUnit.ALL)      ane=8/8   {'ane': 8}   consts=28
negative (ComputeUnit.CPU_ONLY) ane=0/8   {'cpu': 8}   consts=28

Two costs paid to get there. MLComputePlan requires a COMPILED model and does not raise when handed an .mlpackage — it aborts the process from libc++. And constants carry no device, so leaving them in the denominator lets a graph of mostly weights look well placed wherever the arithmetic went.

13.583 TFLOP/s, 86.0% of the cited peak

Convolution stacks, 3x3 stride-1, FLOPs counted as 2 * MACsgpu_tops.py’s convention, so these compose with the GPU rows already in the plan.

width   GMAC/inf   ms med    TFLOP/s   of 15.8 peak   ANE fraction
128       16.97      6.484     5.234   0.331          1.000
256       67.76     15.339     8.835   0.559          1.000
512      270.81     45.887    11.803   0.747          1.000
768      609.15     89.694    13.583   0.860          1.000
1024    1082.78    168.062    12.886   0.816          1.000
1536    2435.93    371.910    13.100   0.829          1.000
2048    4330.23   1319.102     6.565   0.416          1.000

A 3x3 stride-1 stack is the friendliest shape this part sees, so 86% is an upper bound and not a promise, exactly as gpu_tops.py says of its own GEMM figure.

The last row is the one to keep. At width 2048 the rate halves while placement stays at 1.000 — the work is on the device and running at half speed, with 72 MiB per weight tensor, well inside the cap below. Throughput degrades before placement does, so a placement check falls short of a health check.

The ceiling is per-tensor, and a width sweep alone gets it wrong

Growing width at depth 8 put the cliff between 1543.7 MiB and 1599.3 MiB of fp16 weights, 16/16 operations dropping to 1/16. Read alone that says the device holds about 1.5 GiB.

It does not. Holding parameters near constant and changing shape moves the cliff:

depth  width   params        pkg MiB   ANE fraction
8      3584    809,392,640   1543.7    1.000
8      3648    838,554,816   1599.3    0.062
32     1704    810,263,928   1545.3    1.000
32     1800    904,123,800   1724.3    1.000

A larger model passed where a smaller one failed. The control is a two-layer graph, small in total and large in one tensor:

depth  width   one weight   total MiB   ANE fraction
2      3584    220.5 MiB    220.7       1.000
2      3648    228.4 MiB    228.6       0.250
2      4096    288.0 MiB    288.2       0.250

Same boundary at a seventh of the total. The limit is a single weight tensor between 220.5 and 228.4 MiB. 224 MiB sits inside that bracket and is a guess at a round number, not a measurement: the sweep resolution is one width step.

Bryngelson’s Apple Neural Engine (arXiv 2606.22283) documents a per-axis extent cap of 16384; these weights are [3648, 3648, 3, 3] and are nowhere near it. Searching found the reason it goes undocumented — Apple does not publish the internal buffer limits.

And a second ceiling, at exactly 2 GiB

Small tensors, growing depth, so the per-tensor cap stays far away at 55.6 MiB each:

depth  width   weights MiB   ops   ANE fraction
36     1800    1946.8         72   1.000
37     1800    2002.4         74   1.000
38     1800    2058.0         76   0.000
40     1800    2169.3         80   0.000
64     1800    3504.1        128   0.000

2^31 bytes is 2,147,483,648, and the passing and failing rows bracket it at 2,099,520,000 and 2,157,840,000. Bytes rather than operation count: depth 32 at width 2020 carries 2171.5 MiB in 64 operations and fails, while depth 36 at width 1800 carries 1946.8 MiB in 72 operations and passes. It fails wholesale — placement goes to 0.000 and the whole graph moves to the GPU together.

The same graph on Metal

width   GMAC/inf   ms med    TFLOP/s   placement
512      270.81     80.472     6.731   {'gpu': 16}
768      609.15    174.665     6.975   {'gpu': 16}
1024    1082.78    311.664     6.948   {'gpu': 16}
1536    2435.93    719.915     6.767   {'gpu': 16}

6.98 against the Neural Engine’s 13.58 on the same stack, 1.95x, and corroborating gpu_tops.py’s 6.20 fp16 from dense GEMM by a different route. The GPU ran the 3504.1 MiB stack the Neural Engine refused, so above 2 GiB this machine still executes at half rate.

The comparison, at one precision

RETRACTED, AND THE RETRACTION IS THE POINT. This section first tabled “memory: 32 GiB unified” against the device’s 8 GiB and concluded the Mac had “four times the memory”. That is backwards. The quantity that decides placement is what the ENGINE accepts, and the Neural Engine accepts 2 GiB. Against the UGen300’s 8 GiB the Mac’s Neural Engine has a QUARTER of the memory, not four times it. The error came from reading a machine’s unified pool as an accelerator’s working set, and it survived a full draft.

Compared at one precision, fp16, because both parts choose their precision and a comparison across two of them measures the choice rather than the hardware.

M2 Pro ANE M2 Pro Metal Hailo-10H / UGen300
fp16 rate 13.58 TFLOP/s 6.98 TFLOP/s ~10 TOPS, halved
of cited peak 0.86 of 15.8 1.03 of the 6.8 fp32 not measured, no device
weights it holds 2 GiB, measured >= 8176.2 MiB 8 GiB nominal
per-tensor cap ~224 MiB measured none found unknown
host bandwidth ~200 GB/s ~200 GB/s USB 3.1 Gen2, ~1.2 GB/s
device half, 576 121.6 ms, FAILS 62.0 ms, inside bound not measured
power UNMEASURED, sudo UNMEASURED 2.5 W typical

On the synthetic stack the Neural Engine is the faster engine, 13.58 against 6.98. On the graph we ship it loses at 121.6 ms against 62.0 and misses the port’s bound by ten times. A benchmark shape chosen for an accelerator flatters it, and reporting only the synthetic row would have said something the measurement does not.

RFD 1128’s premise still makes the memory axis concrete, corrected: Pixal3D is 24.045 GB in bf16 and about 6 GB at four bits. Neither fits the Neural Engine’s 2 GiB at any precision this part computes in. Metal holds 8176.2 MiB, so the fp16 route on this machine runs through the GPU, and CLAUDE.md now blocklists the Neural Engine as an execution target on that basis.

What stays open, and waits on hardware

The UGen300 side. Its fp16 rate is halved from the 20 TOPS INT8 row rather than measured, the part is not attached to this machine, and RFD 1130 exists to replace those rows once it arrives. Every Hailo figure above predicts rather than reports.

Metal’s ceiling above 8176.2 MiB. The sweep stopped at the device’s 8 GiB because that is the quantity under comparison, not because Metal refused.

Power on both Mac engines. powermetrics --samplers ane_power needs sudo and stayed unrun.

An int4-linear row, which would close the four-bit question cheaply.

Four bits, briefly, and why the ladder was dropped

Core ML does store four-bit weights: 126.0 MiB against fp16’s 504.1 at 264M parameters, a clean quarter. The graph gains eight ios18.constexpr_lut_to_dense operations carrying no device, and the sixteen arithmetic operations stay where they were. The int4 and int8-int8 fast paths arrived with A17 Pro and M4; this is an M2. Four bits buys disk and adds decompression.

Whether it buys capacity depends on which engine is being fitted. Against the Neural Engine’s 2 GiB a quarter is meaningful; against Metal’s 8176.2 MiB nothing needed shrinking. The Neural Engine is blocklisted, so the route that remains fits at fp16 without quantising, and it stays the 8 GiB device that makes four bits compulsory.

An int4-linear row would close the question cheaply and was not run; int4 k-means clusters single-threaded and hung a sweep at 838M parameters before it was killed.

Two apparatus faults worth recording because both presented as agreement rather than as error. A console filter removed two FAILED int4 rows, leaving six that agreed with each other. And scikit-learn >= 1.4 installed 1.9.0, which coremltools 9.0 refuses with “Disabling scikit-learn conversion API” — a warning, after which palettization fails exactly as if the package were absent. The pin now carries both ends.