Two functions that add up every number in a 4096 by 4096 grid:
function sumRows(grid) {
let s = 0;
for (let r = 0; r < N; r++)
for (let c = 0; c < N; c++) s += grid[r][c];
return s;
}
function sumCols(grid) {
let s = 0;
for (let c = 0; c < N; c++)
for (let r = 0; r < N; r++) s += grid[r][c];
return s;
}
Same grid. Same 16.7 million additions. Same answer. The only difference is which loop is on the outside.
On my laptop (Node 24, Apple M3), median of 5 runs:
sumRows 11 ms
sumCols 146 ms
13 times slower. Nothing in the code tells you that.
The CPU never reads just one number
Reading from RAM takes around 100 nanoseconds. In that time the CPU could do hundreds of additions. So it cheats.
When you ask for one number, the CPU fetches the whole chunk of memory around it. That chunk is called a cache line. It's 64 bytes on most Intel and AMD chips, and 128 bytes on Apple's M chips. The CPU keeps recently used lines in a small, fast memory right next to it: the cache.
Each row of the grid sits in memory in one piece. grid[0][0], grid[0][1], grid[0][2] are neighbors.
-
sumRowsreads them in order. The first read of a line is slow (a miss). The next few are already in the cache (hits). -
sumColsreadsgrid[0][0], thengrid[1][0], thengrid[2][0]. Every one of those is in a different row, so a different line. 4096 rows later it comes back forgrid[0][1], which was in the very first line it loaded. But that line is long gone. The cache needed the room.
Here's the same thing on a tiny grid, with 8 numbers per line and a cache that only holds 4 lines. Row by row first:
One miss per line, then 7 free hits. 16 misses for 128 reads.
Now column by column:
128 misses for 128 reads. Every single read goes all the way to memory.
Is it really the cache?
Fair question. The column version also does a bit more work, because it has to look up a different row on every step. So I ran both on grids of different sizes, always doing the same 16.7 million additions in total:
| Grid | About | Rows | Columns | Slower by |
|---|---|---|---|---|
| 128 x 128 | 0.1 MB | 8 ms | 17 ms | 2.2x |
| 512 x 512 | 2 MB | 8 ms | 20 ms | 2.4x |
| 1024 x 1024 | 8 MB | 8 ms | 26 ms | 3.2x |
| 2048 x 2048 | 32 MB | 10 ms | 67 ms | 6.6x |
| 4096 x 4096 | 128 MB | 10 ms | 139 ms | 13x |
When the whole grid fits in the cache, columns are about 2x slower. That's the extra work. The row version stays flat no matter how big the grid gets. The column version falls apart once the grid is bigger than the cache (16 MB of L2 on this chip).
Same story with a Float64Array, where the whole grid is one flat block of memory: 12 ms by rows, 127 ms by columns. So it's not a JavaScript array thing. It's where the numbers live.
The fix
Put the loop that walks along a row on the inside. That's it.
But what if you actually need something per column, like each column's total? You don't have to walk down the columns to get it. Keep the row loop outside and add into an array of totals:
const colSums = new Float64Array(N);
for (let r = 0; r < N; r++) {
const row = grid[r];
for (let c = 0; c < N; c++) colSums[c] += row[c];
}
Same totals as going column by column. I checked, every one matches. 126 ms column by column, 12 ms this way.
If you really do need to walk columns all the time, store the grid the other way around, so each column is the piece that sits together in memory.
It's not just grids
The rule is bigger than loops over 2D arrays. Reading memory in order is cheap. Jumping around is expensive.
That's part of why looping over an array of numbers usually beats walking a linked list with the same values. The array's numbers sit next to each other. The list's nodes can be anywhere in memory, and every hop can be a miss.
The thing to remember
On paper both loops are the same: N² additions. The hardware disagrees. When a loop over a lot of data is slower than it should be, look at the order it touches memory in.
You can step through both orders yourself and watch the cache fill up: https://software-atlas.haiduc0147.dev/t/cpu-cache#locality


Top comments (0)