pg_automergev0.1.0

Benchmarks, 2026-09-28

Hardening and performance stage (2026-09-28)#

Measured before (commit e4eeb26) and after, same machine, release builds. mise run bench-core (Rust, median of five, milliseconds):

Operation3.0 MB text beforeafter877 kB beforeafter83 kB beforeafter
normalize of a compressed save5280253032416932.716.7
normalize of stored bytes2637248816316416.216.1
jsonb walk (no-op sink)194 (per object)247 (one sweep)1447213.46.4

(5,000 one-character changes, 5 kB: normalize of a compressed save 45.5 → 22.5 ms.) The compressed-save shortcut halves writes of Automerge.save() output; the one-sweep walk halves the walk of documents made of many small objects and is a quarter slower on one huge text.

mise run bench-expanded (SQL, mean of three runs, milliseconds):

Workload3.0 MB beforeafter877 kB beforeafter83 kB beforeafter
bytea → automerge of a compressed save5199261734118236.020.4
bytea → automerge of stored bytes2594258117817819.820.6
doc->>'status'2845281643931444.332.6
jsonb_array_length(doc->'items')2830284045131746.132.7
merge(doc, c1)::jsonb->>'status'2837282744632045.533.9
2 merges into d, then 10 reads d->>'status'5117520928161541278153
merge_agg(doc)::jsonb over 9 versions235832313617551629179168
UPDATE .. SET doc = merge(doc, c1)5320524637437739.841.2

The jsonb reads gain from building jsonb directly (no JSON text and jsonb_in: about 60 ms on the 3 MB and 877 kB documents) and, on the list documents, from the one-sweep walk; on the 3 MB text the two roughly cancel out.

SQL paths: redundant loads removed (2026-09-28)#

mise run bench-sql (release build, median of three, milliseconds; one load of the 3 MB text takes about 2.5 s, of the 877 kB list about 160 ms). "newer" is the stored document plus one change by another actor as a compressed save; "changes" its save_after(stored heads). Loads counted by the pg_tests of src/tests/loads.rs.

Pathloads before → after877 kB beforeafter3.0 MB beforeafter
R1 doc->>'status'1 → 131031328172793
R2 three accessors in one SELECT3 → 390892184238383
R3 automerge_heads(doc)0 → 00.50.50.50.5
I1 INSERT of newer (bytea)1 → 121021827782744
W1 merge(doc, changes::bytea)2 → 236237552175205
W2 merge(doc, newer::bytea)3 → 150521073902772
W3 merge(doc, newer::automerge)2 → 139520953112775
W4 upsert of newer, merge(docs.doc, excluded.doc)2 → 238137353425288
W5 W1 again (already there)0 → 0351.01850.9
W6 W3 with a text parameter (\bind, custom plan)2 → 239138053655344

With pg_automerge.verify_writes = off, W1 takes one load (207 ms and 2739 ms); the other paths are unchanged (their writes need no check). W2 no longer loads the stored document plus the save (as a ++ save) and then verifies the result: the save is loaded on its own and, since it contains the stored document, is the result, verified by its own encoding. W3's cast hands its loaded document to merge. W5 decides from the heads in a prefix of the stored value and returns the TOAST pointer it was given, which the UPDATE keeps: no detoasting, compressing or writing of the document. W4 and W6 stay at two loads because Postgres flattens EXCLUDED and constant-folded parameters (see Expanded values).

The pgrx-managed Postgres these numbers come from is an assert-enabled build (randomize_mem shows up in profiles of the jsonb paths), so the jsonb part of R1 and R2 is somewhat slower than on a production build.