Skip to content

feat(heap): place inserts by clustered index order - #1

Draft
Komzpa wants to merge 82 commits into
masterfrom
codex/clustered-write-insert-prototype
Draft

feat(heap): place inserts by clustered index order#1
Komzpa wants to merge 82 commits into
masterfrom
codex/clustered-write-insert-prototype

Conversation

@Komzpa

@Komzpa Komzpa commented Apr 29, 2026

Copy link
Copy Markdown

Summary

Adds clustered-write heap placement for tables that already have a valid clustered index.

When inserting into a clustered table, the heap now tries a bounded list of heap pages found through the remembered clustered index before falling back to the existing target block, FSM, and extension path.

  • caches the relation's indisclustered index OID in relcache as RelationGetClusteredIndex()
  • keeps clustered-write handling safe for every access method PostgreSQL allows CLUSTER to use, while limiting target-page probes to tuple-anchored btree keys
  • uses btree equality probes for plain non-NULL clustered keys, then retries shorter left prefixes so composite indexes like (tile_id, osm_id) can still find the existing tile_id group for brand-new object IDs
  • for brand-new single-column btree keys, probes the logical predecessor/successor key neighbourhood (>= key, then <= key) so first-seen clustered keys are placed near adjacent ranges instead of input-order append
  • scans a bounded window of index TIDs and distinct heap blocks from both ends of each btree equality/prefix range when the forward edge has no FSM-fitting candidate, reserving both TID and page-candidate budget for that tail probe, preferring candidate pages whose FSM entry already promises enough tuple space, then trying the remaining candidates in reverse scan order
  • deliberately avoids unqualified clustered-index scans for GiST, expression indexes, and nullable leading btree keys because they are not anchored to the tuple being inserted
  • opens the remembered clustered index once per heap_multi_insert batch, then groups tuples by discovered btree target block before entering the critical insertion loop
  • reuses the already computed first clustered target block in RelationGetBufferForTuple() during multi-insert, and only lazily probes the index again for additional candidates if that preferred page is full
  • keeps multi-insert target precomputation on a cheap first-candidate index probe; the full bounded candidate window is reserved for the lazy fallback when the preferred page is actually full
  • caches repeated leading-key target probes inside a heap_multi_insert batch for composite clustered btree indexes, with equality comparisons isolated in a resettable memory context
  • uses a bounded overflow-prefix cache for exhausted leading clustered keys, including small by-reference text/geohash-style prefixes stored inline inside the single rd_amcache chunk, while verifying every hit with the clustered btree opfamily equality function
  • delays tail-overflow activation for a leading key until after a small number of below-fillfactor reserve hits, so short grouped diffs such as ordered osm2pgsql-like inserts keep clustered locality instead of being sent to the relation tail after one row
  • applies the per-batch leading-key target cache to single-column clustered btree indexes too, so generated geohash-style keys can reuse repeated batch probes instead of only composite (tile_id, osm_id) shapes benefiting
  • counts repeated leading keys before probing clustered targets inside a heap_multi_insert batch, then lets very hot equal-prefix batches use the regular bulk/FSM path instead of spending most of the write on low-value duplicate target probes
  • remembers each tuple's per-batch prefix-count slot, so tuples already classified as too-hot do not pay a second heap_getattr/opfamily-equality pass while preparing the batch
  • short-circuits fully hot by-value integer/OID equal-prefix batches before allocating the prefix target cache or clustered batch items, and verifies the type is actually pass-by-value before raw Datum comparison, while still suppressing the lazy clustered-index lookup during insertion, so all-duplicate batches stay on the regular bulk/FSM path with less per-tuple overhead
  • extends the same all-equal batch stop-spending path to generated text COLLATE "C" leading keys using the clustered btree equality function, so geohash-like duplicate-key COPY batches avoid prefix-cache allocation and clustered target probes too
  • skips clustered batch allocation, probing, and sorting entirely when no remembered clustered index can be opened, keeping ordinary multi-inserts on the previous hot path
  • preserves the original slot-to-tuple TID mapping after clustered heap_multi_insert reorders a batch, so COPY index maintenance and row triggers keep pointing at the correct heap tuple
  • only allocates clustered batch state when the remembered clustered index can actually provide btree target probes or GiST sort-support ordering, keeps GiST sort eligibility behind a single helper, and removes an unused single-block helper from the heap insertion API
  • centralizes btree target-probe eligibility before multi-insert batch allocation, so invalid, not-ready, partial, expression, non-btree, and nonclusterable remembered indexes skip the per-tuple probe path immediately
  • treats rd_clusteredindex as part of the relcache index-list state when retrying after mid-scan relcache invalidations, alongside primary-key and replica-identity indexes
  • invalidates the table relcache when mark_index_clustered() changes indisclustered, so later inserts in all sessions refresh the remembered clustered index before using clustered-write placement
  • reuses GiST sort support for heap_multi_insert batch ordering when a clustered GiST opclass exposes the same sorted-build comparator; btree bulk inserts stay on the target-block path because benchmarking showed that was faster here
  • avoids recording InvalidBlockNumber in FSM after the clustered-candidate list is exhausted; the real osm2pgsql run caught this as multi-GB FSM bloat before the fix
  • documents the best-effort clustered-write behavior and adds regression coverage for btree composite-prefix and GiST clustered tables
  • adds synthetic and real osm2pgsql benchmark harnesses under src/tools/clustered_write_bench/, including a repeat-run wrapper that stores raw outputs and TSV summaries
  • lets the synthetic wrapper launch a temporary PostgreSQL instance from PG_BINDIR and records server_version.txt, so benchmark runs can be tied to the build under test instead of accidentally using a system server
  • lets the synthetic wrapper run both composite and single-column clustered btree shapes with SINGLE_KEY_VALUES, matching both the left-prefix stress case and the experimental osm2pgsql generated-geohash shape
  • lets the synthetic wrapper switch to a stored generated text clustering key with TEXT_KEY_VALUES, so geohash-like varlena-key changes can be smoke-tested before a full osm2pgsql/PostGIS run
  • lets the synthetic wrapper sweep heap reserve space with HEAP_FILLFACTOR_VALUES, so update locality and duplicate-key insert locality are measured separately instead of blaming every hot-key failure on page selection
  • lets the synthetic wrapper force a configured fraction of inserted diff rows onto one hot clustered key with HOT_TILE_FRACTION_VALUES, making large duplicate-key ranges and already-full equal-key pages visible in repeatable benchmarks
  • lets the synthetic wrapper spread forced duplicate-key rows across several hot keys with HOT_TILE_COUNT_VALUES, so multi-hot COPY batches exercise cleanup paths that single-key hot tests miss
  • keeps synthetic benchmark artifacts bounded by gzip-compressing raw psql outputs and removing temporary pgdata after USE_TEMP_INSTANCE=true runs unless KEEP_TEMP_INSTANCE_DATA=true is set
  • creates temporary benchmark Unix-domain socket directories under ${TMPDIR:-/tmp} instead of OUTDIR, so long output paths do not trip PostgreSQL socket path limits
  • reports heap_block_span and outside_base_heap_block_span in synthetic locality outputs and summaries, separating full mixed reserve/tail span from the compactness of rows that actually left the original clustered key range
  • records run_environment.txt for synthetic benchmark runs, including git head, benchmark matrix settings, uptime/load, filesystem space, and selected PostgreSQL bindir, so noisy timing comparisons can be audited instead of treated as standalone truth
  • reports median_elapsed_ms alongside average/min/max timing summaries, so repeated synthetic runs expose outliers instead of hiding them in a single mean
  • keeps single-row clustered insert candidates above the normal target free-space threshold, and uses a bounded reserve-hit counter before tail overflow, so massive duplicate-key runs stop spending unbounded work on old clustered-neighbor pages while short grouped diffs can still use nearby reserve
  • skips FSM page search for COPY batches already classified as too-hot for clustered target probing, so dense duplicate-key runs append without spending time on old fillfactor-reserve pages
  • lets the synthetic wrapper compare ordered and unordered diff input with ORDER_DIFF_BY_CLUSTER_KEY_VALUES, giving a cheap upper-bound check for micro-sort/buffered-insert ideas
  • lets the synthetic wrapper compare row-at-a-time INSERT ... SELECT with server-side COPY ... FROM via COPY_DIFF_FROM_FILE_VALUES, exercising heap_insert and heap_multi_insert as distinct benchmark paths while cleaning up per-run staged TSV files
  • records hot-range indexed read timings as clustered_write_read_hot and without_cluster_metadata_read_hot, so micro-sort and overflow-placement experiments can be judged on read/write tradeoffs instead of locality counters alone
  • records update-focused hot-range read timings as clustered_write_read_updated_hot and without_cluster_metadata_read_updated_hot, so moved-update read cost is visible separately from broader hot-tile reads
  • lets the synthetic wrapper bias enlarged updates toward the configured hot tile range with HOT_UPDATE_FRACTION_VALUES, splitting locality into update_hot and update_rest when enabled
  • lets the synthetic wrapper compare update-before-insert workload order with UPDATES_BEFORE_INSERTS_VALUES, and records that this is diagnostic rather than a general fix because it moves locality damage from updates into hot inserts
  • records a focused scale 1, repeat 3 COPY-vs-INSERT result: integer COPY is often faster, but fillfactor 90 loses clustered locality, while generated text-key COPY is still noisy and can regress badly when unordered
  • keeps clustered COPY/heap_multi_insert page packing inside clustered target boundaries, so a page selected for one clustered neighborhood is not silently filled with later tuples that targeted a different neighborhood
  • records the boundary-guard benchmark result: fillfactor 90 COPY locality recovered to 83.97-84.75% inside the base range, while text-key COPY and ordered COPY remain separate cost follow-ups
  • hashes normalized generated-text payload bytes for clustered COPY prefix-cache slots while still verifying hits with the clustered btree equality operator, cutting focused generated text-key COPY medians without changing locality
  • starts single-row clustered insert placement with a cheap first-candidate target probe and only expands to the full bounded candidate window after that page fails the locked free-space recheck, cutting the non-hot INSERT ... SELECT cost center without adding persistent target-block cache state
  • records the current post-lazy-probe COPY matrix and rejects a larger COPY buffered-byte limit experiment because it preserved locality but regressed enough timing cases to avoid a broader memory-behavior change
  • keeps same-target clustered COPY groups packed after a full clustered neighbour redirects insertion to the relation tail, cutting most focused COPY medians while preserving the recovered locality
  • records and rejects a direct prefixCountSlots -> COPY target-cache shortcut because it improved several generated text-key COPY cases but regressed the fillfactor 50 unordered text-key path
  • records and rejects a lazy prefix-count allocation experiment because the input-order run detector prototype crashed on the first non-hot COPY case before showing a timing win
  • records and rejects a narrower prefix-count hash-slot reuse experiment because the promising text-key COPY cases did not survive repeat checks and several integer COPY cases regressed
  • records and rejects a presorted-target COPY qsort-skip experiment because the repeat check regressed most fillfactor 90 COPY cases
  • fixes a multi-hot COPY crash where several too-hot prefixes in one batch could delete the prefix-cache comparison context on the skip path and then delete it again during common cleanup
  • keeps the real osm2pgsql benchmark runnable by selected variant, so partial runs do not require binaries for variants that are disabled
  • keeps real osm2pgsql benchmark artifacts bounded by recording run_environment.txt, gzip-compressing per-variant logs by default, and removing each variant's pgdata unless KEEP_PGDATA=true
  • lets the real osm2pgsql benchmark repeat the read workload against the same imported database with READ_REPEATS, so cold-cache and scheduler outliers are visible without repeating the full import
  • lets the real osm2pgsql benchmark skip reads with READ_REPEATS=0 and enable PostgreSQL slow-statement logging with PG_LOG_MIN_DURATION_MS, so append cost centers can be diagnosed without hand-editing benchmark clusters
  • lets the real osm2pgsql benchmark set PostgreSQL work_mem with PG_WORK_MEM, after slow-statement diagnostics showed the ordered staged drain is sensitive to sort memory
  • creates the experimental osm2pgsql staged delete-key index lazily on the first staged DELETE for each target, avoiding btree maintenance for purely inserted staging tables while preserving the full-scan protection when deletes arrive
  • keeps moved heap_update tuples on the regular FSM path, so clustered-write page selection remains an insertion policy and does not fight HOT/fillfactor/old-page locking constraints

Behavior

The normal heap placement path remains the fallback. If there is no clustered index, the clustered index is invalid/not ready, the selected pages cannot fit the tuple, or the catalog state does not satisfy CLUSTER-style requirements, insertion behaves as before. Moved update tuples intentionally keep using the regular update/FSM placement path. Target-page probes currently require a plain btree key prefix from the tuple being inserted; GiST and other non-btree clustered indexes avoid fake unqualified placement probes.

Partial clustered indexes are ignored defensively, matching the fact that PostgreSQL rejects them for CLUSTER. Clustered placement is disabled during bootstrap processing so catalog initialization does not try to scan indexes whose heap files are still being created.

GiST clustered indexes can still participate in heap_multi_insert ordering when their opclass exposes sorted-build sort support. This is batch ordering, not a per-tuple page probe.

BRIN is not used directly in this patch: PostgreSQL's BRIN AM exposes amgetbitmap, not amgettuple, and brinhandler is not marked amclusterable. The benchmark has a use_brin=true mode so a future bitmap/range candidate path can be compared easily.

Synthetic Benchmark

Ran the synthetic osm2pgsql-like diff benchmark on this branch after a fresh tmp_install build:

  • psql -v scale=1 -v use_brin=false -f src/tools/clustered_write_bench/osm2pgsql_diff.sql postgres
  • psql -v scale=1 -v use_brin=true -f src/tools/clustered_write_bench/osm2pgsql_diff.sql postgres

Structured diff statement timings now emitted by the benchmark:

brin_enabled step elapsed_ms
false clustered_write_insert 323.56
false without_cluster_metadata_insert 70.11
false clustered_write_update 261.56
false without_cluster_metadata_update 228.71
true clustered_write_insert 353.25
true without_cluster_metadata_insert 64.20
true clustered_write_update 259.34
true without_cluster_metadata_update 244.78

Locality summary:

brin_enabled variant diff_kind rows heap_blocks_touched pct_inside_base_range avg_block_drift p95_block_drift max_block_drift
false clustered_write insert 20000 6070 79.38 561.72 3601.00 6022
false without_cluster_metadata insert 20000 409 0.00 1786.35 3222.00 3576
false clustered_write update 20000 2223 0.02 5591.97 7448.00 8267
false without_cluster_metadata update 20000 5046 15.88 2465.05 4610.00 5428
true clustered_write insert 20000 6070 79.38 561.72 3601.00 6022
true without_cluster_metadata insert 20000 409 0.00 1786.35 3222.00 3576
true clustered_write update 20000 2223 0.02 5591.97 7448.00 8267
true without_cluster_metadata update 20000 5046 15.88 2465.05 4610.00 5428

A repeated scale=1, use_brin=false, REPEATS=3 wrapper run before the prefix-cache patch showed deterministic locality but noisy timings: clustered insert averaged 708.46 ms with a 648.43..806.90 ms range, while the control insert averaged 131.75 ms with a 112.41..152.19 ms range. After adding the per-batch leading-key target cache, the same wrapper shape measured clustered insert at 618.40 ms average (595.31..645.22 ms) and control insert at 123.52 ms average (97.52..137.78 ms), with unchanged locality. A verified temp-instance run using USE_TEMP_INSTANCE=true PG_BINDIR=/tmp/postgres-clustered-write/tmp_install/usr/local/pgsql/bin REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false recorded PostgreSQL 19devel in server_version.txt; with the by-value hash cache it measured clustered insert at 738.85 ms average (633.56..839.51 ms) and control insert at 162.86 ms average (156.87..173.97 ms), again with clustered insert locality 79.38% vs control 0.00%. After making multi-insert precomputation use only a cheap first-candidate probe and leaving the full bounded search for the lazy full-page fallback, a REPEATS=5 SCALE_VALUES=1 BRIN_VALUES=false temp-instance run measured clustered insert at 617.59 ms average (473.09..785.07 ms) and control insert at 131.90 ms average (104.97..172.14 ms), with unchanged locality. Extending the per-batch leading-key cache to single-column btree indexes and running REPEATS=5 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES=true measured the generated-geohash-like shape at clustered insert 497.63 ms average (437.03..540.23 ms) vs control insert 148.21 ms (128.68..192.37 ms), again with clustered insert locality 79.38% vs control 0.00%. A follow-up hot-duplicate run with REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES=true HOT_TILE_FRACTION_VALUES="0 0.9" kept the normal single-key case in the same range (484.39 ms clustered insert vs 155.14 ms control, locality 79.38% vs 0.00%) but exposed the duplicate-key cliff: with hot_tile_fraction=0.9, clustered insert averaged 1926.53 ms vs control 140.53 ms, while locality was only 10.02% vs 0.00% and average drift improved modestly (3003.62 vs 3218.83 blocks). Several attempted fixes for that cliff were benchmarked locally and rejected before commit, including repointing the remaining batch target group after a full preferred page, taking the backward edge for cheap first-candidate probes, target-run skipping, and prefix-hit caps. The kept hot-prefix pre-count guard measured better on the same verified temp-instance shape: normal single-key clustered insert averaged 328.84 ms vs 87.05 ms control with locality still 79.38% vs 0.00%; hot_tile_fraction=0.9 clustered insert averaged 1110.25 ms vs 87.58 ms control, with locality still 10.02% vs 0.00% and average drift still 3003.62 vs 3218.83 blocks. After caching each tuple's prefix-count slot and splitting the hot locality report into insert_hot/insert_rest, a verified temp-instance REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES=true HOT_TILE_FRACTION_VALUES="0 0.9 1" run measured normal single-key clustered insert at 263.21 ms vs 59.58 ms control, hot_tile_fraction=0.9 clustered insert at 699.08 ms vs 43.35 ms control, and fully-hot 1.0 clustered insert at 773.54 ms vs 45.67 ms control. After short-circuiting fully hot equal-prefix batches before clustered item allocation, a follow-up REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES=true HOT_TILE_FRACTION_VALUES="0.9 1" temp-instance run measured hot_tile_fraction=0.9 clustered insert at 930.95 ms vs 72.87 ms control, and fully-hot 1.0 clustered insert at 983.05 ms vs 66.57 ms control in the current noisy session; a post-regression scale 0.1 hot=1 smoke measured 69.42 ms. After narrowing that path to by-value integer/OID all-equal batches and skipping the prefix cache allocation too, a verified REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES=true HOT_TILE_FRACTION_VALUES="0 0.9 1" temp-instance run measured normal single-key clustered insert at 346.08 ms vs 94.77 ms control, hot_tile_fraction=0.9 clustered insert at 866.17 ms vs 59.85 ms control, and fully-hot 1.0 clustered insert at 725.13 ms vs 42.07 ms control. A post-rebuild two-repeat check on hot 0.9 1 measured 957.26 ms and 1005.07 ms, confirming the same broad improvement class under noise. The split locality makes the remaining cliff explicit: at hot_tile_fraction=0.9, forced hot rows are only 0.03% inside their base range, while the non-hot tail is 99.95% inside.

The composite-prefix path fixes the important diff-insert case: new object IDs under existing tile keys stay inside the original tile heap range about 79% of the time instead of 0% in the control. The structured timings make the write-side cost visible: clustered insert placement is slower than the control in this synthetic stress, while updates stay in the same broad range. A current scale 1, repeat 3, single-key median run shows the hot duplicate-key path is no longer the main cost center: integer fillfactor 90 hot 0.9 clustered insert was 165.92 ms median versus 120.71 ms control, and text fillfactor 90 was 222.00 ms versus 175.87 ms. The ordinary non-hot single-key case still preserves 83.97% to 100% locality but costs roughly 614-1205 ms clustered insert median versus 158-296 ms control, so future optimization should target clustered target-probe cost. Adding the optional BRIN index does not change locality yet, which matches the current contract because clustered placement does not consume BRIN/range candidates directly.

A new text-key synthetic mode was added after the rejected varlena hash-cache experiment so future geohash-key changes can fail fast. The smoke command USE_TEMP_INSTANCE=true PG_BINDIR=/tmp/postgres-clustered-write/tmp_install/usr/local/pgsql/bin REPEATS=1 SCALE_VALUES=0.1 BRIN_VALUES=false SINGLE_KEY_VALUES=true TEXT_KEY_VALUES="false true" HOT_TILE_FRACTION_VALUES="0 0.9" completed with gzip-compressed raw output and removed its temporary pgdata. On this run, integer and text keys had identical locality summaries, while text mode exposed extra write cost (39.25 ms clustered insert vs 22.30 ms in the non-hot case). The hot case remained the known duplicate-key cliff: forced hot rows were only 0.28% inside their base range, while the non-hot tail stayed 99.00% inside.

After switching the synthetic generated text key to COLLATE "C" and extending the all-equal stop-spending guard to text prefixes, a before/after scale 1 hot 1.0 run measured fully hot text-key clustered insert at 979.47 ms before and 810.16 ms after, with unchanged locality (0.03% inside the base range). A scale 1 hot 0/0.9 follow-up kept the same locality summaries for integer and text modes; text-key hot 0.9 measured 853.77 ms clustered insert with the non-hot tail still 99.95% inside. This is still not a duplicate-key locality fix; it only stops spending extra clustered-index work on batches that cannot currently be placed locally.

A fillfactor sweep then separated reserve-space effects from target-page selection. With REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES=true TEXT_KEY_VALUES=false HEAP_FILLFACTOR_VALUES="90 70 50" HOT_TILE_FRACTION_VALUES=0.9, lowering fillfactor from 90 to 50 improved moved-update locality from 7.71% to 99.28% inside the original key range, but forced-hot inserts only moved from 0.03% to 0.29% inside. The first heap_block_span metric showed a long mixed span for hot rows, but a later outside-base span metric clarified the shape: after reserve restoration, the full span includes a few old-range reserve rows plus a compact tail. In a focused scale 1, hot 0.9, repeat 2 run, fillfactor 90 clustered insert_hot had full heap_block_span=3773 but outside_base_heap_block_span=384, matching the control tail; at fillfactor 50, clustered outside-base span was 692 versus control 693.

The kept single-row overflow fix pairs tail fallback with a stricter candidate-space check. Clustered candidates must now satisfy the normal target free-space threshold; when every equal-key neighbor is below that threshold, the insert tries the relation tail and extends from there instead of walking the global FSM. On USE_TEMP_INSTANCE=true ... REPEATS=1 SCALE_VALUES=1 BRIN_VALUES=false HEAP_FILLFACTOR_VALUES="90 50" HOT_TILE_FRACTION_VALUES=0.9 ORDER_DIFF_BY_CLUSTER_KEY_VALUES=false, the first version made clustered insert_hot span match the control shape (384 at fillfactor 90, 693 at fillfactor 50) instead of the previous 3737/6596 spans, but inserts were still expensive (1194.27 ms / 1079.02 ms). The follow-up overflow-prefix cache remembers exhausted leading clustered keys and keeps appending locally, improving the same integer shape to 70.85 ms / 82.54 ms, close to the control at 53.35 ms / 55.03 ms. After extending that bounded cache to by-reference text prefixes, scale 1 generated-text hot inserts that were still around 770-805 ms dropped to 63.07-112.31 ms in the latest inline-cache sweep across the ff90/ff50 single/composite text-key matrix. The by-reference values are kept inline because rd_amcache reset frees only its single palloc chunk; oversized by-reference keys skip the cache. Same-backend memory smokes covered both replacement and relcache invalidation: the invalidation stress dropped from 1082680 total / 787256 used bytes after warmup to 1048576 total / 755272 used bytes after 30 insert/invalidation cycles instead of growing.

The newest reserve-threshold pass fixes a micro-sort-shaped regression caught by ORDER_DIFF_BY_CLUSTER_KEY_VALUES=true: immediate tail-overflow activation after one below-fillfactor hit made sorted non-hot input fast but wrong, dropping clustered insert locality to 20.48% inside the base range. Counting a small number of reserve hits before activating the cached tail target restored sorted scale 1, hot 0 locality to 84.75% at fillfactor 90 and 100% at fillfactor 50; unordered locality stayed at 83.97% / 100%. A hot 0.9/1 regression run kept write timings in the same broad range as the inline overflow-cache sweep. Hot insert_hot span remains a visible tradeoff after reserve restoration: a few hot rows can stay in the old clustered range while the rest append at the tail, so the split insert_hot/insert_rest metrics remain the source of truth rather than treating one span number as victory.

The outside-base span follow-up makes that tradeoff less alarming: the overflow run is compact after it leaves the old range. The large full span should be read as "reserve rows plus compact tail", not as scattered tail placement.

A follow-up multi-hot COPY benchmark added HOT_TILE_COUNT_VALUES and caught a
real cleanup bug. The pre-fix scale 1, repeat 3,
hot_tile_fraction=0.9, hot_tile_count=1 8, COPY run crashed with SIGSEGV
on the first eight-hot-key run while deleting the prefix-cache comparison
memory context. The fix makes the common cleanup tolerate the skip path having
already deleted that context. The repaired run completed with clustered COPY
insert medians of 63.17 ms for one hot key and 86.04 ms for eight hot keys;
the hot_tile_count=128 follow-up also completed and showed the non-skip
multi-hot overflow shape (187.79 ms clustered COPY median versus 59.78 ms
control, non-hot tail 96.85% inside the base range). Raw gzip outputs are
under
/home/kom/tmp/clustered-write-synthetic/multi-hot-cache-update-copy-fixed-20260501-033540/
and
/home/kom/tmp/clustered-write-synthetic/multi-hot-overflow-cache-copy-20260501-033632/;
the pre-fix crash log is under
/home/kom/tmp/clustered-write-synthetic/multi-hot-cache-update-copy-20260501-033322/.

Real osm2pgsql/PostGIS Benchmark

Ran src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh with Geofabrik Georgia, planet daily diff 000/004/977.osc.gz simplified by osmium merge-changes -s, PostgreSQL 18.3, PostGIS 3.6.3, and osm2pgsql 2.2.0.

Write-side results from the three-way run:

variant create elapsed append elapsed database size
baseline_stock 2:20.64 2:21.34 2532 MB
patched_stock 1:26.83 1:40.40 2532 MB
patched_clustered_import 1:47.71 1:42.20 2574 MB

Read/locality summary from the same run:

variant roads exact read polygon exact read locality summary time line p95 blocks/span point p95 blocks/span polygon p95 blocks/span
baseline_stock 192.850 ms 3866.167 ms 21516.779 ms 6 / 2185.7 5.4 / 709 29 / 486.55
patched_stock 64.132 ms 1738.309 ms 9325.861 ms 6 / 2185.7 5.4 / 709 29 / 486.55
patched_clustered_import 87.192 ms 2731.863 ms 9373.608 ms 13 / 15368.7 16 / 2204.4 9 / 20877

Follow-up real run after hardening the benchmark harness used planet daily diff
000/004/978.osc.gz, the same Georgia extract, stock osm2pgsql 2.2.0,
OSM2PGSQL_CACHE_MB=1024, and OSM2PGSQL_PROCS=2. The baseline was system
PostgreSQL 18.3 with PostGIS 3.6; patched rows used this branch's
PostgreSQL 19devel install with locally built PostGIS 3.7.0dev.

variant create elapsed append elapsed database size
baseline_stock 2:13.29 2:43.53 2726 MB
patched_stock 2:54.12 2:41.17 2728 MB
patched_clustered_import, no heap fillfactor reserve 2:10.09 2:22.13 2776 MB
patched_clustered_import, heap fillfactor 90 1:21.37 2:00.01 2780 MB
patched_clustered_import, generated-preserving rewrite by key 1:36.92 2:29.90 2829 MB

Read/locality summary from the 978 run:

variant bbox point bbox line bbox polygon roads exact polygon exact locality summary time line p95 blocks/span point p95 blocks/span polygon p95 blocks/span
baseline_stock 213.205 ms 608.002 ms 1289.247 ms 584.712 ms 2784.592 ms 20463.716 ms 7 / 2381 6 / 1002 32 / 1208
patched_stock 267.111 ms 2381.184 ms 1144.408 ms 99.757 ms 2215.158 ms 9645.548 ms 7 / 2381 6 / 1002 32 / 1208
patched_clustered_import, no heap fillfactor reserve 332.595 ms 631.841 ms 972.157 ms 98.546 ms 1576.143 ms 6123.307 ms 13 / 15394 16 / 2207 10 / 20402
patched_clustered_import, heap fillfactor 90 226.063 ms 619.446 ms 1040.034 ms 135.411 ms 1716.965 ms 6396.165 ms 9 / 15396.5 13 / 2212 9 / 20314.5
patched_clustered_import, generated-preserving rewrite by key 208.438 ms 666.939 ms 1336.187 ms 266.315 ms 4152.334 ms 7671.920 ms 4 / 2362.25 4 / 1097 9 / 1289

This run is deliberately documented as mixed: append time stayed roughly flat
and exact spatial reads improved in this pass, but initial import and bbox line
reads regressed. The stock-osm2pgsql locality aggregate is identical between
the baseline and patched rows, so this validates the real benchmark path and
PG19/PostGIS compatibility rather than proving a stock-osm2pgsql clustering
win.

The experimental clustered-import path is more promising on write time and
spatial reads in this run. Adding heap fillfactor=90 to those import tables
left real reserve space (reloptions reported
{autovacuum_enabled=off,fillfactor=90}) and reduced the compactness metric
from 13/16/10 p95 blocks to 9/13/9 for line/point/polygon, but it did not
fix the long p95 heap span. A follow-up variant that rewrites the initial heap
by osm2pgsql_cluster_key, osm_id before append fixes the heap-span diagnosis,
but the first CREATE TABLE AS SELECT * prototype silently turned the generated
key into a plain text column. The current checked-in experiment moved that
ordering into the COPY path by staging rows in per-target temporary tables
created LIKE ... INCLUDING GENERATED, then draining each target with
ORDER BY osm2pgsql_cluster_key, osm_id. The latest kept improvement defers
creating the clustered btree until after the initial staged drain, so initial
import does not pay btree maintenance while rows are already draining in key
order. A short create+append smoke confirmed all generated keys stay
non-NULL and the clustered btree marker survives. The next kept improvement
stops draining the staged table before every append DELETE: append-mode
DELETEs remove matching ids from the temporary staged table, and append staging
tables create a temporary delete-key index lazily before the first staged
DELETE for that target so those deletes do not full-scan a growing temp table.
In the current same-environment A/B, stock osm2pgsql on
patched PostgreSQL measured 1:58.33 create and 2:05.22 append, while the
clustered import measured 1:30.30 create and 2:13.45 append. It used more
space (2840 MB versus 2727 MB), but p95 block/span tightened to 2/2 line,
2/1 point, and 3/2 polygon from stock 7/2381, 6/1002, and 32/1208.
Reads were mostly better or close (627.029 ms line bbox, 954.597 ms
polygon bbox, 184.525 ms roads exact, 1251.008 ms polygon exact), with one
point bbox spike (3163.663 ms) that needs a repeat before being treated as a
real regression. A repeat of only patched_clustered_import on the same
Georgia 978 inputs did not reproduce that point read spike (978.990 ms) and
kept the same tight p95 block/span (2/2, 2/1, 3/2). The repeat measured
1:07.54 create, 2:51.11 append, 2840 MB, 1146.925 ms line bbox,
1912.456 ms polygon bbox, 293.356 ms roads exact, and 2053.134 ms
polygon exact, so the honest claim remains locality plus plausible append
shape, with final read/write claims needing repeat medians. The repeat also
verified the real-workload cleanup path: logs were gzip-compressed and pgdata
was removed with KEEP_PGDATA=false. Small bounded in-memory sort windows were
tested and rejected because they left key correlation poor and made diff append
much slower.

Adding READ_REPEATS=3 made the read comparison less dependent on one cold
query pass. On the same Georgia 978 inputs, patched stock osm2pgsql measured
1:47.47 create, 2:20.95 append, 2728 MB, warmed medians of 130.545 ms
point bbox, 193.198 ms line bbox, 330.994 ms polygon bbox, 76.100 ms
roads exact, 1852.406 ms polygon exact, and locality-summary median
8123.631 ms, with p95 block/span 7/2381, 6/1002, 32/1208. The staged
clustered import measured 1:21.41 create, 3:05.01 append, 2841 MB,
warmed medians of 110.095 ms, 200.126 ms, 345.462 ms, 46.813 ms, and
1695.422 ms, locality-summary median 6832.766 ms, and p95 block/span
2/2, 2/1, 3/2. That is useful but mixed: tighter heap locality, faster
create, and better point/exact warmed reads; roughly tied/slightly worse
line/polygon bbox; slower append in this run.

Creating the staged delete-key index lazily is a modest cleanup, not the final
append-cost fix. A same-input READ_REPEATS=3 run with the lazy index measured
1:42.58 create, 2:46.88 append, 2841 MB, warmed medians of 78.699 ms
point bbox, 163.190 ms line bbox, 281.257 ms polygon bbox, 40.450 ms
roads exact, and 1382.557 ms polygon exact, with the same tight p95
block/span 2/2, 2/1, 3/2. The append Reading input files phase was
163s, down from the previous repeated clustered run's 181s but still above
the stock run's 131s. The real-workload cleanup removed the per-variant data
directory with KEEP_PGDATA=false; only the empty $WORKDIR/pgdata parent
remained.

An append-only slow-statement diagnostic (READ_REPEATS=0,
PG_LOG_MIN_DURATION_MS=1000) confirmed the remaining clustered append cost is
mostly the ordered staging path, not the temp delete-key index itself. During
append, statements above the 1s threshold included staged COPY work, the
middle-table maintenance/index path, and the final staged drains; the largest
drain was polygon INSERT ... SELECT ... ORDER BY at about 46s. That makes
the drain the obvious next cost center, but not something to remove blindly.

A same-shape follow-up raised the benchmark cluster work_mem from 64MB to
256MB via PGOPTIONS='-c work_mem=256MB'; the harness now exposes the same
setting as PG_WORK_MEM=256MB. With the kept staged/lazy-index path, the
append-only Georgia 978 run measured 1:23.89 create, 2:10.95 append,
2840 MB, and reduced slow logged staged-drain time above 1s from 59.6s
to 43.3s. A single-read follow-up measured 1:18.84 create, 2:13.68
append, 2840 MB, read timings of 205.257 ms point bbox, 611.452 ms line
bbox, 1418.884 ms polygon bbox, 210.900 ms roads exact, and
1116.870 ms polygon exact, while preserving tight p95 block/span 2/2 line,
2/1 point, and 3/2 polygon. This is a practical benchmark/deployment knob
rather than a new algorithmic fix.

Additional follow-up runs tested tempting variants and rejected them or kept them as caveats:

experiment create elapsed append elapsed database size roads exact read polygon exact read result
pre-COPY clustered GiST on way 4:53.68 5:30.27 2528 MB 383.808 ms 4965.307 ms functionally works, but GiST maintenance during ingest is too expensive
btree batch-sort before target probes 3:14.39 4:08.59 2574 MB 350.075 ms 3868.569 ms worse than probing target pages for this workload
one-opened-index-per-heap_multi_insert batch 1:59.29 1:56.57 2574 MB 203.343 ms 2222.364 ms simpler/less overhead in code, but this single rerun did not beat the prior best geohash run
bounded in-memory pre-COPY geohash sort, 8192-row window 1:35.15 4:33.82 2781 MB 3266.747 ms 12322.547 ms cheaper initial import, but still poor correlation and much slower append/read
bounded in-memory pre-COPY geohash sort, 65536-row window 2:21.27 5:11.02 2781 MB 8019.072 ms 2071.512 ms larger window did not repair correlation and made write time worse
CTAS final rewrite by generated key 2:08.24 1:54.96 2830 MB 215.840 ms 1142.027 ms fastest append in one run, but rejected because CTAS loses the generated column
varlena datum_image_hash() prefix cache 2:10.79 2:37.58 2829 MB 202.978 ms 1793.538 ms direct-mapped cache for text geohash key kept locality but made writes worse
per-target ordered staged COPY 3:15.36 3:10.19 2793 MB 182.531 ms 3151.490 ms functionally correct external-sort shape, but btree maintenance during initial drain made writes too slow
direct append into clustered real tables 1:19.69 2:05.19 3370 MB 1224.795 ms 3732.839 ms append reached stock-class time, but storage/read locality collapsed: p95 spans 8249 line, 7526 point, 6653.5 polygon
per-target ordered staged COPY, deferred clustered btree 2:10.01 2:45.90 2837 MB 1890.598 ms 1976.231 ms kept in the osm2pgsql experiment; removes initial btree-maintenance cost and improves bbox reads, but append/drain cost is still high
plain real-table geohash key, generated staging key 3:42.88 4:18.57 2837 MB 462.948 ms 3509.093 ms rejected; key correctness was fine, but avoiding a second generated-expression computation did not reduce write cost and made bbox/polygon reads worse
staged DELETE without temp delete-key index 1:58.92 3:24.06 2840 MB 238.619 ms 2388.673 ms rejected; preserved tight locality but temp deletes full-scanned growing staging tables
staged DELETE with temp delete-key index 1:30.30 2:13.45 2840 MB 184.525 ms 1251.008 ms kept; close-to-stock append with tight locality; point bbox read spiked and needs repeat

So the checked-in osm2pgsql experiment uses a generated geometry-derived geohash
key, per-target ordered staging, staged DELETE cleanup with a temporary
delete-key index during append, and a clustered btree index created after the
initial staged drain; append runs keep using the existing clustered index.
PostgreSQL keeps GiST batch-sort support for opclasses where sorted-build order
is actually beneficial.

Rejected Experiments

The branch keeps negative results documented so the next pass does not circle
back into already-benchmarked dead ends:

experiment result
eager all-candidates precomputation per tuple too much index work on every tuple; kept cheap first-candidate batch probe and lazy full bounded fallback instead
repointing the remaining batch after a full preferred page worse hot-duplicate smokes without useful locality gain
taking the backward btree edge during cheap first-candidate probes moved duplicate-key defence onto the common path too early; backward edge remains only in lazy bounded search
target-run skipping / prefix-hit caps slower hot-prefix smokes and no forced-hot-row locality fix
bounded FIFO prefix cache destroyed composite-prefix locality (0% inside base range), so current COPY batch prefix information must not be evicted blindly
removing skip-only target arrays or relying on lazy clustered lookup reintroduced expensive per-tuple clustered probes; scale 1, hot 1.0 check was about 2016 ms
target-block-only skip without preserving slot remapping stayed above 2100 ms on scale 1, hot 1.0, and risks COPY/index/trigger TID mapping correctness
early leading-prefix whole-batch skip made hot 1.0 scale 0.1 slower
generic early all-equal hot-prefix shortcut broad variant made hot 0.9/1.0 scale 0.1 smokes slower without locality change; kept only the narrower by-value integer/OID and generated text COLLATE "C" all-equal guards after targeted scale 1 validation
per-tuple Datum equality fast-path for integer/OID prefixes semantically safe only when the type is pass-by-value; worsened ordinary prefix-cache hit smokes with unchanged locality
near-FSM fallback after clustered candidate exhaustion RecordAndGetPageWithFreeSpace() near the last attempted clustered page made scale 1 hot 0.9/1.0 inserts about 2.2s with no hot-row locality gain
presorted-target COPY qsort skip repeat 3 looked promising in 7/8 COPY cases, but repeat 6 fillfactor 90 recheck regressed integer unordered/ordered and text ordered, so the code was reverted
skipping the whole batch when one hot prefix dominates hotPrefixTupleCount > ntuples / 2 did not beat the kept all-hot-only guard and did not improve hot-row locality
seeding a leading hot-prefix run before the count cache scale 1 hot 0.9/1.0 got slower with unchanged locality, so the branch keeps the simpler all-hot-only guard
open-addressed by-value prefix cache scale 1 temp-instance run kept locality unchanged but worsened single-key inserts to 780.29 ms, 1677.95 ms, and 1294.90 ms for hot 0, 0.9, and 1.0
btree batch-sort before target probes worse on Georgia (3:14.39 create, 4:08.59 append); target-page probing is better for this workload
pre-COPY clustered GiST on way functional but too expensive during ingest (4:53.68 create, 5:30.27 append)
bounded in-memory pre-COPY geohash sort in osm2pgsql 8192 and 65536 row windows failed to repair generated-key correlation and made append 4:33.82 / 5:11.02; a real replacement for the final rewrite needs a global/external sort
CTAS final rewrite for the generated-key osm2pgsql experiment fastest append in one run (1:54.96), but rejected because CREATE TABLE AS SELECT * does not preserve the generated osm2pgsql_cluster_key column for later appends
datum_image_hash() prefix cache for varlena leading keys made the text geohash prefix cache direct-mapped, but Georgia 978 writes regressed to 2:10.79 create / 2:37.58 append with unchanged locality, so the code change was reverted
plain real-table geohash key with generated staging key correctness passed (0 NULL keys and 0 mismatches after append; real columns were plain), but Georgia 978 regressed to 3:42.88 create / 4:18.57 append with unchanged locality, so the checked-in patch stays with a stored generated real-table key
deleting from staged COPY tables without a temp delete-key index preserved the desired heap shape (2/2, 2/1, 3/2 p95 block/span for line/point/polygon), but each append DELETE scanned the growing staging table and Georgia 978 append regressed to 3:24.06; the kept variant adds a temporary delete-key index for append staging tables
one-opened-index-per-heap_multi_insert batch simplification simpler code, but the Georgia rerun did not beat the prior generated-geohash result (1:59.29 create, 1:56.57 append)
append only after clustered candidates are exhausted rejected as a standalone tweak: synthetic span metrics showed hot rows were already spread while consuming clustered-neighbour fillfactor reserve; local append-fallback prototypes left scale 1, hot 0.9 insert_hot spans unchanged (3737 blocks at fillfactor 90, 6596 at fillfactor 50)
single-row target-block cache for non-exhausted prefixes rejected after focused scale 1, hot 0 synthetic run: ordinary successful-prefix target caching made representative clustered inserts slower (313.52 -> 348.92 ms composite int ff90, 417.75 -> 465.84 ms composite int ff50, 284.92 -> 377.66 ms composite text ff90, 317.57 -> 398.11 ms single text ff90) without locality gain; raw gzip output is under /home/kom/tmp/clustered-write-synthetic/single-row-target-cache-focused-20260430-230337/
cached overflow leading-key metadata in rd_amcache rejected after focused scale 1, hot 0, repeat 3 ordered/non-ordered matrix: locality stayed good, but write timings were mixed/noisy and several representative clustered inserts regressed (418.44 -> 614.21 ms composite int ff50 unordered, 353.75 -> 548.84 ms composite text ff50 ordered, 275.28 -> 559.61 ms single text ff90 unordered); raw gzip output is under /home/kom/tmp/clustered-write-synthetic/overflow-metadata-order-nonhot-20260430-233011/
lowering overflow reserve-hit threshold from 8 to 4 rejected after focused scale 1, repeat 2, integer-key ordered/non-ordered matrix: sorted non-hot fillfactor 50 locality fell to 81.92% inside the base range where threshold 8 had restored 100%, fillfactor 90 sorted non-hot stayed below the kept 84.75% result at 81.91%, and hot span did not improve; raw gzip output is under /home/kom/tmp/clustered-write-synthetic/reserve-threshold4-focused-20260430-233816/
ordered diff input as the non-hot cost fix rejected as a standalone answer after scale 1, repeat 3, single-key hot=0: locality stayed in the same shape (84.75% vs 83.97% inside base range at fillfactor 90, 100% at fillfactor 50), but clustered insert medians were mixed and remained far above control; raw gzip output is under /home/kom/tmp/clustered-write-synthetic/current-nonhot-ordered-median-20260501-014236/
skipping clustered probes for singleton prefixes in a batch rejected after a focused scale 1, repeat 2, single-key hot=0 experiment: locality did not change in the synthetic matrix, timings were mixed/noisy rather than a reliable win, and the policy is risky for genuinely sparse diffs where a singleton may be the only locality-preserving probe; raw gzip output and the reverted patch are under /home/kom/tmp/clustered-write-synthetic/skip-singleton-prefix-experiment-20260501-015018/

The current lesson from the hot-prefix work is that duplicate-key batches need
either a genuinely local page-selection policy for the repeated key, enough
prefix grouping that the all-hot guard can keep overflow compact, or a clear
policy that refuses to spend old fillfactor reserve and starts compact overflow
when the clustered neighborhood is below the normal insert threshold. More
eager probes and extra per-tuple branches have repeatedly moved the cost curve
in the wrong direction.

Validation

  • Fresh upstream integration check: origin/master was at
    f67dbd8398, maumaps/master was still at the branch fork point, and this
    branch merged cleanly with origin/master in a temporary worktree. The
    fresh-master merge commit c28180090f built postgres, passed
    git diff --check, and passed
    make -C src/test/regress check-tests TESTS='test_setup copy create_misc create_table create_index cluster'
    (6/6 tests). A scale 0.02 synthetic temp-instance smoke on that merged
    build kept the intended locality shape: clustered insert 82.50% inside the
    base range versus 0.00% for the control, with gzip raw output and temporary
    pgdata removed under
    /home/kom/tmp/clustered-write-synthetic/fresh-master-smoke-20260501-181027/.
  • make -C src/backend/access/heap hio.o heapam.o -j2
  • make -C src/backend/commands repack.o -j2
  • make -C src/backend/utils/cache relcache.o -j2
  • make -C src/backend postgres -j2
  • make -C src/test/regress check-tests TESTS='test_setup copy create_misc create_table create_index cluster' including a COPY batch reorder test that verifies secondary index lookups after clustered multi-insert
  • PG18 benchmark worktree build/install after the btree/GiST contract cleanup
  • psql -v scale=1 -v use_brin=false -f src/tools/clustered_write_bench/osm2pgsql_diff.sql postgres after the insert-only clustered placement guard
  • real osm2pgsql/PostGIS benchmark on Georgia + planet daily diff through run_osm2pgsql_georgia_bench.sh
  • bash -n src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh
  • cmake --build build -j2 for the experimental osm2pgsql checkout after applying osm2pgsql_cluster_during_import.patch
  • git apply --check osm2pgsql_cluster_during_import.patch against a fresh osm2pgsql checkout
  • git diff --check for code/docs; patch files contain normal context-line spaces, so the patch file itself is checked with git apply --check
  • make -C src/backend/access/heap hio.o heapam.o -j2, make -C src/backend postgres -j2, the same regression slice, and the synthetic osm2pgsql-like benchmark after reserving both TID and page-candidate budget for clustered btree equality/prefix range tails
  • bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, git diff --check, make -C src/backend/access/heap hio.o -j2, make -C src/backend postgres -j2, and the regression slice after making clustered candidates respect target free space and adding the ordered-diff benchmark knob
  • USE_TEMP_INSTANCE=true PG_BINDIR=/tmp/postgres-clustered-write/tmp_install/usr/local/pgsql/bin REPEATS=1 SCALE_VALUES=0.1 BRIN_VALUES=false HEAP_FILLFACTOR_VALUES=90 HOT_TILE_FRACTION_VALUES=0.9 ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true" before/after smoke for the ordered-diff knob and target-free-space tail policy
  • USE_TEMP_INSTANCE=true PG_BINDIR=/tmp/postgres-clustered-write/tmp_install/usr/local/pgsql/bin REPEATS=1 SCALE_VALUES=1 BRIN_VALUES=false HEAP_FILLFACTOR_VALUES="90 50" HOT_TILE_FRACTION_VALUES=0.9 ORDER_DIFF_BY_CLUSTER_KEY_VALUES=false after the compact overflow fix
  • make -C src/backend/access/heap heapam.o -j2, make -C src/backend postgres -j2, regression slice, and git diff --check after comparator cleanup
  • make -C src/backend/access/heap heapam.o -j2, make -C src/backend/utils/cache relcache.o -j2, make -C src/backend postgres -j2, regression slice, and git diff --check after bounding clustered GiST sort memory
  • make -C src/backend/access/heap hio.o -j2, make -C src/backend postgres -j2, regression slice, and git diff --check after bounding clustered candidate helper writes
  • make -C src/backend/access/heap heapam.o -j2, make -C src/backend postgres -j2, regression slice, and git diff --check after initializing GiST INCLUDE attributes for clustered batch sort
  • scale=0.1 and scale=1 synthetic benchmark runs with use_brin=false and use_brin=true after adding structured timing and brin_enabled output; git diff --check; bash -n src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh
  • git diff --check, make -C src/backend/access/heap hio.o heapam.o -j2, make -C src/backend postgres -j2, and regression slice after reusing precomputed clustered multi-insert target blocks
  • scale=0.1 synthetic sanity run after precomputed target reuse: clustered insert 69.21 ms vs control insert 33.97 ms, with clustered insert locality 34.50% inside the base range vs 0% for control
  • bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, git diff --check, a live wrapper smoke with REPEATS=1 SCALE_VALUES=0.1 BRIN_VALUES=false, and a repeated REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false run showing stable locality but noisy timing ranges
  • git diff --check, make -C src/backend/access/heap heapam.o hio.o -j2, make -C src/backend postgres -j2, regression slice, and repeated REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false after adding the per-batch leading-key target cache
  • git diff --check, bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, make -C src/backend/access/heap heapam.o -j2, make -C src/backend postgres -j2, regression slice, USE_TEMP_INSTANCE=true PG_BINDIR=/tmp/postgres-clustered-write/tmp_install/usr/local/pgsql/bin REPEATS=1 SCALE_VALUES=0.1 BRIN_VALUES=false, and verified USE_TEMP_INSTANCE=true ... REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false after adding the by-value prefix hash cache and temp-instance benchmark mode
  • git diff --check, make -C src/backend/access/heap hio.o heapam.o -j2, make -C src/backend postgres -j2, regression slice, and verified USE_TEMP_INSTANCE=true ... REPEATS=5 SCALE_VALUES=1 BRIN_VALUES=false after making multi-insert batch target precomputation use cheap first-candidate probes
  • git diff --check, bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, make -C src/backend/access/heap heapam.o -j2, make -C src/backend postgres -j2, regression slice, USE_TEMP_INSTANCE=true ... REPEATS=1 SCALE_VALUES=0.1 BRIN_VALUES=false SINGLE_KEY_VALUES="false true", and verified USE_TEMP_INSTANCE=true ... REPEATS=5 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES=true after extending leading-key cache coverage to single-column clustered btree indexes
  • git diff --check, bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, make -C src/backend/access/heap hio.o heapam.o -j2, make -C src/backend postgres -j2, make install DESTDIR=/tmp/postgres-clustered-write/tmp_install -j2, USE_TEMP_INSTANCE=true ... REPEATS=1 SCALE_VALUES=0.1 BRIN_VALUES=false SINGLE_KEY_VALUES=true HOT_TILE_FRACTION_VALUES="0 0.9" smokes for the kept benchmark knob and the two rejected heap-placement experiments, and verified USE_TEMP_INSTANCE=true ... REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES=true HOT_TILE_FRACTION_VALUES="0 0.9" after keeping only the hot duplicate benchmark dimension
  • git diff --check, make -C src/backend/access/heap heapam.o -j2, make -C src/backend postgres -j2, make install DESTDIR=/tmp/postgres-clustered-write/tmp_install -j2, USE_TEMP_INSTANCE=true ... REPEATS=1 SCALE_VALUES=0.1 BRIN_VALUES=false SINGLE_KEY_VALUES=true HOT_TILE_FRACTION_VALUES="0 0.9", verified USE_TEMP_INSTANCE=true ... REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES=true HOT_TILE_FRACTION_VALUES="0 0.9", and regression slice after adding the hot-prefix pre-count guard
  • git diff --check, make -C src/backend/access/heap heapam.o -j2, make -C src/backend postgres -j2, make install DESTDIR=/tmp/postgres-clustered-write/tmp_install -j2, USE_TEMP_INSTANCE=true ... REPEATS=1 SCALE_VALUES=0.1 BRIN_VALUES=false SINGLE_KEY_VALUES=true HOT_TILE_FRACTION_VALUES="0 0.9 1", verified USE_TEMP_INSTANCE=true ... REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES=true HOT_TILE_FRACTION_VALUES="0 0.9 1", and regression slice after removing the second hot-prefix recheck pass and splitting hot/rest insert locality
  • git diff --check, make -C src/backend/access/heap heapam.o -j2, make -C src/backend postgres -j2, make install DESTDIR=/tmp/postgres-clustered-write/tmp_install -j2, USE_TEMP_INSTANCE=true ... REPEATS=1 SCALE_VALUES=0.1 BRIN_VALUES=false SINGLE_KEY_VALUES=true HOT_TILE_FRACTION_VALUES="0.9 1", verified USE_TEMP_INSTANCE=true ... REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES=true HOT_TILE_FRACTION_VALUES="0.9 1", USE_TEMP_INSTANCE=true ... REPEATS=1 SCALE_VALUES=0.1 BRIN_VALUES=false SINGLE_KEY_VALUES=true HOT_TILE_FRACTION_VALUES="0", regression slice, and a post-regression hot=1 smoke after short-circuiting fully hot equal-prefix batches
  • git diff --check and USE_TEMP_INSTANCE=true PG_BINDIR=/tmp/postgres-clustered-write/tmp_install/usr/local/pgsql/bin REPEATS=1 SCALE_VALUES=0.02 BRIN_VALUES=false HOT_TILE_FRACTION_VALUES=0 after bounding synthetic benchmark artifacts; the smoke left gzip-compressed raw output, logs, and TSV summaries while confirming pgdata was removed
  • git diff --check, make -C src/backend/access/heap heapam.o -j2, make -C src/backend postgres -j2, make install DESTDIR=/tmp/postgres-clustered-write/tmp_install -j2, regression slice, verified USE_TEMP_INSTANCE=true ... REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES=true HOT_TILE_FRACTION_VALUES="0 0.9 1", and post-rebuild REPEATS=2 ... HOT_TILE_FRACTION_VALUES="0.9 1" after bypassing prefix cache allocation for all-equal by-value batches
  • git diff --check, make -C src/backend/access/heap heapam.o -j2, make -C src/backend postgres -j2, regression slice, and USE_TEMP_INSTANCE=true ... REPEATS=1 SCALE_VALUES=0.1 BRIN_VALUES=false SINGLE_KEY_VALUES=true HOT_TILE_FRACTION_VALUES="0.9 1" after guarding raw prefix comparison with get_typbyval()
  • bash -n src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh, git diff --check, locally built PostGIS 3.7.0dev smoke on the PostgreSQL 19devel install, and full patched_stock + baseline_stock Georgia/osm2pgsql runs on planet daily diff 000/004/978.osc.gz after hardening diff simplification and same-install PostGIS handling
  • git diff --check, git apply --check osm2pgsql_cluster_during_import.patch against a clean osm2pgsql worktree, cmake --build build -j2 in the experimental osm2pgsql checkout, full patched_clustered_import Georgia/osm2pgsql runs before and after adding heap fillfactor=90, and a follow-up final-rewrite-by-key run confirming reloptions, osm2pgsql_cluster_key correlation 1, and improved heap-span locality
  • git diff --check, cmake --build build -j2, and two rejected full patched_clustered_import Georgia/osm2pgsql runs with bounded in-memory pre-COPY sort windows of 8192 and 65536 rows; both were reverted from the experiment after failing to repair generated-key correlation and making append much slower
  • git diff --check, git apply --check osm2pgsql_cluster_during_import.patch against a clean osm2pgsql worktree, cmake --build build -j2, a full generated-preserving rewrite run, and a catalog/null-key check confirming stored generated osm2pgsql_cluster_key columns survived append on all four OSM tables
  • git diff --check, heap/backend builds, regression slice, install repair for PostGIS/hstore after regression refreshed tmp_install, and a rejected full Georgia 978 run for the varlena datum_image_hash() prefix-cache experiment; the code change was reverted and logs were saved under 978-prefix-image-hash
  • git diff --check, bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, USE_TEMP_INSTANCE=true ... SCALE_VALUES=0.02 BRIN_VALUES=false SINGLE_KEY_VALUES=true TEXT_KEY_VALUES="false true" HOT_TILE_FRACTION_VALUES="0 0.9", USE_TEMP_INSTANCE=true ... SCALE_VALUES=0.02 BRIN_VALUES=true SINGLE_KEY_VALUES=true TEXT_KEY_VALUES=true, and a scale 0.1 integer/text comparison after adding the generated text-key synthetic benchmark mode
  • git diff --check, make -C src/backend/access/heap heapam.o -j2, make -C src/backend postgres -j2, regression slice, before/after USE_TEMP_INSTANCE=true ... REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES=true TEXT_KEY_VALUES=true HOT_TILE_FRACTION_VALUES=1, and follow-up TEXT_KEY_VALUES="false true" HOT_TILE_FRACTION_VALUES="0 0.9" after adding the text all-equal clustered batch guard and generated text-key regression coverage
  • git diff --check, bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, a scale 0.02 HEAP_FILLFACTOR_VALUES="90 70 50" smoke, and USE_TEMP_INSTANCE=true ... REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES=true TEXT_KEY_VALUES=false HEAP_FILLFACTOR_VALUES="90 70 50" HOT_TILE_FRACTION_VALUES=0.9 after adding the fillfactor benchmark dimension
  • git diff --check, bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, make -C src/backend/access/heap hio.o heapam.o -j2, make -C src/backend postgres -j2, make install DESTDIR=/tmp/postgres-clustered-write/tmp_install, regression slice, long-OUTDIR synthetic smoke, and scale 1 hot 0.9 span comparison after adding heap_block_span and moving temp-instance sockets under ${TMPDIR:-/tmp}
  • git diff --check, make -C src/backend/access/heap hio.o, regression slice, and focused synthetic temp-instance sweeps after restoring bounded below-fillfactor clustered reserve use. Scale 1, fillfactor 90, non-hot inserts recovered 83.97% inside the base clustered range versus 0% control; hot 0.9 rest inserts recovered 99.95% inside, while hot rows mostly follow control tail drift but keep a large span from the first reserve outlier.
  • git diff --check, make -C src/backend/access/heap hio.o, make install DESTDIR=/tmp/postgres-clustered-write/tmp_install, regression slice, USE_TEMP_INSTANCE=true ... REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES="false true" TEXT_KEY_VALUES=true HEAP_FILLFACTOR_VALUES="90 50" HOT_TILE_FRACTION_VALUES="0.9 1", and a same-backend CacheMemoryContext overwrite smoke after caching by-reference text overflow prefixes.
  • git diff --check, make -C src/backend/access/heap hio.o, make install DESTDIR=/tmp/postgres-clustered-write/tmp_install, regression slice, relcache-invalidation memory smoke, oversized 2KB text-prefix smoke, and USE_TEMP_INSTANCE=true ... REPEATS=2 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES="false true" TEXT_KEY_VALUES="false true" HEAP_FILLFACTOR_VALUES="90 50" HOT_TILE_FRACTION_VALUES="0 0.9 1" after moving by-reference cached prefixes inline inside the single rd_amcache chunk.
  • git diff --check, make -C src/backend/access/heap hio.o, make -C src/backend postgres, make install DESTDIR=/tmp/postgres-clustered-write/tmp_install, USE_TEMP_INSTANCE=true ... REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES="false true" TEXT_KEY_VALUES="false true" HEAP_FILLFACTOR_VALUES="90 50" HOT_TILE_FRACTION_VALUES=0 ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true", USE_TEMP_INSTANCE=true ... REPEATS=2 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES="false true" TEXT_KEY_VALUES="false true" HEAP_FILLFACTOR_VALUES="90 50" HOT_TILE_FRACTION_VALUES="0.9 1", a post-fix REPEATS=1 ordered/hot smoke, and regression slice after delaying tail-overflow activation for short reserve-using prefix groups. A standalone cluster regression invocation still fails because that test expects test_setup to have created tenk1; the full slice with test_setup passes.
  • git diff --check, bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, a scale 0.02 temp-instance smoke, and USE_TEMP_INSTANCE=true ... REPEATS=2 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES="false true" TEXT_KEY_VALUES=false HEAP_FILLFACTOR_VALUES="90 50" HOT_TILE_FRACTION_VALUES=0.9 ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true" after adding outside_base_heap_block_span to distinguish compact tail overflow from mixed reserve/tail full span.
  • git diff --check, bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, and USE_TEMP_INSTANCE=true ... SCALE_VALUES=0.02 HOT_TILE_FRACTION_VALUES=0 after adding run_environment.txt; the smoke verified git head/matrix/load/df capture and temp pgdata cleanup.
  • git diff --check, bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, and USE_TEMP_INSTANCE=true PG_BINDIR=/tmp/postgres-clustered-write/tmp_install/usr/local/pgsql/bin REPEATS=3 SCALE_VALUES=0.02 BRIN_VALUES=false SINGLE_KEY_VALUES=true TEXT_KEY_VALUES=false HEAP_FILLFACTOR_VALUES=90 HOT_TILE_FRACTION_VALUES=0 ORDER_DIFF_BY_CLUSTER_KEY_VALUES=false after adding median_elapsed_ms; the smoke verified correct median values, gzip raw output, and temp pgdata cleanup.
  • USE_TEMP_INSTANCE=true ... REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false SINGLE_KEY_VALUES=true TEXT_KEY_VALUES="false true" HEAP_FILLFACTOR_VALUES="90 50" HOT_TILE_FRACTION_VALUES="0 0.9 1" for the current median hot/non-hot baseline, and USE_TEMP_INSTANCE=true ... HOT_TILE_FRACTION_VALUES=0 ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true" for the ordered-input non-hot check; both removed temp pgdata and left gzip raw output under /home/kom/tmp/clustered-write-synthetic/current-*-median-*/.
  • make -C src/backend/access/heap heapam.o, make -C src/backend postgres, make install DESTDIR=/tmp/postgres-clustered-write/tmp_install, and USE_TEMP_INSTANCE=true ... REPEATS=2 SCALE_VALUES=1 SINGLE_KEY_VALUES=true TEXT_KEY_VALUES="false true" HEAP_FILLFACTOR_VALUES="90 50" HOT_TILE_FRACTION_VALUES=0 ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true" for the rejected singleton-prefix skip experiment; the experimental code was reverted and tmp_install was rebuilt from the clean PR branch afterwards.
  • bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, git diff --check, USE_TEMP_INSTANCE=true ... SCALE_VALUES=0.02 COPY_DIFF_FROM_FILE_VALUES="false true" smoke, and USE_TEMP_INSTANCE=true ... REPEATS=3 SCALE_VALUES=1 SINGLE_KEY_VALUES=true TEXT_KEY_VALUES="false true" HEAP_FILLFACTOR_VALUES="90 50" HOT_TILE_FRACTION_VALUES=0 ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true" COPY_DIFF_FROM_FILE_VALUES="false true" after adding the COPY-vs-INSERT synthetic diff benchmark; both temp-instance runs removed pgdata, the focused run left 48 gzip raw files under /home/kom/tmp/clustered-write-synthetic/copy-vs-insert-single-key-20260501-020036/.
  • git diff --check, make -C src/backend/access/heap heapam.o -j2, make -C src/backend postgres -j2, make install DESTDIR=/tmp/postgres-clustered-write/tmp_install -j2, USE_TEMP_INSTANCE=true ... SCALE_VALUES=0.02 COPY_DIFF_FROM_FILE_VALUES=true smoke, focused integer and text COPY boundary benchmarks, and regression slice test_setup copy create_misc create_table create_index cluster after stopping clustered COPY page packing at target boundaries; benchmark raw gzip outputs are under /home/kom/tmp/clustered-write-synthetic/copy-boundary-*-20260501-*/.
  • git diff --check, make -C src/backend/access/heap heapam.o -j2, make -C src/backend postgres -j2, regression slice test_setup copy create_misc create_table create_index cluster, focused generated text-key COPY benchmark, and integer COPY smoke after hashing normalized text payloads for clustered COPY prefix-cache slots; text medians improved from 1040.17/400.92/853.26/507.18 ms to 435.25/275.26/455.36/366.62 ms, raw gzip outputs are under /home/kom/tmp/clustered-write-synthetic/text-prefix-payload-hash-focused-20260501-022213/ and /home/kom/tmp/clustered-write-synthetic/prefix-payload-hash-integer-smoke-20260501-022359/.
  • git diff --check, make -C src/backend/access/heap hio.o -j2, make -C src/backend postgres -j2, regression slice test_setup copy create_misc create_table create_index cluster, focused non-hot INSERT ... SELECT benchmark, and hot 0.9/1.0 follow-up after making single-row clustered probes lazy; non-hot clustered insert medians fell into 212.22-446.83 ms with unchanged 83.97-100% locality, and hot medians stayed near control with the previous compact-tail shape. Raw gzip outputs are under /home/kom/tmp/clustered-write-synthetic/lazy-single-row-first-candidate-focused-20260501-023105/ and /home/kom/tmp/clustered-write-synthetic/lazy-single-row-first-candidate-hot-20260501-023331/.
  • git diff --check, a current INSERT+COPY scale 1, repeat 2 single-key matrix after the lazy single-row probe fix, and a rejected local MAX_BUFFERED_BYTES=262144 COPY experiment; the larger buffer preserved locality but regressed integer fillfactor 90 COPY medians and generated text fillfactor 90 ordered COPY, so the code experiment was reverted and only docs were kept. Raw gzip outputs are under /home/kom/tmp/clustered-write-synthetic/current-insert-copy-after-lazy-single-row-20260501-023819/ and /home/kom/tmp/clustered-write-synthetic/copy-buffer-256k-experiment-20260501-024252/.
  • git diff --check, make -C src/backend/access/heap heapam.o -j2, full make -C src/test/regress check TESTS=cluster regression schedule (All 245 tests passed), focused scale 1, repeat 3 copy-only matrix, and repeat 6 text ff50 ordered recheck after keeping same-target clustered COPY groups packed on fallback tail pages. Raw gzip outputs are under /home/kom/tmp/clustered-write-synthetic/copy-pack-same-target-20260501-024919/ and /home/kom/tmp/clustered-write-synthetic/copy-pack-text-ff50-ordered-rerun-20260501-025142/.
  • git diff --check, make -C src/backend/access/heap heapam.o -j2, make -C src/backend postgres -j2, make install DESTDIR=/tmp/postgres-clustered-write/tmp_install -j2, a focused generated text-key COPY matrix, and a repeat 6 text ff50 unordered recheck for a rejected direct prefixCountSlots target-cache shortcut; the shortcut improved text ff90 COPY medians but regressed text ff50 unordered to 483.62 ms, so the code experiment was reverted and only docs were kept. Raw gzip outputs are under /home/kom/tmp/clustered-write-synthetic/direct-prefix-target-cache-text-copy-20260501-025740/ and /home/kom/tmp/clustered-write-synthetic/direct-prefix-target-cache-text-ff50-unordered-rerun-20260501-025849/.
  • git diff --check, make -C src/backend/access/heap heapam.o -j2, make -C src/backend postgres -j2, make install DESTDIR=/tmp/postgres-clustered-write/tmp_install -j2, and a rejected USE_TEMP_INSTANCE=true ... REPEATS=3 SCALE_VALUES=1 ... COPY_DIFF_FROM_FILE_VALUES=true non-hot COPY run for a lazy prefix-count allocation prototype; the prototype crashed with SIGSEGV on the first integer fillfactor 90 unordered COPY case, so the code was reverted and the install was rebuilt from the clean branch. Crash log and raw gzip output are under /home/kom/tmp/clustered-write-synthetic/lazy-prefix-count-nonhot-copy-20260501-030413/.
  • USE_TEMP_INSTANCE=true ... SCALE_VALUES=0.02 ... COPY_DIFF_FROM_FILE_VALUES=true clean-install smoke after reverting the lazy prefix-count prototype; it completed, gzip-compressed raw output, and removed temporary pgdata. Raw gzip output is under /home/kom/tmp/clustered-write-synthetic/post-lazy-prefix-clean-copy-smoke-20260501-030920/.
  • git diff --check, make -C src/backend/access/heap heapam.o -j2, make -C src/backend postgres -j2, make install DESTDIR=/tmp/postgres-clustered-write/tmp_install -j2, a rejected scale 1, repeat 3 COPY-only matrix, and a repeat 6 generated text-key unordered recheck for a prefix-count hash-slot reuse prototype. The narrower shortcut kept locality unchanged and improved some text cases in the first matrix, but integer fillfactor 90 regressed and the repeat 6 text unordered recheck measured ff90 at 410.60 ms versus the kept 324.91 ms, so the code was reverted and the install was rebuilt from the clean branch. Raw gzip outputs are under /home/kom/tmp/clustered-write-synthetic/reuse-prefix-hash-slot-copy-20260501-031244/ and /home/kom/tmp/clustered-write-synthetic/reuse-prefix-hash-slot-text-unordered-rerun-20260501-031521/.
  • git diff --check, bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, make -C src/backend/access/heap heapam.o hio.o -j2, make -C src/backend postgres -j2, make install DESTDIR=/tmp/postgres-clustered-write/tmp_install -j2, a pre-fix multi-hot COPY crash reproduction, repaired scale 1, repeat 3, HOT_TILE_COUNT_VALUES="1 8" COPY benchmark, HOT_TILE_COUNT_VALUES=128 overflow-shape follow-up, a final scale 0.02 hot-tile-count smoke after adding the benchmark axis, and make -C src/test/regress check TESTS='test_setup copy create_misc create_table create_index cluster' (the make target ran the full parallel schedule; all 245 tests passed).
  • git diff --check, make -C src/backend/access/heap heapam.o -j2, make -C src/backend postgres -j2, make install DESTDIR=/tmp/postgres-clustered-write/tmp_install -j2, a rejected scale 1, repeat 3, HOT_TILE_COUNT_VALUES=128 COPY experiment lowering CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES from 16 to 4, and a post-revert scale 0.02 COPY smoke; the prototype regressed unordered/ordered clustered COPY medians from 140.79/86.61 ms to 200.88/129.12 ms, so the code was reverted and only the README note was kept.
  • git diff --check, bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, USE_TEMP_INSTANCE=true ... SCALE_VALUES=0.02 TEXT_KEY_VALUES="false true" COPY_DIFF_FROM_FILE_VALUES=true smoke after adding hot read timings, plus a rejected btree leading-key sort prototype for skipped too-hot COPY prefixes. The prototype showed a possible unordered read win (12.92 ms vs clean rerun 15.70 ms) and a compact unordered tail, but did not beat the earlier clean write baseline (157.76 ms vs 140.79 ms) and added heap comparator complexity, so the code was reverted and only the read benchmark was kept. Raw gzip outputs are under /home/kom/tmp/clustered-write-synthetic/read-hot-smoke-20260501-035507/, /home/kom/tmp/clustered-write-synthetic/btree-prefix-sort-read-ab-proto-20260501-035531/, and /home/kom/tmp/clustered-write-synthetic/btree-prefix-sort-read-ab-clean-20260501-035714/.
  • git diff --check, clean rebuild/install into tmp_install, regression slice test_setup copy create_misc create_table create_index cluster, USE_TEMP_INSTANCE=true ... SCALE_VALUES=0.1 HOT_TILE_COUNT_VALUES=16 COPY_DIFF_FROM_FILE_VALUES=true smoke, and a rejected moved-update clustered-placement prototype after adding read_updated_hot. The prototype kept lock ordering intact but regressed update medians to 1191.28/1072.18 ms clustered versus 784.22/889.52 ms control without improving update locality enough, so the heap code was reverted and only the update-read benchmark and README note were kept. Raw gzip outputs are under /home/kom/tmp/clustered-write-synthetic/update-placement-proto-20260501-040654/ and /home/kom/tmp/clustered-write-synthetic/update-read-clean-smoke-20260501-040857/.
  • bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, git diff --check, USE_TEMP_INSTANCE=true ... HOT_UPDATE_FRACTION_VALUES="0.75" UPDATES_BEFORE_INSERTS_VALUES="false true" SCALE_VALUES=0.02 smoke, focused scale 1, repeat 3, fillfactor 90/50 hot-update matrix, and focused update-before A/B after adding hot-update and workload-order axes. The matrix showed fillfactor 50 keeps ordinary updates mostly local (95.13% clustered) but hot-biased updates still fail (update_hot 0% clustered, 15.63% control). Update-before improved clustered update_hot to 15.63% but regressed clustered insert_hot from 28.31% to 1.42% and insert median from 204.08 ms to 345.64 ms, so it is recorded as a rejected standalone fix. Raw gzip outputs are under /home/kom/tmp/clustered-write-synthetic/hot-update-fillfactor-20260501-041447/, /home/kom/tmp/clustered-write-synthetic/update-before-ab-20260501-041913/, and /home/kom/tmp/clustered-write-synthetic/update-axis-final-smoke-20260501-042100/.
  • git diff --check, make -s check-tests TESTS="test_setup copy create_misc create_table create_index cluster", and focused scale 1, repeat 3 COPY benchmarks after skipping FSM lookup for batches already classified as too-hot for clustered target probing. This did not change locality (update_hot still 0% in the hot-update case), but improved dense COPY write cost in the measured cases: fillfactor 50, hot_tile_count=128, hot_update_fraction=0.9 clustered insert median 204.08 ms -> 163.53 ms, and single-hot-key fillfactor 90 clustered insert median 49.96 ms versus 38.81 ms control. Raw gzip outputs are under /home/kom/tmp/clustered-write-synthetic/skip-fsm-hot-prefix-proto-20260501-131256/ and /home/kom/tmp/clustered-write-synthetic/skip-fsm-hot-prefix-single-20260501-131326/.
  • bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, git diff --check, a scale 0.02 temp-instance smoke, and two focused scale 1, repeat 2 COPY matrices after adding UPDATE_PAYLOAD_REPEAT_VALUES plus avg_rows_measured in locality_summary.tsv. The hot-update candidate count is now visible (6272 rows at scale 1, hot_tile_count=128). With inserts first, clustered update_hot stayed 0% inside base for payload repeats 8/16/32/64, while the control degraded with payload size (87.50% -> 15.63%). With updates first, payload 8 restored clustered update_hot to 87.50%, but clustered insert_hot fell from 28.31% to 0%, so payload size and phase order are diagnostic axes rather than standalone fixes. Raw gzip outputs are under /home/kom/tmp/clustered-write-synthetic/update-payload-axis-smoke-20260501-132633/, /home/kom/tmp/clustered-write-synthetic/update-payload-hot-matrix-20260501-132655/, and /home/kom/tmp/clustered-write-synthetic/update-payload-before-after-20260501-132803/.
  • make -s -C src/backend/access/heap hio.o, backend rebuild/install, bash -n src/tools/clustered_write_bench/run_synthetic_bench.sh, git diff --check, a focused hot-update COPY A/B, a non-hot/hot COPY insert sweep, and a post-revert temp-instance smoke for a rejected prototype that disabled below-fillfactor clustered reserve use for all bulk/COPY clustered targets. The prototype proved the conflict: payload 8 recovered clustered update_hot from 0% to 87.50%, and payload 64 to the control-like 15.63%. It was still too broad: clustered insert_hot fell to 0% inside base, and ordinary COPY insert locality also fell to 0% for fillfactor 90/50, with worse spans than control in several cases. The heap code was reverted and only README/PR notes were kept. Raw gzip outputs are under /home/kom/tmp/clustered-write-synthetic/no-bulk-reserve-proto-20260501-133332/, /home/kom/tmp/clustered-write-synthetic/no-bulk-reserve-nonhot-proto-20260501-133415/, and /home/kom/tmp/clustered-write-synthetic/post-no-bulk-reserve-revert-smoke-20260501-133741/.
  • USE_TEMP_INSTANCE=true ... REPEATS=3 SCALE_VALUES=1 HOT_TILE_FRACTION_VALUES=0.9 HOT_TILE_COUNT_VALUES=128 HOT_UPDATE_FRACTION_VALUES=0.9 UPDATE_PAYLOAD_REPEAT_VALUES="8 64" ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true" COPY_DIFF_FROM_FILE_VALUES=true after the reserve experiments to retest ordered COPY specifically under hot insert+update pressure. Ordered input is not a non-hot cost fix, but in this hot-update shape it looks useful: payload 8 recovered clustered update_hot from 0% to 86.88%, cut clustered insert median from 350.89 ms to 107.58 ms, and improved broad hot reads (read_hot median 22.02 ms -> 14.77 ms). Payload 64 recovered update_hot from 0% to 15.50% and cut clustered insert median from 249.40 ms to 129.00 ms. The explicit tradeoff is that insert_hot mostly moves to a compact tail (0.21% inside base), so this is a real osm2pgsql diff ordering direction to benchmark rather than a heap-locality win for new duplicate-key rows. Raw gzip output is under /home/kom/tmp/clustered-write-synthetic/hot-update-order-copy-ab-20260501-133942/.
  • USE_TEMP_INSTANCE=true ... REPEATS=2 SCALE_VALUES=1 TEXT_KEY_VALUES=true HOT_TILE_FRACTION_VALUES=0.9 HOT_TILE_COUNT_VALUES=128 HOT_UPDATE_FRACTION_VALUES=0.9 UPDATE_PAYLOAD_REPEAT_VALUES="8 64" ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true" COPY_DIFF_FROM_FILE_VALUES=true to check whether the ordered-COPY hot-update result transfers to generated text/geohash-like clustered keys. It does: payload 8 recovered clustered update_hot from 0% to 86.88%, cut clustered insert median from 370.74 ms to 134.47 ms, and improved read_updated_hot from 48.67 ms to 27.56 ms; payload 64 recovered update_hot from 0% to 15.50% and cut insert median from 347.72 ms to 135.43 ms. Broad read_hot was noisier for text payload 64, so the next real-osm2pgsql experiment should benchmark both append write time and read slices. Raw gzip output is under /home/kom/tmp/clustered-write-synthetic/hot-update-order-text-copy-ab-20260501-134352/.
  • bash -n src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh, git diff --check, an append-only Georgia 978 slowlog run, and a follow-up single-read run after exposing the real benchmark PG_WORK_MEM knob; the tested PGOPTIONS='-c work_mem=256MB' setting is now available as PG_WORK_MEM=256MB.

Follow-ups

  • add a BRIN/range candidate path if PostgreSQL ever wants clustered-write placement from bitmap/range AMs rather than tuple scans

  • continue measuring the remaining clustered insert write cost against real osm2pgsql diffs; the current best direction is lazy or page-grouped candidate discovery rather than eager all-candidates-per-tuple work

  • continue reducing the remaining hot duplicate-key cost before claiming osm2pgsql diff imports are solved for heavily repeated geometry keys; the pre-count/short-circuit guards remove a large part of the cliff, but HOT_TILE_FRACTION_VALUES="0.9" is still far slower than control for only a small hot-row locality win

  • investigate a real overflow/range policy or prefix-grouped insertion path for massive duplicate-key diffs; the fillfactor and span sweeps show heap reserve helps moved updates but does not keep thousands of new rows local or compact when the original clustered key range is tiny

  • move the osm2pgsql experiment's final key-order rewrite into a global/external sort before COPY, then rerun the real Georgia + planet-diff matrix to see whether it keeps the locality/write win without the full rewrite and larger heap footprint

  • consider a more precise nearest-neighbour path for GiST opclasses that expose distance ordering

  • decide whether clustered writes should always follow indisclustered automatically or be controlled by a table option

  • Updated osm2pgsql_cluster_during_import.patch from a post-import generated-key rewrite to an ordered staged-COPY experiment. COPY rows now go into per-target temporary tables created LIKE the real target INCLUDING GENERATED, then drain into the real heap with ORDER BY osm2pgsql_cluster_key, osm_id on sync. A short create+append smoke on system PG18/PostGIS verified generated keys stay stored/non-NULL and the clustered btree marker survives. The first per-target Georgia + planet daily diff 978 run on patched PG19/PostGIS 3.7 measured 3:15.36 create, 3:10.19 append, 2793 MB database size, reads of 329.192 ms point bbox / 1140.398 ms line bbox / 1849.375 ms polygon bbox / 182.531 ms roads exact / 3151.490 ms polygon exact, and p95 block/span 3/2647.75 line, 3/1069 point, 3/1657 polygon. Deferring the clustered btree until after the initial staged drain improved the same setup to 2:10.01 create, 2:45.90 append, 2837 MB, reads of 190.026 ms point bbox / 666.042 ms line bbox / 1106.330 ms polygon bbox / 1890.598 ms roads exact / 1976.231 ms polygon exact, and p95 block/span 3/2367.75 line, 3/1024 point, 3/1643 polygon. This is better than the rejected single-temp-table fragment staging (6:06 create, 6:08 append, long spans) and enough to keep deferred index creation, but still not good enough: write time remains worse than the previous generated-preserving rewrite target. Raw gzip logs are under /home/kom/tmp/clustered-write-osm2pgsql-ordered-deferred-index-20260501-143958/ and /home/kom/tmp/clustered-write-osm2pgsql-ordered-target-stage-20260501-142615/; the rejected fragment-staging run is under /home/kom/tmp/clustered-write-osm2pgsql-ordered-stage-20260501-135818/.

  • Rejected a plain-real-key osm2pgsql variant after benchmarking. It made the real osm2pgsql_cluster_key a plain text column and kept the staging table generated, so the ordered drain could copy the already-computed key instead of recomputing it in the real table. Correctness passed on the Georgia 978 run (0 NULL keys and 0 mismatches on point/line/polygon/roads; real columns were plain), but write time regressed to 3:42.88 create / 4:18.57 append with unchanged p95 block/span (3/2367.75, 3/1024, 3/1643). Reads were mixed: roads exact improved to 462.948 ms, but bbox reads slowed and polygon exact regressed to 3509.093 ms. The code experiment was reverted; only this negative result is documented. Raw gzip logs are under /home/kom/tmp/clustered-write-osm2pgsql-plain-key-stage-20260501-145922/.

@Komzpa
Komzpa force-pushed the codex/clustered-write-insert-prototype branch from 411f79f to c771090 Compare April 29, 2026 17:23
@Komzpa Komzpa changed the title feat(heap): prototype clustered write placement feat(heap): place inserts by clustered index order Apr 29, 2026
@Komzpa
Komzpa force-pushed the codex/clustered-write-insert-prototype branch 15 times, most recently from 36f15f8 to ff79bbf Compare April 30, 2026 00:12
@Komzpa
Komzpa force-pushed the codex/clustered-write-insert-prototype branch from ff79bbf to 4a5adf8 Compare April 30, 2026 00:15
Komzpa added 30 commits May 1, 2026 02:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant