Inside the M4 Apple Neural Engine · Part 4b

Inside the Compiler

What happens between a MIL graph and the HWX program executed by the M4 Neural Engine

manjeet singh · August 2026

Apple's Neural Engine runs compiled programs. A neural network enters the compiler as a MIL graph (Apple's language for machine-learning operations) and comes out as an HWX binary. That binary is a Mach-O file whose main payload is a stream of Task Descriptors, the register-write records that configure the hardware for one run.

Part 4 mapped those descriptors and the hardware they configure. This article follows the compiler that produces them, from MIL input to HWX output. It also describes a research compiler built from decompilation and live traces, with its output tested on the M4.

The compiler decides which operations share one hardware step (fusion), how tensors are tiled and laid out in memory, which numeric mode to use, where data lives in SRAM, and how DMA transfers are scheduled. We traced these decisions on an M4 running macOS 26.3 with compiler version 9.202.0 targeting the H16G machine model.

The method is the same throughout. We load ANECompiler.framework into our own process, call ANECCompile, and attach LLDB. Breakpoints on the compiler's controller functions give the order of stages. Static disassembly gives what each stage does. Compiler options and retained debug files fill in the policy; byte comparisons of the emitted HWX confirm it. The evidence comes from independent live debugging, static disassembly, controlled experiments, and published community work.

The test inputs (probes) range from a single convolution to branches, residuals, per-channel affine work, a composed attention block, and a four-layer W8A8 chain (W8A8 means 8-bit weights and 8-bit activations).

observed in a successful compile symbols or disassembly controlled experiment still open
Result The tested path is MIL.framework → Zin IR → Zin MIR → scheduler and allocators → Task Descriptors → HWX. Zin is the internal name of the compiler core. It runs thirty-four controller stages in a fixed order. The MIR preparation stage runs fusion twice: a broad round before engine lowering and a narrow round after. In every traced compile, the MIL route entered Zin directly. Breakpoints on the embedded MLIR parser and pass manager did not fire.

The map below shows every component we found, in the order a program passes through them. Each box names the section that examines it and the trace stages it covers. Dashed boxes are components beside the main path: a second front end, a conditional solver, and the compiler's own debug surfaces. The sections that follow walk down the map one band at a time.

THE COMPILER, END TO END · MIL TEXT → HWX PROGRAM stage numbers from the 34-stage trace · §02 ANECompilerService.xpc sandboxed XPC process · chooses a front end by model type §01 Core ML MIL MLIR ANECIR CVAIR LLIR bundle MLIR / MPS route parseSourceFile PassManager::run separate route · §01 MIL.framework parses · Zin imports MIL IR → BuildLayerGraph → control-flow graph prepared and validated stages 1–4 · §01 ANECompiler.framework · Zin compiler LLVM + MLIR embedded · no hits on the MIL compiles Zin IR · recognize and canonicalize constants · DCE · variance and normalization forms · matmul → conv · Q/DQ folding stage 5 · §03 Zin MIR prepare · make the graph implementable stages 6–7 · §04 prepare graphhoist · CSE broad fusiongraph layers · +2176 engine loweringchannel-last · GOC narrow fusionengine layers · +3916 Named checkpoints before_fusion after_fusion after_engine_lowering after_fusion2 after_mir_opt after_reg_spill · §07 MIR builder · choose engines and modes active NE · reassignment · double-int8 · large kernel · legalizers · ANE assignment stage 8 · §05 Machine backend stages 9–34 · §06 Schedulerstage 9 CP allocatorstages 11–13 Spillingstages 14–15 Hazards · depsstages 16–17 Piecesstage 18 Memory cachestage 19 Materialize hardware state stages 20–28 · binary point · split row · prefetch BuildComputeProgram stage 29 CodeGen object 33–34 · live IO libORTools.dylib CP allocator · transposer memcache allocator two-branch graphs · §01 HWX object · 0xBEEFFACE Mach-O __TEXT.__text TD stream · __KERN_0 kernels or LUT · __FVMLIB in / out descriptors compiler metadata · live IO layout → loader and hardware · Part 4 Retained debug files status plist init.json · refine.json --debug flags · §07
Hover a box to see which controller functions run there

Solid boxes are the traced MIL path. Dashed boxes exist in the binary beside it. Stage numbers count the 34 controller functions that fired for one convolution compile (§02).

The compiler process and its front ends

Compilation normally runs inside ANECompilerService.xpc, a sandboxed helper process. Core ML and the _ANEClient API reach it over XPC. The service holds separate compiler classes for different model types (MIL, MLIR, Core ML, ANECIR, CVAIR, and an Espresso translator) and picks one based on the input format. Every trace in this article follows the MIL route.

There is also a direct path. The same ANECompiler.framework can be loaded into any process and called through its exported ANECCompile function. That is how this investigation ran: in-process, with LLDB attached, so every symbol in the framework was reachable.

PROCESS BOUNDARY · TWO WAYS INTO ONE COMPILER Your process Core ML or _ANEClient compileModel → XPC or dlopen ANECompiler ANECCompile in-process this article · LLDB same framework, no sandbox ANECompilerService.xpc sandboxed · dispatch by model type MIL.framework parser · MIL IR · validation · libc++ / libSystem ANECompiler.framework · Zin compiler links MIL · Accelerate · CoreFoundation LLVM + MLIR embedded · no separate dylib libORTools.dylib · loaded lazily CP allocator · transposer · two-branch graphs Output directory model.hwx 65,536 B (probe) probe.status.plist live IO · max DRAM init / refine.json partition scores → aned loads it via IOKit · Part 4 XPC direct writes The XPC service adds sandboxing and format dispatch. Calling ANECCompile directly exposes the same framework to a debugger.
Two ways to reach the same compiler framework: through the sandboxed XPC service, or directly via ANECCompile with LLDB attached.

Linked libraries

ANECompiler.framework links MIL, Accelerate, CoreFoundation, CoreAnalytics, libc++, libSystem, and ncurses. No LLVM or MLIR dynamic library is in that list. The framework has a large body of upstream LLVM and MLIR symbols, but they are all statically linked into the binary.

One more library loads on demand: libORTools.dylib, Google's operations-research solver. The framework resolves three entry points from it, all constraint-programming (CP) solvers: an allocator, a transposer, and a memory-cache allocator.

When does libORTools get pulled in? A plain convolution and serial convolution chains did not load it. A graph with two independent convolution branches did, and called the external transposer and CP allocator. Serial chains of every tested size stayed on the internal solver, so the trigger appears to be graph topology or placement pressure rather than graph size.

ComponentObserved roleCurrent evidence
MIL.frameworkParse and validate the MIL programSymbols, objects, successful compile
ANECompiler.frameworkZin IR, MIR and machine backendLLDB trace and disassembly
libORTools.dylibConditional CP allocation, transposition and cache solvingLive branch-graph solver trace
AppleNeuralEngineProgram and runtime integration around the compiler serviceXPC service dependency
EspressoOne model translation routeService imports and translator class

The MIL route and the MLIR route

The bundle's DTCompiler = com.apple.compilers.llvm.clang.1_0 records the toolchain Apple used to build the framework. That is just the build tool. The framework has none of the Clang front-end classes (no ASTs, no source management, no code generation).

The framework does contain a complete MLIR stack: seven MLIRContext constructor sites, mlir::parseSourceFile, four mlir::PassManager::run sites, the Arith dialect, bufferization interfaces, and parser diagnostics. The XPC service has a dedicated _ANEMLIRCompiler class that uses them.

The question is whether the MIL route (the one this article traces) touches any of that MLIR code. We tested with auto-continuing breakpoints on the context constructors, parser, and pass manager. A convolution compiled to a 65,536-byte HWX and none of those breakpoints fired. The Zin controller functions ran normally.

An explicit .mlir input told a different story: it entered MLIRContext, parseSourceFile, and PassManager::run, accepting Apple's MPS dialect (mps.* and mpsx.*). The two routes share a binary but not a code path.

ONE BINARY, TWO ROUTES ANECompiler.framework DTCompiler = Apple Clang · build toolchain marker MIL route · traced MIL.framework parse BuildLayerGraph Zin IR → Zin MIR scheduler · backend HWX · 65,536 B upstream 0 hits MLIRContext parseSourceFile PassManager::run Explicit .mlir route · separate MLIRContext parseSourceFile · mps.* / mpsx.* PassManager::run translation to Zin? · unmapped not traced past the pass manager · §10 upstream 3 hits The upstream MLIR entry points fired for the .mlir probe and stayed silent for the MIL compiles.
The MIL route and the MLIR route share a binary but use separate code paths. Zero MLIR hits on the MIL compile; three on the .mlir probe.

MIL parsing and import into Zin

MIL.framework parses the program text and its weight blob into its own graph representation: contexts, functions, operations, tensors, and validation objects. The first Zin function reached under LLDB is BuildLayerGraph. Everything after that call happens inside the hardware compiler.

The 34 controller stages of one compile

The compiler is organized as a fixed pipeline. Each step is a controller function responsible for one transformation or decision. The symbol table gives their names; their order comes from a live trace.

To get the trace, we broke at ANECCompile, resolved every Zin controller once the framework had loaded, and set auto-continuing breakpoints on all of them. Then we let a 64-channel convolution compile to completion. Thirty-four controllers fired, in the same order on every repeat.

The strip below shows the trace in the order it fired. Hover or tap a box to see the function name, what it does, and how firmly that role is established. The full list is under the strip. A stage appearing here means its controller ran, though a small graph can pass through a stage without being changed by it.

ONE COMPILE · 34 CONTROLLER STAGES IN FIRING ORDER Import Optimize Plan Resolve Materialize Emit 1 5 8 14 20 29 34 parse · import Zin IR · MIR prepare MIR builder · scheduler · CP alloc spill · hazards · pieces · memcache late passes write descriptor state program · object · IO Auto-continuing breakpoints on every Zin controller · one 64-channel convolution · same order on repeated compiles

All 34 controller stages in firing order for one 64-channel convolution compile. The sequence was identical on repeated runs.
Show the complete 34-function trace
  1. CompileProcedure
  2. BuildLayerGraph
  3. PreProcessControlFlowGraph
  4. ValidateControlFlowGraph
  5. OptimizeControlFlowGraph
  6. RunMirPrepareIr
  7. RunFindInherentParallelism
  8. RunMirBuilder
  9. RunTaskScheduler
  10. ValidateMirInfo
  11. RunCPAllocator
  12. MirPrepareControlFlow
  13. ValidateMirInfo
  14. RunRegisterSpilling
  15. RunMultiSegmentSpilling
  16. RunHazardAnalysis
  17. RunRemoteDependencyAnalysis
  18. RunPieceGeneration
  19. RunMemCacheAllocation
  20. UpdateFinalKernelSHA
  21. SetBinaryPoint
  22. SetSplitRowCompute
  23. DumpDebugProfilingInfo
  24. SetTDExecutionTime
  25. RunCachePrefetch
  26. RunContextSwitch
  27. RunKernelBufferControl
  28. RunComputeAddressTranslationRegisters
  29. BuildComputeProgram
  30. QualifyOnImbalanceRatio
  31. DumpLayerStats
  32. RunHandleMultiAneSynchronization
  33. RunCodeGenObjectGen
  34. SetLiveIOAttributes

The four levels of the pipeline

Disassembly of those controllers shows the thirty-four stages falling into four levels, each more constrained than the last.

Zin IR (stage 5) works on the graph's algebra: recognizing patterns like "subtract, square, reduce" as a variance calculation. MIR preparation (stages 6-7) lowers those recognized operations onto hardware engines and fuses compatible ones together. The MIR builder (stage 8) picks execution modes and forces the graph inside the machine's memory and latency limits. The backend (stages 9-34) schedules, allocates resources, and writes the actual register values and DMA commands into the binary.

This layering keeps graph-level decisions (should a matmul become a convolution?) separate from machine-level ones (which SRAM bank does a tile land in?). Sections 03 through 06 take the four levels in order.

WHERE EACH DECISION IS MADE Zin IR recognize forms simplify graph semantic level stage 5 Fold and eliminate constants · identities · dead code · duplicate tensors Recognize useful forms normalization · activations · reductions · matmul-as-conv MIR prepare lower and fuse split tensors hardware graph stages 6–7 · §04 Reshape the work batch · channel · kernel · spatial splits · tensor caching Join compatible work layer fusion · hoisting · transpose collapse · scale folding MIR builder select modes legalize machine resource level stage 8 · §05 Select execution active NE · engine reassignment · double-int8 · large kernel Satisfy constraints DRAM · L2 · latency · context switch · multi-segment Late backend schedule and bind emit TD program binary level stages 9–34 · §06 Resolve pressure and order CP allocation · spilling · hazards · dependencies · cache allocation Materialize hardware state prefetch · waits · address translation · register images · HWX Each level adds constraints the level above did not have.
Four optimization levels and the decisions each one makes. Each level adds constraints the one above did not have.

Zin IR: graph recognition and canonicalization

The first level works on the graph's algebra. ZinIrOptimize runs dozens of ordered rewrites over the imported graph. Some simplify: pre-calculating constants, removing dead work, collapsing redundant broadcasts and transposes. Some recognize: detecting that a chain of subtract, square, and reduce is really a variance calculation, or that a sequence of elementwise operations is a known activation function. Some restructure: rewriting eligible matmuls as convolutions, folding quantize/dequantize pairs, and hoisting views around convolutions.

After this level, the graph is clean and named. A chain of elementary MIL operations has become one recognized form (a variance, a normalization, an activation) before any hardware planning starts. This level decides what the graph computes. The next two decide how.

Show representative recovered pass names

Zin IR:

  • LowerBatchNorm, ConstInPrecalculation, ZinIrOptDCE
  • EWSquareDetection, VarianceCalculationDetection, NormalizationDetection
  • PerformReplaceMatmulWithConv, PerformTransposeMatmult
  • CollapseBroadcast, CollapseTranspose, CollapseQuantDequant
  • TensorPackingOptimizer, WidthConcatCanonicalizer, ReverseCSE

Zin MIR:

  • ZinMirLayerFusion::Run, ZinMirLayerHoisting::Execute
  • ZinMirSplitSpatially, ExecuteBondedSplitMode
  • ZinMirTensorDimensionLegalizer, ZinMirLatencyLegalizer, ZinMirL2Legalizer
  • ZinMirSetActiveNE, ZinMirOptimizeForDoubleInt8, ZinMirANEAssignment

Zin MIR preparation: lowering, splitting, and two fusion rounds

MIR (machine IR) preparation takes the recognized graph and makes it runnable on hardware. This means splitting tensors that exceed machine limits (along batch, channel, kernel, or spatial dimensions), choosing memory layouts, hoisting compatible work together, and folding weights with their scales.

Interleaved with that work, the compiler runs fusion twice. Fusion is the process of combining separate graph operations into one hardware step. For example, a convolution followed by ReLU can execute as a single Task Descriptor instead of two, with no intermediate write to memory.

Why two rounds? Each sees a different graph. ZinMirPrepareLayers constructs two instances of ZinMirLayerFusion. The first (at offset +2176, mode byte clear) works on graph-level layers, before they are committed to specific hardware engines. The second (at +3916, mode byte set) runs after engine lowering and sees layers that are already bound to hardware.

Between the two rounds, several passes reshape the graph: channel-last conversion, GOC (general-operation-controller) engine reassignment, forward engine lowering, and wrap-axis validation. After the second round, dead-code elimination and post-fusion transpose hoisting clean up.

TWO FUSION ROUNDS INSIDE ZinMirPrepareLayers Prepare graph hoisting · Q/DQ collapse CSE · transpose collapse Broad fusion +2176 · mode 0 graph-level NE · PE · SNE Machine preparation channel-last · GOC reassignment engine lowering · wrap validation Narrow fusion +3916 · mode 1 lowered engine patterns only then DCE · post-fusion hoisting Round 1 · broad graph registry patterns constructed by the mode-0 registry NE Conv · UConv · UPool · KernelRasterizer · CrossCorrelation MatMul · Pool · ElementWise · DualSourceElementWise Bypass · RCAS · NEConv · TdBranching PE Pool · BinaryPool · ElementWiseAdd · ElementWise ElementWiseMirror · UnaryElementWise · GOC DeQuantVector · Dequant · QuantScalar · QuantVector SNE EWActCondition · ScaleBiasCondition · SNEScaleBias Observed · attention MatMul ×2 · Bypass ×3 Unary EW ×2 · ElementWise ×2 Observed · W8A8 chain Conv ×4 on the main path Q/DQ edges already collapsed Round 2 · narrow registry only two pattern constructors NEConv neconv → negoc lowered NEConv or NEBypass plus eligible GOC / activation TdBranching ane_layer → condition_layer attach a hardware condition to an engine task descriptor No ordinary tested graph committed here. Registry constructors show which patterns exist. Stack-filtered commits show which ones fired.
Two fusion rounds inside ZinMirPrepareLayers, the passes between them, and each round's pattern registry.
Show the observed pass order around both fusion rounds
  1. +1320 · ZinMirLayerHoisting::Execute
  2. +1368 · CollapseQuantDequant
  3. +1476 · ScaledEWOrEWWithConstInToGOCFusion
  4. +1524 · CollapseTranspose
  5. +1852 · PreFusionCSE
  6. +1928 · ReverseCSE
  7. +2160 / +2176 · construct mode-0 fusion, then run it
  8. +2352 · OptimizeToChannelLast
  9. +2500 · ZinMirGOCEngineReassignment::Execute
  10. +2652 · remove 8-bit-to-fp16 copy
  11. +2724 / +2756 · fully connected optimization, then CSE
  12. +2904 · insert transpose for PE reduction
  13. +3092 / +3140 / +3192 · SSM and reshape cleanup
  14. +3328 / +3392 / +3472 · dilated-conv optimization, dynamic-conv lowering, matmul kernel padding
  15. +3672 · forward engine lowering
  16. +3736 / +3828 · after_engine_lowering, then wrap-axis validation
  17. +3900 / +3916 · construct mode-1 fusion, then run it
  18. +3924 / +4068 / +4220 · after_fusion2, DCE, post-fusion transpose hoisting

Pattern registries of the two rounds

The broad round's registry covers the ANE's three engine types. NE (Neural Engine) handles convolutions and matmuls. PE (Planar Engine) handles elementwise and pooling work. SNE handles scale-and-bias operations. Each engine type has its own set of fusion patterns.

The narrow round registers only two patterns. NEConv looks for a lowered convolution (opcode 0x5d, a ZinNEConvLayer) or bypass (opcode 0x64, a ZinNEBypassLayer) whose boundary holds an eligible GOC or activation layer. If the predecessor is the captured convolution and the tensor formats and DMA symbols are compatible, it fuses them. TdBranching captures an ane_layer followed by a condition_layer and attaches a hardware condition to the task descriptor.

One methodological note: the framework runs the same fusion machinery during internal validation, so a raw breakpoint count overstates the main path. Every fusion count in this section is filtered to accept a commit only when the call stack contains ZinMirPrepareLayers.

Convolution + ReLU fusion, traced on live objects

Here is what fusion looks like on a live convolution followed by ReLU. The broad round's ZinNEPatterns::Conv pattern found two matches: the convolution layer (key main) and the ReLU layer (key activation). Conv::Fuse took both pointers and built a single ZinNEConvLayer that combines them. The commit callback then rewired the graph: it added the new fused object, redirected incoming edges, moved outgoing edges, and removed both original objects.

In the plain-convolution control (no ReLU), the activation match was null at both visits and nothing was fused.

ONE OBSERVED MIR GRAPH REWRITE BEFORE Convolution object match: main · pointer A Activation object match: activation · ptr B ZinMirLayerFusion::Run ZinMirPrepareLayers +2176 Group · find the pattern matches Conv::Fuse · build ZinNEConvLayer Commit · rewrite the live graph AFTER One ZinNEConvLayer new pointer C convolution + activation added to graph redirect incoming edges move outgoing edges remove pointers A and B CONTROL plain convolution: main = non-null · activation = null at both visits LATER PASS ZinMirPrepareLayers +3916 runs, but produces no second convolution fusion The pointer returned by Conv::Fuse is the pointer added by Commit; both matched input pointers are then removed.
Conv + ReLU fusion traced on live objects: pattern match, fused layer creation, graph rewrite, and source removal.

Fusion commits on the attention probe

The attention probe follows a standard attention pattern: Q, K, and V slices, layout transposes, a QK matmul, scalar scaling, softmax, a PV matmul, and an output transpose. A second version adds a residual connection. Both compile to valid H16G HWX.

The broad round committed nine rewrites on the main attention path: two MatMul (for QK and PV), three Bypass (for view and layout paths), two UnaryElementWise (for the softmax sequence), and two ElementWise (for scaling and graph elementwise work). The residual version added one more PE ElementWise.

The four-layer W8A8 chain committed four Conv rewrites. Its explicit quantize/dequantize edges were already gone by this point, because CollapseQuantDequant runs earlier at +1368, before the first fusion round.

COMPOSED ATTENTION PROBE · SUCCESSFUL H16G COMPILE Q / K / Vslice views Layouttransposes QKmatmul Scalescalar multiply SoftmaxPE sequence PVmatmul Outputtranspose A second version adds a residual connection after the output transpose. Committed in the broad round main compile stack only · nine commits MatMul ×2 QK and PV Bypass ×3 view / layout paths Unary EW ×2 softmax sequence ElementWise ×2 scale and graph EW Attention + residual repeats these commits and adds one PE ElementWise rewrite for the residual edge.
Nine fusion commits recorded on the attention probe's main path. All occurred in the broad round.
ProbeMain broad-round commitsNarrow-round commit
Conv + ReLUConv ×1; one ZinNEConvLayer insertedNone
AttentionMatMul ×2, Bypass ×3, UnaryElementWise ×2, ElementWise ×2None
Attention + residualAttention commits plus ElementWise ×1None
Four-layer W8A8 conv chainConv ×4; explicit Q/DQ edges absent before this roundNone
Affine, fan-out, residual, double activation and layout probesPattern-dependent first-round rewritesNone

Conditions of the narrow round

None of the probes produced a commit in the second (narrow) round. This includes every graph we tested: plain and fused convolutions, branch, residual, affine, activation, and layout graphs, the attention block, and the W8A8 chain.

The registry explains why. NEConv needs a very specific setup: a lowered convolution or bypass whose boundary holds an eligible GOC or activation, in the right tensor format with compatible DMA symbols. TdBranching needs a condition layer that none of these MIL graphs produce.

One source of the pattern that NEConv looks for is ZinMirGOCEngineReassignment, which runs between the two rounds. It visits eligible PE elementwise work, checks for a single compatible predecessor with unused output scale/bias state, creates constant scale-and-bias GOC data, inserts a ZinNEBypassLayer, and rewires the graph. That inserted bypass is the kind of node the narrow round's NEConv pattern is designed to find.

Effect of --O0 and --O1

The compiler accepts --O0 and --O1 optimization flags. We compiled conv + ReLU under the default policy and under both levels. All three produced a 65,536-byte container with a 472-byte Task Descriptor stream.

--O0 and --O1 were byte-identical. The default stream differed from them in 159 of 472 bytes. Hardware lowering and fusion of the conv/ReLU pair into one TD program stayed active at every level. The optimization level changes the register image (the specific values written into descriptors), but lowering and fusion happen regardless.

CONV + RELU · __TEXT.__text default 313 bytes match O0 159 bytes differ 472 B --O0 one TD program · mandatory lowering retained 472 B --O1 byte-identical to --O0 472 B same TD byte changed under default policy 313 of 472 bytes are identical across all three levels.
Task Descriptor byte comparison across three optimization levels for conv + ReLU.

MIR builder: engine assignment, execution modes, and double-int8 eligibility

The MIR builder gives each lowered layer two things: an engine assignment and an execution mode. It then forces the graph inside the machine's limits by running a series of legalizers for DRAM, L2, latency, context switching, and multi-segment operation. Its output is a machine graph with enough detail for the backend to schedule.

The double-int8 pass handles the W8A8 execution mode. When both weights and activations are 8-bit integers, a qualifying convolution can process two int8 products per cycle instead of one fp16 product. ZinMirOptimizeForDoubleInt8 runs after active-NE selection, traverses candidate layers, and calls CanUseDoubleInt8Mode on each convolution.

The eligibility check has two stages. First, CanUseDoubleMacModeBasedOnFormats checks that the activation and kernel elements are each at most one byte and that their numeric formats are compatible. Second, convolution-specific checks verify kernel state, source mode, half-work-unit mode, a vector-palettized exception, and output-channel-group size. If both pass, the result is stored in MIR state at offset +0x4ff. Later stages (legalization, scheduling, TD emission) read that stored value and write the mode and DMA words into the final HWX.

ZinMirOptimizeForDoubleInt8 · CONVOLUTION ELIGIBILITY Visit MIR layer forward traversal of the control-flow graph engine convolution with MIR state? layer kind · attached conv · mode flags no leave mode off continue traversal yes CanUseDoubleMacModeBasedOnFormats primary activation format · activation size ≤ 1 B kernel underlying size ≤ 1 B · compatible numeric class fail leave mode off format rejected pass Convolution constraints non-unity kernel weight · source and half-work-unit state vector-palettized exception · channel assignment calculated output-channel-group size fail leave mode off geometry rejected pass Store double-int8 decision in MIR state layer MIR info +0x4ff → legalization, scheduling, TD emission Checks run in this order. The first failure leaves the mode off.
Double-int8 eligibility checks for one convolution. The first failure leaves the mode off.

Machine backend: scheduling, allocation, and object generation

Once every layer has an engine and a mode, the backend turns the plan into a program: a sequence of register writes and DMA commands. Order matters here. A prefetch address cannot be written until the schedule is known. A wait cannot be placed until hazard analysis has found the dependency it protects.

The trace shows the backend working in three phases. First, it resolves scheduling and resources: the task scheduler (stage 9) fixes the execution order, the constrained-programming allocator (stage 11) assigns resources to live ranges, and spilling (stages 14-15) handles what does not fit. This allocation stage is where the two-branch graph pulled in libORTools.

Next come the correctness passes: hazard analysis (stage 16) derives which tasks must wait for others, remote-dependency analysis (stage 17) handles cross-unit ordering, piece generation (stage 18) cuts the scheduled work into fragments, and memory-cache allocation (stage 19) assigns cache resources.

Finally, stages 20 through 28 write the hardware state that Part 4 found in the descriptors: kernel identity, binary point, split-row compute, timing estimates, cache prefetch, context-switch behavior, kernel-buffer control, and address-translation registers. BuildComputeProgram (stage 29) assembles the register-write stream, RunCodeGenObjectGen (stage 33) writes the 0xBEEFFACE Mach-O, and SetLiveIOAttributes (stage 34) reports the live input and output layout.

Each backend pass produces a separate family of fields in the HWX, which is why the Task Descriptor stream contains distinct register-image records, DMA commands, explicit waits, streamed pieces, cache controls, and translation fields.

FROM MACHINE GRAPH TO TASK DESCRIPTORS MIR graph engines · splits · modes Task scheduler partitions · stage 9 CP allocator live ranges · 11–13 Spilling reg · segment · 14–15 Hazards wait conditions · 16 Dependencies remote ordering · 17 Pieces fragments · stage 18 Memory cache allocation · stage 19 Materialize machine state stages 20–28 · binary point · split row timing · prefetch · address translation BuildComputeProgram → HWX register writes · DMA · waits · kernel data live IO · object sections · stages 29–34 The live trace established this controller order on the 64-channel convolution.
Hover a box to see the controller functions behind it

Each box groups the trace stages named inside it. Ranges list every controller that fired in that span, in order.

Backend pass order from machine graph to HWX object, as observed in the live trace.

Compiler options and retained debug output

The exported function ANECCreateCompilerOptionsCFString converts a Core Foundation dictionary into the compiler's command-line form. It gave us a controlled way to turn on debugging, request status and partition dumps, and select optimization levels.

Dictionary keyGenerated compiler optionObserved result
TargetArchitecture = H16G-t H16GExplicit M4 target
CompileANEProgramForDebugging--debugLarger debug HWX and debug logging
DebugMask = 0xffffffff--debug_mask=0xffffffffReaches gated checkpoints; graph writer is stubbed
DumpStatusDictionaryToFile--fdump-status-dictionary-to-fileRetained status plist
DumpParallelScore--dump-parallel-score=trueRetained partition JSON
DisableOptimizations--O0Changed fused TD register image
OptLvlOne--O1Matched O0 for the probe

A debug compile retains three kinds of output. The status plist records the compiled input, the maximum DRAM usage (1,073,624 bytes for the probe), the procedure name, and every live tensor's dimensions, type, interleave, and strides. The two partition files (init.json and refine.json) report graph structure and partition scores: for the probe, a three-node graph, three-way partition, edge cut 2, and parallel score 0.

Graph dumps are a different story. The release framework reaches its named checkpoints (before_fusion, after_fusion, after_engine_lowering, after_fusion2, after_mir_opt, after_reg_spill) and assembles the expected filenames. But both writers are compiled out. ZinIrOpLayerGraph::DebugPrint creates an empty stream and returns it without reading the graph. The JSON writer is a single ret instruction. No option can recover text that these functions do not generate.

This is why the fusion evidence in section 04 comes from live LLDB object tracing rather than dump files. The pattern matches supply the input pointers, Conv::Fuse supplies the new layer, and the commit methods show it inserted while the sources are removed.

WHAT THE DEBUG SURFACE ACTUALLY RETURNS Compiler options --debug --debug_mask=0xffffffff --dump-parallel-score=true status dictionary dump ANECompiler named graph checkpoints partitioning · fusion · allocation scheduler · object metadata Retained outputs status plist · tensors and max DRAM init.json · initial partition score refine.json · refined partition score debug HWX · compiled machine program Graph inspection paths DOT logging path checkpoint is reached DebugPrint ignores the graph returns an empty string release-build stub JSON file path two constructors are reached filenames are assembled writer is one ret instruction release-build stub Live object trace matched layer pointers new fused engine layer AddNode · edge moves · RemoveNode working pass-level receipt Checkpoints and filenames are reached. Both graph writers are stubs. Live object traversal works.
Three debug output paths: status and partition files work, graph writers are stubs, live object tracing fills the gap.

One convolution, end to end

Follow the 64-channel convolution from the top of the map. ANECCompile hands its MIL text to MIL.framework, which parses it and passes the result to BuildLayerGraph.

Zin IR folds constants and removes dead work. On this simple graph there is nothing further to recognize. MIR preparation runs the broad fusion round, and because a ReLU is present, this is where the convolution and activation become one ZinNEConvLayer. The layer is lowered onto the NE. The narrow round runs but finds nothing to do.

The MIR builder selects the active engines and evaluates double-int8 eligibility. This is an fp16 graph, so the double-int8 mode stays off. The DRAM, L2, and latency legalizers check the graph against machine limits.

The backend schedules the single layer, allocates its resources on the internal solver, and finds no spills or hazards. It generates pieces, assigns the memory cache, writes prefetch and context-switch and address-translation state, builds the compute program, and emits a 65,536-byte HWX. Part 4 read its 472-byte Task Descriptor stream field by field.

A single hardware feature (W8A8, split-row execution, cache prefetch, or tensor streaming) depends on decisions spread across several of these stages. Part 4 showed that a chosen machine state can be reproduced by editing HWX directly. The compiler also has to decide when that state is legal and how it fits with the rest of the schedule.

The research compiler and its M4 results

We built a separate compiler in Objective-C and Objective-C++ from the recovered pipeline. It reads textual MIL, builds typed SSA and operation graphs, and runs graph passes that normalize and decompose operations. A fusion pass joins adjacent operations when their dependencies and measured task forms allow them to share one HWX program.

The planner creates legal H16G tasks using capability tables derived from measurements on one M4 and one macOS build. When compatible adjacent tasks can share one HWX program, they do. Otherwise they remain separate programs connected by IOSurfaces described in a binding manifest. Unsupported operations, shapes, data types, and reduction axes are rejected before object emission.

The object writer generates HWX files from scratch: Mach-O headers, sections, symbols, relocations, program records, tensor records, and payloads, all built from compiler data structures. It does not read or patch an Apple-compiled HWX container.

RESEARCH COMPILER · GRAPH TO PROGRAMS TO M4 MIL graph parse · typed graph Graph passes normalize decompose · fuse H16G plan and partition legalize · tile lifetime · bridge state Compose and write field folds · SRAM forms HWX objects · manifest The planner groups tasks using operation type, geometry, data type, SRAM compatibility, and value lifetime. S128/D128 FP16 attention after graph lowering Program 0 inputs: Q, K matmul Q × transpose(K) scale stored in the matmul output field Program 1 · row normalization reduce max subtract exp reduce sum reciprocal multiply intermediate values remain in SRAM Program 2 input: V matmul P × V output: Y scaled probabilities inside one HWX program IOSurface between programs three synchronous submissions Program bundle 3 HWX + manifest Provisioned runtime surfaces · dispatch aned cache load programs M4 ANE evaluate CPU comparison accuracy · timing The same planner also handles scalar folds, matmul-GELU, affine scan, and the 58-program Chunked DeltaNet graph. Unsupported operations, geometries, data types, and bridge combinations stop before object emission.
S128/D128 FP16 attention compiled into three HWX programs connected by IOSurfaces.

Supported operations

FamilyMeasured H16G coverage
Conv1x116 FP16 channel and spatial geometries, with optional ReLU
Regular convolutionC64 and C128, S32 and S64, K3 and K5
Depthwise convolutionC64, C128, C256, and C512 at S64 and K3
MatmulSquare N128, N256, and N512; tiled multiples of 128 through N4096
Binary ALUAdd at N128 through N2048; multiply at N128 and N512; max and min at N512
Unary and LUTReLU, sigmoid, tanh, GELU, SiLU, exp, log, sqrt, rsqrt, and reciprocal
ReductionSum, mean, and max over measured channel, height, and width axes
LayoutS2D and D2S at B4 and B8, including 64-byte physical row padding
Fused layoutS2D, Conv1x1, and D2S at C8, C16, C24, and C32
W8A8Four-layer C64/S64 Conv1x1 chain with packed middle blocks
Multi-operation graphsAttention, matmul-GELU, affine scan, and Chunked DeltaNet at the measured shapes below

Compiler coverage is limited to the configurations in the table above. For convolution, matmul, ALU, and layout operations, the encoders calculate Task Descriptor fields from the graph and selected schedule. Some unary and reduction encoders use a simpler approach: they copy Task Descriptor words measured for one specific geometry. Anything outside the recorded configurations is rejected before HWX emission.

Results for multi-operation graphs

The table reports the final M4 runs on a Mac16,10 with macOS 26.3 build 25D125. The attention comparison used 50 warmups and five alternating batches of 5,000 synchronous evaluations for each compiler. The matmul-GELU figures come from the earlier two-run comparison.

GraphHWX programsNumerical resultResearch latencyApple compiler
FA2, FP16, S128/D128316,384 values; zero mismatches; maximum error 0369.458 us127.208 us
Matmul, reshape, GELU, N2561maximum error 0.0061035266.94 us76.60 us
Four-step FP16 affine scan, N1288every stage within 1 FP16 ULP; final output within 3 ULPnot measuredInvalidMILProgram
Chunked DeltaNet, C128/D12858output relative L2 0.004214; final-state relative L2 0.00444322728.792 usInvalidMILProgram

FA2 runs as three HWX programs. Program 0 computes Q × transpose(K), with the scale stored in the matmul output field. Program 1 performs the softmax normalization: reduce_max, subtract, exp, reduce_sum, reciprocal, and multiply. These six tasks share one program because their intermediate values can stay in SRAM. Program 2 multiplies the probabilities by V.

At S128/D128, the complete chain takes 369.458 microseconds, about 3x the Apple compiler's 127.208 microseconds. Matmul-GELU composes into a single program and takes 66.94 microseconds, compared with 76.60 for the Apple compiler.

The 58-program Chunked DeltaNet graph measured 22.73 milliseconds. Its MIL uses ordinary operations (matmul, add, multiply, exp) with regular inputs (normalized Q and K tensors, transposed K layout, triangular matrices). There is no dedicated DeltaNet operation.

Hardware activity The research-only FA2 capture reported ANE activity in 61 of 140 samples, with a 601.9 mW active-sample mean and a 681 mW peak. Matmul-GELU was active in 8 of 60 samples at 36 to 134 mW. Chunked DeltaNet was active in 73 of 80 samples at 19 to 403 mW and 86.34 mW on average. The GPU rail stayed at 0 mW in the matmul-GELU and Chunked DeltaNet captures. These system-wide samples confirm ANE execution; they do not provide per-process energy measurements.

Physical tensor layout

Logical rows shorter than 64 bytes are padded to a 64-byte physical row in their tensor records. All higher-level sizes (plane, batch, allocation) derive from that physical stride. For example, a C32/S64/B4 space-to-depth object has a 32-byte logical output row but a 64-byte physical row. Apple's compiler produces the same layout.

The graph keeps the semantic shape. Memory planning assigns the physical strides and storage sizes. The HWX writer records both views, and the runtime fills and reads IOSurfaces through the physical strides. After adding this rule, all 24 reduction forms and all 25 standalone or fused layout forms passed twice on the M4.

Current boundary Coverage is limited to the configurations measured on the M4, and the compiler rejects every other case before HWX emission. The three-program attention path still crosses two IOSurface boundaries. The 58-program Chunked DeltaNet path spends most of its time on synchronous submissions. Broader operation and geometry coverage, dynamic shapes, general legality rules, and cost-based scheduling remain open work.

Unmapped parts of the compiler

Four areas of Apple's compiler remain unmapped: post-lowering machine patterns, one GOC reassignment branch, the scheduling cost function, and whole-graph inspection.

Post-lowering machine patterns

The narrow NEConv pattern checks layout and DMA-symbol state beyond its neconv → negoc shape. Its constructor and predicates are mapped, but none of the tested graphs satisfied every condition. TdBranching has a known ane_layer → condition_layer shape; the MIL form that creates its condition object has not been identified.

GOC reassignment

The reassignment pass creates constant scale-and-bias data and inserts a ZinNEBypassLayer for eligible PE elementwise work. One branch of the pass expects a predecessor with opcode 0x32. That layer class did not appear in any of the constructor traces, so this branch remains unnamed.

Scheduling policy

The live trace fixes the order of backend passes (scheduling, allocation, spilling, hazards, and so on). What remains unknown is the cost function: the thresholds that choose among legal splits. Answering that requires controlled graph families that vary one pressure term at a time.

Whole-graph inspection

The release DebugPrint path produces no layer text. A general live viewer would have to walk ZinIrControlFlowGraph::TraverseForward and recover identity from object methods. The targeted callbacks already do this for individual passes and pattern commits.


The traced compiler path begins with a MIL graph, passes through Zin IR and Zin MIR, and ends in a scheduled HWX program. The research compiler applies the recovered HWX rules to a measured set of operations and shapes, including a three-program FP16 attention graph whose softmax intermediates remain in SRAM. The unmapped passes and scheduling rules in Section 10 define the remaining work.


References

  1. Manjeet Singh. Inside the M4 Apple Neural Engine, Parts 1–4. maderix.github.io/articles, 2026.
  2. Spencer H. Bryngelson. Apple Neural Engine: Architecture, Programming, and Performance. arXiv:2606.22283, June 2026. arxiv.org/abs/2606.22283.
  3. Ramchand Kumaresan. Orion: Characterizing and Programming Apple's Neural Engine for LLM Training and Inference. arXiv:2603.06728, March 2026. arxiv.org/abs/2603.06728.