linearly

Lecture 28

Sparse Matrices

When almost everything is zero, storing everything is the bug. COO, CSR, and friends: formats that keep only what exists.

The idea in one sentence

When almost every entry of a matrix is zero, the honest move is to stop storing the zeros, and every sparse format is one answer to the question that follows: then how do you find what is left?

Where the zeros come from

Sparsity is not a trick somebody invented to save memory. It is what the data looks like.

A batch of token ids arriving at an embedding layer is a matrix of one-hot rows: one entry per row is 1 and the other fifty thousand are 0. A graph is an adjacency matrix, and a user who follows two hundred accounts out of two hundred million contributes a row that is one part in a million away from empty. An attention mask that only lets each position see its neighbours is a band around the diagonal. A pruned layer is a weight matrix somebody deliberately emptied.

one-hot inputs8 of 64a graph16 of 64a local mask15 of 64a pruned layer6 of 64
Fig. 1 

Four sparsity patterns, drawn at eight by eight so the shapes are visible. At the sizes these really occur, the coloured fraction is not one in six, it is one in thousands. The colour marks where the pattern comes from and carries no other meaning.

The four patterns look different, and that difference is the whole subject. One entry per row, scattered, banded, and random are four different problems, and they get four different answers below.

The bargain

Lecture 23 said a dense matrix is a promise about addresses: give me a row and a column and I will compute where that entry lives, with one multiply and one add. Lecture 27 turned that promise into an object with an algebra. All of it depends on the same thing, that the address is computed rather than looked up.

A sparse matrix breaks that promise. Once you skip the zeros, the entry in row ii and column jj is no longer at a place you can compute, because you no longer know how many entries came before it. So the position has to be written down. Every sparse format is a different way of writing it down, and every one of them pays the same tax.

That tax is why sparsity has a threshold. Storing a float32 costs 4 bytes. Storing the column it sat in costs another 4. So a sparse format that keeps one index per value spends 8 bytes on what dense spends 4, and it wins only when it can skip more than half the matrix. Below that, the honest answer is to store the zeros. This is the first of two thresholds in this chapter, and it turns out to be the generous one.

Three arrays, in any order

The first idea anybody has is also the right one to start with. Keep a list of the entries that exist, each one carrying its own row and column. That is coordinate format, universally called COO.

Here is the matrix this chapter uses. Six by six, nine entries that exist, and one row that is entirely empty, which will matter shortly.

the matrix101211131611181415only the coloured cells existrow001123355col032512405value101211131611181415the yellow entry is row 3, column 4, value 18nine entries, three arrays, and any order will do
Fig. 2 

Coordinate format. Every entry that exists carries its own row and its own column, so the three arrays can be shuffled together into any order without changing the matrix. Yellow follows one entry from the grid into the arrays.

COO is easy to build. You can append to it, you can build it out of order, and duplicate entries can be summed at the end. What it is bad at is being read. Ask for row 3 and there is nothing to do but scan all nine entries and collect the ones whose row is 3. On a matrix with a hundred million entries that is a hundred million comparisons to answer a question about one row.

Two cousins solve the same building problem in other ways. Dictionary of keys stores a hash map from an index pair to a value, so a single entry can be written in constant time. List of lists keeps one list of column indices and one list of values per row, so a row can grow without touching the others. Both are for construction. Neither is for arithmetic. The habit that follows is worth keeping: build in one format, convert, compute in another.

Compress the rows

Look at the row array in Figure 2 again: 0, 0, 1, 1, 2, 3, 3, 5, 5. Sorted, and full of repetition. Anything sorted with repeats can be replaced by the places where it changes.

That is compressed sparse row, CSR, and it is three arrays again, but the third one is different in kind. values and colIndex still have one slot per entry. The third array, rowPtr, has one slot per row plus one, and it holds where each row starts.

rowPtr1012111316111814150245779colIndex032512405values101211131611181415row 3 lives in slots 5 and 6, because rowPtr says 5 to 7
Fig. 3 

The three arrays of CSR, grown out of the matrix they describe. Cyan is an entry that exists, yellow follows row 3 from the grid into its two slots. The rowPtr values sit on the row boundaries because that is what they mark.

python
import numpy as np
import scipy.sparse as sp

A = np.array([[10,  0,  0, 12,  0,  0],
              [ 0,  0, 11,  0,  0, 13],
              [ 0, 16,  0,  0,  0,  0],
              [ 0,  0, 11,  0, 18,  0],
              [ 0,  0,  0,  0,  0,  0],
              [14,  0,  0,  0,  0, 15]], dtype=float)

S = sp.csr_array(A)
print(S.data)                # [10. 12. 11. 13. 16. 11. 18. 14. 15.]
print(S.indices)             # [0 3 2 5 1 2 4 0 5]
print(S.indptr)              # [0 2 4 5 7 7 9]
print(S.nnz, A.size)         # 9 36

Read indptr against the picture. Row 0 runs from slot 0 to slot 2, so it owns values 10 and 12. Row 2 runs from 4 to 5, one entry, the 16. Row 3 runs from 5 to 7, the 11 and the 18. Row 4 runs from 7 to 7, which is nothing at all, and that is the empty row showing up as two equal numbers in a row.

The pointer pair is the whole trick

rowPtr holds boundaries, not counts. Its entries are useful only in adjacent pairs, and the pair is what a row is.

101211131611181415values012345678p0p1p2p3p4p5p6row 0row 1row 2row 3row 4emptyrow 5six slices, laid end to end, cut out by the seven pointers
Fig. 4 

Seven pointers cut the values array into six slices that meet end to end and leave no gaps. Row 4 is the pair p4 and p5 landing on the same place, which is how a format with no zeros says that a row has nothing in it.

Write the pair down and the loop writes itself. Everything a row needs is a contiguous range of slots, and the columns for that row sit in the same range of colIndex.

python
def csr_matvec(values, col_index, row_ptr, x):
    out = np.zeros(len(row_ptr) - 1)
    for i in range(len(out)):
        for k in range(row_ptr[i], row_ptr[i + 1]):    # this row's slice
            out[i] += values[k] * x[col_index[k]]
    return out

x = np.array([1., 2., 3., 4., 5., 6.])
print(csr_matvec(S.data, S.indices, S.indptr, x))     # [ 58. 111.  32. 123.   0. 104.]
print(A @ x)                                          # [ 58. 111.  32. 123.   0. 104.]

Nine multiplies, and the same answer as the dense product, which does thirty six. Row 4 runs zero times around the inner loop and produces its zero for free.

Two properties fall out of that loop. The rows are independent, so a machine with many cores can have each one take a range of rows and never talk to its neighbours. And values is read straight through, in order, once, which is the one thing about sparse code that memory likes. What memory does not like is x[col_index[k]], and that indirect read is the reason for everything in the last third of this chapter.

The twin, and two more

Swap the roles of rows and columns and you get compressed sparse column, CSC: a colPtr with one slot per column plus one, a rowIndex, and the same values in a different order. CSC of a matrix is CSR of its transpose, and nothing else about it is new.

CSRrowPtr0245779colIndex032512405values101211131611181415one row is a sliceCSCcolPtr0235679rowIndex052130315values101416111112181315one column is a slice
Fig. 5 

The same nine numbers, twice. CSR makes a row a contiguous slice and a column a search. CSC does the reverse. Yellow marks the slice each format gives you cheaply.

Which one you want is decided by the loop you are about to run, exactly as row-major against column-major was in Lecture 23. This is that choice again, one level up, with the strides replaced by stored indices.

Two more formats are worth knowing by name. Block sparse row keeps small dense blocks instead of single entries, so the index is paid once per block rather than once per number, and the blocks themselves can go to a dense kernel. It is the right format when the nonzeros come in clumps, which pruning schemes can be designed to produce. Diagonal format stores each occupied diagonal as a full vector and one offset, which is nearly free for the banded mask in Figure 1 and disastrous for the scattered graph. Every format is a bet about where the nonzeros are, and a format that bets wrong stores more than dense would.

The product that never touches a zero

Here is the loop you just ran, drawn.

101211131611181415Ax123456Ax58111321230104row 3: 11 × 3 + 18 × 5 = 123, two multiplies out of sixthe whole product: nine multiplies out of thirty six, and row 4 does none
Fig. 6 

Row 3 of the product. Only the two entries that exist reach into xx, and only the two entries of xx they name are read. Row 4 has nothing, so it does no work at all and its answer is zero by construction.

Multiplying two sparse matrices is the same instinct one level up. Walk the nonzeros of AA one row at a time. Each nonzero AikA_{ik} activates row kk of BB, and the whole of that row gets scaled and accumulated into row ii of the result. Zeros are never visited, because you never had them.

The count of that work is worth having in closed form, because it explains everything below. Every nonzero in column kk of AA meets every nonzero in row kk of BB, so the number of multiplications is the sum over kk of those two counts multiplied together. Nothing about the matrix sizes appears in it. Only the structure does.

That is a devastating saving on paper. At one percent density on a 2048×20482048 \times 2048 matrix it is eight hundred and sixty thousand multiplies against the dense product’s eight and a half billion, a factor of ten thousand.

python
n = 2048
rng = np.random.default_rng(0)
M = sp.random_array((n, n), density=0.01, format='csr', dtype=np.float32, rng=rng)

per_row = np.diff(M.indptr)                      # nonzeros in each row of M
per_col = np.bincount(M.indices, minlength=n)    # nonzeros in each column of M
print(int((per_col * per_row).sum()))            # 860444
print(n ** 3)                                    # 8589934592

Now measure it.

When sparse actually wins

Two costs, two thresholds, and they are nowhere near each other.

Memory first, because it is the easy one. A CSR matrix of float32 values with 32-bit indices spends 8 bytes per stored entry against dense’s 4 per position, plus one pointer per row, which disappears at any real size. So the stored size is about twice the density, and it crosses dense at a density of one half.

python
for d in (0.01, 0.25, 0.5):
    M = sp.random_array((n, n), density=d, format='csr', dtype=np.float32, rng=rng)
    stored = M.data.nbytes + M.indices.nbytes + M.indptr.nbytes
    print(d, M.indices.dtype, round(stored / (n * n * 4), 3))

# 0.01 int32 0.02
# 0.25 int32 0.5
# 0.5 int32 1.0

Exactly as predicted, and exactly where predicted. Now time, on the same machine, with the same matrices.

python
from timeit import repeat
bench = lambda f, k: min(repeat(f, number=k, repeat=7)) / k

D = rng.standard_normal((n, n)).astype(np.float32)
dense = bench(lambda: D @ D, 3)
print(round(dense * 1e3, 2), "ms for the dense product")     # 6.19 ms

for d in (0.001, 0.002, 0.005, 0.01, 0.02, 0.05):
    M = sp.random_array((n, n), density=d, format='csr', dtype=np.float32, rng=rng)
    print(d, round(bench(lambda: M @ M, 1) / dense, 3))

# 0.001 0.009
# 0.002 0.024
# 0.005 0.154
# 0.01  0.738
# 0.02  4.404
# 0.05  13.141

At one percent the sparse product does one ten-thousandth of the arithmetic and takes three quarters of the time. At two percent it is four and a half times slower than multiplying every zero by hand.

sparse costdense cost0.1%1%10%100%1/1001/10110density of the matrixdensememorytimetime crossesnear 1 in 100memory crossesat 1 in 2
Fig. 7 

The two thresholds, measured on the machine this was written on. Blue is stored bytes, orange is elapsed time, and the green line is what dense costs. Memory pays off up to one entry in two. Time pays off only up to about one in a hundred, fifty times stricter.

That comparison deserves a caveat in each direction. It is harsh on sparse, because randomly placed nonzeros are the worst case for locality and a matrix with structure would do better. It is also harsh on sparse because the dense product goes to Apple’s Accelerate library, which uses the chip’s matrix hardware and more than one core, while the sparse product is a single thread of ordinary C. Grant both and the gap in arithmetic is still ten thousand to one, and nothing on either list accounts for a gap that size. The arithmetic was never the problem.

The problem is x[col_index[k]]. A dense kernel knows every address before it runs, which is what let Lecture 24 tile it and Lecture 26 hand it to thousands of threads. A sparse kernel learns each address from memory it just read, one at a time, and can predict nothing. It fetches a whole cache line to use one number from it, exactly the failure Lecture 23 measured, and it cannot be reordered out of existence because the order is data.

The hardware’s compromise

Put all of this next to a GPU and it gets worse. The whole model in Lecture 25 rests on thirty two neighbouring threads wanting thirty two neighbouring addresses, and a sparse row hands them thirty two addresses it looked up. Tensor cores are worse still: they eat a fixed rectangular block and have no opinion about which of its entries are zero.

So the hardware made an offer. Instead of allowing sparsity anywhere, demand it everywhere, in a fixed pattern that the silicon can decode. NVIDIA’s article Accelerating Inference with Sparsity Using the NVIDIA Ampere Architecture and NVIDIA TensorRT (July 20, 2021) describes the rule as: in each contiguous block of four values, two must be zero. Half the weights go, the survivors are stored with metadata giving their positions, and the sparse tensor core reads that metadata and skips the rest. The article’s table gives 624 sparse TOPS against 312 dense for FP16 input, and reports ResNet-50 at 76.2 percent top-1 accuracy sparse against 76.1 dense after a three-step retraining recipe.

one row of a weight matrix, pruned two out of every four1030060890012014150four groups of fourcompressvalues136891214152 bits each02130312half the values, plus two bits to say where each one sat
Fig. 8 

Two in every four. The pattern is chosen by the pruning, the positions cost two bits each, and the hardware can decode that while it reads. Cyan is a weight that survived, yellow is where it used to sit.

That is a strange bargain, and it is worth seeing why anyone took it. Fifty percent sparsity is far less than the ninety plus percent an unconstrained pruner can reach on the same network. What the hardware buys with the constraint is that the address becomes computable again, from two bits sitting at a known place which the silicon decodes as it reads. The pattern stays regular enough to keep the tensor core fed, and that is the only currency here.

Where this is going

Part VII asked one question in six chapters: where does a number live, and what does it cost to go and get it. Memory is one long line. A layout is the rule for walking it. A CPU that walks it well beats one that walks it badly by a factor nothing else in the chain can match. A GPU is thousands of walkers who must agree on a direction. Layout algebra is that agreement written down. And this chapter is what happens when you give up the rule and store the answers instead: you save the memory you expected and lose the speed you did not expect to lose.

If there is one habit to keep from all of it, it is that the arithmetic is rarely what costs. Counting multiplies told you the sparse product was ten thousand times cheaper, and it was three quarters as fast. The number that predicted the truth was the number of unpredictable addresses.

This part ends here as a text and starts somewhere else as a practice. Go and write a kernel, badly, and measure it. The Library has the three places worth starting: Triton Puzzles for thinking in blocks, LeetGPU for CUDA practice judged on real hardware, and the GPU MODE kernel leaderboard for problems nobody has finished optimising. The linear algebra in the earlier parts tells you what to compute. This part tells you what it will cost. The rest is measurement.

And the machine you now understand has one more part to serve. Everything Part VII built runs a model as it is; Lecture 29 opens Part VIII by asking what it costs to change one, and answers with this course’s own algebra: rank, subspaces, and rotations, hired to fine-tune. The matrices stop being examples there and start being the workload.