Repository navigation
Storage Performance
What durability costs, measured rather than argued, plus the regression budget the durable log is held to.
Reproduce everything here with:
scripts/bench-durable-log.sh results.jsonlSeven techniques, each taken from a log-structured engine that has already paid for the lesson. They are listed in rough order of how much they matter.
An fsync flushes the whole file, not one caller's bytes. So when N appends
are in flight, one flush can satisfy all N. Every appender contends for the same
flush lock; the winner flushes, and the losers wake to find their target already
durable and return without flushing again.
Measured below: 253 durable appends/second at concurrency 1, 14,387 at concurrency 64. Same code, same disk, 57× the throughput, purely from not making each append buy its own device flush.
This is the mechanism behind PostgreSQL's commit_delay, MySQL's binlog group
commit, and RocksDB's WAL group commit. felix_storage_sync_batch_appends
reports the fan-in actually achieved — a value near 1 under concurrent load means
appends are serialising on the device and something has regressed.
Records are encoded into a reusable staging buffer and handed to the kernel in a
single write. At 128-byte payloads the syscall, not the copy, is what costs —
which is why batching 16 records per append is worth 4.8× to 16× the throughput
of batching one, across every row below. The gain is largest under on_commit
(13–16×), where a batch amortises a device flush as well as a syscall, and
smallest for a single none publisher (4.8×), where there was little syscall
overhead to remove in the first place.
A write lands in the page cache and is cheap; only fsync touches the device.
Keeping them apart is what makes group commit expressible at all, and it is why
None and Periodic can acknowledge in ~1µs.
A seek + read pair mutates the file cursor, so a shared descriptor cannot
serve two readers at once and every read costs an extra syscall. pread takes
the offset as an argument: one descriptor per segment serves any number of
concurrent readers with no lock. Kafka, RocksDB and LevelDB all do this on their
immutable files.
Appending past end of file makes the filesystem allocate blocks and update the
inode's block map on the write path, and invites fragmentation as segments from
different shards interleave. fallocate(FALLOC_FL_KEEP_SIZE) on Linux and
F_PREALLOCATE on macOS reserve the blocks up front without changing the file's
logical length — so recovery's "valid bytes end at EOF" reasoning still holds.
One entry per index_spacing_bytes. A read binary-searches the entries, then
scans forward at most one interval. Locating an offset is O(log n) plus a
bounded read, instead of a scan from the head of the log.
An append changes file data and size, not the owner, mode or times a full fsync
also flushes. On macOS this is F_FULLFSYNC instead — POSIX fsync there leaves
data in the drive's volatile cache, so anything weaker would be measuring a
promise the platform does not keep.
Group commit is what makes many publishers cheap; the commit sequencer is what keeps them in order afterwards (see durable-storage.md). Handing the turn on is where that ordering can quietly undo the first technique.
Waking everyone parked behind a turn makes each of them acquire the lock, find it is still not their turn, and park again. Only one can proceed, so with N publishers in flight a single commit costs N wake-ups to accomplish one — and the work per commit grows with load. That is the classic thundering herd, and it turns concurrency from a throughput lever into a throughput tax.
Waking the single waiter whose turn it now is costs one wake-up per commit at any N. The registry is keyed by offset, so finding that waiter is a lookup, not a scan.
End-to-end durable publishes per second, same machine and harness, varying only the wake-up:
| Publishers | Wake all | Wake one | Gain |
|---|---|---|---|
| 1 | 662,086 | 664,694 | 1.00× |
| 2 | 405,403 | 417,018 | 1.03× |
| 4 | 255,725 | 474,678 | 1.86× |
| 8 | 222,222 | 481,490 | 2.17× |
| 16 | 156,565 | 476,683 | 3.04× |
| 32 | 82,005 | 475,677 | 5.80× |
| 64 | 40,320 | 465,560 | 11.5× |
Read the shapes, not the absolute numbers: an M4 Max laptop, in-process, no QUIC,
FsyncMode::None so no device flush hides the effect. Waking all of them decays
without a floor — at 64 publishers it retains 6% of its single-publisher rate,
so adding publishers makes the broker slower in absolute terms. Waking one holds
flat at ~0.72× from four publishers on.
The step from one publisher to two costs ~38% in both columns. That is not the herd — it is the price of parking and waking at all, which an uncontended publisher never pays. Everything past two is the herd.
graph LR
A["append() called"] --> B["encode batch<br/><i>~0.1µs, no allocation</i>"]
B --> C["one write() syscall<br/><i>~1µs to page cache</i>"]
C --> D{"fsync policy"}
D -->|"None / Periodic"| E["return<br/><b>~1µs total</b>"]
D -->|"OnCommit"| F["wait for a flush<br/><i>shared with concurrent appends</i>"]
F --> G["device flush<br/><b>~4ms on APFS</b>"]
G --> H["return<br/><b>~4ms total</b>"]
classDef fast fill:#e9f5ec,stroke:#4a8a5e,color:#16301f
classDef slow fill:#fdf0e3,stroke:#b07d3a,color:#3d2a12
class A,B,C,E fast
class F,G,H slow
Roughly four thousand times separates "in the page cache" from "on the device" — about 1µs against about 4ms. Nothing in the code can close that gap; group commit exists to amortise it.
Hardware and configuration. Apple Mac Studio (Mac16,9), Apple M4 Max,
16 CPUs, macOS 26.6.1 (Darwin 25.6.0), APFS on internal NVMe. 128-byte payloads,
8,000 measured records per run after a 2,000-record warm-up, 64 MiB segments,
4 KiB index spacing, preallocation on. felix-log-tool bench, release profile.
Latencies are per append call, so at batch 16 one sample covers 16 records.
| Policy | Batch | Concurrency | Records/s | p50 | p99 | p999 |
|---|---|---|---|---|---|---|
none |
1 | 1 | 571,898 | 1µs | 2µs | 5µs |
none |
1 | 8 | 252,825 | 3µs | 391µs | 730µs |
none |
1 | 64 | 255,997 | 3µs | 787µs | 1.5ms |
none |
16 | 1 | 2,768,326 | 3µs | 4µs | 8µs |
none |
16 | 8 | 2,121,453 | 51µs | 337µs | 738µs |
none |
16 | 64 | 1,843,566 | 5µs | 1.7ms | 2.2ms |
periodic (250ms) |
1 | 1 | 492,106 | 1µs | 3µs | 7µs |
periodic |
1 | 8 | 272,182 | 3µs | 413µs | 927µs |
periodic |
1 | 64 | 267,180 | 3µs | 955µs | 2.6ms |
periodic |
16 | 1 | 2,671,267 | 3µs | 5µs | 8µs |
periodic |
16 | 8 | 1,990,194 | 57µs | 261µs | 690µs |
periodic |
16 | 64 | 1,854,732 | 129µs | 255µs | 284µs |
on_commit |
1 | 1 | 253 | 3.99ms | 6.0ms | 10.7ms |
on_commit |
1 | 8 | 2,006 | 3.99ms | 6.9ms | 8.3ms |
on_commit |
1 | 64 | 14,387 | 4.07ms | 8.6ms | 9.2ms |
on_commit |
16 | 1 | 4,038 | 3.96ms | 6.5ms | 9.7ms |
on_commit |
16 | 8 | 28,670 | 4.00ms | 9.8ms | 12.1ms |
on_commit |
16 | 64 | 185,905 | 4.96ms | 8.1ms | 9.2ms |
Recovery is fast enough not to shape restart planning. A 1.01 GiB active
segment - 7,000,000 records - is fully validated in 0.32s, about 3.2 GiB/s.
Since only the active segment is scanned in full, restart cost is bounded by
segment_size_bytes rather than by total data on disk: at the 256 MiB default,
that is under a tenth of a second.
Durability is free until you ask for the device. none and periodic are
within noise of each other — the periodic flush runs off the append path, so its
cost does not appear in append latency at all. A durable stream on periodic
costs roughly what a non-durable one costs, plus the storage write itself.
OnCommit latency is a hardware constant. p50 sits at ~4ms in every single
row, from concurrency 1 to 64 and from batch 1 to 16. That is one APFS device
flush. No amount of software makes a single durable commit faster than the
device; the only variable is how many commits share one.
Throughput under OnCommit scales almost linearly with concurrency.
253 → 2,006 → 14,387 records/second at concurrency 1 → 8 → 64. Combined with
batching, 185,905 records/second — over 700× the single-publisher number, on the
same disk, with the same guarantee.
Tail latency degrades with concurrency for the cheap policies. none at
concurrency 64 shows p999 of 1.5ms against 5µs at concurrency 1.
The likely cause is contention on the segment write lock rather than I/O —
appends must assign offsets in order, and none does no device work to hide
behind — but that is an inference from the shape of the numbers, not something
measured. Confirming it needs lock-wait instrumentation on the append path,
which does not exist yet. It matters for high-fanout publishers on non-durable
streams and is the most likely place a future optimisation pays off.
CI does not yet gate on these. They are the numbers a change should be measured against, and a breach is a design conversation rather than an automatic failure.
| Guard | Budget | Rationale |
|---|---|---|
none / periodic p999, batch 1, concurrency 1 |
≤ 25µs | 3–5× the measured 5–7µs. Anything worse means the non-durable path picked up real work |
none / periodic throughput, batch 1, concurrency 1 |
≥ 300k records/s | ~60% of measured. Guards against a per-record syscall or allocation creeping in |
on_commit p50 |
≤ 1.5× one device flush | The floor is hardware. Exceeding it means an append is buying more than one flush |
on_commit throughput at concurrency 64 |
≥ 40× the concurrency-1 figure | Group commit is the design. Falling toward 1× means the flush lock has stopped batching |
felix_storage_sync_batch_appends under concurrent load |
≥ 8 | Direct measurement of the above; alertable in production |
| Batch-16 throughput vs batch-1 | ≥ 4× | Confirms one write per batch, not per record |
| Recovery time | ≤ 1s per GiB of active segment | Measured at 0.32s for a 1.01 GiB segment (~3.2 GiB/s), so the budget carries about 3× headroom. Only the active segment is fully scanned |
segment_size_bytes is usually presented as a recovery-time dial: only the
active segment is scanned in full at startup, so a smaller segment restarts
faster. That is true, and it is not the whole trade.
Every rollover costs several device flushes — sealing the retired segment, and creating and directory-syncing its replacement — and those flushes land on whichever appends are in flight. Smaller segments roll more often, so the cost appears more often, and it appears in the tail:
| Segment size | p999, 16 publishers | Rollovers per 20k records |
|---|---|---|
| 256 MiB (default) | ~0.9ms | 0 |
| 16 MiB | ~1.3ms | ~1 |
| 1 MiB | ~30ms | ~20 |
| 128 KiB | ~112ms | ~160 |
Trading a 256 MiB segment for a 1 MiB one buys about 0.25s of restart time and costs roughly 30× on p999. Below a few MiB the log is rolling often enough that tail latency is dominated by rollover flushes rather than by appends. Prefer the default unless restart time is genuinely the binding constraint, and if it is, measure the tail rather than assuming recovery is the only thing that moved.
Those numbers are the flushes themselves, and they are irreducible. Two things that used to ride along with them are not:
- A rollover no longer stalls the runtime. The segment set is behind a synchronous lock, so a publisher queued on it parked a Tokio worker rather than yielding it. Held across a rollover's two device flushes, that stalled everything else on the runtime — other shards, subscriber fanout, the accept loop — once as many publishers were queued as there were workers. Appends now wait on an async gate and yield.
-
Rollover contention no longer rejects appends. An append that lost the
race for the new segment
MAX_ROLL_ATTEMPTStimes returnedUnsupported. With 128 KiB segments, 16 publishers on one shard and 4 workers, that was 6 runs in 20. It is 0 in 20 now: the gate admits one rollover at a time, and the publisher that rolls appends under the same gate rather than re-entering the race it just won.
Neither makes a rollover cheaper, so the table above still stands.
The obvious fix for the table above is to stop doing rollover work on the append
path: build the replacement segment ahead of time, off the lock, and swap it in
with a pointer write. rollover_threshold_percent implements exactly that. It
is disabled by default, because it was measured and it is worse.
Five 200k-record runs, 16 MiB segments, 16 publishers, median across runs:
| p99 | p999 | max | throughput | |
|---|---|---|---|---|
| Inline roll (default) | 669µs | 3.98ms | 47.0ms | 99.1k rec/s |
| Background roll | 667µs | 7.97ms | 50.8ms | 98.0k rec/s |
The upper tail is where it shows: the worst background-roll run reached 19.0ms p999 against 5.0ms for the inline path. At 256 MiB and at 1 MiB the two were within noise. The background roll was not faster in any configuration tested.
The reason is F_FULLFSYNC. Durability on macOS means flushing the device write
cache, and that flush does not overlap with concurrent writes — so moving a
rollover's flushes off the append lock does not hide them, it only stops
confining them. Instead of a queue behind one lock, the same cost lands on
whichever appends happen to be in flight, which is strictly worse for the tail.
Two things are worth keeping from the exercise. The first is that reasoning is
specific to a flush that reaches the device; where fdatasync returns once the
write is in the kernel, overlap is real and the trade may invert, which is why
the code is kept and configurable rather than deleted. The second is what the
work actually did buy: measuring it exposed an fsync of a freshly created
(and therefore empty) index file sitting inside the rollover path. Removing it
improved the default inline path from 5.12ms to 3.98ms median p999 (-22%)
and 61.8ms to 47.0ms max (-24%).
| If you need | Choose | Expect |
|---|---|---|
| Lowest latency, loss acceptable on machine failure | none |
~1µs p50, ~500k records/s single publisher |
| A bounded loss window, latency unchanged | periodic { 250ms } |
~1µs p50; up to 250ms of writes at risk |
| No loss of an acknowledged record | on_commit |
~4ms p50 per publisher; scale with concurrency and batching |
periodic is the default because it is the only one of the three whose cost is
invisible on the append path while still bounding loss.
Replication compounds the flush cost: a quorum write is at minimum one local flush plus a network round trip to a follower that is also flushing. Two consequences follow from the numbers above and should shape M5's design rather than be discovered during it:
-
Per-record replication is not viable at
on_commit. At 253 durable appends/second per publisher, a design that flushes once per replicated record caps a node in the low hundreds of writes/second. Replication must batch, and the batch must span publishers — the same argument that produced group commit locally. - The follower's flush should overlap the leader's, not follow it. Serialising them doubles a 4ms constant. Group commit already proves the machinery for fanning one flush out to many waiters; the replication path should reuse it rather than invent a second one.
-
Warm-up is mandatory. The first appends pay for segment creation,
preallocation and page-cache faults.
--warmup-recordsdefaults to 2,000 and those samples are discarded. - Each run gets a fresh directory. Reusing one lets an earlier run's segments and page-cache residency skew the next.
- Throughput is measured after a final flush. A policy that leaves data in the page cache has not finished its work when the last append returns.
-
Percentiles are nearest-rank over every
appendcall, not interpolated. - The environment is recorded in the output. A latency figure without the filesystem and device it came from is not reproducible, and that is the most common way benchmark results become unusable six months later.
-
These are storage-layer numbers, measured against
DiskLogdirectly. They exclude QUIC, framing and fanout. The end-to-end broker figures come fromlatency-demoand are a separate measurement.
Generated from docs/storage-performance.md in the repository. That file is the source of truth; this page may be behind it.
Architecture & Design
- Broker-internal forwarding protocol
- Felix Architecture
- Felix Wire Protocol (v1)
- Felix semantics
- Felix – Comprehensive Design Document
Storage & Replication
- Durable Storage
- Durable Storage Performance
- Felix Durable Segment Format (v2)
- One core log, many semantics
- Plan: group-commit the cache write path
- Shard replication and leader failover
- The cache is a projection over the log
Control Plane & Cluster
- A formal model of one shard
- Control Plane + RAFT Plan (Draft)
- Metadata Raft
- Subscribe routing: redirect, not proxy
- The Postgres the control plane requires
- The local cluster harness
Clients & Configuration
- Auth Design: OIDC Exchange + Felix Tokens
- Broker Config Examples
- Client Config
- Running a client against a cluster
Security
- Bootstrap security
- Panic and abort audit — broker data path
- RBAC Model (Control Plane)
- Soak and resource-leak report — broker
- Threat model: the broker-internal surface
Performance Investigations
- Real-network performance suite
- Sharding investigation: keyed publishes, io_uring, and a ceiling that would not move
- The 0.4.0 performance matrix
- Throughput investigation: why Felix is slower than raw QUIC
Explorations & History