Why a fast AI chip spends most of its time waiting

A GPU can calculate far faster than its memory can feed it. See why reusing data sets the speed, and why one chatbot user keeps a chip under 1% busy.

  • 5 min read
  • 22 min to go deeper
On this page
  1. In one minute
  2. Explore the numbers
  3. How it works
  4. What this means when you build
  5. Go deeperoptional
  6. Skills this lesson builds
  7. Check yourself
  8. Further reading
  9. Codeoptional

An NVIDIA A100 is a GPU, a graphics processor and the kind of chip that runs most AI models. It can do 312 trillion calculations every second. Yet while it writes a chatbot reply for one person, with numbers stored in the usual 2 bytes, it reaches at most 0.7% of that speed. The chip is not slow. It sits idle, waiting for numbers to arrive from its memory. This lesson shows why the wait happens, what ends it, and what that means when you build with AI.

In one minute

A chatbot is memory-bound while it writes a reply: for each new word the chip reads all of the model’s billions of stored numbers from memory and uses each one only once, so its calculators wait for memory. Memory-bound means that memory speed, not calculating speed, sets the pace. Picture a workbench fed from a warehouse across town: carry one part per trip and you spend the day walking; carry a crate of parts that belong together and you spend the day working. A long prompt (the text you send), many users sharing each trip, or a big job cut into reusable blocks is the crate.

Explore the numbers

Open the widget and drag the slider from one word at a time to many at once. Watch the “Chip busy” bar: it starts almost empty and is full somewhere between 128 and 256 words, as more words share each trip to memory. Then press “Multiplying in tiles” and drag from small tiles to big ones to see the same rise from cutting one big job into blocks.

How it works

Two speed limits

The A100 has two limits. The first is calculation: 312 trillion operations a second, where an operation is one addition or one multiplication. The second is delivery: its memory can send 2,039 billion bytes a second. A byte is the unit memory is counted in, and the delivery rate is called bandwidth. The calculation figure is for numbers stored in 2 bytes each, the compact format AI models often use.

Divide the first limit by the second and you get 153: 312 trillion operations a second divided by 2,039 billion bytes a second. The chip stays busy only if the program does 153 operations on every byte it fetches. That ratio, operations per byte fetched, is called arithmetic intensity.

Reuse makes the difference

Most of AI is multiplying matrices, which are grids of numbers. Each number in the answer comes from one row of the first grid and one column of the second. The textbook method fetches that row and column from memory every time, even if it fetched the same row a moment ago. Fetched that way, multiplying two grids of 4096 by 4096 numbers does only 0.5 operations per byte, and the A100 reaches 0.33% of its top speed.

The fix is reuse. Cut each grid into small square blocks called tiles. Bring a few tiles into a small, fast memory right beside the calculators, and finish all the work they allow before fetching anything else. Figure 1 shows a small case: the four green results in one corner need 16 numbers. Fetching for each result separately costs 32 fetches. Fetching tiles costs 16. Bigger tiles save more, but the fast memory is small, so a tile can only grow so far.

Two panels, each a 4 by 4 grid times a 4 by 4 grid giving a 4 by 4 grid. Naive panel: 16 numbers fetched twice each, 32 fetches. Tiled panel: the same 16 numbers fetched once, 16 fetches.

Figure 1. Computing the four green results in one corner. Top: fetch again for every result, 32 fetches. Bottom: fetch each number once and share it, 16 fetches. In a large multiply the saving equals the tile width.

The roofline

Plot speed against work per byte and you get Figure 2, called the roofline: a slope that climbs, then a flat roof. On the slope a job is memory-bound: the chip waits on memory, so faster calculators would not help, but more work per byte would. On the roof a job is compute-bound: calculation is the limit and the chip is busy. The bend between them is the ridge, at 153 for the A100.

Three jobs are marked. The textbook multiply sits low on the slope. Tiles 64 wide reach about 32 operations per byte, and tiles 512 wide reach about 241, past the ridge.

Roofline of one A100: speed climbs with work per byte up to a ridge at 153, then runs flat at 312. The textbook multiply (0.5) and tiles 64 wide (about 32) sit on the slope; tiles 512 wide (about 241) sit on the flat roof.

Figure 2. Speed of one A100 against work per byte fetched, both on scales where each gridline is ten times the last. The orange area waits on memory; the green area is busy. The top speed is 312 trillion operations per second.

Why a chatbot waits

A language model writes text one token at a time, where a token is a word or a piece of a word. (Until Go deeper, this lesson says “words” for tokens.) Its knowledge sits in weights, billions of numbers kept in memory. To write one word, a standard model’s chip must pull every weight out of memory and use it. Each weight is used about once, so the work per byte is about 1, far left of the ridge at 153. Writing the reply word by word is called decode, and it is memory-bound. Reading your prompt first, called prefill, is different: the model handles all the prompt’s words together, so each weight it fetches serves many words. In this example a prompt of 166 words or more puts the chip on the roof; a one-line question is too short to.

A server can also group the next words of many users and run them together, a batch. Each weight fetched then serves the whole batch. Figure 3 shows the effect on one 4096 by 4096 grid of weights: with one word it reaches 0.7% of the A100’s top speed, with 128 words 78.7%, and with 166 words the chip is busy.

Horizontal bars of the share of top speed against words handled together: 1 word 0.7%, 8 words 5.2%, 32 words 20.6%, 128 words 78.7%, then 166 and 1,024 words 100%. The first four are orange, the last two green.

Figure 3. Share of the A100’s top speed against words handled together, for one 4096 by 4096 grid of weights. Orange bars wait on memory; green bars are busy. The words can come from many users in a batch or from one long prompt.

What this means when you build

  • Estimate your speed limit before you rent a GPU. One chat cannot write faster than the memory can deliver the whole model once per word, so divide the memory speed by the model’s size in bytes. A model with 7 billion numbers (an example size) stored in 2 bytes each is 14 GB, and the A100’s 2,039 GB/s allows at most about 146 words a second. Real servers fall below this, so use it as a ceiling, not a forecast.
  • Smaller numbers buy speed. Storing each number in 1 byte instead of 2 halves what must be read, so the same ceiling doubles to about 291 words a second, at some cost in quality, so test on your own task. This is called quantization.
  • Share the chip between chats, within memory limits. In this example about 166 chats at once keep the chip busy, and about 80 if the numbers take 1 byte. Each chat also keeps its own working notes (the attention cache), which cannot be shared and fill the GPU’s memory, so the real group is smaller. If you call a hosted model, the provider does the sharing; send independent requests together, not one after another.
  • Long replies cost more than long prompts, and measure before you buy. A prompt of a few hundred words is read in one pass with the chip mostly busy, but every word of the reply needs a fresh trip through the weights, so a shorter reply saves more time than a shorter prompt. Before paying for a faster chip, check which limit your job hits: if it waits on memory, doubling calculation speed changes nothing. Profilers, tools that show where a program spends its time, report this; NVIDIA’s Nsight Compute is one.

Optionalabout 22 min

Go deeper

Optional. For readers who write Python and are comfortable with a little algebra. Numbers are 16-bit floating-point (FP16), 2 bytes each, unless stated. Run python3 tiled_roofline.py in code/ to print most numbers below that cite no source.

Before you start

The naive approach and why it fails

Counting work and traffic

  • A FLOP is one floating-point addition or multiplication. A multiply-add counts as 2 FLOPs, the convention NVIDIA uses for its peak figure.
  • Bytes moved are the bytes crossing between main memory and a small fast memory beside the arithmetic units. Arithmetic intensity is FLOPs divided by bytes moved.

Reads from fast memory are free in this model; we count only the numbers crossing between the two memories, and the FLOPs.

Take C = A times B with 2 by 2 matrices of 4-byte floats. Each of the four outputs is a dot product of length 2, which is 2 multiply-adds or 4 FLOPs, so 16 FLOPs in all. The least traffic reads A and B and writes C: 12 numbers, or 48 bytes. The intensity is 16 / 48 = 0.333 FLOP per byte.

For n by n matrices the work is 2n³ FLOPs on 3n² numbers. With s bytes per number, the best intensity is 2n / (3s). The code prints these values for 4-byte floats; the 2-byte FP16 numbers used from here on double them:

n best-case intensity (FLOP/byte)
2 0.333
16 2.667
256 42.667
4096 682.667

Work grows as n³ and data as n², so big matrices hold much potential reuse.

The roofline

A processor does arithmetic and memory traffic at the same time, and the slower job sets the pace. Call one piece of GPU work, such as this multiply, a kernel. A kernel with F FLOPs and D bytes of traffic needs at least F / peak seconds for arithmetic and D / bandwidth seconds for traffic. Dividing F by the larger time gives the speed limit:

attainable FLOP/s = min(peak, bandwidth x intensity)

The slope and the roof meet at the ridge point, where intensity equals peak over bandwidth. Left of it a kernel is memory-bound; at or right of it, compute-bound.

For the A100 we use two numbers from NVIDIA’s data sheet (revision “Jun21”, 80 GB SXM column): 312 TFLOP/s for dense 16-bit tensor-core math, and 2,039 GB/s of memory bandwidth (GB means 10⁹ bytes). Dense means every number is used; the sheet’s 624 figure assumes a sparse mode that skips some zeros.

A100_PEAK_FLOPS = 312e12
A100_BANDWIDTH = 2039e9

def ridge(peak_flops, bandwidth):
    return peak_flops / bandwidth      # 153.0 FLOP/byte

The naive multiply on an A100

Here is the textbook multiply, and the traffic we charge it:

def naive_matmul(A, B):
    m, k, n = len(A), len(B), len(B[0])
    C = [[0.0] * n for _ in range(m)]
    for i in range(m):
        for j in range(n):
            s = 0.0
            for p in range(k):
                s += A[i][p] * B[p][j]
            C[i][j] = s
    return C

def naive_traffic(m, n, k):
    return Traffic(loads=2 * m * n * k, stores=m * n, flops=2 * m * n * k)

In the no-reuse model, every multiply-add fetches A[i][p] and B[p][j] from slow memory: 2 FLOPs per 2 numbers, so about 1 FLOP per number, or 0.5 FLOP per byte in FP16. For 4096 by 4096 matrices the code reports:

Quantity Value
FLOPs 1.3744 x 10¹¹
Bytes moved, no reuse 2.7491 x 10¹¹ (about 275 GB)
Intensity 0.500 FLOP/byte
Attainable speed on the A100 1.019 TFLOP/s, which is 0.33% of peak
Time floor from the math 0.441 ms
Time floor from the traffic 134.8 ms

The traffic floor is about 306 times the math floor, so this multiply would spend nearly all its time waiting for memory. In the best case each number moves once: 100.7 MB, an intensity of 1365.3, and a traffic floor of 0.0494 ms, below the math floor.

Real chips have caches, small automatic fast memories, so a real loop already gets some reuse. These numbers show what reuse is worth, not how a program runs.

The idea behind tiling

Tiling keeps one tile of A, one of B and the C tile being built in fast memory. Each element of A is used b times per load, and each element of B likewise, so loads drop by a factor of b while the FLOPs stay the same.

A hand-worked example

Multiply two 4 by 4 matrices with 2 by 2 tiles.

A =  1 2 0 1        B =  1 2 2 1
     3 1 2 0             0 1 1 0
     0 1 1 2             2 1 0 1
     2 0 1 1             1 2 1 0

Each matrix becomes four tiles; A(I,J) is the tile in block-row I and block-column J. The C tile at (I,J) is the sum over P of A(I,P) times B(P,J): two steps per output tile. Each step loads an A tile and a B tile (4 + 4 = 8 numbers) and does 2 x 2 x 2 x 2 = 16 FLOPs. The loop in the next section visits the steps in this order:

Step Output tile (I,J) P A tile loaded B tile loaded Loads this step Loads so far FLOPs so far
1 (0,0) 0 A(0,0) B(0,0) 8 8 16
2 (0,0) 1 A(0,1) B(1,0) 8 16 32
3 (0,1) 0 A(0,0) B(0,1) 8 24 48
4 (0,1) 1 A(0,1) B(1,1) 8 32 64
5 (1,0) 0 A(1,0) B(0,0) 8 40 80
6 (1,0) 1 A(1,1) B(1,0) 8 48 96
7 (1,1) 0 A(1,0) B(0,1) 8 56 112
8 (1,1) 1 A(1,1) B(1,1) 8 64 128

After steps 2, 4, 6 and 8 the finished C tile is written once: 4 stores each, 16 in total.

Now follow C(0,0). It uses A(0,0) = [[1,2],[3,1]], B(0,0) = [[1,2],[0,1]], A(0,1) = [[0,1],[2,0]] and B(1,0) = [[2,1],[1,2]]. Each entry is a dot product split across the two steps:

Entry of C(0,0) Step 1 (P = 0) Step 2 (P = 1) Total
row 0, column 0 1x1 + 2x0 = 1 0x2 + 1x1 = 1 2
row 0, column 1 1x2 + 2x1 = 4 0x1 + 1x2 = 2 6
row 1, column 0 3x1 + 1x0 = 3 2x2 + 0x1 = 4 7
row 1, column 1 3x2 + 1x1 = 7 2x1 + 0x2 = 2 9

So C(0,0) = [[2,6],[7,9]]. The other three tiles follow the same pattern, and the full product is:

C =  2 6 5 1
     7 9 7 5
     4 6 3 1
     5 7 5 3

Count the loads. Without tiles, each of the 16 outputs has 4 terms and each term fetches 2 numbers: 16 x 4 x 2 = 128 loads. With 2 by 2 tiles the table gives 64, so the saving equals the tile size. For C(0,0) alone it is 32 loads against 16. Both ways do 128 FLOPs and 16 stores, so the intensity per number moved rises from 128 / (128 + 16) = 0.89 to 128 / (64 + 16) = 1.6.

The real implementation

The tiled loop

This is the core of code/tiled_roofline.py. It computes the product and counts what a two-level memory would see.

def tiled_matmul(A, B, b):
    m, k = A.shape
    _, n = B.shape
    C = np.zeros((m, n), dtype=np.result_type(A, B))
    t = Traffic()
    for i in range(0, m, b):
        for j in range(0, n, b):
            acc = np.zeros((min(b, m - i), min(b, n - j)), dtype=C.dtype)
            for p in range(0, k, b):
                a_tile = A[i:i + b, p:p + b]
                b_tile = B[p:p + b, j:j + b]
                t.loads += a_tile.size + b_tile.size
                t.flops += 2 * a_tile.shape[0] * a_tile.shape[1] * b_tile.shape[1]
                acc += a_tile @ b_tile
            C[i:i + b, j:j + b] = acc
            t.stores += acc.size
    return C, t

The acc tile stays in fast memory while p walks along k. When b does not divide a size, the last tile is smaller; the min calls handle it.

From the loop to a formula

For square matrices of size n and tile size b, there are (n/b)² output tiles. Each takes n/b steps, and each step loads 2b² numbers. So:

loads  = (n/b)^2 x (n/b) x 2b^2 = 2n^3 / b
stores = n^2
FLOPs  = 2n^3

With s bytes per number, the intensity is

intensity = 2n^3 / (s x (2n^3/b + n^2))  =  (b / s) / (1 + b / (2n))

When b is much smaller than n, the last factor is close to 1 and the intensity is about b / s, so doubling the tile doubles the intensity. At b = 1 the formula gives the naive count, 2n³ loads. At b = n there is one tile per matrix, every number moves once, and the intensity reaches the best case 2n / (3s).

The code also has a closed form that handles sizes b does not divide. Each element of A loads once per column of output tiles, and each element of B once per row of output tiles:

def tiled_traffic(m, n, k, b):
    return Traffic(loads=ceil(n / b) * m * k + ceil(m / b) * k * n,
                   stores=m * n,
                   flops=2 * m * n * k)

Confirming the formula by counting

For n = 128, we run tiled_matmul, check that its product equals A @ B, and compare the counted loads with 2n³ / b. Intensity uses 2-byte numbers.

b Loads counted by the loop Loads from 2n³ / b Intensity (FLOP/byte)
4 1,048,576 1,048,576 1.97
8 524,288 524,288 3.88
16 262,144 262,144 7.53
32 131,072 131,072 14.22
64 65,536 65,536 25.60
128 32,768 32,768 42.67

The counts match exactly. Each row has 16,384 stores and 4,194,304 FLOPs. Stores put the intensity below b / s by the factor 1 + b / (2n), which is 1.5 at b = n = 128: 64 / 1.5 = 42.67, the best case 2n / (3s).

Now scale up to n = 4096 in FP16. Traffic is shown as a multiple of the minimum, 100.7 MB.

Tile size b Traffic against the minimum Intensity (FLOP/byte) Fast memory for three tiles
1 2,731 times 0.5 6 bytes
4 683 times 2.0 96 bytes
16 171 times 7.98 1,536 bytes
64 43 times 31.75 24,576 bytes
256 11 times 124.12 393,216 bytes
512 5.67 times 240.94 1,572,864 bytes
4096 1 time 1365.33 100,663,296 bytes

Doubling b nearly halves the traffic and quadruples the fast memory, so reuse is cheap at first and costly later.

Traffic over the minimum against tile size b, falling from 2,731 at b = 1 to 1 at b = 4096.

Figure 4. Traffic against tile size b, and the largest tile an example 64 KiB memory holds.

Fast memory limits the tile

Three b by b tiles of s-byte numbers need 3b²s bytes. Given a capacity, the largest tile is the integer square root of capacity / (3s):

def max_tile(fast_bytes, elem_bytes):
    return isqrt(fast_bytes // (3 * elem_bytes))

For FP16 numbers and four example capacities, the code prints:

Fast memory Largest tile b Intensity at n = 4096 (FLOP/byte)
16 KiB 52 25.8
64 KiB 104 50.6
256 KiB 209 99.9
1024 KiB 418 195.0

Because b grows with the square root of capacity, quadrupling the memory doubles the intensity. Hong and Kung (1981) proved that no way of computing the n³ products of the standard method beats this square-root law by more than a constant factor. Methods such as Strassen’s, which use fewer multiplications, follow different bounds.

How large must the tile be to reach the ridge on the A100? Setting b / s equal to the ridge gives b = 306. At n = 4096 the exact answer is larger, for two reasons. Writing C adds a term, which alone moves the answer to 318. The tile count must also be a whole number: 13 tiles per side give 151.7 and 12 give 163.8, against a ridge of 153.0, so b must be at least 4096 / 12 = 341.3, which is 342. Three such tiles need 701,784 bytes, about 0.7 MB.

What the model leaves out

A real GPU has several fast memories: registers and shared memory (small memories on the chip itself) and caches. Fast libraries tile at several levels at once, so read 342 as a model result, not a setting to copy. The A100 figure is also the dense 16-bit peak; other formats have other ridges.

Decode versus prefill

A large language model is mostly multiplications between activations (the numbers flowing through the network) and weights (the stored parameters, read from memory on every use).

In prefill, with M prompt tokens, each weight matrix multiplies an M-row activation matrix: matrix times matrix. In decode, each step multiplies one token’s vector by every weight matrix: matrix times vector. A server that decodes several requests together has M equal to the batch size.

Take one weight matrix of 4096 by 4096 FP16 numbers and M tokens. The FLOPs are 2 x M x 4096². The weights take 4096² x 2 bytes, about 33.6 MB, and dominate the traffic when M is small. At M = 1 each weight is used once, so tiling finds no reuse. For small M the intensity is close to M (1.00, 7.97 and 31.51 in the table), so each extra token moves the point about one unit to the right. With every number moved once, the code reports:

Tokens M Intensity (FLOP/byte) Verdict on the A100 Attainable (TFLOP/s) Share of peak
1 1.00 memory-bound 2.04 0.7%
8 7.97 memory-bound 16.25 5.2%
32 31.51 memory-bound 64.24 20.6%
128 120.47 memory-bound 245.64 78.7%
165 152.70 memory-bound 311.35 99.8%
166 153.55 compute-bound 312.00 100.0%
1024 682.67 compute-bound 312.00 100.0%

The table uses 2-byte numbers. With 1-byte weights, M = 1 gives an intensity of 2.0 and 1.3% of peak, and the roof starts at M = 80 (gemm_intensity(1, 4096, 4096, 1) and batch_to_reach_ridge(4096, 4096, 1, A100_PEAK_FLOPS, A100_BANDWIDTH)).

Roofline for a weight matrix times M tokens: M = 1 far left of the ridge, M = 1024 on the roof.

Figure 5. One weight matrix times M tokens: M = 1 on the memory slope, M = 1024 on the roof.

The same fact in time: a dense model (every weight is used for every token) with 7 billion parameters (an example size) in FP16 has 14 GB of weights, and reading them once takes 6.87 ms at 2,039 GB/s. If each token needs every weight once, one decoding request cannot exceed 146 tokens per second on this chip. Real servers fall below this bound. The bound ignores attention, where each new token looks back at the earlier ones: the model keeps their keys and values in an attention cache (KV cache) and reads all of it for every new token.

Try it on your machine

Run python3 code/tiled_roofline.py --time to time a Python loop, NumPy multiplies and a matrix-vector product on your machine. That product does 2 FLOPs per 4-byte element, so its GFLOP/s is half the printed GB/s; compare that GB/s with your memory bandwidth.

Proof it works

Run python3 -m pytest -q code/ from the lesson folder. Each test in code/test_tiled_roofline.py guards one failure; more tests pin every printed table to the code.

  • Odd sizes. The tiled product equals NumPy’s A @ B when b does not divide the sizes, where a wrong edge slice raises no error.
  • Loop and formula agree. Counted loads, stores and FLOPs equal the closed form, odd shapes included. Tile size 1 gives the naive count; a tile as large as the matrix gives the minimum.
  • The worked example. 64 loads, 16 stores, 128 FLOPs and the partial products above.
  • Units and capacity. The A100 ridge is 153.0, which catches a TFLOP/GFLOP slip. A 64 KiB memory allows b = 104, not 128 (two tiles) and not 105 (rounded up).
  • The ridge. The smallest tile reaching it at n = 4096 is 342, not the rule-of-thumb 306 or the 318 that the C stores alone give.
  • Decode and batching. One token is memory-bound, 4096 tokens are compute-bound, and 166 is the first batch size at the ridge.

The second test:

def test_counted_loop_matches_closed_form(shape, b):
    m, k, n = shape
    A, B = np.ones((m, k)), np.ones((k, n))
    _, counted = tr.tiled_matmul(A, B, b)
    assert counted == tr.tiled_traffic(m, n, k, b)
    assert counted.flops == tr.matmul_flops(m, n, k)

Where this is used

  • The roofline. Williams, Waterman and Patterson published it in 2009 for multicore chips. Their paper calls the ratio operational intensity and counts the bytes at main memory, after the caches. NVIDIA’s guides call the ridge the ops:byte ratio.
  • Matrix libraries. NVIDIA’s open-source CUTLASS decomposes GPU matrix multiplication into a hierarchy of tiles. Goto and van de Geijn (2008) describe blocking the matrices to fit each CPU cache level.
  • Attention. FlashAttention (Dao and coauthors, 2022) computes attention block by block, so intermediate results stay on the chip.
  • LLM serving. vLLM (Kwon and coauthors, 2023) stores the attention cache in pages so more requests fit in one batch.

Skills this lesson builds

  • Compute arithmetic intensity (FLOPs per byte moved) for a kernel, compare it with a chip’s ridge point, and say whether the kernel is memory-bound or compute-bound.
  • Derive the traffic of a tiled matrix multiply, 2n³ / b loads, confirm it by counting in a loop, and state its limits.
  • Choose the largest tile for a given fast-memory size and explain the square-root law.
  • Place decode, prefill and batching on a roofline, and bound tokens per second from weight bytes and memory bandwidth.
  • Say what a simple model leaves out: caches, several memory levels and other number formats.

Portfolio project: extend tiled_roofline.py to time NumPy matrix-vector and matrix-matrix products over a range of sizes on your own machine, estimate its memory bandwidth and peak speed, and plot your measurements under a roofline built from them. Write up where the points fall below the model, and say that the plot describes your machine, not a data-centre GPU.

Check yourself

1. A friend runs a language model on a powerful GPU, only for themselves, and says the chip is barely working. Are they right, and what would make the chip do more useful work?

Answer

They are right. For one user each weight is fetched and used about once, so the chip does about 1 operation per byte against a ridge of 153 and mostly waits on memory. To do more useful work it needs more words for each fetch of the weights: run several chats at once, or feed it long prompts. On the A100 in this lesson the chip is busy at 166 words handled together. One chat on its own still goes no faster than memory can deliver the weights.

2. A new chip has twice the top speed of the old one and the same memory speed. What happens to the ridge? What happens to a memory-bound job?

Answer

The ridge doubles, from 153 to about 306 on the A100’s numbers. A memory-bound job’s speed is set by memory speed and work per byte, and neither changed, so it runs no faster. A compute-bound job may now be memory-bound.

3. A kernel does 6 FLOPs per 12 bytes moved, on a machine with a 10 TFLOP/s peak and 500 GB/s of bandwidth. What is its attainable speed? Is it memory-bound or compute-bound?

Answer

The intensity is 6 / 12 = 0.5 FLOP per byte. The ridge is 10 TFLOP/s / 500 GB/s = 20 FLOP per byte. So the kernel is memory-bound, at 500 GB/s x 0.5 FLOP/byte = 250 GFLOP/s, which is 2.5% of peak.

4. A machine has 48 KiB of fast memory and 4-byte numbers. What is the largest tile, and what intensity does the formula predict for a large multiply?

Answer

Three tiles need 3 x b² x 4 bytes. Setting that equal to 49,152 bytes gives b² = 4,096, so b = 64. The intensity is about b / s = 16 FLOP per byte. At n = 4096 the code gives 15.9.

5. Multiply two 64 by 64 matrices with 16 by 16 tiles. How many numbers does the tiled loop load, how many does the naive loop load, and what is the ratio?

Answer

Tiled: 2n³ / b = 2 x 64³ / 16 = 32,768 loads. Naive: 2n³ = 524,288 loads. The ratio is 16, the tile size. The code’s tiled_traffic(64, 64, 64, 16) and naive_traffic(64, 64, 64) return exactly these counts.

Further reading

Gentle

Deep

Optional

Code

Each file runs on its own. Download it, or read it here.

tiled_roofline.py340 linesDownload tiled_roofline.py
Show code
"""Roofline arithmetic and tiled matrix multiplication with traffic counting.

Model used throughout the lesson: two levels of memory. Slow memory is large and has a
limited bandwidth. Fast memory is small, and traffic inside it costs nothing. We count the
elements that cross between the two levels, and the floating-point operations (FLOPs) done.
One multiply-add is 2 FLOPs, the same convention the vendor uses for its peak numbers.

Run `python3 tiled_roofline.py` for the numbers quoted in the lesson, and
`python3 tiled_roofline.py --time` to time numpy and a pure-Python loop on your own machine.
"""
from __future__ import annotations

import sys
import time
from dataclasses import dataclass
from math import ceil, isqrt

import numpy as np

# NVIDIA A100 Tensor Core GPU data sheet (revision "Jun21"), 80GB SXM column.
# BFLOAT16 / FP16 Tensor Core: "312 TFLOPS | 624 TFLOPS*" (* with sparsity). We use the dense value.
# GPU memory bandwidth: 2,039 GB/s (HBM2e). We read GB as 10**9 bytes.
A100_PEAK_FLOPS = 312e12
A100_BANDWIDTH = 2039e9
FP16_BYTES = 2


# --- FLOPs, bytes, intensity, roofline ---------------------------------------------------

def matmul_flops(m: int, n: int, k: int) -> int:
    """C[m, n] = A[m, k] @ B[k, n]: m*n*k multiply-adds, 2 FLOPs each."""
    return 2 * m * n * k


def matmul_min_bytes(m: int, n: int, k: int, elem_bytes: int) -> int:
    """Least traffic possible: read A once, read B once, write C once."""
    return (m * k + k * n + m * n) * elem_bytes


def gemm_intensity(m: int, n: int, k: int, elem_bytes: int) -> float:
    """FLOP per byte if every element moves exactly once (the best case)."""
    return matmul_flops(m, n, k) / matmul_min_bytes(m, n, k, elem_bytes)


def ridge(peak_flops: float, bandwidth: float) -> float:
    """Intensity (FLOP/byte) where the memory slope meets the flat compute roof."""
    return peak_flops / bandwidth


def attainable(intensity: float, peak_flops: float, bandwidth: float) -> float:
    """The roofline: attainable FLOP/s = min(peak, bandwidth * intensity)."""
    return min(peak_flops, bandwidth * intensity)


def classify(intensity: float, peak_flops: float, bandwidth: float) -> str:
    """A tie at the ridge counts as compute-bound."""
    return "memory-bound" if intensity < ridge(peak_flops, bandwidth) else "compute-bound"


def time_floors(flops: float, nbytes: float, peak_flops: float, bandwidth: float) -> tuple[float, float]:
    """The two lower bounds on run time, in seconds: (math alone, traffic alone).

    Neither the math nor the traffic can finish sooner than its own floor, and the two overlap,
    so the run takes at least the larger of the two.
    """
    return flops / peak_flops, nbytes / bandwidth


# --- traffic of a tiled matrix multiply --------------------------------------------------

@dataclass
class Traffic:
    loads: int = 0    # elements read from slow memory
    stores: int = 0   # elements written to slow memory
    flops: int = 0

    def nbytes(self, elem_bytes: int) -> int:
        return (self.loads + self.stores) * elem_bytes

    def intensity(self, elem_bytes: int) -> float:
        return self.flops / self.nbytes(elem_bytes)


def tiled_matmul(A: np.ndarray, B: np.ndarray, b: int) -> tuple[np.ndarray, Traffic]:
    """Multiply with b x b tiles and count what crosses between slow and fast memory.

    For each output tile, the C tile stays in fast memory while we walk along k. Every
    k-step loads one A tile and one B tile. The C tile is written once at the end.
    Edge tiles are smaller when a size is not a multiple of b.
    """
    m, k = A.shape
    k2, n = B.shape
    if k != k2 or b < 1:
        raise ValueError("shapes must agree and the tile size must be at least 1")
    C = np.zeros((m, n), dtype=np.result_type(A, B))
    t = Traffic()
    for i in range(0, m, b):
        for j in range(0, n, b):
            acc = np.zeros((min(b, m - i), min(b, n - j)), dtype=C.dtype)
            for p in range(0, k, b):
                a_tile = A[i:i + b, p:p + b]
                b_tile = B[p:p + b, j:j + b]
                t.loads += a_tile.size + b_tile.size
                t.flops += 2 * a_tile.shape[0] * a_tile.shape[1] * b_tile.shape[1]
                acc += a_tile @ b_tile
            C[i:i + b, j:j + b] = acc
            t.stores += acc.size
    return C, t


def tiled_traffic(m: int, n: int, k: int, b: int) -> Traffic:
    """Closed form of what `tiled_matmul` counts, with no loops over tiles.

    Every A element is loaded once per column of output tiles, ceil(n / b) times.
    Every B element is loaded once per row of output tiles, ceil(m / b) times.
    """
    return Traffic(loads=ceil(n / b) * m * k + ceil(m / b) * k * n,
                   stores=m * n,
                   flops=matmul_flops(m, n, k))


def min_traffic(m: int, n: int, k: int) -> Traffic:
    """Best case: every element of A and B is loaded once, every element of C stored once."""
    return Traffic(loads=m * k + k * n, stores=m * n, flops=matmul_flops(m, n, k))


def naive_traffic(m: int, n: int, k: int) -> Traffic:
    """No reuse: each multiply-add fetches its two operands; C is written once."""
    return Traffic(loads=2 * m * n * k, stores=m * n, flops=matmul_flops(m, n, k))


def tile_steps(m: int, n: int, k: int, b: int) -> list[tuple[int, int, int, int]]:
    """The (output row tile, output column tile, k tile, elements loaded) of each step."""
    steps = []
    for i in range(0, m, b):
        for j in range(0, n, b):
            for p in range(0, k, b):
                bm, bn, bk = min(b, m - i), min(b, n - j), min(b, k - p)
                steps.append((i // b, j // b, p // b, bm * bk + bk * bn))
    return steps


def fast_bytes_needed(b: int, elem_bytes: int) -> int:
    """One A tile, one B tile and one C tile must sit in fast memory together."""
    return 3 * b * b * elem_bytes


def max_tile(fast_bytes: int, elem_bytes: int) -> int:
    """Largest b with 3 * b * b * elem_bytes <= fast_bytes."""
    return isqrt(fast_bytes // (3 * elem_bytes))


def tiled_intensity(n: int, b: int, elem_bytes: int) -> float:
    """FLOP per byte of an n x n x n multiply with b x b tiles: 2n^3 / ((2n^3 / b + n^2) * s)."""
    return tiled_traffic(n, n, n, b).intensity(elem_bytes)


def ridge_tile_if_tile_count_were_fractional(n: int, elem_bytes: int, peak_flops: float, bandwidth: float) -> float:
    """Smallest b with (b / s) / (1 + b / 2n) at or above the ridge, if n / b could be fractional.

    This keeps the term for writing C but drops the whole-tile effect. It is the middle step
    between the rule of thumb s * ridge (306 on the A100) and the exact answer (342 at n = 4096).
    """
    sr = elem_bytes * ridge(peak_flops, bandwidth)
    return sr / (1 - sr / (2 * n))


def smallest_tile_for_ridge(n: int, elem_bytes: int, peak_flops: float, bandwidth: float) -> int:
    """Smallest b whose n x n x n tiled multiply is no longer memory-bound.

    Not the same as the rule of thumb elem_bytes * ridge (306 on the A100), for two causes.
    Writing C adds traffic, which alone moves the answer to 318. The tile count per side,
    ceil(n / b), must also be a whole number: at n = 4096, 13 tiles per side give 151.7 and
    12 give 163.8 (the ridge is 153.0), so b must be at least 4096 / 12 = 341.3, which is 342.
    """
    r = ridge(peak_flops, bandwidth)
    return next(b for b in range(1, n + 1) if tiled_intensity(n, b, elem_bytes) >= r)


# --- decode versus prefill ---------------------------------------------------------------

def batch_to_reach_ridge(n: int, k: int, elem_bytes: int, peak_flops: float, bandwidth: float) -> int:
    """Smallest number of tokens m for which (m x k) @ (k x n) is no longer memory-bound."""
    r = ridge(peak_flops, bandwidth)
    m = 1
    while gemm_intensity(m, n, k, elem_bytes) < r:
        m += 1
    return m


def weight_read_seconds(params: float, elem_bytes: int, bandwidth: float) -> float:
    """Time to read every weight of a model once: the floor for one decoding step."""
    return params * elem_bytes / bandwidth


def decode_tokens_per_s(params: float, elem_bytes: int, bandwidth: float) -> float:
    """Upper bound on one decoding request if each token reads every weight once.

    Ignores the attention cache (stored keys and values) that each token also reads.
    """
    return 1 / weight_read_seconds(params, elem_bytes, bandwidth)


# --- the worked example and a slow reference ---------------------------------------------

EXAMPLE_A = np.array([[1, 2, 0, 1], [3, 1, 2, 0], [0, 1, 1, 2], [2, 0, 1, 1]])
EXAMPLE_B = np.array([[1, 2, 2, 1], [0, 1, 1, 0], [2, 1, 0, 1], [1, 2, 1, 0]])


def naive_matmul(A: list[list[float]], B: list[list[float]]) -> list[list[float]]:
    """Three plain Python loops. Slow on purpose: it shows what the library saves you."""
    m, k, n = len(A), len(B), len(B[0])
    C = [[0.0] * n for _ in range(m)]
    for i in range(m):
        for j in range(n):
            s = 0.0
            for p in range(k):
                s += A[i][p] * B[p][j]
            C[i][j] = s
    return C


def time_on_this_machine(n_small: int = 96, n_mm: int = 1024, n_mv: int = 4096) -> dict[str, float]:
    """Time four jobs and return rates (GFLOP/s, plus GB/s for the matrix-vector case). Your numbers depend on your machine."""
    rng = np.random.default_rng(0)

    def best(fn, repeats=3):
        times = []
        for _ in range(repeats):
            t0 = time.perf_counter()
            fn()
            times.append(time.perf_counter() - t0)
        return min(times)

    a_small = rng.standard_normal((n_small, n_small))
    a_list, b_list = a_small.tolist(), a_small.T.tolist()
    A = rng.standard_normal((n_mm, n_mm), dtype=np.float32)
    B = rng.standard_normal((n_mm, n_mm), dtype=np.float32)
    M = rng.standard_normal((n_mv, n_mv), dtype=np.float32)
    x = rng.standard_normal(n_mv, dtype=np.float32)
    t_py = best(lambda: naive_matmul(a_list, b_list), repeats=1)
    t_np = best(lambda: a_small @ a_small.T)
    t_mm = best(lambda: A @ B)
    t_mv = best(lambda: M @ x)
    return {
        f"pure Python loop, {n_small}x{n_small} matmul (GFLOP/s)": matmul_flops(n_small, n_small, n_small) / t_py / 1e9,
        f"numpy, {n_small}x{n_small} matmul (GFLOP/s)": matmul_flops(n_small, n_small, n_small) / t_np / 1e9,
        f"numpy, {n_mm}x{n_mm} matmul, float32 (GFLOP/s)": matmul_flops(n_mm, n_mm, n_mm) / t_mm / 1e9,
        f"numpy, {n_mv}x{n_mv} matrix-vector, float32 (GFLOP/s)": matmul_flops(n_mv, 1, n_mv) / t_mv / 1e9,
        f"numpy, {n_mv}x{n_mv} matrix-vector, matrix bytes read (GB/s)": n_mv * n_mv * 4 / t_mv / 1e9,
    }


# --- report: the numbers quoted in the lesson ---------------------------------------------

def report() -> None:
    s = FP16_BYTES
    r = ridge(A100_PEAK_FLOPS, A100_BANDWIDTH)
    print(f"A100 SXM 80GB: peak {A100_PEAK_FLOPS / 1e12:.0f} TFLOP/s, bandwidth {A100_BANDWIDTH / 1e9:.0f} GB/s")
    print(f"ridge point: {r:.1f} FLOP/byte")

    print("\n-- tiny example: 2x2 matmul, float32")
    print("flops", matmul_flops(2, 2, 2), "min bytes", matmul_min_bytes(2, 2, 2, 4),
          "intensity", round(gemm_intensity(2, 2, 2, 4), 4))
    for n in (2, 16, 256, 4096):
        print(f"best-case intensity, n={n}, float32: {gemm_intensity(n, n, n, 4):.3f}")

    n = 4096
    print(f"\n-- naive vs ideal, n={n}, FP16")
    nv = naive_traffic(n, n, n)
    best_t = min_traffic(n, n, n)
    print(f"flops {nv.flops:.4e}")
    print(f"naive bytes {nv.nbytes(s):.4e}, intensity {nv.intensity(s):.3f}")
    print(f"min bytes {best_t.nbytes(s):.4e}, intensity {best_t.intensity(s):.1f}")
    print(f"naive attainable {attainable(nv.intensity(s), A100_PEAK_FLOPS, A100_BANDWIDTH) / 1e12:.3f} TFLOP/s "
          f"= {100 * attainable(nv.intensity(s), A100_PEAK_FLOPS, A100_BANDWIDTH) / A100_PEAK_FLOPS:.2f}% of peak")
    t_math, t_naive = time_floors(nv.flops, nv.nbytes(s), A100_PEAK_FLOPS, A100_BANDWIDTH)
    _, t_min = time_floors(best_t.flops, best_t.nbytes(s), A100_PEAK_FLOPS, A100_BANDWIDTH)
    print(f"time floor from math {t_math * 1e3:.3f} ms; from naive traffic {t_naive * 1e3:.1f} ms "
          f"({t_naive / t_math:.0f} times the math floor); from minimum traffic {t_min * 1e3:.4f} ms")

    print("\n-- 4x4 with 2x2 tiles")
    C, t = tiled_matmul(EXAMPLE_A, EXAMPLE_B, 2)
    print("loads", t.loads, "stores", t.stores, "flops", t.flops, "naive", naive_traffic(4, 4, 4))
    print("C =", C.tolist())
    print(f"intensity per number moved: naive {naive_traffic(4, 4, 4).intensity(1):.2f}, tiled {t.intensity(1):.2f}")

    print("\n-- simulated loop, n=128, FP16: counted by tiled_matmul vs the formula 2 n^3 / b")
    rng = np.random.default_rng(0)
    A, B = rng.standard_normal((128, 128)), rng.standard_normal((128, 128))
    for b in (4, 8, 16, 32, 64, 128):
        C, t = tiled_matmul(A, B, b)
        assert np.allclose(C, A @ B)
        print(f"b={b}: loads {t.loads}, formula {2 * 128**3 // b}, stores {t.stores}, flops {t.flops}, "
              f"intensity {t.intensity(s):.2f} FLOP/byte")

    print(f"\n-- tile size sweep, n={n}, FP16")
    print("b, loads (elements), traffic/min, intensity, fast bytes needed")
    for b in (1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 4096):
        t = tiled_traffic(n, n, n, b)
        print(b, t.loads, round(t.nbytes(s) / best_t.nbytes(s), 2), round(t.intensity(s), 2),
              fast_bytes_needed(b, s))

    print("\n-- capacity (example sizes, not a claim about any chip)")
    for kib in (16, 64, 256, 1024):
        b = max_tile(kib * 1024, s)
        print(f"{kib} KiB -> b <= {b}, intensity ~ {tiled_intensity(n, b, s):.1f}")
    rule = s * r
    b_need = smallest_tile_for_ridge(n, s, A100_PEAK_FLOPS, A100_BANDWIDTH)
    print(f"large-n rule: intensity ~ b / elem_bytes, so the ridge {r:.1f} needs b ~ {rule:.0f}")
    b_frac = ridge_tile_if_tile_count_were_fractional(n, s, A100_PEAK_FLOPS, A100_BANDWIDTH)
    print(f"with the C-store term but a fractional tile count: b >= {b_frac:.1f}, so {ceil(b_frac)}")
    print(f"exact at n={n}: smallest b at or above the ridge is {b_need}; "
          f"intensity there {tiled_intensity(n, b_need, s):.1f}, one smaller {tiled_intensity(n, b_need - 1, s):.1f}; "
          f"fast bytes {fast_bytes_needed(b_need, s)}")

    print("\n-- decode vs prefill: (m x 4096) @ (4096 x 4096), FP16")
    for m in (1, 2, 8, 16, 32, 64, 128, 165, 166, 256, 512, 1024, 4096):
        i = gemm_intensity(m, 4096, 4096, s)
        att = attainable(i, A100_PEAK_FLOPS, A100_BANDWIDTH)
        print(f"m={m}: intensity {i:.2f}, {classify(i, A100_PEAK_FLOPS, A100_BANDWIDTH)}, "
              f"{att / 1e12:.2f} TFLOP/s ({100 * att / A100_PEAK_FLOPS:.1f}% of peak)")
    print("batch to reach ridge:", batch_to_reach_ridge(4096, 4096, s, A100_PEAK_FLOPS, A100_BANDWIDTH))

    params = 7e9
    print(f"\n-- 7B params, FP16: weights {params * s / 1e9:.0f} GB; "
          f"read once = {weight_read_seconds(params, s, A100_BANDWIDTH) * 1e3:.2f} ms; "
          f"at most {decode_tokens_per_s(params, s, A100_BANDWIDTH):.0f} tokens/s at batch 1 (upper bound)")

    print("\n-- check-yourself: 48 KiB of fast memory, 4-byte numbers, n = 4096")
    b = max_tile(48 * 1024, 4)
    print(f"largest tile {b}, intensity {tiled_intensity(n, b, 4):.1f}")


if __name__ == "__main__":
    if "--time" in sys.argv:
        for name, value in time_on_this_machine().items():
            print(f"{name}: {value:.2f}")
    else:
        report()
test_tiled_roofline.py171 linesDownload test_tiled_roofline.py
Show code
import math

import numpy as np
import pytest

import tiled_roofline as tr


@pytest.mark.parametrize("shape, b", [((8, 8, 8), 2), ((37, 23, 41), 8), ((1, 33, 17), 8), ((7, 5, 3), 1), ((9, 11, 13), 64)])
def test_tiled_matmul_is_correct_when_sizes_do_not_divide(shape, b):
    """Guards an off-by-one on edge tiles: a wrong slice end gives a wrong C, not an error."""
    m, k, n = shape
    rng = np.random.default_rng(0)
    A, B = rng.standard_normal((m, k)), rng.standard_normal((k, n))
    C, _ = tr.tiled_matmul(A, B, b)
    assert np.allclose(C, A @ B, atol=1e-9)
    assert np.allclose(C, tr.naive_matmul(A.tolist(), B.tolist()), atol=1e-9)


@pytest.mark.parametrize("shape, b", [((64, 64, 64), 8), ((37, 23, 41), 8), ((1, 33, 17), 8), ((9, 11, 13), 64), ((5, 3, 7), 1)])
def test_counted_loop_matches_closed_form(shape, b):
    """The formula in the lesson and the loop must agree, ragged edges included.
    Guards a closed form that drifts from the loop it claims to describe."""
    m, k, n = shape
    A, B = np.ones((m, k)), np.ones((k, n))
    _, counted = tr.tiled_matmul(A, B, b)
    assert counted == tr.tiled_traffic(m, n, k, b)
    assert counted.flops == tr.matmul_flops(m, n, k)


def test_square_formula_and_tile_one_is_the_naive_count():
    """Loads = 2 n^3 / b for square sizes; tile 1 is exactly the no-reuse count."""
    n, b = 64, 16
    assert tr.tiled_traffic(n, n, n, b).loads == 2 * n**3 // b
    assert tr.tiled_traffic(n, n, n, 1) == tr.naive_traffic(n, n, n)
    assert tr.tiled_traffic(n, n, n, n) == tr.min_traffic(n, n, n)    # one tile: every element moves once


def test_worked_example_four_by_four_with_two_by_two_tiles():
    """The numbers the lesson prints by hand: 8 steps of 8 loads, 16 stores, 128 FLOPs."""
    C, t = tr.tiled_matmul(tr.EXAMPLE_A, tr.EXAMPLE_B, 2)
    assert np.array_equal(C, tr.EXAMPLE_A @ tr.EXAMPLE_B)
    assert C.tolist() == [[2, 6, 5, 1], [7, 9, 7, 5], [4, 6, 3, 1], [5, 7, 5, 3]]    # the matrix printed in the lesson
    steps = tr.tile_steps(4, 4, 4, 2)
    assert len(steps) == 8 and {s[3] for s in steps} == {8}
    assert sum(s[3] for s in tr.tile_steps(5, 3, 7, 2)) == tr.tiled_traffic(5, 3, 7, 2).loads    # ragged edges: guards a wrong edge-tile size in the per-step loads
    assert (t.loads, t.stores, t.flops) == (64, 16, 128)
    assert tr.naive_traffic(4, 4, 4).loads == 128
    assert tr.naive_traffic(4, 4, 4).intensity(1) == pytest.approx(0.89, abs=0.005)    # per number moved: 128 / 144
    assert t.intensity(1) == pytest.approx(1.6)                                          # 128 / 80
    # top-left output tile, the two partial products the lesson tabulates
    a, b = tr.EXAMPLE_A, tr.EXAMPLE_B
    step0 = a[0:2, 0:2] @ b[0:2, 0:2]
    step1 = a[0:2, 2:4] @ b[2:4, 0:2]
    assert step0.tolist() == [[1, 4], [3, 7]]
    assert step1.tolist() == [[1, 2], [4, 2]]
    assert (step0 + step1).tolist() == [[2, 6], [7, 9]]


def test_a100_ridge_from_the_datasheet_numbers():
    """312 TFLOP/s over 2,039 GB/s. Guards a unit slip (TFLOP vs GFLOP, GB vs GiB)."""
    assert tr.ridge(tr.A100_PEAK_FLOPS, tr.A100_BANDWIDTH) == pytest.approx(153.0, abs=0.05)


def test_roofline_min_and_classification_tie():
    peak, bw = 100.0, 10.0               # ridge = 10
    assert tr.attainable(2, peak, bw) == 20 and tr.attainable(50, peak, bw) == 100
    assert tr.classify(9.99, peak, bw) == "memory-bound"
    assert tr.classify(10.0, peak, bw) == "compute-bound"    # a tie sits on the roof


def test_intensity_rises_with_tile_and_is_about_b_over_element_size():
    n, s = 4096, tr.FP16_BYTES
    values = [tr.tiled_intensity(n, b, s) for b in (1, 4, 16, 64, 256)]
    assert values == sorted(values) and len(set(values)) == len(values)
    assert tr.tiled_intensity(n, 1, s) == pytest.approx(0.5, rel=1e-3)
    assert tr.tiled_intensity(n, 64, s) == pytest.approx(64 / s, rel=0.02)
    # one tile covering everything moves each element once: the best case
    assert tr.tiled_intensity(n, n, s) == pytest.approx(tr.gemm_intensity(n, n, n, s))


@pytest.mark.parametrize("kib, expected_b, expected_intensity", [(16, 52, 25.8), (64, 104, 50.6), (256, 209, 99.9), (1024, 418, 195.0)])
def test_tile_capacity_bound(kib, expected_b, expected_intensity):
    """Three b x b tiles must fit. Guards forgetting the C tile (2 b^2 gives 128 at 64 KiB) and a rounded-up
    root (105). The rows are the capacity table in the lesson."""
    s = tr.FP16_BYTES
    b = tr.max_tile(kib * 1024, s)
    assert tr.fast_bytes_needed(b, s) <= kib * 1024 < tr.fast_bytes_needed(b + 1, s)
    assert b == expected_b
    assert tr.tiled_intensity(4096, b, s) == pytest.approx(expected_intensity, abs=0.05)


def test_check_yourself_capacity_answer():
    """Check-yourself question 2: 48 KiB, 4-byte numbers -> b = 64, 15.9 FLOP/byte at n = 4096."""
    b = tr.max_tile(48 * 1024, 4)
    assert b == 64
    assert tr.tiled_intensity(4096, b, 4) == pytest.approx(15.9, abs=0.05)


@pytest.mark.parametrize("n, expected", [(2, 1 / 3), (16, 2.667), (256, 42.667), (4096, 682.667)])
def test_best_case_intensity_table_for_four_byte_floats(n, expected):
    """The lesson's best-case table, 2n / (3s) with s = 4. Guards a slip in the byte count or the 3n^2 numbers."""
    assert tr.gemm_intensity(n, n, n, 4) == pytest.approx(expected, abs=0.0005)


def test_smallest_tile_reaching_the_ridge_is_above_the_large_n_rule_for_two_causes():
    """Lesson quotes 342, not the rule of thumb 306. Guards quoting s * ridge as exact. Two causes:
    writing C alone moves 306 to 318; the whole-tile count (13 tiles per side give 151.7, 12 give 163.8)
    moves it to 342."""
    s, peak, bw = tr.FP16_BYTES, tr.A100_PEAK_FLOPS, tr.A100_BANDWIDTH
    ridge = tr.ridge(peak, bw)
    assert round(s * ridge) == 306
    assert math.ceil(tr.ridge_tile_if_tile_count_were_fractional(4096, s, peak, bw)) == 318
    b = tr.smallest_tile_for_ridge(4096, s, peak, bw)
    assert b == 342 and b > 318
    assert tr.tiled_intensity(4096, b, s) >= ridge > tr.tiled_intensity(4096, b - 1, s)
    assert tr.tiled_intensity(4096, 342, s) == pytest.approx(163.8, abs=0.05)    # 12 tiles per side
    assert tr.tiled_intensity(4096, 341, s) == pytest.approx(151.7, abs=0.05)    # 13 tiles per side
    assert tr.fast_bytes_needed(b, s) == 701_784


def test_decode_is_memory_bound_and_batching_crosses_the_ridge():
    s, peak, bw = tr.FP16_BYTES, tr.A100_PEAK_FLOPS, tr.A100_BANDWIDTH
    assert tr.gemm_intensity(1, 4096, 4096, s) == pytest.approx(1.0, abs=0.001)
    assert tr.classify(tr.gemm_intensity(1, 4096, 4096, s), peak, bw) == "memory-bound"
    assert tr.classify(tr.gemm_intensity(4096, 4096, 4096, s), peak, bw) == "compute-bound"
    m = tr.batch_to_reach_ridge(4096, 4096, s, peak, bw)
    assert m == 166
    assert tr.gemm_intensity(m - 1, 4096, 4096, s) < tr.ridge(peak, bw) <= tr.gemm_intensity(m, 4096, 4096, s)


@pytest.mark.parametrize("m, intensity, tflops, share", [
    (1, 1.00, 2.04, 0.7), (8, 7.97, 16.25, 5.2), (32, 31.51, 64.24, 20.6), (128, 120.47, 245.64, 78.7),
    (165, 152.70, 311.35, 99.8), (166, 153.55, 312.00, 100.0), (1024, 682.67, 312.00, 100.0)])
def test_decode_table_rows_match_the_lesson(m, intensity, tflops, share):
    """Guards the decode/prefill table drifting from the code that prints it."""
    peak, bw = tr.A100_PEAK_FLOPS, tr.A100_BANDWIDTH
    i = tr.gemm_intensity(m, 4096, 4096, tr.FP16_BYTES)
    att = tr.attainable(i, peak, bw)
    assert i == pytest.approx(intensity, abs=0.005)
    assert att / 1e12 == pytest.approx(tflops, abs=0.005)
    assert 100 * att / peak == pytest.approx(share, abs=0.05)


def test_decode_ceiling_for_a_seven_billion_parameter_model():
    """6.87 ms to read 14 GB of FP16 weights at 2,039 GB/s -> at most 146 tokens/s. Guards a bytes/params slip."""
    s, bw = tr.FP16_BYTES, tr.A100_BANDWIDTH
    assert tr.weight_read_seconds(7e9, s, bw) * 1e3 == pytest.approx(6.866, abs=0.001)
    assert tr.decode_tokens_per_s(7e9, s, bw) == pytest.approx(145.6, abs=0.1)


def test_naive_multiply_floors_on_the_a100():
    """The naive 4096 table: 1.019 TFLOP/s (0.327% of peak), floors 0.4405 ms and 134.83 ms (306 times),
    0.0494 ms at the minimum traffic. Guards time_floors and the bytes-per-element slip."""
    n, s, peak, bw = 4096, tr.FP16_BYTES, tr.A100_PEAK_FLOPS, tr.A100_BANDWIDTH
    naive, best = tr.naive_traffic(n, n, n), tr.min_traffic(n, n, n)
    assert tr.attainable(naive.intensity(s), peak, bw) == pytest.approx(1.019e12, abs=5e8)
    assert tr.attainable(naive.intensity(s), peak, bw) / peak * 100 == pytest.approx(0.327, abs=0.0005)
    t_math, t_naive = tr.time_floors(naive.flops, naive.nbytes(s), peak, bw)
    _, t_min = tr.time_floors(best.flops, best.nbytes(s), peak, bw)
    assert t_math * 1e3 == pytest.approx(0.4405, abs=0.00005)
    assert t_naive * 1e3 == pytest.approx(134.83, abs=0.005)
    assert t_naive / t_math == pytest.approx(306.07, abs=0.005)
    assert t_min * 1e3 == pytest.approx(0.0494, abs=0.00005) and t_min < t_math
    assert best.nbytes(s) == 100_663_296


def test_timing_helper_runs_and_reports_positive_rates():
    """Smoke test only: the reader runs this on their own machine, so no timing is asserted."""
    rates = tr.time_on_this_machine(n_small=8, n_mm=16, n_mv=32)
    assert len(rates) == 5 and all(v > 0 for v in rates.values())