Inside the Compiler
What happens between a MIL graph and the HWX program executed by the M4 Neural Engine
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.
Research compiler: MIL-to-HWX compiler on GitHubSource code, build instructions, tests, and measured M4 results.
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).
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.
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.
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.
| Component | Observed role | Current evidence |
|---|---|---|
MIL.framework | Parse and validate the MIL program | Symbols, objects, successful compile |
ANECompiler.framework | Zin IR, MIR and machine backend | LLDB trace and disassembly |
libORTools.dylib | Conditional CP allocation, transposition and cache solving | Live branch-graph solver trace |
| AppleNeuralEngine | Program and runtime integration around the compiler service | XPC service dependency |
| Espresso | One model translation route | Service 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.
.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.
Show the complete 34-function trace
CompileProcedureBuildLayerGraphPreProcessControlFlowGraphValidateControlFlowGraphOptimizeControlFlowGraphRunMirPrepareIrRunFindInherentParallelismRunMirBuilderRunTaskSchedulerValidateMirInfoRunCPAllocatorMirPrepareControlFlowValidateMirInfoRunRegisterSpillingRunMultiSegmentSpillingRunHazardAnalysisRunRemoteDependencyAnalysisRunPieceGenerationRunMemCacheAllocationUpdateFinalKernelSHASetBinaryPointSetSplitRowComputeDumpDebugProfilingInfoSetTDExecutionTimeRunCachePrefetchRunContextSwitchRunKernelBufferControlRunComputeAddressTranslationRegistersBuildComputeProgramQualifyOnImbalanceRatioDumpLayerStatsRunHandleMultiAneSynchronizationRunCodeGenObjectGenSetLiveIOAttributes
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.
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,ZinIrOptDCEEWSquareDetection,VarianceCalculationDetection,NormalizationDetectionPerformReplaceMatmulWithConv,PerformTransposeMatmultCollapseBroadcast,CollapseTranspose,CollapseQuantDequantTensorPackingOptimizer,WidthConcatCanonicalizer,ReverseCSE
Zin MIR:
ZinMirLayerFusion::Run,ZinMirLayerHoisting::ExecuteZinMirSplitSpatially,ExecuteBondedSplitModeZinMirTensorDimensionLegalizer,ZinMirLatencyLegalizer,ZinMirL2LegalizerZinMirSetActiveNE,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.
ZinMirPrepareLayers, the passes between them, and each round's pattern registry.Show the observed pass order around both fusion rounds
+1320·ZinMirLayerHoisting::Execute+1368·CollapseQuantDequant+1476·ScaledEWOrEWWithConstInToGOCFusion+1524·CollapseTranspose+1852·PreFusionCSE+1928·ReverseCSE+2160 / +2176· construct mode-0 fusion, then run it+2352·OptimizeToChannelLast+2500·ZinMirGOCEngineReassignment::Execute+2652· remove 8-bit-to-fp16 copy+2724 / +2756· fully connected optimization, then CSE+2904· insert transpose for PE reduction+3092 / +3140 / +3192· SSM and reshape cleanup+3328 / +3392 / +3472· dilated-conv optimization, dynamic-conv lowering, matmul kernel padding+3672· forward engine lowering+3736 / +3828·after_engine_lowering, then wrap-axis validation+3900 / +3916· construct mode-1 fusion, then run it+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.
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.
| Probe | Main broad-round commits | Narrow-round commit |
|---|---|---|
| Conv + ReLU | Conv ×1; one ZinNEConvLayer inserted | None |
| Attention | MatMul ×2, Bypass ×3, UnaryElementWise ×2, ElementWise ×2 | None |
| Attention + residual | Attention commits plus ElementWise ×1 | None |
| Four-layer W8A8 conv chain | Conv ×4; explicit Q/DQ edges absent before this round | None |
| Affine, fan-out, residual, double activation and layout probes | Pattern-dependent first-round rewrites | None |
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.
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.
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.
Each box groups the trace stages named inside it. Ranges list every controller that fired in that span, in order.
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 key | Generated compiler option | Observed result |
|---|---|---|
TargetArchitecture = H16G | -t H16G | Explicit M4 target |
CompileANEProgramForDebugging | --debug | Larger debug HWX and debug logging |
DebugMask = 0xffffffff | --debug_mask=0xffffffff | Reaches gated checkpoints; graph writer is stubbed |
DumpStatusDictionaryToFile | --fdump-status-dictionary-to-file | Retained status plist |
DumpParallelScore | --dump-parallel-score=true | Retained partition JSON |
DisableOptimizations | --O0 | Changed fused TD register image |
OptLvlOne | --O1 | Matched 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.
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.
Supported operations
| Family | Measured H16G coverage |
|---|---|
| Conv1x1 | 16 FP16 channel and spatial geometries, with optional ReLU |
| Regular convolution | C64 and C128, S32 and S64, K3 and K5 |
| Depthwise convolution | C64, C128, C256, and C512 at S64 and K3 |
| Matmul | Square N128, N256, and N512; tiled multiples of 128 through N4096 |
| Binary ALU | Add at N128 through N2048; multiply at N128 and N512; max and min at N512 |
| Unary and LUT | ReLU, sigmoid, tanh, GELU, SiLU, exp, log, sqrt, rsqrt, and reciprocal |
| Reduction | Sum, mean, and max over measured channel, height, and width axes |
| Layout | S2D and D2S at B4 and B8, including 64-byte physical row padding |
| Fused layout | S2D, Conv1x1, and D2S at C8, C16, C24, and C32 |
| W8A8 | Four-layer C64/S64 Conv1x1 chain with packed middle blocks |
| Multi-operation graphs | Attention, 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.
| Graph | HWX programs | Numerical result | Research latency | Apple compiler |
|---|---|---|---|---|
| FA2, FP16, S128/D128 | 3 | 16,384 values; zero mismatches; maximum error 0 | 369.458 us | 127.208 us |
| Matmul, reshape, GELU, N256 | 1 | maximum error 0.00610352 | 66.94 us | 76.60 us |
| Four-step FP16 affine scan, N128 | 8 | every stage within 1 FP16 ULP; final output within 3 ULP | not measured | InvalidMILProgram |
| Chunked DeltaNet, C128/D128 | 58 | output relative L2 0.004214; final-state relative L2 0.004443 | 22728.792 us | InvalidMILProgram |
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.
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.
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
- Manjeet Singh. Inside the M4 Apple Neural Engine, Parts 1–4. maderix.github.io/articles, 2026.
- Spencer H. Bryngelson. Apple Neural Engine: Architecture, Programming, and Performance. arXiv:2606.22283, June 2026. arxiv.org/abs/2606.22283.
- 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.