Group commit and the square-root rule
Measure your machineNeeds Python
Problem
Flushing after every record caps throughput at one record per flush. Batching records and flushing once per batch multiplies throughput, and a batcher that flushes every W seconds trades latency: 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
On a typical SSD, (a) is limited to about 1 ÷ fsync time. Expect throughput to rise by one to two orders of magnitude with batching, and the best W to be close to the formula.
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
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)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
| Knob · default | What 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
| Flush every | Records/s |
|---|---|
| 1 | 355 |
| 10 | 3.45K |
| 100 | 29.1K |
Timer batcher at λ = 2,000/s
| W (ms) | Mean latency (ms) | Records |
|---|---|---|
| 0.5 | 4.46 | 7.98K |
| 1 | 4.61 | 7.98K |
| 2 | 7.21 | 7.98K |
| 4 | 6.79 | 7.98K |
| 8 | 6.95 | 7.98K |
| 16 | 11.1 | 7.94K |
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 recordsWhat 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.