pg_automergev0.1.0

Benchmarks, 2026-09-29

jsonb built in Rust memory (2026-09-29)#

JsonbBuilder assembles the JsonbValue tree itself instead of feeding every event to pushJsonbValue (see jsonb mapping); the jsonb bytes are unchanged. mise run bench-sql, release build, median of seven runs, milliseconds, before and after in one session:

Path877 kB beforeafter83 kB beforeafter
R1 doc->>'status'3132653127
R2 three accessors in one SELECT9118109180

On the 3 MB text (one string) the build was never the cost: 2812 → 2787 ms for R1 (median of five), within noise. Writes do not build jsonb and are unchanged. After the change a profile of R1 on the 877 kB list puts about 47% in the load, 15% in Automerge's document iterator, 4% in the walk's own bookkeeping and about 7% in building the jsonb (JsonbValueToJsonb 2.4%, int64_to_numeric 1%), against about 15% for the pushJsonbValue path before.

merge_agg loads lazily (2026-09-29)#

merge_agg used to load its first input right away and merge every later input that its document lacked into it. Its state now keeps the first stored input as bytes, loads the larger of two inputs first when the heads cannot decide, and lets a loaded input that contains the whole state replace it (see Merging). It also skips inputs it already has by a prefix of their heads, before detoasting them. mise run bench-sql, release build, median of five runs, milliseconds:

Pathloads before → after877 kB beforeafter3.0 MB beforeafter
A1 merge_agg over one row1 → 01708.5255922
A2 merge_agg over the row and a newer version2 → 133517650612533

A1 is now the detoast of the value and a copy of it into the result. In A2 the newer version is loaded, contains the older one and is the result as stored: no merge and no save. The other paths are unchanged. A profile of R1 on the 877 kB list puts about 77% of the time in Automerge and another 11% in glibc's malloc/free, mostly for Automerge's allocations; linking mimalloc as the Rust global allocator instead was tried with the core benchmark and rejected (the 877 kB load about 5% faster, the 3 MB text load about 6% slower).

One-sweep walk without per-object vectors and hashing (2026-09-29)#

The sweep's bookkeeping (a Vec of entries per object and a SipHash HashMap from object id to content, about a third of the walk on the 877 kB list) became one entry vector and a sorted id index searched by bisection (see jsonb mapping); the events are unchanged. mise run bench-core, release build, median of three, milliseconds: jsonb walk (no-op sink) 74.3 → 64.3 on the 877 kB list, 6.6 → 5.7 on the 83 kB list, 250 → 250 on the 3 MB text (one object). mise run bench-sql, median of seven, two before/after pairs in one session:

Path877 kB beforeafter83 kB beforeafter
R1 doc->>'status'268, 268257, 26128, 2626, 25
R2 three accessors in one SELECT807, 809782, 77780, 7776, 80

Containment decided by change counts (2026-09-29)#

automerge_contains loaded the stored document to answer false for a newer or concurrent version, as a stored value or as a save in a bytea (one load, 164-167 ms on the 877 kB list, 2.5 s on the 3 MB text). When the heads do not decide, it now compares change counts read from a prefix of each value (or a save's header): a version with at least as many changes as a and other heads is not in a (see Reading). Older versions still cost one load (true needs the history). mise run bench-sql, release build, median of five, ms:

Case877 kB beforeafter3 MB beforeafter
C1 automerge_contains(doc, newer save bytea)1671.225689.0
C2 automerge_contains(doc, newer stored version)1641.225491.1

C1 on the 3 MB text is the save's checksum (a SHA-256 over 3 MB) and the parameter's detoast. No other path changed.

Header no-ops check that the save parses (2026-09-29)#

A save whose header lists heads the document already has was a no-op after only its framing and checksum were checked, so merge(doc, bytea) and automerge_contains(doc, bytea) accepted a checksummed chunk with known heads and a malformed body, which a load of doc ++ bytes (and so the earlier merge) rejects with 22P02. The header answers that say "contained" now first run header::document_parses (see Merging, case 1), which inflates any deflated columns. Answers of "not contained" and paths that load the save are unchanged. Release build, median of seven, ms:

Case877 kB beforeafter3 MB beforeafter
merge(doc, own compressed save) (no-op)1.22.98.932
automerge_contains(doc, own compressed save)1.13.18.931
merge(doc, doc::bytea) (uncompressed, no-op)5.85.815.614.1
W2 merge(doc, newer save)20621427872767
C1 automerge_contains(doc, newer save)1.21.39.69.0

The added time is the inflation of the save's columns; loading the save instead would cost 200 ms / 2.8 s.

Input amplification measurements (2026-09-29)#

What a load of client bytes costs, measured to design the size limits (release build; peak bytes allocated through the Rust global allocator, counted by a counting allocator in a scratch harness; "doc" is Automerge::load of a document chunk, "chunks" is the same history loaded as bare change chunks, as merge(doc, bytea) applies them).

Small inputs that describe a lot. Automerge's columns are run-length encoded, so a run of n identical (or equally spaced) values costs a few bytes whatever n is, before any deflate:

InputBytesDescribesPeakTime
one change chunk, loads fine (a list of nulls)1041,000,001 ops561 MB3.4 s
save of a list of 400,000 nulls (save_nocompress)174400,001 ops36 MB doc, 231 MB chunks0.4 s, 1.6 s
save of 200,000 one-op changes196200,000 changes194 MB1.3 s
compressed save of a 1,000,000-character repetitive text1,1651,000,001 ops93 MB1.0 s
crafted document chunk: 1,000 changes with 10,000 deps each6610^7 deps1.41 GB3.4 s, then rejected
crafted document chunk: one op with 10^6 successors13410^6 succ entries328 MB3.7 s, then rejected
crafted document chunk: 100,000 empty changes71100,000 changes128 MB0.3 s, then rejected
crafted change: 2 ops, one with 10^6 preds7110^6 preds50 MB0.2 s
crafted document chunk: max_op 10^8, 3 ops115nothing14 kB-

So neither the stored size (174 bytes for any number of nulls), nor the inflated size, nor a declared max_op bounds memory. The crafted documents fail only after reconstruction ("mismatching heads"), with the memory already spent. Memory is linear in each of these quantities (checked at 10^7 ops: 910 MB and 11.3 s as a document, 6.5 GB and 49 s as change chunks), with these costs per unit (peak bytes):

Unit (readable from headers)docchunks
op, appended in counter order (text typed or pasted, list append)89-91530-650
op out of counter order, object creation, distinct map key, mark280-372550-810
change900-1,2751,400-2,300
dependency entry141-
successor entry (text deletes: one per element)13-
successor entries pending on one key (put/delete toggling, crafted)328-590-
pred entry-50
inflated byte (string, bytes values)5-77
change × actor (Automerge's clock cache every 16 changes)0.25-0.30.25-0.3
change with more than ~16 ops × actor (reconstruction)3-

Most of a load's peak is transient: after loading, a 1,000,000-character text holds 1 MB (the op set is columnar); the peak comes from rebuilding every change. Reads of a stored value stay within 1.0-1.5 times its load's peak (automerge_changes, automerge_changes_meta, the jsonb walk into a serde_json::Value), and a merge of two concurrent 1,000,000-character documents peaks at 566 MB, almost all of it applying the other document's changes (the chunks cost). Time follows memory: at most about 8 s per GB of the estimate below on the test machine.

A linear estimate over these quantities with the worst measured cost of each (450 bytes per op, 1,600 per change and an encoder term, 200 per dependency, 30 per successor plus 600 per successor pending on one (object, key) group, 200 per actor, 10 per inflated byte, 0.3 per change × actor and 3 per large change × actor; for change chunks 1,000 per op, 80 per pred, 2,500 per change) stays above the measured peak for every generated and crafted input tried: actual/estimate is at most 0.80 (ops prepended to a list of maps), about 0.2 for appended text and lists, and 0.1 for a key overwritten 200,000 times in one change. Allocation failure itself cannot be turned into an ERROR: std::alloc:: set_alloc_error_hook is unstable in Rust 1.98, a #[global_allocator] must not unwind, and allocating with palloc would longjmp through Automerge's frames.

Load memory limit (2026-09-29)#

pg_automerge.max_load_memory (see Resource limits), implemented from the measurements of the previous entry.

Measured peaks against the estimate (tests/memory_bounds.rs, dev build with optimized dependencies, counting allocator; peak / estimate):

Inputsavecompressedchange chunks
200,000 characters of text0.200.200.57
3,000 typed characters (3,000 changes)0.400.400.41
50,000 characters deleted0.210.210.39
40,000 ints prepended to a list0.690.710.63
20,000 maps prepended to a list0.800.800.67
40,000 map keys0.620.670.54
one key overwritten 40,000 times0.100.100.45
one key set and deleted 20,000 times0.550.560.34
5,000 one-op changes0.330.330.46
1,000 actors / 1,000 conflicting forks0.30 / 0.380.31 / 0.410.43 / 0.43
400 actors, then 400 changes of 16 ops0.320.320.41
6,000 characters with 2,000 marks0.390.420.67
1 MB of bytes0.490.710.69

Crafted chunks (bytes, peak / estimate): a document chunk of 20,000 empty changes (77 bytes) 0.71, of 1,000 changes with 1,000 dependencies each (70 bytes) 0.68, of one op with 200,000 successors (136 bytes) 0.48, of a 200,000-item list (141 bytes) 0.17; a change chunk of 100,000 ops (104 bytes) 0.58, of one op with 1,000,000 preds (71 bytes) 0.08 (priced as the document it normalizes to, where the preds become successors on one key); a save followed by 2,000 change chunks 0.34. Change chunks whose header lists repeat entries (added after review, which found the first version priced other actors by name: a 200 kB input estimated just under 2 GB aborted a scratch cluster capped at 4 GB): 1,100,000 or 2,200,000 empty other actors, plain or compressed (1-2 kB), 0.64, 1,100,000 copies of one 16-byte actor 0.32, 1,100,000 copies of one dependency (compressed, 34 kB) 0.36; in release they take 1.3 s per GB of the estimate, the battery's worst being 5.5 s per GB (change chunks of 1,000 actors). Merges of two concurrent documents (both loaded, the other's changes applied): 0.21 to 0.38 of the two loads plus the apply estimate. Refused inputs peak below 64 kB for the run-length bombs (100 to 221 bytes describing 10^8 to 2^62 rows) and below a quarter of the limit for deflate bombs (256 MB of zeros, stopped at a tenth of a 100 MB limit). A 200,000-input release fuzz session (the properties of Testing) found no violation.

tests/limits.sh: under ulimit -v 1000000, with the limit off, the 12 kB compressed save of a 12,000,000-character text and a 113-byte change chunk describing 20,000,000 ops each abort the backend (memory allocation of 1543503872 bytes failed, signal 6) and the cluster restarts; with the default limit both are refused with 53400 through every path and nothing restarts. 32 s, most of it generating the text.

Cost (release build, median; before is the previous commit, two runs each). mise run bench-core, ms:

beforeafter
normalize, 3 MB text stored / compressed2498 / 25262495 / 2567
normalize, 877 kB list stored / compressed161 / 166166 / 172
normalize, 83 kB list stored / compressed16.0 / 16.616.1 / 17.4
the scan alone, 877 kB list stored / compressed-1.7 / 3.7
the scan alone, 3 MB text stored / compressed-0.0 / 21.6

mise run bench-sql (BENCH_REPS=5, ms; before: the mean of two runs):

case83 kB beforeafter877 kB beforeafter3 MB beforeafter
R1 read (unchanged code)252626126527892780
R2 three reads (unchanged code)777878180683628333
I1 insert of a compressed save212220521027562752
W1 merge(doc, changes)383836436852045223
W2 merge(doc, newer save)222320921127712791
W3 merge(doc, save::automerge)222420621427652793
W4 upsert413936938252835318
W6 W3 as a text parameter393937438353125382
A2 merge_agg of two versions181817217625472532
R3, W5, A1, C1, C2 (no load)unchanged

The differences are of the size of the drift of the read-only cases R1 and R2, whose code did not change (up to 3%). The first version of the scan decoded every value and computed Gmax exactly for every document, 10.7 ms on the 877 kB list (+6% on its writes); counting literal runs without decoding them and taking Gmax at its bound unless that decides a refusal brought it to 1.7 ms.

Remeasured at the final verification, interleaved (the 72e8d57 build, then this one, three pairs of mise run bench-sql runs, 5 to 9 repetitions each; mean of the medians, ms), the write cost is above the drift after all on the smaller documents, while the read-only R1 stays within 0.5%:

case83 kB beforeafter877 kB beforeafter3 MB beforeafter
R1 read (unchanged code)26.026.026026127812765
I1 insert of a compressed save21.323.0 (+8%)201213 (+6%)27542765 (+0.4%)
W1 merge(doc, changes)37.741.0 (+9%)360374 (+4%)51965219 (+0.4%)
W2 merge(doc, newer save)22.323.7 (+6%)208215 (+4%)27882790 (+0.1%)
W3 merge(doc, save::automerge)22.023.0 (+5%)205215 (+5%)27702802 (+1.2%)
W6 W3 as a text parameter38.340.3 (+5%)373390 (+5%)53455363 (+0.3%)

(3 MB: one pair.) bench-core, two pairs: normalize of the 877 kB list 161.9 → 164.6 ms stored, 167.7 → 175.6 ms compressed (+5%), of the 3 MB text within 1%. So the price of the limit is about 4-6% on writes of list-shaped documents of 0.1-1 MB (1-2 ms at 83 kB, 7-15 ms at 877 kB, of which the scans of a compressed input and of its normalized result account for 5.4 ms; the rest was not attributed), and about 1% on a large text, whose columns are few long runs. Recovered in the next entry. Reads are unchanged; a merge of stored values pays the scan of its result (A2, merge_agg of two versions: 174 → 181 ms at 877 kB, one pair).

The limit's write cost recovered (2026-09-29)#

Profiled with perf on an LTO-off symbolized build of the extension (backends running I1 and W1-W3 in a loop) and timed piece by piece in the core, the write cost of the limit was all in the scans, and the unattributed part was the inflation of a compressed save done three times: by the scan, by Automerge::load, and by header::inflate_document for the save-and-load shortcut, which also hashed the inflated chunk (together 4.1 ms of normalize's 173 ms on the 877 kB list). Two changes:

  • The scan keeps the columns it inflated for a single compressed save (budget::scan_input_keep) and header::inflated_document_is compares the normalized save with them in place: 0.19 ms instead of 4.1 ms (on the 3 MB text 0.7 ms instead of 28 ms), so a compressed save is inflated twice, as before the limit (without a limit, three times: nothing is kept). The columns are held through the load, after its estimate is checked, at most the inflated bytes, each charged 10: the compressed saves of the memory battery peak a little higher (1 MB of bytes 0.61 → 0.71 of the estimate, 40,000 map keys 0.65 → 0.67), the worst input is still 0.80.
  • column_stats steps over literal numbers by their LEB128 last bytes, eight bytes at a time, over strings by their lengths without the general decoder, and reads one-byte run headers inline: the scan of the stored 877 kB list takes 0.7 ms instead of 1.7 ms (compressed: 2.6 instead of 3.7, most of it the inflation). A unit test checks it against decoding every value on 20,000 generated and corrupted columns.

Interleaved mise run bench-sql runs (the 72e8d57 build before the limit, the previous commit, this one; median of the per-run medians of 9 repetitions, six rounds, 3 MB text three rounds of 5; ms) and user-space instructions per statement (perf stat on the backend, 10-20 statements):

casedocbefore the limitlimitthisinstructions: limitthis
I183 kB21.422.4 (+5%)21.0 (-2%)+2.6%+0.2%
I1877 kB203.2207.9 (+2%)199.7 (-2%)+2.4%+0.0%
I13 MB27452774 (+1%)2740 (-0.2%)
W183 kB37.638.0 (+1%)37.7 (+0.4%)+0.6%+0.3%
W1877 kB361.7367.2 (+2%)363.9 (+0.6%)+0.7%+0.3%
W13 MB51595245 (+2%)5221 (+1%)±0.0%
W283 kB22.122.8 (+3%)22.4 (+1%)+2.5%+0.2%
W2877 kB205.4213.2 (+4%)204.3 (-0.5%)+2.4%+0.1%
W23 MB27702785 (+0.5%)2756 (-0.5%)
W383 kB22.122.7 (+3%)22.0 (-0.4%)+2.5%+0.2%
W3877 kB205.7211.5 (+3%)204.2 (-0.8%)+2.4%+0.0%
W33 MB27712771 (0%)2755 (-0.6%)

What is left is the scan of the stored document a merge of change chunks applies them to (W1, 0.3% of its instructions; its time is within the drift, and on the 3 MB text W1 executes the same number of instructions as before the limit, ±0.004%, so its +1% is drift too). In bench-core Automerge::load itself, which did not change, varies by 2-4% between the three binaries (code layout), so its normalize is best compared net of the load in the same binary: on the 877 kB list a compressed save costs 8.2 ms beyond its load before the limit, 13.7 with the limit, 7.3 now; a canonical one 2.6, 5.4 and 3.8 (three runs each).