Row-Major Array Addressing

A processor sees no rows and no columns — only a flat, one-dimensional expanse of numbered bytes. Yet programmers happily write grid[i][j] and tensor[i][j][k] as if the machine kept their data in a tidy rectangle. Somewhere in the compiler, that comfortable fiction must be turned back into a single number: the byte address of exactly one element. The rule that does the turning is the addressing polynomial, and choosing how to flatten a multi-dimensional array into one line of memory is the difference between row-major and column-major layout.

This is not a footnote. It is where the elegance of expression translation meets the hard geometry of memory. Every a[i][j] in the source becomes a little burst of multiplications and additions in the intermediate code — and getting that arithmetic exactly right, once, in the code generator, is what lets a loop over a matrix run at the speed of light instead of reading garbage.

One dimension is just base plus scaled index

Start where expression translation left off. A one-dimensional array a of elements that are each w bytes wide stores a[i] at

\textit{addr}(a[i]) \;=\; \textit{base}(a) \;+\; i \times w.

The width w is type-driven — 4 for a 32-bit int, 8 for a double — and comes from the symbol table. Nothing here is surprising; the interesting part is what happens when we nest.

Row-major: last index varies fastest

Picture a 3 \times 4 matrix. Row-major order (used by C, C++, Python/NumPy by default, Pascal, Rust) lays the whole first row down first, then the whole second row, then the third — so as you step through memory the rightmost index changes fastest. Element a[i][j] of an array declared with n_2 columns sits at linear element-offset i \times n_2 + j: skip i whole rows of n_2 elements, then step j across the row you land in. The figure shows the flattening for a[1][2].

Multiply that element-offset by the byte width and add the base, and you have the full two-dimensional addressing formula:

\textit{addr}(a[i][j]) \;=\; \textit{base}(a) \;+\; (\,i \times n_2 + j\,)\times w.

Notice that the number of rows n_1 never appears — only the number of columns n_2 matters, because it is the length of one row, the stride between successive values of i. That asymmetry is the whole personality of row-major order.

The addressing polynomial, in general

For k dimensions the pattern nests, and it nests as a Horner polynomial. An array declared with bounds [n_1][n_2]\cdots[n_k] stores element a[i_1][i_2]\cdots[i_k] at element-offset

i_1 n_2 n_3\cdots n_k \;+\; i_2 n_3\cdots n_k \;+\; \cdots \;+\; i_{k-1}n_k \;+\; i_k,

which is far nastier written flat than it is written the way a compiler actually emits it — by Horner's method, folding the indices in from the left, one multiply-and-add per dimension:

\textit{offset} \;=\; \big(\cdots((i_1)\,n_2 + i_2)\,n_3 + i_3)\cdots\big)\,n_k + i_k,\qquad \textit{addr} = \textit{base} + \textit{offset}\times w.

For three dimensions this is the tidy \textit{base} + ((i\,n_2 + j)\,n_3 + k)\times w — an accumulator that starts at the first index and, for each further dimension, multiplies by that dimension's size and adds the next index. It is exactly the loop you would write to evaluate any polynomial efficiently, and it costs only k-1 multiplications and k-1 additions for a k-dimensional access.

Row-major vs column-major

Column-major order (Fortran, MATLAB, R, Julia, and the BLAS/LAPACK numeric world) makes the first index vary fastest: it stores whole columns contiguously. Same elements, mirror-image polynomial —

\textit{addr}_{\text{col}}(a[i][j]) \;=\; \textit{base}(a) \;+\; (\,j \times n_1 + i\,)\times w.

The choice is pure convention, but it has teeth. The elements you touch consecutively in the fast layout are adjacent in memory, so they share cache lines; touch them in the wrong order and every access is a cache miss. That is why a matrix loop written for i { for j { a[i][j] } } flies in C (row-major, inner index j strides by one) but crawls if you transpose the loops — and why the identical nesting is exactly backwards in Fortran.

LayoutContiguous unitFastest-varying indexAddress of a[i][j]Languages
Row-majora rowlast (j)(i\,n_2 + j)\,wC, C++, Rust, NumPy, Pascal
Column-majora columnfirst (i)(j\,n_1 + i)\,wFortran, MATLAB, Julia, R

How the IR lowers a[i][j]

In three-address code the access is not a primitive — it is the Horner polynomial spelled out as a chain of tiny instructions, ending in an indexed load or store. For an int matrix with n_2 = 4 columns, x = a[i][j] lowers to:

t1 = i * 4 // i * n2 (n2 = number of columns) t2 = t1 + j // (i*n2 + j) — the element index t3 = t2 * 4 // scale by element width w = 4 bytes t4 = a[t3] // indexed read at the computed byte offset x = t4

Every dimension after the first adds one multiply-by-size and one add-the-next-index — the accumulator of Horner's method, made of primitive TAC operators. The final scale-by-w converts the element index into a byte offset for the indexed access. The code generator below builds this for an array of any rank.

The addressing polynomial, in code

Here is Horner's method as a compiler would apply it: fold the indices against the dimension sizes to get an element-offset, then scale by the width and add the base. We compute a couple of addresses in a 3 \times 4 matrix of 4-byte ints and one in a 2 \times 3 \times 4 tensor of 8-byte doubles.

// Row-major linear address of a[i1][i2]...[ik]. // dims = declared sizes [n1, n2, ..., nk] // idx = the indices [i1, i2, ..., ik] // base = base byte address of the array // width = element size in bytes function rowMajorAddr(dims: number[], idx: number[], base: number, width: number): number { // Horner fold: offset accumulates one (× size, + index) per dimension. let offset = 0; for (let d = 0; d < idx.length; d++) { offset = offset * dims[d] + idx[d]; // × this dimension's size, + this index } return base + offset * width; // scale element-offset to bytes, add base } // 3 x 4 int matrix, base 1000, width 4. const dims2 = [3, 4]; for (const [i, j] of [[0, 0], [1, 2], [2, 3]]) { const a = rowMajorAddr(dims2, [i, j], 1000, 4); console.log(`int a[${i}][${j}] offset = ${(a - 1000) / 4} addr = ${a}`); } console.log("---"); // 2 x 3 x 4 double tensor, base 5000, width 8. const dims3 = [2, 3, 4]; for (const [i, j, k] of [[0, 0, 0], [1, 2, 3], [0, 1, 2]]) { const a = rowMajorAddr(dims3, [i, j, k], 5000, 8); console.log(`double t[${i}][${j}][${k}] offset = ${(a - 5000) / 8} addr = ${a}`); }

The matrix's a[1][2] lands at element-offset 1\times 4 + 2 = 6, byte address 1000 + 24 = 1024 — exactly what the figure highlighted. The tensor's t[1][2][3] folds to ((1\cdot 3 + 2)\cdot 4 + 3) = 23, the very last element, at byte 5000 + 184. One loop, any rank.

Then the compiler plays a lovely trick. If indices run from \textit{low} rather than 0, the naive address is \textit{base} + (i - \textit{low})\times w. But (i-\textit{low})\times w = i\times w - \textit{low}\times w, and that second term is a constant the compiler knows at compile time. So it precomputes a virtual base \textit{base} - \textit{low}\times w — an address that may point before the array actually starts — and then addresses every element with the plain i\times w formula. The offset for the lower bound is paid once, folded into a fictitious base, and the hot inner loop stays as cheap as if the array were zero-based. It generalises cleanly to every dimension of a multi-dimensional array.

The single most common blunder is to reach for the wrong dimension size. In row-major a[i][j], the stride between successive rows is the number of columns n_2, not the number of rows. Students constantly write i\times n_1 + j using the row count — which computes a plausible-looking but completely wrong address, and worse, one that only sometimes reads out of bounds, so the bug hides until n_1 \ne n_2. The rule to burn in: in row-major you multiply an index by the product of all the dimension sizes to its right. For a plain 2-D array that product is just n_2. And the mirror trap: use the row-major polynomial on a column-major array (or vice versa) and you will transpose your entire matrix silently. Match the polynomial to the language's layout.