linearly

Lecture 23

How Memory Holds a Matrix

Memory is one long line, so every matrix is a promise about addresses. Row-major order, strides, and cache lines: the physics under every reshape.

The idea in one sentence

Memory is one long line of numbered bytes, so a matrix is a rule for turning a row and a column into an address, and everything fast or slow about matrix code starts with that rule.

The line under the grid

You draw a matrix as a grid because a grid is how the algebra reads. Rows across, columns down, an entry at every crossing. The machine has none of that. It has a line of bytes, numbered from zero, and one instruction that says fetch the byte at number qq.

So the grid has to be a promise. Somebody has to decide, once, which byte holds the entry in row ii and column jj, and then keep the promise forever. That decision is called the layout, and it is the whole subject of this chapter.

The simplest promise is the one C makes. Lay row 0 down first, left to right. Then row 1 right after it. Then row 2. Nothing is skipped and nothing is repeated, so the entry in row ii and column jj is preceded by ii whole rows and jj more entries.

Write α(i,j)\alpha(i, j) for the address of that entry, pp for the address of the first entry, ss for the size of one number in bytes, and nn for the number of columns. Then

α(i,j)  =  p+(in+j)s.\alpha(i, j) \;=\; p + (i\,n + j)\,s .
j = 0j = 1j = 2j = 3i = 00123i = 14567i = 2891011how you think about itaddress = base + (i x 4 + j) x 8= base + (2 x 4 + 1) x 8= base + 724 columns, 8 bytes each,so one step down a columnis 32 bytes forwardwhat the machine has: one line of bytes0+012+1634+3256+4878+64910+8011row 0row 1row 2A[2][1] is byte 72
Fig. 1 

The grid on top is a way of reading; the strip below is what exists. Cyan, orange and purple mark which row a byte came from. Rows land end to end in the order they are written, which is what makes the address formula a multiplication and an addition and nothing more.

Everything about the layout is in two numbers per axis. The shape says how many steps an index may take, and the stride says how many bytes one step costs. Moving one column to the right costs ss bytes. Moving one row down costs nsn\,s bytes, because a row is nn numbers long.

the layout machinei = 2j = 1i x 32 bytes = 64j x 8 bytes = 8+ basebase + 72one addressTwo numbers per axis and nothing else: a shape, and a stride.address = base + i x stride0 + j x stride1A d-dimensional array carries d strides and the rule is the same sum.
Fig. 2 

The layout as a machine: indices in, one address out. Yellow marks the inputs, green the answer. Change the two multipliers and the same machine describes any layout in this chapter.

Give those two multipliers names and the formula stops depending on the convention. Let s0s_0 be the stride along rows and s1s_1 the stride along columns:

α(i,j)  =  p+is0+js1.\alpha(i, j) \;=\; p + i\,s_0 + j\,s_1 .

A dd-dimensional array works the same way, with one stride per axis:

α(i1,,id)  =  p+k=1diksk.\alpha(i_1, \ldots, i_d) \;=\; p + \sum_{k=1}^{d} i_k\, s_k .

That sum is the entire theory. The rest of this chapter is what happens when you choose the sks_k badly.

Two conventions, and who chose which

C fills a row before moving down. Fortran fills a column before moving right. Both are consistent, both cost one multiplication and one addition, and the two of them split the numerical world in half. Fortran’s promise is

α(i,j)  =  p+(jm+i)s,\alpha(i, j) \;=\; p + (j\,m + i)\,s ,

with mm the number of rows. The names for the two habits are row-major and column-major, and Wikipedia’s row- and column-major order article keeps the roll call.

row-majorcolumn-major
C, C++, Objective-CFortran, MATLAB, Octave
Pascal, PL/IJulia, R, Scilab
NumPy by defaultBLAS and LAPACK

The last row is the one that costs people time. NumPy is row-major, and every serious linear algebra library underneath it is column-major, because BLAS and LAPACK grew up in Fortran. That mismatch is handled for you, and it is handled by a flag rather than by copying anything, which is a trick this chapter earns two sections from now.

NumPy will build either layout on request, and it will tell you which one you have.

python
import numpy as np

A = np.arange(12.0).reshape(3, 4)          # row-major, the default
F = np.asfortranarray(A)                   # the same numbers, column-major

print(A.strides)                           # (32, 8)
print(F.strides)                           # (8, 24)
print(np.array_equal(A, F))                # True

print(A.ravel(order='K'))                  # [ 0.  1.  2.  3.  4.  5.  6.  7.  8.  9. 10. 11.]
print(F.ravel(order='K'))                  # [ 0.  4.  8.  1.  5.  9.  2.  6. 10.  3.  7. 11.]

Read A.strides as the two multipliers from Figure 2. One step down a column costs 32 bytes, because a row is 4 doubles long and you have to clear it. One step right costs 8, the size of a double. In F those numbers trade places: a step right costs 24 because a column is 3 doubles long, and a step down costs 8. The order='K' flag on ravel means walk in memory order, so the two printed lists are literally the two lines of bytes.

The matrices are equal. The bytes are in a different order. Nothing else about them differs, and everything about their speed does.

Strides make views free

Once the layout is two numbers per axis, a surprising amount of array manipulation costs nothing. It is arithmetic on the strides, and the data never moves.

Transposing swaps the two strides. Row 5 of ATA^{\T} is column 5 of AA, so the stride that used to carry you along a row now carries you down a column, and the promise still holds for every entry.

read it 3 by 4shape (3, 4), strides (32, 8)01234567891011Rows run along memory.One step in j is 8 bytes,one step in i is 32.01234567891011the same 96 bytes, never copied04815926103711read it 4 by 3, the transposeshape (4, 3), strides (8, 32)Now columns run along memory,because the two strides swapped.Yellow is one byte, and it is thesame byte in all three pictures.np.shares_memory(A, A.T) is True.
Fig. 3 

Green is the three by four reading, orange is the four by three reading, cyan is the bytes themselves, and yellow is one byte followed through all three. Transposing rewrote a pair of strides and moved nothing.

Slicing with a step multiplies a stride. Reshaping a contiguous array recomputes the strides from the new shape. Adding a length-one axis inserts a stride nobody will ever use. All of it is bookkeeping.

python
import numpy as np

A = np.arange(12.0).reshape(3, 4)
print(A.T.strides)                         # (8, 32)
print(np.shares_memory(A, A.T))            # True

B = np.arange(24.0).reshape(2, 3, 4)
print(B.strides)                           # (96, 32, 8)
print(B[:, ::2, ::2].strides)              # (96, 64, 16)
print(B.transpose(2, 0, 1).strides)        # (8, 96, 32)
print(B.reshape(6, 4).strides)             # (32, 8)

The three strides of B read from the outside in. One step along the first axis skips a whole 3×43 \times 4 block, which is 96 bytes. One step along the second skips a row of 4 doubles, which is 32. One step along the last is a single double. Take every other element on the last two axes and those two strides double. Nothing was copied, and np.shares_memory says so.

This is also why the transpose flag in BLAS is not a courtesy. A row-major matrix, read with the strides swapped, is a column-major matrix. The Fortran library and the C caller can agree about the bytes and disagree about which index runs fastest, and neither of them has to move anything.

Sixteen numbers arrive whether you want them or not

So far every address costs the same. That is the part memory does not honor.

Between the core and main memory sits a cache, and the cache does not deal in bytes. It deals in blocks called cache lines. Ask for one number and the machine fetches the whole aligned block around it, because fetching 128 bytes costs almost exactly what fetching 8 would.

the coreone requestyou asked for this one doubleone cache line: 128 bytes, 16 doublesnot fetched yetOn the machine this was written on, sysctl hw.cachelinesize reports 128.On most x86 desktops it reports 64. Either way the rule is the same:memory is sold in blocks, and you pay for the whole block.
Fig. 4 

One request, one whole line. The yellow cell is what the program asked for and the blue frame is what the machine delivered. On this Mac a line is 128 bytes, which is 16 doubles or 32 floats.

You can read the number off the machine you are sitting at. On the Apple silicon Mac this chapter was written on, sysctl hw.cachelinesize prints 128. On most x86 desktops it prints 64. The size changes; the fact does not.

Now put the cache line next to the address formula and the whole chapter falls out. Walking along a row of a row-major matrix moves 8 bytes at a time, so 16 consecutive entries ride in on one line and 15 of them were free. Walking down a column moves nsn\,s bytes at a time. Once nsn\,s reaches 128, every single entry needs a line of its own, and the other 15 numbers on each line are thrown away.

walk along a row16 values wanted, 1 line fetched128 bytes used out of 128walk down a column4 values wanted, 4 lines fetched32 bytes used out of 512Each strip is one cache line. Green and pink mark the values the walk wanted;blue frames mark the lines the machine had to fetch to deliver them. Same grid,same count of values, sixteen times the traffic.
Fig. 5 

Each strip is one cache line. The row walk on the left asks for 16 values and pays for one line. The column walk on the right asks for 4 values and pays for four. Green and pink are the values wanted, blue is what the machine had to fetch.

That is a sixteen-fold difference in traffic for the same count of arithmetic operations, and nothing in the source code announces it. The loop looks the same either way. Only the order of the two indices tells you which one you wrote.

What a stride costs, measured

The argument above is worth checking rather than believing, and checking it is easy. Take one long array, sum every element, then sum every second element, every fourth, and so on. Each run touches fewer elements, so measure the cost per element touched. If the cache line story is right, that cost should climb while the stride is still smaller than a line, then stop climbing the moment each element needs a line of its own.

python
import numpy as np
from timeit import repeat

a = np.ones(1 << 25, dtype=np.float64)     # 256 MiB, far larger than any cache

for step in (1, 2, 4, 8, 16, 32, 64):
    v = a[::step]
    t = min(repeat(lambda: v.sum(), number=3, repeat=9)) / 3
    print(step * 8, "bytes  %.3f ns/element" % (t / v.size * 1e9))

#    8 bytes  0.134 ns/element
#   16 bytes  0.212 ns/element
#   32 bytes  0.387 ns/element
#   64 bytes  0.708 ns/element
#  128 bytes  2.596 ns/element
#  256 bytes  2.682 ns/element
#  512 bytes  2.581 ns/element
01238163264128256512stride in bytesnanosecondsper element128 bytes: one cache line0.132.602.58Once the stride passes 128 bytes every element costs a whole line,so the curve stops climbing. There is nothing left to waste.
Fig. 6 

The measurement, on the machine this was written on. Cost per element roughly doubles with each doubling of the stride, then flattens at 128 bytes. Past a full line there is nothing left to waste, so the curve has nowhere to go.

The knee lands on 128 bytes, which is what sysctl reported, and the flat stretch after it is the proof: a stride of 512 bytes is no worse than 128, because both throw away everything except one number per line. Between the two ends is a factor of about 19.

Force it and the difference arrives. Copying a matrix into a fresh C-order buffer is a walk along rows; copying its transpose into the same kind of buffer is a walk down columns, and no reordering can rescue both sides at once.

python
import numpy as np
from timeit import repeat

M = np.arange(4096 * 4096, dtype=np.float64).reshape(4096, 4096)
bench = lambda f: min(repeat(f, number=5, repeat=7)) / 5

good = bench(lambda: M.copy())             # 0.002230 s
bad = bench(lambda: M.T.copy())            # 0.073013 s
print(bad / good)                          # 32.73782816383471

The same 128 MiB in, the same 128 MiB out, thirty times the time. Repeated runs here put the factor between 27 and 38, which is a wide spread and a small point: the exact number depends on what else the machine is doing, and the order of magnitude does not. The only difference between the two lines is the order of the visits.

The number BLAS asks for that nothing else does

One more consequence, and it explains a signature that puzzles everyone who meets it.

A library rarely gets a whole matrix. It gets a window inside a larger allocation: a block of a big matrix, a slice of a batch, a panel that some blocking scheme cut out. The window has its own height and width. It does not have its own stride. Its rows are still spaced by the width of the thing it was cut from.

the allocation you own: 1000 numbers per rowthe window you hand to BLASwhat those four rows look like in memory6 wanted9946 wanted9946 wanted9946 wanted994 numbers skipped between one row and the nextThe window is not contiguous. Its row stride is still 1000, the width of theparent. BLAS calls that number lda and asks for it separately from m, n and k,which is how one call can work on a block of a much larger matrix.
Fig. 7 

A window inside a bigger allocation. Its shape is 4 by 6 and its row stride is 1000, the width of the parent. BLAS asks for that stride separately and calls it the leading dimension.

python
import numpy as np

big = np.zeros((1000, 1000))
win = big[100:110, 200:206]

print(win.shape)                           # (10, 6)
print(win.strides)                         # (8000, 8)
print(win.flags['C_CONTIGUOUS'])           # False
print(np.shares_memory(big, win))          # True

The row stride is 8000 bytes, which is 1000 doubles, which is the parent’s width. That is why every BLAS routine carries a size argument you did not expect. Here is the workhorse, the one that occupies the rest of this part of the course:

c
void sgemm_(const char *transa, const char *transb,
            const int *m, const int *n, const int *k,
            const float *alpha,
            const float *a, const int *lda,
            const float *b, const int *ldb,
            const float *beta,
            float *c, const int *ldc);

The shapes are m, n and k. The three extra integers lda, ldb and ldc are the leading dimensions. The reference documentation defines lda as the first dimension of a as declared in the calling program, at least max(1, m) when transa is N. Shape and stride are asked for separately because they are separate facts, exactly as in Figure 2. It is also what lets one call work on a block of a much larger matrix without copying it out first, and the next chapter leans hard on that.

Fields or columns

The last layout choice has nothing to do with matrices, and it turns up wherever data has named parts. Say every record has an xx, a yy and a zz. You can store one whole record after another, or you can store all the xx together, then all the yy, then all the zz. The first is called an array of structs, the second a struct of arrays.

xyzxyzxyzxyzxyzxyzarray of structs: x, y, z, x, y, z, …one line holds 16 numbers and only 6 of them are xxxxxxxxxxxxxxxxxxxstruct of arrays: all the x togetherthe same line holds 16 numbers and all 16 are xSumming one field over 4 million records, the layout below beat the layout aboveby 2.25 times on the machine this was written on. Nothing changed but the order.
Fig. 8 

The same records, two layouts. The blue frame is one 128-byte line. Above it delivers 6 useful numbers out of 16, below it delivers 16 out of 16, and the code that reads them is identical.

python
import numpy as np
from timeit import repeat

n = 1 << 22
aos = np.zeros(n, dtype=[('x', 'f8'), ('y', 'f8'), ('z', 'f8')])
aos['x'] = 1.0
soa = np.ones(n, dtype=np.float64)

bench = lambda f: min(repeat(f, number=3, repeat=9)) / 3
print(aos['x'].strides, soa.strides)       # (24,) (8,)
print(bench(lambda: aos['x'].sum()))       # 0.001221
print(bench(lambda: soa.sum()))            # 0.000542

A stride of 24 bytes against a stride of 8, and a factor of 2.25 on the machine this was written on. The struct of arrays is the layout every numerical framework quietly imposes when it asks you for an array of features rather than a list of objects.

This also answers a question Lecture 3 left open. A batch of images with shape (5,10,784)(5, 10, 784) is not a nest of Python containers. It is one block of 39200 numbers carrying three strides, and every batched matrix multiply in a forward pass is a promise about them. The shapes tell you what the algebra does. The strides tell you what it costs.

Where this is going

Nothing in this chapter multiplied two matrices. Everything in it decided how fast that will go.

The next chapter takes one line, C+=ABC \mathrel{+}= AB at size 1024, and drives it from two and a half seconds down to under three milliseconds on one chip, without changing a single arithmetic operation. Every step is a decision about the order the memory is walked, which is to say every step is a sentence from this chapter cashed in. The row walk and the column walk of Figure 5 turn out to be two rungs of that ladder, and the swap between them is the largest single jump on it.