Skip to content
About
Main content

Group commit and the square-root rule

Measure your machineNeeds Python

Problem

Flushing after every record caps at one record per flush. Batching records and flushing once per batch multiplies throughput, and a batcher that flushes every W seconds trades : each record waits on average W/2 for its batch plus the flush itself. With flushes costing F and records arriving at λ per second, a short W flushes too often (each flush delays the next batch) and a long W makes records wait; the square-root rule puts the best wait at W* = √(2·F/λ).

Write a loop that appends 100-byte records to a file and flushes (a) after every record, (b) after every 10, 100 records. Measure records per second. Then implement a timer-based batch with wait W at λ = 2,000 records per second and measure the mean latency per record for several values of W. Compare the best W with W* = √(2 · fsync ÷ λ). On macOS the flush is F_FULLFSYNC.

The question. How much does batching raise throughput, and which batch wait gives the lowest latency?


Predict

Flushing every 100 records instead of every record: by how much does throughput rise?

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.

    group-commit-and-the-square-root.py61 linesDownload
    View the code
    group-commit-and-the-square-root.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Group commit and the square-root wait.
    
    Part 1: append 100-byte records and flush after every 1, 10 and 100 records
    (F_FULLFSYNC on macOS, fsync elsewhere): records per second. Part 2: records
    arrive at rate λ; a timer-based batcher flushes every W seconds. The mean
    latency per record (waiting for the batch, then the flush) for several W,
    against W* = √(2 · fsync / λ).
    """
    import argparse, json, math, os, random, sys, tempfile, time
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--records", type=int, default=2000, help="Records per batch-size run.")
    p.add_argument("--rate", type=float, default=2000, help="Arrival rate λ (records/s) in part 2.")
    p.add_argument("--waits-ms", default="0.5,1,2,4,8,16", help="Batch waits W to try (ms).")
    p.add_argument("--seconds", type=float, default=4, help="Seconds per W in part 2.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    def flush(fd):
        if sys.platform == "darwin":
            import fcntl; fcntl.fcntl(fd, fcntl.F_FULLFSYNC)
        else: os.fsync(fd)
    path = os.path.join(tempfile.mkdtemp(), "gc.log"); fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND)
    part1 = []
    for every in (1, 10, 100):
        t = time.perf_counter()
        for i in range(args.records):
            os.write(fd, b"x" * 100)
            if (i + 1) % every == 0: flush(fd)
        el = time.perf_counter() - t; part1.append({"flush_every": every, "records_per_s": args.records / el})
    t = time.perf_counter()
    for _ in range(50): os.write(fd, b"x" * 100); flush(fd)
    fsync_s = (time.perf_counter() - t) / 50
    part2 = []
    for w_ms in map(float, args.waits_ms.split(",")):
        W = w_ms / 1000; rng = random.Random(1); lat = []; now = time.perf_counter(); end = now + args.seconds
        next_arrival = now; pending = []; next_flush = now + W
        while time.perf_counter() < end:
            t_now = time.perf_counter()
            while next_arrival <= t_now: pending.append(next_arrival); os.write(fd, b"x" * 100); next_arrival += rng.expovariate(args.rate)
            if t_now >= next_flush:
                if pending:
                    flush(fd); done = time.perf_counter(); lat += [done - a for a in pending]; pending = []
                next_flush = max(next_flush + W, time.perf_counter())
        part2.append({"wait_ms": w_ms, "mean_latency_ms": sum(lat) / len(lat) * 1000, "records": len(lat)})
    os.close(fd); os.remove(path)
    best = min(part2, key=lambda r: r["mean_latency_ms"])
    summary = {"fsync_ms": fsync_s * 1000, "w_star_ms": math.sqrt(2 * fsync_s / args.rate) * 1000, "best_w_ms": best["wait_ms"]}
    for r in part1: print(f"flush every {r['flush_every']:>3} records: {r['records_per_s']:10,.0f} records/s")
    print(f"one flush = {summary['fsync_ms']:.2f} ms; W* = √(2·fsync/λ) = {summary['w_star_ms']:.2f} ms at λ = {args.rate:g}/s")
    for r in part2: print(f"  W = {r['wait_ms']:>4g} ms: mean latency {r['mean_latency_ms']:6.2f} ms over {r['records']:,} records")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary, "tables": {"batches": part1, "waits": part2}}, 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 group-commit-and-the-square-root.py
    uv run group-commit-and-the-square-root.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--records2000 Records per batch-size run.
--rate2000 Arrival rate λ (records/s) in part 2.
--waits-ms0.5, 1, 2, 4, 8, 16 Batch waits W to try (ms).
--seconds4 Seconds per W in part 2.

Our run

Records per flush

Records per flush
Flush everyRecords/s
1355
103.45K
10029.1K

Timer batcher at λ = 2,000/s

Timer batcher at λ = 2,000/s
W (ms)Mean latency (ms)Records
0.54.467.98K
14.617.98K
27.217.98K
46.797.98K
86.957.98K
1611.17.94K
Reference runApple M2 · 8 cores · 8 GB
Output · uv run group-commit-and-the-square-root.py
flush every   1 records:        355 records/s
flush every  10 records:      3,446 records/s
flush every 100 records:     29,117 records/s
one flush = 3.13 ms; W* = √(2·fsync/λ) = 1.77 ms at λ = 2000/s
  W =  0.5 ms: mean latency   4.46 ms over 7,977 records
  W =    1 ms: mean latency   4.61 ms over 7,977 records
  W =    2 ms: mean latency   7.21 ms over 7,977 records
  W =    4 ms: mean latency   6.79 ms over 7,977 records
  W =    8 ms: mean latency   6.95 ms over 7,977 records
  W =   16 ms: mean latency  11.12 ms over 7,939 records
macOS 26.6.2 · Python 3.13.3 · run 2026-10-25

What we found

Flushing every record allowed 355 records per second; every 10, 3.45K; every 100, 29.1K: throughput rose almost in proportion to the batch, by nearly two orders of magnitude. One flush took 3.13 ms, so W* = 1.77 ms at 2,000 records per second. The lowest mean latency came at W = 0.5 ms (4.61 ms), close to the formula; the curve was nearly flat from 0.5 to 2 ms and rose steadily beyond, to 11.1 ms at 16 ms.


Read it

Throughput of a durable log is set by how many records share a flush, and latency by how long they wait for it. The square-root rule is a starting point for the wait; measured latency, which includes queueing behind slow flushes, is the arbiter.