Introduction: once bandwidth is enough, the next wall is computation

Lesson 9 dealt with getting data to the right place as fast as possible (UMA removes the PCIe copy). But having data quickly is not enough — a scalar CPU still has to COMPUTE one element at a time, in order. 3D graphics and deep learning both revolve around ONE core operation: matrix multiplication. This lesson compares 3 hardware architectures running that SAME operation at throughputs that differ by a factor of thousands.


📚 Prerequisites
It helps to read Lesson 9 (Apple Silicon & UMA) — this lesson carries the "dedicated hardware acceleration" story straight on to COMPUTATION rather than bandwidth.

1. SIMD against SIMT

SIMD (single instruction, multiple data) — modern CPUs have wide vector registers (AVX-256, say, handles 8 32-bit numbers AT ONCE in ONE instruction) — still fundamentally a scalar architecture, just with each instruction "widened" to cover more data. SIMT (single instruction, multiple threads) — how a GPU is organised: thousands of small, simple Shader Core running the SAME instruction but across thousands of independent data streams in parallel — a FUNDAMENTALLY different thing from SIMD's "widening" of one instruction.

⚠️ Pitfall: a GPU cannot replace a CPU for every program
GPUs are extremely strong on PURELY PARALLEL workloads (each thread working independently, none branching differently) — but VERY weak on complex sequential branching code (nested if/else, loops whose iterations depend on each other): each shader core is far simpler than a CPU core, and when threads in the SAME group (a warp) branch in DIFFERENT directions, the GPU has to run BOTH branches SEQUENTIALLY for that group — losing the parallel advantage entirely.

2. NPUs & Apple AMX

NPU (neural processing unit) and the Tensor Core are DEDICATED hardware built to accelerate exactly ONE operation: matrix multiplication (and its convolution variants) — tens of times faster than the equivalent on an ordinary CPU, because the hardware is designed to do ONE thing rather than to be flexible like a CPU. Apple AMX (Apple matrix coprocessor) is a matrix coprocessor HIDDEN inside the Apple Silicon CPU core itself — no data has to move to the GPU (continuing Lesson 9's UMA spirit); the CPU issues AMX instructions directly when running through the Accelerate framework or CoreML.

simd_vs_simt.txt (two ways of organising parallelism)
SIMD (CPU vector, vd AVX-256):
  1 lenh --> xu ly CUNG LUC 8 so 32-bit trong 1 thanh ghi rong
  Van la 1 loi thuc thi (control flow), chi "no rong" DU LIEU

SIMT (GPU, vd CUDA/Metal):
  1 lenh --> hang nghin Shader Core doc lap chay CUNG lenh do
  MOI Shader Core co du lieu RIENG - that su nhieu LUONG thuc thi song song
  (neu cac luong trong 1 nhom RE NHANH khac huong -> phai chay TUAN TU ca 2 nhanh)
⚠️ Pitfall: careless quantization collapses model quality
To run faster on an NPU or tensor core, AI model weights are usually quantized from FP32 (32-bit floating point) down to INT8 (8-bit integer) — 4 times smaller and considerably faster. But done CARELESSLY (without calibrating the value ranges properly, or ignoring sensitive layers), model accuracy can DROP SEVERELY — quantization is a technique that needs careful measurement, not a switch you flip.
accelerator_comparison.txt (the 3 acceleration routes at a glance)
Kien truc      | Muc dich chinh              | Vi du
---------------|------------------------------|---------------------------
CPU + SIMD     | Da nang, phep toan da dang   | AVX/AVX-512 (x86), NEON (ARM)
GPU (SIMT)     | Song song HANG NGHIN luong   | Do hoa, huan luyen mo hinh lon
NPU/TensorCore | Chuyen dung nhan ma tran     | Suy luan AI tren thiet bi di dong
Apple AMX      | Nhan ma tran AN trong CPU    | Accelerate framework, CoreML
                 (khong can chuyen du lieu sang GPU - tiep noi tinh than UMA Bai 9)

3. Counting FLOPs and computing throughput

Multiplying two $N \times N$ matrices with the classic sequential algorithm: each of the $N^2$ result elements needs $N$ multiplications + $(N-1)$ additions — total floating-point operations (FLOPs):

$$\text{Total FLOPs} = N^2 \times (2N - 1) = 2N^3 - N^2$$

Verified for real: multiplying 1024×1024 matrices takes exactly 2.146.435.072 FLOPs (~2.15 billion). Compare the time for that SAME operation on 3 architectures — a scalar CPU (4 GFLOPS), a SIMD CPU (32 GFLOPS, equivalent to 8-wide AVX = 8× scalar), and a GPU (10 TFLOPS):

flops_compare.js (extract from the shared cpu-core.js engine)
function matrixMultiplyFlops(n) {
  return 2 * Math.pow(n, 3) - Math.pow(n, 2);
}
function computeTimeSeconds(flops, flopsPerSecond) {
  return flops / flopsPerSecond;
}
// Verified: matrixMultiplyFlops(1024) = 2.146.435.072 FLOPs
// Verified: scalar (4 GFLOPS)  -> 0,5366 giay
// Verified: SIMD (32 GFLOPS)   -> 0,0671 giay (nhanh hon scalar DUNG 8 lan = 32/4)
// Verified: GPU (10 TFLOPS)    -> 0,000215 giay = 0,215 ms (nhanh hon scalar 2500 lan)

SIMD beats scalar by exactly 8× — EXACTLY the vector width, not an estimate. The GPU beats scalar by 2500× — that enormous gap comes from the GPU having thousands of shader cores genuinely working in parallel, quite unlike SIMD merely "widening" one instruction.

⚠️ Ignoring data-loading overhead when the matrix is too small
Before a GPU or AMX unit can compute, the data must be loaded into its dedicated registers or memory — a FIXED cost, independent of matrix size. For a LARGE ENOUGH matrix (1024×1024, say) this is negligible next to the compute time. But verified for real: with a matrix of 4×4, far too small (just 112 FLOPs), the 0.1 ms loading overhead makes the GPU SLOWER than even the plain scalar CPU (despite the scalar CPU being far "weaker" on paper) — too little computation to pay back the startup cost.
small_matrix_pitfall.js (verified with the engine — overhead outweighs the gain)
// Ma tran 4x4: chi 112 FLOPs - qua nho de GPU "dang" cong suc
const cmpSmall = compareComputeMethods(4, 4, 32, 10, 0.0001); // 0,1ms overhead GPU
// cmpSmall.scalarTimeSeconds ~ 0.000000028 s (khong overhead)
// cmpSmall.gpuTimeSeconds    ~ 0.0001 s (GAN NHU TOAN BO la overhead, tinh toan that ~0)
// Verified: cmpSmall.gpuTimeSeconds > cmpSmall.scalarTimeSeconds -> GPU CHAM HON!

4. The roofline model: 10 TFLOPS only means something if the data keeps up

There is a quiet assumption running through all of section 3 that has to be brought into the open: every time above was computed by dividing FLOPs by PEAK throughput. That silently assumes the hardware always has data on hand to compute with — infinite memory bandwidth. No hardware is like that. To compute you must first load, and for a great many problems it is the loading that decides the time.

The roofline model (Williams, 2009) weighs those two limits against each other using a single quantity: arithmetic intensity — how many operations you get per byte read from memory.

$$\text{AI} = \frac{\text{FLOPs}}{\text{bytes of memory traffic}} \qquad \text{Attainable throughput} = \min(\text{peak FLOPS},\; \text{AI} \times \text{bandwidth})$$

Dividing one by the other gives the ridge point — the minimum intensity at which bandwidth stops being the bottleneck. For a 10 TFLOPS GPU with 600 GB/s: $10\times10^{12} / 600\times10^{9} = \mathbf{16.67}$ FLOP/byte. Below that you are memory-bound, above it compute-bound.

bandwidth roof AI × 600 GB/s peak FLOPS roof — 10 TFLOPS ridge point 16.67 MEMORY-bound COMPUTE-bound vector add: AI 0.167 — only 1% of peak matrix multiply: AI 170 — 100% attainable throughput arithmetic intensity (FLOP/byte) →
The roof has two parts: the slope on the left is bandwidth, the flat section on the right is peak FLOPS. Where your problem sits under that roof decides which hardware purchase would actually help.

Put two operations on that same GPU. An $N \times N$ matrix multiply does $2N^3 - N^2$ operations while touching only $3N^2$ elements — the work grows as $N^3$ and the data only as $N^2$, so the bigger it gets the better the deal. Vector addition is the opposite: 2 operations per element, but it must read x, read y and write y — a ratio that is fixed, and never improves however large the array grows.

roofline.js (verified with the engine — same GPU, 100% against 1%)
const PEAK = 10e12;  // 10 TFLOPS
const BW   = 600e9;  // 600 GB/s  -> ridge point = 16.67 FLOP/byte

// Matrix multiply 1024x1024, FP32, each matrix touched once (ideal blocking)
rooflineAttainable(matmulArithmeticIntensity(1024, 4), PEAK, BW);
// AI = 170.58 -> { bound: 'compute', attainable: 10 TFLOPS, fractionOfPeak: 1.00 }

// Vector add y[i] = a*x[i] + y[i] on the SAME hardware
rooflineAttainable(vectorAddArithmeticIntensity(4), PEAK, BW);
// AI = 0.167  -> { bound: 'memory',  attainable: 100 GFLOPS, fractionOfPeak: 0.01 }

// Same GPU. 100% of peak against 1% of peak. Peak FLOPS predicts neither.
⚠️ Pitfall: comparing hardware by peak FLOPS
Verified: on the SAME GPU, matrix multiply uses 100% of the available power while vector addition uses just 1% — a factor of 100 apart, from the identical peak-FLOPS figure printed on the box. For a memory-bound problem, buying a card with twice the FLOPS delivers exactly 0% improvement: the flat roof on the right rises, but the problem is sitting on the slope on the left. What you need to buy is bandwidth, or what you need to fix is the algorithm — tiling to reuse data through the cache (Lesson 7) is exactly how you drag AI to the right.

This is also why matrix multiplication, rather than any other operation, became the thing every piece of AI hardware races to accelerate: it is one of the few operations whose arithmetic intensity grows with size, so the more FLOPS you pile on, the more there is to use them for.
🔍 The AI of 170 is an UPPER BOUND, not something you get for free
The formula above assumes each matrix is read EXACTLY ONCE. The naive triple loop does not manage that — it re-reads rows and columns many times over, so its real AI is far lower and the problem can fall back across into memory-bound territory. What lifts it towards the upper bound is tiling : load a small block that fits in cache and reuse it exhaustively before discarding it — exactly Lesson 7's temporal locality, and the reason a BLAS library beats a hand-written loop by tens of times at an identical operation count.

5. Warp divergence: the price of one stray thread

The pitfall in section 1 said the GPU "has to run both branches sequentially" when threads go different ways. That phenomenon has a name — warp divergence — and it is worth putting a number on, because intuition gets it wrong here.

A GPU does not schedule threads individually. It groups them — NVIDIA calls the group a warp (32 threads), AMD a wavefront (64) — and the whole group SHARES one program counter. The entire warp must execute the same instruction at any instant. So when it meets an if where threads go different ways, the hardware has no option but to run the then branch with the remaining threads DISABLED, then run the else branch with the other group disabled.

warp_divergence.js (verified with the engine — one thread is enough)
// All 32 threads take the same path
warpDivergence(new Array(32).fill(true));
// -> { passes: 1, efficiency: 1.00, wastedSlots: 0 }

// The warp splits evenly, 16 and 16
warpDivergence(Array.from({ length: 32 }, (_, i) => i < 16));
// -> { passes: 2, efficiency: 0.50, wastedSlots: 32 }

// ONE thread out of 32 goes the other way
warpDivergence(Array.from({ length: 32 }, (_, i) => i === 0));
// -> { passes: 2, efficiency: 0.50, wastedSlots: 32 }   identical to the 16/16 split
⚠️ Pitfall: assuming a little divergence costs a little
Verified: a warp split evenly 16/16 drops to 50% efficiency. But a warp with EXACTLY ONE thread going the other way also drops to exactly 50% — identical. The damage is not proportional to the number of stray threads, because what matters is how many PASSES the warp must make, and one divergent thread is already enough to force two.

The practical consequence: if (threadId % 2 == 0) ruins EVERY warp, while if (threadId / 32 % 2 == 0) — which splits the threads equally too — leaves no warp divergent at all, because the boundary falls exactly on a warp boundary. Same logical condition, same thread count per branch, twice the performance. That is why GPU programming cares which thread sits in which warp, not only about the algorithm.

6. Hands-on: matrix multiply on scalar, SIMD and GPU/AMX

Change the matrix size and the three architectures' figures to watch the times move directly — try dropping N very low (to 4, say) to see the GPU overhead pitfall verified above for yourself:

⚡ Scalar against SIMD against GPU/AMX
Scalar
SIMD
GPU/AMX

Summary

  • ✅ SIMD "widens" one CPU instruction to cover several data elements at once; SIMT (the GPU) runs thousands of genuinely independent threads — a fundamentally different way of organising parallelism.
  • ✅ NPUs, tensor cores and Apple AMX are DEDICATED hardware built purely to accelerate matrix multiplication.
  • ✅ Verified: a 1024×1024 matrix multiply is 2,146,435,072 FLOPs. SIMD beats scalar by exactly 8× (the vector width); the GPU beats scalar by 2500×.
  • ✅ Pitfall: for a matrix that is too small (4×4), the GPU's data-loading overhead makes it SLOWER than the scalar CPU — verified with the engine.
  • ✅ Pitfall: GPUs are very weak on complex sequential branching code — they cannot replace a CPU for everything.
  • ✅ Roofline: attainable throughput = $\min(\text{peak FLOPS}, \text{AI} \times \text{bandwidth})$. Verified on the SAME 10 TFLOPS / 600 GB/s GPU: matrix multiply (AI 170) uses 100% of the power, vector addition (AI 0.167) just 1% — peak FLOPS is not enough to predict performance.
  • ✅ Pitfall: for a memory-bound problem, buying hardware with more FLOPS gives exactly 0% improvement. Tiling to reuse the cache (Lesson 7) is what drags AI over to the compute-bound side.
  • ✅ Warp divergence: verified, just ONE thread in 32 going the other way pulls the whole warp down to 50% efficiency — exactly the damage of an even 16/16 split. The cost is not proportional to the number of stray threads.

This whole lesson runs in one direction: to go faster, pile on more dedicated hardware — more cores, a separate matrix unit, wider bandwidth. But piling on means the chip grows, and the bigger a chip is the likelier a single speck of dust ruins it, with cost rising far faster than area. That is the economic wall standing behind every technical wall above — Lesson 11 puts a number on that price, and shows why the industry was forced to cut the big chip into small pieces (chiplets).

Review quiz

Question 1

What is the CORE difference between SIMD (CPU vectors) and SIMT (GPUs)?

Question 2

Verified: a 1024×1024 matrix multiply on SIMD (32 GFLOPS) beats scalar (4 GFLOPS) by exactly 8×. Where does that 8 come from?

Question 3

Verified: with a 4×4 matrix (only 112 FLOPs), the GPU (carrying 0.1 ms of loading overhead) is SLOWER than the scalar CPU — despite being far "stronger" on paper. Why?

Question 4

Why does FP32→INT8 quantization need careful measurement rather than blanket application?

Download the lesson's practice code

File JavaScript CPUJS — a miniature computer-architecture library used across all 12 lessons. Lesson 10 has just added matrixMultiplyFlops(), computeTimeSeconds(), compareComputeMethods(), rooflineAttainable(), matmulArithmeticIntensity(), vectorAddArithmeticIntensity(), warpDivergence() — matrix-multiply FLOP counting, compute-time comparison across the 3 architectures, the roofline model (arithmetic intensity against bandwidth) and warp divergence, with a self-test that checks every number quoted in this lesson (run node cpu-core.js, nothing to install):

Download cpu-core.js

📖 References

Related lessons in this series

Lesson 9: Apple Silicon & Unified Memory Architecture (UMA) Lesson 11: The End of Moore's Law & Chiplet Packaging Back to the Computer Architecture roadmap

Comments