False sharing: measure the 64-byte penalty
Measure your machineNeeds C compiler, Python
Problem
CPUs keep memory coherent per cache line, not per variable. When one core writes a line, every other core's copy of it is invalidated, and the next write from another core must fetch it back. Two threads writing different variables on the same line therefore pay that transfer on every write, although they share no data: false sharing. Padding each variable to its own line removes it; so does keeping the hot variable in a register and writing the shared location once.
Start 8 threads. Each increments its own counter 100 million times. Run it with the counters packed in one array, then with each counter padded to its own 64-byte line. Then try counting in a local variable and writing once at the end. The script also pads to 128 bytes, the line size Apple silicon uses, and times one thread alone. It compiles and runs the C program for you (median of 5 runs).
The question. How much slower are packed counters than padded ones, and does the padded version run as fast as one thread alone?
Predict
Expect the packed version to be several times slower (often 5 to 20 times, depending on the CPU), and the padded version to scale with the number of threads. Expected result: the padded run takes about the same time as one thread alone; the packed run is much slower, although no data is shared.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
false-sharing-measure-the-64-byte.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """False sharing: measure the cache line. 8 threads each increment their own counter 100 million times: counters packed in one array, padded to 64 bytes, padded to 128 bytes (the line size on Apple silicon), and counted in a local variable written once at the end; plus one thread alone. The C program is compiled and run by this script (needs cc). """ import argparse, json, os, platform, subprocess, tempfile p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--threads", type=int, default=8, help="Threads.") p.add_argument("--iter", type=int, default=100_000_000, help="Increments per thread.") p.add_argument("--repeats", type=int, default=5, help="Runs per variant; the median is reported.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() C = r""" #include <pthread.h> #include <stdio.h> #include <time.h> #ifndef T #define T 8 #endif #if defined(PAD) struct { volatile long v; char pad[PAD - sizeof(long)]; } counts[T]; #define C(i) counts[i].v #else volatile long counts[T]; #define C(i) counts[i] #endif static void *worker(void *arg) { long id = (long)arg; #ifdef LOCAL long local = 0; for (long i = 0; i < ITER; i++) { local++; __asm__ volatile("" : "+r"(local)); } /* kept in a register */ C(id) = local; #else for (long i = 0; i < ITER; i++) C(id)++; #endif return NULL; } int main(void) { pthread_t th[T]; struct timespec a, b; clock_gettime(CLOCK_MONOTONIC, &a); for (long i = 0; i < T; i++) pthread_create(&th[i], NULL, worker, (void *)i); for (int i = 0; i < T; i++) pthread_join(th[i], NULL); clock_gettime(CLOCK_MONOTONIC, &b); printf("%.3f\n", (b.tv_sec - a.tv_sec) + (b.tv_nsec - a.tv_nsec) / 1e9); } """ d = tempfile.mkdtemp(); src = os.path.join(d, "lab.c"); open(src, "w").write(C) def run(label, flags): exe = os.path.join(d, "lab_" + str(abs(hash(label)))) subprocess.run(["cc", "-O1", "-pthread", src, "-o", exe, f"-DITER={args.iter}L", *flags], check=True) runs = sorted(float(subprocess.run([exe], capture_output=True, text=True, check=True).stdout) for _ in range(args.repeats)) return {"variant": label, "seconds": runs[len(runs) // 2]} rows = [run("1 thread alone", ["-DT=1"]), run(f"{args.threads} threads, packed counters", [f"-DT={args.threads}"]), run(f"{args.threads} threads, padded to 64 bytes", [f"-DT={args.threads}", "-DPAD=64"]), run(f"{args.threads} threads, padded to 128 bytes", [f"-DT={args.threads}", "-DPAD=128"]), run(f"{args.threads} threads, local counter", [f"-DT={args.threads}", "-DLOCAL"])] for r in rows: print(f"{r['variant']:<34} {r['seconds']:6.2f} s") print(f"CPU: {platform.machine()} {platform.processor()}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"variants": rows, "summary": [{"packed_over_one": rows[1]["seconds"] / rows[0]["seconds"], "pad64_over_one": rows[2]["seconds"] / rows[0]["seconds"], "pad128_over_one": rows[3]["seconds"] / rows[0]["seconds"]}]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run false-sharing-measure-the-64-byte.py uv run false-sharing-measure-the-64-byte.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --threads8 | Threads. |
| --iter100,000,000 | Increments per thread. |
| --repeats5 | Runs per variant; the median is reported. |
Our run
Data table
| Variant | Seconds |
|---|---|
| — | 0.197 |
| — | 0.437 |
| — | 0.113 |
| — | 0.081 |
| — | 0.038 |
Eight threads, five layouts
| Variant | Time |
|---|---|
| 1 thread alone | 197 ms |
| 8 threads, packed counters | 437 ms |
| 8 threads, padded to 64 bytes | 113 ms |
| 8 threads, padded to 128 bytes | 81 ms |
| 8 threads, local counter | 38 ms |
1 thread alone 0.20 s
8 threads, packed counters 0.44 s
8 threads, padded to 64 bytes 0.11 s
8 threads, padded to 128 bytes 0.08 s
8 threads, local counter 0.04 s
CPU: arm64 armWhat we found
Packed counters took 437 ms, against 113 ms padded to 64 bytes and 81 ms padded to 128: packing was several times slower than padding, at the low end of the expected range. Padding to 64 bytes still left neighbours sharing a 128-byte line on this CPU, which is why 128 was faster again. The local counter was fastest (38 ms). One thread alone took 197 ms, longer than eight padded threads, which the expectation does not predict; on a CPU with performance and efficiency cores the lone thread's speed depends on where it is scheduled, so treat that row as unreliable rather than as a speed-up from sharing.
Read it
The line size is a property of the CPU, and the right padding is the line size of the machine it runs on: 64 bytes on most x86, 128 on Apple silicon. The cheapest fix is not padding at all but not writing shared memory in the hot loop: accumulate locally and publish once.
False sharing is invisible in the code, since no variable is shared, and in most profilers, which show the slow instruction (an increment) but not why it is slow.