Compilers, Graphs and Runtimes

Part 4 of How to Make Your Model Fast (Part 3: Kernels | Part 5: Quantisation)

The layer between your model and the kernels that actually run is a compiler, and it decides most of what you measure. This chapter builds that layer up: how a graph gets captured out of Python, what an IR stack is and how lowering works as a sequence of rewrites, the passes that buy the speed, and why memory planning decides whether your model fits on the device at all. It then covers autotuning, runtime delegates and subgraph partitioning, and the operational hazards of shipping compiled artefacts. You should finish able to read a compiler's own diagnostics and work out why a pipeline meant to make your model faster made it slower.

Part 3 ended with a single kernel running near the roofline, which is almost never where a real deployment sits, because a model is not one kernel. It is a few hundred operations wired into a graph, and the thing deciding which kernels run, in what order, over what memory, in what layout, is the compiler.

So: what happens between model.forward() and the instruction stream, and which steps can I influence? My day job at Arm sits on exactly this boundary, ML compilers and libraries for Arm architectures, and the honest summary is that most of the surprising performance in a deployment, good and bad, comes from compiler passes nobody looked at.

Why an ML Compiler Exists at All

The operator times backend explosion

The naive plan for making machine learning fast is to write a good library: someone writes the best convolution anyone has seen, everyone calls it, done. That worked in 2014 and stopped working for a reason you can count.

A modern core operator set is roughly 180 to 200 operations. PyTorch’s Core ATen opset, which torch.export targets after decomposition, sits in that range, and ONNX’s standard opset is similar.The full ATen surface is far larger, well over two thousand operator overloads once you count in-place variants, out variants and dtype specialisations. The point of a "core" opset is to be the small set everything else decomposes into, so a backend has a finite obligation. Now multiply.

The combinatorial obligation of a hand written kernel library, for one plausible product matrix:

Axis Count Examples
Operators 180 conv2d, matmul, softmax, reduce, gather, resize
Instruction set targets 6 AVX2, AVX-512, NEON, SVE2, a GPU shader ISA, an NPU command stream
Data types 4 fp32, fp16, bf16, int8
Memory layouts 3 NCHW, NHWC, blocked NC8HW8
Total kernel variants 12,960  

At a generous two engineer-days per variant including tests and a benchmark, that is 25,920 engineer-days, about 104 engineer-years. And that is before shapes: a GEMM fast at \(M=1\) is not the code that is fast at \(M=512\).

Libraries respond by cheating, sensibly: cover the twenty operators that matter with enormous care, give everything else a reference implementation. That is why your model runs at 90% of peak until you use one unusual operator, then falls off a cliff. The cliff is the library’s budget being honest with you.

What the compiler actually promises

A compiler attacks the multiplication rather than the cells. Instead of 12,960 kernels you write a decomposition into a few dozen structured primitives, a lowering into loop nests, a set of loop transformations (tile, vectorise, unroll, fuse), and one code generator per instruction set: cost \(O + P + T + B\) instead of \(O \times B \times D \times L\). You pay in quality, because generated code is usually worse than a hand written kernel where someone bothered to write one.Which is why production stacks are hybrids. On Arm a graph compiler does the graph-level work and dispatches GEMM and convolution into Arm Compute Library or similar, generating code itself only for the elementwise and reduction tail.

Takeaway: A kernel library’s cost grows as the product of operators, targets, data types and layouts; a compiler’s cost grows as their sum. That is why every serious deployment stack has a compiler in it, whether or not it calls itself one.

Capturing the Graph

Before a compiler can optimise anything it needs a graph, and your model is not a graph. It is Python.

Tracing, scripting and export

Three ways to turn Python into a graph, and what each one loses:

Strategy How it works Control flow How it fails Status
Tracing Run on an example input, record the tensor operations Lost, resolved to the branch that ran Silently, with shapes and branches baked in The fallback everywhere
Scripting Parse the source, compile a typed Python subset Preserved Loudly, on Python outside the subset TorchScript, maintenance mode
Export Bytecode analysis into one FX graph plus guards Preserved via higher order operators Loudly, raising on a graph break torch.export, current answer

Tracing is robust because it only ever sees real tensors, and lossy for the same reason: any Python that is not a tensor operation simply vanishes. torch.export instead builds one FX graph in Core ATen IR, plus a signature separating parameters, buffers and user inputs, plus guards recording its assumptions.The critical difference from torch.compile is behaviour on a graph break. torch.compile may give up on a region and run it in eager Python, fine for training throughput and useless on a device with no interpreter. torch.export raises instead, which is annoying and correct. The graph is a flat list of nodes, each with one of six opcodes:

# opcode         name     target                   args
placeholder      x        x                        ()
get_attr         w        linear.weight            ()
call_function    mm       aten.mm.default          (x, w_t)
call_function    add      aten.add.Tensor          (mm, b)
call_function    relu     aten.relu.default        (add,)
output           output   output                   ((relu,),)

Static single assignment, explicit data flow, no hidden state. Everything downstream works on structures of this shape.

ONNX and its sharp edges

ONNX lets a model from one framework run in a runtime written by someone else: a protobuf holding a graph, its initialisers and a set of opset versions. Genuinely useful, with failure modes that will cost you a week if nobody warns you.

The four ONNX problems I hit most often, and what each looks like from the outside:

Problem Symptom Usual fix
Opset drift “Unsupported opset 21”, or an operator silently decomposed into eight primitives Pin the export opset to the minimum your runtime supports, then check the decomposition
Operators that do not round trip Export fails on grid_sample or NonMaxSuppression, or succeeds and runs 5x slower Rewrite the module, or register a custom symbolic plus a matching runtime kernel
Shape inference failure Dimensions print as unk__42; the backend refuses the subgraph Feed static shapes, or run symbolic shape inference and freeze what you can
Version confusion ir_version and opset_version are different numbers and people mix them up Record both in the artefact manifest

Opset drift is the format working as designed. LayerNormalization only became a standard ONNX operator at opset 17, so export a transformer at opset 16 and you get a faithful decomposition: ReduceMean, Sub, Pow, ReduceMean, Add, Sqrt, Div, Mul, Add. Nine nodes and eight round trips to memory, while the backend’s fused kernel sits unused because its matcher expects the one-node form.Every serious ONNX consumer therefore ships pattern matchers that reassemble decomposed layer norms, GELUs and attention blocks. They are brittle: swap two commutative operands in the exporter and the pattern stops matching, with no error, only a slowdown. The model is correct, just slow, and the slowness is invisible in the diff.

Shape inference failure quietly destroys partitioning. A Reshape whose shape argument is computed from another tensor cannot be resolved statically, so output dimensions become unknown and propagate that uncertainty through every downstream node. Backends requiring static shapes then decline all of them.

Dynamic control flow

A graph is a directed acyclic graph of tensor operations. A Python if whose condition depends on tensor data is not that, and neither is a while whose trip count depends on a decoded token. Three honest ways out:

  1. Specialise. Trace with a fixed condition and ship a graph valid only for that condition, with a guard. Fast, and a correctness landmine if the guard is missing.
  2. Represent the control flow. Use higher order operators the IR understands: torch.cond and torch.while_loop, or ONNX If, Loop and Scan. The branches become subgraphs.
  3. Split the model. Keep the control flow on the host and compile the branches separately.

Option two is principled and costs something real: most accelerator backends cannot take a subgraph containing an If, so the partitioner cuts there. One data-dependent branch mid-network turns one delegated subgraph into three, and you pay the fallback tax described below.

import torch
from torch import cond

class Gate(torch.nn.Module):
    def forward(self, x):
        # Data-dependent branch: tracing bakes in whichever side ran,
        # and torch.export raises rather than guessing.
        #   if x.sum() > 0: return x * 2
        #   return x - 1
        return cond(x.sum() > 0, lambda t: t * 2, lambda t: t - 1, (x,))

ep = torch.export.export(Gate(), (torch.ones(4),))

The IR Stack and How Lowering Works

Four levels of abstraction

Compilers descend through levels, and at each level “what is a value?” has a different answer. An optimisation belongs at the highest level where it is still expressible.

The four levels every ML compiler passes through, whatever it calls them:

Level A value is An operation is What you optimise here Examples
Graph IR A whole tensor, typed by shape and dtype conv2d, softmax, matmul Fusion, layout, memory planning, folding StableHLO, ONNX, ATen/FX, TOSA
Tensor IR A tensor plus an iteration space A contraction with explicit index maps Tiling, loop fusion, vectorisation strategy Linalg, TVM Tensor Expression
Loop IR One element at one index A load, a multiply, a store Unrolling, software pipelining scf plus arith, TVM TIR
Machine code A register or a vector lane An instruction Instruction selection and scheduling LLVM IR, then NEON or SVE

Dialects, StableHLO and Linalg

MLIR made the levels idea practical. Its one big idea is the dialect: a namespaced set of operations, types and attributes that coexists with every other dialect in the same module, so stablehlo.dot_general, linalg.matmul, scf.for and llvm.fmul can all appear in one IR, sometimes in one function mid-conversion.

StableHLO is a portable, versioned operation set derived from XLA’s HLO, with explicit compatibility guarantees. Graph level: whole tensors, no loops. Its value is as a stable contract between frameworks and backends.

Linalg is the structured tensor level. Each operation carries indexing maps (affine maps from the iteration space into each operand) and iterator types (parallel, reduction). That metadata enables generic transformations: a pass can tile, fuse or vectorise any Linalg operation without knowing whether it is a convolution or a layer norm, because the maps say which loops are independent.

Lowering is therefore a sequence of small, locally verifiable rewrites, not one translation. A pass that cannot handle something leaves it alone for a later pass, which is why a half-lowered module shows two dialects side by side.

One lowering step in detail

A linear layer with bias and ReLU, at graph level:

// Graph level: three whole-tensor operations, three result tensors.
%0 = stablehlo.dot_general %x, %w
       : (tensor<1x256xf32>, tensor<256x128xf32>) -> tensor<1x128xf32>
%1 = stablehlo.add %0, %bias_bcast : tensor<1x128xf32>
%2 = stablehlo.maximum %1, %zeros  : tensor<1x128xf32>

Lowering to the structured level does two things at once: it gives the contraction an explicit iteration space, and it lets the two elementwise operations collapse into one, because they now share that space.

// Structured level: one contraction, one fused elementwise producer.
%acc = linalg.fill ins(%c0 : f32) outs(%init : tensor<1x128xf32>)
%mm  = linalg.matmul
         ins(%x, %w : tensor<1x256xf32>, tensor<256x128xf32>)
         outs(%acc  : tensor<1x128xf32>) -> tensor<1x128xf32>
%out = linalg.generic {
         indexing_maps = [affine_map<(m, n) -> (m, n)>,
                          affine_map<(m, n) -> (n)>,
                          affine_map<(m, n) -> (m, n)>],
         iterator_types = ["parallel", "parallel"]}
       ins(%mm, %bias : tensor<1x128xf32>, tensor<128xf32>)
       outs(%init : tensor<1x128xf32>) {
       ^bb0(%a: f32, %b: f32, %o: f32):
         %s = arith.addf %a, %b : f32
         %r = arith.maximumf %s, %c0 : f32
         linalg.yield %r : f32
       } -> tensor<1x128xf32>

Read the indexing_maps: the bias is indexed by n alone, which is precisely the broadcast, expressed as an affine map rather than a materialised tensor. %1 and %2 no longer exist as separate tensors.

The next step tiles linalg.matmul into scf.for loops and fuses %out into the innermost tile, so bias and ReLU apply while the accumulator is still in registers. That is the fusion that saves the memory traffic, and it is only expressible because the earlier step attached iteration spaces to everything.

The Passes That Buy the Speed

Folding, elimination and algebra

Constant folding evaluates anything whose inputs are all constant. The headline case is batch normalisation: at inference it is an affine function with constant parameters, so it folds into the preceding convolution’s weights.

\[W' = \frac{\gamma \, W}{\sqrt{\sigma^2 + \epsilon}}, \qquad b' = \beta + \frac{\gamma \, (b - \mu)}{\sqrt{\sigma^2 + \epsilon}}\]

Fifty convolutions in a ResNet means fifty whole-tensor elementwise passes deleted at zero accuracy cost.Not quite zero numerical cost: folding changes operation order, so outputs differ in the last mantissa bits. Harmless in fp32, occasionally not in fp16, where the folded weight overflows if gamma over sigma is large. A good compiler declines the fold if the folded range would saturate.

Dead code elimination removes nodes nobody reads, and exported graphs are full of them: auxiliary training heads, unused masks, the second element of a tuple you never indexed. Common subexpression elimination merges identical computations on identical inputs, which matters because attention implementations routinely build the same positional bias twice, once per call site.

Algebraic simplification rewrites into cheaper equivalents: x * 1 and x + 0 disappear, transpose(transpose(x)) becomes x, two stacked reshapes become one, division by a constant becomes multiplication by its reciprocal. These run repeatedly, because each other pass creates fresh debris for them to clear.

Fusion, vertical and horizontal

Fusion is the highest-value graph pass, and the reason is bandwidth, not arithmetic.

Vertical fusion merges a producer into its consumer. Take conv -> bias -> relu with a \(1 \times 64 \times 56 \times 56\) fp32 activation: 200,704 elements, 802,816 bytes, call it 0.80 MB.

On an illustrative device with 20 GB/s of bandwidth, 3.21 MB costs \(3.21 \times 10^6 / (20 \times 10^9) = 161\ \mu\text{s}\). The convolution is \(3 \times 3 \times 64 \times 64 \times 56 \times 56 = 115.6\) million MACs, 231 MFLOP, which at an assumed 200 GFLOP/s takes 1.16 ms. Fusion removes 14% of that layer’s time, fifty times over.

The mechanism is arithmetic intensity, from Part 1: a standalone ReLU reads 4 bytes, writes 4 bytes and does one comparison, 0.125 FLOP/byte, memory bound on any hardware ever built. Fusing it into a producer does not make it faster, it makes it free.

Horizontal fusion merges siblings that share an input. Attention’s three projections are the canonical case: instead of three GEMMs of \([128 \times 768] \times [768 \times 768]\) each re-reading the same 393 KB activation, concatenate the weights into \([768 \times 2304]\) and run one GEMM, reading the activation once, saving 0.79 MB, and paying one launch instead of three.

Layout, propagation and scheduling

Every backend has a preferred layout. Arm NEON and SVE want NHWC for convolution, because the channel dimension is then contiguous and vectorises cleanly, and int8 dot product instructions want four channels adjacent. Some GPU paths want NCHW; blocked layouts like NC8HW8 interleave a channel block to match a vector width exactly.

When two adjacent operations disagree the compiler inserts a transpose, which is pure overhead: the 0.80 MB tensor above costs a read and a write, 1.61 MB, 80 µs at 20 GB/s. Once per layer across 50 layers is 4 ms of doing nothing.

Layout assignment is therefore global. The compiler labels every node so total transpose cost is minimised, then propagates labels so runs of compatible operations share a layout and conversions get pushed to the graph boundary, ideally into preprocessing where they are free. This is the pass I have seen produce the largest unexplained regressions: one operator with only an NCHW implementation forces two transposes per layer around a whole region.

Scheduling picks execution order, which for a branching graph changes which tensors are simultaneously live and whether a weight prefetch overlaps compute. It and memory planning are one problem seen from two sides, so a new scheduling heuristic can move peak memory by 40% without changing a kernel.

Takeaway: Fusion, layout and scheduling have the largest effect of any graph passes, and all three are about memory traffic, not arithmetic. A pass that is not reducing bytes moved or kernel launches will probably not save you.

Memory Planning and Why Peak Is What Kills You

Liveness analysis over the graph

On a server you allocate from a caching allocator and stop thinking. On a part with 8 MiB of usable SRAM there is no virtual memory, no swap and no out-of-memory killer: the allocation either fits at initialisation or the product does not work. What matters is peak memory, not total. Fix a topological order; a tensor is then live from when its producer starts writing to when its last consumer finishes reading, and two tensors may share bytes exactly when their intervals do not overlap.

Tensors in an illustrative seven-operation backbone slice with a long skip, int8 activations, batch 1:

Tensor Shape (C, H, W) Size (KiB) Produced by Last consumer Live interval
t0 3, 512, 512 768 input op1 [0, 1]
t1 32, 256, 256 2048 op1 op6 [1, 6]
t2 64, 128, 128 1024 op2 op4 [2, 4]
t3 64, 128, 128 1024 op3 op4 [3, 4]
t4 64, 128, 128 1024 op4 (add) op5 [4, 5]
t5 32, 256, 256 2048 op5 (upsample) op6 [5, 6]
t6 64, 256, 256 4096 op6 (concat) op7 [6, 7]
t7 16, 256, 256 1024 op7 output [7, end]

Give every tensor its own buffer and you need 13,056 KiB, 12.75 MiB, which does not fit.

Live bytes at each step of the execution order, the sum that peak memory actually measures:

Operation Live tensors Live bytes (KiB)
op1 t0, t1 2816
op2 t1, t2 3072
op3 t1, t2, t3 4096
op4 t1, t2, t3, t4 5120
op5 t1, t4, t5 5120
op6 t1, t5, t6 8192
op7 t6, t7 5120

Peak is 8192 KiB, exactly 8 MiB. Liveness analysis has recovered 4.75 MiB without touching a kernel.

Greedy allocation into an arena

Knowing the peak is not achieving it: you must assign each tensor a byte offset in one arena so tensors overlapping in time never overlap in space. That is the dynamic storage allocation problem, equivalent to two-dimensional strip packing and NP-hard, so planners go greedy: sort by size descending, place each at the lowest offset colliding with no already-placed tensor whose interval overlaps.TFLite's arena planner, ExecuTorch's memory planning passes and TVM's storage rewrite all use variants of this. Size descending is usual because large tensors are what fragment the arena, so placing them first keeps the holes fillable by small ones.

Greedy offset assignment, largest tensor placed first, for the tensors above:

Order Tensor Size Offset Occupies
1 t6 4096 0 [0, 4096)
2 t1 2048 4096 [4096, 6144)
3 t5 2048 6144 [6144, 8192)
4 t2 1024 0 [0, 1024)
5 t3 1024 1024 [1024, 2048)
6 t4 1024 2048 [2048, 3072)
7 t7 1024 4096 [4096, 5120)
8 t0 768 0 [0, 768)

t2 sits at offset 0 because it dies at op4 and t6 is not born until op6; t7 reuses t1’s bytes for the same reason. The arena is 8192 KiB, equal to the peak, so this plan has zero fragmentation. That is common but not guaranteed: greedy plans routinely land 5 to 15% above the peak, which is always a lower bound.

Eight megabytes and what to do when you miss

The plan needs exactly 8 MiB and the device has 8 MiB, so it does not fit: weights, runtime state and DMA scratch also have to live somewhere. You have missed by a little and the product does not ship. Ordinary Tuesday.

The largest line is t6, and a channel-axis concatenation need not copy anything. If the planner places t1 and t5 adjacently in the right order, t6 is a view over those same 4096 KiB and op6 becomes a no-op. This is concat elision, and every mature edge compiler does it. Op6’s live set is then 4096 KiB and op7’s is 5120: new peak 5 MiB, three megabytes recovered by deleting an operation rather than optimising one.

The escape ladder, in the order I try it:

  1. Elide copies: concat, split, slice and reshape can often be views.
  2. Reorder the schedule so large tensors are born later and die sooner.
  3. Recompute rather than store, when recomputation is cheap against the bandwidth saved.
  4. Tile the graph: push a spatial strip through several layers at once, so intermediates are tiles rather than whole feature maps. This is what fits a 512x512 input in a few hundred KB.
  5. Quantise the activations, which quarters everything at once (Part 5).
  6. Spill the largest buffers to external DRAM, at perhaps a 10x bandwidth penalty.

Takeaway: Peak memory, not total, decides whether a model fits. Liveness plus greedy arena allocation typically recovers 2 to 3x over naive allocation, and deleting copies, especially concatenations, is usually worth more than any allocator cleverness.

Autotuning, Cost Models and the Compile Budget

Once the graph is settled, each kernel still needs a schedule: tile sizes, loop order, unroll factors, vector widths, whether to stage a tile in shared memory. AutoTVM’s model was a hand written template with knobs, searched by measuring on the real device.

Count the space for a \(1024^3\) GEMM with M, N and K each split into three levels. Since \(1024 = 2^{10}\), the ordered factor triples per axis number \(\binom{12}{2} = 66\), so three axes give \(66^3 = 287{,}496\). Times six unroll settings and two vectorisation settings: about 3.4 million configurations for one operator at one shape. At 100 ms per trial, realistic once you include compilation, transfer and timed repetitions, exhaustive search is 344,995 s, or 96 hours, on one target.

So nobody searches exhaustively. The cost model is a learned regressor over schedule features: loop extents, estimated cache line accesses, vector width, arithmetic intensity, unrolled instruction count. It does not need accurate runtimes, only correct ranking, since its job is to choose the next batch to measure, so AutoTVM measures 500 to 2,000 configurations instead of millions.

Ansor removed the templates. It generates sketches from the mathematical definition of the computation, annotates them randomly, and searches by evolutionary mutation under a learned cost model. It also adds a task scheduler spending a global budget across subgraphs by estimated end-to-end gain, which is the right framing: tuning an operator worth 2% of runtime to perfection wastes the budget.

Tuning budget for one network on one target, at an assumed 0.3 s amortised per measured trial:

Quantity Value
Distinct (operator, shape, dtype) tasks 40
Trials per task 1,000
Total trials 40,000
Wall clock at 0.3 s per trial 12,000 s = 3.3 hours
Six target configurations 20 hours

Three hours fits overnight; twenty hours does not fit a per-commit CI job. Hence the central operational fact: autotuning is a build artefact, not a build step. Tune once, cache.

The cache is a tuning log keyed by operator, full shape and dtype signature, and a target string such as llvm -mtriple=aarch64-linux-gnu -mattr=+neon,+dotprod. A hit gives the tuned schedule; a miss silently gives a fallback, commonly 2 to 10x slower. Here is the trap I have walked into personally. Add +sve because you are exploring a new part. Change a sequence length from 128 to 127. Bump the compiler and have it normalise the target string differently. Each is a total cache miss, each produces a correct model, and each appears as an overnight latency regression with no code change to blame.The defence is cheap: make a cache miss loud. Fail the build, or at minimum warn per missed operator with the key it looked for, and assert the hit rate in CI. A silent fallback is the worst default: the failure is invisible in every signal except the benchmark.

Takeaway: Autotuning is search under a wall-clock budget, so cost models exist to avoid measurement and caches exist to avoid search. Treat a tuned schedule cache as a versioned build artefact, and make misses fail loudly rather than degrade quietly.

Runtimes, Delegates and Subgraph Partitioning

A compiled graph still needs something to execute it, allocate its arena and talk to the driver. Every runtime has the same core abstraction under a different name.

The same idea, five vocabularies:

Stack Backend abstraction Examples Shipped artefact
TFLite Delegate XNNPACK, GPU, NNAPI, Core ML, Hexagon .tflite flatbuffer, delegate chosen at load
ExecuTorch Backend plus partitioner XNNPACK, Core ML, Vulkan, QNN, Arm TOSA/Ethos-U .pte with pre-lowered backend blobs
ONNX Runtime Execution provider CPU, CUDA, TensorRT, OpenVINO, CoreML, QNN, ArmNN .onnx plus an EP priority list
TensorRT Engine builder NVIDIA GPUs only Serialised engine, locked to version and architecture
ArmNN Backend CpuRef, CpuAcc (Compute Library), GpuAcc (Mali), Ethos-N Optimised graph built at load time

The mechanism is subgraph partitioning. The runtime asks each backend, in priority order, which nodes it can execute. Support is per-attribute, not per-operator-name: a backend refuses a node because the dilation is unusual, the dtype is fp16 rather than int8, or an axis is dynamic. It then forms maximal connected subgraphs of supported nodes, replaces each with one “call this backend” node, and leaves the rest to the CPU. Most partitioners also enforce a minimum subgraph size, since delegating two isolated nodes costs more in handoff than it saves.

The fallback tax

Take a 120 node graph, 108 supported, 8 ms of compute. Each delegated partition is entered and exited once, and each of those boundaries costs a synchronisation plus, usually, a copy of the boundary tensor out of the backend’s memory space and back. For a 1 MiB tensor at an assumed 8 GB/s one copy is 131 µs, so a round trip plus roughly 50 µs of synchronisation is about 0.31 ms per boundary.

The same 108 supported nodes, arranged two ways:

Arrangement Delegated partitions Boundaries Boundary cost Total
12 unsupported nodes contiguous at the tail 1 2 0.62 ms 8.6 ms
12 unsupported nodes scattered singly 13 26 8.06 ms 16.1 ms

Identical model, identical hardware, identical supported-operator set, 1.9x difference in latency decided by where the unsupported nodes sit, and the second case is worse than the table shows because fusion cannot cross a boundary either. The pathological version is one unsupported node inside a residual block, splitting the network in half.

I have spent more time chasing single unsupported operators than almost any other category of performance bug, and the fix is nearly always: implement it in the backend, rewrite the model to use supported operators, or accept CPU for the whole thing. Read the partitioning report first. TFLite logs how many nodes were delegated and in how many partitions, ONNX Runtime dumps node placement per execution provider, and ExecuTorch prints a partition summary. One line saying “13 partitions” is worth a day of profiling.

Ahead of time, just in time, and the version trap

Ahead of time against just in time, for an edge target:

Property Ahead of time Just in time
Compiles on The build machine The device, at first use
Compiler on device Not needed Needed, plus a writable cache
Shapes Must be known at build time Seen at runtime, can specialise
Startup cost None Seconds to minutes for an engine build
Artefacts One per target variant One covering many targets
Typical home TFLite Micro, ExecuTorch on an MCU TensorRT, ORT with a TensorRT provider

AOT is the only option when the device has no room for a compiler, which covers most of embedded: a microcontroller runs a pre-planned arena and a fixed kernel list, and the memory plan is inspectable at build time. The practical middle ground is AOT for the graph plus a warm-up and an on-disk cache for anything JIT, so the first real request is never the first compile.

Serialisation makes this an operations problem. A serialised TensorRT engine is tied to the TensorRT version, the CUDA version and the compute capability it was built for, often the SKU. Move it and deserialisation fails, or silently rebuilds and your measured cold start becomes two minutes. The mobile equivalent is a delegate whose behaviour depends on a vendor driver that changes with an OTA update you do not control.

Pin all five of these, and record them in the artefact manifest:

Component Why it moves latency or numerics
Exporter version Changes the decomposition, which changes what pattern matchers find
Opset version Adds or changes operators; determines fused versus decomposed forms
Compiler version Changes fusion, layout and scheduling heuristics
Runtime version Changes partitioning rules and which operators a backend claims
Driver or firmware Changes the accelerator’s behaviour underneath all of the above

CI should assert both a latency bound and an output hash against a golden. Without the hash you discover, three weeks late, that a minor version bump changed a fusion decision, which changed the accumulation order, which moved an accuracy metric by half a point.Bit-exactness across compiler versions is not reasonable to demand, since fusion legitimately changes accumulation order. What is reasonable is a tolerance plus an accuracy check on a held-out set, run on every dependency bump: the goal is not to prevent change but to notice it.

Takeaway: The runtime gives each backend the subgraphs it claims and runs the rest on CPU, so latency depends less on how many operators are supported than on how the unsupported ones are distributed. Check the partition count before you profile anything.

When the Compiler Makes It Slower

This happens often enough that I keep a checklist. Work it in order; the early items catch most of it.

  1. Check the measurement. Same shapes, same threads, same CPU governor, enough iterations, first run discarded.
  2. Confirm it compiled at all. Stacks fall back to a reference path silently, and zero delegated nodes is the most common cause of all.
  3. Count the partitions. One is good, thirteen is a disaster. Find the unsupported operator behind each split.
  4. Diff the final graph against the input. Look for what the compiler added: transposes, quantise and dequantise pairs, copies.
  5. Check the layout. An NCHW pipeline feeding an NHWC backend costs a transpose per inference or, worse, per layer.
  6. Check the schedule cache. Confirm the target string and shape signature match a tuning log entry exactly.
  7. Check for dynamic shapes. One dynamic dimension disables tiling, vectorisation and static memory planning at once; compile a fixed shape as a control.
  8. Compare arena size to your memory hierarchy. Past SRAM or a cache level, you traded arithmetic for bandwidth and lost.
  9. Check for numerics-driven refusals. A strict-precision or deterministic flag disables fast paths and fusions, quietly.
  10. Profile the kernels, not the graph, since graph totals hide the one kernel taking 80% of the time: Part 3 for the kernel, Part 10 for the method.
  11. Only then file a bug, with the smallest graph that reproduces it. A two-node reproducer gets fixed; a whole model does not.

A Few Problems to Work

Problem 1. An inception-style block takes an input x of 1024 KiB, feeds it to four sequential branches each producing 512 KiB, then concatenates the four into a 2048 KiB result. x is live until the last branch consumes it. Compute peak live memory with a materialised concatenation, then with concat elision, and give the percentage saving.

Click here for the answer.

Materialised concatenation, walking the timeline:

  • Branch 1: x (1024) + c1 (512) = 1536 KiB
  • Branch 2: 1024 + 1024 = 2048 KiB
  • Branch 3: 1024 + 1536 = 2560 KiB
  • Branch 4: 1024 + 2048 = 3072 KiB, and x dies here
  • Concat: c1..c4 (2048) + output (2048) = 4096 KiB

With concat elision the four outputs are written into four adjacent 512 KiB slices of one 2048 KiB buffer, so the concatenation node disappears. The worst moment is now branch 4: x (1024) plus the shared output buffer (2048) = 3072 KiB.

The saving is \((4096 - 3072)/4096 = 25\%\), plus a deleted 2048 KiB copy, 105 µs at 20 GB/s.

Problem 2. A 200 node graph runs in 8 ms fully delegated. Your backend supports 180 nodes and each partition boundary costs 0.31 ms. First, compute latency if the 20 unsupported nodes are contiguous, and if they scatter into 20 delegated partitions. Second, if the CPU-only baseline is 22 ms, at how many delegated partitions does delegation stop paying?

Click here for the answer.

Contiguous. The 180 supported nodes form one partition, and a partition in the middle of a graph has two boundaries, in and out:

\[8 + 2 \times 0.31 = 8.62\ \text{ms}\]

Scattered. Twenty partitions, each with entry and exit, is 40 boundaries:

\[8 + 40 \times 0.31 = 20.4\ \text{ms}\]

A 2.4x regression from partitioning alone, before counting lost cross-boundary fusion.

Break-even. With \(p\) partitions latency is \(8 + 0.62p\), so \(0.62p = 14\) and \(p = 22.6\): at 23 or more you are slower than not delegating at all. Note how close 20 already is, beating plain CPU by 7% for all the complexity of an accelerator, which is why forcing a fragmented graph entirely onto CPU is sometimes the correct optimisation.

Problem 3. You are tuning a network with 40 distinct operator shapes across 5 target configurations, at 0.3 s per measured trial and 1,200 guided trials per task. Does the tuning run fit a 12 hour overnight window, and if a target string change invalidates the cache for 30% of tasks on every target, what does each incident cost?

Click here for the answer.

Tuning run. \(1{,}200 \times 40 \times 5 = 240{,}000\) trials at 0.3 s = 72,000 s = 20 hours, which does not fit. Run the five targets in parallel on five device farms instead: \(1{,}200 \times 40 \times 0.3 = 14{,}400\) s = 4 hours each.

Recurring cost. 30% of 200 tasks is 60, so retuning costs \(60 \times 1{,}200 \times 0.3 = 21{,}600\) s = 6 hours per incident, assuming you notice. If you do not, 30% of your operators run fallback schedules at 2 to 10x their tuned time, which is far more expensive. Failing the build on a miss converts an invisible loss into a visible six hour job, which is the trade you want.

Problem 4. A 50 layer network runs on a backend that wants NHWC. Twelve layers have only NCHW kernels and are scattered, never adjacent. Each activation is 0.80 MB and bandwidth is 20 GB/s. How many transposes does layout assignment insert, what do they cost, and what changes if the twelve are contiguous instead?

Click here for the answer.

Each isolated NCHW layer needs a transpose in and a transpose out, so 12 islands give 24 transposes. A transpose reads and writes the tensor, 1.61 MB, at \(1.61 \times 10^6 / (20 \times 10^9) = 80\ \mu\text{s}\) each. Total \(24 \times 80 = 1.92\ \text{ms}\).

Contiguous, the twelve form one NCHW island with one transpose in and one out: \(2 \times 80 = 160\ \mu\text{s}\), a 12x reduction from reordering alone.

Same structure as the partitioning problem, and not a coincidence: both are the cost of switching execution context, and both are minimised by clustering the offenders rather than by removing them.

What’s Next

The compiler decides which kernels run, in what order, over which bytes. Almost every lever in this chapter, fusion, layout, memory planning, partitioning, is really a lever on bytes moved. The remaining way to move far fewer bytes is to make each one carry more information, by changing the numeric format itself. On most edge deployments that buys more than any other single change: an int8 model moves a quarter of the bytes of an fp32 one and runs on hardware paths that do not exist for floats at all.

That’s all for Part 4! For Part 5, on quantisation, click here.

Citation

For attribution in academic contexts, please cite this work as:

    Zaheer, "How to Make Your Model Fast", online, 2026.

or as a BibTeX entry:

    @book{make-your-model-fast,
      title = {How to Make Your Model Fast: A Systems View of Efficient Machine Learning, from Silicon to Agents},
      author = {Zaheer, Usamah},
      howpublished = {Online},
      note = {Retrieved from https://ai.usamah.me},
      year = {2026}
    }