Skip to content
About
Main content

False sharing: measure the 64-byte penalty

Measure your machineNeeds C compiler, Python

Problem

CPUs keep memory coherent per , 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: . 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

Eight threads, each incrementing its own counter: how much slower are packed counters than counters padded to 64 bytes?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    false-sharing-measure-the-64-byte.py74 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat it changes
--threads8 Threads.
--iter100,000,000 Increments per thread.
--repeats5 Runs per variant; the median is reported.

Our run

Time for 100 million increments per threadApple M2 · 8 cores · 8 GB
Data table
Time for 100 million increments per thread
VariantSeconds
—0.197
—0.437
—0.113
—0.081
—0.038
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

Eight threads, five layouts

Eight threads, five layouts
VariantTime
1 thread alone197 ms
8 threads, packed counters437 ms
8 threads, padded to 64 bytes113 ms
8 threads, padded to 128 bytes81 ms
8 threads, local counter38 ms
Output · uv run false-sharing-measure-the-64-byte.py
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 arm

What 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.