Home POD 0009: The Data-Oriented LedgerDownload PDF

POD 0009: The Data-Oriented Ledger

Abstract

This record explains the layout of the 0.2.0 core: a Ledger that stores Lamport's acceptor variables as columns over a power-of-two window, two bitmaps that make every scan a word walk, a slot tag per cell, and messages, records, and released entries that reference a value inside that ledger instead of copying it. It states the pointer contract, gives the memory formulas per configuration, says what the change made faster and what it did not, lists the constraints the layout imposes, and records the alternatives that were rejected.

Status and Implementation Boundary

Recovery reserves one chunk of values and metadata, rather than one complete ledger window. Its indexes are relative to the active recovery base; per-peer report bitmaps use the same offsets. On chunk rollover metadata is reset while payload bytes remain uninitialised until a valid report stores them. A read-quorum selection is frozen before phase two starts, including when the window forces a retry. See the chunk-local recovery argument in the proof chapter.

The public procedures and durable/wire formats are unchanged. The size and layout of Node change; consumers recompile, and code inspecting its recovery arrays must use chunk-relative indexes. Raw node images are not a supported journal format.

The matched benchmark and Valgrind drivers live outside the library. They introduce no runtime dependencies, storage adapters, threads, clocks, or network services into the core. Measurements distinguish static capacity, allocated heap, resident memory, and elapsed time. Callgrind instruction counts guide investigation; they are not substitutes for uninstrumented performance measurements.

Problem Statement

In 0.1.0 a value lived inline in Accept_Message, Commit_Message, Promise_Message, the durable records, and Committed. Message(Value) was a union, so every envelope was as large as the largest variant plus the value, and a value was copied at each of these points: into the leader's proposal cell, into the accept envelope for each peer, into each acceptor's cell, into the acceptor's write record, into the commit envelope for each peer, into each learner's cell, and into the committed entry. For an 8-byte value the copies were noise; for a 1 KiB value they were most of the work. A profile of the in-memory benchmark on the benchmark host (POD 0007, third pass) showed the fat message union and the value copies dominating the 1 KiB workload. The per-slot Durable_Cell{slot, accepted: Maybe(Accepted), committed: Maybe(Value)} also meant that a phase-one scan or a retransmission scan loaded whole cells, values included, to read a ballot.

The Layout

Columns

Ledger(Value, WINDOW) in src/ledger.odin is struct-of-arrays:

FieldTypeLamport variable
promisedBallotmaxBal for every decree at or above the recovery base of the promise.
anchorTrim_AnchorThe certified prefix (POD 0003).
slot[WINDOW]SlotThe tag: which slot owns the cell; zero is empty.
promised_at[WINDOW]BallotA per-decree promise; the effective promise is max(promised, promised_at[c]).
vote_ballot[WINDOW]BallotmaxVBal.
state[WINDOW]Cell_StateEmpty, Voted, or Chosen.
value[WINDOW]ValuemaxVal, or the chosen value.
used, chosenBit_Set(WINDOW)Bitmaps over cells: has a vote or decision; has a decision.

A cell's index is cell_of(slot, WINDOW) = (slot - 1) & (WINDOW - 1). ledger_open retags a cell for a new slot and clears its ballots, state, and bitmap bits, leaving the value storage to be overwritten by the next vote. ledger_record_vote and ledger_record_chosen are the only writers of state and the bitmaps, so the invariant "used iff state != .Empty, chosen iff state == .Chosen" is local to two #force_inline procedures and is checked by node_assert_valid when INVARIANT_CHECKS is on.

Bitmaps and tags

Bit_Set(N) is an array of native bit_set[0..<64] words. bit_set_next(bs, from) masks the word that holds from and counts trailing zeros, then walks words; bit_set_last counts leading zeros from the top. Every window walk in the core (on_prepare, on_learn, resend_to, ledger_highest_used, replicated_log_pending_stop_sign, replicated_log_observe_durable) walks a bitmap and touches only the cells it selects, then reads only the columns it needs. The slot tag makes reuse safe: a cell that held slot 5 and now holds slot 261 answers ledger_cell(l, 5) with false, so a stale message about slot 5 cannot read slot 261's vote.

Ballots

Ballot :: distinct u64 packs round (40 bits), priority (8 bits), and node (16 bits), most significant first, so the lexicographic order of "The Part-Time Parliament" is integer comparison and max over a column is a plain reduction (ledger_highest_ballot). Every ballot column is eight bytes per cell.

Volatile columns

Node keeps its phase-one evidence (recovered_slot, recovered_ballot, recovered_state, recovered_value) and its phase-two bookkeeping (lead_slot, lead_ballot, acknowledgements, acknowledged) as columns in separate index domains: phase one is relative to recover_base within one chunk; phase two uses the ledger window cell. The proposal a leader is driving is its own vote in the ledger; there is no second copy of the value.

The Pointer Contract

Promise_Message, Accept_Message, Commit_Message, Write_Vote, Write_Chosen, and Committed carry value: ^Value.

The contract is stated on Write, on the message types, and on Committed in the source, and tests/harness.odin opens with it.

Capacity and Memory

The sizes below are size_of results from the current sources on a 64-bit build.

Per window cell

A ledger cell costs three 8-byte columns (slot, promised_at, vote_ballot), one byte of state, size_of(Value) of storage, and two bits of bitmaps: 25+size_of(Value) bytes plus 14 byte. The fixed part is promised (8) and anchor (16).

ConfigurationBytes per cellsize_of(Ledger)
Ledger(u64, 256)33 + 1/48,536
Ledger([128]u64, 256) (1 KiB values)1,049 + 1/4268,632

Node adds phase-two columns per window cell: lead_slot (8 bytes), lead_ballot (8), acknowledged (4), and acknowledgements (8 bytes per 64 members, rounded up). Recovery adds CHUNK_SLOTS * (17 + size_of(Value)) bytes of slot, ballot, state and value columns, plus MAX_MEMBERS * ceil(CHUNK_SLOTS / 64) * 8 bytes for promise_seen. Alignment and scalar fields add overhead; use tools/memory_report.odin and the archived before/after CSVs for exact target-specific sizes.

Per message and record

Message(Value) is 64 bytes and Envelope(Value) 72 bytes for every Value (the largest variant is Promise_Range_Message); Write(Value) is 32 bytes; Committed(Value) is 16 bytes. Effects capacities are therefore independent of the payload type: size_of(Effects(u64, 7, 256, 64)) is 41,840 bytes, and the benchmark's Effects(u64, 3, 4096, 256) is 137,904 bytes.

Historical Measurements

The in-memory benchmark (bench/main.odin, recorded by make bench-compare into bench/results/latest.json) measures the cost per committed value for three and five voters, 8-byte and 1 KiB values, synchronous, pipelined, and batched proposals, and the ownership mode.

No benchmark number is typed into this record. The book and the README read their tables from the recorded file.

Where the remaining cost is

A callgrind run of the three-voter, 8-byte workload on the recording host (bench/main.odin --only=u64-3n, optimized build with PAXOS_INVARIANT_CHECKS=false) gives the instruction budget behind the 8-byte rows. One committed value is seven transitions: the leader's node_propose, two on_accept, two on_accepted, and two on_commit. Together they execute about 1,200 instructions, roughly 170 per transition; the in-process harness (Packet copies in and out of its queue, the queue itself, sampling) adds about a quarter of the program's instructions on top. The benchmark's nanoseconds per value therefore correspond to several instructions per cycle: the path is instruction-bound, not memory-bound, which is what the layout was meant to achieve.

Inside the library the instructions are spread thin rather than concentrated. The largest single items are the copies of Envelope(Value) into the effects buffer (72 bytes each, six per value), the union dispatch in node_step and message_decided_through, the per-cell ledger checks in on_accept and record_commit, and the one membership lookup per message. Two changes made after the profile removed a second lookup per message (Node.self_index, and the sender's index passed from node_step to the handlers that need it) and replaced the four two-branch small_array.push_back calls with a one-branch write into inline storage; both simplify the code and neither moved the instruction count by more than noise, which is the evidence that no single hot spot is left.

What would move it, and why it is not done here:

The Odin features the layout does lean on are the ones the profile shows paying for themselves: struct-of-arrays columns and fixed bit_set words for the window (bit_set_next is a count_trailing_zeros loop), distinct u64 ballots compared as integers, #force_inline on the cell and effect helpers, #no_bounds_check where the index was just checked, @(cold) on the failure path, #config for the invariant walks, and inline small_array storage so a transition touches no allocator.

Constraints the Layout Imposes

Alternatives Considered

Validation and Acceptance Gates

Implementation and correctness

All recovery values, metadata, and per-peer report bitmaps now scale with the recovery chunk. Ledger and phase-two state remain window-sized. Chunk-relative indexing checks bounds before subtraction; chunk rollover resets metadata without clearing payload bytes. Read-quorum selection is frozen before issuing phase-two votes, including across window-limited retries. Public procedures and durable/wire formats are unchanged.

The new tests cover absolute-slot selection, chunk sizes 1/3/8, ring crossings, duplicate and stale reports, delayed manifests, blocked recovery, large values, sparse retransmission, and late higher votes after a partial drive. The expanded simulator exposed two harness defects (duplicate-packet borrowing and premature quiescence) and the partial-drive selection defect; all were fixed.

Validation: 79 tests in debug and optimized builds; 600 default-capacity simulations (6 million steps), plus 120 small-window/chunk-3 simulations (1.2 million steps) with majority and both flexible-quorum extremes. Contract fixtures, style, vet, the example, and benchmark smoke checks pass. The book and ledger design record compile.

Static memory

For three members, 1 KiB values, window 256/chunk 64, node plus one effects buffer decreased from 633,120 to 433,176 bytes (31.6%). At window 4,096/chunk 256 it decreased from 9,080,448 to 5,081,568 bytes (44.0%). Chunk equal to window retains the original node size. See the before/after CSV files. These figures exclude queues, application state and runtime overhead.

Matched timings

The JSON contains 90 rows: four libraries plus the preserved Odin baseline, 18 workloads, nine samples per row. Every row validates all 4,096 ordered payloads per epoch at every learner. The paired 5% regression gate passed; this does not mean every workload became faster.

Nanoseconds per completed value (median), finite in-process workload:

Recorded 20260917T073751Z on AMD Ryzen 7 5800H with Radeon Graphics. Nine samples per row; median ns per completed value; lower is better.

𝑁BytesDepthOdin beforeOdinZigOmniPaxosLibPaxos3
381107.5109.2114.41062.12543.6
388111.5111119224.32548.5
3864108.1111118.388.12555
3641118.4115.8150.41228.72507.6
3648122.6123.2149.4321.52586.7
36464119121152.496.32603
310241357.13001725.83251.83388
310248430.3338.11708.82246.73536.5
3102464469.9349.319382450.53815.1
581202.7206.71663077.33335.9
588206.3211.3173.8511.93348.7
5864204.5209.5177.5153.23431.9
5641218.9224.3245.63269.33325.1
5648221.2221.4252.5672.13404.5
56464218.2220.8259.5250.43557.6
510241773.3739.22834.86517.75226.9
510248848.3792.12834.73887.55341.6
5102464893.8935.23350.43170.25923.4

The paired median ratios show 15.8-26.3% lower cost for the three-node 1 KiB workloads. Several small-payload rows are approximately 1-3% slower; the five-node 1 KiB/depth-64 paired ratio is 1.047 with a 95% interval of 0.928-1.089. That row is not an established improvement. Zig and OmniPaxos still lead some workload categories.

These numbers are not directly interchangeable with the historical README table: the common drivers remove the journal replay mirror, use equal command counts and payloads, retain complete finite logs, and time completion. LibPaxos retains its native preexecution work; OmniPaxos retains native coalescing. No language-wide or production-service superiority is established.

Profile-guided decision

The first retry-scan experiment counted occupied cells before scanning. Callgrind instructions increased from 12,591,988 to 17,833,184 in the dedicated retransmission workload, so that implementation was discarded.

The retained one-wrap scan reduces those instructions to 10,139,090 (19.5% fewer). A paired native timing experiment reported a median ratio of 0.877, with a 95% interval of 0.820-0.905. The sparse-retry regression test verifies that a single used slot produces one retry, not repeated duplicates. The aggregate matched gate additionally checks unchanged steady-state paths.

No speculative wire batching, protocol mode, storage adapter, networking, or threading was added. Further candidates were not implemented without measured benefit.

Final memory profiles

Sampled post-exec peak resident bytes, portable profiling builds (eight epochs):

WorkloadOdinZigOmniPaxosLibPaxos
3 voters, 8 B, depth 13,067,9042,400,2562,109,4402,584,576
5 voters, 8 B, depth 13,657,7283,592,1922,162,6883,555,328
3 voters, 1024 B, depth 6425,354,24052,789,24832,935,93627,901,952

Odin is not the minimum-RSS implementation in every row. For the large-payload row, its static node is 4,943,664 bytes versus Zig's 17,173,488 bytes; effects are 137,904 versus 5,606,920 bytes. Both use the same configured window/chunk and fixed payload. The driver capacities and native algorithm differences remain part of the comparison.

Massif separately reports allocated capacity: it excludes static/BSS storage and can exceed RSS where allocated pages remain untouched. The JSON retains both metrics; they must not be summed or used interchangeably. The compressed archive includes annotated Callgrind traces, Massif snapshots, logs, and the accepted/rejected retry experiments. Profiling elapsed times are not used as performance measurements.

Reproduction and limits

The following evidence files are under bench/results/:

The book chapter "Reproducing Measurements" (docs/book/06_measurement_methods.typ) gives the workload contract and Callgrind/Massif commands.

The paper argument and these tests establish reviewable evidence, not machine-checked implementation correctness. Memory comparisons must distinguish inline storage, heap allocations, and RSS. Timing measurements are host-specific and finite-horizon; they do not include storage, serialization, network delay, or application work.

References