DEV Community

Cover image for Same loop, same numbers, 13x slower. I only swapped two lines.
Thới Hải Đức
Thới Hải Đức

Posted on

Same loop, same numbers, 13x slower. I only swapped two lines.

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

  • sumRows reads them in order. The first read of a line is slow (a miss). The next few are already in the cache (hits).
  • sumCols reads grid[0][0], then grid[1][0], then grid[2][0]. Every one of those is in a different row, so a different line. 4096 rows later it comes back for grid[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:

Row by row on an 8 by 16 grid. The first read of each cache line misses, the next 7 hit. 16 misses for 128 reads

One miss per line, then 7 free hits. 16 misses for 128 reads.

Now column by column:

Column by column on the same grid. Every read lands on a different line, and lines get evicted before they are used again. 128 misses for 128 reads

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];
}
Enter fullscreen mode Exit fullscreen mode

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)