Part 2 of How to Make Your Model Fast (Part 1: Rooflines | Part 3: Kernels)
Part 1 gave you a roofline; this chapter tells you where its numbers come from. We walk through the four kinds of silicon that run machine learning outside a datacentre, then go deep on Arm CPUs: NEON, SVE2, and the dot product and matrix instructions that made int8 inference worth doing. We build a memory hierarchy table, derive why every fast kernel is tiled, open up an NPU to look at its MAC array and its SRAM, and work out why shared memory is not free memory. By the end you should be able to take an unfamiliar datasheet and extract peak compute, peak bandwidth and the ridge point without trusting a single marketing number.
Part 1 gave you a model of a machine: a peak compute rate, a peak memory bandwidth, and a ridge point where the two cross. That model is only worth anything if you can fill in the numbers for a device you actually have to ship on. This chapter is about where those numbers come from, which means it is about silicon: what instructions the CPU really has, how the memory hierarchy is shaped, what an NPU is underneath its headline TOPS figure, and why the chip that gave you 28 ms per frame on Monday morning gives you 44 ms on Tuesday afternoon.
I work on this boundary for a living. At Arm I write and optimise ML libraries and compiler back ends, which mostly means staring at instruction schedules and cache behaviour until a kernel stops being stupid. Before that I spent two years at Dyson putting convolutional networks onto robot hardware, where the model that wins the accuracy bake-off and the model that survives a hot plastic enclosure are rarely the same one. Both jobs taught me that you can predict most of a deployment’s performance from a datasheet and an afternoon of arithmetic, and that almost nobody does it.
Open the block diagram of any phone, camera, car module or vacuum robot. You will find at most four kinds of compute that can usefully run a neural network, and usually three of them on a single die.
The four classes of edge compute, with order-of-magnitude envelopes. These are class-level ranges, not any specific product; read them as “what to expect before you look up the real part”.
| Class | Peak int8 | Sustained power | Strong at | Weak at |
|---|---|---|---|---|
| CPU vector units (one big-core cluster) | 0.2 to 3 Top/s | 1 to 5 W | any operator, any shape, low fixed overhead, easy to debug | energy per operation on large dense layers |
| Integrated GPU | 0.5 to 10 Top/s | 2 to 10 W | wide fp16 parallelism, image pre and post processing | per-dispatch overhead, contention with the UI |
| NPU (on-SoC) | 1 to 50 Top/s | 0.5 to 5 W | static int8 graphs of supported operators | unusual operators, dynamic shapes, tiny layers |
| Vector DSP | 0.2 to 4 Top/s | 0.2 to 2 W | always-on sensing, int8 and int16 with irregular access | floating point, very large models |
| Small discrete accelerator | 4 to 250 Top/s | 2 to 75 W | adding compute to a host you cannot change | host transfer latency, board cost, thermals |
The CPU is the only engine guaranteed to run your model: every operator, every shape, all your control flow, and a profiler you already know. Its vector units are genuinely fast, 64 multiply-accumulates per cycle on a big core with the int8 matrix extension. What it is bad at is energy: it pays for an out-of-order pipeline, branch prediction, address translation and coherent caches on every instruction, and a matrix multiply needs none of that. On a large dense convolution it is 5 to 20 times worse in operations per joule than a fixed-function MAC array on the same node.
The integrated GPU is a wide throughput machine that already exists on the die because something has to draw the screen. It is excellent at fp16 and at anything image-shaped: resize, colour conversion, warping, non-maximum suppression. Its weakness is fixed cost per dispatch: a kernel launch, a queue submission and a fence wait can cost 50 to 200 microseconds, so a graph of forty small operators spends longer in the driver than in arithmetic. It also shares bandwidth and power with whatever composites the user interface.
An NPU is a fixed-function matrix engine with its own local SRAM and command stream. It is the best operations-per-joule option on the die, often by an order of magnitude over the CPU, and that is the entire reason it exists. The price is rigidity, which gets a section of its own below.
A vector DSP sits between the two: a programmable core with a very wide SIMD unit, often 512 or 1024 bits, tuned for integer signal processing rather than floating point.
If you cannot change the host, you bolt an accelerator onto it: the M.2, USB and small PCIe category. A Google Coral Edge TPU is publicly specified at 4 Top/s int8 for roughly 2 W, and Jetson-class modules reach tens of Top/s in a 7 to 60 W envelope. Both are vendor-published peaks, reachable on a synthetic kernel with everything resident.
The trap is the link. A USB 3 accelerator that infers in 3 ms is not a 3 ms solution if moving a 640x640x3 tensor across the bus and waiting for the completion interrupt costs another 4 ms. Measure the round trip from frame in to result out, never the device-side kernel time.
Takeaway: the CPU always works, the GPU is wide but has expensive dispatches, the NPU has the best operations per joule and the least flexibility, and the DSP owns the always-on tier. Most products use two or three in one pipeline.
This is the part I know best, so we will go slowly and do the arithmetic properly.
NEON (formally Advanced SIMD) gives you 32 architectural registers of exactly 128 bits, addressed by lane layout: v0.16b is sixteen 8-bit lanes, v0.8h eight 16-bit lanes, v0.4s four 32-bit lanes. The width is fixed by the architecture, so a NEON binary compiled in 2013 runs unchanged on a 2026 core.
Fixed width costs you tails. With a reduction length of 1000 and 16 bytes per vector you do 62 full iterations and then a scalar loop for the last 8 elements: often a third of the source of a hand-written kernel, contributing nothing to throughput.
The Scalable Vector Extension takes the opposite approach. Vector length is an implementation choice, any multiple of 128 bits from 128 up to 2048, and the program does not know it at compile time. You never write “16 bytes”; you write svcntb(), which returns the byte count of a vector on whatever core you are running on.
Two features make this practical. Predication: 16 predicate registers let every load, store and arithmetic instruction be masked per lane, and the whilelt idiom builds a predicate true for lanes below the loop bound, so the last partial iteration runs the same instructions as every other one. Length-agnostic idioms: loop increments use INCB/CNTB rather than an immediate, and reductions like svaddv fold a whole vector whatever its width.
Here is a complete int8 dot product with no tail loop:
#include <arm_sve.h>
#include <stdint.h>
int32_t sve_dot_i8(const int8_t *a, const int8_t *b, int64_t n) {
svint32_t acc = svdup_s32(0);
int64_t i = 0;
svbool_t pg = svwhilelt_b8(i, n);
while (svptest_any(svptrue_b8(), pg)) {
svint8_t va = svld1_s8(pg, a + i); /* inactive lanes read as 0 */
svint8_t vb = svld1_s8(pg, b + i);
acc = svdot_s32(acc, va, vb); /* 4-way dot into int32 lanes */
i += svcntb(); /* vector length in bytes */
pg = svwhilelt_b8(i, n);
}
return svaddv_s32(svptrue_b32(), acc);
}
That is correct for n = 3 and for n = 3000000, on a 128-bit implementation and a 512-bit one, from a single binary.
SVE2 adds the integer and fixed-point operations DSP and media code needs, so it is a general replacement for NEON rather than an HPC extension. Length agnosticism matters because you ship one binary to a fleet containing several cores. Without it you compile N versions and dispatch at runtime, or compile for the narrowest implementation and waste half the throughput on the wide ones.
Before Armv8.2, int8 inference on an Arm CPU was awkward. With no int8 multiply-accumulate, the pattern was widen-multiply-accumulate: SMULL/SMLAL give 16-bit products from 8-bit inputs, and you widened again to 32 bits before the 16-bit accumulator overflowed. Much bookkeeping, and int8 was barely faster than fp32. The dot product instructions changed that.
SDOT Vd.4S, Vn.16B, Vm.16B treats each source as four groups of four signed bytes. For each of the four 32-bit destination lanes it multiplies the corresponding group element-wise and adds all four products into that lane’s existing value. Sixteen multiplies and sixteen additions in one instruction, accumulating straight into 32 bits so overflow is a non-issue. Take:
Vn.16B = [ 1, 2, 3, 4 | 5, 6, 7, 8 | -1, -2, -3, -4 | 2, 2, 2, 2]
Vm.16B = [ 1, 1, 1, 1 | 2, 0, 2, 0 | 3, 3, 3, 3 | -1, 1, -1, 1]
Vd.4S = [ 0, 0, 0, 0 ]
After SDOT Vd.4S, Vn.16B, Vm.16B:
so Vd.4S = [10, 24, -30, 0]: a length-4 reduction in each of four lanes.
SMMLA does a small matrix product instead. It reads Vn as a 2x8 int8 matrix, Vm as the transpose of a 2x8 (so, an 8x2), multiplies them and accumulates the 2x2 int32 result into Vd.4S. That is \(2 \times 2 \times 8 = 32\) multiply-accumulates per instruction, double SDOT, from the same 256 bits of input, because each input byte now feeds two products instead of one. In C you reach both through ACLE intrinsics:
#include <arm_neon.h>
int32x4_t acc = vdupq_n_s32(0);
int8x16_t a = vld1q_s8(pa), b = vld1q_s8(pb);
acc = vdotq_s32(acc, a, b); /* SDOT: 16 MACs, 4 independent lanes */
acc = vmmlaq_s32(acc, a, b); /* SMMLA: 32 MACs, a 2x2 int32 tile */
Multiply-accumulates performed by one 128-bit instruction, by data type. This is the most useful table on the page when estimating a CPU’s peak.
| Instruction | Input type | Accumulator | MACs per instruction |
|---|---|---|---|
FMLA v.4s |
fp32 | fp32 | 4 |
FMLA v.8h |
fp16 | fp16 | 8 |
BFDOT v.4s |
bf16 | fp32 | 8 |
BFMMLA v.4s |
bf16 | fp32 | 16 |
SMLAL/SMLAL2
|
int8 | int16 | 8 |
SDOT v.4s |
int8 | int32 | 16 |
SMMLA v.4s |
int8 | int32 | 32 |
Now the peak. Let a big core run at 2.0 GHz with two 128-bit SIMD pipes that can each issue SMMLA every cycle.
Four such cores give 1.02 Top/s of int8 before you touch the NPU. With SDOT you get half that; with the old SMLAL path about a quarter. This is why int8 quantisation (Part 5) and feature detection must be designed together: quantising buys you nothing if your runtime dispatches to a kernel written for a core from 2016.
Takeaway: one SDOT is 16 int8 MACs and one SMMLA is 32. Peak int8 per core is SIMD pipes times MACs per instruction times two times clock, which lands near 256 Gop/s on a modern big core at 2 GHz.
Having 64 MACs per cycle is meaningless if you cannot deliver operands at 64 MACs per cycle. Everything interesting about kernel engineering follows from that sentence.
Approximate latency, bandwidth and relative energy for each level of a typical Arm SoC memory hierarchy. These are orders of magnitude for a mid-range mobile-class part, not the specification of any product. Look yours up; use these to check that you looked it up correctly.
| Level | Typical capacity | Latency (core cycles) | Sustained bandwidth | Relative energy per byte |
|---|---|---|---|---|
| Vector register file | 512 B (32 x 16 B, NEON) | ~1 (forwarded) | 48 to 96 B/cycle | 1x |
| L1 data cache | 32 to 64 KB | 3 to 5 | ~32 B/cycle (2 x 16 B loads) | ~5x |
| L2 cache | 256 KB to 2 MB | 12 to 25 | 16 to 32 B/cycle | ~15x |
| System level cache / L3 | 2 to 16 MB | 30 to 80 | 8 to 16 B/cycle per core | ~40x |
| DRAM (LPDDR4X / LPDDR5) | 2 to 16 GB | 120 to 400 | 15 to 70 GB/s, shared by everything | ~200x |
The latency column spans more than two orders of magnitude, and out-of-order execution hides only the top of it. The energy column is why the NPU exists: at 45 nm, Horowitz’s widely cited figures put an 8-bit integer multiply at about 0.2 pJ and a 32-bit DRAM read at about 640 pJ, a ratio near 3000 to 1.
Consider \(C = AB\) with \(A\) of shape \(M \times K\), \(B\) of shape \(K \times N\), int8 inputs. The naive triple loop reads \(K\) bytes from \(A\) and \(K\) from \(B\) for every one of the \(MN\) outputs, so it moves \(2MNK\) bytes and does \(MNK\) MACs. That is 0.5 MACs per byte, forever, whatever the matrix size.
Our core wants 64 MACs per cycle, so each level imposes a required reuse: the MACs per byte you must achieve against it to avoid being limited by it. Take a 2.0 GHz core and four big cores sharing an LPDDR5-6400 64-bit interface (51.2 GB/s peak, so 12.8 GB/s per core, which is 6.4 B/cycle):
\[\text{L1: } \frac{64\ \text{MACs/cycle}}{32\ \text{B/cycle}} = 2 \qquad \text{L2: } \frac{64}{16} = 4 \qquad \text{DRAM: } \frac{64}{6.4} = 10\]Two levels of tiling supply those numbers. Register tiling holds an \(M_r \times N_r\) block of \(C\) in vector registers; each step along \(K\) loads \(M_r + N_r\) bytes and does \(M_r N_r\) MACs, so reuse against L1 is \(M_r N_r / (M_r + N_r)\). An 8x12 tile gives \(96/20 = 4.8\), above the required 2, and its 96 int32 accumulators occupy exactly 24 of the 32 NEON registers. Real micro-kernels are 8x12 or 12x8 for that reason: the tile is chosen by the register file, not by taste.
Cache blocking picks an \(M_c \times N_c\) block of \(C\) and sweeps the full \(K\) dimension through it, giving DRAM reuse \(M_c N_c / (M_c + N_c)\). With \(M_c = N_c = 256\) that is \(65536/512 = 128\) MACs per byte, twelve times the required 10; even a 64x64 block gives 32.
A tiled kernel exists, then, because the required reuse at each level is fixed by the ratio of compute throughput to that level’s bandwidth, and only a blocked loop nest supplies it. Real implementations also pack each panel contiguously first, trading a little bandwidth to turn strided loads into streams the prefetcher understands. We build one in Part 3.
Takeaway: every cache level sets a required reuse equal to MACs per cycle divided by bytes per cycle. Register tiling supplies the L1 number and cache blocking supplies the DRAM number; a kernel missing either runs at a fraction of peak however good its inner loop is.
Strip away the marketing and an NPU is a two-dimensional grid of multiply-accumulate units, a local SRAM, a DMA engine and a sequencer walking a pre-compiled command stream. The grid is the point: an \(R \times C\) array does \(RC\) MACs per cycle from one instruction, because control logic is amortised across thousands of multipliers instead of a handful of SIMD lanes. A 128x128 array at 1 GHz does 16,384 MACs per cycle, 16.4 TMAC/s, which a datasheet prints as 32.8 TOPS.
How operands move through the grid is the dataflow. Weight stationary holds one weight in each processing element while activations stream in and partial sums propagate across. This is the classic systolic array: excellent when each weight is reused many times, bad when it is not, since a fully connected layer at batch 1 uses each weight once and the array spends its time being reloaded. Output stationary gives each element one accumulator in a local register while weights and activations both stream, avoiding partial-sum movement (int32, wider than the int8 inputs) and suiting deep reductions.
Either way, utilisation decides your real throughput. If the array maps input channels to rows and output channels to columns, a layer narrower than the array leaves rows or columns idle.
Worked example. A 128x128 output-stationary array at 1 GHz, peak 16.4 TMAC/s, running a layer with 48 input channels and 320 output channels.
The “32.8 TOPS” chip just delivered 10.2 TOPS on a perfectly ordinary layer, and nothing was broken. It is also why depthwise separable convolutions, which every efficient mobile architecture is full of, disappoint on systolic hardware: a depthwise convolution has no input-channel reduction, so it uses one row.
The array is rarely the bottleneck; the local SRAM is. Typical on-NPU SRAM is 1 to 8 MB, and per tile of work it holds the input tile, the layer’s weights, the output tile and usually a double buffer so DMA overlaps compute. If the working set does not fit the compiler tiles it, and tiling re-reads from DRAM. If the weights alone do not fit, you re-read them every inference.
Worked example. A 2048x2048 int8 fully connected layer has 4,194,304 weights (4 MiB) and does 4,194,304 MACs at batch 1. If they stream from a 51.2 GB/s interface:
\[t_{\text{mem}} = \frac{4.19\times10^{6}\ \text{B}}{51.2\times10^{9}\ \text{B/s}} = 81.9\ \mu s \qquad t_{\text{compute}} = \frac{4.19\times10^{6}}{16.4\times10^{12}} = 0.26\ \mu s\]The layer is 320 times memory bound and the array runs at 0.3% utilisation. Nothing can be done at batch 1, because the arithmetic intensity of a matrix-vector product is fixed near 1 MAC per byte. This is the most important fact about running transformers on edge NPUs, and we return to it in Part 8.
NPU compilers do ahead-of-time memory planning: they fix tile sizes, allocate SRAM addresses, schedule DMA and emit a static command stream before the first inference. That is how they get their efficiency, and the source of every frustration you will have with them.
Dynamic shapes break the plan. If sequence length or detection count is unknown at compile time, the compiler must recompile per shape (hundreds of milliseconds, unusable per frame), pad to a maximum shape (wasting proportional compute), or bucket into a few fixed shapes. There is no fourth option, and bucketing is what I would do.
Unsupported operators break the graph. The operator set is hardware plus a limited programmable layer, so a custom activation, an unusual reduction axis or a five-dimensional transpose can all miss. The runtime then runs that piece on the CPU, and the cost is not the cost of the operator. One miss in the middle turns a network into three subgraphs: NPU, CPU, NPU. Each boundary costs a driver synchronisation, a layout conversion (NPU-internal tiled formats are not NHWC), and cache maintenance over the intermediate tensor in both directions. At a few hundred microseconds a crossing on a 5 ms network, one operator takes 10 to 20% of your frame budget while doing almost no arithmetic.
At Dyson this was comfortably the commonest reason a model that looked fine in the framework disappointed on the robot. The fix is to change the model, not fight the compiler: rebuild the exotic operator from supported ones, move post-processing out of the graph, and freeze your shapes. Three subgraphs down to one beats any amount of kernel tuning.
Takeaway: an NPU’s real throughput is peak times array utilisation, and utilisation collapses on narrow, depthwise and batch-1 layers. One unsupported operator can cost far more than the operator does, because it buys you two extra subgraph boundaries.
On an SoC the CPU, GPU and NPU usually share the same DRAM, so tensor handoff looks free and everyone says “zero copy”. What is genuinely free is the memcpy you avoid: on a 4 MB tensor at 10 GB/s effective, about 0.8 ms. Four things are not free.
Cache maintenance. If the accelerator is not I/O coherent, the CPU must write back dirty lines before the accelerator reads the buffer and invalidate stale lines before reading its output, per cache line, over the whole buffer.
Bandwidth contention. Zero copy does not create bandwidth. When the NPU streams weights at 30 GB/s from a 51.2 GB/s interface, the CPU thread doing your letterbox resize gets what is left and can run at half speed. I have seen post-processing blamed for a regression whose real cause was that the NPU work ahead of it had got faster and was now saturating the memory controller.
Layout mismatch. If the producer writes NHWC fp32 with arbitrary stride and the consumer wants a tiled int8 layout with 64-byte alignment, “zero copy” silently becomes a repack: a copy with a transpose in it. Allocate in the layout the driver wants.
Import cost. Mapping an external buffer into the device address space, and possibly the IOMMU, costs tens to hundreds of microseconds. Do it at model load; a pipeline that re-imports every frame can spend longer in the driver than in the network.
Almost. The honest benchmark curve has three phases.
Phase 1, the first few iterations: slow. Cold caches, first-touch page faults on lazily allocated buffers, the runtime choosing kernels, the allocator growing arenas. The first inference is often 2 to 10 times steady state, hence warm-up iterations.
Phase 2, roughly iteration 5 to a few hundred: fastest. Caches hot, and the governor has raised the clock, often to a boost frequency only available while the die is cool. This is the number that ends up in marketing material.
Phase 3, after the thermal mass saturates: the truth. Dynamic voltage and frequency scaling responds to junction temperature, to a power budget shared across the SoC, and on enclosed devices to a skin temperature limit that often binds before anything on the die is hot. The clock drops and throughput settles 20 to 50% below phase 2. How long that takes depends on thermal mass: tens of seconds for a phone, minutes for a metal chassis, never on a heatsink. A robot vacuum is a plastic box with a motor in it, so at Dyson the sustained number was the only one that mattered.
To measure honestly:
scaling_cur_freq and the thermal zones). Without them you cannot tell a throttled run from a slow kernel.performance, or pin scaling_min_freq and scaling_max_freq together. That makes a measurement repeatable, which is what you want while optimising and not what you want for an acceptance test.Arm SoCs are heterogeneous: large out-of-order cores at a high clock alongside small efficient cores at a lower one, sharing a coherent interconnect. Per-core int8 throughput commonly differs by 3 to 5 times, because the big core has both a higher clock and more SIMD pipes.
Two consequences cost real time. Static work splitting is a disaster: split a layer into equal chunks across all 8 cores and the big cores idle while the little cores grind, so the operation runs at the speed of the slowest core. Thread migration destroys your working set: the scheduler places threads from a utilisation signal that ramps over tens of milliseconds, so a freshly woken inference thread can start on a little core, be promoted mid-inference and arrive with cold L1 and L2. For a 5 ms inference the ramp never finishes.
The remedies, in order of effort:
sched_setaffinity to the big cluster, or a cpuset. ONNX Runtime, TFLite and the Arm Compute Library all expose thread affinity. Do this first and measure again.Concentrating work on fewer cores raises their clock but also concentrates power in a small area of die, so pinning is normally faster and often less efficient than spreading. Which matters depends on whether your constraint is latency or battery.
Takeaway: the fastest run is never the honest one. Warm up, run for a fixed duration, log clocks and temperature, pin the inference thread to the big cluster, and report burst and sustained numbers side by side with your real duty cycle.
Everything above exists so you can do this quickly. From Part 1 you need three numbers: peak compute at your precision, peak memory bandwidth, and the ridge point where they cross.
How to translate common datasheet lines into roofline inputs.
| What the datasheet says | What it usually means | What you do with it |
|---|---|---|
| “15 TOPS AI performance” | int8, peak, at burst clock, often summed over CPU + GPU + NPU | Ask which engine, which precision, dense or sparse. Divide by 2 for MAC/s. Never sum engines you cannot run at once. |
| “up to 30 TOPS with sparsity” | 2:4 structured sparsity assumed | Halve it unless you are shipping a structurally sparse model |
| “4 x 16-bit LPDDR5-6400” | four 16-bit channels at 6400 MT/s | \(64\ \text{bits} \times 6400\,\text{MT/s} / 8 = 51.2\) GB/s peak; expect 60 to 80% achievable |
| “8 MB system cache” | shared by CPU, GPU, NPU, display, ISP | Working sets below a few MB may never reach DRAM. Check your largest tensor. |
| “Octa-core: 4 at 2.6 GHz + 4 at 2.0 GHz” | two different microarchitectures | Compute each cluster separately, then add. Do not multiply 8 by the big-core figure. |
| “NPU with 2 MB local SRAM” | the actual tiling constraint | Compare against your largest layer’s input plus weights plus output |
| “Peak 12 W, typical 5 W” | burst versus sustained power | Expect sustained throughput near the typical figure |
Worked example: a hypothetical Device X. The datasheet gives 4 big cores at 2.6 GHz with FEAT_I8MM and two SIMD pipes, 4 little cores at 2.0 GHz with FEAT_DotProd and one SIMD pipe, an 8 TOPS int8 NPU, and 4 x 16-bit LPDDR5-6400.
Peak compute. Big cores: \(2 \times 32 \times 2 = 128\) ops/cycle each, so \(4 \times 128 \times 2.6\times10^{9} = 1.331\) Top/s. Little cores: \(1 \times 16 \times 2 = 32\) ops/cycle each, so \(4 \times 32 \times 2.0\times10^{9} = 0.256\) Top/s. CPU total 1.59 Top/s; the NPU’s 8 TOPS is 4 TMAC/s. Two rooflines, and you should draw both.
Peak bandwidth. \(6400 \times 8\ \text{B} = 51.2\) GB/s, derated to 70% for refresh, bank conflicts and read/write turnaround: 35.8 GB/s.
Ridge point. CPU: \(1.59\times10^{12} / 35.8\times10^{9} = 44\) ops per byte, or 22 MACs per byte. NPU: \(8\times10^{12} / 35.8\times10^{9} = 223\) ops per byte, or 112 MACs per byte.
Sit with that second number. The NPU has five times the compute of the CPU cluster and the same memory system, so its ridge point is five times further right. A model at 40 MACs per byte is comfortably compute bound on the CPU and badly memory bound on the NPU. Moving it saves energy, because the NPU burns far less per operation, but it need not make it faster. This arithmetic is why people are surprised that “the NPU is not helping”.
Takeaway: peak compute is cores times pipes times MACs per instruction times two times clock; peak bandwidth is data rate times bus width over eight, derated; the ridge point is their ratio. Compute one roofline per engine, because the faster engine has the harder ridge point to clear.
Problem 1. A device has 6 identical cores at 2.4 GHz, each with two 128-bit SIMD pipes and FEAT_DotProd but not FEAT_I8MM. Memory is a single 32-bit LPDDR4X-4266 channel. (a) Peak int8 throughput? (b) With FEAT_I8MM? (c) The ridge point in the I8MM case? (d) A 3x3 depthwise convolution with 32 channels on a 112x112 int8 feature map, stride 1, same padding: compute or memory bound, and by how much?
(a) SDOT is 16 MACs, so \(2 \times 16 \times 2 = 64\) ops per cycle per core, giving \(64 \times 2.4\times10^{9} = 153.6\) Gop/s each and 921.6 Gop/s over six cores.
(b) SMMLA is 32 MACs, so double: 307.2 Gop/s per core, 1.843 Top/s.
(c) Bandwidth \(= 4266\ \text{MT/s} \times 4\ \text{B} = 17.06\) GB/s. Ridge \(= 1.843\times10^{12} / 17.06\times10^{9} = 108\) ops per byte, or 54 MACs per byte.
(d) MACs: \(112 \times 112 \times 32 \times 9 = 3{,}612{,}672\), so 7.23 Mops. Bytes: input \(112 \times 112 \times 32 = 401{,}408\), output the same, weights \(32 \times 9 = 288\), total 803,104 B. Intensity \(= 7.23\times10^{6} / 803{,}104 = 9.0\) ops per byte.
Nine against a ridge of 108, so memory bound by a factor of 12: \(803{,}104 / 17.06\times10^{9} = 47.1\ \mu s\) from memory against \(3.9\ \mu s\) of arithmetic. Depthwise convolutions are almost pure data movement, which is why MAC count tells you so little about latency.
Problem 2. An NPU has a 128x128 output-stationary MAC array at 1.0 GHz, 2 MB of local SRAM, and shares a 51.2 GB/s LPDDR5 interface. (a) Peak int8 in TOPS? (b) A convolution has 96 input channels and 200 output channels: array utilisation and effective throughput, with input channels on rows and output channels on columns? (c) A 1024x1024 int8 fully connected layer at batch 1 streaming its weights from DRAM: what array utilisation?
(a) \(128 \times 128 = 16{,}384\) MACs/cycle at 1 GHz \(= 16.384\) TMAC/s \(=\) 32.8 TOPS.
(b) Rows \(= 96/128 = 75\%\). Columns: 200 needs \(\lceil 200/128 \rceil = 2\) passes, so \(200/(2 \times 128) = 78.1\%\). Combined \(0.75 \times 0.781 = 58.6\%\), giving \(16.384 \times 0.586 = 9.6\) TMAC/s, i.e. 19.2 TOPS, still under 60% of the headline.
(c) Weights: \(1024 \times 1024 = 1{,}048{,}576\) bytes, and the same number of MACs.
\[t_{\text{mem}} = 1.049\times10^{6}/51.2\times10^{9} = 20.5\ \mu s, \qquad t_{\text{compute}} = 1.049\times10^{6}/16.384\times10^{12} = 0.064\ \mu s\]Utilisation \(= 0.064/20.5 =\) 0.31%. Note that 1 MiB fits in 2 MB of SRAM, so keeping the weights resident gives a completely different answer: for batch-1 layers, “do the weights fit in SRAM and stay there” is the design question that matters.
Problem 3. A layer is decomposed into 32 independent tiles. A big core completes a tile in 4 ms, a little core in 13 ms. The device has 4 big and 4 little cores. Find the makespan for (a) a static even split of 8 tiles per core, (b) the 4 big cores only, (c) a dynamic work-stealing queue over all 8 cores.
(a) Big cores finish at \(8 \times 4 = 32\) ms, little cores at \(8 \times 13 = 104\) ms. The makespan is the slowest: 104 ms, with the big cores idle for 72 ms of it.
(b) 8 tiles each: \(8 \times 4 =\) 32 ms. Using half the cores is 3.25 times faster than using all of them badly.
(c) Simulate the queue. Bigs claim 4 tiles at t = 0, 4, 8, 12, 16, 20 (24 tiles); littles claim 4 at t = 0 and t = 13 (8 tiles). That is 32, pool empty at t = 20. Bigs finish at t = 24, littles at t = 26, so the makespan is 26 ms, within 6% of the fluid-limit bound \(32/(4/4 + 4/13) = 24.5\) ms.
Static splitting is 4 times worse than work stealing; big-only is 23% worse and takes one line of code, which is why pinning is the right first move and work stealing the right second.
Problem 4. A detector runs at 28 ms per frame for the first 25 seconds of a continuous run, then throttles to a steady 44 ms. (a) What mean frame rate does a 5 minute continuous benchmark report? (b) A 10 second benchmark? (c) The product needs 15 fps. Assuming the device holds its burst clock indefinitely at duty cycles at or below 50%, is 15 fps sustainable, and what is the maximum sustainable frame rate?
(a) First 25 s at 28 ms: \(25/0.028 = 892\) complete frames, taking 24.98 s. The remaining 275.02 s at 44 ms gives \(275.02/0.044 = 6250\) frames. Total 7142 frames in 300 s \(=\) 23.8 fps.
(b) A 10 second run never leaves the burst phase: \(1/0.028 =\) 35.7 fps, 50% higher than the 5 minute figure from the same device and binary. This is how two engineers benchmark one model and disagree by half.
(c) At 15 fps with 28 ms of work per frame the duty cycle is \(15 \times 0.028 = 0.42\), i.e. 42%, below the 50% threshold. So yes, 15 fps is sustainable at the burst clock, and the honest quote is 28 ms, not 44 ms. The maximum sustainable rate is \(0.50/0.028 =\) 17.8 fps. Sustained performance is not a property of the chip alone; it is a property of the chip and your duty cycle.
We now have the machine: the instructions that do the arithmetic, the hierarchy that feeds them, the accelerators alongside, and the thermal and scheduling behaviour that decides which measurements were real. What we have not done is write a fast kernel. That is next: taking a matrix multiply from the naive triple loop to something that reaches 70% of the peak we just computed, one blocking decision at a time.