WORKBENCH
05 · InferenceLesson 5.1

Why ~17 tokens per second?

Why is LLM decoding limited by memory speed, and how fast should my Spark be before I run anything?

Listen · 14 min0:00 / 13:40

When a language model writes, every new word means reading the entire model out of memory once. So how fast it writes is set by how quickly memory can hand bytes over, not by how quickly the chip can do math.

That one fact lets you predict how fast your DGX Spark will run any model, with a single division, before you download it. It explains why a provider like Fireworks feels quicker per user than your Spark before any of their clever tricks. And it turns every speed trick in inference into the same move: get more useful work out of each byte you pull from memory. It’s also the honest answer, with a number, to the interview question “why is generating text slow?”

It builds on How slow is slow?, where you met the difference between how long one fetch takes and how much can flow per second, and on Waiting vs working. It’s the spine of the inference lessons. Next you’ll build a server and race it against Fireworks in Baseline: my Spark vs Fireworks, and each lesson after that is one way around this limit.

Today

You’ll want How slow is slow? first (how long one fetch takes versus how fast data flows, and the Spark’s memory numbers) and Waiting vs working (a GPU call hands control back before the work is done). You’ve written a transformer forward pass, so you already know what’s inside a layer. About 3 hours, half of it measuring.

The Problem

You have built Llama from scratch. You know the forward pass: embed a token, run it through 32 layers of attention and MLP, project to the vocabulary, sample. You know about the KV cache. So here is the question from the diagnostic, sharpened into a number:

Predict firstwrite it down

You run Llama 3.1 8B in bf16 on your DGX Spark, one user, short prompt. How many tokens per second will it generate? Write a number, and one sentence on what limits it.

Reveal

About 17 tokens per second at the very most, and in practice a bit less. The limit is not the GPU’s math. It is that every token needs every weight read out of memory once: 8 billion weights × 2 bytes = 16 GB, through a pipe the spec sheet rates at 273 GB/s. 16 / 273 ≈ 0.059 seconds per token, which is about 17 per second. If you said “the GPU is slow” or “attention is expensive”, this lesson is for you. If you said 17, this lesson is about why it is 17 and how to beat it.

Here is the strange part. The Spark’s GPU can do somewhere around a hundred trillion bf16 multiply-adds per second (we will pin that down by measuring). Generating one token of an 8B model needs about 16 billion operations. Divide, and the GPU should make thousands of tokens per second. It makes seventeen. The tensor cores are idle more than 99% of the time, waiting for weights to arrive.

That gap between “how fast it can think” and “how fast it can be fed” is the whole lesson. And once you see it, every inference optimization in this track (quantization, batching, speculative decoding, the lot) turns into the same move: find a way to get more useful work out of each byte you drag out of memory.

The Idea

One token, one trip through every weight

Picture a kitchen with one chef who can chop absurdly fast, maybe a thousand onions a second. The onions live in a warehouse across town and arrive by one delivery van. How many onions per second does the kitchen process? Not a thousand. It processes exactly as many as the van delivers. The chef is irrelevant. The van is everything.

In decoding, the chef is the GPU’s math units and the van is the memory bus. Here is what one decode step actually does, for one new token:

  1. Take the vector for the latest token (4,096 numbers for Llama 3.1 8B).
  2. For each of the 32 layers, multiply that vector by the layer’s weight matrices: the attention projections (Q, K, V, output) and the MLP (gate, up, down).
  3. Multiply the result by the output projection to get a score for each of the 128,256 vocabulary entries. Sample one.

Step 2 is a matrix-vector multiplyMultiplying a matrix by a single column of numbers. Each weight in the matrix gets used exactly once: one multiply, one add, then it is never touched again for this token., 32 times over. Every weight in every matrix has to travel from memory into the chip, get used for one multiply and one add, and be thrown away. Then the next token comes and the whole model has to travel again, because the GPU has nowhere near 16 GB of on-chip storage to keep it in.

So the cost of a token is, to first order, the time to read all the weights once:

time per token  ≈  bytes of weights  ÷  memory bandwidth
                ≈  8.03e9 params × 2 bytes  ÷  273e9 bytes/s
                ≈  16.06 GB ÷ 273 GB/s
                ≈  0.0588 s

tokens per second  ≈  1 ÷ 0.0588  ≈  17

memory bandwidthHow many bytes per second can move between memory and the chip. Not how long one read takes (that is latency), but how wide the pipe is once data is flowing. The Spark’s spec sheet says about 273 GB/s. is the number from the latency lesson that you probably filed away as trivia. It is now the most important number about your machine.

FIG_001[ THE ONE DIVISION ]
MEMORY · 128 GBLPDDR5X, SHARED CPU+GPUWEIGHTS8.03B × 2 B = 16 GBREST: FREE273 GB/STHE NARROW PARTGPU · TENSOR CORES~100+ TFLOP/S BF16BUSY <1% OFTHE TIME16 GB ÷ 273 GB/S = 59 MS PER TOKEN → ~17 TOK/SSPEC-SHEET BANDWIDTH · A CEILING, NOT A PROMISE
Every token drags the whole model through the pipe. The pipe, not the GPU, sets the speed.

Two refinements, so the number is honest. First, the embedding table at the bottom of the model is a lookup: you read one row of it, not all 525 million entries. The output projection at the top is a full matrix, so it does get read. That makes the true weight traffic about 15 GB, and the ceiling about 18 tok/s. Second, you never get the full spec-sheet bandwidth. Real memory systems deliver some fraction of their rated speed, and real software adds overhead on top. So “17” is a ceiling to beat your measurements against, not a promise. You will measure the real bandwidth in the Build section and redo the division with it.

Watch one step

Step through a single decode step below. Each cell is one layer, about 436 MB in bf16. Watch the two timelines at the bottom: the memory bus is busy the whole time, and the tensor cores get a sliver at the end of each delivery. Then switch the batch to 16 and 128 and run it again.

FIG_002[ ONE DECODE STEP, LAYER BY LAYER ]
MEMORY · 128 GB LPDDR5XEACH CELL = ONE LAYER · 436 MB273 GB/STHE PIPEGPU · TENSOR CORESWAITING FOR WEIGHTSMEMORY BUSTENSOR CORES0 MS55 MS · ONE TOKEN PER STREAM

batch
elapsed 0.0 msmoved 0.00 GBtensor cores busy -tokens made 0
Llama 3.1 8B, bf16, on spec-sheet Spark numbers (273 GB/s; ~125 TFLOP/s assumed). The bus is always busy. The math is a hairline until many tokens share each delivery.

Notice what changing the batch did. The memory bar did not change at all: the same 15 GB crossed the pipe. The compute slivers grew. With 128 users in one step, each layer’s weights got used 128 times per trip instead of once, and you produced 128 tokens for the price of one delivery. Keep that in your pocket. It is the most important idea in inference economics, and we will come back to it.

Arithmetic intensity: work per byte

Now the real vocabulary. The useful question about any piece of computation is: for every byte I bring in from memory, how much math do I do with it? That ratio has a name: arithmetic intensityFLOPs done divided by bytes moved from memory. High means you do lots of work per byte (good for the chip’s math units). Low means you mostly shuffle data.. It is measured in FLOPsFloating-point operations: one multiply or one add of decimal numbers. A multiply-add counts as 2. “FLOP/s” (with the slash) means per second, a speed. per byte.

For single-stream decode it is easy to work out by hand. Each weight does one multiply and one add, so 2 FLOPs. Each weight is 2 bytes in bf16. So:

arithmetic intensity (decode, batch 1, bf16)  =  2 FLOPs / 2 bytes  =  1 FLOP per byte

Is 1 a lot? Compare it with what the chip can sustain. If the GPU can do (say) 125 trillion FLOPs a second, and memory delivers 273 billion bytes a second, then to keep the math units busy you would need to do 125e12 / 273e9 ≈ 458 FLOPs for every byte you bring in. Decode does 1. You are short by a factor of hundreds.

That number, peak FLOPs divided by bandwidth, is the machine’s ridge pointPeak FLOP/s divided by memory bandwidth, in FLOPs per byte. Work with intensity below it is limited by memory; above it, by math. Every chip has its own.. It is a property of the hardware, not of your code. Below the ridge you are memory-boundThe work finishes when the data finishes arriving. Faster math would not help; fewer or faster bytes would. Also called bandwidth-bound.. Above it you are compute-boundThe data arrives faster than the math can use it. Now faster math units would help, and fewer bytes would not..

A note on the 125 figure. NVIDIA markets the Spark as “up to 1 petaFLOP” of AI compute, which is FP4 precision with 2:4 sparsity, the most flattering number possible. A common rule of thumb for this generation is that dense is half of sparse, and each doubling of precision halves the rate: 1000 → 500 (dense FP4) → 250 (FP8) → 125 (BF16). Treat 125 TFLOP/s as an upper bound, not a spec. The real dense BF16 rate may be lower, and you will measure it yourself with one big matrix multiply. Whatever it turns out to be, the ridge point stays in the hundreds, and decode at 1 FLOP/byte stays hopelessly far to the left of it.

The roofline

In 2009, Samuel Williams, Andrew Waterman and David Patterson drew this idea as a picture, and the picture stuck. It is called the roofline modelA plot of achievable FLOP/s against arithmetic intensity. A sloped line (bandwidth × intensity) meets a flat line (peak compute). Your workload sits somewhere under that roof..

Put arithmetic intensity on the x-axis and achieved FLOP/s on the y-axis, both on log scales. Two lines limit you. The flat line at the top is the compute roof: you cannot go faster than the chip’s peak math. The sloped line on the left is the bandwidth slope: if you do I FLOPs per byte and bytes arrive at 273 GB/s, you can do at most 273e9 × I FLOPs per second. Wherever the two meet is the ridge point. Your program lives somewhere under the roof, and which line is above it tells you what to fix.

Here is the calculator for this whole lesson. Start with the defaults (8B, bf16, Spark, batch 1). Then drag batch up slowly and watch the dot walk up the slope. Then drag context up and watch it stall.

FIG_003[ DECODE CALCULATOR + ROOFLINE ]
Model
Weights
Hardware
ARITHMETIC INTENSITY · FLOPs PER BYTE MOVED (LOG)TFLOP/S DONE (LOG)
Per stream
- tok/s
All streams
- tok/s
Step time
-
Intensity / ridge
-
Bound by
-
Memory needed
-

Hardware numbers are approximate spec-sheet figures (dense BF16; the Spark's compute is an upper-bound estimate). The dashed trail shows every batch size at the current settings. KV cache assumed bf16.

A few things to try, because each one is a later lesson in miniature:

Prefill is the opposite workload

There is one more piece before the escape routes, and it matters a lot for the next lesson’s metrics.

When you send a prompt, the model does not process it one token at a time. All the prompt’s tokens are known up front, so it runs them through the model together in one pass. This is prefillThe first phase of generation: running the whole prompt through the model at once to fill the KV cache and produce the first output token.. Each weight matrix now multiplies a whole block of token vectors at once, a matrix-matrix multiplyMultiplying a matrix by another matrix (here: weights × many tokens). Each weight, once loaded, is reused for every token in the block, so work per byte grows with the number of tokens.. A 1,000-token prompt reuses every weight 1,000 times per read. Arithmetic intensity is about 1,000. That is over the ridge: prefill is compute-bound.

Then comes decodeThe second phase: generating output tokens one at a time, each depending on the one before. Each step is a matrix-vector multiply per layer, so it is memory-bound.. You cannot do decode tokens in parallel, because token 5 depends on token 4, which you have not picked yet. One at a time, one full read of the weights per token.

FIG_004[ PREFILL VS DECODE ]
PREFILL · MATRIX × MATRIXW×N PROMPT TOKENSONE READ OF W → N TOKENS OF MATHINTENSITY ≈ N FLOP/BYTE · COMPUTE-BOUNDSETS TIME TO FIRST TOKENDECODE · MATRIX × VECTORW×1 TOKENONE READ OF W → 1 TOKEN OF MATHINTENSITY ≈ 1 FLOP/BYTE · MEMORY-BOUNDSETS TOKENS PER SECOND
Same weights, same read. Prefill does N tokens of math per read; decode does one.

So the two numbers you will benchmark against Fireworks in the next lesson come from opposite sides of the roofline:

Same model, same number of tokens: about 0.13 seconds one way and about a minute the other. If you remember one picture from this lesson, make it that one.

The KV cache is also bytes

You know the KV cacheThe stored keys and values for every past token, in every layer, so attention does not have to recompute them each step. It is read in full at every decode step. saves recomputing attention over the past. What it costs is memory, and since attention has to look at every past key and value each step, it is also more bytes to read per token. The weights are the same for every token; the KV cache grows with every token.

Size per token of context:

KV bytes/token = 2 (K,V) × layers × kv_heads × head_dim × bytes/value

Llama 3.1 8B:   2 × 32 × 8 × 128 × 2  =  131,072 bytes  =  128 KiB

Llama 3.1 8B has 32 query heads but only 8 key/value heads, a trick called GQAGrouped-query attention: several query heads share one set of key and value heads. Llama 3.1 8B shares each K/V head across 4 query heads, which cuts the KV cache by 4x.. Without it, the cache would be 4x bigger. Now scale it:

FIG_005[ WEIGHTS VS KV CACHE, ONE STREAM ]
BYTES READ PER TOKENCEILINGWEIGHTS16.1 GB-KV @ 1K0.13 GB16.9 TOK/SKV @ 8K1.1 GB15.9 TOK/SKV @ 32K4.3 GB13.4 TOK/SKV @ 128K17.2 GB8.2 TOK/SCEILING = 273 GB/S ÷ (WEIGHTS + KV)
Llama 3.1 8B, bf16 weights and cache, spec-sheet 273 GB/s. At 128K context the cache outweighs the model and the ceiling halves.

For a single chat at normal lengths the cache is noise: 8K tokens of context costs you about 6% of your speed. It starts to matter in two situations. One is very long context, as in the figure. The other is batching, which is where it gets interesting: when 32 users each have 4K tokens of context, that is 32 × 4,096 × 128 KiB = 16 GiB, the same as one user at 128K. Weights are shared across a batch. KV caches are not. Each user’s cache belongs to that user and has to be read separately. This is why, in Figure 003, throughput climbs steeply with batch at short context and then flattens long before the ridge point at 1K and beyond. It is also why the next few lessons spend so much effort on the cache: how much memory it takes, what is in it, and how to share it.

(The formula above counts bytes, not FLOPs. Attention’s math per token also grows with context, but its intensity stays low, a few FLOPs per byte with GQA, because each cached key and value is used once per step. Attention in decode is memory-bound too.)

Every way around the wall

The whole equation for single-stream decode fits on one line:

tokens/sec  ≈  usable bandwidth  ÷  bytes read per token

Everything in the rest of this track attacks one side of that division, or changes how many tokens you get out of one read.

FIG_006[ THE ESCAPE ROUTES ]
TOK/S ≈ BANDWIDTH ÷ BYTES PER TOKEN× TOKENS PER READFEWER BYTESPER WEIGHTFP8: 2×4-BIT: ~4×QUANTIZATIONFEWER WEIGHTSPER TOKEN30B STORED3B READMOEMANY USERSPER READB STREAMS,ONE READBATCHINGMANY TOKENSPER READGUESS K,CHECK IN ONESPECULATIONWIDERPIPE273 GB/S →3,350 GB/SHBMAND FOR THE KV SIDE OF THE BYTES:GQA · KV QUANTIZATION · PAGED + PREFIX-SHARED CACHEFOR PREFILL (COMPUTE SIDE): FLASHATTENTION · PREFIX CACHING SKIPS IT ENTIRELY
Each optimization in this track is a way to get more tokens out of each byte that crosses the pipe.

Fewer bytes per weight: quantization. Store each weight in 8 bits instead of 16 and you read 8 GB per token instead of 16: ceiling 34 tok/s. In 4 bits, roughly 4 GB and 68 tok/s. Real 4-bit formats store a scale factor alongside each small group of weights, so they average closer to 4.5 to 5 bits per weight; a popular 4-bit file of Llama 3.1 8B (llama.cpp’s Q4_K_M) is about 4.9 GB, which predicts about 55 tok/s. The cost is accuracy, and measuring that cost honestly is the quantization lesson. Notice the math still happens in 16-bit or so after unpacking; the win is purely in the bytes crossing the pipe.

Fewer weights per token: mixture of experts. An MoEMixture of experts: a model whose MLP layers are split into many smaller “expert” MLPs, with a small router that sends each token to only a few of them. Most weights sit idle for any given token. model replaces each big MLP with many small ones and only uses a few per token. Qwen3 30B-A3B has about 30.5 billion parameters in total, but each token activates only about 3.3 billion (8 of its 128 experts per layer, plus the shared attention). On bandwidth, it decodes like a 3B model: in bf16, about 6.7 GB per token, ceiling about 41 tok/s. But all 61 GB of weights have to sit in memory, because the next token might want different experts. This is where the Spark is unusual. Its 128 GB is slow, but it is a lot. A 24 GB gaming card cannot even hold that model; the Spark holds it and has room left over. Big-but-sparse models are the Spark’s sweet spot. (One catch, visible in Figure 003: batch many users and between them they touch most of the experts, so the MoE advantage shrinks under heavy batching.)

Many users per read: batching. This is the one inference companies live on. Process B users’ tokens in the same step, and each weight read serves B tokens. While you are far below the ridge point, the step barely gets slower, so throughput goes up almost B-fold for free. Per-token cost is GPU-hours divided by tokens produced; if the GPU-hour produces 50x the tokens, each token costs about 1/50 as much. The limits are the ridge point (the math finally catches up) and the KV cache (each user’s cache is their own bytes). Batching and continuous batching are how real servers ride that curve.

Many tokens per read: speculative decoding. If you could somehow know the next 4 tokens in advance, you could check all of them in one forward pass, like a tiny prefill, for about the price of one weight read. Speculative decoding uses a small, fast draft model to guess the next few tokens, then has the big model check them all in one pass. When the guesses are good, you get several tokens per trip through the weights. It works precisely because decode leaves the math units idle: checking 5 tokens instead of 1 costs almost nothing extra on a memory-bound machine.

A wider pipe: faster memory. The brute-force option. Data-center GPUs use HBMHigh-bandwidth memory: DRAM chips stacked vertically and placed right next to the GPU on the same package, with thousands of wires between them. Much faster than normal memory, and much more expensive per GB.. The H100 SXM’s spec is about 3.35 TB/s, roughly 12x the Spark. That buys about 209 tok/s for an 8B bf16 model, single stream. The Spark trades bandwidth for capacity: lots of affordable memory, slowly.

The rest of the track fills in the remaining pieces: serving engines like vLLM combine all of these at once, and FlashAttention attacks the memory traffic inside attention itself, which matters most for long prompts.

Predict firstwrite it down

Before you build anything, predict the decode ceiling on the Spark (spec 273 GB/s, batch 1, short context) for: (a) Llama 3.2 1B in bf16, (b) Llama 3.1 8B in Q8_0 (8.5 bits per weight), (c) Llama 3.1 8B in Q4_K_M (about 4.9 GB on disk).

Reveal

(a) 1.24 billion params × 2 bytes ≈ 2.5 GB, so 273 / 2.5 ≈ 110 tok/s. (This model shares its embedding and output matrix, so the whole thing really is read every token.) (b) 8.03e9 × 8.5 / 8 ≈ 8.5 GB, so ~32 tok/s. (c) 273 / 4.9 ≈ ~55 tok/s. Expect the small model to land furthest below its ceiling: with only 2.5 GB to read per token, fixed per-step costs (launching kernels, sampling, Python) become a bigger share of each 9 ms step.

Build It

You will measure the two numbers that define your Spark’s roofline, then use them to predict real model speeds, then check the predictions with a real engine. Nothing here is fancy. All of it is the kind of measurement that strong inference engineers do on day one with new hardware.

Get a working CUDA PyTorch on the SparkAgent: fine

The Spark runs DGX OS (Ubuntu) on an ARM64 CPU, so x86 instructions and some pip wheels do not apply. The least painful path is usually NVIDIA’s own containers. Check NVIDIA’s DGX Spark playbooks and the DGX Spark user guide (both on NVIDIA’s site; search “DGX Spark playbooks”) for the currently recommended PyTorch container and the exact docker run line. Do not trust a command from memory, including this lesson’s: the supported images change.

Once you are inside, confirm the GPU is visible:

import torch
print(torch.__version__, torch.cuda.is_available())
print(torch.cuda.get_device_name(0))
props = torch.cuda.get_device_properties(0)
print(f"{props.total_memory / 1e9:.1f} GB visible to CUDA, {props.multi_processor_count} SMs")
Measure the real memory bandwidthHands only

Write bw.py. The idea: allocate two big buffers on the GPU, copy one into the other many times, and divide bytes moved by time. You write it. The shape:

import torch, time

def copy_bandwidth(n_bytes: int, iters: int = 50) -> float:
    """Return achieved GB/s for a device-to-device copy of n_bytes."""
    # 1. allocate src and dst: torch.empty(n_bytes, dtype=torch.uint8, device="cuda")
    # 2. warm up: a few copies first (the first call pays one-time costs)
    # 3. synchronize, start the clock, run `iters` copies, synchronize, stop the clock
    # 4. bytes moved per copy = ?  (think before you answer)
    ...

for size_mb in [8, 32, 128, 512, 2048, 8192]:
    print(size_mb, "MB:", f"{copy_bandwidth(size_mb * 2**20):.0f} GB/s")

Hints, in order of how often people get them wrong:

  • Count both directions. dst.copy_(src) reads every byte of src and writes every byte of dst. Both cross the memory bus. Bytes moved per copy is 2 * n_bytes.
  • Synchronize. As you saw in Waiting vs working, a CUDA call returns as soon as the work is queued. Without torch.cuda.synchronize() before stopping the clock, you time the queueing, not the copy. (CUDA events, torch.cuda.Event, are the other correct way.)
  • Go big. Small buffers fit in the GPU’s on-chip cache and report fantasy numbers. The sweep is there so you can see where the cache stops helping. Trust only the sizes in the GBs.
  • Optional second measurement: a read-only kernel like src.float().sum() is not ideal (the cast writes a temporary), but src.sum() on a big float16 tensor mostly reads. Compare its GB/s (counting n_bytes once) with the copy.
Measure the real compute roofHands only

Write flops.py: time a big bf16 matrix multiply and report TFLOP/s. A square matmul of size n×n by n×n does 2 * n**3 FLOPs (n³ multiply-adds). Sweep n from 1024 to 16384. Same rules: warm up, synchronize, run several iterations.

def matmul_tflops(n: int, iters: int = 20) -> float:
    a = torch.randn(n, n, dtype=torch.bfloat16, device="cuda")
    b = torch.randn(n, n, dtype=torch.bfloat16, device="cuda")
    # warm up, synchronize, time `iters` of (a @ b), synchronize
    # return 2 * n**3 * iters / seconds / 1e12
    ...

Then compute your machine’s ridge point from your two numbers: measured TFLOP/s × 1e12 ÷ measured GB/s × 1e9. Small matmuls will look slow; that is the roofline again (a small matrix has too little work per byte to fill the chip). The number you want is the plateau at large n.

Write the prediction sheetHands only

Write a function and fill in a table before you run any model:

def predict_decode(params_read: float, bytes_per_weight: float, bw_gbs: float) -> float:
    """Ceiling tokens/sec at batch 1, short context."""
    ...

Predict, using your measured bandwidth from t2 (and also the 273 spec number, for comparison): Llama 3.2 1B bf16, Llama 3.1 8B in BF16, Q8_0 and Q4_K_M GGUF. Use the model file size on disk as “bytes per token” for GGUF files; it is a good approximation, since nearly all of the file is weights.

Measure real decode speed at batch 1Agent: explain only

Use an existing engine. Two good choices; pick one, read its docs, and check the DGX Spark playbooks first, because NVIDIA publishes tested setups for some of these:

  • llama.cpp, built with CUDA. Its llama-bench tool measures prompt processing and generation speed separately. A typical run looks like llama-bench -m model.gguf -p 512 -n 128, which reports pp512 (prefill speed, tokens/sec) and tg128 (decode speed, tokens/sec). Check llama-bench --help; the flags evolve.
  • vLLM, if you would rather use the engine you will meet in serving engines. It has its own benchmark scripts; look up the current ones in the vLLM docs rather than trusting a remembered command.

Download the GGUF files from Hugging Face (the 1B in BF16 or F16, the 8B in BF16, Q8_0 and Q4_K_M). Record decode tokens/sec for each. Note the prefill number too; you will want it for the next lesson.

Measure throughput vs batch sizeAgent: explain only

Now watch batching happen. llama.cpp ships a llama-batched-bench tool that runs several sequences in parallel and reports generation speed for each batch size (look for options to set the list of parallel sequence counts, prompt length and generation length; check its --help). vLLM’s throughput benchmark does the same at a higher level. Measure the 8B Q4_K_M at batch 1, 2, 4, 8, 16, 32, 64 with a short prompt. Record total tokens/sec and per-stream tokens/sec.

Done when
  • bw.py prints a bandwidth sweep, and you can say which sizes were hitting cache and what your real sustained GB/s is.
  • flops.py prints a TFLOP/s plateau, and you have computed your Spark’s measured ridge point.
  • You have a prediction table written down before running a single model.
  • You have measured batch-1 decode speed for at least three model/precision combinations and a throughput-vs-batch curve for one.

Measure

Fill this in. “Ceiling” uses your measured bandwidth; ”% of ceiling” is measured ÷ ceiling.

ModelFormatBytes / tokenCeiling @ 273 specCeiling @ measured BWMeasured tok/s% of ceiling
Llama 3.2 1BBF16~2.5 GB~110
Llama 3.1 8BBF16~16 GB~17
Llama 3.1 8BQ8_0~8.5 GB~32
Llama 3.1 8BQ4_K_M~4.9 GB~55

And the machine itself:

QuantitySpec / estimateMeasured
Memory bandwidth273 GB/s (spec sheet)
Dense BF16 matmul≤125 TFLOP/s (estimate)
Ridge point≤~460 FLOP/byte

What to expect. Your copy benchmark will almost certainly come in below 273 GB/s. That is normal for every memory system: the spec is the theoretical peak of the memory chips’ pins, and real traffic loses some to refresh cycles, scheduling and the way reads and writes take turns. Write down your number without judging it; it is the real pipe.

Real engines on a memory-bound model typically land somewhere around 60 to 85% of the ceiling you compute from spec bandwidth, and closer to your measured-bandwidth ceiling. Do not take that range as gospel; it is what you are here to measure. The gap comes from a few honest places:

The batch curve should look like Figure 003: total throughput climbs almost linearly at first (batch 2 gives nearly 2x), then bends and flattens. Per-stream speed sags slowly as batch grows. Where does it bend? Compare against your measured ridge point and against the KV cache bytes from the formula. With short prompts and a 4-bit model, you can plausibly get into the hundreds of tokens per second in total, while each individual stream still runs slower than at batch 1.

Break It

Tiny buffers. Run bw.py with an 8 MB buffer and believe the result. You will “measure” bandwidth that is far beyond the spec, because the whole buffer lives in the GPU’s on-chip cache, which is much faster than main memory. This is the latency ladder from lesson 1 showing up as bandwidth: every level of the memory hierarchy has its own pipe width. LLM weights are far too large for any cache, which is exactly why decode sees the slow pipe.

Forget to synchronize. Delete the torch.cuda.synchronize() before you stop the clock. Your bandwidth and TFLOP/s numbers become impossible, sometimes hundreds of times the spec, because you timed how fast Python can put work in a queue. Every GPU benchmark you ever read online that looks too good to be true should make you ask this question first.

Count bytes once. Report the copy as n_bytes / t instead of 2 * n_bytes / t. Your bandwidth halves, every ceiling halves, and your engine suddenly appears to beat the laws of physics at 150% of ceiling. When a measurement exceeds a ceiling, the ceiling is wrong; go find the factor of two.

Make the cache heavy. Run the 8B with a very long prompt already in context (llama-bench can benchmark generation at a given context depth in recent versions; check --help) and watch decode speed drop. Predict the drop first with the KV formula: 32K tokens adds about 4.3 GB per step on top of 16 GB of bf16 weights, or on top of 4.9 GB for Q4_K_M, where it hurts proportionally much more. (Some engines quantize the KV cache too; check what yours does before predicting.)

Explain It Back

Say each answer out loud, in plain words, as if a smart interviewer asked.

1. Why is LLM decoding limited by memory bandwidth rather than compute?

Each new token depends on the previous one, so you generate one at a time. For each token, every weight matrix multiplies a single vector, which means each weight gets read from memory and used for exactly one multiply-add before you are done with it. That is about 1 FLOP per byte in bf16. The GPU can do hundreds of FLOPs in the time it takes one byte to arrive, so it spends almost all its time waiting for weights. The speed is set by how fast you can stream the whole model out of memory, once per token.

2. Why doesn’t a GPU with 2x the FLOPs make your chatbot 2x faster?

Because the chatbot (one user, decoding) is not using the FLOPs it already has; the math units are idle over 99% of the time. Doubling them doubles the idle capacity. What would help is doubling memory bandwidth, or halving the bytes per token. A 2x-FLOPs chip does help prefill (time to first token on a long prompt), and it raises the ridge point, which lets a server batch more users before going compute-bound. But single-stream tokens per second stays put.

3. Estimate the decode speed of a 70B model in 4-bit on the Spark.

70 billion weights × about half a byte = about 35 GB per token. 273 GB/s ÷ 35 GB ≈ 8 tok/s as a ceiling. Real 4-bit formats are closer to 4.5-5 bits per weight, so the file is more like 40-43 GB, giving a ceiling around 6-7 tok/s, and you would measure somewhat less than that: call it roughly 4-6 tok/s. Usable for reading along, slow for anything interactive. It does fit in 128 GB, which is the Spark’s selling point; a 24 GB card cannot even load it.

4. How does this scale: why does per-token cost drop with batching, and where does that stop?

The expensive part of a decode step is reading the weights, and that cost is the same whether one user or 64 users’ tokens ride along. So throughput goes up nearly B-fold while the hardware cost per hour stays the same, and cost per token falls roughly as 1/B. It stops for two reasons. First, at the ridge point (peak FLOPs ÷ bandwidth, a few hundred on these chips), the math becomes the bottleneck and extra users make each step slower. Second, and usually sooner at realistic context lengths, each user’s KV cache is extra bytes that are not shared, so total bytes per step grow with B and the throughput curve flattens. Memory capacity is a third wall: all those caches have to fit. This is why serving companies care so much about batching, KV cache size and memory capacity.

5. Prefill vs decode: which one is compute-bound, and which user-facing metric does each set?

Prefill processes all prompt tokens in one pass, so each weight read is reused for every token in the prompt; intensity is about the prompt length, well above the ridge, so it is compute-bound. It sets time to first token. Decode generates one token per pass, intensity about 1 FLOP/byte, so it is memory-bound. It sets tokens per second. That is why a provider’s TTFT and tokens/sec can be improved by different hardware and different tricks.

6. Why does a 30B mixture-of-experts model decode faster than a dense 8B on the Spark?

Speed depends on bytes read per token, not bytes stored. Qwen3 30B-A3B only activates about 3.3B parameters per token, so it reads about 6.7 GB per token in bf16, less than half of a dense 8B’s 16 GB. It does need about 61 GB of memory to hold all the experts, which the Spark has. Under heavy batching different users route to different experts, so the batch as a whole reads more of the model and the advantage shrinks.

Go Deeper

Connections

This lesson is the spine of the Inference track. Built on: How slow is slow? (bandwidth vs latency) and Waiting vs working (why you synchronize before timing).

Next: Baseline: my Spark vs Fireworks. You will build a naive streaming server and benchmark it. The ceiling you computed here is the line that server will be measured against: how close does it get, and where does the rest go?

After that, each lesson in the track is one of the escape routes: memory math and the KV cache (counting the bytes), batching (sharing each read), prefix caching (skipping prefill), quantization (fewer bytes per weight), speculative decoding (more tokens per read), serving engines (all of it at once), and FlashAttention (fewer bytes inside attention).