Assembly (x86-64, AArch64, RISC-V) Module 13 – Atomics and memory ordering
Caches, cache lines and false sharing
Time a row walk against a column walk on Apple silicon, read the cache line size, and see why threads slow down when their data share a line.
What you will learn
- Explain how a cache moves memory in lines, and find the line size a system reports
- Predict which of two loops over the same data runs faster from how each uses cache lines
- Detect false sharing between threads and remove it with padding and alignment
- Compare a prefetched walk in memory order with a pointer-chasing walk of the same nodes
- Find the cache line size on macOS, Linux, x86-64 and AArch64
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how a cache moves memory in lines, and find the line size a system reports
- Predict which of two loops over the same data runs faster from how each uses cache lines
- Detect false sharing between threads and remove it with padding and alignment
- Compare a prefetched walk in memory order with a pointer-chasing walk of the same nodes
- Find the cache line size on macOS, Linux, x86-64 and AArch64
Before you start
On this page
matrix.c adds up the same 16,777,216 numbers twice and gets the same total both times, yet on an Apple silicon Mac
under macOS, best of five trials each, the column-by-column walk took many times as long as the row-by-row one.
Nothing differs but the order of the loads.
A cache: A small, fast memory near a core that keeps copies of recently used memory, so that later loads of the same or nearby addresses need no trip to main memory. keeps recently used memory close to the core, and it brings memory in whole
cache line: The fixed-size, aligned block in which a cache stores and moves memory. Sizes differ between processors and cache levels: Intel gives 64 bytes for its Core processors. A miss brings in the whole line.: a load that walks memory in order uses every byte it brought in, and one
that jumps uses a sliver of each line.
Threads feel the same unit. Two threads that write different variables in one line slow each other down, which is called false sharing: A slowdown in which threads write different variables that happen to lie in the same cache line, so the line moves between their cores although no variable is shared.: only one core at a time may write a line, so the line moves back and forth between their cores. Every run below is native.
A cache moves memory in whole lines, and the system reports their size
Each cache moves memory in cache lines of its own fixed size. macOS reports one line size and the cache sizes
through sysctl, which Apple documents for exactly this:
"""Asks macOS for the cache line size and the cache sizes of the two kinds of core."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# The line size, the L1 data cache of each kind of core (perflevel0 is the faster kind, perflevel1 the other),
# and the L2 cache of the perflevel0 cores, all in bytes
/usr/sbin/sysctl hw.cachelinesize hw.perflevel0.l1dcachesize hw.perflevel1.l1dcachesize hw.perflevel0.l2cachesize
""") Output
# The line size, the L1 data cache of each kind of core (perflevel0 is the faster kind, perflevel1 the other), # and the L2 cache of the perflevel0 cores, all in bytes $ /usr/sbin/sysctl hw.cachelinesize hw.perflevel0.l1dcachesize hw.perflevel1.l1dcachesize hw.perflevel0.l2cachesize hw.cachelinesize: 128 hw.perflevel0.l1dcachesize: 131072 hw.perflevel1.l1dcachesize: 65536 hw.perflevel0.l2cachesize: 16777216
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lines_session.py
hw.cachelinesize is 128, the size Apple calls the system cache line: on a miss, a cache with lines of that size
fetches the aligned 128 bytes around the address, not just the 4 or 8 the load asked for. Apple’s open-source
kernel reads that number from the cache beyond the L1, and lists 64-byte lines for the L1 data caches of the cores it
names. Apple numbers the host’s two kinds of core from the fastest: a perflevel0 core has a 131,072-byte L1 data
cache, a perflevel1 core half that, and the L2 of the perflevel0 cores holds 16,777,216 bytes. Intel’s manual
gives 64-byte lines for the L1 and L2 caches of its Core and Atom processors.
A walk in memory order uses every byte of each line
matrix.c fills a 4096 x 4096 matrix of int, 64 MiB, and sums it both ways at -O1, so that neither loop is
vectorized and each does the same work per element. C stores a matrix row by row, so m[i][j] and m[i][j + 1] are
4 bytes apart while m[i][j] and m[i + 1][j] are 16,384 bytes apart:
// matrix.c: sums one 4096 x 4096 matrix of int twice, row by row and column by column, and times each walk.
// Both walks add the same 16,777,216 numbers, so they print the same sum; only the order of the loads differs.
// Built with -O1, so neither loop is vectorized and the two loops do the same work per element.
#include <stdio.h>
#include <time.h>
#define N 4096
static int m[N][N]; // 64 MiB, four times the largest cache the host reports
static double now_ms(void) {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return t.tv_sec * 1e3 + t.tv_nsec / 1e6;
}
// Consecutive elements: m[i][j] and m[i][j + 1] are 4 bytes apart.
static long by_rows(void) {
long sum = 0;
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++) sum += m[i][j];
return sum;
}
// One element per row: m[i][j] and m[i + 1][j] are 4096 x 4 = 16,384 bytes apart.
static long by_columns(void) {
long sum = 0;
for (int j = 0; j < N; j++)
for (int i = 0; i < N; i++) sum += m[i][j];
return sum;
}
int main(void) {
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++) m[i][j] = (i + j) % 10;
double best_rows = 1e30, best_columns = 1e30;
long sum_rows = 0, sum_columns = 0;
for (int trial = 0; trial < 5; trial++) { // the best of 5 trials, as the machine is shared
double t0 = now_ms();
sum_rows = by_rows();
double t1 = now_ms();
sum_columns = by_columns();
double t2 = now_ms();
if (t1 - t0 < best_rows) best_rows = t1 - t0;
if (t2 - t1 < best_columns) best_columns = t2 - t1;
}
printf("matrix: %d x %d int, %zu bytes\n", N, N, sizeof m);
printf("row by row: sum %ld, best of 5: %.1f ms\n", sum_rows, best_rows);
printf("column by column: sum %ld, best of 5: %.1f ms\n", sum_columns, best_columns);
printf("column by column took %.1f times as long\n", best_columns / best_rows);
return 0;
} """Builds matrix.c without vectorization and times a row-by-row and a column-by-column sum of the same 64 MiB."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
clang -O1 -o matrix matrix.c
./matrix
""") Output
$ clang -O1 -o matrix matrix.c $ ./matrix matrix: 4096 x 4096 int, 67108864 bytes row by row: sum 75497480, best of 5: 4.1 ms column by column: sum 75497480, best of 5: 48.1 ms column by column took 11.6 times as long
This output changes from run to run: The times change from run to run and with the load of the machine; the sums and the sizes do not.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 matrix_session.py
The row walk has spatial locality: The tendency of a program to use memory near memory it has just used, such as the next element of a row. It lets one cache line serve many loads.: once a 128-byte line has come in, the next 31
loads find their ints in it. The column walk moves 16,384 bytes per load, so each load needs a new line, and one
column touches 4,096 lines, 512 KiB, more than the 128 KiB L1 of a perflevel0 core can hold. Both walks need the
same cache lines, but the column walk fetches each into the L1 many times over: by the time it returns for the next
column, those lines have left the L1:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first load misses. The row walk loads m[0][0]. Its line is not in the caches, so the whole 128-byte line that holds it comes in from memory: m[0][0] to m[0][31].
- The next 31 loads hit. m[0][1] to m[0][31] are in that same line, 4 bytes apart, so they come from the caches without another trip to memory.
- One fetch per 32 ints. m[0][32] starts the next line. The row walk brings each line in once and uses all of it: 16,777,216 ints / 32 = 524,288 line fetches for the matrix.
- The column walk starts. The column walk also starts with m[0][0] and fetches its line, but uses one int of the 32 before it moves to m[1][0].
- Each load is a new line. m[1][0] is 16,384 bytes further on, in another line, and so is every element below it. One column touches 4,096 lines: 512 KiB, more than the 128 KiB L1 of a perflevel0 core can hold.
- The lines have left the L1. When the walk returns for m[0][1], its line has long been pushed out of the L1 to make room. In the worst case every load fetches a line again: 16,777,216 fetches, 32 times the row walk.
Key idea
Memory reaches the core a line at a time. A loop is fast when consecutive loads fall in the same line, and slow when every load needs a line of its own.
Three questions go further:
- False sharing slows threads that write separate variables in one line, and padding each variable to its own line removes it.
- A hardware prefetcher: A part of the processor that watches the addresses of a program's loads and fetches the lines it predicts will be needed next, before the loads ask for them. fetches lines ahead of a walk in memory order, which pointer chasing: Walking a data structure by loading an address from memory and then loading through that address, as a linked-list walk does at every node. More in “Structs, arrays and linked lists in assembly” through a shuffled list defeats.
- Each system reports the line size its own way: sysctl on macOS, sysfs on Linux, CPUID or CTR_EL0 on the processor.
Checkpoint
Check: Caches and cache lines · Walking memory in order
Practise
Three tasks on matrix.c. Build with clang -O1 -o matrix matrix.c and run ./matrix.
- Change
inttolong longin the matrix and in nothing else. Work out how many elements now share a 128-byte line, predict whether the gap between the walks grows or shrinks, then run it. - Swap the two loops of
by_columnsso that it walks row by row, and check that its time falls to the row walk’s. - Work out how many lines one column of a 4096 x 4096 matrix of
chartouches, and whether they fit in aperflevel0core’s L1.
Practice that adapts to you
Practise this lesson
8 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
False sharing measured, and the padding that removes it Pro: opens with a Pro, Premium or Ultimate pass
Two threads that never touch each other’s variables can still slow each other down when those variables share a cache line. The run below shows the cost on two private counters, and the alignment that removes it.
In this part:
- Reproducing false sharing with two private counters
- Padding each counter to its own line
2 runnable examples (C, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Prefetchers and pointer chasing, measured on the same nodes Pro: opens with a Pro, Premium or Ultimate pass
A walk in memory order is fast for a second reason besides whole lines: the processor notices the pattern and fetches lines before the loads ask for them. A linked list in shuffled order defeats that, with the same nodes and the same work per node.
In this part:
- Timing the same nodes in memory order and in a shuffled order
- Making a linked structure kinder to the cache
2 runnable examples (C, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Finding the cache line size on each system, and the read that macOS refuses Pro: opens with a Pro, Premium or Ultimate pass
Padding to a cache line needs the line size of the machine the code runs on, and every system reports it somewhere else. Here are the four places to ask, including the processor register that a macOS program is not allowed to read.
In this part:
- Reading the Cache Type Register from a program, and why macOS answers with SIGILL
- The supported query on each system
2 runnable examples (Python, ctr.c, ctr.s) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
Why does summing a large C matrix row by row run faster than column by column?
C stores a matrix row by row, so the elements of a row are next to each other in memory, and the elements of a column are a whole row apart. A cache brings memory in whole lines, 64 bytes on many x86-64 processors and 128, as macOS reports it, on the Apple silicon host of the runs above, so a row walk uses every element of each line it fetches: for 4-byte ints, one miss serves 32 loads on a 128-byte line. A column walk jumps a row’s length on every load, so each load needs a line of its own, and in a large matrix those lines have left the cache before the walk comes back for the next column. Both walks do the same additions and give the same sum.
Three harder questions build on this one: counters that refuse to scale, a list against an array of the same records, and padding chosen for two kinds of processor.
-
Mid to senior
A server keeps one request counter per worker thread in an array, long counts[16], indexed by thread number, so that no two threads ever write the same counter. Adding threads makes it slower, not faster. Explain why, show how you would confirm it, and fix it.
-
Mid to senior
A linked list and an array hold the same two million records, and a loop sums one field of each. Both loops are O(n), yet the list's loop is many times slower. Explain the gap in terms of cache lines and loads, and say when a list can be made fast.
-
Senior to staff
A library pads per-thread data to avoid false sharing and must run on x86-64 Linux servers and on Apple silicon Macs. How do you choose the padding at compile time, how would you query the line size at run time on each system, and what does each answer cost?
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
In what unit does a cache move memory, and how big is it?
In whole aligned lines. Many x86-64 processors use 64 bytes; on the Apple silicon Mac used for the runs, sysctl reported 128. A miss brings in the whole line.
-
Why does summing a large C matrix row by row run faster than column by column?
C stores rows next to each other, so a row walk uses every element of each line it fetches: one miss serves 32 loads of 4-byte ints on a 128-byte line. A column walk jumps a whole row per load and needs a new line for every element.
-
Both walks of the matrix do the same additions and give the same sum. What sets their cost?
The order of the loads: whether consecutive loads fall in the same cache line or each needs a line of its own.
-
What is spatial locality, and why does it matter for caches?
The habit of using memory near memory just used, such as the next element of a row. It lets one fetched cache line serve many loads.
-
What is false sharing?
A slowdown in which threads write different variables that happen to sit in the same cache line, so the line keeps moving between their cores although no variable is actually shared.
Key takeaways
- Caches move memory in whole lines, and macOS reports the system line through sysctl: 128 bytes on the host of the runs.
- A loop whose consecutive loads fall in the same line uses all of each fetch; a loop that jumps a row per load needs a new line for every element.
- The order of the loads, not the number of additions, set the cost of the two walks of
matrix.c.
Terms in this lesson
- cache
- A small, fast memory near a core that keeps copies of recently used memory, so that later loads of the same or nearby addresses need no trip to main memory.
- cache coherence
- The hardware protocol that keeps the cores' cached copies of a line consistent, so that only one core at a time may write a line and others see its writes.
- cache line
- The fixed-size, aligned block in which a cache stores and moves memory. Sizes differ between processors and cache levels: Intel gives 64 bytes for its Core processors. A miss brings in the whole line.
- false sharing
- A slowdown in which threads write different variables that happen to lie in the same cache line, so the line moves between their cores although no variable is shared.
- hardware prefetcher
- A part of the processor that watches the addresses of a program's loads and fetches the lines it predicts will be needed next, before the loads ask for them.
- spatial locality
- The tendency of a program to use memory near memory it has just used, such as the next element of a row. It lets one cache line serve many loads.
References
- Determining system capabilities (sysctlbyname: hw.cachelinesize, hw.perflevelN cache sizes) (Apple Inc.)
- Addressing architectural differences in your macOS code (cache line sizes differ: fetch hw.cachelinesize) (Apple Inc.)
- XNU kernel source (osfmk/arm/cpuid.c: hw.cachelinesize from the cache beyond the L1; osfmk/arm64/proc_reg.h: 64-byte L1 data lines) (Apple Inc.)
- Intel 64 and IA-32 Architectures Software Developer's Manuals (Vol. 1: prefetchers, CPUID leaf 01H EBX[15:8]; Vol. 3A: memory cache control, MESI) (Intel Corporation)
- Arm A-profile architecture registers: CTR_EL0, Cache Type Register (Arm Limited)
- Linux kernel ABI: /sys/devices/system/cpu/cpuX/cache/indexY (coherency_line_size) (The Linux kernel development community)
- ISO/IEC 9899 working draft N3220 (6.2.8 extended alignment, 6.5.3.2 row-major arrays, 6.7.6 alignment specifier, 7.21 offsetof) (ISO/IEC JTC1/SC22/WG14)
Report a problem with this lesson
Kept only in this browser. Your Learn progress