Skip to content
About
Main content

SPSC ring buffer: head and tail on one line, then on two

Measure your machineNeeds C compiler, Python

Problem

A single-producer single-consumer needs no : the producer writes only tail, the consumer writes only head, and each reads the other's index to know how far it may go. If head and tail sit on the same , every update by one side invalidates the other's copy, so the indices ping-pong between cores on every item. Putting each on its own line removes that, and publishing in batches (update tail once per k items) cuts the remaining traffic by k, at the price of the consumer seeing a batch later.

Run one producer thread and one consumer thread that move 100 million long values through the ring from section 1 of Concurrency-friendly structures. Measure items per second in two builds: (a) head and tail in the same cache line, (b) each on its own line. Then add batched publishing and plot against the batch size k = 1, 2, 4, 8, 16, 64. The script compiles and runs the C program for you.

The question. How much does separating head and tail gain, and how much does batching add?


Predict

At batch size 1, how much does putting head and tail on separate cache lines gain?

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.

    spsc-ring-buffer-head-and-tail.py76 linesDownload
    View the code
    spsc-ring-buffer-head-and-tail.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """SPSC ring buffer: head and tail on one cache line, or two.
    
    One producer and one consumer move 100 million longs through a ring:
    head and tail in the same cache line, on separate lines (128 bytes apart,
    the line size on Apple silicon), and with batched publishing (the producer
    updates tail once per k items). The C program is compiled and run by this
    script (needs cc).
    """
    import argparse, json, os, subprocess, tempfile
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--items", type=int, default=100_000_000, help="Items moved.")
    p.add_argument("--batches", type=lambda s: [int(v) for v in s.split(",")], default=[1, 2, 4, 8, 16, 64], help="Batch sizes k.")
    p.add_argument("--repeats", type=int, default=3, 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 <stdatomic.h>
    #include <stdio.h>
    #include <time.h>
    #define CAP 4096
    #ifdef SAME_LINE
    struct { _Atomic long head; _Atomic long tail; } q;
    #else
    struct { _Alignas(128) _Atomic long head; _Alignas(128) _Atomic long tail; } q;
    #endif
    long buf[CAP];
    static void *producer(void *a) {
        long t = 0, pub = 0;
        for (long i = 0; i < N; i++) {
            while (t - atomic_load_explicit(&q.head, memory_order_acquire) >= CAP) ;
            buf[t % CAP] = i; t++;
            if (t - pub >= K || i == N - 1) { atomic_store_explicit(&q.tail, t, memory_order_release); pub = t; }
        }
        return NULL;
    }
    static void *consumer(void *a) {
        long h = 0, sum = 0;
        while (h < N) {
            long t = atomic_load_explicit(&q.tail, memory_order_acquire);
            while (h < t) { sum += buf[h % CAP]; h++; }
            atomic_store_explicit(&q.head, h, memory_order_release);
        }
        return (void *)sum;
    }
    int main(void) {
        pthread_t p, c; struct timespec a, b;
        clock_gettime(CLOCK_MONOTONIC, &a);
        pthread_create(&c, NULL, consumer, NULL); pthread_create(&p, NULL, producer, NULL);
        pthread_join(p, NULL); pthread_join(c, NULL);
        clock_gettime(CLOCK_MONOTONIC, &b);
        printf("%.4f\n", (b.tv_sec - a.tv_sec) + (b.tv_nsec - a.tv_nsec) / 1e9);
    }
    """
    d = tempfile.mkdtemp(); src = os.path.join(d, "ring.c"); open(src, "w").write(C)
    def run(label, flags, k):
        exe = os.path.join(d, "ring" + str(abs(hash(label))))
        subprocess.run(["cc", "-O2", "-pthread", src, "-o", exe, f"-DN={args.items}L", f"-DK={k}", *flags], check=True)
        times = sorted(float(subprocess.run([exe], capture_output=True, text=True, check=True).stdout) for _ in range(args.repeats))
        s = times[len(times) // 2]
        return {"variant": label, "k": k, "million_per_s": args.items / s / 1e6}
    rows = [run("head and tail on one line", ["-DSAME_LINE"], 1)]
    rows += [run(f"separate lines, batch {k}", [], k) for k in args.batches]
    for r in rows: print(f"{r['variant']:<28} {r['million_per_s']:8.1f} million items/s")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"variants": rows, "batches": [r for r in rows if r["variant"].startswith("separate")]}}, 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 spsc-ring-buffer-head-and-tail.py
    uv run spsc-ring-buffer-head-and-tail.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--items100,000,000 Items moved.
--batches1, 2, 4, 8, 16, 64 Batch sizes k.
--repeats3 Runs per variant; the median is reported.

Our run

Throughput against batch size (head and tail on separate lines)Apple M2 · 8 cores · 8 GB
Data table
Throughput against batch size (head and tail on separate lines)
Batch size kMillion items/s
139.8
267.4
4121
8204
16385
64667
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

Throughput

Throughput
BuildMillion items/s
head and tail on one line44.8
separate lines, batch 139.8
separate lines, batch 267.4
separate lines, batch 4121
separate lines, batch 8204
separate lines, batch 16385
separate lines, batch 64667
Output · uv run spsc-ring-buffer-head-and-tail.py
head and tail on one line        44.8 million items/s
separate lines, batch 1          39.8 million items/s
separate lines, batch 2          67.4 million items/s
separate lines, batch 4         121.5 million items/s
separate lines, batch 8         204.0 million items/s
separate lines, batch 16        384.6 million items/s
separate lines, batch 64        666.7 million items/s

What we found

Separating head and tail made no difference on this machine: 44.8 million items per second on one line, 39.8 million on separate lines, instead of the expected order of magnitude. The ring's slots are themselves shared between producer and consumer, and at batch 1 each item still moves a slot line across cores, which dominates. Batching is where the gain was: throughput rose roughly in proportion to k, to 385 million at k = 16 and 667 million at k = 64.


Read it

What a costs is cache-line traffic, and the traffic that matters is whatever crosses cores per item. Separating the indices removes one source; batching removes it per item for all of them, which is why it dominated here. The price of k is : the consumer cannot see the first item of a batch until the producer publishes the last.

The order-of-magnitude figure depends on the CPU's coherence protocol and on how the ring's own slots are laid out; on this run, the indices were not the bottleneck.