Master Local AI
  1. 0Load
  2. 1You type
  3. 2Tokenize
  4. 3To the GPU
  5. 4Prefill
  6. 5Decode
  7. 6Show text
Can it run?

Chapter 3 · Practitioner

Why Memory Bandwidth Sets Your Tokens per Second

Every token reads the whole model. Bandwidth sets your speed.

  1. 0Loadonce: file → VRAM
  2. 1You typetext in the app
  3. 2TokenizeCPU: text → IDs
  4. 3To the GPUIDs cross PCIe
  5. 4Prefillwhole prompt, one pass
  6. 5Decoderead all weights → 1 token
  7. 6Show textID → word · repeat 5

1Watch

Now

Press a step on the right, or change any control under the machine.

Readout

Key to the machine

A grain mill. The silo is GPU memory. The elevator is bandwidth: one lift per token. Each sack is a user, so batching is cheap. Watch the mill sit idle.
How the numbers work

To make one token the GPU must read every active weight out of VRAM once, so batch-1 decode runs at about 70% × bandwidth ÷ bytes per token, and the mill sits idle: an 8B model in BF16 on an RTX 5090 keeps its tensor cores busy about 1% of the time. Put more sacks under the chute and one lift serves them all: the weight read is shared, each user's KV cache is read again, and the mill gets busier until compute limits the step (the roofline ridge). Compute ceilings are NVIDIA's dense tensor figures for the format's math path (RTX Blackwell whitepaper; sparse AI TOPS ÷ 2 for FP4, ÷ 4 INT8, ÷ 8 FP8, ÷ 16 BF16 where only AI TOPS is published) at 50% efficiency; the M4 GPU is about 4.3 TFLOPS. In the drawing the belt speed is proportional to bandwidth and the whole machine runs 75× slower than real time: one lift (one decode step) every 75 × the seconds per token, so the counters count tiles and lifts in the drawing, while the dial on the scale shows the real total tokens per second. The sight glass is scaled to the card's capacity line; red above it is the overflow. When weights + KV cache + about 1 GB of runtime pass capacity, the model does not load: the gate shuts and the elevator stops (offloading to system RAM is Fig 9).

  • Silo = VRAM (weights, KV cache)
  • Elevator = memory bandwidth
  • Tiles = tokens, one per sack
  • Red beacon = model does not fit
Speed
Open full screen ↗

2Try this

Then change anything yourself.

3Read on ↓

Where bandwidth comes from

Bandwidth is bus width times data rate.

\text{bandwidth} = \frac{\text{bus width (bits)}}{8} \times \text{data rate per pin (Gb/s)}
Device Memory Bus Rate Bandwidth
GeForce RTX 5090 GDDR7 512-bit 28 Gb/s 1,792 GB/s
GeForce RTX 5080 GDDR7 256-bit 30 Gb/s 960 GB/s
GeForce RTX 5060 Ti 16GB GDDR7 128-bit 28 Gb/s 448 GB/s
DGX Spark (GB10) LPDDR5X unified 256-bit 8,533 MT/s 273 GB/s
Apple M4 (this Mac) LPDDR5X unified 128-bit 7,500 MT/s 120 GB/s
DGX Station (GB300) GPU HBM3e stacked — 7.1 TB/s

Each 32-bit GDDR channel is one memory chip, so the chip count follows the bus width. Capacity and bandwidth are separate decisions. The RTX PRO 6000 Blackwell keeps the 5090's 512-bit, 1,792 GB/s bus, but denser chips on both sides of the board give it 96 GB.

Predicting tokens/s

Batch-1 decode reads the active weights and the KV cache for every token. The playground predicts speed for an 8B model at 4-bit across the NVIDIA lineup:

The roofline

Speed is capped by memory or by compute, whichever limit is lower.

  • Arithmetic intensity: FLOPs performed per byte moved.
  • Limit: \min(\text{peak compute},\ \text{intensity} \times \text{bandwidth}).
  • Ridge point: peak FLOPs ÷ bandwidth. Below it the device is memory bound. Above it the device is compute bound.
  • GPUs have ridges of hundreds of FLOPs per byte. Batch-1 decode sits at 2/b (1 FLOP/byte at FP16).
  • Batching B sequences reuses each weight read B times. This moves the point right by B\times.

Lab: measure this machine's bandwidth

Time a copy to measure bandwidth, then predict and verify tokens/s. The copy reads and writes every byte once. The lab predicts decode speed for your chat model and compares it with Ollama's measured speed.

Lab · runs on your own machine

uv run labs/03_bandwidth.pyDownload all labs (.zip)

Needs uv and, for the live parts, Ollama with a small chat model: ollama pull qwen3:0.6b.

Show the code
# /// script
# requires-python = ">=3.11"
# dependencies = ["numpy", "torch"]
# ///
import _local as local

import time, torch

def copy_bandwidth(device, gigabytes=1.0, repeats=10):
    n = int(gigabytes * 1e9 / 4)
    src = torch.empty(n, dtype=torch.float32, device=device).uniform_()
    dst = torch.empty_like(src)
    sync = {"cuda": torch.cuda.synchronize, "mps": torch.mps.synchronize}.get(device, lambda: None)
    dst.copy_(src); sync()
    start = time.perf_counter()
    for _ in range(repeats):
        dst.copy_(src)
    sync()
    return 2 * src.numel() * 4 * repeats / (time.perf_counter() - start) / 1e9  # read + write

device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
measured_bw = copy_bandwidth(device)
print(f"{device}: ≈ {measured_bw:.0f} GB/s achieved (spec for an M4 is 120 GB/s; NVIDIA GPUs list theirs on the chapter page)")

model = local.need_chat_model()
if model:
    info = next(m for m in local.models() if m["name"] == model)
    weights_gb = info["size"] / 1e9
    predicted = measured_bw / weights_gb
    local.generate(model, "Hi", num_predict=4)
    r = local.generate(model, "Explain memory bandwidth in one paragraph.", num_predict=128)
    print(f"{model}: {weights_gb:.2f} GB of weights")
    print(f"predicted ≈ {predicted:.0f} tok/s  (bandwidth ÷ bytes per token)")
    print(f"measured   {r['decode_tps']:.0f} tok/s  → {r['decode_tps']/predicted:.0%} of the bandwidth bound")
Sample output on this course's Mac (Apple M4, 32 GB)
mps: ≈ 56 GB/s achieved (spec for an M4 is 120 GB/s; NVIDIA GPUs list theirs on the chapter page)
qwen3:0.6b: 0.52 GB of weights
predicted ≈ 107 tok/s  (bandwidth ÷ bytes per token)
measured   89 tok/s  → 83% of the bandwidth bound

What to notice:

  • A copy reaches about 45–70% of the spec-sheet bandwidth (this M4 measured 56–71 of 120 GB/s). Predictions from the measured number are conservative.
  • Real decode reaches roughly 50–85% of the bound at spec bandwidth. Launch overhead, dequantization, KV reads and sampling cause the gap, which is larger for tiny models.
  • A result far below ~50% means a fault: the model spilled out of VRAM, the backend fell back to the CPU, or the build is a debug build.

Beating the wall without more bandwidth

Four methods raise tokens/s without more bandwidth.

  • Quantization (Chapter 5): fewer bytes per weight give proportionally more tokens/s.
  • Batching (Chapter 8): many users share each weight read.
  • Speculative decoding: a small draft model (or extra heads, as in EAGLE or Medusa) proposes tokens. The big model verifies them all in one forward pass. When most guesses are accepted, you get 2–3× more tokens per weight read.
  • MoE (Chapter 10): the model reads only the active experts' weights.

Explain it

Answer each question aloud.

  1. Estimate tokens/s for an 8B 4-bit model on an RTX 5090. A token reads about 4.9 GB, so 1,792 GB/s × ~0.7 ÷ 4.9 GB ≈ 250 tok/s at most.
  2. Why is DGX Spark slower than an RTX 5090, yet made for bigger models? It has 273 GB/s against 1,792, but 128 GB of unified memory. It fits 70B–120B-class models that a 32 GB card cannot hold.
  3. How does speculative decoding help a bandwidth-bound workload? One weight read verifies several drafted tokens. This turns spare compute into accepted tokens.

Exercises

  1. Use the roofline playground to find the batch size where an RTX 5090 at FP8 stops being memory bound, assuming 400 TFLOPS of usable FP8 compute.
  2. Re-run the lab with a 4k-token prompt and output, and compare the KV-cache slowdown with the formula.