From 4a5adf800bb449b30ce1088c9563db78e028d882 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Wed, 29 Apr 2026 21:17:25 +0400 Subject: [PATCH 01/81] feat(heap): place inserts by clustered index order --- doc/src/sgml/ref/cluster.sgml | 12 + src/backend/access/heap/heapam.c | 277 +++++++++++- src/backend/access/heap/hio.c | 406 +++++++++++++++++- src/backend/commands/repack.c | 15 +- src/backend/utils/cache/relcache.c | 38 +- src/include/access/hio.h | 7 + src/include/utils/rel.h | 5 +- src/include/utils/relcache.h | 1 + src/test/regress/expected/cluster.out | 69 +++ src/test/regress/sql/cluster.sql | 59 +++ src/tools/clustered_write_bench/README | 107 +++++ .../osm2pgsql_cluster_during_import.patch | 130 ++++++ .../clustered_write_bench/osm2pgsql_diff.sql | 227 ++++++++++ .../osm2pgsql_georgia_read.sql | 114 +++++ .../run_osm2pgsql_georgia_bench.sh | 306 +++++++++++++ 15 files changed, 1758 insertions(+), 15 deletions(-) create mode 100644 src/tools/clustered_write_bench/README create mode 100644 src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch create mode 100644 src/tools/clustered_write_bench/osm2pgsql_diff.sql create mode 100644 src/tools/clustered_write_bench/osm2pgsql_georgia_read.sql create mode 100755 src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh diff --git a/doc/src/sgml/ref/cluster.sgml b/doc/src/sgml/ref/cluster.sgml index ffb3ff898c69a..2f162a875ebb1 100644 --- a/doc/src/sgml/ref/cluster.sgml +++ b/doc/src/sgml/ref/cluster.sgml @@ -109,6 +109,18 @@ CLUSTER [ ( option [, ...] ) ] [ + + When a table has a remembered clustered btree index, later row insertions + whose leading clustered key is available try to place new heap tuples near + existing heap pages found from that clustered key before falling back to the + normal free-space and relation-extension placement rules. Bulk inserts can + also use access-method-specific ordering support, such as GiST sort support, + when it is available. These heuristics reduce how quickly freshly inserted + rows drift away from the clustered order, but they are only best-effort; + updates, deletes, page-level free space, and concurrent writes can still + make periodic reclustering useful. + + Each backend running CLUSTER will report its progress in the pg_stat_progress_cluster view. See diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index abfd8e8970a60..fbc707db658b3 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -31,6 +31,9 @@ */ #include "postgres.h" +#include "access/genam.h" +#include "access/gist.h" +#include "access/gist_private.h" #include "access/heapam.h" #include "access/heaptoast.h" #include "access/hio.h" @@ -40,8 +43,10 @@ #include "access/valid.h" #include "access/visibilitymap.h" #include "access/xloginsert.h" +#include "catalog/pg_am_d.h" #include "catalog/pg_database.h" #include "catalog/pg_database_d.h" +#include "catalog/pg_index.h" #include "commands/vacuum.h" #include "executor/instrument_node.h" #include "pgstat.h" @@ -53,12 +58,38 @@ #include "utils/datum.h" #include "utils/injection_point.h" #include "utils/inval.h" +#include "utils/relcache.h" +#include "utils/sortsupport.h" #include "utils/spccache.h" #include "utils/syscache.h" +typedef struct HeapTupleClusteredWriteItem +{ + HeapTuple tuple; + BlockNumber targetBlock; + Datum clusterValues[INDEX_MAX_KEYS]; + bool clusterIsNull[INDEX_MAX_KEYS]; + bool hasClusterKey; + int inputIndex; +} HeapTupleClusteredWriteItem; + +typedef struct HeapTupleClusteredSortContext +{ + int nkeys; + SortSupportData sortKeys[INDEX_MAX_KEYS]; +} HeapTupleClusteredSortContext; + static HeapTuple heap_prepare_insert(Relation relation, HeapTuple tup, TransactionId xid, CommandId cid, uint32 options); +static int heap_clustered_write_item_cmp(const void *a, const void *b, void *arg); +static bool heap_clustered_write_index_can_sort(Relation relation, + Relation indexRelation); +static int heap_prepare_clustered_write_sort(Relation relation, + Relation indexRelation, + HeapTupleClusteredWriteItem *items, + int ntuples, + HeapTupleClusteredSortContext *context); static XLogRecPtr log_heap_update(Relation reln, Buffer oldbuf, Buffer newbuf, HeapTuple oldtup, HeapTuple newtup, HeapTuple old_key_tuple, @@ -112,6 +143,149 @@ static XLogRecPtr log_heap_new_cid(Relation relation, HeapTuple tup); static HeapTuple ExtractReplicaIdentity(Relation relation, HeapTuple tp, bool key_required, bool *copy); +static int +heap_clustered_write_item_cmp(const void *a, const void *b, void *arg) +{ + const HeapTupleClusteredWriteItem *left = (const HeapTupleClusteredWriteItem *) a; + const HeapTupleClusteredWriteItem *right = (const HeapTupleClusteredWriteItem *) b; + HeapTupleClusteredSortContext *context = (HeapTupleClusteredSortContext *) arg; + + if (context != NULL && context->nkeys > 0 && + left->hasClusterKey && right->hasClusterKey) + { + for (int i = 0; i < context->nkeys; i++) + { + int compare; + + compare = ApplySortComparator(left->clusterValues[i], + left->clusterIsNull[i], + right->clusterValues[i], + right->clusterIsNull[i], + &context->sortKeys[i]); + if (compare != 0) + return compare; + } + } + else if (left->hasClusterKey && !right->hasClusterKey) + return -1; + else if (!left->hasClusterKey && right->hasClusterKey) + return 1; + + if (left->targetBlock == InvalidBlockNumber && + right->targetBlock != InvalidBlockNumber) + return 1; + if (left->targetBlock != InvalidBlockNumber && + right->targetBlock == InvalidBlockNumber) + return -1; + if (left->targetBlock < right->targetBlock) + return -1; + if (left->targetBlock > right->targetBlock) + return 1; + + return left->inputIndex - right->inputIndex; +} + +static bool +heap_clustered_write_index_can_sort(Relation relation, Relation indexRelation) +{ + int nkeys; + + if (IsBootstrapProcessingMode() || indexRelation == NULL) + return false; + + if (indexRelation->rd_index->indrelid != RelationGetRelid(relation) || + indexRelation->rd_rel->relam != GIST_AM_OID || + !indexRelation->rd_index->indisvalid || + !indexRelation->rd_index->indisready || + !heap_attisnull(indexRelation->rd_indextuple, + Anum_pg_index_indexprs, NULL) || + !heap_attisnull(indexRelation->rd_indextuple, + Anum_pg_index_indpred, NULL)) + return false; + + nkeys = indexRelation->rd_index->indnkeyatts; + if (nkeys <= 0 || nkeys > INDEX_MAX_KEYS) + return false; + + for (int i = 0; i < nkeys; i++) + { + if (indexRelation->rd_index->indkey.values[i] <= 0) + return false; + + if (!OidIsValid(index_getprocid(indexRelation, i + 1, + GIST_SORTSUPPORT_PROC))) + return false; + } + + return true; +} + +/* + * Prepare per-batch ordering by the remembered clustered key. + * + * GiST opclasses can participate when they provide the sort support function + * used by sorted GiST index builds; compress values first so the comparator + * sees the same representation as the GiST build tuplesort path. The + * target-block probe remains the fallback for btree and other AMs. + */ +static int +heap_prepare_clustered_write_sort(Relation relation, + Relation indexRelation, + HeapTupleClusteredWriteItem *items, + int ntuples, + HeapTupleClusteredSortContext *context) +{ + GISTSTATE *giststate = NULL; + int nkeys; + + context->nkeys = 0; + + if (!heap_clustered_write_index_can_sort(relation, indexRelation)) + return 0; + + nkeys = indexRelation->rd_index->indnkeyatts; + + for (int i = 0; i < nkeys; i++) + { + SortSupport sortKey = &context->sortKeys[i]; + + memset(sortKey, 0, sizeof(SortSupportData)); + sortKey->ssup_cxt = CurrentMemoryContext; + sortKey->ssup_collation = indexRelation->rd_indcollation[i]; + sortKey->ssup_nulls_first = false; + sortKey->ssup_attno = i + 1; + sortKey->abbreviate = false; + + PrepareSortSupportFromGistIndexRel(indexRelation, sortKey); + } + + giststate = initGISTstate(indexRelation); + + for (int i = 0; i < ntuples; i++) + { + items[i].hasClusterKey = true; + + for (int key = 0; key < nkeys; key++) + { + AttrNumber attnum = indexRelation->rd_index->indkey.values[key]; + + items[i].clusterValues[key] = + heap_getattr(items[i].tuple, attnum, relation->rd_att, + &items[i].clusterIsNull[key]); + } + + gistCompressValues(giststate, indexRelation, + items[i].clusterValues, + items[i].clusterIsNull, true, + items[i].clusterValues); + } + + context->nkeys = nkeys; + freeGISTstate(giststate); + + return nkeys; +} + /* * This table lists the heavyweight lock mode that corresponds to each tuple @@ -2030,6 +2204,7 @@ heap_insert(Relation relation, HeapTuple tup, CommandId cid, * this will also pin the requisite visibility map page. */ buffer = RelationGetBufferForTuple(relation, heaptup->t_len, + heaptup, InvalidBuffer, options, bistate, &vmbuffer, NULL, 0); @@ -2284,6 +2459,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, { TransactionId xid = GetCurrentTransactionId(); HeapTuple *heaptuples; + int *heaptuple_slot_indexes = NULL; int i; int ndone; PGAlignedBlock scratch; @@ -2319,6 +2495,92 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, options); } + /* + * Clustered multi-inserts work best when tuples targeting the same + * already-clustered heap area are adjacent. Compute candidate blocks + * before entering the critical insertion loop, then keep tuples without a + * clustered-index target in input order behind the clustered groups. + */ + if ((options & HEAP_INSERT_SKIP_FSM) == 0 && ntuples > 1) + { + HeapTupleClusteredWriteItem *clustered; + HeapTupleClusteredSortContext sortContext; + Oid clusteredIndexOid = InvalidOid; + Relation clusteredIndexRelation = NULL; + bool use_clustered_target_probe = false; + bool use_clustered_sort = false; + bool has_clustered_target = false; + bool has_clustered_sort = false; + + if (!IsBootstrapProcessingMode()) + { + clusteredIndexOid = RelationGetClusteredIndex(relation); + if (OidIsValid(clusteredIndexOid)) + clusteredIndexRelation = + try_index_open(clusteredIndexOid, AccessShareLock); + } + if (clusteredIndexRelation != NULL && + clusteredIndexRelation->rd_rel->relam == BTREE_AM_OID && + heap_attisnull(clusteredIndexRelation->rd_indextuple, + Anum_pg_index_indexprs, NULL)) + use_clustered_target_probe = true; + else if (heap_clustered_write_index_can_sort(relation, + clusteredIndexRelation)) + use_clustered_sort = true; + + if (use_clustered_target_probe || use_clustered_sort) + { + clustered = palloc_array(HeapTupleClusteredWriteItem, ntuples); + for (i = 0; i < ntuples; i++) + { + clustered[i].tuple = heaptuples[i]; + clustered[i].hasClusterKey = false; + clustered[i].targetBlock = InvalidBlockNumber; + if (use_clustered_target_probe) + { + BlockNumber targetBlock; + + if (RelationGetClusteredTargetBlocksFromIndex(relation, + clusteredIndexRelation, + heaptuples[i], + heaptuples[i]->t_len, + &targetBlock, + 1) > 0) + clustered[i].targetBlock = targetBlock; + } + clustered[i].inputIndex = i; + if (clustered[i].targetBlock != InvalidBlockNumber) + has_clustered_target = true; + } + + if (use_clustered_sort) + has_clustered_sort = + heap_prepare_clustered_write_sort(relation, + clusteredIndexRelation, + clustered, ntuples, + &sortContext) > 0; + + if (has_clustered_target || has_clustered_sort) + { + heaptuple_slot_indexes = palloc_array(int, ntuples); + qsort_arg(clustered, ntuples, + sizeof(HeapTupleClusteredWriteItem), + heap_clustered_write_item_cmp, + has_clustered_sort ? &sortContext : NULL); + for (i = 0; i < ntuples; i++) + { + heaptuples[i] = clustered[i].tuple; + heaptuple_slot_indexes[i] = clustered[i].inputIndex; + } + } + + pfree(clustered); + } + + if (clusteredIndexRelation != NULL) + index_close(clusteredIndexRelation, AccessShareLock); + } + /* * We're about to do the actual inserts -- but check for conflict first, * to minimize the possibility of having to roll back work we've just @@ -2381,6 +2643,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, * empty page. See all_frozen_set below. */ buffer = RelationGetBufferForTuple(relation, heaptuples[ndone]->t_len, + heaptuples[ndone], InvalidBuffer, options, bistate, &vmbuffer, NULL, npages - npages_used); @@ -2640,8 +2903,17 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, } /* copy t_self fields back to the caller's slots */ - for (i = 0; i < ntuples; i++) - slots[i]->tts_tid = heaptuples[i]->t_self; + if (heaptuple_slot_indexes != NULL) + { + for (i = 0; i < ntuples; i++) + slots[heaptuple_slot_indexes[i]]->tts_tid = heaptuples[i]->t_self; + pfree(heaptuple_slot_indexes); + } + else + { + for (i = 0; i < ntuples; i++) + slots[i]->tts_tid = heaptuples[i]->t_self; + } pgstat_count_heap_insert(relation, ntuples); } @@ -3906,6 +4178,7 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, { /* It doesn't fit, must use RelationGetBufferForTuple. */ newbuf = RelationGetBufferForTuple(relation, heaptup->t_len, + NULL, buffer, 0, NULL, &vmbuffer_new, &vmbuffer, 0); diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index e96e0f77d9264..8d9c6013edcfd 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -15,14 +15,42 @@ #include "postgres.h" +#include "access/amapi.h" +#include "access/genam.h" #include "access/heapam.h" #include "access/hio.h" #include "access/htup_details.h" #include "access/visibilitymap.h" +#include "catalog/pg_am_d.h" +#include "catalog/pg_index.h" +#include "miscadmin.h" #include "storage/bufmgr.h" #include "storage/freespace.h" #include "storage/lmgr.h" +#include "utils/lsyscache.h" +#include "utils/relcache.h" +#include "utils/snapmgr.h" +/* + * Keep clustered-write placement bounded. Large duplicate-key ranges can + * span many heap pages; probing a small number of distinct candidate pages is + * enough to avoid the worst "first equal tuple is on a full page" case without + * turning every insert into a long index walk. + */ +#define CLUSTERED_WRITE_MAX_INDEX_TIDS 1024 +#define CLUSTERED_WRITE_MAX_HEAP_BLOCKS 32 + +static bool ClusteredWriteRememberCandidate(Relation relation, + BlockNumber nblocks, + ItemPointer tid, + BlockNumber *candidates, + Size *candidateFreeSpace, + int *ncandidates); +static int RelationGetClusteredTargetBlocksForTuple(Relation relation, + HeapTuple tuple, + Size len, + BlockNumber *targetBlocks, + int maxTargetBlocks); /* * RelationPutHeapTuple - place tuple at specified page @@ -79,6 +107,312 @@ RelationPutHeapTuple(Relation relation, } } +/* + * Remember a distinct heap block found through the clustered index. The + * caller still rechecks page space under the buffer lock before inserting. + */ +static bool +ClusteredWriteRememberCandidate(Relation relation, BlockNumber nblocks, + ItemPointer tid, BlockNumber *candidates, + Size *candidateFreeSpace, int *ncandidates) +{ + BlockNumber candidate; + + if (tid == NULL) + return false; + + candidate = ItemPointerGetBlockNumber(tid); + if (candidate == InvalidBlockNumber || candidate >= nblocks) + return false; + + for (int i = 0; i < *ncandidates; i++) + { + if (candidates[i] == candidate) + return false; + } + + candidates[*ncandidates] = candidate; + candidateFreeSpace[*ncandidates] = GetRecordedFreeSpace(relation, + candidate); + (*ncandidates)++; + + return true; +} + +/* + * RelationGetClusteredTargetBlocksFromIndex + * + * For relations with CLUSTER metadata, try to use the clustered index to find + * heap blocks that are already close in the relation's clustered order. A + * btree key probe can find equal-key neighbours directly; if the full key has + * no matches, shorter left-prefix probes can still find the existing key + * group for composite indexes such as (tile_id, osm_id). + * + * Do not use an unqualified clustered-index scan as an insertion hint. For + * GiST, expression indexes, and btree rows whose leading key is NULL, such a + * scan is not anchored to the tuple being inserted and tends to find the same + * candidate pages for unrelated tuples. GiST can still improve multi-inserts + * through the batch sort path in heapam.c when the opclass exposes sort + * support. + * + * Partial clustered indexes are rejected by CLUSTER, but guard against them + * here anyway in case catalog state is transient or manually corrupted. + */ +int +RelationGetClusteredTargetBlocksFromIndex(Relation relation, + Relation indexRelation, + HeapTuple tuple, + Size len, + BlockNumber *targetBlocks, + int maxTargetBlocks) +{ + ScanKeyData skey[INDEX_MAX_KEYS]; + IndexScanDesc scan; + ItemPointer tid; + BlockNumber candidates[CLUSTERED_WRITE_MAX_HEAP_BLOCKS]; + Size candidateFreeSpace[CLUSTERED_WRITE_MAX_HEAP_BLOCKS]; + BlockNumber nblocks; + int nkeys; + int nscankeys = 0; + int ntuples = 0; + int ncandidates = 0; + int ntargets = 0; + bool useBtreePrefixProbe = false; + + Assert(maxTargetBlocks > 0); + + if (IsBootstrapProcessingMode() || indexRelation == NULL || + tuple == NULL || len > MaxHeapTupleSize) + return 0; + + if (relation->rd_rel->relkind != RELKIND_RELATION && + relation->rd_rel->relkind != RELKIND_MATVIEW) + return 0; + + if (indexRelation->rd_index->indrelid != RelationGetRelid(relation) || + indexRelation->rd_rel->relam != BTREE_AM_OID || + !indexRelation->rd_indam->amclusterable || + indexRelation->rd_indam->amgettuple == NULL || + !indexRelation->rd_index->indisvalid || + !indexRelation->rd_index->indisready || + !heap_attisnull(indexRelation->rd_indextuple, + Anum_pg_index_indpred, NULL)) + { + return 0; + } + + nkeys = indexRelation->rd_index->indnkeyatts; + if (nkeys <= 0 || nkeys > INDEX_MAX_KEYS) + { + return 0; + } + nblocks = RelationGetNumberOfBlocks(relation); + + if (heap_attisnull(indexRelation->rd_indextuple, + Anum_pg_index_indexprs, NULL)) + { + useBtreePrefixProbe = true; + + for (int i = 0; i < nkeys; i++) + { + AttrNumber attnum = indexRelation->rd_index->indkey.values[i]; + Datum value; + bool isnull; + Oid eqOperator; + RegProcedure eqProcedure; + + if (attnum <= 0) + break; + + value = heap_getattr(tuple, attnum, relation->rd_att, &isnull); + if (isnull) + break; + + eqOperator = get_opfamily_member(indexRelation->rd_opfamily[i], + indexRelation->rd_opcintype[i], + indexRelation->rd_opcintype[i], + BTEqualStrategyNumber); + if (!OidIsValid(eqOperator)) + break; + + eqProcedure = get_opcode(eqOperator); + if (!RegProcedureIsValid(eqProcedure)) + break; + + ScanKeyInit(&skey[i], + i + 1, + BTEqualStrategyNumber, + eqProcedure, + value); + nscankeys++; + } + + if (nscankeys == 0) + useBtreePrefixProbe = false; + } + + if (!useBtreePrefixProbe) + return 0; + + for (int probeKeys = nscankeys; probeKeys > 0; probeKeys--) + { + /* + * Plain btree keys can use equality scan keys and retry shorter left + * prefixes. Unqualified scans are deliberately skipped because they + * are not anchored to the tuple being inserted. + */ + scan = index_beginscan(relation, indexRelation, SnapshotAny, NULL, + probeKeys, 0, 0); + index_rescan(scan, probeKeys > 0 ? skey : NULL, probeKeys, NULL, 0); + + while ((tid = index_getnext_tid(scan, ForwardScanDirection)) != NULL) + { + if (++ntuples > CLUSTERED_WRITE_MAX_INDEX_TIDS) + break; + + ClusteredWriteRememberCandidate(relation, nblocks, tid, + candidates, candidateFreeSpace, + &ncandidates); + if (ncandidates >= CLUSTERED_WRITE_MAX_HEAP_BLOCKS) + break; + } + + index_endscan(scan); + + if (nscankeys == 0 || + ntuples >= CLUSTERED_WRITE_MAX_INDEX_TIDS || + ncandidates >= CLUSTERED_WRITE_MAX_HEAP_BLOCKS) + break; + } + + /* + * A clustered btree can have no equal-key or prefix-key neighbour yet, + * especially while an initially empty clustered index is being maintained + * during COPY. In that case, try the logical neighbourhood of the leading + * key: the first tuple at or after it, then the first tuple at or before + * it. That gives brand-new clustered keys a chance to land beside + * adjacent key ranges instead of being appended in input order. + */ + if (ncandidates == 0 && useBtreePrefixProbe && nscankeys > 0) + { + ScanKeyData rangeKey; + Oid rangeOperator; + RegProcedure rangeProcedure; + + rangeOperator = get_opfamily_member(indexRelation->rd_opfamily[0], + indexRelation->rd_opcintype[0], + indexRelation->rd_opcintype[0], + BTGreaterEqualStrategyNumber); + rangeProcedure = OidIsValid(rangeOperator) ? + get_opcode(rangeOperator) : InvalidOid; + if (RegProcedureIsValid(rangeProcedure)) + { + ScanKeyInit(&rangeKey, + 1, + BTGreaterEqualStrategyNumber, + rangeProcedure, + skey[0].sk_argument); + scan = index_beginscan(relation, indexRelation, SnapshotAny, NULL, + 1, 0, 0); + index_rescan(scan, &rangeKey, 1, NULL, 0); + tid = index_getnext_tid(scan, ForwardScanDirection); + if (tid != NULL) + { + ClusteredWriteRememberCandidate(relation, nblocks, tid, + candidates, + candidateFreeSpace, + &ncandidates); + } + index_endscan(scan); + } + + rangeOperator = get_opfamily_member(indexRelation->rd_opfamily[0], + indexRelation->rd_opcintype[0], + indexRelation->rd_opcintype[0], + BTLessEqualStrategyNumber); + rangeProcedure = OidIsValid(rangeOperator) ? + get_opcode(rangeOperator) : InvalidOid; + if (RegProcedureIsValid(rangeProcedure) && + ncandidates < CLUSTERED_WRITE_MAX_HEAP_BLOCKS) + { + ScanKeyInit(&rangeKey, + 1, + BTLessEqualStrategyNumber, + rangeProcedure, + skey[0].sk_argument); + scan = index_beginscan(relation, indexRelation, SnapshotAny, NULL, + 1, 0, 0); + index_rescan(scan, &rangeKey, 1, NULL, 0); + tid = index_getnext_tid(scan, BackwardScanDirection); + if (tid != NULL) + { + ClusteredWriteRememberCandidate(relation, nblocks, tid, + candidates, + candidateFreeSpace, + &ncandidates); + } + index_endscan(scan); + } + } + + /* + * Prefer pages whose FSM entry already says they can fit this tuple. If + * the FSM has no clear winner, try the rest in reverse scan order; that + * keeps us away from the first page of a large equal-key range while + * preserving the normal locked-page free-space recheck in + * RelationGetBufferForTuple(). + */ + for (int i = 0; i < ncandidates && ntargets < maxTargetBlocks; i++) + { + if (candidateFreeSpace[i] >= len) + targetBlocks[ntargets++] = candidates[i]; + } + + for (int i = ncandidates - 1; i >= 0 && ntargets < maxTargetBlocks; i--) + { + if (candidateFreeSpace[i] < len) + targetBlocks[ntargets++] = candidates[i]; + } + + return ntargets; +} + +/* + * Open the relation's remembered clustered index for callers that only need a + * one-off candidate lookup. + */ +static int +RelationGetClusteredTargetBlocksForTuple(Relation relation, HeapTuple tuple, + Size len, + BlockNumber *targetBlocks, + int maxTargetBlocks) +{ + Oid indexOid; + Relation indexRelation; + int ntargets; + + if (IsBootstrapProcessingMode() || tuple == NULL || len > MaxHeapTupleSize) + return 0; + + indexOid = RelationGetClusteredIndex(relation); + if (!OidIsValid(indexOid)) + return 0; + + indexRelation = try_index_open(indexOid, AccessShareLock); + if (indexRelation == NULL) + return 0; + + ntargets = RelationGetClusteredTargetBlocksFromIndex(relation, + indexRelation, + tuple, len, + targetBlocks, + maxTargetBlocks); + + index_close(indexRelation, AccessShareLock); + + return ntargets; +} + /* * Read in a buffer in mode, using bulk-insert strategy if bistate isn't NULL. */ @@ -498,6 +832,7 @@ RelationAddBlocks(Relation relation, BulkInsertState bistate, */ Buffer RelationGetBufferForTuple(Relation relation, Size len, + HeapTuple tuple, Buffer otherBuffer, uint32 options, BulkInsertState bistate, Buffer *vmbuffer, Buffer *vmbuffer_other, @@ -510,8 +845,12 @@ RelationGetBufferForTuple(Relation relation, Size len, pageFreeSpace = 0, saveFreeSpace = 0, targetFreeSpace = 0; + BlockNumber clusteredTargetBlocks[CLUSTERED_WRITE_MAX_HEAP_BLOCKS]; BlockNumber targetBlock, otherBlock; + int nclusteredTargetBlocks = 0, + clusteredTargetIndex = 0; + bool usingClusteredTarget = false; bool unlockedTargetBuffer; bool recheckVmPins; @@ -568,7 +907,24 @@ RelationGetBufferForTuple(Relation relation, Size len, * When use_fsm is false, we either put the tuple onto the existing target * page or extend the relation. */ - if (bistate && bistate->current_buf != InvalidBuffer) + targetBlock = InvalidBlockNumber; + + if (use_fsm) + { + nclusteredTargetBlocks = + RelationGetClusteredTargetBlocksForTuple(relation, tuple, len, + clusteredTargetBlocks, + lengthof(clusteredTargetBlocks)); + if (nclusteredTargetBlocks > 0) + targetBlock = clusteredTargetBlocks[clusteredTargetIndex]; + usingClusteredTarget = (targetBlock != InvalidBlockNumber); + } + + if (targetBlock != InvalidBlockNumber) + { + /* found a clustered-index neighbor */ + } + else if (bistate && bistate->current_buf != InvalidBuffer) targetBlock = BufferGetBlockNumber(bistate->current_buf); else targetBlock = RelationGetTargetBlock(relation); @@ -698,7 +1054,7 @@ RelationGetBufferForTuple(Relation relation, Size len, } pageFreeSpace = PageGetHeapFreeSpace(page); - if (targetFreeSpace <= pageFreeSpace) + if ((usingClusteredTarget ? len : targetFreeSpace) <= pageFreeSpace) { /* use this page as future insert target, too */ RelationSetTargetBlock(relation, targetBlock); @@ -721,6 +1077,39 @@ RelationGetBufferForTuple(Relation relation, Size len, else LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + if (usingClusteredTarget) + { + if (use_fsm) + RecordPageWithFreeSpace(relation, targetBlock, pageFreeSpace); + + clusteredTargetIndex++; + if (clusteredTargetIndex < nclusteredTargetBlocks) + { + targetBlock = clusteredTargetBlocks[clusteredTargetIndex]; + continue; + } + + usingClusteredTarget = false; + if (bistate && bistate->next_free != InvalidBlockNumber) + { + Assert(bistate->next_free <= bistate->last_free); + targetBlock = bistate->next_free; + if (bistate->next_free >= bistate->last_free) + { + bistate->next_free = InvalidBlockNumber; + bistate->last_free = InvalidBlockNumber; + } + else + bistate->next_free++; + } + else if (use_fsm) + targetBlock = GetPageWithFreeSpace(relation, targetFreeSpace); + else + targetBlock = InvalidBlockNumber; + + continue; + } + /* Is there an ongoing bulk extension? */ if (bistate && bistate->next_free != InvalidBlockNumber) { @@ -736,6 +1125,7 @@ RelationGetBufferForTuple(Relation relation, Size len, RecordPageWithFreeSpace(relation, targetBlock, pageFreeSpace); targetBlock = bistate->next_free; + usingClusteredTarget = false; if (bistate->next_free >= bistate->last_free) { bistate->next_free = InvalidBlockNumber; @@ -755,10 +1145,14 @@ RelationGetBufferForTuple(Relation relation, Size len, * Update FSM as to condition of this page, and ask for another * page to try. */ - targetBlock = RecordAndGetPageWithFreeSpace(relation, - targetBlock, - pageFreeSpace, - targetFreeSpace); + if (targetBlock == InvalidBlockNumber) + targetBlock = GetPageWithFreeSpace(relation, targetFreeSpace); + else + targetBlock = RecordAndGetPageWithFreeSpace(relation, + targetBlock, + pageFreeSpace, + targetFreeSpace); + usingClusteredTarget = false; } } diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c index ce5b99c38b1e5..d4ed561089f47 100644 --- a/src/backend/commands/repack.c +++ b/src/backend/commands/repack.c @@ -835,6 +835,7 @@ mark_index_clustered(Relation rel, Oid indexOid, bool is_internal) foreach(index, RelationGetIndexList(rel)) { Oid thisIndexOid = lfirst_oid(index); + bool dirty = false; indexTuple = SearchSysCacheCopy1(INDEXRELID, ObjectIdGetDatum(thisIndexOid)); @@ -849,7 +850,7 @@ mark_index_clustered(Relation rel, Oid indexOid, bool is_internal) if (indexForm->indisclustered) { indexForm->indisclustered = false; - CatalogTupleUpdate(pg_index, &indexTuple->t_self, indexTuple); + dirty = true; } else if (thisIndexOid == indexOid) { @@ -857,7 +858,19 @@ mark_index_clustered(Relation rel, Oid indexOid, bool is_internal) if (!indexForm->indisvalid) elog(ERROR, "cannot cluster on invalid index %u", indexOid); indexForm->indisclustered = true; + dirty = true; + } + + if (dirty) + { CatalogTupleUpdate(pg_index, &indexTuple->t_self, indexTuple); + + /* + * The table relcache remembers the clustered index OID, so all + * sessions must refresh it before later heap insertions consult + * clustered-write placement. + */ + CacheInvalidateRelcache(rel); } InvokeObjectPostAlterHookArg(IndexRelationId, thisIndexOid, 0, diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c index e19f0d3e51cf3..fd6654d460a67 100644 --- a/src/backend/utils/cache/relcache.c +++ b/src/backend/utils/cache/relcache.c @@ -4835,6 +4835,7 @@ RelationGetIndexList(Relation relation) char replident = relation->rd_rel->relreplident; Oid pkeyIndex = InvalidOid; Oid candidateIndex = InvalidOid; + Oid clusteredIndex = InvalidOid; bool pkdeferrable = false; MemoryContext oldcxt; @@ -4876,6 +4877,9 @@ RelationGetIndexList(Relation relation) /* add index's OID to result list */ result = lappend_oid(result, index->indexrelid); + if (index->indisclustered) + clusteredIndex = index->indexrelid; + /* * Non-unique or predicate indexes aren't interesting for either oid * indexes or replication identity indexes, so don't check them. @@ -4928,6 +4932,7 @@ RelationGetIndexList(Relation relation) relation->rd_indexlist = list_copy(result); relation->rd_pkindex = pkeyIndex; relation->rd_ispkdeferrable = pkdeferrable; + relation->rd_clusteredindex = clusteredIndex; if (replident == REPLICA_IDENTITY_DEFAULT && OidIsValid(pkeyIndex) && !pkdeferrable) relation->rd_replidindex = pkeyIndex; else if (replident == REPLICA_IDENTITY_INDEX && OidIsValid(candidateIndex)) @@ -5075,6 +5080,27 @@ RelationGetReplicaIndex(Relation relation) return relation->rd_replidindex; } +/* + * RelationGetClusteredIndex -- get OID of the relation's clustered index + * + * Returns InvalidOid if there is no such index. + */ +Oid +RelationGetClusteredIndex(Relation relation) +{ + List *ilist; + + if (!relation->rd_indexvalid) + { + /* RelationGetIndexList does the heavy lifting. */ + ilist = RelationGetIndexList(relation); + list_free(ilist); + Assert(relation->rd_indexvalid); + } + + return relation->rd_clusteredindex; +} + /* * RelationGetIndexExpressions -- get the index expressions for an index * @@ -5302,6 +5328,7 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind) List *newindexoidlist; Oid relpkindex; Oid relreplindex; + Oid relclusteredindex; ListCell *l; MemoryContext oldcxt; @@ -5340,14 +5367,15 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind) return NULL; /* - * Copy the rd_pkindex and rd_replidindex values computed by - * RelationGetIndexList before proceeding. This is needed because a - * relcache flush could occur inside index_open below, resetting the + * Copy the rd_pkindex, rd_replidindex, and rd_clusteredindex values + * computed by RelationGetIndexList before proceeding. This is needed + * because a relcache flush could occur inside index_open below, resetting the * fields managed by RelationGetIndexList. We need to do the work with * stable values of these fields. */ relpkindex = relation->rd_pkindex; relreplindex = relation->rd_replidindex; + relclusteredindex = relation->rd_clusteredindex; /* * For each index, add referenced attributes to indexattrs. @@ -5480,7 +5508,8 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind) newindexoidlist = RelationGetIndexList(relation); if (equal(indexoidlist, newindexoidlist) && relpkindex == relation->rd_pkindex && - relreplindex == relation->rd_replidindex) + relreplindex == relation->rd_replidindex && + relclusteredindex == relation->rd_clusteredindex) { /* Still the same index set, so proceed */ list_free(newindexoidlist); @@ -6498,6 +6527,7 @@ load_relcache_init_file(bool shared) rel->rd_indexlist = NIL; rel->rd_pkindex = InvalidOid; rel->rd_replidindex = InvalidOid; + rel->rd_clusteredindex = InvalidOid; rel->rd_attrsvalid = false; rel->rd_keyattr = NULL; rel->rd_pkattr = NULL; diff --git a/src/include/access/hio.h b/src/include/access/hio.h index 60cfc375fd523..9e31033381ad8 100644 --- a/src/include/access/hio.h +++ b/src/include/access/hio.h @@ -53,7 +53,14 @@ typedef struct BulkInsertStateData extern void RelationPutHeapTuple(Relation relation, Buffer buffer, HeapTuple tuple, bool token); +extern int RelationGetClusteredTargetBlocksFromIndex(Relation relation, + Relation indexRelation, + HeapTuple tuple, + Size len, + BlockNumber *targetBlocks, + int maxTargetBlocks); extern Buffer RelationGetBufferForTuple(Relation relation, Size len, + HeapTuple tuple, Buffer otherBuffer, uint32 options, BulkInsertStateData *bistate, Buffer *vmbuffer, Buffer *vmbuffer_other, diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h index cd1e92f230258..3dbc19e127abe 100644 --- a/src/include/utils/rel.h +++ b/src/include/utils/rel.h @@ -61,8 +61,8 @@ typedef struct RelationData bool rd_islocaltemp; /* rel is a temp rel of this session */ bool rd_isnailed; /* rel is nailed in cache */ bool rd_isvalid; /* relcache entry is valid */ - bool rd_indexvalid; /* is rd_indexlist valid? (also rd_pkindex and - * rd_replidindex) */ + bool rd_indexvalid; /* is rd_indexlist valid? (also rd_pkindex, + * rd_replidindex, and rd_clusteredindex) */ bool rd_statvalid; /* is rd_statlist valid? */ /*---------- @@ -153,6 +153,7 @@ typedef struct RelationData Oid rd_pkindex; /* OID of (deferrable?) primary key, if any */ bool rd_ispkdeferrable; /* is rd_pkindex a deferrable PK? */ Oid rd_replidindex; /* OID of replica identity index, if any */ + Oid rd_clusteredindex; /* OID of clustered index, if any */ /* data managed by RelationGetStatExtList: */ List *rd_statlist; /* list of OIDs of extended stats */ diff --git a/src/include/utils/relcache.h b/src/include/utils/relcache.h index 2700224939a72..735ba21c3c5a0 100644 --- a/src/include/utils/relcache.h +++ b/src/include/utils/relcache.h @@ -56,6 +56,7 @@ extern List *RelationGetIndexList(Relation relation); extern List *RelationGetStatExtList(Relation relation); extern Oid RelationGetPrimaryKeyIndex(Relation relation, bool deferrable_ok); extern Oid RelationGetReplicaIndex(Relation relation); +extern Oid RelationGetClusteredIndex(Relation relation); extern List *RelationGetIndexExpressions(Relation relation); extern List *RelationGetDummyIndexExpressions(Relation relation); extern List *RelationGetIndexPredicate(Relation relation); diff --git a/src/test/regress/expected/cluster.out b/src/test/regress/expected/cluster.out index 8f80f9f752d7d..6881faff51895 100644 --- a/src/test/regress/expected/cluster.out +++ b/src/test/regress/expected/cluster.out @@ -306,10 +306,78 @@ WHERE pg_class.oid=indexrelid --------- (0 rows) +-- Verify that clustered writes prefer heap blocks in clustered key order. +CREATE TABLE clstr_write_btree (id int, k int, filler text) WITH (fillfactor = 10); +INSERT INTO clstr_write_btree +SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10) +FROM generate_series(1, 400) AS g; +CREATE INDEX clstr_write_btree_k_id ON clstr_write_btree (k, id); +CLUSTER clstr_write_btree USING clstr_write_btree_k_id; +INSERT INTO clstr_write_btree VALUES (1001, 1, repeat('y', 10)); +SELECT tid_block(new_row.ctid) <= + (SELECT max(tid_block(ctid)) FROM clstr_write_btree WHERE k = 1 AND id <> 1001) + AS placed_near_clustered_key +FROM clstr_write_btree AS new_row +WHERE id = 1001; + placed_near_clustered_key +--------------------------- + t +(1 row) + +DROP TABLE clstr_write_btree; +-- Verify that reordered clustered COPY batches keep each slot's TID. +CREATE TABLE clstr_write_copy_tid (id int, k int, filler text) WITH (fillfactor = 10); +INSERT INTO clstr_write_copy_tid +SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10) +FROM generate_series(1, 400) AS g; +CREATE INDEX clstr_write_copy_tid_k_id ON clstr_write_copy_tid (k, id); +CREATE INDEX clstr_write_copy_tid_id ON clstr_write_copy_tid (id); +CLUSTER clstr_write_copy_tid USING clstr_write_copy_tid_k_id; +COPY clstr_write_copy_tid (id, k, filler) FROM stdin; +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT id FROM ( + SELECT id FROM clstr_write_copy_tid WHERE id = 1001 + UNION ALL + SELECT id FROM clstr_write_copy_tid WHERE id = 1002 + UNION ALL + SELECT id FROM clstr_write_copy_tid WHERE id = 2001 + UNION ALL + SELECT id FROM clstr_write_copy_tid WHERE id = 2002 +) AS indexed_lookup +ORDER BY id; + id +------ + 1001 + 1002 + 2001 + 2002 +(4 rows) + +RESET enable_bitmapscan; +RESET enable_seqscan; +DROP TABLE clstr_write_copy_tid; +-- Verify that clustered writes do not break other clusterable AMs. +CREATE TABLE clstr_write_gist (id int, p point, filler text) WITH (fillfactor = 50); +INSERT INTO clstr_write_gist +SELECT g, point(g, g), repeat('x', 1000) +FROM generate_series(1, 20) AS g; +CREATE INDEX clstr_write_gist_p ON clstr_write_gist USING gist (p); +CLUSTER clstr_write_gist USING clstr_write_gist_p; +INSERT INTO clstr_write_gist VALUES (1001, point(1.5, 1.5), repeat('y', 1000)); +SELECT count(*) AS rows, count(*) FILTER (WHERE p <@ box(point(1, 1), point(2, 2))) AS nearby +FROM clstr_write_gist; + rows | nearby +------+-------- + 21 | 3 +(1 row) + +DROP TABLE clstr_write_gist; -- Verify that toast tables are clusterable CLUSTER pg_toast.pg_toast_826 USING pg_toast_826_index; -- Verify that clustering all tables does in fact cluster the right ones CREATE USER regress_clstr_user; +GRANT CREATE ON SCHEMA public TO regress_clstr_user; CREATE TABLE clstr_1 (a INT PRIMARY KEY); CREATE TABLE clstr_2 (a INT PRIMARY KEY); CREATE TABLE clstr_3 (a INT PRIMARY KEY); @@ -860,4 +928,5 @@ DROP TABLE clstr_3; DROP TABLE clstr_4; DROP TABLE clstr_expression; DROP TABLE clstrpart; +REVOKE CREATE ON SCHEMA public FROM regress_clstr_user; DROP USER regress_clstr_user; diff --git a/src/test/regress/sql/cluster.sql b/src/test/regress/sql/cluster.sql index a8cb7ca982dea..29c8b4cd26ee2 100644 --- a/src/test/regress/sql/cluster.sql +++ b/src/test/regress/sql/cluster.sql @@ -103,11 +103,69 @@ WHERE pg_class.oid=indexrelid AND pg_class_2.relname = 'clstr_tst' AND indisclustered; +-- Verify that clustered writes prefer heap blocks in clustered key order. +CREATE TABLE clstr_write_btree (id int, k int, filler text) WITH (fillfactor = 10); +INSERT INTO clstr_write_btree +SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10) +FROM generate_series(1, 400) AS g; +CREATE INDEX clstr_write_btree_k_id ON clstr_write_btree (k, id); +CLUSTER clstr_write_btree USING clstr_write_btree_k_id; +INSERT INTO clstr_write_btree VALUES (1001, 1, repeat('y', 10)); +SELECT tid_block(new_row.ctid) <= + (SELECT max(tid_block(ctid)) FROM clstr_write_btree WHERE k = 1 AND id <> 1001) + AS placed_near_clustered_key +FROM clstr_write_btree AS new_row +WHERE id = 1001; +DROP TABLE clstr_write_btree; + +-- Verify that reordered clustered COPY batches keep each slot's TID. +CREATE TABLE clstr_write_copy_tid (id int, k int, filler text) WITH (fillfactor = 10); +INSERT INTO clstr_write_copy_tid +SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10) +FROM generate_series(1, 400) AS g; +CREATE INDEX clstr_write_copy_tid_k_id ON clstr_write_copy_tid (k, id); +CREATE INDEX clstr_write_copy_tid_id ON clstr_write_copy_tid (id); +CLUSTER clstr_write_copy_tid USING clstr_write_copy_tid_k_id; +COPY clstr_write_copy_tid (id, k, filler) FROM stdin; +2001 2 y +1001 1 y +2002 2 y +1002 1 y +\. +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT id FROM ( + SELECT id FROM clstr_write_copy_tid WHERE id = 1001 + UNION ALL + SELECT id FROM clstr_write_copy_tid WHERE id = 1002 + UNION ALL + SELECT id FROM clstr_write_copy_tid WHERE id = 2001 + UNION ALL + SELECT id FROM clstr_write_copy_tid WHERE id = 2002 +) AS indexed_lookup +ORDER BY id; +RESET enable_bitmapscan; +RESET enable_seqscan; +DROP TABLE clstr_write_copy_tid; + +-- Verify that clustered writes do not break other clusterable AMs. +CREATE TABLE clstr_write_gist (id int, p point, filler text) WITH (fillfactor = 50); +INSERT INTO clstr_write_gist +SELECT g, point(g, g), repeat('x', 1000) +FROM generate_series(1, 20) AS g; +CREATE INDEX clstr_write_gist_p ON clstr_write_gist USING gist (p); +CLUSTER clstr_write_gist USING clstr_write_gist_p; +INSERT INTO clstr_write_gist VALUES (1001, point(1.5, 1.5), repeat('y', 1000)); +SELECT count(*) AS rows, count(*) FILTER (WHERE p <@ box(point(1, 1), point(2, 2))) AS nearby +FROM clstr_write_gist; +DROP TABLE clstr_write_gist; + -- Verify that toast tables are clusterable CLUSTER pg_toast.pg_toast_826 USING pg_toast_826_index; -- Verify that clustering all tables does in fact cluster the right ones CREATE USER regress_clstr_user; +GRANT CREATE ON SCHEMA public TO regress_clstr_user; CREATE TABLE clstr_1 (a INT PRIMARY KEY); CREATE TABLE clstr_2 (a INT PRIMARY KEY); CREATE TABLE clstr_3 (a INT PRIMARY KEY); @@ -441,4 +499,5 @@ DROP TABLE clstr_4; DROP TABLE clstr_expression; DROP TABLE clstrpart; +REVOKE CREATE ON SCHEMA public FROM regress_clstr_user; DROP USER regress_clstr_user; diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README new file mode 100644 index 0000000000000..12c37260712f5 --- /dev/null +++ b/src/tools/clustered_write_bench/README @@ -0,0 +1,107 @@ +clustered_write_bench +===================== + +These scripts measure how well a remembered clustered index survives an +osm2pgsql-like diff workload. + +Run the script against a build with clustered writes: + + psql -v scale=1 -v use_brin=false -f src/tools/clustered_write_bench/osm2pgsql_diff.sql postgres + +Larger scale values increase table and diff sizes. The workload: + +* loads a synthetic table keyed by an OSM-like object id and a tile-like + clustering key; +* creates two physically clustered copies of the heap; +* leaves one copy with remembered clustered-index metadata and clears that + metadata on the other copy with `ALTER TABLE ... SET WITHOUT CLUSTER`; +* records the original heap block range for every tile key; +* applies a diff with new objects spread across existing tile keys; +* applies updates to existing objects that enlarge rows enough to force moved + heap tuples on a full-ish table, as a regression/stress metric; +* reports how far inserted and moved tuples drifted from their original tile + ranges. + +The `clustered_write` variant shows the new placement heuristic. The +`without_cluster_metadata` variant is the in-script control: it starts from a +clustered heap but disables remembered-index placement before the diff. +Current clustered-write placement is aimed at row insertions; the update rows +are included to make any accidental moved-update regression obvious. +If updates look worse for `clustered_write`, that usually means the inserted +diff rows consumed per-page free space that the later enlarged updates would +otherwise have used. That is a real tradeoff to inspect rather than a success +condition. + +The optional `use_brin=true` variable also creates a BRIN index on the tile key. +Current clustered-write placement does not use BRIN directly: BRIN exposes +`amgetbitmap`, not `amgettuple`, so it needs a separate bitmap/range candidate +path. The option is present to make before/after experiments easy when that +path is added. + +Real osm2pgsql benchmark +------------------------ + +`run_osm2pgsql_georgia_bench.sh` runs a larger end-to-end workload against +PostGIS and osm2pgsql: + + src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh + +The script downloads the Geofabrik Georgia extract and the current planet daily +replication diff, simplifies that daily change file with `osmium merge-changes +-s` so osm2pgsql sees only the last change for each object id, then measures +three variants: + +* `baseline_stock`: system PostgreSQL without this patch, stock osm2pgsql. +* `patched_stock`: patched PostgreSQL, stock osm2pgsql. +* `patched_clustered_import`: patched PostgreSQL and an experimental + osm2pgsql build with `--cluster-during-import`. + +The write side records `/usr/bin/time -v` output for the initial import and the +daily diff append. The read side runs spatial bbox and exact-intersection +queries from `osm2pgsql_georgia_read.sql`, with `EXPLAIN (ANALYZE, BUFFERS)`, +and also reports a geohash-to-heap-block locality summary. All output goes +under `$WORKDIR/logs` (`$HOME/tmp/clustered-write-osm2pgsql/logs` by default). + +Useful overrides: + +* `BASELINE_PG_BIN=/path/to/bin` selects the unpatched PostgreSQL build. +* `PATCHED_PG_BIN=/path/to/bin` selects the patched PostgreSQL build. +* `STOCK_OSM2PGSQL=/path/to/osm2pgsql` selects stock osm2pgsql. +* `CLUSTERED_OSM2PGSQL=/path/to/osm2pgsql` selects the experimental osm2pgsql. +* `OSM2PGSQL_STYLE=/path/to/default.style` selects the pgsql style file. +* `OSC_URL=https://...osc.gz` pins a specific planet daily diff. +* `BENCH_VARIANTS=baseline_stock,patched_stock` runs only selected variants. + +Only selected variants need their PostgreSQL and osm2pgsql binaries to exist. +Common input-preparation tools such as curl, osmium, awk, sed, and the +osm2pgsql style file are still required because the extract and simplified diff +are shared across variants. + +The script copies PostGIS extension SQL and shared libraries into non-system +PostgreSQL installs before creating the test database. This keeps the patched +build usable without installing it globally. + +The experimental osm2pgsql variant is not part of PostgreSQL. For local +testing, apply `osm2pgsql_cluster_during_import.patch` to a fresh osm2pgsql +checkout and point `CLUSTERED_OSM2PGSQL` at the resulting binary. The patch +adds `--cluster-during-import`, creates a generated geometry-derived geohash key +and a clustered btree index before the first COPY, and skips osm2pgsql's final +table rewrite. + +Directly clustering the import tables with a pre-COPY GiST index on `way` was +also tested as a negative control. It worked functionally, but maintaining the +GiST index during ingest made the Georgia import and daily append much slower +than the generated-key path, so the checked-in osm2pgsql experiment keeps the +btree key while PostgreSQL keeps GiST batch-sort support available for opclasses +where it is a better fit. + +Important output columns: + +* `variant`: either the clustered-write path or the in-script control without + remembered clustered-index metadata. +* `pct_inside_base_range`: higher is better; percent of diff tuples still inside + the tile's original clustered heap block range. +* `avg_block_drift`, `p95_block_drift`, `max_block_drift`: lower is better; + block distance outside the original tile range. +* `heap_blocks_touched`: lower can be better for locality, but interpret it + with the drift metrics because very small values can also mean hot spots. diff --git a/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch new file mode 100644 index 0000000000000..65eedf9790440 --- /dev/null +++ b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch @@ -0,0 +1,130 @@ +diff --git a/src/command-line-parser.cpp b/src/command-line-parser.cpp +index 232a70a..4a98592 100644 +--- a/src/command-line-parser.cpp ++++ b/src/command-line-parser.cpp +@@ -388,2 +388,8 @@ options_t parse_command_line(int argc, char *argv[]) + ++ // --cluster-during-import ++ app.add_flag("--cluster-during-import", options.cluster_during_import) ++ ->description("Experimental: create a clustered geometry-derived key before " ++ "loading rows and skip the final geometry sort.") ++ ->group("Pgsql output options"); ++ + // --keep-coastlines +diff --git a/src/options.hpp b/src/options.hpp +index 4f56bfb..4cc115b 100644 +--- a/src/options.hpp ++++ b/src/options.hpp +@@ -138,2 +138,5 @@ struct options_t + ++ /// create a clustered btree index over a generated geometry key before the first COPY ++ bool cluster_during_import = false; ++ + /// Output multi-geometries intead of several simple geometries +diff --git a/src/output-pgsql.cpp b/src/output-pgsql.cpp +index 6b250a6..4b38667 100644 +--- a/src/output-pgsql.cpp ++++ b/src/output-pgsql.cpp +@@ -513,3 +513,4 @@ output_pgsql_t::output_pgsql_t(std::shared_ptr const &mid, + options.projection->target_srs(), options.append, +- options.hstore_mode, copy_thread, options.output_dbschema); ++ options.hstore_mode, copy_thread, options.output_dbschema, ++ options.cluster_during_import); + } +diff --git a/src/table.cpp b/src/table.cpp +index 6b65eb9..72276b3 100644 +--- a/src/table.cpp ++++ b/src/table.cpp +@@ -29,6 +29,7 @@ table_t::table_t(std::string const &name, std::string type, columns_t columns, + std::shared_ptr const ©_thread, +- std::string const &schema) ++ std::string const &schema, bool const cluster_during_import) + : m_target(std::make_shared(schema, name, "osm_id")), + m_type(std::move(type)), m_srid(fmt::to_string(srid)), m_append(append), +- m_hstore_mode(hstore_mode), m_columns(std::move(columns)), ++ m_hstore_mode(hstore_mode), m_cluster_during_import(cluster_during_import), ++ m_columns(std::move(columns)), + m_hstore_columns(std::move(hstore_columns)), m_copy(copy_thread) +@@ -47,3 +48,5 @@ table_t::table_t(table_t const &other, + m_type(other.m_type), m_srid(other.m_srid), m_append(other.m_append), +- m_hstore_mode(other.m_hstore_mode), m_columns(other.m_columns), ++ m_hstore_mode(other.m_hstore_mode), ++ m_cluster_during_import(other.m_cluster_during_import), ++ m_columns(other.m_columns), + m_hstore_columns(other.m_hstore_columns), m_table_space(other.m_table_space), +@@ -120,3 +123,13 @@ void table_t::start(connection_params_t const &connection_params, + +- sql += fmt::format("way geometry({},{}) )", m_type, m_srid); ++ sql += fmt::format("way geometry({},{})", m_type, m_srid); ++ ++ if (m_cluster_during_import) { ++ sql += ++ ", osm2pgsql_cluster_key text COLLATE \"C\" " ++ "GENERATED ALWAYS AS " ++ "(ST_GeoHash(ST_Transform(ST_Envelope(way), 4326), 7)) " ++ "STORED"; ++ } ++ ++ sql += " )"; + +@@ -132,2 +145,20 @@ void table_t::start(connection_params_t const &connection_params, + ++ if (m_cluster_during_import) { ++ auto const idx_name = m_target->name() + "_cluster_key_idx"; ++ auto const quoted_idx_name = fmt::format(R"("{}")", idx_name); ++ ++ check_identifier(idx_name, "index names"); ++ log_info("Creating clustered geometry key index on table '{}'...", ++ m_target->name()); ++ m_db_connection->exec( ++ "CREATE INDEX {} ON {} USING BTREE " ++ "(osm2pgsql_cluster_key) {}", ++ quoted_idx_name, qual_name, tablespace_clause(table_space)); ++ ++ log_info("Remembering clustered geometry key order for table '{}'...", ++ m_target->name()); ++ m_db_connection->exec("CLUSTER {} USING {}", qual_name, ++ quoted_idx_name); ++ } ++ + if (m_srid != "4326") { +@@ -192,13 +223,18 @@ void table_t::stop(bool updateable, bool enable_hstore_index, + +- log_info("Clustering table '{}' by geometry...", m_target->name()); ++ if (m_cluster_during_import) { ++ log_info("Keeping table '{}' in clustered import order...", ++ m_target->name()); ++ } else { ++ log_info("Clustering table '{}' by geometry...", m_target->name()); + +- std::string const sql = +- fmt::format("CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way", +- qual_tmp_name, m_table_space, qual_name); ++ std::string const sql = fmt::format( ++ "CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way", ++ qual_tmp_name, m_table_space, qual_name); + +- m_db_connection->exec(sql); ++ m_db_connection->exec(sql); + +- m_db_connection->exec("DROP TABLE {}", qual_name); +- m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")", qual_tmp_name, +- m_target->name()); ++ m_db_connection->exec("DROP TABLE {}", qual_name); ++ m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")", ++ qual_tmp_name, m_target->name()); ++ } + +diff --git a/src/table.hpp b/src/table.hpp +index db0f73f..122e1b5 100644 +--- a/src/table.hpp ++++ b/src/table.hpp +@@ -34,3 +34,3 @@ public: + std::shared_ptr const ©_thread, +- std::string const &schema); ++ std::string const &schema, bool cluster_during_import); + +@@ -79,2 +79,3 @@ private: + hstore_column m_hstore_mode; ++ bool m_cluster_during_import; + columns_t m_columns; diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql new file mode 100644 index 0000000000000..a1190be3ea6cf --- /dev/null +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -0,0 +1,227 @@ +\set ON_ERROR_STOP on + +\if :{?scale} +\else +\set scale 1 +\endif + +\if :{?use_brin} +\else +\set use_brin false +\endif + +\timing on + +drop table if exists clustered_write_osm_diff cascade; +drop table if exists clustered_write_osm_diff_on cascade; +drop table if exists clustered_write_osm_diff_off cascade; + +create temp table clustered_write_settings as +select (200000 * :scale)::int as base_rows, + (20000 * :scale)::int as insert_rows, + (20000 * :scale)::int as update_rows, + (4096 * :scale)::int as tile_count; + +create unlogged table clustered_write_osm_diff_on +( + osm_id bigint primary key, + tile_id int not null, + version int not null, + payload text not null +) with (fillfactor = 90); + +insert into clustered_write_osm_diff_on +select g, + ((g - 1) % s.tile_count) + 1, + 1, + repeat('base', 16) +from clustered_write_settings as s, + generate_series(1, s.base_rows) as g; + +create index clustered_write_osm_diff_tile_idx + on clustered_write_osm_diff_on (tile_id, osm_id); + +\if :use_brin +create index clustered_write_osm_diff_tile_brin_idx + on clustered_write_osm_diff_on using brin (tile_id) with (pages_per_range = 32); +\endif + +cluster clustered_write_osm_diff_on using clustered_write_osm_diff_tile_idx; +analyze clustered_write_osm_diff_on; + +create unlogged table clustered_write_osm_diff_off +( + osm_id bigint primary key, + tile_id int not null, + version int not null, + payload text not null +) with (fillfactor = 90); + +insert into clustered_write_osm_diff_off +select * +from clustered_write_osm_diff_on; + +create index clustered_write_osm_diff_off_tile_idx + on clustered_write_osm_diff_off (tile_id, osm_id); + +\if :use_brin +create index clustered_write_osm_diff_off_tile_brin_idx + on clustered_write_osm_diff_off using brin (tile_id) with (pages_per_range = 32); +\endif + +cluster clustered_write_osm_diff_off using clustered_write_osm_diff_off_tile_idx; +alter table clustered_write_osm_diff_off set without cluster; +analyze clustered_write_osm_diff_off; + +-- Start the diff phase from a fresh relcache. The control table was just +-- clustered and then marked SET WITHOUT CLUSTER; reconnecting ensures the +-- following writes observe the cleared catalog bit rather than any relation +-- state cached during setup. +select current_database() as clustered_write_database \gset +\connect :clustered_write_database +\timing on + +create temp table clustered_write_settings as +select (200000 * :scale)::int as base_rows, + (20000 * :scale)::int as insert_rows, + (20000 * :scale)::int as update_rows, + (4096 * :scale)::int as tile_count; + +create temp table clustered_write_base_ranges as +select 'clustered_write'::text as variant, + tile_id, + min(tid_block(ctid)) as min_block, + max(tid_block(ctid)) as max_block +from clustered_write_osm_diff_on +group by tile_id + +union all + +select 'without_cluster_metadata'::text as variant, + tile_id, + min(tid_block(ctid)) as min_block, + max(tid_block(ctid)) as max_block +from clustered_write_osm_diff_off +group by tile_id; + +create temp table clustered_write_diff_inserts as +select s.base_rows + g as osm_id, + (((g::bigint * 1103515245 + 12345) % s.tile_count) + 1)::int as tile_id +from clustered_write_settings as s, + generate_series(1, s.insert_rows) as g; + +insert into clustered_write_osm_diff_on +select d.osm_id, + d.tile_id, + 1, + repeat('insert', 16) +from clustered_write_diff_inserts as d; + +insert into clustered_write_osm_diff_off +select d.osm_id, + d.tile_id, + 1, + repeat('insert', 16) +from clustered_write_diff_inserts as d; + +create temp table clustered_write_diff_updates as +select distinct (((g::bigint * 2654435761) % s.base_rows) + 1)::bigint as osm_id +from clustered_write_settings as s, + generate_series(1, s.update_rows) as g; + +update clustered_write_osm_diff_on as o +set version = o.version + 1, + payload = repeat('updated-row', 64) +from clustered_write_diff_updates as u +where o.osm_id = u.osm_id; + +update clustered_write_osm_diff_off as o +set version = o.version + 1, + payload = repeat('updated-row', 64) +from clustered_write_diff_updates as u +where o.osm_id = u.osm_id; + +analyze clustered_write_osm_diff_on; +analyze clustered_write_osm_diff_off; + +with measured as +( + select 'clustered_write'::text as variant, + 'insert'::text as diff_kind, + tid_block(o.ctid) as heap_block, + r.min_block, + r.max_block + from clustered_write_osm_diff_on as o + join clustered_write_settings as s on true + join clustered_write_base_ranges as r + on r.variant = 'clustered_write' + and r.tile_id = o.tile_id + where o.osm_id > s.base_rows + + union all + + select 'clustered_write'::text as variant, + 'update'::text as diff_kind, + tid_block(o.ctid) as heap_block, + r.min_block, + r.max_block + from clustered_write_osm_diff_on as o + join clustered_write_settings as s on true + join clustered_write_base_ranges as r + on r.variant = 'clustered_write' + and r.tile_id = o.tile_id + where o.osm_id <= s.base_rows + and o.version > 1 + + union all + + select 'without_cluster_metadata'::text as variant, + 'insert'::text as diff_kind, + tid_block(o.ctid) as heap_block, + r.min_block, + r.max_block + from clustered_write_osm_diff_off as o + join clustered_write_settings as s on true + join clustered_write_base_ranges as r + on r.variant = 'without_cluster_metadata' + and r.tile_id = o.tile_id + where o.osm_id > s.base_rows + + union all + + select 'without_cluster_metadata'::text as variant, + 'update'::text as diff_kind, + tid_block(o.ctid) as heap_block, + r.min_block, + r.max_block + from clustered_write_osm_diff_off as o + join clustered_write_settings as s on true + join clustered_write_base_ranges as r + on r.variant = 'without_cluster_metadata' + and r.tile_id = o.tile_id + where o.osm_id <= s.base_rows + and o.version > 1 +), +drift as +( + select variant, + diff_kind, + heap_block, + case + when heap_block < min_block then min_block - heap_block + when heap_block > max_block then heap_block - max_block + else 0 + end as block_drift + from measured +) +select variant, + diff_kind, + count(*) as rows_measured, + count(distinct heap_block) as heap_blocks_touched, + round(100.0 * avg((block_drift = 0)::int), 2) as pct_inside_base_range, + round(avg(block_drift)::numeric, 2) as avg_block_drift, + percentile_cont(0.95) within group (order by block_drift) as p95_block_drift, + max(block_drift) as max_block_drift +from drift +group by variant, diff_kind +order by variant, diff_kind; diff --git a/src/tools/clustered_write_bench/osm2pgsql_georgia_read.sql b/src/tools/clustered_write_bench/osm2pgsql_georgia_read.sql new file mode 100644 index 0000000000000..bdac32b8005a1 --- /dev/null +++ b/src/tools/clustered_write_bench/osm2pgsql_georgia_read.sql @@ -0,0 +1,114 @@ +\set ON_ERROR_STOP on +\timing on + +create extension if not exists postgis; + +drop table if exists clustered_write_read_windows; + +create temp table clustered_write_read_windows +( + name text primary key, + geom geometry(Polygon, 3857) not null +); + +insert into clustered_write_read_windows +values + ('tbilisi_core', ST_Transform(ST_MakeEnvelope(44.75, 41.67, 44.86, 41.75, 4326), 3857)), + ('batumi_core', ST_Transform(ST_MakeEnvelope(41.59, 41.61, 41.68, 41.67, 4326), 3857)), + ('kutaisi_core', ST_Transform(ST_MakeEnvelope(42.62, 42.23, 42.75, 42.31, 4326), 3857)), + ('georgia_west_east', ST_Transform(ST_MakeEnvelope(39.9, 41.0, 46.8, 43.7, 4326), 3857)); + +analyze clustered_write_read_windows; + +\echo 'bbox count reads' + +explain (analyze, buffers, timing, summary) +select w.name, count(*) as rows_seen +from clustered_write_read_windows as w +join planet_osm_point as p + on p.way && w.geom +group by w.name +order by w.name; + +explain (analyze, buffers, timing, summary) +select w.name, count(*) as rows_seen +from clustered_write_read_windows as w +join planet_osm_line as l + on l.way && w.geom +group by w.name +order by w.name; + +explain (analyze, buffers, timing, summary) +select w.name, count(*) as rows_seen +from clustered_write_read_windows as w +join planet_osm_polygon as p + on p.way && w.geom +group by w.name +order by w.name; + +\echo 'exact spatial reads' + +explain (analyze, buffers, timing, summary) +select w.name, count(*) as rows_seen +from clustered_write_read_windows as w +join planet_osm_roads as r + on r.way && w.geom + and ST_Intersects(r.way, w.geom) +group by w.name +order by w.name; + +explain (analyze, buffers, timing, summary) +select w.name, count(*) as rows_seen +from clustered_write_read_windows as w +join planet_osm_polygon as p + on p.way && w.geom + and ST_Intersects(p.way, w.geom) +group by w.name +order by w.name; + +\echo 'heap locality summary' + +with table_blocks as +( + select 'point'::text as table_name, + (ctid::text::point)[0]::bigint as block_id, + ST_GeoHash(ST_Transform(ST_Envelope(way), 4326), 7) as geohash + from planet_osm_point + where way is not null + + union all + + select 'line'::text as table_name, + (ctid::text::point)[0]::bigint as block_id, + ST_GeoHash(ST_Transform(ST_Envelope(way), 4326), 7) as geohash + from planet_osm_line + where way is not null + + union all + + select 'polygon'::text as table_name, + (ctid::text::point)[0]::bigint as block_id, + ST_GeoHash(ST_Transform(ST_Envelope(way), 4326), 7) as geohash + from planet_osm_polygon + where way is not null +), +geohash_blocks as +( + select table_name, + geohash, + count(*) as row_count, + count(distinct block_id) as block_count, + max(block_id) - min(block_id) as block_span + from table_blocks + group by table_name, geohash + having count(*) >= 10 +) +select table_name, + count(*) as geohashes, + percentile_cont(0.50) within group (order by block_count) as p50_blocks, + percentile_cont(0.95) within group (order by block_count) as p95_blocks, + percentile_cont(0.50) within group (order by block_span) as p50_span, + percentile_cont(0.95) within group (order by block_span) as p95_span +from geohash_blocks +group by table_name +order by table_name; diff --git a/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh new file mode 100755 index 0000000000000..3000a80e7d3f8 --- /dev/null +++ b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh @@ -0,0 +1,306 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +BENCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +WORKDIR="${WORKDIR:-$HOME/tmp/clustered-write-osm2pgsql}" +GEORGIA_URL="${GEORGIA_URL:-https://download.geofabrik.de/europe/georgia-latest.osm.pbf}" +PLANET_DAY_STATE_URL="${PLANET_DAY_STATE_URL:-https://planet.openstreetmap.org/replication/day/state.txt}" +OSC_URL="${OSC_URL:-}" + +PATCHED_PG_BIN="${PATCHED_PG_BIN:-$ROOT_DIR/tmp_install/usr/local/pgsql/bin}" +BASELINE_PG_BIN="${BASELINE_PG_BIN:-/usr/lib/postgresql/18/bin}" +STOCK_OSM2PGSQL="${STOCK_OSM2PGSQL:-osm2pgsql}" +CLUSTERED_OSM2PGSQL="${CLUSTERED_OSM2PGSQL:-/tmp/osm2pgsql-clustered-import/build/osm2pgsql}" +OSM2PGSQL_STYLE="${OSM2PGSQL_STYLE:-/tmp/osm2pgsql-clustered-import/default.style}" +OSMIUM="${OSMIUM:-osmium}" + +POSTGIS_SHARE="${POSTGIS_SHARE:-/usr/share/postgresql/18/extension}" +POSTGIS_LIB="${POSTGIS_LIB:-/usr/lib/postgresql/18/lib}" + +PG_PORT_BASE="${PG_PORT_BASE:-55432}" +OSM2PGSQL_CACHE_MB="${OSM2PGSQL_CACHE_MB:-2048}" +OSM2PGSQL_PROCS="${OSM2PGSQL_PROCS:-4}" +BENCH_VARIANTS="${BENCH_VARIANTS:-baseline_stock,patched_stock,patched_clustered_import}" + +mkdir -p "$WORKDIR"/{data,logs,pgdata} + +log() +{ + printf '[%s] %s\n' "$(date -Is)" "$*" >&2 +} + +require_executable() +{ + if ! command -v "$1" >/dev/null 2>&1; then + printf 'missing executable: %s\n' "$1" >&2 + exit 1 + fi +} + +require_path_executable() +{ + if [[ ! -x "$1" ]]; then + printf 'missing executable: %s\n' "$1" >&2 + exit 1 + fi +} + +variant_enabled() +{ + local wanted="$1" + + case ",$BENCH_VARIANTS," in + *,"$wanted",*) return 0 ;; + *) return 1 ;; + esac +} + +resolve_executable() +{ + local executable="$1" + + if command -v "$executable" >/dev/null 2>&1; then + command -v "$executable" + elif [[ -x "$executable" ]]; then + printf '%s\n' "$executable" + else + printf 'missing executable: %s\n' "$executable" >&2 + exit 1 + fi +} + +download_if_missing() +{ + local url="$1" + local out="$2" + + if [[ -s "$out" ]]; then + log "using existing $out" + return + fi + + log "downloading $url" + curl --fail --location --retry 3 --output "$out".tmp "$url" + mv "$out".tmp "$out" +} + +osc_path_for_sequence() +{ + local seq="$1" + printf '%09d' "$seq" | sed -E 's#(...)(...)(...)#\1/\2/\3.osc.gz#' +} + +resolve_daily_diff_url() +{ + if [[ -n "$OSC_URL" ]]; then + printf '%s\n' "$OSC_URL" + return + fi + + local state_file="$WORKDIR/data/day-state.txt" + curl --fail --location --retry 3 --output "$state_file".tmp "$PLANET_DAY_STATE_URL" + mv "$state_file".tmp "$state_file" + + local sequence + sequence="$(awk -F= '$1 == "sequenceNumber" {print $2}' "$state_file")" + if [[ -z "$sequence" ]]; then + printf 'could not read sequenceNumber from %s\n' "$state_file" >&2 + exit 1 + fi + + printf 'https://planet.openstreetmap.org/replication/day/%s\n' "$(osc_path_for_sequence "$sequence")" +} + +simplify_daily_diff() +{ + local in_file="$1" + local out_file="${in_file%.osc.gz}-simplified.osc.gz" + + if [[ -s "$out_file" && "$out_file" -nt "$in_file" ]]; then + log "using existing simplified diff $out_file" + printf '%s\n' "$out_file" + return + fi + + log "simplifying daily diff for osm2pgsql append" + "$OSMIUM" merge-changes -s -O -o "$out_file".tmp "$in_file" + mv "$out_file".tmp "$out_file" + printf '%s\n' "$out_file" +} + +copy_postgis_into_install() +{ + local pg_bin="$1" + local pg_home + pg_home="$(cd "$pg_bin/.." && pwd)" + + if [[ "$pg_bin" == /usr/lib/postgresql/* ]]; then + return + fi + + mkdir -p "$pg_home/share/extension" "$pg_home/lib" + cp -a "$POSTGIS_SHARE"/postgis* "$pg_home/share/extension/" + cp -a "$POSTGIS_SHARE"/address_standardizer* "$pg_home/share/extension/" 2>/dev/null || true + cp -a "$POSTGIS_LIB"/postgis-*.so "$pg_home/lib/" + cp -a "$POSTGIS_LIB"/postgis_raster-*.so "$pg_home/lib/" 2>/dev/null || true +} + +start_server() +{ + local name="$1" + local pg_bin="$2" + local port="$3" + local data_dir="$WORKDIR/pgdata/$name" + local log_file="$WORKDIR/logs/$name-postgres.log" + + rm -rf "$data_dir" + "$pg_bin/initdb" -D "$data_dir" >"$WORKDIR/logs/$name-initdb.log" + cat >>"$data_dir/postgresql.conf" </dev/null 2>&1 || true +} + +run_psql() +{ + local pg_bin="$1" + local port="$2" + local db="$3" + shift 3 + + "$pg_bin/psql" -v ON_ERROR_STOP=1 -h 127.0.0.1 -p "$port" -d "$db" "$@" +} + +run_variant() +{ + local name="$1" + local pg_bin="$2" + local port="$3" + local osm2pgsql="$4" + local mode="$5" + local pbf="$6" + local osc="$7" + + require_path_executable "$pg_bin/initdb" + require_path_executable "$pg_bin/pg_ctl" + require_path_executable "$pg_bin/psql" + require_path_executable "$osm2pgsql" + copy_postgis_into_install "$pg_bin" + + log "starting PostgreSQL for $name on port $port" + start_server "$name" "$pg_bin" "$port" + trap "stop_server '$pg_bin' '$name'" EXIT + + "$pg_bin/createdb" -h 127.0.0.1 -p "$port" osm + run_psql "$pg_bin" "$port" osm -c 'create extension postgis; create extension hstore;' + + local common_args=( + --slim + --database osm + --host 127.0.0.1 + --port "$port" + --style "$OSM2PGSQL_STYLE" + --cache "$OSM2PGSQL_CACHE_MB" + --number-processes "$OSM2PGSQL_PROCS" + ) + + local create_args=(--create) + if [[ "$mode" == "clustered_import" ]]; then + create_args+=(--cluster-during-import) + fi + + log "initial import for $name" + /usr/bin/time -v -o "$WORKDIR/logs/$name-create.time" \ + "$osm2pgsql" "${common_args[@]}" "${create_args[@]}" "$pbf" \ + >"$WORKDIR/logs/$name-create.stdout" \ + 2>"$WORKDIR/logs/$name-create.stderr" + + log "daily diff append for $name" + /usr/bin/time -v -o "$WORKDIR/logs/$name-append.time" \ + "$osm2pgsql" "${common_args[@]}" --append "$osc" \ + >"$WORKDIR/logs/$name-append.stdout" \ + 2>"$WORKDIR/logs/$name-append.stderr" + + log "read benchmark for $name" + run_psql "$pg_bin" "$port" osm \ + -f "$BENCH_DIR/osm2pgsql_georgia_read.sql" \ + >"$WORKDIR/logs/$name-read.sqlout" \ + 2>"$WORKDIR/logs/$name-read.sqlerr" + + run_psql "$pg_bin" "$port" osm \ + -c "select current_database() as db, pg_size_pretty(pg_database_size(current_database())) as database_size;" \ + >"$WORKDIR/logs/$name-size.sqlout" + + stop_server "$pg_bin" "$name" + trap - EXIT +} + +require_executable curl +require_executable awk +require_executable sed +require_executable "$OSMIUM" +if variant_enabled baseline_stock; then + require_path_executable "$BASELINE_PG_BIN/initdb" +fi +if variant_enabled patched_stock || variant_enabled patched_clustered_import; then + require_path_executable "$PATCHED_PG_BIN/initdb" +fi +if variant_enabled patched_clustered_import; then + require_path_executable "$CLUSTERED_OSM2PGSQL" +fi +if [[ ! -f "$OSM2PGSQL_STYLE" ]]; then + printf 'missing osm2pgsql style file: %s\n' "$OSM2PGSQL_STYLE" >&2 + exit 1 +fi +stock_osm2pgsql_path="" +if variant_enabled baseline_stock || variant_enabled patched_stock; then + stock_osm2pgsql_path="$(resolve_executable "$STOCK_OSM2PGSQL")" +fi + +georgia_pbf="$WORKDIR/data/georgia-latest.osm.pbf" +daily_diff_url="$(resolve_daily_diff_url)" +daily_diff="$WORKDIR/data/$(basename "$daily_diff_url")" + +download_if_missing "$GEORGIA_URL" "$georgia_pbf" +download_if_missing "$daily_diff_url" "$daily_diff" +simplified_daily_diff="$(simplify_daily_diff "$daily_diff")" + +log "benchmark inputs:" +log " georgia=$GEORGIA_URL" +log " daily_diff=$daily_diff_url" +log " simplified_daily_diff=$simplified_daily_diff" +log " variants=$BENCH_VARIANTS" +log " workdir=$WORKDIR" + +if variant_enabled baseline_stock; then + run_variant baseline_stock "$BASELINE_PG_BIN" "$((PG_PORT_BASE + 1))" "$stock_osm2pgsql_path" stock "$georgia_pbf" "$simplified_daily_diff" +fi + +if variant_enabled patched_stock; then + run_variant patched_stock "$PATCHED_PG_BIN" "$((PG_PORT_BASE + 2))" "$stock_osm2pgsql_path" stock "$georgia_pbf" "$simplified_daily_diff" +fi + +if variant_enabled patched_clustered_import; then + run_variant patched_clustered_import "$PATCHED_PG_BIN" "$((PG_PORT_BASE + 3))" "$CLUSTERED_OSM2PGSQL" clustered_import "$georgia_pbf" "$simplified_daily_diff" +fi + +log "done; logs are in $WORKDIR/logs" From a9ad872b28515a7e827ce64c46893802a4b64fa9 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 04:19:58 +0400 Subject: [PATCH 02/81] fix(heap): keep clustered placement to inserts --- src/backend/access/heap/hio.c | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index 8d9c6013edcfd..7e4fc5facf401 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -909,7 +909,13 @@ RelationGetBufferForTuple(Relation relation, Size len, */ targetBlock = InvalidBlockNumber; - if (use_fsm) + /* + * Clustered target selection is an insertion policy. heap_update passes + * otherBuffer for the old tuple page and has its own placement constraints + * around HOT/fillfactor and buffer locking; keep moved update tuples on the + * regular FSM path. + */ + if (use_fsm && otherBuffer == InvalidBuffer) { nclusteredTargetBlocks = RelationGetClusteredTargetBlocksForTuple(relation, tuple, len, From 38aa6f30dd7487096d1ddafca16927fb7880795c Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 04:23:55 +0400 Subject: [PATCH 03/81] refactor(heap): gate clustered target probes explicitly --- src/backend/access/heap/heapam.c | 7 +- src/backend/access/heap/hio.c | 130 ++++++++++++++++++------------- src/include/access/hio.h | 2 + 3 files changed, 82 insertions(+), 57 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index fbc707db658b3..b489678e97461 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -195,6 +195,7 @@ heap_clustered_write_index_can_sort(Relation relation, Relation indexRelation) if (indexRelation->rd_index->indrelid != RelationGetRelid(relation) || indexRelation->rd_rel->relam != GIST_AM_OID || + !indexRelation->rd_indam->amclusterable || !indexRelation->rd_index->indisvalid || !indexRelation->rd_index->indisready || !heap_attisnull(indexRelation->rd_indextuple, @@ -2519,10 +2520,8 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, clusteredIndexRelation = try_index_open(clusteredIndexOid, AccessShareLock); } - if (clusteredIndexRelation != NULL && - clusteredIndexRelation->rd_rel->relam == BTREE_AM_OID && - heap_attisnull(clusteredIndexRelation->rd_indextuple, - Anum_pg_index_indexprs, NULL)) + if (RelationCanUseClusteredTargetProbe(relation, + clusteredIndexRelation)) use_clustered_target_probe = true; else if (heap_clustered_write_index_can_sort(relation, clusteredIndexRelation)) diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index 7e4fc5facf401..14d4caa7cf71f 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -52,6 +52,52 @@ static int RelationGetClusteredTargetBlocksForTuple(Relation relation, BlockNumber *targetBlocks, int maxTargetBlocks); +/* + * RelationCanUseClusteredTargetProbe + * + * Return true when the remembered clustered index can support tuple-anchored + * target-block probes. Keep this in sync with the stronger per-tuple checks + * in RelationGetClusteredTargetBlocksFromIndex(), so callers can skip batch + * allocation and per-tuple work when the clustered index is not usable for + * heap page placement. + */ +bool +RelationCanUseClusteredTargetProbe(Relation relation, Relation indexRelation) +{ + int nkeys; + + if (IsBootstrapProcessingMode() || indexRelation == NULL) + return false; + + if (relation->rd_rel->relkind != RELKIND_RELATION && + relation->rd_rel->relkind != RELKIND_MATVIEW) + return false; + + if (indexRelation->rd_index->indrelid != RelationGetRelid(relation) || + indexRelation->rd_rel->relam != BTREE_AM_OID || + !indexRelation->rd_indam->amclusterable || + indexRelation->rd_indam->amgettuple == NULL || + !indexRelation->rd_index->indisvalid || + !indexRelation->rd_index->indisready || + !heap_attisnull(indexRelation->rd_indextuple, + Anum_pg_index_indexprs, NULL) || + !heap_attisnull(indexRelation->rd_indextuple, + Anum_pg_index_indpred, NULL)) + return false; + + nkeys = indexRelation->rd_index->indnkeyatts; + if (nkeys <= 0 || nkeys > INDEX_MAX_KEYS) + return false; + + for (int i = 0; i < nkeys; i++) + { + if (indexRelation->rd_index->indkey.values[i] <= 0) + return false; + } + + return true; +} + /* * RelationPutHeapTuple - place tuple at specified page * @@ -185,72 +231,50 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, tuple == NULL || len > MaxHeapTupleSize) return 0; - if (relation->rd_rel->relkind != RELKIND_RELATION && - relation->rd_rel->relkind != RELKIND_MATVIEW) - return 0; - - if (indexRelation->rd_index->indrelid != RelationGetRelid(relation) || - indexRelation->rd_rel->relam != BTREE_AM_OID || - !indexRelation->rd_indam->amclusterable || - indexRelation->rd_indam->amgettuple == NULL || - !indexRelation->rd_index->indisvalid || - !indexRelation->rd_index->indisready || - !heap_attisnull(indexRelation->rd_indextuple, - Anum_pg_index_indpred, NULL)) - { + if (!RelationCanUseClusteredTargetProbe(relation, indexRelation)) return 0; - } nkeys = indexRelation->rd_index->indnkeyatts; - if (nkeys <= 0 || nkeys > INDEX_MAX_KEYS) - { - return 0; - } nblocks = RelationGetNumberOfBlocks(relation); - if (heap_attisnull(indexRelation->rd_indextuple, - Anum_pg_index_indexprs, NULL)) - { - useBtreePrefixProbe = true; - - for (int i = 0; i < nkeys; i++) - { - AttrNumber attnum = indexRelation->rd_index->indkey.values[i]; - Datum value; - bool isnull; - Oid eqOperator; - RegProcedure eqProcedure; + useBtreePrefixProbe = true; - if (attnum <= 0) - break; + for (int i = 0; i < nkeys; i++) + { + AttrNumber attnum = indexRelation->rd_index->indkey.values[i]; + Datum value; + bool isnull; + Oid eqOperator; + RegProcedure eqProcedure; - value = heap_getattr(tuple, attnum, relation->rd_att, &isnull); - if (isnull) - break; + Assert(attnum > 0); - eqOperator = get_opfamily_member(indexRelation->rd_opfamily[i], - indexRelation->rd_opcintype[i], - indexRelation->rd_opcintype[i], - BTEqualStrategyNumber); - if (!OidIsValid(eqOperator)) - break; + value = heap_getattr(tuple, attnum, relation->rd_att, &isnull); + if (isnull) + break; - eqProcedure = get_opcode(eqOperator); - if (!RegProcedureIsValid(eqProcedure)) - break; + eqOperator = get_opfamily_member(indexRelation->rd_opfamily[i], + indexRelation->rd_opcintype[i], + indexRelation->rd_opcintype[i], + BTEqualStrategyNumber); + if (!OidIsValid(eqOperator)) + break; - ScanKeyInit(&skey[i], - i + 1, - BTEqualStrategyNumber, - eqProcedure, - value); - nscankeys++; - } + eqProcedure = get_opcode(eqOperator); + if (!RegProcedureIsValid(eqProcedure)) + break; - if (nscankeys == 0) - useBtreePrefixProbe = false; + ScanKeyInit(&skey[i], + i + 1, + BTEqualStrategyNumber, + eqProcedure, + value); + nscankeys++; } + if (nscankeys == 0) + useBtreePrefixProbe = false; + if (!useBtreePrefixProbe) return 0; diff --git a/src/include/access/hio.h b/src/include/access/hio.h index 9e31033381ad8..9ff30896d83af 100644 --- a/src/include/access/hio.h +++ b/src/include/access/hio.h @@ -53,6 +53,8 @@ typedef struct BulkInsertStateData extern void RelationPutHeapTuple(Relation relation, Buffer buffer, HeapTuple tuple, bool token); +extern bool RelationCanUseClusteredTargetProbe(Relation relation, + Relation indexRelation); extern int RelationGetClusteredTargetBlocksFromIndex(Relation relation, Relation indexRelation, HeapTuple tuple, From 6e127695e6c65a97fb9564842c48c7f4f0fd8da3 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 04:27:33 +0400 Subject: [PATCH 04/81] fix(heap): probe clustered btree ranges from both ends --- src/backend/access/heap/hio.c | 100 ++++++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 28 deletions(-) diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index 14d4caa7cf71f..95fb919102980 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -46,6 +46,16 @@ static bool ClusteredWriteRememberCandidate(Relation relation, BlockNumber *candidates, Size *candidateFreeSpace, int *ncandidates); +static void ClusteredWriteRememberPrefixCandidates(Relation relation, + Relation indexRelation, + BlockNumber nblocks, + ScanKey skey, + int nscankeys, + ScanDirection direction, + BlockNumber *candidates, + Size *candidateFreeSpace, + int *ncandidates, + int *ntuples); static int RelationGetClusteredTargetBlocksForTuple(Relation relation, HeapTuple tuple, Size len, @@ -185,6 +195,48 @@ ClusteredWriteRememberCandidate(Relation relation, BlockNumber nblocks, return true; } +/* + * Remember candidate pages from one bounded scan of a btree equality-prefix + * range. Callers run this in both directions so large duplicate-key or + * prefix-key ranges contribute pages from each edge instead of only from the + * oldest/lowest TIDs. + */ +static void +ClusteredWriteRememberPrefixCandidates(Relation relation, + Relation indexRelation, + BlockNumber nblocks, + ScanKey skey, + int nscankeys, + ScanDirection direction, + BlockNumber *candidates, + Size *candidateFreeSpace, + int *ncandidates, + int *ntuples) +{ + IndexScanDesc scan; + ItemPointer tid; + + Assert(nscankeys > 0); + + scan = index_beginscan(relation, indexRelation, SnapshotAny, NULL, + nscankeys, 0, 0); + index_rescan(scan, skey, nscankeys, NULL, 0); + + while ((tid = index_getnext_tid(scan, direction)) != NULL) + { + if (++(*ntuples) > CLUSTERED_WRITE_MAX_INDEX_TIDS) + break; + + ClusteredWriteRememberCandidate(relation, nblocks, tid, + candidates, candidateFreeSpace, + ncandidates); + if (*ncandidates >= CLUSTERED_WRITE_MAX_HEAP_BLOCKS) + break; + } + + index_endscan(scan); +} + /* * RelationGetClusteredTargetBlocksFromIndex * @@ -223,7 +275,6 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, int ntuples = 0; int ncandidates = 0; int ntargets = 0; - bool useBtreePrefixProbe = false; Assert(maxTargetBlocks > 0); @@ -237,8 +288,6 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, nkeys = indexRelation->rd_index->indnkeyatts; nblocks = RelationGetNumberOfBlocks(relation); - useBtreePrefixProbe = true; - for (int i = 0; i < nkeys; i++) { AttrNumber attnum = indexRelation->rd_index->indkey.values[i]; @@ -273,38 +322,33 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, } if (nscankeys == 0) - useBtreePrefixProbe = false; - - if (!useBtreePrefixProbe) return 0; for (int probeKeys = nscankeys; probeKeys > 0; probeKeys--) { /* * Plain btree keys can use equality scan keys and retry shorter left - * prefixes. Unqualified scans are deliberately skipped because they - * are not anchored to the tuple being inserted. + * prefixes. Probe each matching range from both ends, so very large + * duplicate-key ranges do not only contribute their first, often-full + * heap pages. Unqualified scans are deliberately skipped because + * they are not anchored to the tuple being inserted. */ - scan = index_beginscan(relation, indexRelation, SnapshotAny, NULL, - probeKeys, 0, 0); - index_rescan(scan, probeKeys > 0 ? skey : NULL, probeKeys, NULL, 0); + ClusteredWriteRememberPrefixCandidates(relation, indexRelation, + nblocks, skey, probeKeys, + ForwardScanDirection, + candidates, candidateFreeSpace, + &ncandidates, &ntuples); - while ((tid = index_getnext_tid(scan, ForwardScanDirection)) != NULL) - { - if (++ntuples > CLUSTERED_WRITE_MAX_INDEX_TIDS) - break; - - ClusteredWriteRememberCandidate(relation, nblocks, tid, - candidates, candidateFreeSpace, - &ncandidates); - if (ncandidates >= CLUSTERED_WRITE_MAX_HEAP_BLOCKS) - break; - } - - index_endscan(scan); - - if (nscankeys == 0 || - ntuples >= CLUSTERED_WRITE_MAX_INDEX_TIDS || + if (ntuples < CLUSTERED_WRITE_MAX_INDEX_TIDS && + ncandidates < CLUSTERED_WRITE_MAX_HEAP_BLOCKS) + ClusteredWriteRememberPrefixCandidates(relation, indexRelation, + nblocks, skey, probeKeys, + BackwardScanDirection, + candidates, + candidateFreeSpace, + &ncandidates, &ntuples); + + if (ntuples >= CLUSTERED_WRITE_MAX_INDEX_TIDS || ncandidates >= CLUSTERED_WRITE_MAX_HEAP_BLOCKS) break; } @@ -317,7 +361,7 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, * it. That gives brand-new clustered keys a chance to land beside * adjacent key ranges instead of being appended in input order. */ - if (ncandidates == 0 && useBtreePrefixProbe && nscankeys > 0) + if (ncandidates == 0 && nscankeys > 0) { ScanKeyData rangeKey; Oid rangeOperator; From 2b21ca6ad9e696b5799530e39674688d89ded5cc Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 04:30:43 +0400 Subject: [PATCH 05/81] fix(heap): reserve budget for clustered range tails --- src/backend/access/heap/hio.c | 54 +++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index 95fb919102980..199f740e17c8f 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -46,6 +46,9 @@ static bool ClusteredWriteRememberCandidate(Relation relation, BlockNumber *candidates, Size *candidateFreeSpace, int *ncandidates); +static bool ClusteredWriteHasFittingCandidate(Size *candidateFreeSpace, + int ncandidates, + Size len); static void ClusteredWriteRememberPrefixCandidates(Relation relation, Relation indexRelation, BlockNumber nblocks, @@ -55,7 +58,8 @@ static void ClusteredWriteRememberPrefixCandidates(Relation relation, BlockNumber *candidates, Size *candidateFreeSpace, int *ncandidates, - int *ntuples); + int *ntuples, + int tupleLimit); static int RelationGetClusteredTargetBlocksForTuple(Relation relation, HeapTuple tuple, Size len, @@ -195,6 +199,19 @@ ClusteredWriteRememberCandidate(Relation relation, BlockNumber nblocks, return true; } +static bool +ClusteredWriteHasFittingCandidate(Size *candidateFreeSpace, int ncandidates, + Size len) +{ + for (int i = 0; i < ncandidates; i++) + { + if (candidateFreeSpace[i] >= len) + return true; + } + + return false; +} + /* * Remember candidate pages from one bounded scan of a btree equality-prefix * range. Callers run this in both directions so large duplicate-key or @@ -211,12 +228,14 @@ ClusteredWriteRememberPrefixCandidates(Relation relation, BlockNumber *candidates, Size *candidateFreeSpace, int *ncandidates, - int *ntuples) + int *ntuples, + int tupleLimit) { IndexScanDesc scan; ItemPointer tid; Assert(nscankeys > 0); + Assert(tupleLimit > 0); scan = index_beginscan(relation, indexRelation, SnapshotAny, NULL, nscankeys, 0, 0); @@ -224,8 +243,9 @@ ClusteredWriteRememberPrefixCandidates(Relation relation, while ((tid = index_getnext_tid(scan, direction)) != NULL) { - if (++(*ntuples) > CLUSTERED_WRITE_MAX_INDEX_TIDS) + if (*ntuples >= tupleLimit) break; + (*ntuples)++; ClusteredWriteRememberCandidate(relation, nblocks, tid, candidates, candidateFreeSpace, @@ -326,6 +346,9 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, for (int probeKeys = nscankeys; probeKeys > 0; probeKeys--) { + int remainingTuples; + int forwardLimit; + /* * Plain btree keys can use equality scan keys and retry shorter left * prefixes. Probe each matching range from both ends, so very large @@ -333,20 +356,39 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, * heap pages. Unqualified scans are deliberately skipped because * they are not anchored to the tuple being inserted. */ + remainingTuples = CLUSTERED_WRITE_MAX_INDEX_TIDS - ntuples; + if (remainingTuples <= 0) + break; + + /* + * Reserve roughly half the remaining TID budget for the backward scan + * so a very large range cannot spend the whole budget at its head. + */ + forwardLimit = ntuples + Max(1, remainingTuples / 2); ClusteredWriteRememberPrefixCandidates(relation, indexRelation, nblocks, skey, probeKeys, ForwardScanDirection, candidates, candidateFreeSpace, - &ncandidates, &ntuples); + &ncandidates, &ntuples, + forwardLimit); + /* + * The backward edge is only needed when the forward edge did not find + * any candidate page that the FSM already considers large enough. + * This avoids doubling index probes for the common case while still + * helping large duplicate-key ranges whose first pages are full. + */ if (ntuples < CLUSTERED_WRITE_MAX_INDEX_TIDS && - ncandidates < CLUSTERED_WRITE_MAX_HEAP_BLOCKS) + ncandidates < CLUSTERED_WRITE_MAX_HEAP_BLOCKS && + !ClusteredWriteHasFittingCandidate(candidateFreeSpace, + ncandidates, len)) ClusteredWriteRememberPrefixCandidates(relation, indexRelation, nblocks, skey, probeKeys, BackwardScanDirection, candidates, candidateFreeSpace, - &ncandidates, &ntuples); + &ncandidates, &ntuples, + CLUSTERED_WRITE_MAX_INDEX_TIDS); if (ntuples >= CLUSTERED_WRITE_MAX_INDEX_TIDS || ncandidates >= CLUSTERED_WRITE_MAX_HEAP_BLOCKS) From 2004694e1761fd925951b62983b1edc2417ab961 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 04:34:07 +0400 Subject: [PATCH 06/81] fix(heap): reserve clustered range candidate slots --- src/backend/access/heap/hio.c | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index 199f740e17c8f..a3d1f19587abc 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -59,7 +59,8 @@ static void ClusteredWriteRememberPrefixCandidates(Relation relation, Size *candidateFreeSpace, int *ncandidates, int *ntuples, - int tupleLimit); + int tupleLimit, + int candidateLimit); static int RelationGetClusteredTargetBlocksForTuple(Relation relation, HeapTuple tuple, Size len, @@ -229,13 +230,16 @@ ClusteredWriteRememberPrefixCandidates(Relation relation, Size *candidateFreeSpace, int *ncandidates, int *ntuples, - int tupleLimit) + int tupleLimit, + int candidateLimit) { IndexScanDesc scan; ItemPointer tid; Assert(nscankeys > 0); Assert(tupleLimit > 0); + Assert(candidateLimit > 0); + Assert(candidateLimit <= CLUSTERED_WRITE_MAX_HEAP_BLOCKS); scan = index_beginscan(relation, indexRelation, SnapshotAny, NULL, nscankeys, 0, 0); @@ -250,7 +254,7 @@ ClusteredWriteRememberPrefixCandidates(Relation relation, ClusteredWriteRememberCandidate(relation, nblocks, tid, candidates, candidateFreeSpace, ncandidates); - if (*ncandidates >= CLUSTERED_WRITE_MAX_HEAP_BLOCKS) + if (*ncandidates >= candidateLimit) break; } @@ -346,6 +350,8 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, for (int probeKeys = nscankeys; probeKeys > 0; probeKeys--) { + int forwardCandidateLimit; + int remainingCandidates; int remainingTuples; int forwardLimit; @@ -356,21 +362,26 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, * heap pages. Unqualified scans are deliberately skipped because * they are not anchored to the tuple being inserted. */ + remainingCandidates = CLUSTERED_WRITE_MAX_HEAP_BLOCKS - ncandidates; remainingTuples = CLUSTERED_WRITE_MAX_INDEX_TIDS - ntuples; - if (remainingTuples <= 0) + if (remainingCandidates <= 0 || remainingTuples <= 0) break; /* - * Reserve roughly half the remaining TID budget for the backward scan - * so a very large range cannot spend the whole budget at its head. + * Reserve roughly half the remaining TID and candidate-page budgets + * for the backward scan, so a very large range cannot spend all of + * either budget at its head. */ + forwardCandidateLimit = ncandidates + + Max(1, remainingCandidates / 2); forwardLimit = ntuples + Max(1, remainingTuples / 2); ClusteredWriteRememberPrefixCandidates(relation, indexRelation, nblocks, skey, probeKeys, ForwardScanDirection, candidates, candidateFreeSpace, &ncandidates, &ntuples, - forwardLimit); + forwardLimit, + forwardCandidateLimit); /* * The backward edge is only needed when the forward edge did not find @@ -388,7 +399,8 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, candidates, candidateFreeSpace, &ncandidates, &ntuples, - CLUSTERED_WRITE_MAX_INDEX_TIDS); + CLUSTERED_WRITE_MAX_INDEX_TIDS, + CLUSTERED_WRITE_MAX_HEAP_BLOCKS); if (ntuples >= CLUSTERED_WRITE_MAX_INDEX_TIDS || ncandidates >= CLUSTERED_WRITE_MAX_HEAP_BLOCKS) From c5b0e4bb8ab1966045201ad597a9e15a2635e7de Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 04:37:01 +0400 Subject: [PATCH 07/81] refactor(heap): compare clustered input indexes explicitly --- src/backend/access/heap/heapam.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index b489678e97461..0b6d0a7deab2d 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -182,7 +182,11 @@ heap_clustered_write_item_cmp(const void *a, const void *b, void *arg) if (left->targetBlock > right->targetBlock) return 1; - return left->inputIndex - right->inputIndex; + if (left->inputIndex < right->inputIndex) + return -1; + if (left->inputIndex > right->inputIndex) + return 1; + return 0; } static bool From 2f34ea4910b187cf0fc36876f057c8cdd25705ef Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 04:43:55 +0400 Subject: [PATCH 08/81] fix(heap): bound clustered GiST sort memory --- src/backend/access/heap/heapam.c | 30 +++++++++++++++++++++++++++++- src/backend/utils/cache/relcache.c | 6 ++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 0b6d0a7deab2d..78456ac229dcf 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -58,6 +58,7 @@ #include "utils/datum.h" #include "utils/injection_point.h" #include "utils/inval.h" +#include "utils/memutils.h" #include "utils/relcache.h" #include "utils/sortsupport.h" #include "utils/spccache.h" @@ -77,6 +78,12 @@ typedef struct HeapTupleClusteredSortContext { int nkeys; SortSupportData sortKeys[INDEX_MAX_KEYS]; + + /* GiST compressed keys must survive until qsort finishes. */ + MemoryContext sortCxt; + + /* Comparator support functions should not leak into the caller context. */ + MemoryContext compareCxt; } HeapTupleClusteredSortContext; @@ -156,12 +163,17 @@ heap_clustered_write_item_cmp(const void *a, const void *b, void *arg) for (int i = 0; i < context->nkeys; i++) { int compare; + MemoryContext oldcontext; + oldcontext = MemoryContextSwitchTo(context->compareCxt); compare = ApplySortComparator(left->clusterValues[i], left->clusterIsNull[i], right->clusterValues[i], right->clusterIsNull[i], &context->sortKeys[i]); + MemoryContextSwitchTo(oldcontext); + MemoryContextReset(context->compareCxt); + if (compare != 0) return compare; } @@ -241,14 +253,24 @@ heap_prepare_clustered_write_sort(Relation relation, HeapTupleClusteredSortContext *context) { GISTSTATE *giststate = NULL; + MemoryContext oldcontext; int nkeys; context->nkeys = 0; + context->sortCxt = NULL; + context->compareCxt = NULL; if (!heap_clustered_write_index_can_sort(relation, indexRelation)) return 0; nkeys = indexRelation->rd_index->indnkeyatts; + context->sortCxt = AllocSetContextCreate(CurrentMemoryContext, + "heap clustered write sort", + ALLOCSET_DEFAULT_SIZES); + context->compareCxt = AllocSetContextCreate(context->sortCxt, + "heap clustered write compare", + ALLOCSET_DEFAULT_SIZES); + oldcontext = MemoryContextSwitchTo(context->sortCxt); for (int i = 0; i < nkeys; i++) { @@ -265,6 +287,7 @@ heap_prepare_clustered_write_sort(Relation relation, } giststate = initGISTstate(indexRelation); + giststate->tempCxt = context->sortCxt; for (int i = 0; i < ntuples; i++) { @@ -287,6 +310,7 @@ heap_prepare_clustered_write_sort(Relation relation, context->nkeys = nkeys; freeGISTstate(giststate); + MemoryContextSwitchTo(oldcontext); return nkeys; } @@ -2509,7 +2533,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, if ((options & HEAP_INSERT_SKIP_FSM) == 0 && ntuples > 1) { HeapTupleClusteredWriteItem *clustered; - HeapTupleClusteredSortContext sortContext; + HeapTupleClusteredSortContext sortContext = {0}; Oid clusteredIndexOid = InvalidOid; Relation clusteredIndexRelation = NULL; bool use_clustered_target_probe = false; @@ -2575,7 +2599,11 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, heaptuples[i] = clustered[i].tuple; heaptuple_slot_indexes[i] = clustered[i].inputIndex; } + if (sortContext.sortCxt != NULL) + MemoryContextDelete(sortContext.sortCxt); } + else if (sortContext.sortCxt != NULL) + MemoryContextDelete(sortContext.sortCxt); pfree(clustered); } diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c index fd6654d460a67..50057353f4b50 100644 --- a/src/backend/utils/cache/relcache.c +++ b/src/backend/utils/cache/relcache.c @@ -4819,9 +4819,11 @@ RelationGetFKeyList(Relation relation) * indexes, and syscache lookup could cause SI messages to be processed! * * In exactly the same way, we update rd_pkindex, which is the OID of the - * relation's primary key index if any, else InvalidOid; and rd_replidindex, + * relation's primary key index if any, else InvalidOid; rd_replidindex, * which is the pg_class OID of an index to be used as the relation's - * replication identity index, or InvalidOid if there is no such index. + * replication identity index, or InvalidOid if there is no such index; and + * rd_clusteredindex, which is the OID of the relation's clustered index if + * any, else InvalidOid. */ List * RelationGetIndexList(Relation relation) From 0b14d8fbfaa7f4fa00d983079dd8878d1f822f3a Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 04:46:26 +0400 Subject: [PATCH 09/81] fix(heap): bound clustered candidate helper --- src/backend/access/heap/hio.c | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index a3d1f19587abc..d83ef1ce5fa75 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -45,7 +45,8 @@ static bool ClusteredWriteRememberCandidate(Relation relation, ItemPointer tid, BlockNumber *candidates, Size *candidateFreeSpace, - int *ncandidates); + int *ncandidates, + int maxCandidates); static bool ClusteredWriteHasFittingCandidate(Size *candidateFreeSpace, int ncandidates, Size len); @@ -175,13 +176,20 @@ RelationPutHeapTuple(Relation relation, static bool ClusteredWriteRememberCandidate(Relation relation, BlockNumber nblocks, ItemPointer tid, BlockNumber *candidates, - Size *candidateFreeSpace, int *ncandidates) + Size *candidateFreeSpace, int *ncandidates, + int maxCandidates) { BlockNumber candidate; + Assert(maxCandidates > 0); + Assert(maxCandidates <= CLUSTERED_WRITE_MAX_HEAP_BLOCKS); + if (tid == NULL) return false; + if (*ncandidates >= maxCandidates) + return false; + candidate = ItemPointerGetBlockNumber(tid); if (candidate == InvalidBlockNumber || candidate >= nblocks) return false; @@ -253,7 +261,7 @@ ClusteredWriteRememberPrefixCandidates(Relation relation, ClusteredWriteRememberCandidate(relation, nblocks, tid, candidates, candidateFreeSpace, - ncandidates); + ncandidates, candidateLimit); if (*ncandidates >= candidateLimit) break; } @@ -443,7 +451,8 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, ClusteredWriteRememberCandidate(relation, nblocks, tid, candidates, candidateFreeSpace, - &ncandidates); + &ncandidates, + CLUSTERED_WRITE_MAX_HEAP_BLOCKS); } index_endscan(scan); } @@ -471,7 +480,8 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, ClusteredWriteRememberCandidate(relation, nblocks, tid, candidates, candidateFreeSpace, - &ncandidates); + &ncandidates, + CLUSTERED_WRITE_MAX_HEAP_BLOCKS); } index_endscan(scan); } From 509c38ecf5ad525fe91ffbf9afadab62c0e10d97 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 04:49:57 +0400 Subject: [PATCH 10/81] fix(heap): initialize GiST include sort attrs --- src/backend/access/heap/heapam.c | 17 +++++++++++------ src/test/regress/expected/cluster.out | 2 +- src/test/regress/sql/cluster.sql | 2 +- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 78456ac229dcf..f9b0dba0f4284 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -204,6 +204,7 @@ heap_clustered_write_item_cmp(const void *a, const void *b, void *arg) static bool heap_clustered_write_index_can_sort(Relation relation, Relation indexRelation) { + int natts; int nkeys; if (IsBootstrapProcessingMode() || indexRelation == NULL) @@ -220,16 +221,18 @@ heap_clustered_write_index_can_sort(Relation relation, Relation indexRelation) Anum_pg_index_indpred, NULL)) return false; - nkeys = indexRelation->rd_index->indnkeyatts; - if (nkeys <= 0 || nkeys > INDEX_MAX_KEYS) + natts = IndexRelationGetNumberOfAttributes(indexRelation); + nkeys = IndexRelationGetNumberOfKeyAttributes(indexRelation); + if (nkeys <= 0 || nkeys > natts || natts > INDEX_MAX_KEYS) return false; - for (int i = 0; i < nkeys; i++) + for (int i = 0; i < natts; i++) { if (indexRelation->rd_index->indkey.values[i] <= 0) return false; - if (!OidIsValid(index_getprocid(indexRelation, i + 1, + if (i < nkeys && + !OidIsValid(index_getprocid(indexRelation, i + 1, GIST_SORTSUPPORT_PROC))) return false; } @@ -254,6 +257,7 @@ heap_prepare_clustered_write_sort(Relation relation, { GISTSTATE *giststate = NULL; MemoryContext oldcontext; + int natts; int nkeys; context->nkeys = 0; @@ -263,7 +267,8 @@ heap_prepare_clustered_write_sort(Relation relation, if (!heap_clustered_write_index_can_sort(relation, indexRelation)) return 0; - nkeys = indexRelation->rd_index->indnkeyatts; + natts = IndexRelationGetNumberOfAttributes(indexRelation); + nkeys = IndexRelationGetNumberOfKeyAttributes(indexRelation); context->sortCxt = AllocSetContextCreate(CurrentMemoryContext, "heap clustered write sort", ALLOCSET_DEFAULT_SIZES); @@ -293,7 +298,7 @@ heap_prepare_clustered_write_sort(Relation relation, { items[i].hasClusterKey = true; - for (int key = 0; key < nkeys; key++) + for (int key = 0; key < natts; key++) { AttrNumber attnum = indexRelation->rd_index->indkey.values[key]; diff --git a/src/test/regress/expected/cluster.out b/src/test/regress/expected/cluster.out index 6881faff51895..17255205c23ad 100644 --- a/src/test/regress/expected/cluster.out +++ b/src/test/regress/expected/cluster.out @@ -362,7 +362,7 @@ CREATE TABLE clstr_write_gist (id int, p point, filler text) WITH (fillfactor = INSERT INTO clstr_write_gist SELECT g, point(g, g), repeat('x', 1000) FROM generate_series(1, 20) AS g; -CREATE INDEX clstr_write_gist_p ON clstr_write_gist USING gist (p); +CREATE INDEX clstr_write_gist_p ON clstr_write_gist USING gist (p) INCLUDE (id); CLUSTER clstr_write_gist USING clstr_write_gist_p; INSERT INTO clstr_write_gist VALUES (1001, point(1.5, 1.5), repeat('y', 1000)); SELECT count(*) AS rows, count(*) FILTER (WHERE p <@ box(point(1, 1), point(2, 2))) AS nearby diff --git a/src/test/regress/sql/cluster.sql b/src/test/regress/sql/cluster.sql index 29c8b4cd26ee2..653665b8c8ee9 100644 --- a/src/test/regress/sql/cluster.sql +++ b/src/test/regress/sql/cluster.sql @@ -153,7 +153,7 @@ CREATE TABLE clstr_write_gist (id int, p point, filler text) WITH (fillfactor = INSERT INTO clstr_write_gist SELECT g, point(g, g), repeat('x', 1000) FROM generate_series(1, 20) AS g; -CREATE INDEX clstr_write_gist_p ON clstr_write_gist USING gist (p); +CREATE INDEX clstr_write_gist_p ON clstr_write_gist USING gist (p) INCLUDE (id); CLUSTER clstr_write_gist USING clstr_write_gist_p; INSERT INTO clstr_write_gist VALUES (1001, point(1.5, 1.5), repeat('y', 1000)); SELECT count(*) AS rows, count(*) FILTER (WHERE p <@ box(point(1, 1), point(2, 2))) AS nearby From e9d848e9a580db949b17b777ab1d37a1f6dd6930 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 10:07:25 +0400 Subject: [PATCH 11/81] test(heap): report clustered diff timings --- src/tools/clustered_write_bench/README | 6 ++ .../clustered_write_bench/osm2pgsql_diff.sql | 58 +++++++++++++++++-- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 12c37260712f5..021e63abbf2f0 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -97,8 +97,14 @@ where it is a better fit. Important output columns: +* `brin_enabled`: whether the optional BRIN index was present for this run. + Current placement does not consume BRIN directly, so this should normally + leave the locality metrics unchanged until a BRIN/range path is implemented. * `variant`: either the clustered-write path or the in-script control without remembered clustered-index metadata. +* `step` and `elapsed_ms`: structured timing rows for the diff insert/update + statements, emitted before the locality summary so runs can be compared + without scraping `psql` timing chatter. * `pct_inside_base_range`: higher is better; percent of diff tuples still inside the tile's original clustered heap block range. * `avg_block_drift`, `p95_block_drift`, `max_block_drift`: lower is better; diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index a1190be3ea6cf..3d6f206f73b10 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -20,7 +20,8 @@ create temp table clustered_write_settings as select (200000 * :scale)::int as base_rows, (20000 * :scale)::int as insert_rows, (20000 * :scale)::int as update_rows, - (4096 * :scale)::int as tile_count; + (4096 * :scale)::int as tile_count, + (:'use_brin')::boolean as brin_enabled; create unlogged table clustered_write_osm_diff_on ( @@ -85,7 +86,15 @@ create temp table clustered_write_settings as select (200000 * :scale)::int as base_rows, (20000 * :scale)::int as insert_rows, (20000 * :scale)::int as update_rows, - (4096 * :scale)::int as tile_count; + (4096 * :scale)::int as tile_count, + (:'use_brin')::boolean as brin_enabled; + +create temp table clustered_write_step_timings +( + step text primary key, + started_at timestamptz not null, + finished_at timestamptz +); create temp table clustered_write_base_ranges as select 'clustered_write'::text as variant, @@ -110,6 +119,9 @@ select s.base_rows + g as osm_id, from clustered_write_settings as s, generate_series(1, s.insert_rows) as g; +insert into clustered_write_step_timings +values ('clustered_write_insert', clock_timestamp(), null); + insert into clustered_write_osm_diff_on select d.osm_id, d.tile_id, @@ -117,6 +129,13 @@ select d.osm_id, repeat('insert', 16) from clustered_write_diff_inserts as d; +update clustered_write_step_timings +set finished_at = clock_timestamp() +where step = 'clustered_write_insert'; + +insert into clustered_write_step_timings +values ('without_cluster_metadata_insert', clock_timestamp(), null); + insert into clustered_write_osm_diff_off select d.osm_id, d.tile_id, @@ -124,26 +143,51 @@ select d.osm_id, repeat('insert', 16) from clustered_write_diff_inserts as d; +update clustered_write_step_timings +set finished_at = clock_timestamp() +where step = 'without_cluster_metadata_insert'; + create temp table clustered_write_diff_updates as select distinct (((g::bigint * 2654435761) % s.base_rows) + 1)::bigint as osm_id from clustered_write_settings as s, generate_series(1, s.update_rows) as g; +insert into clustered_write_step_timings +values ('clustered_write_update', clock_timestamp(), null); + update clustered_write_osm_diff_on as o set version = o.version + 1, payload = repeat('updated-row', 64) from clustered_write_diff_updates as u where o.osm_id = u.osm_id; +update clustered_write_step_timings +set finished_at = clock_timestamp() +where step = 'clustered_write_update'; + +insert into clustered_write_step_timings +values ('without_cluster_metadata_update', clock_timestamp(), null); + update clustered_write_osm_diff_off as o set version = o.version + 1, payload = repeat('updated-row', 64) from clustered_write_diff_updates as u where o.osm_id = u.osm_id; +update clustered_write_step_timings +set finished_at = clock_timestamp() +where step = 'without_cluster_metadata_update'; + analyze clustered_write_osm_diff_on; analyze clustered_write_osm_diff_off; +select s.brin_enabled, + t.step, + round((extract(epoch from t.finished_at - t.started_at) * 1000)::numeric, 2) as elapsed_ms +from clustered_write_step_timings as t +join clustered_write_settings as s on true +order by t.step; + with measured as ( select 'clustered_write'::text as variant, @@ -214,14 +258,16 @@ drift as end as block_drift from measured ) -select variant, +select s.brin_enabled, + variant, diff_kind, count(*) as rows_measured, count(distinct heap_block) as heap_blocks_touched, round(100.0 * avg((block_drift = 0)::int), 2) as pct_inside_base_range, round(avg(block_drift)::numeric, 2) as avg_block_drift, - percentile_cont(0.95) within group (order by block_drift) as p95_block_drift, + round((percentile_cont(0.95) within group (order by block_drift))::numeric, 2) as p95_block_drift, max(block_drift) as max_block_drift from drift -group by variant, diff_kind -order by variant, diff_kind; +join clustered_write_settings as s on true +group by s.brin_enabled, variant, diff_kind +order by s.brin_enabled, variant, diff_kind; From ea09d1a1871b907c95de4d33aa061d0b060ba73c Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 10:18:25 +0400 Subject: [PATCH 12/81] fix(heap): reuse clustered multi-insert target --- src/backend/access/heap/heapam.c | 21 ++++++++++++- src/backend/access/heap/hio.c | 52 ++++++++++++++++++++++++++++---- src/include/access/hio.h | 1 + 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index f9b0dba0f4284..68f2d3a6cc329 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -2239,6 +2239,7 @@ heap_insert(Relation relation, HeapTuple tup, CommandId cid, */ buffer = RelationGetBufferForTuple(relation, heaptup->t_len, heaptup, + InvalidBlockNumber, InvalidBuffer, options, bistate, &vmbuffer, NULL, 0); @@ -2494,6 +2495,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, TransactionId xid = GetCurrentTransactionId(); HeapTuple *heaptuples; int *heaptuple_slot_indexes = NULL; + BlockNumber *heaptuple_clustered_target_blocks = NULL; int i; int ndone; PGAlignedBlock scratch; @@ -2595,6 +2597,8 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, if (has_clustered_target || has_clustered_sort) { heaptuple_slot_indexes = palloc_array(int, ntuples); + heaptuple_clustered_target_blocks = + palloc_array(BlockNumber, ntuples); qsort_arg(clustered, ntuples, sizeof(HeapTupleClusteredWriteItem), heap_clustered_write_item_cmp, @@ -2603,6 +2607,8 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, { heaptuples[i] = clustered[i].tuple; heaptuple_slot_indexes[i] = clustered[i].inputIndex; + heaptuple_clustered_target_blocks[i] = + clustered[i].targetBlock; } if (sortContext.sortCxt != NULL) MemoryContextDelete(sortContext.sortCxt); @@ -2646,6 +2652,8 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, while (ndone < ntuples) { Buffer buffer; + HeapTuple clustered_target_tuple = heaptuples[ndone]; + BlockNumber clustered_target_block = InvalidBlockNumber; bool all_visible_cleared = false; bool all_frozen_set = false; int nthispage; @@ -2678,8 +2686,17 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, * Also pin visibility map page if COPY FREEZE inserts tuples into an * empty page. See all_frozen_set below. */ + if (heaptuple_clustered_target_blocks != NULL) + { + clustered_target_block = + heaptuple_clustered_target_blocks[ndone]; + if (clustered_target_block == InvalidBlockNumber) + clustered_target_tuple = NULL; + } + buffer = RelationGetBufferForTuple(relation, heaptuples[ndone]->t_len, - heaptuples[ndone], + clustered_target_tuple, + clustered_target_block, InvalidBuffer, options, bistate, &vmbuffer, NULL, npages - npages_used); @@ -2944,6 +2961,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, for (i = 0; i < ntuples; i++) slots[heaptuple_slot_indexes[i]]->tts_tid = heaptuples[i]->t_self; pfree(heaptuple_slot_indexes); + pfree(heaptuple_clustered_target_blocks); } else { @@ -4215,6 +4233,7 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup, /* It doesn't fit, must use RelationGetBufferForTuple. */ newbuf = RelationGetBufferForTuple(relation, heaptup->t_len, NULL, + InvalidBlockNumber, buffer, 0, NULL, &vmbuffer_new, &vmbuffer, 0); diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index d83ef1ce5fa75..8fe4969dbcd40 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -954,6 +954,12 @@ RelationAddBlocks(Relation relation, BulkInsertState bistate, * BULKWRITE buffer selection strategy object to the buffer manager. * Passing NULL for bistate selects the default behavior. * + * preferredBlock can pass a clustered-index target that the caller already + * computed while preparing the batch. RelationGetBufferForTuple still owns + * the page-space validation and fallback path, so a stale or full target + * page is harmless. If the preferred page is full and the caller also + * passed tuple, we lazily probe the index for the remaining candidates. + * * We don't fill existing pages further than the fillfactor, except for large * tuples in nearly-empty pages. This is OK since this routine is not * consulted when updating a tuple and keeping it on the same page, which is @@ -965,6 +971,7 @@ RelationAddBlocks(Relation relation, BulkInsertState bistate, Buffer RelationGetBufferForTuple(Relation relation, Size len, HeapTuple tuple, + BlockNumber preferredBlock, Buffer otherBuffer, uint32 options, BulkInsertState bistate, Buffer *vmbuffer, Buffer *vmbuffer_other, @@ -983,6 +990,7 @@ RelationGetBufferForTuple(Relation relation, Size len, int nclusteredTargetBlocks = 0, clusteredTargetIndex = 0; bool usingClusteredTarget = false; + bool usingPreferredBlock = false; bool unlockedTargetBuffer; bool recheckVmPins; @@ -1049,12 +1057,22 @@ RelationGetBufferForTuple(Relation relation, Size len, */ if (use_fsm && otherBuffer == InvalidBuffer) { - nclusteredTargetBlocks = - RelationGetClusteredTargetBlocksForTuple(relation, tuple, len, - clusteredTargetBlocks, - lengthof(clusteredTargetBlocks)); - if (nclusteredTargetBlocks > 0) - targetBlock = clusteredTargetBlocks[clusteredTargetIndex]; + if (BlockNumberIsValid(preferredBlock)) + { + clusteredTargetBlocks[0] = preferredBlock; + nclusteredTargetBlocks = 1; + targetBlock = preferredBlock; + usingPreferredBlock = true; + } + else if (tuple != NULL) + { + nclusteredTargetBlocks = + RelationGetClusteredTargetBlocksForTuple(relation, tuple, len, + clusteredTargetBlocks, + lengthof(clusteredTargetBlocks)); + if (nclusteredTargetBlocks > 0) + targetBlock = clusteredTargetBlocks[clusteredTargetIndex]; + } usingClusteredTarget = (targetBlock != InvalidBlockNumber); } @@ -1217,6 +1235,8 @@ RelationGetBufferForTuple(Relation relation, Size len, if (usingClusteredTarget) { + BlockNumber attemptedBlock = targetBlock; + if (use_fsm) RecordPageWithFreeSpace(relation, targetBlock, pageFreeSpace); @@ -1227,6 +1247,26 @@ RelationGetBufferForTuple(Relation relation, Size len, continue; } + if (usingPreferredBlock && tuple != NULL) + { + usingPreferredBlock = false; + nclusteredTargetBlocks = + RelationGetClusteredTargetBlocksForTuple(relation, tuple, + len, + clusteredTargetBlocks, + lengthof(clusteredTargetBlocks)); + for (clusteredTargetIndex = 0; + clusteredTargetIndex < nclusteredTargetBlocks; + clusteredTargetIndex++) + { + targetBlock = clusteredTargetBlocks[clusteredTargetIndex]; + if (targetBlock != attemptedBlock) + break; + } + if (clusteredTargetIndex < nclusteredTargetBlocks) + continue; + } + usingClusteredTarget = false; if (bistate && bistate->next_free != InvalidBlockNumber) { diff --git a/src/include/access/hio.h b/src/include/access/hio.h index 9ff30896d83af..4860f71b9f139 100644 --- a/src/include/access/hio.h +++ b/src/include/access/hio.h @@ -63,6 +63,7 @@ extern int RelationGetClusteredTargetBlocksFromIndex(Relation relation, int maxTargetBlocks); extern Buffer RelationGetBufferForTuple(Relation relation, Size len, HeapTuple tuple, + BlockNumber preferredBlock, Buffer otherBuffer, uint32 options, BulkInsertStateData *bistate, Buffer *vmbuffer, Buffer *vmbuffer_other, From e8227bea3f85e638ffa745711fc7527c848643e7 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 10:24:53 +0400 Subject: [PATCH 13/81] test(heap): add repeated clustered benchmark wrapper --- src/tools/clustered_write_bench/README | 11 ++ .../run_synthetic_bench.sh | 119 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100755 src/tools/clustered_write_bench/run_synthetic_bench.sh diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 021e63abbf2f0..95f5b79a5c878 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -8,6 +8,17 @@ Run the script against a build with clustered writes: psql -v scale=1 -v use_brin=false -f src/tools/clustered_write_bench/osm2pgsql_diff.sql postgres +For repeated runs, use the wrapper: + + REPEATS=3 SCALE_VALUES="0.1 1" BRIN_VALUES="false true" \ + src/tools/clustered_write_bench/run_synthetic_bench.sh + +The wrapper runs the same SQL against `$DBNAME` (`postgres` by default), stores +the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, +`locality.tsv`, `timing_summary.tsv`, and `locality_summary.tsv`. This is the +preferred way to compare small hot-path changes because single runs can be +noisy even with `fsync=off`. + Larger scale values increase table and diff sizes. The workload: * loads a synthetic table keyed by an OSM-like object id and a tile-like diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh new file mode 100755 index 0000000000000..2390715f7ca74 --- /dev/null +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + +DBNAME=${DBNAME:-postgres} +PSQL=${PSQL:-psql} +REPEATS=${REPEATS:-3} +SCALE_VALUES=${SCALE_VALUES:-"0.1 1"} +BRIN_VALUES=${BRIN_VALUES:-"false true"} +OUTDIR=${OUTDIR:-"$HOME/tmp/clustered-write-synthetic/$(date +%Y%m%d-%H%M%S)"} + +mkdir -p "$OUTDIR/raw" + +timings_tsv="$OUTDIR/timings.tsv" +locality_tsv="$OUTDIR/locality.tsv" +timing_summary_tsv="$OUTDIR/timing_summary.tsv" +locality_summary_tsv="$OUTDIR/locality_summary.tsv" + +printf 'run\tscale\tbrin_enabled\tstep\telapsed_ms\n' >"$timings_tsv" +printf 'run\tscale\tbrin_enabled\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" + +for scale in $SCALE_VALUES; do + for brin in $BRIN_VALUES; do + for run in $(seq 1 "$REPEATS"); do + raw="$OUTDIR/raw/scale-${scale}_brin-${brin}_run-${run}.out" + + "$PSQL" -X -v ON_ERROR_STOP=1 \ + -v scale="$scale" \ + -v use_brin="$brin" \ + -d "$DBNAME" >"$raw" <>"$timings_tsv" + + awk -F'|' \ + -v run="$run" \ + -v scale="$scale" \ + -v brin="$brin" \ + '$2 == "clustered_write" || + $2 == "without_cluster_metadata" { + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, $2, $3, $4, $5, $6, $7, $8, $9 + }' "$raw" >>"$locality_tsv" + done + done +done + +awk -F'\t' ' + BEGIN { + OFS = "\t" + print "scale", "brin_enabled", "step", "runs", "avg_elapsed_ms", "min_elapsed_ms", "max_elapsed_ms" + } + NR > 1 { + key = $2 OFS $3 OFS $4 + sum[key] += $5 + count[key]++ + if (!(key in min) || $5 < min[key]) + min[key] = $5 + if (!(key in max) || $5 > max[key]) + max[key] = $5 + } + END { + for (key in count) + printf "%s\t%d\t%.2f\t%.2f\t%.2f\n", + key, count[key], sum[key] / count[key], min[key], max[key] + } +' "$timings_tsv" >"$timing_summary_tsv" +{ + head -n 1 "$timing_summary_tsv" + tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 +} >"$timing_summary_tsv.tmp" +mv "$timing_summary_tsv.tmp" "$timing_summary_tsv" + +awk -F'\t' ' + BEGIN { + OFS = "\t" + print "scale", "brin_enabled", "variant", "diff_kind", "runs", + "avg_pct_inside_base_range", "avg_block_drift", "avg_p95_block_drift" + } + NR > 1 { + key = $2 OFS $3 OFS $4 OFS $5 + pct[key] += $8 + avg[key] += $9 + p95[key] += $10 + count[key]++ + } + END { + for (key in count) + printf "%s\t%d\t%.2f\t%.2f\t%.2f\n", + key, count[key], pct[key] / count[key], + avg[key] / count[key], p95[key] / count[key] + } +' "$locality_tsv" >"$locality_summary_tsv" +{ + head -n 1 "$locality_summary_tsv" + tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 +} >"$locality_summary_tsv.tmp" +mv "$locality_summary_tsv.tmp" "$locality_summary_tsv" + +printf 'raw output: %s/raw\n' "$OUTDIR" +printf 'timings: %s\n' "$timings_tsv" +printf 'locality: %s\n' "$locality_tsv" +printf 'timing summary: %s\n' "$timing_summary_tsv" +printf 'locality summary: %s\n' "$locality_summary_tsv" From ced31f130d38a2060aabb01be90f0768d918f03a Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 10:31:40 +0400 Subject: [PATCH 14/81] fix(heap): cache clustered batch prefix targets --- src/backend/access/heap/heapam.c | 120 +++++++++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 7 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 68f2d3a6cc329..0bf8f7337b0b5 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -58,6 +58,7 @@ #include "utils/datum.h" #include "utils/injection_point.h" #include "utils/inval.h" +#include "utils/lsyscache.h" #include "utils/memutils.h" #include "utils/relcache.h" #include "utils/sortsupport.h" @@ -86,6 +87,12 @@ typedef struct HeapTupleClusteredSortContext MemoryContext compareCxt; } HeapTupleClusteredSortContext; +typedef struct HeapTupleClusteredTargetCacheEntry +{ + Datum prefixValue; + BlockNumber targetBlock; +} HeapTupleClusteredTargetCacheEntry; + static HeapTuple heap_prepare_insert(Relation relation, HeapTuple tup, TransactionId xid, CommandId cid, uint32 options); @@ -2547,6 +2554,12 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, bool use_clustered_sort = false; bool has_clustered_target = false; bool has_clustered_sort = false; + AttrNumber prefixCacheAttnum = InvalidAttrNumber; + Oid prefixCacheCollation = InvalidOid; + RegProcedure prefixCacheEqProc = InvalidOid; + HeapTupleClusteredTargetCacheEntry *prefixTargetCache = NULL; + MemoryContext prefixCacheCompareCxt = NULL; + int nprefixTargetCache = 0; if (!IsBootstrapProcessingMode()) { @@ -2564,6 +2577,44 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, if (use_clustered_target_probe || use_clustered_sort) { + /* + * Composite clustered indexes commonly group many batch tuples by + * the leading key, while the trailing key is a new object id. A + * small per-batch cache avoids repeating the same anchored prefix + * probe for every tuple in that group. The equality function may + * detoast or allocate, so comparisons run in a resettable context. + */ + if (use_clustered_target_probe && + clusteredIndexRelation->rd_index->indnkeyatts > 1) + { + Oid eqOperator; + + prefixCacheAttnum = + clusteredIndexRelation->rd_index->indkey.values[0]; + prefixCacheCollation = + clusteredIndexRelation->rd_indcollation[0]; + eqOperator = + get_opfamily_member(clusteredIndexRelation->rd_opfamily[0], + clusteredIndexRelation->rd_opcintype[0], + clusteredIndexRelation->rd_opcintype[0], + BTEqualStrategyNumber); + if (OidIsValid(eqOperator)) + prefixCacheEqProc = get_opcode(eqOperator); + if (prefixCacheAttnum <= 0 || + !RegProcedureIsValid(prefixCacheEqProc)) + prefixCacheAttnum = InvalidAttrNumber; + else + { + prefixTargetCache = + palloc_array(HeapTupleClusteredTargetCacheEntry, + ntuples); + prefixCacheCompareCxt = + AllocSetContextCreate(CurrentMemoryContext, + "clustered target cache compare", + ALLOCSET_DEFAULT_SIZES); + } + } + clustered = palloc_array(HeapTupleClusteredWriteItem, ntuples); for (i = 0; i < ntuples; i++) { @@ -2573,14 +2624,64 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, if (use_clustered_target_probe) { BlockNumber targetBlock; + Datum prefixValue = (Datum) 0; + bool prefixIsNull = true; + bool foundCachedTarget = false; - if (RelationGetClusteredTargetBlocksFromIndex(relation, - clusteredIndexRelation, - heaptuples[i], - heaptuples[i]->t_len, - &targetBlock, - 1) > 0) - clustered[i].targetBlock = targetBlock; + if (prefixTargetCache != NULL) + { + prefixValue = + heap_getattr(heaptuples[i], + prefixCacheAttnum, + relation->rd_att, + &prefixIsNull); + if (!prefixIsNull) + { + for (int j = 0; j < nprefixTargetCache; j++) + { + bool equal; + MemoryContext oldcontext; + + oldcontext = + MemoryContextSwitchTo(prefixCacheCompareCxt); + equal = + DatumGetBool(OidFunctionCall2Coll(prefixCacheEqProc, + prefixCacheCollation, + prefixTargetCache[j].prefixValue, + prefixValue)); + MemoryContextSwitchTo(oldcontext); + MemoryContextReset(prefixCacheCompareCxt); + + if (equal) + { + clustered[i].targetBlock = + prefixTargetCache[j].targetBlock; + foundCachedTarget = true; + break; + } + } + } + } + + if (!foundCachedTarget) + { + if (RelationGetClusteredTargetBlocksFromIndex(relation, + clusteredIndexRelation, + heaptuples[i], + heaptuples[i]->t_len, + &targetBlock, + 1) > 0) + clustered[i].targetBlock = targetBlock; + + if (prefixTargetCache != NULL && !prefixIsNull) + { + prefixTargetCache[nprefixTargetCache].prefixValue = + prefixValue; + prefixTargetCache[nprefixTargetCache].targetBlock = + clustered[i].targetBlock; + nprefixTargetCache++; + } + } } clustered[i].inputIndex = i; if (clustered[i].targetBlock != InvalidBlockNumber) @@ -2617,6 +2718,11 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, MemoryContextDelete(sortContext.sortCxt); pfree(clustered); + if (prefixTargetCache != NULL) + { + pfree(prefixTargetCache); + MemoryContextDelete(prefixCacheCompareCxt); + } } if (clusteredIndexRelation != NULL) From 53df2d71588048064e759973472a994d23aee300 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 10:45:21 +0400 Subject: [PATCH 15/81] perf(heap): hash clustered prefix target cache --- src/backend/access/heap/heapam.c | 75 ++++++++++++++++--- src/tools/clustered_write_bench/README | 10 ++- .../clustered_write_bench/osm2pgsql_diff.sql | 25 +++++-- .../run_synthetic_bench.sh | 34 +++++++++ 4 files changed, 126 insertions(+), 18 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 0bf8f7337b0b5..0073bc72d8b5d 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -31,6 +31,7 @@ */ #include "postgres.h" +#include "common/hashfn.h" #include "access/genam.h" #include "access/gist.h" #include "access/gist_private.h" @@ -91,8 +92,18 @@ typedef struct HeapTupleClusteredTargetCacheEntry { Datum prefixValue; BlockNumber targetBlock; + bool occupied; } HeapTupleClusteredTargetCacheEntry; +/* + * COPY currently feeds heap_multi_insert() in batches of up to 1000 tuples. A + * small direct-mapped hash table gives by-value leading keys, such as OSM tile + * ids, cheap repeated target lookups. Collisions only cause cache misses or + * evictions: every hit is still verified by the clustered index opfamily's + * equality function before reusing the target block. + */ +#define CLUSTERED_WRITE_PREFIX_TARGET_HASH_FACTOR 4 + static HeapTuple heap_prepare_insert(Relation relation, HeapTuple tup, TransactionId xid, CommandId cid, uint32 options); @@ -2559,7 +2570,10 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, RegProcedure prefixCacheEqProc = InvalidOid; HeapTupleClusteredTargetCacheEntry *prefixTargetCache = NULL; MemoryContext prefixCacheCompareCxt = NULL; - int nprefixTargetCache = 0; + bool usePrefixHashCache = false; + int prefixTargetCacheLimit = 0; + int prefixTargetCacheSize = 0; + int prefixTargetCacheMask = 0; if (!IsBootstrapProcessingMode()) { @@ -2605,9 +2619,19 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, prefixCacheAttnum = InvalidAttrNumber; else { + prefixTargetCacheLimit = + pg_nextpower2_32(Max(16, + ntuples * + CLUSTERED_WRITE_PREFIX_TARGET_HASH_FACTOR)); + usePrefixHashCache = + get_typbyval(clusteredIndexRelation->rd_opcintype[0]); + + if (!usePrefixHashCache) + prefixTargetCacheLimit = ntuples; prefixTargetCache = - palloc_array(HeapTupleClusteredTargetCacheEntry, - ntuples); + palloc0_array(HeapTupleClusteredTargetCacheEntry, + prefixTargetCacheLimit); + prefixTargetCacheMask = prefixTargetCacheLimit - 1; prefixCacheCompareCxt = AllocSetContextCreate(CurrentMemoryContext, "clustered target cache compare", @@ -2627,6 +2651,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, Datum prefixValue = (Datum) 0; bool prefixIsNull = true; bool foundCachedTarget = false; + int prefixCacheSlot = -1; if (prefixTargetCache != NULL) { @@ -2637,17 +2662,38 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, &prefixIsNull); if (!prefixIsNull) { - for (int j = 0; j < nprefixTargetCache; j++) + int ncacheEntries; + + if (usePrefixHashCache) + { + uint32 prefixHash; + + prefixHash = hash_bytes((unsigned char *) &prefixValue, + sizeof(Datum)); + prefixCacheSlot = + prefixHash & prefixTargetCacheMask; + ncacheEntries = + prefixTargetCache[prefixCacheSlot].occupied ? + 1 : 0; + } + else + ncacheEntries = prefixTargetCacheSize; + + for (int j = 0; j < ncacheEntries; j++) { bool equal; MemoryContext oldcontext; + int cacheSlot; + + cacheSlot = usePrefixHashCache ? + prefixCacheSlot : j; oldcontext = MemoryContextSwitchTo(prefixCacheCompareCxt); equal = DatumGetBool(OidFunctionCall2Coll(prefixCacheEqProc, prefixCacheCollation, - prefixTargetCache[j].prefixValue, + prefixTargetCache[cacheSlot].prefixValue, prefixValue)); MemoryContextSwitchTo(oldcontext); MemoryContextReset(prefixCacheCompareCxt); @@ -2655,7 +2701,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, if (equal) { clustered[i].targetBlock = - prefixTargetCache[j].targetBlock; + prefixTargetCache[cacheSlot].targetBlock; foundCachedTarget = true; break; } @@ -2675,11 +2721,22 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, if (prefixTargetCache != NULL && !prefixIsNull) { - prefixTargetCache[nprefixTargetCache].prefixValue = + int cacheSlot; + + if (usePrefixHashCache) + cacheSlot = prefixCacheSlot; + else + { + Assert(prefixTargetCacheSize < + prefixTargetCacheLimit); + cacheSlot = prefixTargetCacheSize++; + } + + prefixTargetCache[cacheSlot].prefixValue = prefixValue; - prefixTargetCache[nprefixTargetCache].targetBlock = + prefixTargetCache[cacheSlot].targetBlock = clustered[i].targetBlock; - nprefixTargetCache++; + prefixTargetCache[cacheSlot].occupied = true; } } } diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 95f5b79a5c878..afad5b54b5fa9 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -13,11 +13,19 @@ For repeated runs, use the wrapper: REPEATS=3 SCALE_VALUES="0.1 1" BRIN_VALUES="false true" \ src/tools/clustered_write_bench/run_synthetic_bench.sh +To avoid accidentally benchmarking the system PostgreSQL instead of the build +under test, the wrapper can create and stop a temporary instance itself: + + USE_TEMP_INSTANCE=true PG_BINDIR=/path/to/patched/postgres/bin \ + REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false \ + src/tools/clustered_write_bench/run_synthetic_bench.sh + The wrapper runs the same SQL against `$DBNAME` (`postgres` by default), stores the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, `locality.tsv`, `timing_summary.tsv`, and `locality_summary.tsv`. This is the preferred way to compare small hot-path changes because single runs can be -noisy even with `fsync=off`. +noisy even with `fsync=off`. It also writes `server_version.txt`; check that +file before comparing runs that were not started with `USE_TEMP_INSTANCE=true`. Larger scale values increase table and diff sizes. The workload: diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index 3d6f206f73b10..db419e77c4d33 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -82,6 +82,15 @@ select current_database() as clustered_write_database \gset \connect :clustered_write_database \timing on +create function pg_temp.tid_block(tid) +returns bigint +language sql +immutable +parallel safe +as $$ + select split_part(trim(both '()' from $1::text), ',', 1)::bigint; +$$; + create temp table clustered_write_settings as select (200000 * :scale)::int as base_rows, (20000 * :scale)::int as insert_rows, @@ -99,8 +108,8 @@ create temp table clustered_write_step_timings create temp table clustered_write_base_ranges as select 'clustered_write'::text as variant, tile_id, - min(tid_block(ctid)) as min_block, - max(tid_block(ctid)) as max_block + min(pg_temp.tid_block(ctid)) as min_block, + max(pg_temp.tid_block(ctid)) as max_block from clustered_write_osm_diff_on group by tile_id @@ -108,8 +117,8 @@ union all select 'without_cluster_metadata'::text as variant, tile_id, - min(tid_block(ctid)) as min_block, - max(tid_block(ctid)) as max_block + min(pg_temp.tid_block(ctid)) as min_block, + max(pg_temp.tid_block(ctid)) as max_block from clustered_write_osm_diff_off group by tile_id; @@ -192,7 +201,7 @@ with measured as ( select 'clustered_write'::text as variant, 'insert'::text as diff_kind, - tid_block(o.ctid) as heap_block, + pg_temp.tid_block(o.ctid) as heap_block, r.min_block, r.max_block from clustered_write_osm_diff_on as o @@ -206,7 +215,7 @@ with measured as select 'clustered_write'::text as variant, 'update'::text as diff_kind, - tid_block(o.ctid) as heap_block, + pg_temp.tid_block(o.ctid) as heap_block, r.min_block, r.max_block from clustered_write_osm_diff_on as o @@ -221,7 +230,7 @@ with measured as select 'without_cluster_metadata'::text as variant, 'insert'::text as diff_kind, - tid_block(o.ctid) as heap_block, + pg_temp.tid_block(o.ctid) as heap_block, r.min_block, r.max_block from clustered_write_osm_diff_off as o @@ -235,7 +244,7 @@ with measured as select 'without_cluster_metadata'::text as variant, 'update'::text as diff_kind, - tid_block(o.ctid) as heap_block, + pg_temp.tid_block(o.ctid) as heap_block, r.min_block, r.max_block from clustered_write_osm_diff_off as o diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index 2390715f7ca74..bf83909d50f5c 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -5,6 +5,10 @@ SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) DBNAME=${DBNAME:-postgres} PSQL=${PSQL:-psql} +PG_BINDIR=${PG_BINDIR:-} +USE_TEMP_INSTANCE=${USE_TEMP_INSTANCE:-false} +PGPORT=${PGPORT:-6543} +PG_OPTS=${PG_OPTS:-} REPEATS=${REPEATS:-3} SCALE_VALUES=${SCALE_VALUES:-"0.1 1"} BRIN_VALUES=${BRIN_VALUES:-"false true"} @@ -12,6 +16,36 @@ OUTDIR=${OUTDIR:-"$HOME/tmp/clustered-write-synthetic/$(date +%Y%m%d-%H%M%S)"} mkdir -p "$OUTDIR/raw" +if [[ "$USE_TEMP_INSTANCE" == "true" ]]; then + if [[ -z "$PG_BINDIR" ]]; then + printf 'USE_TEMP_INSTANCE=true requires PG_BINDIR=/path/to/postgres/bin\n' >&2 + exit 1 + fi + + PGDATA="$OUTDIR/pgdata" + PGHOST="$OUTDIR/socket" + export PGHOST PGPORT + mkdir -p "$PGHOST" + PSQL="$PG_BINDIR/psql" + + "$PG_BINDIR/initdb" -D "$PGDATA" --auth trust --no-sync --no-instructions \ + >"$OUTDIR/initdb.log" + "$PG_BINDIR/pg_ctl" -D "$PGDATA" -l "$OUTDIR/postgres.log" \ + -o "-k $PGHOST -p $PGPORT -c listen_addresses='' -c fsync=off -c synchronous_commit=off -c full_page_writes=off $PG_OPTS" \ + start >"$OUTDIR/pg_ctl_start.log" + + stop_temp_instance() + { + "$PG_BINDIR/pg_ctl" -D "$PGDATA" stop -m fast \ + >"$OUTDIR/pg_ctl_stop.log" 2>&1 || true + } + trap stop_temp_instance EXIT +fi + +"$PSQL" -X -v ON_ERROR_STOP=1 -d "$DBNAME" \ + -c 'select version() as benchmark_postgres_version' \ + >"$OUTDIR/server_version.txt" + timings_tsv="$OUTDIR/timings.tsv" locality_tsv="$OUTDIR/locality.tsv" timing_summary_tsv="$OUTDIR/timing_summary.tsv" From a0635743830518aad0c6eabc9acfc958e594e0d2 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 10:52:41 +0400 Subject: [PATCH 16/81] perf(heap): cheapen clustered batch target probe --- src/backend/access/heap/heapam.c | 3 +- src/backend/access/heap/hio.c | 53 +++++++++++++++++++++++++------- src/include/access/hio.h | 3 +- 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 0073bc72d8b5d..5fbea75e18da8 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -2716,7 +2716,8 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, heaptuples[i], heaptuples[i]->t_len, &targetBlock, - 1) > 0) + 1, + true) > 0) clustered[i].targetBlock = targetBlock; if (prefixTargetCache != NULL && !prefixIsNull) diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index 8fe4969dbcd40..d1adbc5966242 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -201,8 +201,9 @@ ClusteredWriteRememberCandidate(Relation relation, BlockNumber nblocks, } candidates[*ncandidates] = candidate; - candidateFreeSpace[*ncandidates] = GetRecordedFreeSpace(relation, - candidate); + if (candidateFreeSpace != NULL) + candidateFreeSpace[*ncandidates] = GetRecordedFreeSpace(relation, + candidate); (*ncandidates)++; return true; @@ -278,6 +279,11 @@ ClusteredWriteRememberPrefixCandidates(Relation relation, * no matches, shorter left-prefix probes can still find the existing key * group for composite indexes such as (tile_id, osm_id). * + * firstCandidateOnly is used by heap_multi_insert batch preparation, where the + * caller only wants a cheap preferred block before the locked page-space + * recheck. If that preferred page is full, RelationGetBufferForTuple() falls + * back to the full bounded candidate search. + * * Do not use an unqualified clustered-index scan as an insertion hint. For * GiST, expression indexes, and btree rows whose leading key is NULL, such a * scan is not anchored to the tuple being inserted and tends to find the same @@ -294,13 +300,15 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, HeapTuple tuple, Size len, BlockNumber *targetBlocks, - int maxTargetBlocks) + int maxTargetBlocks, + bool firstCandidateOnly) { ScanKeyData skey[INDEX_MAX_KEYS]; IndexScanDesc scan; ItemPointer tid; BlockNumber candidates[CLUSTERED_WRITE_MAX_HEAP_BLOCKS]; Size candidateFreeSpace[CLUSTERED_WRITE_MAX_HEAP_BLOCKS]; + Size *candidateFreeSpacePtr; BlockNumber nblocks; int nkeys; int nscankeys = 0; @@ -310,6 +318,8 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, Assert(maxTargetBlocks > 0); + candidateFreeSpacePtr = firstCandidateOnly ? NULL : candidateFreeSpace; + if (IsBootstrapProcessingMode() || indexRelation == NULL || tuple == NULL || len > MaxHeapTupleSize) return 0; @@ -380,17 +390,29 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, * for the backward scan, so a very large range cannot spend all of * either budget at its head. */ - forwardCandidateLimit = ncandidates + - Max(1, remainingCandidates / 2); - forwardLimit = ntuples + Max(1, remainingTuples / 2); + if (firstCandidateOnly) + { + forwardCandidateLimit = ncandidates + 1; + forwardLimit = ntuples + 1; + } + else + { + forwardCandidateLimit = ncandidates + + Max(1, remainingCandidates / 2); + forwardLimit = ntuples + Max(1, remainingTuples / 2); + } ClusteredWriteRememberPrefixCandidates(relation, indexRelation, nblocks, skey, probeKeys, ForwardScanDirection, - candidates, candidateFreeSpace, + candidates, + candidateFreeSpacePtr, &ncandidates, &ntuples, forwardLimit, forwardCandidateLimit); + if (firstCandidateOnly && ncandidates > 0) + break; + /* * The backward edge is only needed when the forward edge did not find * any candidate page that the FSM already considers large enough. @@ -399,13 +421,14 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, */ if (ntuples < CLUSTERED_WRITE_MAX_INDEX_TIDS && ncandidates < CLUSTERED_WRITE_MAX_HEAP_BLOCKS && + !firstCandidateOnly && !ClusteredWriteHasFittingCandidate(candidateFreeSpace, ncandidates, len)) ClusteredWriteRememberPrefixCandidates(relation, indexRelation, nblocks, skey, probeKeys, BackwardScanDirection, candidates, - candidateFreeSpace, + candidateFreeSpacePtr, &ncandidates, &ntuples, CLUSTERED_WRITE_MAX_INDEX_TIDS, CLUSTERED_WRITE_MAX_HEAP_BLOCKS); @@ -450,7 +473,7 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, { ClusteredWriteRememberCandidate(relation, nblocks, tid, candidates, - candidateFreeSpace, + candidateFreeSpacePtr, &ncandidates, CLUSTERED_WRITE_MAX_HEAP_BLOCKS); } @@ -479,7 +502,7 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, { ClusteredWriteRememberCandidate(relation, nblocks, tid, candidates, - candidateFreeSpace, + candidateFreeSpacePtr, &ncandidates, CLUSTERED_WRITE_MAX_HEAP_BLOCKS); } @@ -494,6 +517,13 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, * preserving the normal locked-page free-space recheck in * RelationGetBufferForTuple(). */ + if (firstCandidateOnly) + { + if (ncandidates > 0) + targetBlocks[ntargets++] = candidates[0]; + return ntargets; + } + for (int i = 0; i < ncandidates && ntargets < maxTargetBlocks; i++) { if (candidateFreeSpace[i] >= len) @@ -538,7 +568,8 @@ RelationGetClusteredTargetBlocksForTuple(Relation relation, HeapTuple tuple, indexRelation, tuple, len, targetBlocks, - maxTargetBlocks); + maxTargetBlocks, + false); index_close(indexRelation, AccessShareLock); diff --git a/src/include/access/hio.h b/src/include/access/hio.h index 4860f71b9f139..0cdde5fc3d6ac 100644 --- a/src/include/access/hio.h +++ b/src/include/access/hio.h @@ -60,7 +60,8 @@ extern int RelationGetClusteredTargetBlocksFromIndex(Relation relation, HeapTuple tuple, Size len, BlockNumber *targetBlocks, - int maxTargetBlocks); + int maxTargetBlocks, + bool firstCandidateOnly); extern Buffer RelationGetBufferForTuple(Relation relation, Size len, HeapTuple tuple, BlockNumber preferredBlock, From 3ae3acc543998d78b4b49ea61ca37699e1b6173d Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 10:59:22 +0400 Subject: [PATCH 17/81] perf(heap): cache single-key clustered probes --- src/backend/access/heap/heapam.c | 15 +-- src/tools/clustered_write_bench/README | 7 ++ .../clustered_write_bench/osm2pgsql_diff.sql | 21 +++- .../run_synthetic_bench.sh | 96 ++++++++++--------- 4 files changed, 86 insertions(+), 53 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 5fbea75e18da8..52747413bcd9b 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -2592,14 +2592,15 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, if (use_clustered_target_probe || use_clustered_sort) { /* - * Composite clustered indexes commonly group many batch tuples by - * the leading key, while the trailing key is a new object id. A - * small per-batch cache avoids repeating the same anchored prefix - * probe for every tuple in that group. The equality function may - * detoast or allocate, so comparisons run in a resettable context. + * Clustered btree indexes commonly group many batch tuples by + * their leading key. For single-column indexes this is the exact + * clustered key; for composite indexes the trailing key can be a + * new object id. A small per-batch cache avoids repeating the same + * anchored probe for every tuple in that group. The equality + * function may detoast or allocate, so comparisons run in a + * resettable context. */ - if (use_clustered_target_probe && - clusteredIndexRelation->rd_index->indnkeyatts > 1) + if (use_clustered_target_probe) { Oid eqOperator; diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index afad5b54b5fa9..6afa964206dfe 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -20,6 +20,11 @@ under test, the wrapper can create and stop a temporary instance itself: REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false \ src/tools/clustered_write_bench/run_synthetic_bench.sh +Set `SINGLE_KEY_VALUES="false true"` to run both clustered index shapes: +`false` uses the composite `(tile_id, osm_id)` key that stresses left-prefix +placement for new object ids, while `true` uses a single-column `(tile_id)` key +closer to the experimental osm2pgsql generated-geohash index. + The wrapper runs the same SQL against `$DBNAME` (`postgres` by default), stores the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, `locality.tsv`, `timing_summary.tsv`, and `locality_summary.tsv`. This is the @@ -119,6 +124,8 @@ Important output columns: * `brin_enabled`: whether the optional BRIN index was present for this run. Current placement does not consume BRIN directly, so this should normally leave the locality metrics unchanged until a BRIN/range path is implemented. +* `single_key_cluster`: whether the remembered clustered btree index was on + just the tile key instead of `(tile_id, osm_id)`. * `variant`: either the clustered-write path or the in-script control without remembered clustered-index metadata. * `step` and `elapsed_ms`: structured timing rows for the diff insert/update diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index db419e77c4d33..77197e365b0c3 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -10,6 +10,11 @@ \set use_brin false \endif +\if :{?single_key_cluster} +\else +\set single_key_cluster false +\endif + \timing on drop table if exists clustered_write_osm_diff cascade; @@ -21,7 +26,8 @@ select (200000 * :scale)::int as base_rows, (20000 * :scale)::int as insert_rows, (20000 * :scale)::int as update_rows, (4096 * :scale)::int as tile_count, - (:'use_brin')::boolean as brin_enabled; + (:'use_brin')::boolean as brin_enabled, + (:'single_key_cluster')::boolean as single_key_cluster; create unlogged table clustered_write_osm_diff_on ( @@ -39,8 +45,13 @@ select g, from clustered_write_settings as s, generate_series(1, s.base_rows) as g; +\if :single_key_cluster +create index clustered_write_osm_diff_tile_idx + on clustered_write_osm_diff_on (tile_id); +\else create index clustered_write_osm_diff_tile_idx on clustered_write_osm_diff_on (tile_id, osm_id); +\endif \if :use_brin create index clustered_write_osm_diff_tile_brin_idx @@ -62,8 +73,13 @@ insert into clustered_write_osm_diff_off select * from clustered_write_osm_diff_on; +\if :single_key_cluster +create index clustered_write_osm_diff_off_tile_idx + on clustered_write_osm_diff_off (tile_id); +\else create index clustered_write_osm_diff_off_tile_idx on clustered_write_osm_diff_off (tile_id, osm_id); +\endif \if :use_brin create index clustered_write_osm_diff_off_tile_brin_idx @@ -96,7 +112,8 @@ select (200000 * :scale)::int as base_rows, (20000 * :scale)::int as insert_rows, (20000 * :scale)::int as update_rows, (4096 * :scale)::int as tile_count, - (:'use_brin')::boolean as brin_enabled; + (:'use_brin')::boolean as brin_enabled, + (:'single_key_cluster')::boolean as single_key_cluster; create temp table clustered_write_step_timings ( diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index bf83909d50f5c..392a19881ba00 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -12,6 +12,7 @@ PG_OPTS=${PG_OPTS:-} REPEATS=${REPEATS:-3} SCALE_VALUES=${SCALE_VALUES:-"0.1 1"} BRIN_VALUES=${BRIN_VALUES:-"false true"} +SINGLE_KEY_VALUES=${SINGLE_KEY_VALUES:-"false"} OUTDIR=${OUTDIR:-"$HOME/tmp/clustered-write-synthetic/$(date +%Y%m%d-%H%M%S)"} mkdir -p "$OUTDIR/raw" @@ -51,18 +52,20 @@ locality_tsv="$OUTDIR/locality.tsv" timing_summary_tsv="$OUTDIR/timing_summary.tsv" locality_summary_tsv="$OUTDIR/locality_summary.tsv" -printf 'run\tscale\tbrin_enabled\tstep\telapsed_ms\n' >"$timings_tsv" -printf 'run\tscale\tbrin_enabled\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\tstep\telapsed_ms\n' >"$timings_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" for scale in $SCALE_VALUES; do for brin in $BRIN_VALUES; do - for run in $(seq 1 "$REPEATS"); do - raw="$OUTDIR/raw/scale-${scale}_brin-${brin}_run-${run}.out" - - "$PSQL" -X -v ON_ERROR_STOP=1 \ - -v scale="$scale" \ - -v use_brin="$brin" \ - -d "$DBNAME" >"$raw" <"$raw" <>"$timings_tsv" - - awk -F'|' \ - -v run="$run" \ - -v scale="$scale" \ - -v brin="$brin" \ - '$2 == "clustered_write" || - $2 == "without_cluster_metadata" { - printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - run, scale, brin, $2, $3, $4, $5, $6, $7, $8, $9 - }' "$raw" >>"$locality_tsv" + awk -F'|' \ + -v run="$run" \ + -v scale="$scale" \ + -v brin="$brin" \ + -v single_key="$single_key" \ + '$2 == "clustered_write_insert" || + $2 == "clustered_write_update" || + $2 == "without_cluster_metadata_insert" || + $2 == "without_cluster_metadata_update" { + printf "%s\t%s\t%s\t%s\t%s\t%s\n", run, scale, brin, single_key, $2, $3 + }' "$raw" >>"$timings_tsv" + + awk -F'|' \ + -v run="$run" \ + -v scale="$scale" \ + -v brin="$brin" \ + -v single_key="$single_key" \ + '$2 == "clustered_write" || + $2 == "without_cluster_metadata" { + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, $2, $3, $4, $5, $6, $7, $8, $9 + }' "$raw" >>"$locality_tsv" + done done done done @@ -97,16 +103,17 @@ done awk -F'\t' ' BEGIN { OFS = "\t" - print "scale", "brin_enabled", "step", "runs", "avg_elapsed_ms", "min_elapsed_ms", "max_elapsed_ms" + print "scale", "brin_enabled", "single_key_cluster", "step", + "runs", "avg_elapsed_ms", "min_elapsed_ms", "max_elapsed_ms" } NR > 1 { - key = $2 OFS $3 OFS $4 - sum[key] += $5 + key = $2 OFS $3 OFS $4 OFS $5 + sum[key] += $6 count[key]++ - if (!(key in min) || $5 < min[key]) - min[key] = $5 - if (!(key in max) || $5 > max[key]) - max[key] = $5 + if (!(key in min) || $6 < min[key]) + min[key] = $6 + if (!(key in max) || $6 > max[key]) + max[key] = $6 } END { for (key in count) @@ -116,21 +123,22 @@ awk -F'\t' ' ' "$timings_tsv" >"$timing_summary_tsv" { head -n 1 "$timing_summary_tsv" - tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 + tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 } >"$timing_summary_tsv.tmp" mv "$timing_summary_tsv.tmp" "$timing_summary_tsv" awk -F'\t' ' BEGIN { OFS = "\t" - print "scale", "brin_enabled", "variant", "diff_kind", "runs", - "avg_pct_inside_base_range", "avg_block_drift", "avg_p95_block_drift" + print "scale", "brin_enabled", "single_key_cluster", "variant", + "diff_kind", "runs", "avg_pct_inside_base_range", + "avg_block_drift", "avg_p95_block_drift" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 - pct[key] += $8 - avg[key] += $9 - p95[key] += $10 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 + pct[key] += $9 + avg[key] += $10 + p95[key] += $11 count[key]++ } END { @@ -142,7 +150,7 @@ awk -F'\t' ' ' "$locality_tsv" >"$locality_summary_tsv" { head -n 1 "$locality_summary_tsv" - tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 + tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5 } >"$locality_summary_tsv.tmp" mv "$locality_summary_tsv.tmp" "$locality_summary_tsv" From 6b2759975d433bb26ffc1fb8ce1428708a19d8b4 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 11:09:32 +0400 Subject: [PATCH 18/81] test(heap): benchmark hot clustered duplicate inserts --- src/tools/clustered_write_bench/README | 7 ++ .../clustered_write_bench/osm2pgsql_diff.sql | 16 ++- .../run_synthetic_bench.sh | 106 ++++++++++-------- 3 files changed, 77 insertions(+), 52 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 6afa964206dfe..b0d9775d951e0 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -25,6 +25,11 @@ Set `SINGLE_KEY_VALUES="false true"` to run both clustered index shapes: placement for new object ids, while `true` uses a single-column `(tile_id)` key closer to the experimental osm2pgsql generated-geohash index. +Set `HOT_TILE_FRACTION_VALUES="0 0.9"` to stress duplicate-key diffs. A value +of `0.9` sends 90% of inserted diff rows to tile `1`, which exercises the case +where the first equal-key pages are already full and later inserts must advance +to newer nearby heap pages. + The wrapper runs the same SQL against `$DBNAME` (`postgres` by default), stores the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, `locality.tsv`, `timing_summary.tsv`, and `locality_summary.tsv`. This is the @@ -126,6 +131,8 @@ Important output columns: leave the locality metrics unchanged until a BRIN/range path is implemented. * `single_key_cluster`: whether the remembered clustered btree index was on just the tile key instead of `(tile_id, osm_id)`. +* `hot_tile_fraction`: fraction of inserted diff rows forced onto one tile key + to stress duplicate-key placement over already-full equal-key pages. * `variant`: either the clustered-write path or the in-script control without remembered clustered-index metadata. * `step` and `elapsed_ms`: structured timing rows for the diff insert/update diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index 77197e365b0c3..695d5149b149b 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -15,6 +15,11 @@ \set single_key_cluster false \endif +\if :{?hot_tile_fraction} +\else +\set hot_tile_fraction 0 +\endif + \timing on drop table if exists clustered_write_osm_diff cascade; @@ -27,7 +32,8 @@ select (200000 * :scale)::int as base_rows, (20000 * :scale)::int as update_rows, (4096 * :scale)::int as tile_count, (:'use_brin')::boolean as brin_enabled, - (:'single_key_cluster')::boolean as single_key_cluster; + (:'single_key_cluster')::boolean as single_key_cluster, + (:hot_tile_fraction)::numeric as hot_tile_fraction; create unlogged table clustered_write_osm_diff_on ( @@ -113,7 +119,8 @@ select (200000 * :scale)::int as base_rows, (20000 * :scale)::int as update_rows, (4096 * :scale)::int as tile_count, (:'use_brin')::boolean as brin_enabled, - (:'single_key_cluster')::boolean as single_key_cluster; + (:'single_key_cluster')::boolean as single_key_cluster, + (:hot_tile_fraction)::numeric as hot_tile_fraction; create temp table clustered_write_step_timings ( @@ -141,7 +148,10 @@ group by tile_id; create temp table clustered_write_diff_inserts as select s.base_rows + g as osm_id, - (((g::bigint * 1103515245 + 12345) % s.tile_count) + 1)::int as tile_id + case + when g <= (s.insert_rows * s.hot_tile_fraction)::int then 1 + else (((g::bigint * 1103515245 + 12345) % s.tile_count) + 1)::int + end as tile_id from clustered_write_settings as s, generate_series(1, s.insert_rows) as g; diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index 392a19881ba00..3cd4c7eb90421 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -13,6 +13,7 @@ REPEATS=${REPEATS:-3} SCALE_VALUES=${SCALE_VALUES:-"0.1 1"} BRIN_VALUES=${BRIN_VALUES:-"false true"} SINGLE_KEY_VALUES=${SINGLE_KEY_VALUES:-"false"} +HOT_TILE_FRACTION_VALUES=${HOT_TILE_FRACTION_VALUES:-"0"} OUTDIR=${OUTDIR:-"$HOME/tmp/clustered-write-synthetic/$(date +%Y%m%d-%H%M%S)"} mkdir -p "$OUTDIR/raw" @@ -52,20 +53,22 @@ locality_tsv="$OUTDIR/locality.tsv" timing_summary_tsv="$OUTDIR/timing_summary.tsv" locality_summary_tsv="$OUTDIR/locality_summary.tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\tstep\telapsed_ms\n' >"$timings_tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\thot_tile_fraction\tstep\telapsed_ms\n' >"$timings_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\thot_tile_fraction\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" for scale in $SCALE_VALUES; do for brin in $BRIN_VALUES; do for single_key in $SINGLE_KEY_VALUES; do - for run in $(seq 1 "$REPEATS"); do - raw="$OUTDIR/raw/scale-${scale}_brin-${brin}_single-key-${single_key}_run-${run}.out" - - "$PSQL" -X -v ON_ERROR_STOP=1 \ - -v scale="$scale" \ - -v use_brin="$brin" \ - -v single_key_cluster="$single_key" \ - -d "$DBNAME" >"$raw" <"$raw" <>"$timings_tsv" - - awk -F'|' \ - -v run="$run" \ - -v scale="$scale" \ - -v brin="$brin" \ - -v single_key="$single_key" \ - '$2 == "clustered_write" || - $2 == "without_cluster_metadata" { - printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - run, scale, brin, single_key, $2, $3, $4, $5, $6, $7, $8, $9 - }' "$raw" >>"$locality_tsv" + awk -F'|' \ + -v run="$run" \ + -v scale="$scale" \ + -v brin="$brin" \ + -v single_key="$single_key" \ + -v hot_tile_fraction="$hot_tile_fraction" \ + '$2 == "clustered_write_insert" || + $2 == "clustered_write_update" || + $2 == "without_cluster_metadata_insert" || + $2 == "without_cluster_metadata_update" { + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", run, scale, brin, single_key, hot_tile_fraction, $2, $3 + }' "$raw" >>"$timings_tsv" + + awk -F'|' \ + -v run="$run" \ + -v scale="$scale" \ + -v brin="$brin" \ + -v single_key="$single_key" \ + -v hot_tile_fraction="$hot_tile_fraction" \ + '$2 == "clustered_write" || + $2 == "without_cluster_metadata" { + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, hot_tile_fraction, $2, $3, $4, $5, $6, $7, $8, $9 + }' "$raw" >>"$locality_tsv" + done done done done @@ -103,17 +109,18 @@ done awk -F'\t' ' BEGIN { OFS = "\t" - print "scale", "brin_enabled", "single_key_cluster", "step", - "runs", "avg_elapsed_ms", "min_elapsed_ms", "max_elapsed_ms" + print "scale", "brin_enabled", "single_key_cluster", + "hot_tile_fraction", "step", "runs", "avg_elapsed_ms", + "min_elapsed_ms", "max_elapsed_ms" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 - sum[key] += $6 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 + sum[key] += $7 count[key]++ - if (!(key in min) || $6 < min[key]) - min[key] = $6 - if (!(key in max) || $6 > max[key]) - max[key] = $6 + if (!(key in min) || $7 < min[key]) + min[key] = $7 + if (!(key in max) || $7 > max[key]) + max[key] = $7 } END { for (key in count) @@ -123,22 +130,23 @@ awk -F'\t' ' ' "$timings_tsv" >"$timing_summary_tsv" { head -n 1 "$timing_summary_tsv" - tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 + tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4V -k5,5 } >"$timing_summary_tsv.tmp" mv "$timing_summary_tsv.tmp" "$timing_summary_tsv" awk -F'\t' ' BEGIN { OFS = "\t" - print "scale", "brin_enabled", "single_key_cluster", "variant", - "diff_kind", "runs", "avg_pct_inside_base_range", - "avg_block_drift", "avg_p95_block_drift" + print "scale", "brin_enabled", "single_key_cluster", + "hot_tile_fraction", "variant", "diff_kind", "runs", + "avg_pct_inside_base_range", "avg_block_drift", + "avg_p95_block_drift" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 OFS $6 - pct[key] += $9 - avg[key] += $10 - p95[key] += $11 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 + pct[key] += $10 + avg[key] += $11 + p95[key] += $12 count[key]++ } END { @@ -150,7 +158,7 @@ awk -F'\t' ' ' "$locality_tsv" >"$locality_summary_tsv" { head -n 1 "$locality_summary_tsv" - tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5 + tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4V -k5,5 -k6,6 } >"$locality_summary_tsv.tmp" mv "$locality_summary_tsv.tmp" "$locality_summary_tsv" From e3748b6b695fc8bb89d887d0f764a9bfbb9f81d5 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 11:23:48 +0400 Subject: [PATCH 19/81] perf(heap): skip clustered probes for hot batch prefixes --- src/backend/access/heap/heapam.c | 151 ++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 1 deletion(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 52747413bcd9b..c50dacc6e24dd 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -95,6 +95,13 @@ typedef struct HeapTupleClusteredTargetCacheEntry bool occupied; } HeapTupleClusteredTargetCacheEntry; +typedef struct HeapTupleClusteredPrefixCountEntry +{ + Datum prefixValue; + int ntuples; + bool occupied; +} HeapTupleClusteredPrefixCountEntry; + /* * COPY currently feeds heap_multi_insert() in batches of up to 1000 tuples. A * small direct-mapped hash table gives by-value leading keys, such as OSM tile @@ -104,6 +111,15 @@ typedef struct HeapTupleClusteredTargetCacheEntry */ #define CLUSTERED_WRITE_PREFIX_TARGET_HASH_FACTOR 4 +/* + * Very hot leading keys are a bad fit for clustered probing: the batch will + * have to spill across many heap pages anyway, and repeatedly anchoring on the + * same old page can dominate insertion time. Keep normal OSM-style tile + * grouping clustered, but let very dense equal-prefix runs use the regular + * bulk/FSM path. + */ +#define CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES 16 + static HeapTuple heap_prepare_insert(Relation relation, HeapTuple tup, TransactionId xid, CommandId cid, uint32 options); @@ -2569,10 +2585,12 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, Oid prefixCacheCollation = InvalidOid; RegProcedure prefixCacheEqProc = InvalidOid; HeapTupleClusteredTargetCacheEntry *prefixTargetCache = NULL; + HeapTupleClusteredPrefixCountEntry *prefixCountCache = NULL; MemoryContext prefixCacheCompareCxt = NULL; bool usePrefixHashCache = false; int prefixTargetCacheLimit = 0; int prefixTargetCacheSize = 0; + int prefixCountCacheSize = 0; int prefixTargetCacheMask = 0; if (!IsBootstrapProcessingMode()) @@ -2632,11 +2650,96 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, prefixTargetCache = palloc0_array(HeapTupleClusteredTargetCacheEntry, prefixTargetCacheLimit); + prefixCountCache = + palloc0_array(HeapTupleClusteredPrefixCountEntry, + prefixTargetCacheLimit); prefixTargetCacheMask = prefixTargetCacheLimit - 1; prefixCacheCompareCxt = AllocSetContextCreate(CurrentMemoryContext, "clustered target cache compare", ALLOCSET_DEFAULT_SIZES); + + for (i = 0; i < ntuples; i++) + { + Datum prefixValue; + bool prefixIsNull; + bool foundCount = false; + int prefixCacheSlot = -1; + int ncacheEntries; + + prefixValue = + heap_getattr(heaptuples[i], + prefixCacheAttnum, + relation->rd_att, + &prefixIsNull); + if (prefixIsNull) + continue; + + if (usePrefixHashCache) + { + uint32 prefixHash; + + prefixHash = hash_bytes((unsigned char *) &prefixValue, + sizeof(Datum)); + prefixCacheSlot = + prefixHash & prefixTargetCacheMask; + ncacheEntries = + prefixCountCache[prefixCacheSlot].occupied ? + 1 : 0; + } + else + ncacheEntries = prefixCountCacheSize; + + for (int j = 0; j < ncacheEntries; j++) + { + bool equal; + MemoryContext oldcontext; + int cacheSlot; + + cacheSlot = usePrefixHashCache ? + prefixCacheSlot : j; + + oldcontext = + MemoryContextSwitchTo(prefixCacheCompareCxt); + equal = + DatumGetBool(OidFunctionCall2Coll(prefixCacheEqProc, + prefixCacheCollation, + prefixCountCache[cacheSlot].prefixValue, + prefixValue)); + MemoryContextSwitchTo(oldcontext); + MemoryContextReset(prefixCacheCompareCxt); + + if (equal) + { + prefixCountCache[cacheSlot].ntuples++; + foundCount = true; + break; + } + } + + if (!foundCount) + { + int cacheSlot; + + if (usePrefixHashCache) + { + cacheSlot = prefixCacheSlot; + if (prefixCountCache[cacheSlot].occupied) + continue; + } + else + { + Assert(prefixCountCacheSize < + prefixTargetCacheLimit); + cacheSlot = prefixCountCacheSize++; + } + + prefixCountCache[cacheSlot].prefixValue = + prefixValue; + prefixCountCache[cacheSlot].ntuples = 1; + prefixCountCache[cacheSlot].occupied = true; + } + } } } @@ -2652,6 +2755,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, Datum prefixValue = (Datum) 0; bool prefixIsNull = true; bool foundCachedTarget = false; + bool skipClusteredTargetProbe = false; int prefixCacheSlot = -1; if (prefixTargetCache != NULL) @@ -2664,6 +2768,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, if (!prefixIsNull) { int ncacheEntries; + int ncountEntries; if (usePrefixHashCache) { @@ -2676,9 +2781,52 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, ncacheEntries = prefixTargetCache[prefixCacheSlot].occupied ? 1 : 0; + ncountEntries = + prefixCountCache[prefixCacheSlot].occupied ? + 1 : 0; } else + { ncacheEntries = prefixTargetCacheSize; + ncountEntries = prefixCountCacheSize; + } + + if (prefixCountCache != NULL) + { + for (int j = 0; j < ncountEntries; j++) + { + bool equal; + MemoryContext oldcontext; + int cacheSlot; + + cacheSlot = usePrefixHashCache ? + prefixCacheSlot : j; + + if (!prefixCountCache[cacheSlot].occupied) + continue; + + oldcontext = + MemoryContextSwitchTo(prefixCacheCompareCxt); + equal = + DatumGetBool(OidFunctionCall2Coll(prefixCacheEqProc, + prefixCacheCollation, + prefixCountCache[cacheSlot].prefixValue, + prefixValue)); + MemoryContextSwitchTo(oldcontext); + MemoryContextReset(prefixCacheCompareCxt); + + if (equal) + { + skipClusteredTargetProbe = + prefixCountCache[cacheSlot].ntuples > + CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES; + break; + } + } + } + + if (skipClusteredTargetProbe) + ncacheEntries = 0; for (int j = 0; j < ncacheEntries; j++) { @@ -2710,7 +2858,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, } } - if (!foundCachedTarget) + if (!foundCachedTarget && !skipClusteredTargetProbe) { if (RelationGetClusteredTargetBlocksFromIndex(relation, clusteredIndexRelation, @@ -2779,6 +2927,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, pfree(clustered); if (prefixTargetCache != NULL) { + pfree(prefixCountCache); pfree(prefixTargetCache); MemoryContextDelete(prefixCacheCompareCxt); } From 2df1fd34d6ea7d4a4c5186173d3eb0f92e2b6d1c Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 13:43:03 +0400 Subject: [PATCH 20/81] perf(heap): avoid hot-prefix rechecks in multi-insert --- src/backend/access/heap/heapam.c | 91 ++++++++----------- src/tools/clustered_write_bench/README | 6 ++ .../clustered_write_bench/osm2pgsql_diff.sql | 17 +++- 3 files changed, 57 insertions(+), 57 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index c50dacc6e24dd..99acf9a8446c6 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -2581,11 +2581,13 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, bool use_clustered_sort = false; bool has_clustered_target = false; bool has_clustered_sort = false; + bool has_skipped_clustered_target_probe = false; AttrNumber prefixCacheAttnum = InvalidAttrNumber; Oid prefixCacheCollation = InvalidOid; RegProcedure prefixCacheEqProc = InvalidOid; HeapTupleClusteredTargetCacheEntry *prefixTargetCache = NULL; HeapTupleClusteredPrefixCountEntry *prefixCountCache = NULL; + int *prefixCountSlots = NULL; MemoryContext prefixCacheCompareCxt = NULL; bool usePrefixHashCache = false; int prefixTargetCacheLimit = 0; @@ -2653,6 +2655,9 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, prefixCountCache = palloc0_array(HeapTupleClusteredPrefixCountEntry, prefixTargetCacheLimit); + prefixCountSlots = palloc_array(int, ntuples); + for (i = 0; i < ntuples; i++) + prefixCountSlots[i] = -1; prefixTargetCacheMask = prefixTargetCacheLimit - 1; prefixCacheCompareCxt = AllocSetContextCreate(CurrentMemoryContext, @@ -2712,6 +2717,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, if (equal) { prefixCountCache[cacheSlot].ntuples++; + prefixCountSlots[i] = cacheSlot; foundCount = true; break; } @@ -2738,6 +2744,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, prefixValue; prefixCountCache[cacheSlot].ntuples = 1; prefixCountCache[cacheSlot].occupied = true; + prefixCountSlots[i] = cacheSlot; } } } @@ -2760,15 +2767,27 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, if (prefixTargetCache != NULL) { - prefixValue = - heap_getattr(heaptuples[i], - prefixCacheAttnum, - relation->rd_att, - &prefixIsNull); - if (!prefixIsNull) + if (prefixCountSlots != NULL && + prefixCountSlots[i] >= 0) + { + int countSlot = prefixCountSlots[i]; + + skipClusteredTargetProbe = + prefixCountCache[countSlot].ntuples > + CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES; + } + + if (!skipClusteredTargetProbe) + { + prefixValue = + heap_getattr(heaptuples[i], + prefixCacheAttnum, + relation->rd_att, + &prefixIsNull); + } + if (!prefixIsNull && !skipClusteredTargetProbe) { int ncacheEntries; - int ncountEntries; if (usePrefixHashCache) { @@ -2781,52 +2800,9 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, ncacheEntries = prefixTargetCache[prefixCacheSlot].occupied ? 1 : 0; - ncountEntries = - prefixCountCache[prefixCacheSlot].occupied ? - 1 : 0; } else - { ncacheEntries = prefixTargetCacheSize; - ncountEntries = prefixCountCacheSize; - } - - if (prefixCountCache != NULL) - { - for (int j = 0; j < ncountEntries; j++) - { - bool equal; - MemoryContext oldcontext; - int cacheSlot; - - cacheSlot = usePrefixHashCache ? - prefixCacheSlot : j; - - if (!prefixCountCache[cacheSlot].occupied) - continue; - - oldcontext = - MemoryContextSwitchTo(prefixCacheCompareCxt); - equal = - DatumGetBool(OidFunctionCall2Coll(prefixCacheEqProc, - prefixCacheCollation, - prefixCountCache[cacheSlot].prefixValue, - prefixValue)); - MemoryContextSwitchTo(oldcontext); - MemoryContextReset(prefixCacheCompareCxt); - - if (equal) - { - skipClusteredTargetProbe = - prefixCountCache[cacheSlot].ntuples > - CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES; - break; - } - } - } - - if (skipClusteredTargetProbe) - ncacheEntries = 0; for (int j = 0; j < ncacheEntries; j++) { @@ -2857,6 +2833,8 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, } } } + if (skipClusteredTargetProbe) + has_skipped_clustered_target_probe = true; if (!foundCachedTarget && !skipClusteredTargetProbe) { @@ -2902,15 +2880,17 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, clustered, ntuples, &sortContext) > 0; - if (has_clustered_target || has_clustered_sort) + if (has_clustered_target || has_clustered_sort || + has_skipped_clustered_target_probe) { heaptuple_slot_indexes = palloc_array(int, ntuples); heaptuple_clustered_target_blocks = palloc_array(BlockNumber, ntuples); - qsort_arg(clustered, ntuples, - sizeof(HeapTupleClusteredWriteItem), - heap_clustered_write_item_cmp, - has_clustered_sort ? &sortContext : NULL); + if (has_clustered_target || has_clustered_sort) + qsort_arg(clustered, ntuples, + sizeof(HeapTupleClusteredWriteItem), + heap_clustered_write_item_cmp, + has_clustered_sort ? &sortContext : NULL); for (i = 0; i < ntuples; i++) { heaptuples[i] = clustered[i].tuple; @@ -2927,6 +2907,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, pfree(clustered); if (prefixTargetCache != NULL) { + pfree(prefixCountSlots); pfree(prefixCountCache); pfree(prefixTargetCache); MemoryContextDelete(prefixCacheCompareCxt); diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index b0d9775d951e0..6d36060ab236b 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -29,6 +29,10 @@ Set `HOT_TILE_FRACTION_VALUES="0 0.9"` to stress duplicate-key diffs. A value of `0.9` sends 90% of inserted diff rows to tile `1`, which exercises the case where the first equal-key pages are already full and later inserts must advance to newer nearby heap pages. +When this value is greater than zero, locality output splits inserts into +`insert_hot` for the forced duplicate-key rows and `insert_rest` for the +remaining rows. This keeps a hot-key cliff from being hidden by a small +well-placed non-hot tail. The wrapper runs the same SQL against `$DBNAME` (`postgres` by default), stores the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, @@ -135,6 +139,8 @@ Important output columns: to stress duplicate-key placement over already-full equal-key pages. * `variant`: either the clustered-write path or the in-script control without remembered clustered-index metadata. +* `diff_kind`: `insert`/`update` normally, or `insert_hot`/`insert_rest` when + `hot_tile_fraction` is nonzero. * `step` and `elapsed_ms`: structured timing rows for the diff insert/update statements, emitted before the locality summary so runs can be compared without scraping `psql` timing chatter. diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index 695d5149b149b..1d6440c3dded1 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -148,6 +148,7 @@ group by tile_id; create temp table clustered_write_diff_inserts as select s.base_rows + g as osm_id, + (g <= (s.insert_rows * s.hot_tile_fraction)::int) as is_hot_insert, case when g <= (s.insert_rows * s.hot_tile_fraction)::int then 1 else (((g::bigint * 1103515245 + 12345) % s.tile_count) + 1)::int @@ -227,12 +228,18 @@ order by t.step; with measured as ( select 'clustered_write'::text as variant, - 'insert'::text as diff_kind, + case + when s.hot_tile_fraction > 0 and d.is_hot_insert then 'insert_hot' + when s.hot_tile_fraction > 0 then 'insert_rest' + else 'insert' + end as diff_kind, pg_temp.tid_block(o.ctid) as heap_block, r.min_block, r.max_block from clustered_write_osm_diff_on as o join clustered_write_settings as s on true + join clustered_write_diff_inserts as d + on d.osm_id = o.osm_id join clustered_write_base_ranges as r on r.variant = 'clustered_write' and r.tile_id = o.tile_id @@ -256,12 +263,18 @@ with measured as union all select 'without_cluster_metadata'::text as variant, - 'insert'::text as diff_kind, + case + when s.hot_tile_fraction > 0 and d.is_hot_insert then 'insert_hot' + when s.hot_tile_fraction > 0 then 'insert_rest' + else 'insert' + end as diff_kind, pg_temp.tid_block(o.ctid) as heap_block, r.min_block, r.max_block from clustered_write_osm_diff_off as o join clustered_write_settings as s on true + join clustered_write_diff_inserts as d + on d.osm_id = o.osm_id join clustered_write_base_ranges as r on r.variant = 'without_cluster_metadata' and r.tile_id = o.tile_id From f82c9f58481a9ea653b8ba8dea66bfdfa2a9dd34 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 16:08:25 +0400 Subject: [PATCH 21/81] perf(heap): short-circuit fully hot clustered batches --- src/backend/access/heap/heapam.c | 259 ++++++++++++++++++------------- 1 file changed, 150 insertions(+), 109 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 99acf9a8446c6..86dd670188f33 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -2530,6 +2530,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, HeapTuple *heaptuples; int *heaptuple_slot_indexes = NULL; BlockNumber *heaptuple_clustered_target_blocks = NULL; + bool heaptuple_skip_clustered_target_lookup = false; int i; int ndone; PGAlignedBlock scratch; @@ -2594,6 +2595,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, int prefixTargetCacheSize = 0; int prefixCountCacheSize = 0; int prefixTargetCacheMask = 0; + int hotPrefixTupleCount = 0; if (!IsBootstrapProcessingMode()) { @@ -2747,150 +2749,173 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, prefixCountSlots[i] = cacheSlot; } } + + if (prefixCountCache != NULL) + { + int ncountSlots = usePrefixHashCache ? + prefixTargetCacheLimit : prefixCountCacheSize; + + for (i = 0; i < ncountSlots; i++) + { + if (prefixCountCache[i].occupied && + prefixCountCache[i].ntuples > + CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES) + hotPrefixTupleCount += + prefixCountCache[i].ntuples; + } + } } } - clustered = palloc_array(HeapTupleClusteredWriteItem, ntuples); - for (i = 0; i < ntuples; i++) + if (use_clustered_target_probe && + prefixCountCache != NULL && + hotPrefixTupleCount == ntuples) { - clustered[i].tuple = heaptuples[i]; - clustered[i].hasClusterKey = false; - clustered[i].targetBlock = InvalidBlockNumber; - if (use_clustered_target_probe) + heaptuple_skip_clustered_target_lookup = true; + clustered = NULL; + } + else + { + clustered = palloc_array(HeapTupleClusteredWriteItem, ntuples); + for (i = 0; i < ntuples; i++) { - BlockNumber targetBlock; - Datum prefixValue = (Datum) 0; - bool prefixIsNull = true; - bool foundCachedTarget = false; - bool skipClusteredTargetProbe = false; - int prefixCacheSlot = -1; - - if (prefixTargetCache != NULL) + clustered[i].tuple = heaptuples[i]; + clustered[i].hasClusterKey = false; + clustered[i].targetBlock = InvalidBlockNumber; + if (use_clustered_target_probe) { - if (prefixCountSlots != NULL && - prefixCountSlots[i] >= 0) - { - int countSlot = prefixCountSlots[i]; - - skipClusteredTargetProbe = - prefixCountCache[countSlot].ntuples > - CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES; - } + BlockNumber targetBlock; + Datum prefixValue = (Datum) 0; + bool prefixIsNull = true; + bool foundCachedTarget = false; + bool skipClusteredTargetProbe = false; + int prefixCacheSlot = -1; - if (!skipClusteredTargetProbe) - { - prefixValue = - heap_getattr(heaptuples[i], - prefixCacheAttnum, - relation->rd_att, - &prefixIsNull); - } - if (!prefixIsNull && !skipClusteredTargetProbe) + if (prefixTargetCache != NULL) { - int ncacheEntries; - - if (usePrefixHashCache) + if (prefixCountSlots != NULL && + prefixCountSlots[i] >= 0) { - uint32 prefixHash; - - prefixHash = hash_bytes((unsigned char *) &prefixValue, - sizeof(Datum)); - prefixCacheSlot = - prefixHash & prefixTargetCacheMask; - ncacheEntries = - prefixTargetCache[prefixCacheSlot].occupied ? - 1 : 0; + int countSlot = prefixCountSlots[i]; + + skipClusteredTargetProbe = + prefixCountCache[countSlot].ntuples > + CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES; } - else - ncacheEntries = prefixTargetCacheSize; - for (int j = 0; j < ncacheEntries; j++) + if (!skipClusteredTargetProbe) { - bool equal; - MemoryContext oldcontext; - int cacheSlot; - - cacheSlot = usePrefixHashCache ? - prefixCacheSlot : j; + prefixValue = + heap_getattr(heaptuples[i], + prefixCacheAttnum, + relation->rd_att, + &prefixIsNull); + } + if (!prefixIsNull && !skipClusteredTargetProbe) + { + int ncacheEntries; - oldcontext = - MemoryContextSwitchTo(prefixCacheCompareCxt); - equal = - DatumGetBool(OidFunctionCall2Coll(prefixCacheEqProc, - prefixCacheCollation, - prefixTargetCache[cacheSlot].prefixValue, - prefixValue)); - MemoryContextSwitchTo(oldcontext); - MemoryContextReset(prefixCacheCompareCxt); + if (usePrefixHashCache) + { + uint32 prefixHash; + + prefixHash = hash_bytes((unsigned char *) &prefixValue, + sizeof(Datum)); + prefixCacheSlot = + prefixHash & prefixTargetCacheMask; + ncacheEntries = + prefixTargetCache[prefixCacheSlot].occupied ? + 1 : 0; + } + else + ncacheEntries = prefixTargetCacheSize; - if (equal) + for (int j = 0; j < ncacheEntries; j++) { - clustered[i].targetBlock = - prefixTargetCache[cacheSlot].targetBlock; - foundCachedTarget = true; - break; + bool equal; + MemoryContext oldcontext; + int cacheSlot; + + cacheSlot = usePrefixHashCache ? + prefixCacheSlot : j; + + oldcontext = + MemoryContextSwitchTo(prefixCacheCompareCxt); + equal = + DatumGetBool(OidFunctionCall2Coll(prefixCacheEqProc, + prefixCacheCollation, + prefixTargetCache[cacheSlot].prefixValue, + prefixValue)); + MemoryContextSwitchTo(oldcontext); + MemoryContextReset(prefixCacheCompareCxt); + + if (equal) + { + clustered[i].targetBlock = + prefixTargetCache[cacheSlot].targetBlock; + foundCachedTarget = true; + break; + } } } } - } - if (skipClusteredTargetProbe) - has_skipped_clustered_target_probe = true; + if (skipClusteredTargetProbe) + has_skipped_clustered_target_probe = true; - if (!foundCachedTarget && !skipClusteredTargetProbe) - { - if (RelationGetClusteredTargetBlocksFromIndex(relation, - clusteredIndexRelation, - heaptuples[i], - heaptuples[i]->t_len, - &targetBlock, - 1, - true) > 0) - clustered[i].targetBlock = targetBlock; - - if (prefixTargetCache != NULL && !prefixIsNull) + if (!foundCachedTarget && !skipClusteredTargetProbe) { - int cacheSlot; - - if (usePrefixHashCache) - cacheSlot = prefixCacheSlot; - else + if (RelationGetClusteredTargetBlocksFromIndex(relation, + clusteredIndexRelation, + heaptuples[i], + heaptuples[i]->t_len, + &targetBlock, + 1, + true) > 0) + clustered[i].targetBlock = targetBlock; + + if (prefixTargetCache != NULL && !prefixIsNull) { - Assert(prefixTargetCacheSize < - prefixTargetCacheLimit); - cacheSlot = prefixTargetCacheSize++; - } + int cacheSlot; - prefixTargetCache[cacheSlot].prefixValue = - prefixValue; - prefixTargetCache[cacheSlot].targetBlock = - clustered[i].targetBlock; - prefixTargetCache[cacheSlot].occupied = true; + if (usePrefixHashCache) + cacheSlot = prefixCacheSlot; + else + { + Assert(prefixTargetCacheSize < + prefixTargetCacheLimit); + cacheSlot = prefixTargetCacheSize++; + } + + prefixTargetCache[cacheSlot].prefixValue = + prefixValue; + prefixTargetCache[cacheSlot].targetBlock = + clustered[i].targetBlock; + prefixTargetCache[cacheSlot].occupied = true; + } } } + clustered[i].inputIndex = i; + if (clustered[i].targetBlock != InvalidBlockNumber) + has_clustered_target = true; } - clustered[i].inputIndex = i; - if (clustered[i].targetBlock != InvalidBlockNumber) - has_clustered_target = true; } - if (use_clustered_sort) + if (use_clustered_sort && clustered != NULL) has_clustered_sort = heap_prepare_clustered_write_sort(relation, clusteredIndexRelation, clustered, ntuples, &sortContext) > 0; - if (has_clustered_target || has_clustered_sort || - has_skipped_clustered_target_probe) + if (has_clustered_target || has_clustered_sort) { heaptuple_slot_indexes = palloc_array(int, ntuples); heaptuple_clustered_target_blocks = palloc_array(BlockNumber, ntuples); - if (has_clustered_target || has_clustered_sort) - qsort_arg(clustered, ntuples, - sizeof(HeapTupleClusteredWriteItem), - heap_clustered_write_item_cmp, - has_clustered_sort ? &sortContext : NULL); + qsort_arg(clustered, ntuples, + sizeof(HeapTupleClusteredWriteItem), + heap_clustered_write_item_cmp, + has_clustered_sort ? &sortContext : NULL); for (i = 0; i < ntuples; i++) { heaptuples[i] = clustered[i].tuple; @@ -2901,10 +2926,24 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, if (sortContext.sortCxt != NULL) MemoryContextDelete(sortContext.sortCxt); } + else if (has_skipped_clustered_target_probe) + { + /* + * Dense equal-prefix batches intentionally skipped the + * precomputed clustered target probe. Keep the actual insert + * on the regular bulk/FSM path too, without materializing a + * per-tuple InvalidBlockNumber array solely to suppress lazy + * clustered lookups in RelationGetBufferForTuple(). + */ + heaptuple_skip_clustered_target_lookup = true; + if (sortContext.sortCxt != NULL) + MemoryContextDelete(sortContext.sortCxt); + } else if (sortContext.sortCxt != NULL) MemoryContextDelete(sortContext.sortCxt); - pfree(clustered); + if (clustered != NULL) + pfree(clustered); if (prefixTargetCache != NULL) { pfree(prefixCountSlots); @@ -2981,7 +3020,9 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, * Also pin visibility map page if COPY FREEZE inserts tuples into an * empty page. See all_frozen_set below. */ - if (heaptuple_clustered_target_blocks != NULL) + if (heaptuple_skip_clustered_target_lookup) + clustered_target_tuple = NULL; + else if (heaptuple_clustered_target_blocks != NULL) { clustered_target_block = heaptuple_clustered_target_blocks[ndone]; From 0f9ce5cdd200d923928452c12aecdc12099c8735 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 16:29:12 +0400 Subject: [PATCH 22/81] test(clustered-write): keep synthetic bench artifacts small --- src/tools/clustered_write_bench/README | 7 +++++++ .../clustered_write_bench/run_synthetic_bench.sh | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 6d36060ab236b..da7a316130925 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -40,6 +40,13 @@ the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, preferred way to compare small hot-path changes because single runs can be noisy even with `fsync=off`. It also writes `server_version.txt`; check that file before comparing runs that were not started with `USE_TEMP_INSTANCE=true`. +Raw `psql` outputs are compressed with gzip by default; set +`COMPRESS_RAW=false` to leave them as plain `.out` files. + +When `USE_TEMP_INSTANCE=true`, the temporary data directory and socket +directory are removed after the run by default, leaving the logs and summaries +behind. Set `KEEP_TEMP_INSTANCE_DATA=true` only when you need to inspect the +finished cluster. Larger scale values increase table and diff sizes. The workload: diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index 3cd4c7eb90421..e7e9695970694 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -15,6 +15,8 @@ BRIN_VALUES=${BRIN_VALUES:-"false true"} SINGLE_KEY_VALUES=${SINGLE_KEY_VALUES:-"false"} HOT_TILE_FRACTION_VALUES=${HOT_TILE_FRACTION_VALUES:-"0"} OUTDIR=${OUTDIR:-"$HOME/tmp/clustered-write-synthetic/$(date +%Y%m%d-%H%M%S)"} +COMPRESS_RAW=${COMPRESS_RAW:-true} +KEEP_TEMP_INSTANCE_DATA=${KEEP_TEMP_INSTANCE_DATA:-false} mkdir -p "$OUTDIR/raw" @@ -40,6 +42,9 @@ if [[ "$USE_TEMP_INSTANCE" == "true" ]]; then { "$PG_BINDIR/pg_ctl" -D "$PGDATA" stop -m fast \ >"$OUTDIR/pg_ctl_stop.log" 2>&1 || true + if [[ "$KEEP_TEMP_INSTANCE_DATA" != "true" ]]; then + rm -rf "$PGDATA" "$PGHOST" + fi } trap stop_temp_instance EXIT fi @@ -100,6 +105,10 @@ SQL printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", run, scale, brin, single_key, hot_tile_fraction, $2, $3, $4, $5, $6, $7, $8, $9 }' "$raw" >>"$locality_tsv" + + if [[ "$COMPRESS_RAW" == "true" ]]; then + gzip -f "$raw" + fi done done done @@ -167,3 +176,9 @@ printf 'timings: %s\n' "$timings_tsv" printf 'locality: %s\n' "$locality_tsv" printf 'timing summary: %s\n' "$timing_summary_tsv" printf 'locality summary: %s\n' "$locality_summary_tsv" +if [[ "$COMPRESS_RAW" == "true" ]]; then + printf 'raw files compressed: true\n' +fi +if [[ "$USE_TEMP_INSTANCE" == "true" && "$KEEP_TEMP_INSTANCE_DATA" != "true" ]]; then + printf 'temporary instance data removed: true\n' +fi From 3170b6b1a54c89ba151f08b04aa7f5b9087e1742 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 16:36:37 +0400 Subject: [PATCH 23/81] docs(clustered-write): record rejected benchmark experiments --- src/tools/clustered_write_bench/README | 63 ++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index da7a316130925..511e8c272c135 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -135,6 +135,69 @@ than the generated-key path, so the checked-in osm2pgsql experiment keeps the btree key while PostgreSQL keeps GiST batch-sort support available for opclasses where it is a better fit. +Rejected experiments +-------------------- + +This branch intentionally keeps a record of negative results. Clustered heap +placement has several tempting local optimizations that look obvious in code +review but lose either locality, write time, or both when replayed through the +synthetic and osm2pgsql workloads. + +Do not repeat these paths blindly: + +* **Eager all-candidates precomputation per tuple:** rejected because the + multi-insert preparation path only needs a preferred first block. Computing + the full bounded target-page list before the insertion loop moved too much + index work onto every tuple. The kept design does a cheap first-candidate + batch probe and leaves the full bounded candidate window for the lazy + full-page fallback in `RelationGetBufferForTuple()`. +* **Repointing the rest of a batch after a preferred page is full:** rejected + on hot duplicate-key smokes. It did not produce a useful locality gain and + made scale `0.1` hot-tile insert timings worse. +* **Taking the backward btree edge during cheap first-candidate probes:** + rejected for the same reason. The backward edge is still useful for the + lazy full bounded search, but doing it during the cheap batch precompute + made the common path pay for duplicate-key defence too early. +* **Target-run skipping and prefix-hit caps:** rejected while attacking the hot + duplicate-key cliff. Both variants were slower on hot-prefix scale `0.1` + smokes without fixing the poor forced-hot-row locality. +* **Bounded FIFO prefix cache:** rejected because it destroyed the synthetic + composite-prefix locality. Clustered insert locality fell to `0%` inside the + base range, so the cache must not evict the exact prefix information needed + by the current COPY batch. +* **Removing skip-only target arrays / relying on lazy clustered lookup:** + rejected because it reintroduced expensive per-tuple clustered-index probes + in fully hot batches. In the scale `1`, hot `1.0` check this path was around + `2016 ms`, far worse than the kept skip-only suppression path. +* **Target-block-only skip without preserving slot remapping:** rejected after + scale `1`, hot `1.0` checks stayed above `2100 ms`. The slot remapping is + also correctness-sensitive for COPY index maintenance and triggers. +* **Early leading-prefix whole-batch skip:** rejected because the quick + shortcut looked attractive for duplicate keys but made hot `1.0` scale `0.1` + slower in practice. +* **Early all-equal hot-prefix shortcut before cache allocation:** rejected + after the disk-pressure recovery run. It increased hot `0.9`/`1.0` scale + `0.1` insert timings in smoke runs without changing locality. +* **Datum equality fast-path for integer/OID prefixes:** rejected for now. Even + though integer `Datum` equality is semantically safe for the tested built-in + types, the extra branch did not help the workload; hot `0.9` and `1.0` smoke + timings got worse while locality stayed unchanged. +* **Btree batch sorting before target probes:** rejected on the real Georgia + osm2pgsql run (`3:14.39` create, `4:08.59` append). For this workload, + btree target-page probing beats sorting the batch first. +* **Pre-COPY clustered GiST index on `way`:** rejected as an osm2pgsql import + strategy. It worked functionally, but GiST maintenance during ingest was + too expensive (`4:53.68` create, `5:30.27` append in the Georgia run). +* **One-opened-index-per-`heap_multi_insert` batch as a simplification:** kept + out as a performance change because the single Georgia rerun did not beat + the prior generated-geohash result (`1:59.29` create, `1:56.57` append). + +The useful lesson from the rejected hot-prefix work is that duplicate-key +batches need either a genuinely local page-selection policy for the repeated +key or a very cheap decision to stop spending clustered-write work on that key. +Adding more eager probes or more per-tuple branching has repeatedly moved the +cost curve in the wrong direction. + Important output columns: * `brin_enabled`: whether the optional BRIN index was present for this run. From 1f5cc504934a437f190bb528fd245525651383da Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 17:25:39 +0400 Subject: [PATCH 24/81] perf(heap): bypass prefix cache for all-equal batches --- src/backend/access/heap/heapam.c | 78 +++++++++++++++++++++++++- src/tools/clustered_write_bench/README | 24 +++++--- 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 86dd670188f33..4104ee6055391 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -48,6 +48,7 @@ #include "catalog/pg_database.h" #include "catalog/pg_database_d.h" #include "catalog/pg_index.h" +#include "catalog/pg_type_d.h" #include "commands/vacuum.h" #include "executor/instrument_node.h" #include "pgstat.h" @@ -124,6 +125,7 @@ typedef struct HeapTupleClusteredPrefixCountEntry static HeapTuple heap_prepare_insert(Relation relation, HeapTuple tup, TransactionId xid, CommandId cid, uint32 options); static int heap_clustered_write_item_cmp(const void *a, const void *b, void *arg); +static bool heap_clustered_write_prefix_can_compare_by_datum(Oid typeOid); static bool heap_clustered_write_index_can_sort(Relation relation, Relation indexRelation); static int heap_prepare_clustered_write_sort(Relation relation, @@ -235,6 +237,21 @@ heap_clustered_write_item_cmp(const void *a, const void *b, void *arg) return 0; } +static bool +heap_clustered_write_prefix_can_compare_by_datum(Oid typeOid) +{ + switch (typeOid) + { + case INT2OID: + case INT4OID: + case INT8OID: + case OIDOID: + return true; + default: + return false; + } +} + static bool heap_clustered_write_index_can_sort(Relation relation, Relation indexRelation) { @@ -2642,6 +2659,64 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, prefixCacheAttnum = InvalidAttrNumber; else { + /* + * Very hot by-value prefixes do not need the full prefix + * cache machinery. If the whole COPY batch has the same + * simple key, skip clustered placement before allocating + * the cache and take the regular bulk/FSM path instead. + */ + if (heap_clustered_write_prefix_can_compare_by_datum( + clusteredIndexRelation->rd_opcintype[0])) + { + Datum firstPrefixValue; + Datum lastPrefixValue; + bool firstPrefixIsNull; + bool lastPrefixIsNull; + bool allSamePrefix = false; + + firstPrefixValue = + heap_getattr(heaptuples[0], + prefixCacheAttnum, + relation->rd_att, + &firstPrefixIsNull); + lastPrefixValue = + heap_getattr(heaptuples[ntuples - 1], + prefixCacheAttnum, + relation->rd_att, + &lastPrefixIsNull); + + if (!firstPrefixIsNull && !lastPrefixIsNull && + firstPrefixValue == lastPrefixValue) + { + allSamePrefix = true; + for (i = 1; i < ntuples - 1; i++) + { + Datum prefixValue; + bool prefixIsNull; + + prefixValue = + heap_getattr(heaptuples[i], + prefixCacheAttnum, + relation->rd_att, + &prefixIsNull); + if (prefixIsNull || + prefixValue != firstPrefixValue) + { + allSamePrefix = false; + break; + } + } + } + + if (allSamePrefix && + ntuples > + CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES) + hotPrefixTupleCount = ntuples; + } + + if (hotPrefixTupleCount == ntuples) + goto skip_clustered_prefix_cache; + prefixTargetCacheLimit = pg_nextpower2_32(Max(16, ntuples * @@ -2767,8 +2842,9 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, } } +skip_clustered_prefix_cache: + if (use_clustered_target_probe && - prefixCountCache != NULL && hotPrefixTupleCount == ntuples) { heaptuple_skip_clustered_target_lookup = true; diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 511e8c272c135..aa06915b06bc1 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -33,6 +33,13 @@ When this value is greater than zero, locality output splits inserts into `insert_hot` for the forced duplicate-key rows and `insert_rest` for the remaining rows. This keeps a hot-key cliff from being hidden by a small well-placed non-hot tail. +The current by-value all-equal batch guard deliberately treats a fully hot +integer/OID prefix batch as "stop spending clustered-write work": at scale `1`, +single-key clustered insert improved from the previous clean-head `1330.10 ms` +to `866.17 ms` at hot `0.9`, and from `1702.04 ms` to `725.13 ms` at hot `1.0` +in the verified temp-instance run. The hot rows still do not become local; +the win is avoiding wasted clustered-index work until a real duplicate-key page +selection policy exists. The wrapper runs the same SQL against `$DBNAME` (`postgres` by default), stores the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, @@ -175,13 +182,16 @@ Do not repeat these paths blindly: * **Early leading-prefix whole-batch skip:** rejected because the quick shortcut looked attractive for duplicate keys but made hot `1.0` scale `0.1` slower in practice. -* **Early all-equal hot-prefix shortcut before cache allocation:** rejected - after the disk-pressure recovery run. It increased hot `0.9`/`1.0` scale - `0.1` insert timings in smoke runs without changing locality. -* **Datum equality fast-path for integer/OID prefixes:** rejected for now. Even - though integer `Datum` equality is semantically safe for the tested built-in - types, the extra branch did not help the workload; hot `0.9` and `1.0` smoke - timings got worse while locality stayed unchanged. +* **Generic early all-equal hot-prefix shortcut:** rejected after the + disk-pressure recovery run. The broad version increased hot `0.9`/`1.0` + scale `0.1` insert timings in smoke runs without changing locality. The + kept variant is narrower: it only handles all-equal by-value integer/OID + prefixes and was accepted only after the scale `1` temp-instance run above. +* **Per-tuple Datum equality fast-path for integer/OID prefixes:** rejected for + now. Even though integer `Datum` equality is semantically safe for the + tested built-in types, adding the branch to ordinary prefix-cache hits did + not help the workload; hot `0.9` and `1.0` smoke timings got worse while + locality stayed unchanged. * **Btree batch sorting before target probes:** rejected on the real Georgia osm2pgsql run (`3:14.39` create, `4:08.59` append). For this workload, btree target-page probing beats sorting the batch first. From ff5da43b9113419e0aa03df0a88c89efc3e523b2 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 17:42:45 +0400 Subject: [PATCH 25/81] fix(heap): guard raw prefix comparison by value --- src/backend/access/heap/heapam.c | 3 +++ src/tools/clustered_write_bench/README | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 4104ee6055391..eeabf51101c3c 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -240,6 +240,9 @@ heap_clustered_write_item_cmp(const void *a, const void *b, void *arg) static bool heap_clustered_write_prefix_can_compare_by_datum(Oid typeOid) { + if (!get_typbyval(typeOid)) + return false; + switch (typeOid) { case INT2OID: diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index aa06915b06bc1..bb10172d1e26d 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -192,6 +192,15 @@ Do not repeat these paths blindly: tested built-in types, adding the branch to ordinary prefix-cache hits did not help the workload; hot `0.9` and `1.0` smoke timings got worse while locality stayed unchanged. +* **Near-FSM fallback after clustered candidate exhaustion:** rejected. Using + `RecordAndGetPageWithFreeSpace()` near the last attempted clustered page + looked like a cheap way to keep duplicate-key overflow physically close, but + the scale `1` single-key run made hot `0.9`/`1.0` inserts about `2.2s` with + no hot-row locality gain. +* **Skipping the whole batch when one hot prefix dominates:** rejected. A + `hotPrefixTupleCount > ntuples / 2` variant avoided preserving the small + non-hot tail, but it did not beat the kept all-hot-only guard and did not + improve hot-row locality. * **Btree batch sorting before target probes:** rejected on the real Georgia osm2pgsql run (`3:14.39` create, `4:08.59` append). For this workload, btree target-page probing beats sorting the batch first. From 7130df2e39bdce3444c0898186e216ca0bee813c Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 17:49:33 +0400 Subject: [PATCH 26/81] docs(clustered-write): record leading-run rejection --- src/tools/clustered_write_bench/README | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index bb10172d1e26d..aa5121cff95df 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -201,6 +201,10 @@ Do not repeat these paths blindly: `hotPrefixTupleCount > ntuples / 2` variant avoided preserving the small non-hot tail, but it 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:** rejected. This + tried to skip equality work for COPY batches shaped like one long duplicate + prefix followed by a small useful tail, but the scale `1` single-key run made + hot `0.9` and `1.0` slower while locality stayed unchanged. * **Btree batch sorting before target probes:** rejected on the real Georgia osm2pgsql run (`3:14.39` create, `4:08.59` append). For this workload, btree target-page probing beats sorting the batch first. From 3c8c618fa79a425f004713c796ee3d4098ccf24b Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 17:59:14 +0400 Subject: [PATCH 27/81] docs(clustered-write): record prefix-cache rejection --- src/tools/clustered_write_bench/README | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index aa5121cff95df..24200a38af8c8 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -205,6 +205,11 @@ Do not repeat these paths blindly: tried to skip equality work for COPY batches shaped like one long duplicate prefix followed by a small useful tail, but the scale `1` single-key run made hot `0.9` and `1.0` slower while locality stayed unchanged. +* **Open-addressed by-value prefix cache:** rejected. Handling hash collisions + precisely looked cleaner than the direct-mapped cache, but the scale `1` + temp-instance run made single-key insert timings worse (`780.29 ms`, + `1677.95 ms`, and `1294.90 ms` for hot `0`, `0.9`, and `1.0`) while the + locality summary stayed unchanged. * **Btree batch sorting before target probes:** rejected on the real Georgia osm2pgsql run (`3:14.39` create, `4:08.59` append). For this workload, btree target-page probing beats sorting the batch first. From e9594551d9e1924cf551b89b4ae60623cf8e4337 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 18:28:19 +0400 Subject: [PATCH 28/81] fix(bench): harden osm2pgsql diff preparation --- src/tools/clustered_write_bench/README | 36 ++++++++++++++++++- .../run_osm2pgsql_georgia_bench.sh | 26 ++++++++++---- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 24200a38af8c8..c58682da3c8e7 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -126,7 +126,41 @@ are shared across variants. The script copies PostGIS extension SQL and shared libraries into non-system PostgreSQL installs before creating the test database. This keeps the patched -build usable without installing it globally. +build usable without installing it globally. If `POSTGIS_SHARE` and +`POSTGIS_LIB` already point at the selected non-system PostgreSQL install, the +copy step is skipped so the benchmark can also use a locally built PostGIS +extension in-place. + +The simplified daily diff is written through a temporary `.osc.gz` file with an +explicit `osmium` output format. That keeps the preparation step independent +of temporary suffixes, while preserving a gzip-compressed input for repeated +append runs. + +One verified run used planet daily diff `000/004/978.osc.gz`, Geofabrik +Georgia, stock osm2pgsql `2.2.0`, and `OSM2PGSQL_CACHE_MB=1024` +`OSM2PGSQL_PROCS=2`. The `baseline_stock` row was PostgreSQL `18.3` with +PostGIS `3.6`; the `patched_stock` row was this branch's PostgreSQL `19devel` +build 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 | + +Read/locality summary from the same run: + +| variant | bbox point | bbox line | bbox polygon | roads exact | polygon exact | locality summary | 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 | + +The current result is mixed rather than a victory lap: the patched run keeps +append time roughly flat and improves the exact polygon/roads reads in this +run, but initial import and bbox line reads are worse. The locality aggregate +is identical for the two stock-osm2pgsql variants, which means this run mainly +validates the end-to-end harness and PostgreSQL/PostGIS compatibility; it does +not yet prove that stock osm2pgsql diff imports preserve clustered geometry +layout better. The experimental osm2pgsql variant is not part of PostgreSQL. For local testing, apply `osm2pgsql_cluster_during_import.patch` to a fresh osm2pgsql diff --git a/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh index 3000a80e7d3f8..6a404b71bdbad 100755 --- a/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh +++ b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh @@ -117,6 +117,7 @@ simplify_daily_diff() { local in_file="$1" local out_file="${in_file%.osc.gz}-simplified.osc.gz" + local tmp_file="$out_file.tmp.osc.gz" if [[ -s "$out_file" && "$out_file" -nt "$in_file" ]]; then log "using existing simplified diff $out_file" @@ -125,8 +126,12 @@ simplify_daily_diff() fi log "simplifying daily diff for osm2pgsql append" - "$OSMIUM" merge-changes -s -O -o "$out_file".tmp "$in_file" - mv "$out_file".tmp "$out_file" + rm -f "$tmp_file" + if ! "$OSMIUM" merge-changes -s -O --output-format=osc.gz -o "$tmp_file" "$in_file"; then + rm -f "$tmp_file" + return 1 + fi + mv "$tmp_file" "$out_file" printf '%s\n' "$out_file" } @@ -134,6 +139,8 @@ copy_postgis_into_install() { local pg_bin="$1" local pg_home + local pg_extension_dir + local pg_lib_dir pg_home="$(cd "$pg_bin/.." && pwd)" if [[ "$pg_bin" == /usr/lib/postgresql/* ]]; then @@ -141,10 +148,17 @@ copy_postgis_into_install() fi mkdir -p "$pg_home/share/extension" "$pg_home/lib" - cp -a "$POSTGIS_SHARE"/postgis* "$pg_home/share/extension/" - cp -a "$POSTGIS_SHARE"/address_standardizer* "$pg_home/share/extension/" 2>/dev/null || true - cp -a "$POSTGIS_LIB"/postgis-*.so "$pg_home/lib/" - cp -a "$POSTGIS_LIB"/postgis_raster-*.so "$pg_home/lib/" 2>/dev/null || true + pg_extension_dir="$(cd "$pg_home/share/extension" && pwd -P)" + pg_lib_dir="$(cd "$pg_home/lib" && pwd -P)" + + if [[ "$(cd "$POSTGIS_SHARE" && pwd -P)" != "$pg_extension_dir" ]]; then + cp -a "$POSTGIS_SHARE"/postgis* "$pg_home/share/extension/" + cp -a "$POSTGIS_SHARE"/address_standardizer* "$pg_home/share/extension/" 2>/dev/null || true + fi + if [[ "$(cd "$POSTGIS_LIB" && pwd -P)" != "$pg_lib_dir" ]]; then + cp -a "$POSTGIS_LIB"/postgis-*.so "$pg_home/lib/" + cp -a "$POSTGIS_LIB"/postgis_raster-*.so "$pg_home/lib/" 2>/dev/null || true + fi } start_server() From 01cf12da58f7a371ce3976a338208688bc705f92 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 18:44:07 +0400 Subject: [PATCH 29/81] fix(bench): reserve heap space for clustered osm2pgsql import --- src/tools/clustered_write_bench/README | 24 ++++- .../osm2pgsql_cluster_during_import.patch | 96 ++++++++++++++----- .../osm2pgsql_georgia_read.sql | 21 ++++ 3 files changed, 115 insertions(+), 26 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index c58682da3c8e7..bea9daa88db12 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -139,13 +139,15 @@ append runs. One verified run used planet daily diff `000/004/978.osc.gz`, Geofabrik Georgia, stock osm2pgsql `2.2.0`, and `OSM2PGSQL_CACHE_MB=1024` `OSM2PGSQL_PROCS=2`. The `baseline_stock` row was PostgreSQL `18.3` with -PostGIS `3.6`; the `patched_stock` row was this branch's PostgreSQL `19devel` -build with locally built PostGIS `3.7.0dev`. +PostGIS `3.6`; the patched rows used this branch's PostgreSQL `19devel` build +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 | Read/locality summary from the same run: @@ -153,6 +155,8 @@ Read/locality summary from the same run: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | | 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 | The current result is mixed rather than a victory lap: the patched run keeps append time roughly flat and improves the exact polygon/roads reads in this @@ -162,12 +166,24 @@ validates the end-to-end harness and PostgreSQL/PostGIS compatibility; it does not yet prove that stock osm2pgsql diff imports preserve clustered geometry layout better. +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. It did not fix +the long p95 heap span, and `pg_stats.correlation` for +`osm2pgsql_cluster_key` stayed low or negative (`line=-0.0757`, +`point=-0.2435`, `polygon=-0.6679`, `roads=-0.0230`). That means the current +pre-COPY clustered btree helps repeated-key placement and leaves room for +diffs, but it is not a replacement for actually sorting or buffering the +initial COPY stream by geometry key. + The experimental osm2pgsql variant is not part of PostgreSQL. For local testing, apply `osm2pgsql_cluster_during_import.patch` to a fresh osm2pgsql checkout and point `CLUSTERED_OSM2PGSQL` at the resulting binary. The patch adds `--cluster-during-import`, creates a generated geometry-derived geohash key -and a clustered btree index before the first COPY, and skips osm2pgsql's final -table rewrite. +and a clustered btree index before the first COPY, creates the heap with +`fillfactor=90`, and skips osm2pgsql's final table rewrite. Directly clustering the import tables with a pre-COPY GiST index on `way` was also tested as a negative control. It worked functionally, but maintaining the diff --git a/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch index 65eedf9790440..a82a4a96fed0b 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch +++ b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch @@ -2,8 +2,10 @@ diff --git a/src/command-line-parser.cpp b/src/command-line-parser.cpp index 232a70a..4a98592 100644 --- a/src/command-line-parser.cpp +++ b/src/command-line-parser.cpp -@@ -388,2 +388,8 @@ options_t parse_command_line(int argc, char *argv[]) - +@@ -386,6 +386,12 @@ options_t parse_command_line(int argc, char *argv[]) + "the columns.") + ->group("Pgsql output options"); + + // --cluster-during-import + app.add_flag("--cluster-during-import", options.cluster_during_import) + ->description("Experimental: create a clustered geometry-derived key before " @@ -11,31 +13,43 @@ index 232a70a..4a98592 100644 + ->group("Pgsql output options"); + // --keep-coastlines + app.add_flag("-K,--keep-coastlines", options.keep_coastlines) + ->description("Keep coastline data (default: discard objects tagged" diff --git a/src/options.hpp b/src/options.hpp index 4f56bfb..4cc115b 100644 --- a/src/options.hpp +++ b/src/options.hpp -@@ -138,2 +138,5 @@ struct options_t - +@@ -136,6 +136,9 @@ struct options_t + + bool enable_hstore_index = false; ///< add an index on the hstore column + + /// create a clustered btree index over a generated geometry key before the first COPY + bool cluster_during_import = false; + /// Output multi-geometries intead of several simple geometries + bool enable_multi = false; + diff --git a/src/output-pgsql.cpp b/src/output-pgsql.cpp index 6b250a6..4b38667 100644 --- a/src/output-pgsql.cpp +++ b/src/output-pgsql.cpp -@@ -513,3 +513,4 @@ output_pgsql_t::output_pgsql_t(std::shared_ptr const &mid, +@@ -511,7 +511,8 @@ output_pgsql_t::output_pgsql_t(std::shared_ptr const &mid, + m_tables.at(i) = std::make_unique( + name, type, columns, options.hstore_columns, options.projection->target_srs(), options.append, - options.hstore_mode, copy_thread, options.output_dbschema); + options.hstore_mode, copy_thread, options.output_dbschema, + options.cluster_during_import); } + } + diff --git a/src/table.cpp b/src/table.cpp -index 6b65eb9..72276b3 100644 +index 6b65eb9..f03f38b 100644 --- a/src/table.cpp +++ b/src/table.cpp -@@ -29,6 +29,7 @@ table_t::table_t(std::string const &name, std::string type, columns_t columns, +@@ -27,10 +27,11 @@ table_t::table_t(std::string const &name, std::string type, columns_t columns, + hstores_t hstore_columns, int const srid, bool const append, + hstore_column hstore_mode, std::shared_ptr const ©_thread, - std::string const &schema) + std::string const &schema, bool const cluster_during_import) @@ -45,15 +59,23 @@ index 6b65eb9..72276b3 100644 + m_hstore_mode(hstore_mode), m_cluster_during_import(cluster_during_import), + m_columns(std::move(columns)), m_hstore_columns(std::move(hstore_columns)), m_copy(copy_thread) -@@ -47,3 +48,5 @@ table_t::table_t(table_t const &other, + { + // if we dont have any columns +@@ -45,7 +46,9 @@ table_t::table_t(table_t const &other, + std::shared_ptr const ©_thread) + : m_connection_params(other.m_connection_params), m_target(other.m_target), m_type(other.m_type), m_srid(other.m_srid), m_append(other.m_append), - m_hstore_mode(other.m_hstore_mode), m_columns(other.m_columns), + m_hstore_mode(other.m_hstore_mode), + m_cluster_during_import(other.m_cluster_during_import), + m_columns(other.m_columns), m_hstore_columns(other.m_hstore_columns), m_table_space(other.m_table_space), -@@ -120,3 +123,13 @@ void table_t::start(connection_params_t const &connection_params, - + m_copy(copy_thread) + { +@@ -118,18 +121,52 @@ void table_t::start(connection_params_t const &connection_params, + sql += "\"tags\" hstore,"; + } + - sql += fmt::format("way geometry({},{}) )", m_type, m_srid); + sql += fmt::format("way geometry({},{})", m_type, m_srid); + @@ -66,9 +88,25 @@ index 6b65eb9..72276b3 100644 + } + + sql += " )"; - -@@ -132,2 +145,20 @@ void table_t::start(connection_params_t const &connection_params, - + + // The final tables are created with CREATE TABLE AS ... SELECT * FROM ... + // This means that they won't get this autovacuum setting, so it doesn't +- // doesn't need to be RESET on these tables +- sql += " WITH (autovacuum_enabled = off)"; ++ // doesn't need to be RESET on these tables. Clustered import keeps the ++ // first heap layout, so leave room for later diff rows in the same key ++ // ranges instead of filling all clustered pages during COPY. ++ if (m_cluster_during_import) { ++ sql += " WITH (autovacuum_enabled = off, fillfactor = 90)"; ++ } else { ++ sql += " WITH (autovacuum_enabled = off)"; ++ } + //add the main table space + sql += m_table_space; + + //create the table + m_db_connection->exec(sql); + + if (m_cluster_during_import) { + auto const idx_name = m_target->name() + "_cluster_key_idx"; + auto const quoted_idx_name = fmt::format(R"("{}")", idx_name); @@ -88,25 +126,29 @@ index 6b65eb9..72276b3 100644 + } + if (m_srid != "4326") { -@@ -192,13 +223,18 @@ void table_t::stop(bool updateable, bool enable_hstore_index, - + create_geom_check_trigger(*m_db_connection, m_target->schema(), + m_target->name(), "ST_IsValid(NEW.way)"); +@@ -190,17 +227,22 @@ void table_t::stop(bool updateable, bool enable_hstore_index, + m_target->name()); + } + - log_info("Clustering table '{}' by geometry...", m_target->name()); + if (m_cluster_during_import) { + log_info("Keeping table '{}' in clustered import order...", + m_target->name()); + } else { + log_info("Clustering table '{}' by geometry...", m_target->name()); - + - std::string const sql = - fmt::format("CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way", - qual_tmp_name, m_table_space, qual_name); + std::string const sql = fmt::format( + "CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way", + qual_tmp_name, m_table_space, qual_name); - + - m_db_connection->exec(sql); + m_db_connection->exec(sql); - + - m_db_connection->exec("DROP TABLE {}", qual_name); - m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")", qual_tmp_name, - m_target->name()); @@ -114,17 +156,27 @@ index 6b65eb9..72276b3 100644 + m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")", + qual_tmp_name, m_target->name()); + } - + + log_info("Creating geometry index on table '{}'...", m_target->name()); + diff --git a/src/table.hpp b/src/table.hpp index db0f73f..122e1b5 100644 --- a/src/table.hpp +++ b/src/table.hpp -@@ -34,3 +34,3 @@ public: +@@ -32,7 +32,7 @@ public: + hstores_t hstore_columns, int srid, bool append, + hstore_column hstore_mode, std::shared_ptr const ©_thread, - std::string const &schema); + std::string const &schema, bool cluster_during_import); - -@@ -79,2 +79,3 @@ private: + + table_t(table_t const &other, + std::shared_ptr const ©_thread); +@@ -77,6 +77,7 @@ private: + std::string m_srid; + bool m_append; hstore_column m_hstore_mode; + bool m_cluster_during_import; columns_t m_columns; + hstores_t m_hstore_columns; + std::string m_table_space; diff --git a/src/tools/clustered_write_bench/osm2pgsql_georgia_read.sql b/src/tools/clustered_write_bench/osm2pgsql_georgia_read.sql index bdac32b8005a1..2b8355deeef5d 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_georgia_read.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_georgia_read.sql @@ -112,3 +112,24 @@ select table_name, from geohash_blocks group by table_name order by table_name; + +\echo 'clustered import diagnostics' + +select relname, reloptions +from pg_class +where relname in ('planet_osm_point', + 'planet_osm_line', + 'planet_osm_polygon', + 'planet_osm_roads') +order by relname; + +analyze planet_osm_point; +analyze planet_osm_line; +analyze planet_osm_polygon; +analyze planet_osm_roads; + +select tablename, correlation +from pg_stats +where schemaname = 'public' + and attname = 'osm2pgsql_cluster_key' +order by tablename; From dd37d311bea093ab379161c69298749746e3795b Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 18:54:31 +0400 Subject: [PATCH 30/81] perf(bench): sort clustered osm2pgsql import by key --- src/tools/clustered_write_bench/README | 21 ++++++---- .../osm2pgsql_cluster_during_import.patch | 38 ++++++++++++++++--- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index bea9daa88db12..824be6acbcf22 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -148,6 +148,7 @@ with locally built PostGIS `3.7.0dev`. | 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, final rewrite by generated key | 2:08.24 | 1:54.96 | 2830 MB | Read/locality summary from the same run: @@ -157,6 +158,7 @@ Read/locality summary from the same run: | 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, final rewrite by generated key | 162.437 ms | 643.238 ms | 1040.441 ms | 215.840 ms | 1142.027 ms | 5606.016 ms | 7 / 2592 | 6 / 1135 | 33 / 1344.5 | The current result is mixed rather than a victory lap: the patched run keeps append time roughly flat and improves the exact polygon/roads reads in this @@ -171,19 +173,24 @@ 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. It did not fix -the long p95 heap span, and `pg_stats.correlation` for -`osm2pgsql_cluster_key` stayed low or negative (`line=-0.0757`, -`point=-0.2435`, `polygon=-0.6679`, `roads=-0.0230`). That means the current -pre-COPY clustered btree helps repeated-key placement and leaves room for -diffs, but it is not a replacement for actually sorting or buffering the -initial COPY stream by geometry key. +the long p95 heap span while the initial COPY stream stayed unordered, and +`pg_stats.correlation` for `osm2pgsql_cluster_key` stayed low or negative +(`line=-0.0757`, `point=-0.2435`, `polygon=-0.6679`, `roads=-0.0230`). + +Rewriting the final heap by `osm2pgsql_cluster_key, osm_id` fixed that +diagnosis: correlation became `1` for point, line, polygon, and roads, while +p95 span returned close to the stock geometry-clustered baseline. The cost is +a real table rewrite and a slightly larger database, so this is best read as +the correctness/performance target for a future streaming or bounded-buffer +sort before COPY rather than as proof that the pre-COPY btree alone is enough. The experimental osm2pgsql variant is not part of PostgreSQL. For local testing, apply `osm2pgsql_cluster_during_import.patch` to a fresh osm2pgsql checkout and point `CLUSTERED_OSM2PGSQL` at the resulting binary. The patch adds `--cluster-during-import`, creates a generated geometry-derived geohash key and a clustered btree index before the first COPY, creates the heap with -`fillfactor=90`, and skips osm2pgsql's final table rewrite. +`fillfactor=90`, rewrites the loaded heap by that key, and restores the +clustered btree marker before diff append. Directly clustering the import tables with a pre-COPY GiST index on `way` was also tested as a negative control. It worked functionally, but maintaining the diff --git a/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch index a82a4a96fed0b..23e5e2cd7ffbf 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch +++ b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch @@ -44,7 +44,7 @@ index 6b250a6..4b38667 100644 } diff --git a/src/table.cpp b/src/table.cpp -index 6b65eb9..f03f38b 100644 +index 6b65eb9..a79332e 100644 --- a/src/table.cpp +++ b/src/table.cpp @@ -27,10 +27,11 @@ table_t::table_t(std::string const &name, std::string type, columns_t columns, @@ -128,30 +128,56 @@ index 6b65eb9..f03f38b 100644 if (m_srid != "4326") { create_geom_check_trigger(*m_db_connection, m_target->schema(), m_target->name(), "ST_IsValid(NEW.way)"); -@@ -190,17 +227,22 @@ void table_t::stop(bool updateable, bool enable_hstore_index, +@@ -190,17 +227,48 @@ void table_t::stop(bool updateable, bool enable_hstore_index, m_target->name()); } - log_info("Clustering table '{}' by geometry...", m_target->name()); + if (m_cluster_during_import) { -+ log_info("Keeping table '{}' in clustered import order...", ++ log_info("Rewriting table '{}' by generated clustered key...", + m_target->name()); -+ } else { -+ log_info("Clustering table '{}' by geometry...", m_target->name()); - std::string const sql = - fmt::format("CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way", - qual_tmp_name, m_table_space, qual_name); + std::string const sql = fmt::format( -+ "CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way", ++ "CREATE TABLE {} WITH (autovacuum_enabled = off, " ++ "fillfactor = 90) {} AS SELECT * FROM {} " ++ "ORDER BY osm2pgsql_cluster_key, osm_id", + qual_tmp_name, m_table_space, qual_name); - m_db_connection->exec(sql); + m_db_connection->exec(sql); ++ ++ m_db_connection->exec("DROP TABLE {}", qual_name); ++ m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")", ++ qual_tmp_name, m_target->name()); ++ ++ auto const idx_name = m_target->name() + "_cluster_key_idx"; ++ auto const quoted_idx_name = fmt::format(R"("{}")", idx_name); ++ ++ check_identifier(idx_name, "index names"); ++ log_info("Restoring clustered geometry key index on table '{}'...", ++ m_target->name()); ++ m_db_connection->exec( ++ "CREATE INDEX {} ON {} USING BTREE " ++ "(osm2pgsql_cluster_key) {}", ++ quoted_idx_name, qual_name, ++ tablespace_clause(table_space_index)); ++ m_db_connection->exec("CLUSTER {} USING {}", qual_name, ++ quoted_idx_name); ++ } else { ++ log_info("Clustering table '{}' by geometry...", m_target->name()); ++ ++ std::string const sql = fmt::format( ++ "CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way", ++ qual_tmp_name, m_table_space, qual_name); - m_db_connection->exec("DROP TABLE {}", qual_name); - m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")", qual_tmp_name, - m_target->name()); ++ m_db_connection->exec(sql); ++ + m_db_connection->exec("DROP TABLE {}", qual_name); + m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")", + qual_tmp_name, m_target->name()); From 35b8218ea126713a500048c0e2d2563d7e57388d Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 19:24:56 +0400 Subject: [PATCH 31/81] docs(clustered-write): reject bounded osm2pgsql sort --- src/tools/clustered_write_bench/README | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 824be6acbcf22..33c655d29cd17 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -181,8 +181,11 @@ Rewriting the final heap by `osm2pgsql_cluster_key, osm_id` fixed that diagnosis: correlation became `1` for point, line, polygon, and roads, while p95 span returned close to the stock geometry-clustered baseline. The cost is a real table rewrite and a slightly larger database, so this is best read as -the correctness/performance target for a future streaming or bounded-buffer -sort before COPY rather than as proof that the pre-COPY btree alone is enough. +the correctness/performance target for a future global or external sort before +COPY rather than as proof that the pre-COPY btree alone is enough. Small +bounded in-memory COPY sort windows were tested after this point and rejected: +they lowered or raised initial import time depending on the window, but left +`osm2pgsql_cluster_key` correlation poor and made diff append much slower. The experimental osm2pgsql variant is not part of PostgreSQL. For local testing, apply `osm2pgsql_cluster_during_import.patch` to a fresh osm2pgsql @@ -273,6 +276,15 @@ Do not repeat these paths blindly: * **Pre-COPY clustered GiST index on `way`:** rejected as an osm2pgsql import strategy. It worked functionally, but GiST maintenance during ingest was too expensive (`4:53.68` create, `5:30.27` append in the Georgia run). +* **Bounded in-memory pre-COPY geohash sort in osm2pgsql:** rejected as a + replacement for the final generated-key rewrite. An `8192`-row table-local + buffer had `1:35.15` create and `4:33.82` append, with poor key correlation + (`line=-0.0787`, `point=-0.2400`, `polygon=-0.6597`, `roads=0.1146`) and + long p95 spans (`15412`, `2187`, `20392.5`). A larger `65536`-row buffer was + worse on write time (`2:21.27` create, `5:11.02` append) and still did not + repair correlation (`line≈0`, `point=-0.1958`, `polygon=-0.6488`, + `roads=0.4155`). The copy stream needs a global/external sort, not this + small micro-sort. * **One-opened-index-per-`heap_multi_insert` batch as a simplification:** kept out as a performance change because the single Georgia rerun did not beat the prior generated-geohash result (`1:59.29` create, `1:56.57` append). From 2c9097bc5181b8eb2fe9d2071d67b2d8bacec2bb Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 19:34:43 +0400 Subject: [PATCH 32/81] fix(bench): preserve osm2pgsql generated cluster key --- src/tools/clustered_write_bench/README | 39 ++++++++++++------- .../osm2pgsql_cluster_during_import.patch | 16 +++++--- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 33c655d29cd17..95de203b83a12 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -148,7 +148,7 @@ with locally built PostGIS `3.7.0dev`. | 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, final rewrite by generated key | 2:08.24 | 1:54.96 | 2830 MB | +| patched_clustered_import, generated-preserving rewrite by key | 1:36.92 | 2:29.90 | 2829 MB | Read/locality summary from the same run: @@ -158,7 +158,7 @@ Read/locality summary from the same run: | 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, final rewrite by generated key | 162.437 ms | 643.238 ms | 1040.441 ms | 215.840 ms | 1142.027 ms | 5606.016 ms | 7 / 2592 | 6 / 1135 | 33 / 1344.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 | The current result is mixed rather than a victory lap: the patched run keeps append time roughly flat and improves the exact polygon/roads reads in this @@ -177,23 +177,30 @@ the long p95 heap span while the initial COPY stream stayed unordered, and `pg_stats.correlation` for `osm2pgsql_cluster_key` stayed low or negative (`line=-0.0757`, `point=-0.2435`, `polygon=-0.6679`, `roads=-0.0230`). -Rewriting the final heap by `osm2pgsql_cluster_key, osm_id` fixed that -diagnosis: correlation became `1` for point, line, polygon, and roads, while -p95 span returned close to the stock geometry-clustered baseline. The cost is -a real table rewrite and a slightly larger database, so this is best read as -the correctness/performance target for a future global or external sort before -COPY rather than as proof that the pre-COPY btree alone is enough. Small -bounded in-memory COPY sort windows were tested after this point and rejected: -they lowered or raised initial import time depending on the window, but left -`osm2pgsql_cluster_key` correlation poor and made diff append much slower. +Rewriting the final heap by `osm2pgsql_cluster_key, osm_id` fixed the heap-span +diagnosis, but the first `CREATE TABLE AS SELECT *` prototype silently turned +the generated key into a plain text column. The checked-in experiment now +rebuilds the final heap with `CREATE TABLE ... LIKE ... INCLUDING GENERATED` +and ordered `INSERT`, so later diff rows keep computing the key. A catalog +check after append confirmed all four OSM tables still have stored generated +`osm2pgsql_cluster_key` columns and zero NULL keys. This is a correctness win +with a mixed timing profile: initial import is faster than the CTAS prototype +and append remains faster than the stock baseline, but exact polygon reads were +noisier/slower in this single run. The cost is still a real table rewrite, so +this is best read as the correctness/performance target for a future global or +external sort before COPY rather than as proof that the pre-COPY btree alone is +enough. Small bounded in-memory COPY sort windows were tested after this point +and rejected: they lowered or raised initial import time depending on the +window, but left `osm2pgsql_cluster_key` correlation poor and made diff append +much slower. The experimental osm2pgsql variant is not part of PostgreSQL. For local testing, apply `osm2pgsql_cluster_during_import.patch` to a fresh osm2pgsql checkout and point `CLUSTERED_OSM2PGSQL` at the resulting binary. The patch adds `--cluster-during-import`, creates a generated geometry-derived geohash key and a clustered btree index before the first COPY, creates the heap with -`fillfactor=90`, rewrites the loaded heap by that key, and restores the -clustered btree marker before diff append. +`fillfactor=90`, rewrites the loaded heap by that key while preserving the +generated column, and restores the clustered btree marker before diff append. Directly clustering the import tables with a pre-COPY GiST index on `way` was also tested as a negative control. It worked functionally, but maintaining the @@ -285,6 +292,12 @@ Do not repeat these paths blindly: repair correlation (`line≈0`, `point=-0.1958`, `polygon=-0.6488`, `roads=0.4155`). The copy stream needs a global/external sort, not this small micro-sort. +* **CTAS final rewrite for the generated-key osm2pgsql experiment:** rejected + as a correctness shape even though it had the best single-run append timing + (`1:54.96`). `CREATE TABLE AS SELECT *` does not preserve the generated + column, so later append rows would stop computing `osm2pgsql_cluster_key`. + The checked-in experiment uses `LIKE ... INCLUDING GENERATED` plus ordered + `INSERT` instead. * **One-opened-index-per-`heap_multi_insert` batch as a simplification:** kept out as a performance change because the single Georgia rerun did not beat the prior generated-geohash result (`1:59.29` create, `1:56.57` append). diff --git a/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch index 23e5e2cd7ffbf..3a201658178d4 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch +++ b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch @@ -44,7 +44,7 @@ index 6b250a6..4b38667 100644 } diff --git a/src/table.cpp b/src/table.cpp -index 6b65eb9..a79332e 100644 +index 6b65eb9..581428a 100644 --- a/src/table.cpp +++ b/src/table.cpp @@ -27,10 +27,11 @@ table_t::table_t(std::string const &name, std::string type, columns_t columns, @@ -128,7 +128,7 @@ index 6b65eb9..a79332e 100644 if (m_srid != "4326") { create_geom_check_trigger(*m_db_connection, m_target->schema(), m_target->name(), "ST_IsValid(NEW.way)"); -@@ -190,17 +227,48 @@ void table_t::stop(bool updateable, bool enable_hstore_index, +@@ -190,17 +227,52 @@ void table_t::stop(bool updateable, bool enable_hstore_index, m_target->name()); } @@ -141,13 +141,17 @@ index 6b65eb9..a79332e 100644 - fmt::format("CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way", - qual_tmp_name, m_table_space, qual_name); + std::string const sql = fmt::format( -+ "CREATE TABLE {} WITH (autovacuum_enabled = off, " -+ "fillfactor = 90) {} AS SELECT * FROM {} " -+ "ORDER BY osm2pgsql_cluster_key, osm_id", -+ qual_tmp_name, m_table_space, qual_name); ++ "CREATE TABLE {} (LIKE {} INCLUDING GENERATED) " ++ "WITH (autovacuum_enabled = off, fillfactor = 90) {}", ++ qual_tmp_name, qual_name, m_table_space); - m_db_connection->exec(sql); + m_db_connection->exec(sql); ++ m_db_connection->exec( ++ "INSERT INTO {} ({}) SELECT {} FROM {} " ++ "ORDER BY osm2pgsql_cluster_key, osm_id", ++ qual_tmp_name, m_target->rows(), m_target->rows(), ++ qual_name); + + m_db_connection->exec("DROP TABLE {}", qual_name); + m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")", From b834bac2a1377f5e93e8cea25760161db6945799 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 19:46:22 +0400 Subject: [PATCH 33/81] docs(clustered-write): reject varlena prefix hash cache --- src/tools/clustered_write_bench/README | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 95de203b83a12..1219bba14b5ed 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -298,6 +298,14 @@ Do not repeat these paths blindly: column, so later append rows would stop computing `osm2pgsql_cluster_key`. The checked-in experiment uses `LIKE ... INCLUDING GENERATED` plus ordered `INSERT` instead. +* **`datum_image_hash()` prefix cache for varlena leading keys:** rejected. + This tried to make the clustered btree prefix cache direct-mapped for the + generated text geohash key instead of doing linear opfamily equality checks. + On the Georgia `978` generated-preserving run it did not improve writes: + create regressed to `2:10.79` and append to `2:37.58`, while locality stayed + the same (`line 4/2362.25`, `point 4/1097`, `polygon 9/1289` p95 + blocks/span). Exact polygon reads were faster in that single run, but the + write path got worse, so the code change was reverted. * **One-opened-index-per-`heap_multi_insert` batch as a simplification:** kept out as a performance change because the single Georgia rerun did not beat the prior generated-geohash result (`1:59.29` create, `1:56.57` append). From 0d27067d5aa5033441e7c2a1c1534986650f77d7 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 19:51:55 +0400 Subject: [PATCH 34/81] test(clustered-write): add text-key synthetic benchmark --- src/tools/clustered_write_bench/README | 13 +- .../clustered_write_bench/osm2pgsql_diff.sql | 56 ++++++-- .../run_synthetic_bench.sh | 121 +++++++++--------- 3 files changed, 121 insertions(+), 69 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 1219bba14b5ed..835be3432b446 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -25,6 +25,14 @@ Set `SINGLE_KEY_VALUES="false true"` to run both clustered index shapes: placement for new object ids, while `true` uses a single-column `(tile_id)` key closer to the experimental osm2pgsql generated-geohash index. +Set `TEXT_KEY_VALUES="false true"` to repeat the run with a stored generated +`text` clustering key. This keeps the integer `tile_id` for locality +measurement, but clusters on `cluster_key`, a deterministic text encoding of +the same tile. It is a cheap guard for geohash-like osm2pgsql experiments and +varlena btree-prefix changes before spending time on the full Georgia import. +When `use_brin=true`, the optional BRIN index follows the active clustering key +(`tile_id` for integer mode, `cluster_key` for text mode). + Set `HOT_TILE_FRACTION_VALUES="0 0.9"` to stress duplicate-key diffs. A value of `0.9` sends 90% of inserted diff rows to tile `1`, which exercises the case where the first equal-key pages are already full and later inserts must advance @@ -58,7 +66,8 @@ finished cluster. Larger scale values increase table and diff sizes. The workload: * loads a synthetic table keyed by an OSM-like object id and a tile-like - clustering key; + clustering key, either integer or a generated text key over the same tile + domain; * creates two physically clustered copies of the heap; * leaves one copy with remembered clustered-index metadata and clears that metadata on the other copy with `ALTER TABLE ... SET WITHOUT CLUSTER`; @@ -323,6 +332,8 @@ Important output columns: leave the locality metrics unchanged until a BRIN/range path is implemented. * `single_key_cluster`: whether the remembered clustered btree index was on just the tile key instead of `(tile_id, osm_id)`. +* `text_cluster_key`: whether the remembered clustered btree index used the + generated text `cluster_key` instead of the integer tile columns. * `hot_tile_fraction`: fraction of inserted diff rows forced onto one tile key to stress duplicate-key placement over already-full equal-key pages. * `variant`: either the clustered-write path or the in-script control without diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index 1d6440c3dded1..d38946229c688 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -15,6 +15,11 @@ \set single_key_cluster false \endif +\if :{?text_cluster_key} +\else +\set text_cluster_key false +\endif + \if :{?hot_tile_fraction} \else \set hot_tile_fraction 0 @@ -33,17 +38,19 @@ select (200000 * :scale)::int as base_rows, (4096 * :scale)::int as tile_count, (:'use_brin')::boolean as brin_enabled, (:'single_key_cluster')::boolean as single_key_cluster, + (:'text_cluster_key')::boolean as text_cluster_key, (:hot_tile_fraction)::numeric as hot_tile_fraction; create unlogged table clustered_write_osm_diff_on ( osm_id bigint primary key, tile_id int not null, + cluster_key text generated always as ('g' || lpad(tile_id::text, 8, '0')) stored, version int not null, payload text not null ) with (fillfactor = 90); -insert into clustered_write_osm_diff_on +insert into clustered_write_osm_diff_on (osm_id, tile_id, version, payload) select g, ((g - 1) % s.tile_count) + 1, 1, @@ -51,18 +58,28 @@ select g, from clustered_write_settings as s, generate_series(1, s.base_rows) as g; -\if :single_key_cluster +\if :text_cluster_key create index clustered_write_osm_diff_tile_idx - on clustered_write_osm_diff_on (tile_id); + on clustered_write_osm_diff_on (cluster_key); \else + \if :single_key_cluster +create index clustered_write_osm_diff_tile_idx + on clustered_write_osm_diff_on (tile_id); + \else create index clustered_write_osm_diff_tile_idx on clustered_write_osm_diff_on (tile_id, osm_id); + \endif \endif \if :use_brin +\if :text_cluster_key +create index clustered_write_osm_diff_tile_brin_idx + on clustered_write_osm_diff_on using brin (cluster_key) with (pages_per_range = 32); +\else create index clustered_write_osm_diff_tile_brin_idx on clustered_write_osm_diff_on using brin (tile_id) with (pages_per_range = 32); \endif +\endif cluster clustered_write_osm_diff_on using clustered_write_osm_diff_tile_idx; analyze clustered_write_osm_diff_on; @@ -71,26 +88,40 @@ create unlogged table clustered_write_osm_diff_off ( osm_id bigint primary key, tile_id int not null, + cluster_key text generated always as ('g' || lpad(tile_id::text, 8, '0')) stored, version int not null, payload text not null ) with (fillfactor = 90); -insert into clustered_write_osm_diff_off -select * +insert into clustered_write_osm_diff_off (osm_id, tile_id, version, payload) +select osm_id, + tile_id, + version, + payload from clustered_write_osm_diff_on; -\if :single_key_cluster +\if :text_cluster_key create index clustered_write_osm_diff_off_tile_idx - on clustered_write_osm_diff_off (tile_id); + on clustered_write_osm_diff_off (cluster_key); \else + \if :single_key_cluster +create index clustered_write_osm_diff_off_tile_idx + on clustered_write_osm_diff_off (tile_id); + \else create index clustered_write_osm_diff_off_tile_idx on clustered_write_osm_diff_off (tile_id, osm_id); + \endif \endif \if :use_brin +\if :text_cluster_key +create index clustered_write_osm_diff_off_tile_brin_idx + on clustered_write_osm_diff_off using brin (cluster_key) with (pages_per_range = 32); +\else create index clustered_write_osm_diff_off_tile_brin_idx on clustered_write_osm_diff_off using brin (tile_id) with (pages_per_range = 32); \endif +\endif cluster clustered_write_osm_diff_off using clustered_write_osm_diff_off_tile_idx; alter table clustered_write_osm_diff_off set without cluster; @@ -120,6 +151,7 @@ select (200000 * :scale)::int as base_rows, (4096 * :scale)::int as tile_count, (:'use_brin')::boolean as brin_enabled, (:'single_key_cluster')::boolean as single_key_cluster, + (:'text_cluster_key')::boolean as text_cluster_key, (:hot_tile_fraction)::numeric as hot_tile_fraction; create temp table clustered_write_step_timings @@ -159,7 +191,7 @@ from clustered_write_settings as s, insert into clustered_write_step_timings values ('clustered_write_insert', clock_timestamp(), null); -insert into clustered_write_osm_diff_on +insert into clustered_write_osm_diff_on (osm_id, tile_id, version, payload) select d.osm_id, d.tile_id, 1, @@ -173,7 +205,7 @@ where step = 'clustered_write_insert'; insert into clustered_write_step_timings values ('without_cluster_metadata_insert', clock_timestamp(), null); -insert into clustered_write_osm_diff_off +insert into clustered_write_osm_diff_off (osm_id, tile_id, version, payload) select d.osm_id, d.tile_id, 1, @@ -219,6 +251,7 @@ analyze clustered_write_osm_diff_on; analyze clustered_write_osm_diff_off; select s.brin_enabled, + s.text_cluster_key, t.step, round((extract(epoch from t.finished_at - t.started_at) * 1000)::numeric, 2) as elapsed_ms from clustered_write_step_timings as t @@ -308,6 +341,7 @@ drift as from measured ) select s.brin_enabled, + s.text_cluster_key, variant, diff_kind, count(*) as rows_measured, @@ -318,5 +352,5 @@ select s.brin_enabled, max(block_drift) as max_block_drift from drift join clustered_write_settings as s on true -group by s.brin_enabled, variant, diff_kind -order by s.brin_enabled, variant, diff_kind; +group by s.brin_enabled, s.text_cluster_key, variant, diff_kind +order by s.brin_enabled, s.text_cluster_key, variant, diff_kind; diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index e7e9695970694..f231f29b3b977 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -13,6 +13,7 @@ REPEATS=${REPEATS:-3} SCALE_VALUES=${SCALE_VALUES:-"0.1 1"} BRIN_VALUES=${BRIN_VALUES:-"false true"} SINGLE_KEY_VALUES=${SINGLE_KEY_VALUES:-"false"} +TEXT_KEY_VALUES=${TEXT_KEY_VALUES:-"false"} HOT_TILE_FRACTION_VALUES=${HOT_TILE_FRACTION_VALUES:-"0"} OUTDIR=${OUTDIR:-"$HOME/tmp/clustered-write-synthetic/$(date +%Y%m%d-%H%M%S)"} COMPRESS_RAW=${COMPRESS_RAW:-true} @@ -58,22 +59,24 @@ locality_tsv="$OUTDIR/locality.tsv" timing_summary_tsv="$OUTDIR/timing_summary.tsv" locality_summary_tsv="$OUTDIR/locality_summary.tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\thot_tile_fraction\tstep\telapsed_ms\n' >"$timings_tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\thot_tile_fraction\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\thot_tile_fraction\tstep\telapsed_ms\n' >"$timings_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\thot_tile_fraction\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" for scale in $SCALE_VALUES; do for brin in $BRIN_VALUES; do for single_key in $SINGLE_KEY_VALUES; do - for hot_tile_fraction in $HOT_TILE_FRACTION_VALUES; do - for run in $(seq 1 "$REPEATS"); do - raw="$OUTDIR/raw/scale-${scale}_brin-${brin}_single-key-${single_key}_hot-tile-${hot_tile_fraction}_run-${run}.out" - - "$PSQL" -X -v ON_ERROR_STOP=1 \ - -v scale="$scale" \ - -v use_brin="$brin" \ - -v single_key_cluster="$single_key" \ - -v hot_tile_fraction="$hot_tile_fraction" \ - -d "$DBNAME" >"$raw" <"$raw" <>"$timings_tsv" - - awk -F'|' \ - -v run="$run" \ - -v scale="$scale" \ - -v brin="$brin" \ - -v single_key="$single_key" \ - -v hot_tile_fraction="$hot_tile_fraction" \ - '$2 == "clustered_write" || - $2 == "without_cluster_metadata" { - printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - run, scale, brin, single_key, hot_tile_fraction, $2, $3, $4, $5, $6, $7, $8, $9 - }' "$raw" >>"$locality_tsv" - - if [[ "$COMPRESS_RAW" == "true" ]]; then - gzip -f "$raw" - fi + awk -F'|' \ + -v run="$run" \ + -v scale="$scale" \ + -v brin="$brin" \ + -v single_key="$single_key" \ + -v text_cluster_key="$text_cluster_key" \ + -v hot_tile_fraction="$hot_tile_fraction" \ + '$3 == "clustered_write_insert" || + $3 == "clustered_write_update" || + $3 == "without_cluster_metadata_insert" || + $3 == "without_cluster_metadata_update" { + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, text_cluster_key, hot_tile_fraction, $3, $4 + }' "$raw" >>"$timings_tsv" + + awk -F'|' \ + -v run="$run" \ + -v scale="$scale" \ + -v brin="$brin" \ + -v single_key="$single_key" \ + -v text_cluster_key="$text_cluster_key" \ + -v hot_tile_fraction="$hot_tile_fraction" \ + '$3 == "clustered_write" || + $3 == "without_cluster_metadata" { + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, text_cluster_key, hot_tile_fraction, $3, $4, $5, $6, $7, $8, $9, $10 + }' "$raw" >>"$locality_tsv" + + if [[ "$COMPRESS_RAW" == "true" ]]; then + gzip -f "$raw" + fi + done done done done @@ -119,17 +126,17 @@ awk -F'\t' ' BEGIN { OFS = "\t" print "scale", "brin_enabled", "single_key_cluster", - "hot_tile_fraction", "step", "runs", "avg_elapsed_ms", - "min_elapsed_ms", "max_elapsed_ms" + "text_cluster_key", "hot_tile_fraction", "step", "runs", + "avg_elapsed_ms", "min_elapsed_ms", "max_elapsed_ms" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 OFS $6 - sum[key] += $7 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 + sum[key] += $8 count[key]++ - if (!(key in min) || $7 < min[key]) - min[key] = $7 - if (!(key in max) || $7 > max[key]) - max[key] = $7 + if (!(key in min) || $8 < min[key]) + min[key] = $8 + if (!(key in max) || $8 > max[key]) + max[key] = $8 } END { for (key in count) @@ -139,7 +146,7 @@ awk -F'\t' ' ' "$timings_tsv" >"$timing_summary_tsv" { head -n 1 "$timing_summary_tsv" - tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4V -k5,5 + tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5V -k6,6 } >"$timing_summary_tsv.tmp" mv "$timing_summary_tsv.tmp" "$timing_summary_tsv" @@ -147,15 +154,15 @@ awk -F'\t' ' BEGIN { OFS = "\t" print "scale", "brin_enabled", "single_key_cluster", - "hot_tile_fraction", "variant", "diff_kind", "runs", - "avg_pct_inside_base_range", "avg_block_drift", - "avg_p95_block_drift" + "text_cluster_key", "hot_tile_fraction", "variant", + "diff_kind", "runs", "avg_pct_inside_base_range", + "avg_block_drift", "avg_p95_block_drift" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 - pct[key] += $10 - avg[key] += $11 - p95[key] += $12 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 + pct[key] += $11 + avg[key] += $12 + p95[key] += $13 count[key]++ } END { @@ -167,7 +174,7 @@ awk -F'\t' ' ' "$locality_tsv" >"$locality_summary_tsv" { head -n 1 "$locality_summary_tsv" - tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4V -k5,5 -k6,6 + tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5V -k6,6 -k7,7 } >"$locality_summary_tsv.tmp" mv "$locality_summary_tsv.tmp" "$locality_summary_tsv" From bf0f178717463287cac27ef9c98b6ecf75cc799b Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 20:04:26 +0400 Subject: [PATCH 35/81] perf(heap): skip all-equal text clustered batches --- src/backend/access/heap/heapam.c | 117 ++++++++++++++---- src/test/regress/expected/cluster.out | 23 ++++ src/test/regress/sql/cluster.sql | 40 ++++++ src/tools/clustered_write_bench/README | 32 +++-- .../clustered_write_bench/osm2pgsql_diff.sql | 4 +- 5 files changed, 180 insertions(+), 36 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index eeabf51101c3c..9a6fa3773db80 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -125,6 +125,7 @@ typedef struct HeapTupleClusteredPrefixCountEntry static HeapTuple heap_prepare_insert(Relation relation, HeapTuple tup, TransactionId xid, CommandId cid, uint32 options); static int heap_clustered_write_item_cmp(const void *a, const void *b, void *arg); +static bool heap_clustered_write_prefix_can_compare_all_equal(Oid typeOid); static bool heap_clustered_write_prefix_can_compare_by_datum(Oid typeOid); static bool heap_clustered_write_index_can_sort(Relation relation, Relation indexRelation); @@ -237,6 +238,21 @@ heap_clustered_write_item_cmp(const void *a, const void *b, void *arg) return 0; } +static bool +heap_clustered_write_prefix_can_compare_all_equal(Oid typeOid) +{ + if (heap_clustered_write_prefix_can_compare_by_datum(typeOid)) + return true; + + switch (typeOid) + { + case TEXTOID: + return true; + default: + return false; + } +} + static bool heap_clustered_write_prefix_can_compare_by_datum(Oid typeOid) { @@ -2663,12 +2679,12 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, else { /* - * Very hot by-value prefixes do not need the full prefix + * Very hot simple prefixes do not need the full prefix * cache machinery. If the whole COPY batch has the same * simple key, skip clustered placement before allocating * the cache and take the regular bulk/FSM path instead. */ - if (heap_clustered_write_prefix_can_compare_by_datum( + if (heap_clustered_write_prefix_can_compare_all_equal( clusteredIndexRelation->rd_opcintype[0])) { Datum firstPrefixValue; @@ -2676,6 +2692,11 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, bool firstPrefixIsNull; bool lastPrefixIsNull; bool allSamePrefix = false; + bool comparePrefixByDatum; + + comparePrefixByDatum = + heap_clustered_write_prefix_can_compare_by_datum( + clusteredIndexRelation->rd_opcintype[0]); firstPrefixValue = heap_getattr(heaptuples[0], @@ -2688,25 +2709,73 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, relation->rd_att, &lastPrefixIsNull); - if (!firstPrefixIsNull && !lastPrefixIsNull && - firstPrefixValue == lastPrefixValue) + if (!firstPrefixIsNull && !lastPrefixIsNull) { - allSamePrefix = true; - for (i = 1; i < ntuples - 1; i++) + if (comparePrefixByDatum) + allSamePrefix = + firstPrefixValue == lastPrefixValue; + else { - Datum prefixValue; - bool prefixIsNull; + MemoryContext oldcontext; + + prefixCacheCompareCxt = + AllocSetContextCreate(CurrentMemoryContext, + "clustered hot prefix compare", + ALLOCSET_DEFAULT_SIZES); + oldcontext = + MemoryContextSwitchTo(prefixCacheCompareCxt); + allSamePrefix = + DatumGetBool(OidFunctionCall2Coll(prefixCacheEqProc, + prefixCacheCollation, + firstPrefixValue, + lastPrefixValue)); + MemoryContextSwitchTo(oldcontext); + MemoryContextReset(prefixCacheCompareCxt); + } - prefixValue = - heap_getattr(heaptuples[i], - prefixCacheAttnum, - relation->rd_att, - &prefixIsNull); - if (prefixIsNull || - prefixValue != firstPrefixValue) + if (allSamePrefix) + { + for (i = 1; i < ntuples - 1; i++) { - allSamePrefix = false; - break; + Datum prefixValue; + bool prefixIsNull; + + prefixValue = + heap_getattr(heaptuples[i], + prefixCacheAttnum, + relation->rd_att, + &prefixIsNull); + if (prefixIsNull) + { + allSamePrefix = false; + break; + } + + if (comparePrefixByDatum) + { + if (prefixValue != firstPrefixValue) + { + allSamePrefix = false; + break; + } + } + else + { + MemoryContext oldcontext; + + oldcontext = + MemoryContextSwitchTo(prefixCacheCompareCxt); + allSamePrefix = + DatumGetBool(OidFunctionCall2Coll(prefixCacheEqProc, + prefixCacheCollation, + firstPrefixValue, + prefixValue)); + MemoryContextSwitchTo(oldcontext); + MemoryContextReset(prefixCacheCompareCxt); + + if (!allSamePrefix) + break; + } } } } @@ -2739,10 +2808,11 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, for (i = 0; i < ntuples; i++) prefixCountSlots[i] = -1; prefixTargetCacheMask = prefixTargetCacheLimit - 1; - prefixCacheCompareCxt = - AllocSetContextCreate(CurrentMemoryContext, - "clustered target cache compare", - ALLOCSET_DEFAULT_SIZES); + if (prefixCacheCompareCxt == NULL) + prefixCacheCompareCxt = + AllocSetContextCreate(CurrentMemoryContext, + "clustered target cache compare", + ALLOCSET_DEFAULT_SIZES); for (i = 0; i < ntuples; i++) { @@ -2852,6 +2922,11 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, { heaptuple_skip_clustered_target_lookup = true; clustered = NULL; + if (prefixCacheCompareCxt != NULL) + { + MemoryContextDelete(prefixCacheCompareCxt); + prefixCacheCompareCxt = NULL; + } } else { diff --git a/src/test/regress/expected/cluster.out b/src/test/regress/expected/cluster.out index 17255205c23ad..b2543a65ee570 100644 --- a/src/test/regress/expected/cluster.out +++ b/src/test/regress/expected/cluster.out @@ -357,6 +357,29 @@ ORDER BY id; RESET enable_bitmapscan; RESET enable_seqscan; DROP TABLE clstr_write_copy_tid; +-- Verify all-equal clustered COPY batches also work with generated text keys. +CREATE TABLE clstr_write_text_key ( + id int PRIMARY KEY, + tile_id int, + cluster_key text COLLATE "C" GENERATED ALWAYS AS ('g' || lpad(tile_id::text, 8, '0')) STORED, + filler text +) WITH (fillfactor = 10); +INSERT INTO clstr_write_text_key (id, tile_id, filler) +SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10) +FROM generate_series(1, 400) AS g; +CREATE INDEX clstr_write_text_key_cluster ON clstr_write_text_key (cluster_key); +CLUSTER clstr_write_text_key USING clstr_write_text_key_cluster; +COPY clstr_write_text_key (id, tile_id, filler) FROM stdin; +SELECT count(*) AS copied, + count(*) FILTER (WHERE cluster_key = 'g00000001') AS generated_keys +FROM clstr_write_text_key +WHERE id >= 1001; + copied | generated_keys +--------+---------------- + 20 | 20 +(1 row) + +DROP TABLE clstr_write_text_key; -- Verify that clustered writes do not break other clusterable AMs. CREATE TABLE clstr_write_gist (id int, p point, filler text) WITH (fillfactor = 50); INSERT INTO clstr_write_gist diff --git a/src/test/regress/sql/cluster.sql b/src/test/regress/sql/cluster.sql index 653665b8c8ee9..275fc60db3b00 100644 --- a/src/test/regress/sql/cluster.sql +++ b/src/test/regress/sql/cluster.sql @@ -148,6 +148,46 @@ RESET enable_bitmapscan; RESET enable_seqscan; DROP TABLE clstr_write_copy_tid; +-- Verify all-equal clustered COPY batches also work with generated text keys. +CREATE TABLE clstr_write_text_key ( + id int PRIMARY KEY, + tile_id int, + cluster_key text COLLATE "C" GENERATED ALWAYS AS ('g' || lpad(tile_id::text, 8, '0')) STORED, + filler text +) WITH (fillfactor = 10); +INSERT INTO clstr_write_text_key (id, tile_id, filler) +SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10) +FROM generate_series(1, 400) AS g; +CREATE INDEX clstr_write_text_key_cluster ON clstr_write_text_key (cluster_key); +CLUSTER clstr_write_text_key USING clstr_write_text_key_cluster; +COPY clstr_write_text_key (id, tile_id, filler) FROM stdin; +1001 1 y +1002 1 y +1003 1 y +1004 1 y +1005 1 y +1006 1 y +1007 1 y +1008 1 y +1009 1 y +1010 1 y +1011 1 y +1012 1 y +1013 1 y +1014 1 y +1015 1 y +1016 1 y +1017 1 y +1018 1 y +1019 1 y +1020 1 y +\. +SELECT count(*) AS copied, + count(*) FILTER (WHERE cluster_key = 'g00000001') AS generated_keys +FROM clstr_write_text_key +WHERE id >= 1001; +DROP TABLE clstr_write_text_key; + -- Verify that clustered writes do not break other clusterable AMs. CREATE TABLE clstr_write_gist (id int, p point, filler text) WITH (fillfactor = 50); INSERT INTO clstr_write_gist diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 835be3432b446..c5fa35c3a9d3c 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -28,10 +28,12 @@ closer to the experimental osm2pgsql generated-geohash index. Set `TEXT_KEY_VALUES="false true"` to repeat the run with a stored generated `text` clustering key. This keeps the integer `tile_id` for locality measurement, but clusters on `cluster_key`, a deterministic text encoding of -the same tile. It is a cheap guard for geohash-like osm2pgsql experiments and -varlena btree-prefix changes before spending time on the full Georgia import. -When `use_brin=true`, the optional BRIN index follows the active clustering key -(`tile_id` for integer mode, `cluster_key` for text mode). +the same tile using `COLLATE "C"`, matching the experimental osm2pgsql +generated-geohash key. It is a cheap guard for geohash-like osm2pgsql +experiments and varlena btree-prefix changes before spending time on the full +Georgia import. When `use_brin=true`, the optional BRIN index follows the +active clustering key (`tile_id` for integer mode, `cluster_key` for text +mode). Set `HOT_TILE_FRACTION_VALUES="0 0.9"` to stress duplicate-key diffs. A value of `0.9` sends 90% of inserted diff rows to tile `1`, which exercises the case @@ -41,13 +43,16 @@ When this value is greater than zero, locality output splits inserts into `insert_hot` for the forced duplicate-key rows and `insert_rest` for the remaining rows. This keeps a hot-key cliff from being hidden by a small well-placed non-hot tail. -The current by-value all-equal batch guard deliberately treats a fully hot -integer/OID prefix batch as "stop spending clustered-write work": at scale `1`, -single-key clustered insert improved from the previous clean-head `1330.10 ms` -to `866.17 ms` at hot `0.9`, and from `1702.04 ms` to `725.13 ms` at hot `1.0` -in the verified temp-instance run. The hot rows still do not become local; -the win is avoiding wasted clustered-index work until a real duplicate-key page -selection policy exists. +The current all-equal batch guard deliberately treats a fully hot simple prefix +batch as "stop spending clustered-write work": at scale `1`, single-key +integer/OID clustered insert improved from the previous clean-head +`1330.10 ms` to `866.17 ms` at hot `0.9`, and from `1702.04 ms` to +`725.13 ms` at hot `1.0` in the verified temp-instance run. The same guard is +also allowed for generated `text COLLATE "C"` keys, where a follow-up +repeat-run improved fully hot text-key clustered insert from `979.47 ms` to +`810.16 ms`. The hot rows still do not become local; the win is avoiding +wasted clustered-index work until a real duplicate-key page selection policy +exists. The wrapper runs the same SQL against `$DBNAME` (`postgres` by default), stores the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, @@ -261,8 +266,9 @@ Do not repeat these paths blindly: * **Generic early all-equal hot-prefix shortcut:** rejected after the disk-pressure recovery run. The broad version increased hot `0.9`/`1.0` scale `0.1` insert timings in smoke runs without changing locality. The - kept variant is narrower: it only handles all-equal by-value integer/OID - prefixes and was accepted only after the scale `1` temp-instance run above. + kept variant is narrower: it handles all-equal by-value integer/OID prefixes + and generated `text COLLATE "C"` prefixes, both accepted only after targeted + scale `1` temp-instance runs. * **Per-tuple Datum equality fast-path for integer/OID prefixes:** rejected for now. Even though integer `Datum` equality is semantically safe for the tested built-in types, adding the branch to ordinary prefix-cache hits did diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index d38946229c688..2d45056ae39db 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -45,7 +45,7 @@ create unlogged table clustered_write_osm_diff_on ( osm_id bigint primary key, tile_id int not null, - cluster_key text generated always as ('g' || lpad(tile_id::text, 8, '0')) stored, + cluster_key text collate "C" generated always as ('g' || lpad(tile_id::text, 8, '0')) stored, version int not null, payload text not null ) with (fillfactor = 90); @@ -88,7 +88,7 @@ create unlogged table clustered_write_osm_diff_off ( osm_id bigint primary key, tile_id int not null, - cluster_key text generated always as ('g' || lpad(tile_id::text, 8, '0')) stored, + cluster_key text collate "C" generated always as ('g' || lpad(tile_id::text, 8, '0')) stored, version int not null, payload text not null ) with (fillfactor = 90); From c16c12044a366235813e5f4284142a270d5fbc25 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 20:09:51 +0400 Subject: [PATCH 36/81] test(clustered-write): benchmark heap fillfactor reserve --- src/tools/clustered_write_bench/README | 19 +++ .../clustered_write_bench/osm2pgsql_diff.sql | 17 ++- .../run_synthetic_bench.sh | 130 ++++++++++-------- 3 files changed, 101 insertions(+), 65 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index c5fa35c3a9d3c..8b1dd5876288d 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -35,6 +35,13 @@ Georgia import. When `use_brin=true`, the optional BRIN index follows the active clustering key (`tile_id` for integer mode, `cluster_key` for text mode). +Set `HEAP_FILLFACTOR_VALUES="90 70 50"` to compare how much reserved heap +space a diff workload needs before clustered-write placement can keep repeated +keys local. The default is `90`, matching the current experimental +osm2pgsql clustered-import patch. Lower values increase initial heap size, but +help distinguish a page-selection bug from the simpler case where there is no +nearby page with enough free space. + Set `HOT_TILE_FRACTION_VALUES="0 0.9"` to stress duplicate-key diffs. A value of `0.9` sends 90% of inserted diff rows to tile `1`, which exercises the case where the first equal-key pages are already full and later inserts must advance @@ -99,6 +106,16 @@ Current clustered-write placement does not use BRIN directly: BRIN exposes path. The option is present to make before/after experiments easy when that path is added. +A scale `1` fillfactor sweep shows what reserve space can and cannot solve. +With `HOT_TILE_FRACTION_VALUES=0.9`, lowering `HEAP_FILLFACTOR_VALUES` 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. That is expected for this shape: each tile starts with only +about dozens of base rows, so even a large per-page reserve cannot absorb +thousands of new duplicate-key rows. Fillfactor helps update pressure and +small follow-up inserts; it is not a substitute for a real overflow/range +policy for massive equal-key diffs. + Real osm2pgsql benchmark ------------------------ @@ -340,6 +357,8 @@ Important output columns: just the tile key instead of `(tile_id, osm_id)`. * `text_cluster_key`: whether the remembered clustered btree index used the generated text `cluster_key` instead of the integer tile columns. +* `heap_fillfactor`: heap fillfactor used for the initial clustered table and + the control copy. * `hot_tile_fraction`: fraction of inserted diff rows forced onto one tile key to stress duplicate-key placement over already-full equal-key pages. * `variant`: either the clustered-write path or the in-script control without diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index 2d45056ae39db..c43a2137b7225 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -25,6 +25,11 @@ \set hot_tile_fraction 0 \endif +\if :{?heap_fillfactor} +\else +\set heap_fillfactor 90 +\endif + \timing on drop table if exists clustered_write_osm_diff cascade; @@ -39,6 +44,7 @@ select (200000 * :scale)::int as base_rows, (:'use_brin')::boolean as brin_enabled, (:'single_key_cluster')::boolean as single_key_cluster, (:'text_cluster_key')::boolean as text_cluster_key, + (:heap_fillfactor)::int as heap_fillfactor, (:hot_tile_fraction)::numeric as hot_tile_fraction; create unlogged table clustered_write_osm_diff_on @@ -48,7 +54,7 @@ create unlogged table clustered_write_osm_diff_on cluster_key text collate "C" generated always as ('g' || lpad(tile_id::text, 8, '0')) stored, version int not null, payload text not null -) with (fillfactor = 90); +) with (fillfactor = :heap_fillfactor); insert into clustered_write_osm_diff_on (osm_id, tile_id, version, payload) select g, @@ -91,7 +97,7 @@ create unlogged table clustered_write_osm_diff_off cluster_key text collate "C" generated always as ('g' || lpad(tile_id::text, 8, '0')) stored, version int not null, payload text not null -) with (fillfactor = 90); +) with (fillfactor = :heap_fillfactor); insert into clustered_write_osm_diff_off (osm_id, tile_id, version, payload) select osm_id, @@ -152,6 +158,7 @@ select (200000 * :scale)::int as base_rows, (:'use_brin')::boolean as brin_enabled, (:'single_key_cluster')::boolean as single_key_cluster, (:'text_cluster_key')::boolean as text_cluster_key, + (:heap_fillfactor)::int as heap_fillfactor, (:hot_tile_fraction)::numeric as hot_tile_fraction; create temp table clustered_write_step_timings @@ -252,6 +259,7 @@ analyze clustered_write_osm_diff_off; select s.brin_enabled, s.text_cluster_key, + s.heap_fillfactor, t.step, round((extract(epoch from t.finished_at - t.started_at) * 1000)::numeric, 2) as elapsed_ms from clustered_write_step_timings as t @@ -342,6 +350,7 @@ drift as ) select s.brin_enabled, s.text_cluster_key, + s.heap_fillfactor, variant, diff_kind, count(*) as rows_measured, @@ -352,5 +361,5 @@ select s.brin_enabled, max(block_drift) as max_block_drift from drift join clustered_write_settings as s on true -group by s.brin_enabled, s.text_cluster_key, variant, diff_kind -order by s.brin_enabled, s.text_cluster_key, variant, diff_kind; +group by s.brin_enabled, s.text_cluster_key, s.heap_fillfactor, variant, diff_kind +order by s.brin_enabled, s.text_cluster_key, s.heap_fillfactor, variant, diff_kind; diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index f231f29b3b977..5a6f5cd98673d 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -15,6 +15,7 @@ BRIN_VALUES=${BRIN_VALUES:-"false true"} SINGLE_KEY_VALUES=${SINGLE_KEY_VALUES:-"false"} TEXT_KEY_VALUES=${TEXT_KEY_VALUES:-"false"} HOT_TILE_FRACTION_VALUES=${HOT_TILE_FRACTION_VALUES:-"0"} +HEAP_FILLFACTOR_VALUES=${HEAP_FILLFACTOR_VALUES:-"90"} OUTDIR=${OUTDIR:-"$HOME/tmp/clustered-write-synthetic/$(date +%Y%m%d-%H%M%S)"} COMPRESS_RAW=${COMPRESS_RAW:-true} KEEP_TEMP_INSTANCE_DATA=${KEEP_TEMP_INSTANCE_DATA:-false} @@ -59,24 +60,26 @@ locality_tsv="$OUTDIR/locality.tsv" timing_summary_tsv="$OUTDIR/timing_summary.tsv" locality_summary_tsv="$OUTDIR/locality_summary.tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\thot_tile_fraction\tstep\telapsed_ms\n' >"$timings_tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\thot_tile_fraction\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\tstep\telapsed_ms\n' >"$timings_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" for scale in $SCALE_VALUES; do for brin in $BRIN_VALUES; do for single_key in $SINGLE_KEY_VALUES; do for text_cluster_key in $TEXT_KEY_VALUES; do - for hot_tile_fraction in $HOT_TILE_FRACTION_VALUES; do - for run in $(seq 1 "$REPEATS"); do - raw="$OUTDIR/raw/scale-${scale}_brin-${brin}_single-key-${single_key}_text-key-${text_cluster_key}_hot-tile-${hot_tile_fraction}_run-${run}.out" - - "$PSQL" -X -v ON_ERROR_STOP=1 \ - -v scale="$scale" \ - -v use_brin="$brin" \ - -v single_key_cluster="$single_key" \ - -v text_cluster_key="$text_cluster_key" \ - -v hot_tile_fraction="$hot_tile_fraction" \ - -d "$DBNAME" >"$raw" <"$raw" <>"$timings_tsv" - - awk -F'|' \ - -v run="$run" \ - -v scale="$scale" \ - -v brin="$brin" \ - -v single_key="$single_key" \ - -v text_cluster_key="$text_cluster_key" \ - -v hot_tile_fraction="$hot_tile_fraction" \ - '$3 == "clustered_write" || - $3 == "without_cluster_metadata" { - printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - run, scale, brin, single_key, text_cluster_key, hot_tile_fraction, $3, $4, $5, $6, $7, $8, $9, $10 - }' "$raw" >>"$locality_tsv" - - if [[ "$COMPRESS_RAW" == "true" ]]; then - gzip -f "$raw" - fi + awk -F'|' \ + -v run="$run" \ + -v scale="$scale" \ + -v brin="$brin" \ + -v single_key="$single_key" \ + -v text_cluster_key="$text_cluster_key" \ + -v heap_fillfactor="$heap_fillfactor" \ + -v hot_tile_fraction="$hot_tile_fraction" \ + '$4 == "clustered_write_insert" || + $4 == "clustered_write_update" || + $4 == "without_cluster_metadata_insert" || + $4 == "without_cluster_metadata_update" { + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, $4, $5 + }' "$raw" >>"$timings_tsv" + + awk -F'|' \ + -v run="$run" \ + -v scale="$scale" \ + -v brin="$brin" \ + -v single_key="$single_key" \ + -v text_cluster_key="$text_cluster_key" \ + -v heap_fillfactor="$heap_fillfactor" \ + -v hot_tile_fraction="$hot_tile_fraction" \ + '$4 == "clustered_write" || + $4 == "without_cluster_metadata" { + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, $4, $5, $6, $7, $8, $9, $10, $11 + }' "$raw" >>"$locality_tsv" + + if [[ "$COMPRESS_RAW" == "true" ]]; then + gzip -f "$raw" + fi + done done done done @@ -126,17 +132,18 @@ awk -F'\t' ' BEGIN { OFS = "\t" print "scale", "brin_enabled", "single_key_cluster", - "text_cluster_key", "hot_tile_fraction", "step", "runs", - "avg_elapsed_ms", "min_elapsed_ms", "max_elapsed_ms" + "text_cluster_key", "heap_fillfactor", "hot_tile_fraction", + "step", "runs", "avg_elapsed_ms", "min_elapsed_ms", + "max_elapsed_ms" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 - sum[key] += $8 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 + sum[key] += $9 count[key]++ - if (!(key in min) || $8 < min[key]) - min[key] = $8 - if (!(key in max) || $8 > max[key]) - max[key] = $8 + if (!(key in min) || $9 < min[key]) + min[key] = $9 + if (!(key in max) || $9 > max[key]) + max[key] = $9 } END { for (key in count) @@ -146,7 +153,7 @@ awk -F'\t' ' ' "$timings_tsv" >"$timing_summary_tsv" { head -n 1 "$timing_summary_tsv" - tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5V -k6,6 + tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7 } >"$timing_summary_tsv.tmp" mv "$timing_summary_tsv.tmp" "$timing_summary_tsv" @@ -154,15 +161,16 @@ awk -F'\t' ' BEGIN { OFS = "\t" print "scale", "brin_enabled", "single_key_cluster", - "text_cluster_key", "hot_tile_fraction", "variant", - "diff_kind", "runs", "avg_pct_inside_base_range", - "avg_block_drift", "avg_p95_block_drift" + "text_cluster_key", "heap_fillfactor", "hot_tile_fraction", + "variant", "diff_kind", "runs", + "avg_pct_inside_base_range", "avg_block_drift", + "avg_p95_block_drift" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 - pct[key] += $11 - avg[key] += $12 - p95[key] += $13 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 + pct[key] += $12 + avg[key] += $13 + p95[key] += $14 count[key]++ } END { @@ -174,7 +182,7 @@ awk -F'\t' ' ' "$locality_tsv" >"$locality_summary_tsv" { head -n 1 "$locality_summary_tsv" - tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5V -k6,6 -k7,7 + tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7 -k8,8 } >"$locality_summary_tsv.tmp" mv "$locality_summary_tsv.tmp" "$locality_summary_tsv" From cc9f260151b5b013da0fdd7b9ea3ec2fea0865a2 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 20:22:32 +0400 Subject: [PATCH 37/81] test(clustered-write): report hot overflow span --- src/tools/clustered_write_bench/README | 28 +++++++++++++---- .../clustered_write_bench/osm2pgsql_diff.sql | 1 + .../run_synthetic_bench.sh | 30 ++++++++++--------- 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 8b1dd5876288d..73bae1f8d01ac 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -67,13 +67,20 @@ the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, preferred way to compare small hot-path changes because single runs can be noisy even with `fsync=off`. It also writes `server_version.txt`; check that file before comparing runs that were not started with `USE_TEMP_INSTANCE=true`. +`locality.tsv` reports both `heap_blocks_touched` and `heap_block_span`. +The span is useful for duplicate-key overflow experiments: a huge hot diff may +not fit back inside the original key range, but it can still be compact if the +new rows occupy a short contiguous run of heap blocks. Raw `psql` outputs are compressed with gzip by default; set `COMPRESS_RAW=false` to leave them as plain `.out` files. When `USE_TEMP_INSTANCE=true`, the temporary data directory and socket directory are removed after the run by default, leaving the logs and summaries -behind. Set `KEEP_TEMP_INSTANCE_DATA=true` only when you need to inspect the -finished cluster. +behind. The socket directory is created under `${TMPDIR:-/tmp}` rather than +inside `OUTDIR`, because PostgreSQL's Unix-domain socket path limit is short +and long benchmark output paths otherwise fail before the run starts. Set +`KEEP_TEMP_INSTANCE_DATA=true` only when you need to inspect the finished +cluster. Larger scale values increase table and diff sizes. The workload: @@ -341,12 +348,20 @@ Do not repeat these paths blindly: * **One-opened-index-per-`heap_multi_insert` batch as a simplification:** kept out as a performance change because the single Georgia rerun did not beat the prior generated-geohash result (`1:59.29` create, `1:56.57` append). +* **Append instead of FSM after clustered candidates are exhausted:** rejected + for now. The synthetic span metric showed the real hot-key problem earlier: + duplicate-key rows consume fillfactor reserve across many clustered-neighbour + pages before the exhausted-candidate fallback matters. A local prototype + that appended after candidate exhaustion, plus an all-hot multi-insert append + variant, left scale `1`, hot `0.9` `insert_hot` spans unchanged (`3737` + blocks at heap fillfactor `90`, `6596` at fillfactor `50`), so this needs + prefix grouping/sorting rather than a late fallback tweak. The useful lesson from the rejected hot-prefix work is that duplicate-key batches need either a genuinely local page-selection policy for the repeated -key or a very cheap decision to stop spending clustered-write work on that key. -Adding more eager probes or more per-tuple branching has repeatedly moved the -cost curve in the wrong direction. +key, or enough prefix grouping that the already-kept all-hot guard can keep the +overflow compact. Adding more eager probes, late FSM fallback tweaks, or more +per-tuple branching has repeatedly moved the cost curve in the wrong direction. Important output columns: @@ -372,5 +387,8 @@ Important output columns: the tile's original clustered heap block range. * `avg_block_drift`, `p95_block_drift`, `max_block_drift`: lower is better; block distance outside the original tile range. +* `heap_block_span`: lower is better for the measured diff rows themselves; + this is `max(heap_block) - min(heap_block) + 1`, so it catches whether an + overflow tail is compact even when it cannot fit inside the old base range. * `heap_blocks_touched`: lower can be better for locality, but interpret it with the drift metrics because very small values can also mean hot spots. diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index c43a2137b7225..a878f2361b58d 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -355,6 +355,7 @@ select s.brin_enabled, diff_kind, count(*) as rows_measured, count(distinct heap_block) as heap_blocks_touched, + max(heap_block) - min(heap_block) + 1 as heap_block_span, round(100.0 * avg((block_drift = 0)::int), 2) as pct_inside_base_range, round(avg(block_drift)::numeric, 2) as avg_block_drift, round((percentile_cont(0.95) within group (order by block_drift))::numeric, 2) as p95_block_drift, diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index 5a6f5cd98673d..4f7ada1a7cc0b 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -29,9 +29,8 @@ if [[ "$USE_TEMP_INSTANCE" == "true" ]]; then fi PGDATA="$OUTDIR/pgdata" - PGHOST="$OUTDIR/socket" + PGHOST=$(mktemp -d "${TMPDIR:-/tmp}/clustered-write-bench-socket.XXXXXX") export PGHOST PGPORT - mkdir -p "$PGHOST" PSQL="$PG_BINDIR/psql" "$PG_BINDIR/initdb" -D "$PGDATA" --auth trust --no-sync --no-instructions \ @@ -45,8 +44,9 @@ if [[ "$USE_TEMP_INSTANCE" == "true" ]]; then "$PG_BINDIR/pg_ctl" -D "$PGDATA" stop -m fast \ >"$OUTDIR/pg_ctl_stop.log" 2>&1 || true if [[ "$KEEP_TEMP_INSTANCE_DATA" != "true" ]]; then - rm -rf "$PGDATA" "$PGHOST" + rm -rf "$PGDATA" fi + rm -rf "$PGHOST" } trap stop_temp_instance EXIT fi @@ -61,7 +61,7 @@ timing_summary_tsv="$OUTDIR/timing_summary.tsv" locality_summary_tsv="$OUTDIR/locality_summary.tsv" printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\tstep\telapsed_ms\n' >"$timings_tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\theap_block_span\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" for scale in $SCALE_VALUES; do for brin in $BRIN_VALUES; do @@ -113,8 +113,8 @@ SQL -v hot_tile_fraction="$hot_tile_fraction" \ '$4 == "clustered_write" || $4 == "without_cluster_metadata" { - printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, $4, $5, $6, $7, $8, $9, $10, $11 + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, $4, $5, $6, $7, $8, $9, $10, $11, $12 }' "$raw" >>"$locality_tsv" if [[ "$COMPRESS_RAW" == "true" ]]; then @@ -163,21 +163,23 @@ awk -F'\t' ' print "scale", "brin_enabled", "single_key_cluster", "text_cluster_key", "heap_fillfactor", "hot_tile_fraction", "variant", "diff_kind", "runs", - "avg_pct_inside_base_range", "avg_block_drift", - "avg_p95_block_drift" + "avg_heap_block_span", "avg_pct_inside_base_range", + "avg_block_drift", "avg_p95_block_drift" } NR > 1 { key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 - pct[key] += $12 - avg[key] += $13 - p95[key] += $14 + span[key] += $12 + pct[key] += $13 + avg[key] += $14 + p95[key] += $15 count[key]++ } END { for (key in count) - printf "%s\t%d\t%.2f\t%.2f\t%.2f\n", - key, count[key], pct[key] / count[key], - avg[key] / count[key], p95[key] / count[key] + printf "%s\t%d\t%.2f\t%.2f\t%.2f\t%.2f\n", + key, count[key], span[key] / count[key], + pct[key] / count[key], avg[key] / count[key], + p95[key] / count[key] } ' "$locality_tsv" >"$locality_summary_tsv" { From c9ef79970d2c68c56bc87561d103750307d13ee6 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 20:38:16 +0400 Subject: [PATCH 38/81] perf(heap): compact clustered overflow inserts --- src/backend/access/heap/hio.c | 46 +++++-- src/test/regress/expected/cluster.out | 11 +- src/test/regress/sql/cluster.sql | 5 +- src/tools/clustered_write_bench/README | 38 +++-- .../clustered_write_bench/osm2pgsql_diff.sql | 31 ++++- .../run_synthetic_bench.sh | 130 +++++++++--------- 6 files changed, 169 insertions(+), 92 deletions(-) diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index d1adbc5966242..d3b74f4d08cf1 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -511,11 +511,11 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, } /* - * Prefer pages whose FSM entry already says they can fit this tuple. If - * the FSM has no clear winner, try the rest in reverse scan order; that - * keeps us away from the first page of a large equal-key range while - * preserving the normal locked-page free-space recheck in - * RelationGetBufferForTuple(). + * Prefer pages whose FSM entry already says they can fit the caller's + * target free-space requirement. When every clustered neighbor is below + * that normal insert threshold, let RelationGetBufferForTuple() fall back + * to the relation tail instead of consuming fillfactor reserve from many + * unrelated pages. */ if (firstCandidateOnly) { @@ -1021,6 +1021,7 @@ RelationGetBufferForTuple(Relation relation, Size len, int nclusteredTargetBlocks = 0, clusteredTargetIndex = 0; bool usingClusteredTarget = false; + bool usingClusteredOverflowTarget = false; bool usingPreferredBlock = false; bool unlockedTargetBuffer; bool recheckVmPins; @@ -1098,7 +1099,8 @@ RelationGetBufferForTuple(Relation relation, Size len, else if (tuple != NULL) { nclusteredTargetBlocks = - RelationGetClusteredTargetBlocksForTuple(relation, tuple, len, + RelationGetClusteredTargetBlocksForTuple(relation, tuple, + targetFreeSpace, clusteredTargetBlocks, lengthof(clusteredTargetBlocks)); if (nclusteredTargetBlocks > 0) @@ -1241,7 +1243,7 @@ RelationGetBufferForTuple(Relation relation, Size len, } pageFreeSpace = PageGetHeapFreeSpace(page); - if ((usingClusteredTarget ? len : targetFreeSpace) <= pageFreeSpace) + if (targetFreeSpace <= pageFreeSpace) { /* use this page as future insert target, too */ RelationSetTargetBlock(relation, targetBlock); @@ -1283,7 +1285,7 @@ RelationGetBufferForTuple(Relation relation, Size len, usingPreferredBlock = false; nclusteredTargetBlocks = RelationGetClusteredTargetBlocksForTuple(relation, tuple, - len, + targetFreeSpace, clusteredTargetBlocks, lengthof(clusteredTargetBlocks)); for (clusteredTargetIndex = 0; @@ -1311,14 +1313,36 @@ RelationGetBufferForTuple(Relation relation, Size len, else bistate->next_free++; } - else if (use_fsm) - targetBlock = GetPageWithFreeSpace(relation, targetFreeSpace); else - targetBlock = InvalidBlockNumber; + { + BlockNumber nblocks = RelationGetNumberOfBlocks(relation); + + if (nblocks > 0) + { + /* + * Once every clustered neighbor is full, append the + * overflow run at the relation tail instead of scattering + * dense equal-key inserts through unrelated FSM pages. + */ + targetBlock = nblocks - 1; + usingClusteredOverflowTarget = true; + } + else + targetBlock = InvalidBlockNumber; + } continue; } + if (usingClusteredOverflowTarget) + { + if (use_fsm) + RecordPageWithFreeSpace(relation, targetBlock, + pageFreeSpace); + usingClusteredOverflowTarget = false; + break; + } + /* Is there an ongoing bulk extension? */ if (bistate && bistate->next_free != InvalidBlockNumber) { diff --git a/src/test/regress/expected/cluster.out b/src/test/regress/expected/cluster.out index b2543a65ee570..461cbcfbbf869 100644 --- a/src/test/regress/expected/cluster.out +++ b/src/test/regress/expected/cluster.out @@ -306,7 +306,8 @@ WHERE pg_class.oid=indexrelid --------- (0 rows) --- Verify that clustered writes prefer heap blocks in clustered key order. +-- Verify that clustered writes do not consume fillfactor reserve when the +-- clustered key's existing pages are below the normal insert threshold. CREATE TABLE clstr_write_btree (id int, k int, filler text) WITH (fillfactor = 10); INSERT INTO clstr_write_btree SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10) @@ -316,12 +317,12 @@ CLUSTER clstr_write_btree USING clstr_write_btree_k_id; INSERT INTO clstr_write_btree VALUES (1001, 1, repeat('y', 10)); SELECT tid_block(new_row.ctid) <= (SELECT max(tid_block(ctid)) FROM clstr_write_btree WHERE k = 1 AND id <> 1001) - AS placed_near_clustered_key + AS used_clustered_key_reserve FROM clstr_write_btree AS new_row WHERE id = 1001; - placed_near_clustered_key ---------------------------- - t + used_clustered_key_reserve +---------------------------- + f (1 row) DROP TABLE clstr_write_btree; diff --git a/src/test/regress/sql/cluster.sql b/src/test/regress/sql/cluster.sql index 275fc60db3b00..ff6deb7b99ab3 100644 --- a/src/test/regress/sql/cluster.sql +++ b/src/test/regress/sql/cluster.sql @@ -103,7 +103,8 @@ WHERE pg_class.oid=indexrelid AND pg_class_2.relname = 'clstr_tst' AND indisclustered; --- Verify that clustered writes prefer heap blocks in clustered key order. +-- Verify that clustered writes do not consume fillfactor reserve when the +-- clustered key's existing pages are below the normal insert threshold. CREATE TABLE clstr_write_btree (id int, k int, filler text) WITH (fillfactor = 10); INSERT INTO clstr_write_btree SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10) @@ -113,7 +114,7 @@ CLUSTER clstr_write_btree USING clstr_write_btree_k_id; INSERT INTO clstr_write_btree VALUES (1001, 1, repeat('y', 10)); SELECT tid_block(new_row.ctid) <= (SELECT max(tid_block(ctid)) FROM clstr_write_btree WHERE k = 1 AND id <> 1001) - AS placed_near_clustered_key + AS used_clustered_key_reserve FROM clstr_write_btree AS new_row WHERE id = 1001; DROP TABLE clstr_write_btree; diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 73bae1f8d01ac..ca0c611d9faf7 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -58,8 +58,23 @@ integer/OID clustered insert improved from the previous clean-head also allowed for generated `text COLLATE "C"` keys, where a follow-up repeat-run improved fully hot text-key clustered insert from `979.47 ms` to `810.16 ms`. The hot rows still do not become local; the win is avoiding -wasted clustered-index work until a real duplicate-key page selection policy -exists. +wasted clustered-index work. For single-row `INSERT ... SELECT` paths, the +current page policy also requires clustered candidates to satisfy the normal +insert target free-space threshold; when the equal-key neighborhood is below +that threshold, the overflow run moves to the relation tail instead of +consuming fillfactor reserve from many old pages. On the scale `1`, hot `0.9` +synthetic run, this reduced clustered `insert_hot` span to the control shape +(`384` blocks at fillfactor `90`, `693` at fillfactor `50`) instead of the +previous scattered spans (`3737` and `6596`). The write-time cost is still +visible: clustered inserts in that run were about `1.1-1.2s` versus `74-76 ms` +for the no-cluster-metadata control. + +Set `ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true"` to run the insert diff +with an explicit `ORDER BY tile_id, osm_id`. This is a diagnostic upper bound +for "buffer and micro-sort before insert" ideas. In the current hot synthetic +shape it does not improve locality by itself, because the important decision is +whether clustered insertion consumes old fillfactor reserve or starts a compact +overflow run. The wrapper runs the same SQL against `$DBNAME` (`postgres` by default), stores the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, @@ -348,14 +363,15 @@ Do not repeat these paths blindly: * **One-opened-index-per-`heap_multi_insert` batch as a simplification:** kept out as a performance change because the single Georgia rerun did not beat the prior generated-geohash result (`1:59.29` create, `1:56.57` append). -* **Append instead of FSM after clustered candidates are exhausted:** rejected - for now. The synthetic span metric showed the real hot-key problem earlier: - duplicate-key rows consume fillfactor reserve across many clustered-neighbour - pages before the exhausted-candidate fallback matters. A local prototype - that appended after candidate exhaustion, plus an all-hot multi-insert append - variant, left scale `1`, hot `0.9` `insert_hot` spans unchanged (`3737` - blocks at heap fillfactor `90`, `6596` at fillfactor `50`), so this needs - prefix grouping/sorting rather than a late fallback tweak. +* **Append instead of FSM only after clustered candidates are exhausted:** + rejected as a standalone tweak. The synthetic span metric showed the real + hot-key problem earlier: duplicate-key rows consumed fillfactor reserve across + many clustered-neighbour pages before the exhausted-candidate fallback + mattered. A local prototype that appended only after candidate exhaustion, + plus an all-hot multi-insert append variant, left scale `1`, hot `0.9` + `insert_hot` spans unchanged (`3737` blocks at heap fillfactor `90`, `6596` + at fillfactor `50`). The kept fix pairs the tail fallback with a stricter + target-free-space check for clustered candidates. The useful lesson from the rejected hot-prefix work is that duplicate-key batches need either a genuinely local page-selection policy for the repeated @@ -376,6 +392,8 @@ Important output columns: the control copy. * `hot_tile_fraction`: fraction of inserted diff rows forced onto one tile key to stress duplicate-key placement over already-full equal-key pages. +* `order_diff_by_cluster_key`: whether the synthetic diff insert was explicitly + ordered by `(tile_id, osm_id)` before insertion. * `variant`: either the clustered-write path or the in-script control without remembered clustered-index metadata. * `diff_kind`: `insert`/`update` normally, or `insert_hot`/`insert_rest` when diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index a878f2361b58d..6e86a0ee65638 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -30,6 +30,11 @@ \set heap_fillfactor 90 \endif +\if :{?order_diff_by_cluster_key} +\else +\set order_diff_by_cluster_key false +\endif + \timing on drop table if exists clustered_write_osm_diff cascade; @@ -45,7 +50,8 @@ select (200000 * :scale)::int as base_rows, (:'single_key_cluster')::boolean as single_key_cluster, (:'text_cluster_key')::boolean as text_cluster_key, (:heap_fillfactor)::int as heap_fillfactor, - (:hot_tile_fraction)::numeric as hot_tile_fraction; + (:hot_tile_fraction)::numeric as hot_tile_fraction, + (:'order_diff_by_cluster_key')::boolean as order_diff_by_cluster_key; create unlogged table clustered_write_osm_diff_on ( @@ -159,7 +165,8 @@ select (200000 * :scale)::int as base_rows, (:'single_key_cluster')::boolean as single_key_cluster, (:'text_cluster_key')::boolean as text_cluster_key, (:heap_fillfactor)::int as heap_fillfactor, - (:hot_tile_fraction)::numeric as hot_tile_fraction; + (:hot_tile_fraction)::numeric as hot_tile_fraction, + (:'order_diff_by_cluster_key')::boolean as order_diff_by_cluster_key; create temp table clustered_write_step_timings ( @@ -198,12 +205,22 @@ from clustered_write_settings as s, insert into clustered_write_step_timings values ('clustered_write_insert', clock_timestamp(), null); +\if :order_diff_by_cluster_key +insert into clustered_write_osm_diff_on (osm_id, tile_id, version, payload) +select d.osm_id, + d.tile_id, + 1, + repeat('insert', 16) +from clustered_write_diff_inserts as d +order by d.tile_id, d.osm_id; +\else insert into clustered_write_osm_diff_on (osm_id, tile_id, version, payload) select d.osm_id, d.tile_id, 1, repeat('insert', 16) from clustered_write_diff_inserts as d; +\endif update clustered_write_step_timings set finished_at = clock_timestamp() @@ -212,12 +229,22 @@ where step = 'clustered_write_insert'; insert into clustered_write_step_timings values ('without_cluster_metadata_insert', clock_timestamp(), null); +\if :order_diff_by_cluster_key +insert into clustered_write_osm_diff_off (osm_id, tile_id, version, payload) +select d.osm_id, + d.tile_id, + 1, + repeat('insert', 16) +from clustered_write_diff_inserts as d +order by d.tile_id, d.osm_id; +\else insert into clustered_write_osm_diff_off (osm_id, tile_id, version, payload) select d.osm_id, d.tile_id, 1, repeat('insert', 16) from clustered_write_diff_inserts as d; +\endif update clustered_write_step_timings set finished_at = clock_timestamp() diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index 4f7ada1a7cc0b..3470166be85b7 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -16,6 +16,7 @@ SINGLE_KEY_VALUES=${SINGLE_KEY_VALUES:-"false"} TEXT_KEY_VALUES=${TEXT_KEY_VALUES:-"false"} HOT_TILE_FRACTION_VALUES=${HOT_TILE_FRACTION_VALUES:-"0"} HEAP_FILLFACTOR_VALUES=${HEAP_FILLFACTOR_VALUES:-"90"} +ORDER_DIFF_BY_CLUSTER_KEY_VALUES=${ORDER_DIFF_BY_CLUSTER_KEY_VALUES:-"false"} OUTDIR=${OUTDIR:-"$HOME/tmp/clustered-write-synthetic/$(date +%Y%m%d-%H%M%S)"} COMPRESS_RAW=${COMPRESS_RAW:-true} KEEP_TEMP_INSTANCE_DATA=${KEEP_TEMP_INSTANCE_DATA:-false} @@ -60,8 +61,8 @@ locality_tsv="$OUTDIR/locality.tsv" timing_summary_tsv="$OUTDIR/timing_summary.tsv" locality_summary_tsv="$OUTDIR/locality_summary.tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\tstep\telapsed_ms\n' >"$timings_tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\theap_block_span\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\torder_diff_by_cluster_key\tstep\telapsed_ms\n' >"$timings_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\torder_diff_by_cluster_key\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\theap_block_span\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" for scale in $SCALE_VALUES; do for brin in $BRIN_VALUES; do @@ -69,17 +70,19 @@ for scale in $SCALE_VALUES; do for text_cluster_key in $TEXT_KEY_VALUES; do for heap_fillfactor in $HEAP_FILLFACTOR_VALUES; do for hot_tile_fraction in $HOT_TILE_FRACTION_VALUES; do - for run in $(seq 1 "$REPEATS"); do - raw="$OUTDIR/raw/scale-${scale}_brin-${brin}_single-key-${single_key}_text-key-${text_cluster_key}_fillfactor-${heap_fillfactor}_hot-tile-${hot_tile_fraction}_run-${run}.out" - - "$PSQL" -X -v ON_ERROR_STOP=1 \ - -v scale="$scale" \ - -v use_brin="$brin" \ - -v single_key_cluster="$single_key" \ - -v text_cluster_key="$text_cluster_key" \ - -v heap_fillfactor="$heap_fillfactor" \ - -v hot_tile_fraction="$hot_tile_fraction" \ - -d "$DBNAME" >"$raw" <"$raw" <>"$timings_tsv" - - awk -F'|' \ - -v run="$run" \ - -v scale="$scale" \ - -v brin="$brin" \ - -v single_key="$single_key" \ - -v text_cluster_key="$text_cluster_key" \ - -v heap_fillfactor="$heap_fillfactor" \ - -v hot_tile_fraction="$hot_tile_fraction" \ - '$4 == "clustered_write" || - $4 == "without_cluster_metadata" { - printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, $4, $5, $6, $7, $8, $9, $10, $11, $12 - }' "$raw" >>"$locality_tsv" - - if [[ "$COMPRESS_RAW" == "true" ]]; then - gzip -f "$raw" - fi + awk -F'|' \ + -v run="$run" \ + -v scale="$scale" \ + -v brin="$brin" \ + -v single_key="$single_key" \ + -v text_cluster_key="$text_cluster_key" \ + -v heap_fillfactor="$heap_fillfactor" \ + -v hot_tile_fraction="$hot_tile_fraction" \ + -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \ + '$4 == "clustered_write_insert" || + $4 == "clustered_write_update" || + $4 == "without_cluster_metadata_insert" || + $4 == "without_cluster_metadata_update" { + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, order_diff_by_cluster_key, $4, $5 + }' "$raw" >>"$timings_tsv" + + awk -F'|' \ + -v run="$run" \ + -v scale="$scale" \ + -v brin="$brin" \ + -v single_key="$single_key" \ + -v text_cluster_key="$text_cluster_key" \ + -v heap_fillfactor="$heap_fillfactor" \ + -v hot_tile_fraction="$hot_tile_fraction" \ + -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \ + '$4 == "clustered_write" || + $4 == "without_cluster_metadata" { + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, order_diff_by_cluster_key, $4, $5, $6, $7, $8, $9, $10, $11, $12 + }' "$raw" >>"$locality_tsv" + + if [[ "$COMPRESS_RAW" == "true" ]]; then + gzip -f "$raw" + fi + done done done done @@ -133,17 +139,17 @@ awk -F'\t' ' OFS = "\t" print "scale", "brin_enabled", "single_key_cluster", "text_cluster_key", "heap_fillfactor", "hot_tile_fraction", - "step", "runs", "avg_elapsed_ms", "min_elapsed_ms", - "max_elapsed_ms" + "order_diff_by_cluster_key", "step", "runs", + "avg_elapsed_ms", "min_elapsed_ms", "max_elapsed_ms" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 - sum[key] += $9 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 + sum[key] += $10 count[key]++ - if (!(key in min) || $9 < min[key]) - min[key] = $9 - if (!(key in max) || $9 > max[key]) - max[key] = $9 + if (!(key in min) || $10 < min[key]) + min[key] = $10 + if (!(key in max) || $10 > max[key]) + max[key] = $10 } END { for (key in count) @@ -153,7 +159,7 @@ awk -F'\t' ' ' "$timings_tsv" >"$timing_summary_tsv" { head -n 1 "$timing_summary_tsv" - tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7 + tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7 -k8,8 } >"$timing_summary_tsv.tmp" mv "$timing_summary_tsv.tmp" "$timing_summary_tsv" @@ -162,16 +168,16 @@ awk -F'\t' ' OFS = "\t" print "scale", "brin_enabled", "single_key_cluster", "text_cluster_key", "heap_fillfactor", "hot_tile_fraction", - "variant", "diff_kind", "runs", + "order_diff_by_cluster_key", "variant", "diff_kind", "runs", "avg_heap_block_span", "avg_pct_inside_base_range", "avg_block_drift", "avg_p95_block_drift" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 - span[key] += $12 - pct[key] += $13 - avg[key] += $14 - p95[key] += $15 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 + span[key] += $13 + pct[key] += $14 + avg[key] += $15 + p95[key] += $16 count[key]++ } END { @@ -184,7 +190,7 @@ awk -F'\t' ' ' "$locality_tsv" >"$locality_summary_tsv" { head -n 1 "$locality_summary_tsv" - tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7 -k8,8 + tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7 -k8,8 -k9,9 } >"$locality_summary_tsv.tmp" mv "$locality_summary_tsv.tmp" "$locality_summary_tsv" From 9dd45d1976ebd29263d2f9ae2e98890d2c20896d Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 20:50:04 +0400 Subject: [PATCH 39/81] perf(heap): cache clustered overflow prefixes --- src/backend/access/heap/heapam.c | 3 +- src/backend/access/heap/hio.c | 250 +++++++++++++++++++++++-- src/include/access/hio.h | 3 +- src/tools/clustered_write_bench/README | 12 +- 4 files changed, 248 insertions(+), 20 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 9a6fa3773db80..dda7d4feb9cea 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -3024,7 +3024,8 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, heaptuples[i]->t_len, &targetBlock, 1, - true) > 0) + true, + NULL) > 0) clustered[i].targetBlock = targetBlock; if (prefixTargetCache != NULL && !prefixIsNull) diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index d3b74f4d08cf1..634464d0b3f63 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -28,6 +28,7 @@ #include "storage/freespace.h" #include "storage/lmgr.h" #include "utils/lsyscache.h" +#include "utils/memutils.h" #include "utils/relcache.h" #include "utils/snapmgr.h" @@ -39,6 +40,18 @@ */ #define CLUSTERED_WRITE_MAX_INDEX_TIDS 1024 #define CLUSTERED_WRITE_MAX_HEAP_BLOCKS 32 +#define CLUSTERED_WRITE_CACHE_MAGIC 0x48435743 /* HCWC */ + +typedef struct HeapClusteredWriteCache +{ + uint32 magic; + bool overflowActive; + AttrNumber overflowAttnum; + Oid overflowCollation; + RegProcedure overflowEqProc; + Datum overflowValue; + BlockNumber overflowTargetBlock; +} HeapClusteredWriteCache; static bool ClusteredWriteRememberCandidate(Relation relation, BlockNumber nblocks, @@ -66,7 +79,16 @@ static int RelationGetClusteredTargetBlocksForTuple(Relation relation, HeapTuple tuple, Size len, BlockNumber *targetBlocks, - int maxTargetBlocks); + int maxTargetBlocks, + bool *clusteredCandidatesExhausted); +static bool ClusteredWriteGetCachedOverflowTarget(Relation relation, + HeapTuple tuple, + BlockNumber *targetBlock); +static void ClusteredWriteRememberOverflowTarget(Relation relation, + HeapTuple tuple, + BlockNumber targetBlock); +static void ClusteredWriteUpdateCachedOverflowTarget(Relation relation, + BlockNumber targetBlock); /* * RelationCanUseClusteredTargetProbe @@ -301,7 +323,8 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, Size len, BlockNumber *targetBlocks, int maxTargetBlocks, - bool firstCandidateOnly) + bool firstCandidateOnly, + bool *clusteredCandidatesExhausted) { ScanKeyData skey[INDEX_MAX_KEYS]; IndexScanDesc scan; @@ -318,6 +341,9 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, Assert(maxTargetBlocks > 0); + if (clusteredCandidatesExhausted != NULL) + *clusteredCandidatesExhausted = false; + candidateFreeSpacePtr = firstCandidateOnly ? NULL : candidateFreeSpace; if (IsBootstrapProcessingMode() || indexRelation == NULL || @@ -530,13 +556,162 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, targetBlocks[ntargets++] = candidates[i]; } - for (int i = ncandidates - 1; i >= 0 && ntargets < maxTargetBlocks; i--) + if (ntargets == 0 && ncandidates > 0 && + clusteredCandidatesExhausted != NULL) + *clusteredCandidatesExhausted = true; + + return ntargets; +} + +static HeapClusteredWriteCache * +ClusteredWriteGetCache(Relation relation) +{ + HeapClusteredWriteCache *cache; + + cache = (HeapClusteredWriteCache *) relation->rd_amcache; + if (cache != NULL) { - if (candidateFreeSpace[i] < len) - targetBlocks[ntargets++] = candidates[i]; + if (cache->magic == CLUSTERED_WRITE_CACHE_MAGIC) + return cache; + + /* + * Heap owns rd_amcache for heap relations. If this ever fires, avoid + * interpreting another AM's private bytes as clustered-write state. + */ + return NULL; } - return ntargets; + cache = MemoryContextAllocZero(CacheMemoryContext, + sizeof(HeapClusteredWriteCache)); + cache->magic = CLUSTERED_WRITE_CACHE_MAGIC; + relation->rd_amcache = cache; + return cache; +} + +static bool +ClusteredWriteGetCachedOverflowTarget(Relation relation, HeapTuple tuple, + BlockNumber *targetBlock) +{ + HeapClusteredWriteCache *cache; + Datum prefixValue; + bool prefixIsNull; + bool equal; + + cache = (HeapClusteredWriteCache *) relation->rd_amcache; + if (cache == NULL || + cache->magic != CLUSTERED_WRITE_CACHE_MAGIC || + !cache->overflowActive || + !BlockNumberIsValid(cache->overflowTargetBlock) || + tuple == NULL) + return false; + + prefixValue = heap_getattr(tuple, cache->overflowAttnum, + relation->rd_att, &prefixIsNull); + if (prefixIsNull) + return false; + + equal = DatumGetBool(OidFunctionCall2Coll(cache->overflowEqProc, + cache->overflowCollation, + cache->overflowValue, + prefixValue)); + if (!equal) + return false; + + *targetBlock = cache->overflowTargetBlock; + return true; +} + +static void +ClusteredWriteRememberOverflowTarget(Relation relation, HeapTuple tuple, + BlockNumber targetBlock) +{ + HeapClusteredWriteCache *cache; + Relation indexRelation; + Oid indexOid; + Oid eqOperator; + RegProcedure eqProc; + Oid typeOid; + AttrNumber attnum; + Datum prefixValue; + bool prefixIsNull; + bool typByVal; + + if (tuple == NULL || !BlockNumberIsValid(targetBlock)) + return; + + indexOid = RelationGetClusteredIndex(relation); + if (!OidIsValid(indexOid)) + return; + + indexRelation = try_index_open(indexOid, AccessShareLock); + if (indexRelation == NULL) + return; + + if (!RelationCanUseClusteredTargetProbe(relation, indexRelation)) + { + index_close(indexRelation, AccessShareLock); + return; + } + + attnum = indexRelation->rd_index->indkey.values[0]; + typeOid = indexRelation->rd_opcintype[0]; + typByVal = get_typbyval(typeOid); + if (attnum <= 0 || !typByVal) + { + index_close(indexRelation, AccessShareLock); + return; + } + + eqOperator = get_opfamily_member(indexRelation->rd_opfamily[0], + typeOid, + typeOid, + BTEqualStrategyNumber); + if (!OidIsValid(eqOperator)) + { + index_close(indexRelation, AccessShareLock); + return; + } + eqProc = get_opcode(eqOperator); + if (!RegProcedureIsValid(eqProc)) + { + index_close(indexRelation, AccessShareLock); + return; + } + + prefixValue = heap_getattr(tuple, attnum, relation->rd_att, &prefixIsNull); + if (prefixIsNull) + { + index_close(indexRelation, AccessShareLock); + return; + } + + cache = ClusteredWriteGetCache(relation); + if (cache != NULL) + { + cache->overflowActive = true; + cache->overflowAttnum = attnum; + cache->overflowCollation = indexRelation->rd_indcollation[0]; + cache->overflowEqProc = eqProc; + cache->overflowValue = prefixValue; + cache->overflowTargetBlock = targetBlock; + } + + index_close(indexRelation, AccessShareLock); +} + +static void +ClusteredWriteUpdateCachedOverflowTarget(Relation relation, + BlockNumber targetBlock) +{ + HeapClusteredWriteCache *cache; + + cache = (HeapClusteredWriteCache *) relation->rd_amcache; + if (cache == NULL || + cache->magic != CLUSTERED_WRITE_CACHE_MAGIC || + !cache->overflowActive) + return; + + cache->overflowTargetBlock = targetBlock; } /* @@ -547,7 +722,8 @@ static int RelationGetClusteredTargetBlocksForTuple(Relation relation, HeapTuple tuple, Size len, BlockNumber *targetBlocks, - int maxTargetBlocks) + int maxTargetBlocks, + bool *clusteredCandidatesExhausted) { Oid indexOid; Relation indexRelation; @@ -569,7 +745,8 @@ RelationGetClusteredTargetBlocksForTuple(Relation relation, HeapTuple tuple, tuple, len, targetBlocks, maxTargetBlocks, - false); + false, + clusteredCandidatesExhausted); index_close(indexRelation, AccessShareLock); @@ -1022,6 +1199,7 @@ RelationGetBufferForTuple(Relation relation, Size len, clusteredTargetIndex = 0; bool usingClusteredTarget = false; bool usingClusteredOverflowTarget = false; + bool clusteredCandidatesExhausted = false; bool usingPreferredBlock = false; bool unlockedTargetBuffer; bool recheckVmPins; @@ -1098,15 +1276,36 @@ RelationGetBufferForTuple(Relation relation, Size len, } else if (tuple != NULL) { - nclusteredTargetBlocks = - RelationGetClusteredTargetBlocksForTuple(relation, tuple, - targetFreeSpace, - clusteredTargetBlocks, - lengthof(clusteredTargetBlocks)); - if (nclusteredTargetBlocks > 0) - targetBlock = clusteredTargetBlocks[clusteredTargetIndex]; + if (ClusteredWriteGetCachedOverflowTarget(relation, tuple, + &targetBlock)) + usingClusteredOverflowTarget = true; + else + { + nclusteredTargetBlocks = + RelationGetClusteredTargetBlocksForTuple(relation, tuple, + targetFreeSpace, + clusteredTargetBlocks, + lengthof(clusteredTargetBlocks), + &clusteredCandidatesExhausted); + if (nclusteredTargetBlocks > 0) + targetBlock = clusteredTargetBlocks[clusteredTargetIndex]; + else if (clusteredCandidatesExhausted) + { + BlockNumber nblocks = RelationGetNumberOfBlocks(relation); + + if (nblocks > 0) + { + targetBlock = nblocks - 1; + usingClusteredOverflowTarget = true; + ClusteredWriteRememberOverflowTarget(relation, tuple, + targetBlock); + } + } + } } usingClusteredTarget = (targetBlock != InvalidBlockNumber); + if (usingClusteredOverflowTarget) + usingClusteredTarget = false; } if (targetBlock != InvalidBlockNumber) @@ -1287,7 +1486,8 @@ RelationGetBufferForTuple(Relation relation, Size len, RelationGetClusteredTargetBlocksForTuple(relation, tuple, targetFreeSpace, clusteredTargetBlocks, - lengthof(clusteredTargetBlocks)); + lengthof(clusteredTargetBlocks), + &clusteredCandidatesExhausted); for (clusteredTargetIndex = 0; clusteredTargetIndex < nclusteredTargetBlocks; clusteredTargetIndex++) @@ -1298,6 +1498,21 @@ RelationGetBufferForTuple(Relation relation, Size len, } if (clusteredTargetIndex < nclusteredTargetBlocks) continue; + if (clusteredCandidatesExhausted) + { + BlockNumber nblocks = RelationGetNumberOfBlocks(relation); + + usingClusteredTarget = false; + if (nblocks > 0) + { + targetBlock = nblocks - 1; + usingClusteredOverflowTarget = true; + ClusteredWriteRememberOverflowTarget(relation, tuple, + targetBlock); + continue; + } + targetBlock = InvalidBlockNumber; + } } usingClusteredTarget = false; @@ -1339,7 +1554,6 @@ RelationGetBufferForTuple(Relation relation, Size len, if (use_fsm) RecordPageWithFreeSpace(relation, targetBlock, pageFreeSpace); - usingClusteredOverflowTarget = false; break; } @@ -1506,6 +1720,8 @@ RelationGetBufferForTuple(Relation relation, Size len, * good bet most of the time. So for now, don't add it to FSM yet. */ RelationSetTargetBlock(relation, targetBlock); + if (usingClusteredOverflowTarget) + ClusteredWriteUpdateCachedOverflowTarget(relation, targetBlock); return buffer; } diff --git a/src/include/access/hio.h b/src/include/access/hio.h index 0cdde5fc3d6ac..16b658d730173 100644 --- a/src/include/access/hio.h +++ b/src/include/access/hio.h @@ -61,7 +61,8 @@ extern int RelationGetClusteredTargetBlocksFromIndex(Relation relation, Size len, BlockNumber *targetBlocks, int maxTargetBlocks, - bool firstCandidateOnly); + bool firstCandidateOnly, + bool *clusteredCandidatesExhausted); extern Buffer RelationGetBufferForTuple(Relation relation, Size len, HeapTuple tuple, BlockNumber preferredBlock, diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index ca0c611d9faf7..1096be2245ad3 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -372,12 +372,22 @@ Do not repeat these paths blindly: `insert_hot` spans unchanged (`3737` blocks at heap fillfactor `90`, `6596` at fillfactor `50`). The kept fix pairs the tail fallback with a stricter target-free-space check for clustered candidates. +* **Per-row scans of already-exhausted hot prefixes:** fixed with a small + heap-AM overflow cache for by-value leading clustered keys. After the + target-free-space fix made the hot-key span compact, `insert into ... select` + still paid an index probe for every row in the same exhausted `tile_id`. + Remembering the exhausted prefix and current tail block brought the scale `1`, + hot `0.9` clustered insert from `1194.27 ms` / `1079.02 ms` at fillfactor + `90` / `50` to `70.85 ms` / `82.54 ms`, while preserving the compact + `insert_hot` spans (`384` / `693` blocks). The useful lesson from the rejected hot-prefix work is that duplicate-key batches need either a genuinely local page-selection policy for the repeated key, or enough prefix grouping that the already-kept all-hot guard can keep the overflow compact. Adding more eager probes, late FSM fallback tweaks, or more -per-tuple branching has repeatedly moved the cost curve in the wrong direction. +per-tuple branching has repeatedly moved the cost curve in the wrong direction; +when a prefix has already exhausted its clustered-neighbour pages, the fast path +is to keep appending locally and recheck the real page free space. Important output columns: From c51b4776f57375211f79c3572db1037316d54286 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 21:03:55 +0400 Subject: [PATCH 40/81] perf(heap): bound clustered reserve overflow --- src/backend/access/heap/hio.c | 160 ++++++++++++++++++++------ src/test/regress/expected/cluster.out | 21 ++-- src/test/regress/sql/cluster.sql | 12 +- 3 files changed, 147 insertions(+), 46 deletions(-) diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index 634464d0b3f63..5fe2b623df8dc 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -40,17 +40,24 @@ */ #define CLUSTERED_WRITE_MAX_INDEX_TIDS 1024 #define CLUSTERED_WRITE_MAX_HEAP_BLOCKS 32 +#define CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES 32 #define CLUSTERED_WRITE_CACHE_MAGIC 0x48435743 /* HCWC */ -typedef struct HeapClusteredWriteCache +typedef struct HeapClusteredWriteOverflowEntry { - uint32 magic; - bool overflowActive; + bool active; AttrNumber overflowAttnum; Oid overflowCollation; RegProcedure overflowEqProc; Datum overflowValue; BlockNumber overflowTargetBlock; +} HeapClusteredWriteOverflowEntry; + +typedef struct HeapClusteredWriteCache +{ + uint32 magic; + int overflowNextEntry; + HeapClusteredWriteOverflowEntry overflowEntries[CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES]; } HeapClusteredWriteCache; static bool ClusteredWriteRememberCandidate(Relation relation, @@ -555,6 +562,18 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, if (candidateFreeSpace[i] >= len) targetBlocks[ntargets++] = candidates[i]; } + if (ntargets == 0) + { + /* + * CLUSTER can leave useful reserve space on heap pages before the FSM + * has a matching entry. Return the bounded neighbour list anyway and + * let RelationGetBufferForTuple() recheck the actual page space under + * lock; long equal-prefix runs are redirected through the overflow cache + * once they consume one below-fillfactor neighbour. + */ + for (int i = 0; i < ncandidates && ntargets < maxTargetBlocks; i++) + targetBlocks[ntargets++] = candidates[i]; + } if (ntargets == 0 && ncandidates > 0 && clusteredCandidatesExhausted != NULL) @@ -595,30 +614,39 @@ ClusteredWriteGetCachedOverflowTarget(Relation relation, HeapTuple tuple, HeapClusteredWriteCache *cache; Datum prefixValue; bool prefixIsNull; - bool equal; cache = (HeapClusteredWriteCache *) relation->rd_amcache; if (cache == NULL || cache->magic != CLUSTERED_WRITE_CACHE_MAGIC || - !cache->overflowActive || - !BlockNumberIsValid(cache->overflowTargetBlock) || tuple == NULL) return false; - prefixValue = heap_getattr(tuple, cache->overflowAttnum, - relation->rd_att, &prefixIsNull); - if (prefixIsNull) - return false; + for (int i = 0; i < CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; i++) + { + HeapClusteredWriteOverflowEntry *entry = &cache->overflowEntries[i]; + bool equal; - equal = DatumGetBool(OidFunctionCall2Coll(cache->overflowEqProc, - cache->overflowCollation, - cache->overflowValue, - prefixValue)); - if (!equal) - return false; + if (!entry->active || + !BlockNumberIsValid(entry->overflowTargetBlock)) + continue; - *targetBlock = cache->overflowTargetBlock; - return true; + prefixValue = heap_getattr(tuple, entry->overflowAttnum, + relation->rd_att, &prefixIsNull); + if (prefixIsNull) + continue; + + equal = DatumGetBool(OidFunctionCall2Coll(entry->overflowEqProc, + entry->overflowCollation, + entry->overflowValue, + prefixValue)); + if (!equal) + continue; + + *targetBlock = entry->overflowTargetBlock; + return true; + } + + return false; } static void @@ -688,12 +716,54 @@ ClusteredWriteRememberOverflowTarget(Relation relation, HeapTuple tuple, cache = ClusteredWriteGetCache(relation); if (cache != NULL) { - cache->overflowActive = true; - cache->overflowAttnum = attnum; - cache->overflowCollation = indexRelation->rd_indcollation[0]; - cache->overflowEqProc = eqProc; - cache->overflowValue = prefixValue; - cache->overflowTargetBlock = targetBlock; + HeapClusteredWriteOverflowEntry *entry = NULL; + int insertAt; + + for (int i = 0; i < CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; i++) + { + bool equal; + + entry = &cache->overflowEntries[i]; + if (!entry->active || + entry->overflowAttnum != attnum || + entry->overflowCollation != indexRelation->rd_indcollation[0] || + entry->overflowEqProc != eqProc) + continue; + + equal = DatumGetBool(OidFunctionCall2Coll(entry->overflowEqProc, + entry->overflowCollation, + entry->overflowValue, + prefixValue)); + if (equal) + break; + entry = NULL; + } + + if (entry == NULL) + { + for (int i = 0; i < CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; i++) + { + if (!cache->overflowEntries[i].active) + { + entry = &cache->overflowEntries[i]; + break; + } + } + } + + if (entry == NULL) + { + insertAt = cache->overflowNextEntry++ % + CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; + entry = &cache->overflowEntries[insertAt]; + } + + entry->active = true; + entry->overflowAttnum = attnum; + entry->overflowCollation = indexRelation->rd_indcollation[0]; + entry->overflowEqProc = eqProc; + entry->overflowValue = prefixValue; + entry->overflowTargetBlock = targetBlock; } index_close(indexRelation, AccessShareLock); @@ -707,11 +777,14 @@ ClusteredWriteUpdateCachedOverflowTarget(Relation relation, cache = (HeapClusteredWriteCache *) relation->rd_amcache; if (cache == NULL || - cache->magic != CLUSTERED_WRITE_CACHE_MAGIC || - !cache->overflowActive) + cache->magic != CLUSTERED_WRITE_CACHE_MAGIC) return; - cache->overflowTargetBlock = targetBlock; + for (int i = 0; i < CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; i++) + { + if (cache->overflowEntries[i].active) + cache->overflowEntries[i].overflowTargetBlock = targetBlock; + } } /* @@ -1189,6 +1262,7 @@ RelationGetBufferForTuple(Relation relation, Size len, Buffer buffer = InvalidBuffer; Page page; Size nearlyEmptyFreeSpace, + clusteredTargetFreeSpace = 0, pageFreeSpace = 0, saveFreeSpace = 0, targetFreeSpace = 0; @@ -1238,6 +1312,9 @@ RelationGetBufferForTuple(Relation relation, Size len, targetFreeSpace = Max(len, nearlyEmptyFreeSpace); else targetFreeSpace = len + saveFreeSpace; + clusteredTargetFreeSpace = + (saveFreeSpace > 0 && targetFreeSpace == len + saveFreeSpace) ? + len : targetFreeSpace; if (otherBuffer != InvalidBuffer) otherBlock = BufferGetBlockNumber(otherBuffer); @@ -1283,7 +1360,7 @@ RelationGetBufferForTuple(Relation relation, Size len, { nclusteredTargetBlocks = RelationGetClusteredTargetBlocksForTuple(relation, tuple, - targetFreeSpace, + clusteredTargetFreeSpace, clusteredTargetBlocks, lengthof(clusteredTargetBlocks), &clusteredCandidatesExhausted); @@ -1442,10 +1519,29 @@ RelationGetBufferForTuple(Relation relation, Size len, } pageFreeSpace = PageGetHeapFreeSpace(page); - if (targetFreeSpace <= pageFreeSpace) + if (targetFreeSpace <= pageFreeSpace || + (usingClusteredTarget && len <= pageFreeSpace)) { - /* use this page as future insert target, too */ - RelationSetTargetBlock(relation, targetBlock); + if (targetFreeSpace <= pageFreeSpace) + { + /* use this page as future insert target, too */ + RelationSetTargetBlock(relation, targetBlock); + } + else + { + BlockNumber nblocks = RelationGetNumberOfBlocks(relation); + + /* + * Let a clustered write use one below-fillfactor neighbor, + * then redirect more equal-prefix overflow to the relation + * tail. This keeps rare keys near their cluster without + * spraying long duplicate-key runs through reserve space. + */ + RelationSetTargetBlock(relation, InvalidBlockNumber); + if (tuple != NULL && nblocks > 0) + ClusteredWriteRememberOverflowTarget(relation, tuple, + nblocks - 1); + } return buffer; } @@ -1484,7 +1580,7 @@ RelationGetBufferForTuple(Relation relation, Size len, usingPreferredBlock = false; nclusteredTargetBlocks = RelationGetClusteredTargetBlocksForTuple(relation, tuple, - targetFreeSpace, + clusteredTargetFreeSpace, clusteredTargetBlocks, lengthof(clusteredTargetBlocks), &clusteredCandidatesExhausted); diff --git a/src/test/regress/expected/cluster.out b/src/test/regress/expected/cluster.out index 461cbcfbbf869..52b611bb12278 100644 --- a/src/test/regress/expected/cluster.out +++ b/src/test/regress/expected/cluster.out @@ -306,8 +306,8 @@ WHERE pg_class.oid=indexrelid --------- (0 rows) --- Verify that clustered writes do not consume fillfactor reserve when the --- clustered key's existing pages are below the normal insert threshold. +-- Verify that clustered writes may use one fillfactor-reserve neighbor for a +-- clustered key, but repeated equal-prefix overflow is redirected to the tail. CREATE TABLE clstr_write_btree (id int, k int, filler text) WITH (fillfactor = 10); INSERT INTO clstr_write_btree SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10) @@ -315,15 +315,18 @@ FROM generate_series(1, 400) AS g; CREATE INDEX clstr_write_btree_k_id ON clstr_write_btree (k, id); CLUSTER clstr_write_btree USING clstr_write_btree_k_id; INSERT INTO clstr_write_btree VALUES (1001, 1, repeat('y', 10)); -SELECT tid_block(new_row.ctid) <= - (SELECT max(tid_block(ctid)) FROM clstr_write_btree WHERE k = 1 AND id <> 1001) +INSERT INTO clstr_write_btree VALUES (1002, 1, repeat('y', 10)); +SELECT id, tid_block(new_row.ctid) <= + (SELECT max(tid_block(ctid)) FROM clstr_write_btree WHERE k = 1 AND id NOT IN (1001, 1002)) AS used_clustered_key_reserve FROM clstr_write_btree AS new_row -WHERE id = 1001; - used_clustered_key_reserve ----------------------------- - f -(1 row) +WHERE id IN (1001, 1002) +ORDER BY id; + id | used_clustered_key_reserve +------+---------------------------- + 1001 | t + 1002 | f +(2 rows) DROP TABLE clstr_write_btree; -- Verify that reordered clustered COPY batches keep each slot's TID. diff --git a/src/test/regress/sql/cluster.sql b/src/test/regress/sql/cluster.sql index ff6deb7b99ab3..f05c93a1da832 100644 --- a/src/test/regress/sql/cluster.sql +++ b/src/test/regress/sql/cluster.sql @@ -103,8 +103,8 @@ WHERE pg_class.oid=indexrelid AND pg_class_2.relname = 'clstr_tst' AND indisclustered; --- Verify that clustered writes do not consume fillfactor reserve when the --- clustered key's existing pages are below the normal insert threshold. +-- Verify that clustered writes may use one fillfactor-reserve neighbor for a +-- clustered key, but repeated equal-prefix overflow is redirected to the tail. CREATE TABLE clstr_write_btree (id int, k int, filler text) WITH (fillfactor = 10); INSERT INTO clstr_write_btree SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10) @@ -112,11 +112,13 @@ FROM generate_series(1, 400) AS g; CREATE INDEX clstr_write_btree_k_id ON clstr_write_btree (k, id); CLUSTER clstr_write_btree USING clstr_write_btree_k_id; INSERT INTO clstr_write_btree VALUES (1001, 1, repeat('y', 10)); -SELECT tid_block(new_row.ctid) <= - (SELECT max(tid_block(ctid)) FROM clstr_write_btree WHERE k = 1 AND id <> 1001) +INSERT INTO clstr_write_btree VALUES (1002, 1, repeat('y', 10)); +SELECT id, tid_block(new_row.ctid) <= + (SELECT max(tid_block(ctid)) FROM clstr_write_btree WHERE k = 1 AND id NOT IN (1001, 1002)) AS used_clustered_key_reserve FROM clstr_write_btree AS new_row -WHERE id = 1001; +WHERE id IN (1001, 1002) +ORDER BY id; DROP TABLE clstr_write_btree; -- Verify that reordered clustered COPY batches keep each slot's TID. From e0cfc21bea266a1e0a5df90e465a17c41826c564 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 22:50:30 +0400 Subject: [PATCH 41/81] perf(heap): cache varlena clustered overflow prefixes --- src/backend/access/heap/hio.c | 44 ++++++++++++++++++++++---- src/test/regress/expected/cluster.out | 22 +++++++++++++ src/test/regress/sql/cluster.sql | 17 ++++++++++ src/tools/clustered_write_bench/README | 17 +++++++--- 4 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index 5fe2b623df8dc..9f2484a3d6b6e 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -27,6 +27,7 @@ #include "storage/bufmgr.h" #include "storage/freespace.h" #include "storage/lmgr.h" +#include "utils/datum.h" #include "utils/lsyscache.h" #include "utils/memutils.h" #include "utils/relcache.h" @@ -50,13 +51,15 @@ typedef struct HeapClusteredWriteOverflowEntry Oid overflowCollation; RegProcedure overflowEqProc; Datum overflowValue; + int16 overflowTypLen; + bool overflowTypByVal; BlockNumber overflowTargetBlock; } HeapClusteredWriteOverflowEntry; typedef struct HeapClusteredWriteCache { uint32 magic; - int overflowNextEntry; + uint32 overflowNextEntry; HeapClusteredWriteOverflowEntry overflowEntries[CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES]; } HeapClusteredWriteCache; @@ -96,6 +99,10 @@ static void ClusteredWriteRememberOverflowTarget(Relation relation, BlockNumber targetBlock); static void ClusteredWriteUpdateCachedOverflowTarget(Relation relation, BlockNumber targetBlock); +static void ClusteredWriteStoreOverflowValue(HeapClusteredWriteOverflowEntry *entry, + Datum value, + bool typByVal, + int16 typLen); /* * RelationCanUseClusteredTargetProbe @@ -607,6 +614,29 @@ ClusteredWriteGetCache(Relation relation) return cache; } +static void +ClusteredWriteStoreOverflowValue(HeapClusteredWriteOverflowEntry *entry, + Datum value, bool typByVal, int16 typLen) +{ + MemoryContext oldcontext; + + if (entry->active && !entry->overflowTypByVal) + pfree(DatumGetPointer(entry->overflowValue)); + + entry->overflowTypByVal = typByVal; + entry->overflowTypLen = typLen; + + if (typByVal) + { + entry->overflowValue = value; + return; + } + + oldcontext = MemoryContextSwitchTo(CacheMemoryContext); + entry->overflowValue = datumCopy(value, typByVal, typLen); + MemoryContextSwitchTo(oldcontext); +} + static bool ClusteredWriteGetCachedOverflowTarget(Relation relation, HeapTuple tuple, BlockNumber *targetBlock) @@ -663,6 +693,7 @@ ClusteredWriteRememberOverflowTarget(Relation relation, HeapTuple tuple, Datum prefixValue; bool prefixIsNull; bool typByVal; + int16 typLen; if (tuple == NULL || !BlockNumberIsValid(targetBlock)) return; @@ -683,8 +714,8 @@ ClusteredWriteRememberOverflowTarget(Relation relation, HeapTuple tuple, attnum = indexRelation->rd_index->indkey.values[0]; typeOid = indexRelation->rd_opcintype[0]; - typByVal = get_typbyval(typeOid); - if (attnum <= 0 || !typByVal) + get_typlenbyval(typeOid, &typLen, &typByVal); + if (attnum <= 0) { index_close(indexRelation, AccessShareLock); return; @@ -717,7 +748,6 @@ ClusteredWriteRememberOverflowTarget(Relation relation, HeapTuple tuple, if (cache != NULL) { HeapClusteredWriteOverflowEntry *entry = NULL; - int insertAt; for (int i = 0; i < CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; i++) { @@ -753,17 +783,19 @@ ClusteredWriteRememberOverflowTarget(Relation relation, HeapTuple tuple, if (entry == NULL) { + uint32 insertAt; + insertAt = cache->overflowNextEntry++ % CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; entry = &cache->overflowEntries[insertAt]; } - entry->active = true; entry->overflowAttnum = attnum; entry->overflowCollation = indexRelation->rd_indcollation[0]; entry->overflowEqProc = eqProc; - entry->overflowValue = prefixValue; + ClusteredWriteStoreOverflowValue(entry, prefixValue, typByVal, typLen); entry->overflowTargetBlock = targetBlock; + entry->active = true; } index_close(indexRelation, AccessShareLock); diff --git a/src/test/regress/expected/cluster.out b/src/test/regress/expected/cluster.out index 52b611bb12278..248fd3f7f4c29 100644 --- a/src/test/regress/expected/cluster.out +++ b/src/test/regress/expected/cluster.out @@ -329,6 +329,28 @@ ORDER BY id; (2 rows) DROP TABLE clstr_write_btree; +-- Verify that by-reference clustered keys use the same bounded overflow path. +CREATE TABLE clstr_write_text_overflow (id int, k text COLLATE "C", filler text) WITH (fillfactor = 10); +INSERT INTO clstr_write_text_overflow +SELECT g, CASE WHEN g <= 200 THEN 'a' ELSE 'b' END, repeat('x', 10) +FROM generate_series(1, 400) AS g; +CREATE INDEX clstr_write_text_overflow_k_id ON clstr_write_text_overflow (k, id); +CLUSTER clstr_write_text_overflow USING clstr_write_text_overflow_k_id; +INSERT INTO clstr_write_text_overflow VALUES (1001, 'a', repeat('y', 10)); +INSERT INTO clstr_write_text_overflow VALUES (1002, 'a', repeat('y', 10)); +SELECT id, tid_block(new_row.ctid) <= + (SELECT max(tid_block(ctid)) FROM clstr_write_text_overflow WHERE k = 'a' AND id NOT IN (1001, 1002)) + AS used_clustered_key_reserve +FROM clstr_write_text_overflow AS new_row +WHERE id IN (1001, 1002) +ORDER BY id; + id | used_clustered_key_reserve +------+---------------------------- + 1001 | t + 1002 | f +(2 rows) + +DROP TABLE clstr_write_text_overflow; -- Verify that reordered clustered COPY batches keep each slot's TID. CREATE TABLE clstr_write_copy_tid (id int, k int, filler text) WITH (fillfactor = 10); INSERT INTO clstr_write_copy_tid diff --git a/src/test/regress/sql/cluster.sql b/src/test/regress/sql/cluster.sql index f05c93a1da832..2edb13f9b13b9 100644 --- a/src/test/regress/sql/cluster.sql +++ b/src/test/regress/sql/cluster.sql @@ -121,6 +121,23 @@ WHERE id IN (1001, 1002) ORDER BY id; DROP TABLE clstr_write_btree; +-- Verify that by-reference clustered keys use the same bounded overflow path. +CREATE TABLE clstr_write_text_overflow (id int, k text COLLATE "C", filler text) WITH (fillfactor = 10); +INSERT INTO clstr_write_text_overflow +SELECT g, CASE WHEN g <= 200 THEN 'a' ELSE 'b' END, repeat('x', 10) +FROM generate_series(1, 400) AS g; +CREATE INDEX clstr_write_text_overflow_k_id ON clstr_write_text_overflow (k, id); +CLUSTER clstr_write_text_overflow USING clstr_write_text_overflow_k_id; +INSERT INTO clstr_write_text_overflow VALUES (1001, 'a', repeat('y', 10)); +INSERT INTO clstr_write_text_overflow VALUES (1002, 'a', repeat('y', 10)); +SELECT id, tid_block(new_row.ctid) <= + (SELECT max(tid_block(ctid)) FROM clstr_write_text_overflow WHERE k = 'a' AND id NOT IN (1001, 1002)) + AS used_clustered_key_reserve +FROM clstr_write_text_overflow AS new_row +WHERE id IN (1001, 1002) +ORDER BY id; +DROP TABLE clstr_write_text_overflow; + -- Verify that reordered clustered COPY batches keep each slot's TID. CREATE TABLE clstr_write_copy_tid (id int, k int, filler text) WITH (fillfactor = 10); INSERT INTO clstr_write_copy_tid diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 1096be2245ad3..756797facb727 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -373,13 +373,22 @@ Do not repeat these paths blindly: at fillfactor `50`). The kept fix pairs the tail fallback with a stricter target-free-space check for clustered candidates. * **Per-row scans of already-exhausted hot prefixes:** fixed with a small - heap-AM overflow cache for by-value leading clustered keys. After the + heap-AM overflow cache for leading clustered keys. After the target-free-space fix made the hot-key span compact, `insert into ... select` still paid an index probe for every row in the same exhausted `tile_id`. Remembering the exhausted prefix and current tail block brought the scale `1`, - hot `0.9` clustered insert from `1194.27 ms` / `1079.02 ms` at fillfactor - `90` / `50` to `70.85 ms` / `82.54 ms`, while preserving the compact - `insert_hot` spans (`384` / `693` blocks). + hot `0.9` integer clustered insert from `1194.27 ms` / `1079.02 ms` at + fillfactor `90` / `50` to `70.85 ms` / `82.54 ms`, while preserving the + compact `insert_hot` spans (`384` / `693` blocks). +* **By-reference clustered prefixes in the overflow cache:** fixed after the + by-value cache left generated text keys on the slow path. Before copying + varlena prefixes into `CacheMemoryContext`, scale `1` text hot inserts still + took roughly `770-805 ms`. With bounded cached text prefixes, the same + text-key matrix falls to `60.69-121.84 ms` depending on fillfactor, + single-key mode, and hot fraction. A same-backend smoke test that overwrote + `128` text prefixes through a `32`-entry cache held `CacheMemoryContext` + steady at `1048576` total bytes / `788232` used bytes before and after + `16384` additional inserts. The useful lesson from the rejected hot-prefix work is that duplicate-key batches need either a genuinely local page-selection policy for the repeated From 71dbdbf136930e3b29be4097f9658bc80682aac8 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 22:58:42 +0400 Subject: [PATCH 42/81] fix(heap): keep clustered overflow cache inline --- src/backend/access/heap/hio.c | 39 ++++++++++++++++++-------- src/tools/clustered_write_bench/README | 20 +++++++------ 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index 9f2484a3d6b6e..9e2aa81c46bdd 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -42,6 +42,7 @@ #define CLUSTERED_WRITE_MAX_INDEX_TIDS 1024 #define CLUSTERED_WRITE_MAX_HEAP_BLOCKS 32 #define CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES 32 +#define CLUSTERED_WRITE_OVERFLOW_VALUE_BYTES 1024 #define CLUSTERED_WRITE_CACHE_MAGIC 0x48435743 /* HCWC */ typedef struct HeapClusteredWriteOverflowEntry @@ -51,9 +52,11 @@ typedef struct HeapClusteredWriteOverflowEntry Oid overflowCollation; RegProcedure overflowEqProc; Datum overflowValue; + Size overflowValueLen; int16 overflowTypLen; bool overflowTypByVal; BlockNumber overflowTargetBlock; + char overflowValueStorage[CLUSTERED_WRITE_OVERFLOW_VALUE_BYTES]; } HeapClusteredWriteOverflowEntry; typedef struct HeapClusteredWriteCache @@ -99,7 +102,7 @@ static void ClusteredWriteRememberOverflowTarget(Relation relation, BlockNumber targetBlock); static void ClusteredWriteUpdateCachedOverflowTarget(Relation relation, BlockNumber targetBlock); -static void ClusteredWriteStoreOverflowValue(HeapClusteredWriteOverflowEntry *entry, +static bool ClusteredWriteStoreOverflowValue(HeapClusteredWriteOverflowEntry *entry, Datum value, bool typByVal, int16 typLen); @@ -614,27 +617,33 @@ ClusteredWriteGetCache(Relation relation) return cache; } -static void +static bool ClusteredWriteStoreOverflowValue(HeapClusteredWriteOverflowEntry *entry, Datum value, bool typByVal, int16 typLen) { - MemoryContext oldcontext; - - if (entry->active && !entry->overflowTypByVal) - pfree(DatumGetPointer(entry->overflowValue)); - entry->overflowTypByVal = typByVal; entry->overflowTypLen = typLen; if (typByVal) { entry->overflowValue = value; - return; + entry->overflowValueLen = sizeof(Datum); + return true; } - oldcontext = MemoryContextSwitchTo(CacheMemoryContext); - entry->overflowValue = datumCopy(value, typByVal, typLen); - MemoryContextSwitchTo(oldcontext); + /* + * rd_amcache must be one palloc chunk. Keep small pass-by-reference + * prefixes inline and decline large values instead of allocating + * subsidiary chunks that relcache invalidation would not know to free. + */ + entry->overflowValueLen = datumGetSize(value, typByVal, typLen); + if (entry->overflowValueLen > CLUSTERED_WRITE_OVERFLOW_VALUE_BYTES) + return false; + + memcpy(entry->overflowValueStorage, DatumGetPointer(value), + entry->overflowValueLen); + entry->overflowValue = PointerGetDatum(entry->overflowValueStorage); + return true; } static bool @@ -793,7 +802,13 @@ ClusteredWriteRememberOverflowTarget(Relation relation, HeapTuple tuple, entry->overflowAttnum = attnum; entry->overflowCollation = indexRelation->rd_indcollation[0]; entry->overflowEqProc = eqProc; - ClusteredWriteStoreOverflowValue(entry, prefixValue, typByVal, typLen); + if (!ClusteredWriteStoreOverflowValue(entry, prefixValue, + typByVal, typLen)) + { + entry->active = false; + index_close(indexRelation, AccessShareLock); + return; + } entry->overflowTargetBlock = targetBlock; entry->active = true; } diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 756797facb727..95ee67ac61d60 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -381,14 +381,18 @@ Do not repeat these paths blindly: fillfactor `90` / `50` to `70.85 ms` / `82.54 ms`, while preserving the compact `insert_hot` spans (`384` / `693` blocks). * **By-reference clustered prefixes in the overflow cache:** fixed after the - by-value cache left generated text keys on the slow path. Before copying - varlena prefixes into `CacheMemoryContext`, scale `1` text hot inserts still - took roughly `770-805 ms`. With bounded cached text prefixes, the same - text-key matrix falls to `60.69-121.84 ms` depending on fillfactor, - single-key mode, and hot fraction. A same-backend smoke test that overwrote - `128` text prefixes through a `32`-entry cache held `CacheMemoryContext` - steady at `1048576` total bytes / `788232` used bytes before and after - `16384` additional inserts. + by-value cache left generated text keys on the slow path. The first + implementation copied varlena prefixes into separate `CacheMemoryContext` + chunks, which was fast but violated the `rd_amcache` single-chunk lifetime + rule. The kept implementation stores small by-reference prefixes inline in + the `32`-entry cache and skips caching oversized values. Before this path, + scale `1` text hot inserts still took roughly `770-805 ms`; with bounded + cached text prefixes, the text-key matrix falls to `63.07-112.31 ms` in the + latest inline-cache sweep, depending on fillfactor, single-key mode, and hot + fraction. A same-backend overwrite smoke held `CacheMemoryContext` steady, + and a relcache-invalidation stress (`30` insert/invalidation cycles) dropped + from `1082680` total / `787256` used bytes after warmup to `1048576` total / + `755272` used bytes after stress instead of growing. The useful lesson from the rejected hot-prefix work is that duplicate-key batches need either a genuinely local page-selection policy for the repeated From 0ba32194f15c73dfebf5f5ad21190b79a9e0c6ab Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 23:06:01 +0400 Subject: [PATCH 43/81] docs(heap): record rejected clustered target cache --- src/tools/clustered_write_bench/README | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 95ee67ac61d60..e9beb5a3085c3 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -393,6 +393,18 @@ Do not repeat these paths blindly: and a relcache-invalidation stress (`30` insert/invalidation cycles) dropped from `1082680` total / `787256` used bytes after warmup to `1048576` total / `755272` used bytes after stress instead of growing. +* **Single-row target-block cache for non-exhausted prefixes:** rejected. This + tried to remember ordinary successful leading-prefix target pages for + `INSERT ... SELECT`, not just exhausted-prefix overflow pages. The idea was + to avoid repeated btree target probes in the non-hot synthetic matrix, but it + added enough extra per-prefix metadata work that scale `1`, hot `0` got + slower without improving locality. Representative clustered insert timings + regressed from the inline-overflow-cache sweep's `313.52 ms` to `348.92 ms` + for composite integer fillfactor `90`, from `417.75 ms` to `465.84 ms` for + composite integer fillfactor `50`, from `284.92 ms` to `377.66 ms` for + composite text fillfactor `90`, and from `317.57 ms` to `398.11 ms` for + single text fillfactor `90`. The raw focused run is under + `/home/kom/tmp/clustered-write-synthetic/single-row-target-cache-focused-20260430-230337/`. The useful lesson from the rejected hot-prefix work is that duplicate-key batches need either a genuinely local page-selection policy for the repeated From 341411c85f4ad31ab7cc8a0a8ce0f2f7f9e6e835 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 23:26:31 +0400 Subject: [PATCH 44/81] perf(heap): defer clustered overflow after reserve hits --- src/backend/access/heap/hio.c | 60 ++++++++++++++++++++++---- src/test/regress/expected/cluster.out | 45 +++++++++++++------ src/test/regress/sql/cluster.sql | 23 +++++----- src/tools/clustered_write_bench/README | 45 +++++++++++++------ 4 files changed, 128 insertions(+), 45 deletions(-) diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index 9e2aa81c46bdd..424fabc7a9765 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -43,6 +43,7 @@ #define CLUSTERED_WRITE_MAX_HEAP_BLOCKS 32 #define CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES 32 #define CLUSTERED_WRITE_OVERFLOW_VALUE_BYTES 1024 +#define CLUSTERED_WRITE_OVERFLOW_RESERVE_USES 8 #define CLUSTERED_WRITE_CACHE_MAGIC 0x48435743 /* HCWC */ typedef struct HeapClusteredWriteOverflowEntry @@ -54,6 +55,7 @@ typedef struct HeapClusteredWriteOverflowEntry Datum overflowValue; Size overflowValueLen; int16 overflowTypLen; + uint16 overflowReserveUses; bool overflowTypByVal; BlockNumber overflowTargetBlock; char overflowValueStorage[CLUSTERED_WRITE_OVERFLOW_VALUE_BYTES]; @@ -100,6 +102,9 @@ static bool ClusteredWriteGetCachedOverflowTarget(Relation relation, static void ClusteredWriteRememberOverflowTarget(Relation relation, HeapTuple tuple, BlockNumber targetBlock); +static void ClusteredWriteRememberReserveUse(Relation relation, + HeapTuple tuple, + BlockNumber targetBlock); static void ClusteredWriteUpdateCachedOverflowTarget(Relation relation, BlockNumber targetBlock); static bool ClusteredWriteStoreOverflowValue(HeapClusteredWriteOverflowEntry *entry, @@ -689,8 +694,9 @@ ClusteredWriteGetCachedOverflowTarget(Relation relation, HeapTuple tuple, } static void -ClusteredWriteRememberOverflowTarget(Relation relation, HeapTuple tuple, - BlockNumber targetBlock) +ClusteredWriteRememberOverflowTargetInternal(Relation relation, HeapTuple tuple, + BlockNumber targetBlock, + bool countReserveUse) { HeapClusteredWriteCache *cache; Relation indexRelation; @@ -757,6 +763,7 @@ ClusteredWriteRememberOverflowTarget(Relation relation, HeapTuple tuple, if (cache != NULL) { HeapClusteredWriteOverflowEntry *entry = NULL; + bool matchedEntry = false; for (int i = 0; i < CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; i++) { @@ -774,7 +781,10 @@ ClusteredWriteRememberOverflowTarget(Relation relation, HeapTuple tuple, entry->overflowValue, prefixValue)); if (equal) + { + matchedEntry = true; break; + } entry = NULL; } @@ -798,6 +808,8 @@ ClusteredWriteRememberOverflowTarget(Relation relation, HeapTuple tuple, CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; entry = &cache->overflowEntries[insertAt]; } + if (!matchedEntry) + entry->overflowReserveUses = 0; entry->overflowAttnum = attnum; entry->overflowCollation = indexRelation->rd_indcollation[0]; @@ -809,13 +821,42 @@ ClusteredWriteRememberOverflowTarget(Relation relation, HeapTuple tuple, index_close(indexRelation, AccessShareLock); return; } - entry->overflowTargetBlock = targetBlock; + if (countReserveUse) + { + if (entry->overflowReserveUses < UINT16_MAX) + entry->overflowReserveUses++; + if (entry->overflowReserveUses < CLUSTERED_WRITE_OVERFLOW_RESERVE_USES) + entry->overflowTargetBlock = InvalidBlockNumber; + else + entry->overflowTargetBlock = targetBlock; + } + else + { + entry->overflowReserveUses = CLUSTERED_WRITE_OVERFLOW_RESERVE_USES; + entry->overflowTargetBlock = targetBlock; + } entry->active = true; } index_close(indexRelation, AccessShareLock); } +static void +ClusteredWriteRememberOverflowTarget(Relation relation, HeapTuple tuple, + BlockNumber targetBlock) +{ + ClusteredWriteRememberOverflowTargetInternal(relation, tuple, targetBlock, + false); +} + +static void +ClusteredWriteRememberReserveUse(Relation relation, HeapTuple tuple, + BlockNumber targetBlock) +{ + ClusteredWriteRememberOverflowTargetInternal(relation, tuple, targetBlock, + true); +} + static void ClusteredWriteUpdateCachedOverflowTarget(Relation relation, BlockNumber targetBlock) @@ -829,7 +870,8 @@ ClusteredWriteUpdateCachedOverflowTarget(Relation relation, for (int i = 0; i < CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; i++) { - if (cache->overflowEntries[i].active) + if (cache->overflowEntries[i].active && + BlockNumberIsValid(cache->overflowEntries[i].overflowTargetBlock)) cache->overflowEntries[i].overflowTargetBlock = targetBlock; } } @@ -1579,15 +1621,15 @@ RelationGetBufferForTuple(Relation relation, Size len, BlockNumber nblocks = RelationGetNumberOfBlocks(relation); /* - * Let a clustered write use one below-fillfactor neighbor, - * then redirect more equal-prefix overflow to the relation - * tail. This keeps rare keys near their cluster without + * Let clustered writes use a small number of below-fillfactor + * neighbors before redirecting equal-prefix overflow to the + * relation tail. That keeps short grouped diffs local without * spraying long duplicate-key runs through reserve space. */ RelationSetTargetBlock(relation, InvalidBlockNumber); if (tuple != NULL && nblocks > 0) - ClusteredWriteRememberOverflowTarget(relation, tuple, - nblocks - 1); + ClusteredWriteRememberReserveUse(relation, tuple, + nblocks - 1); } return buffer; } diff --git a/src/test/regress/expected/cluster.out b/src/test/regress/expected/cluster.out index 248fd3f7f4c29..2825dc5bfa6e8 100644 --- a/src/test/regress/expected/cluster.out +++ b/src/test/regress/expected/cluster.out @@ -306,27 +306,36 @@ WHERE pg_class.oid=indexrelid --------- (0 rows) --- Verify that clustered writes may use one fillfactor-reserve neighbor for a --- clustered key, but repeated equal-prefix overflow is redirected to the tail. +-- Verify that clustered writes may use a small number of fillfactor-reserve +-- neighbors for a clustered key, but longer equal-prefix overflow is +-- redirected to the tail. CREATE TABLE clstr_write_btree (id int, k int, filler text) WITH (fillfactor = 10); INSERT INTO clstr_write_btree SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10) FROM generate_series(1, 400) AS g; CREATE INDEX clstr_write_btree_k_id ON clstr_write_btree (k, id); CLUSTER clstr_write_btree USING clstr_write_btree_k_id; -INSERT INTO clstr_write_btree VALUES (1001, 1, repeat('y', 10)); -INSERT INTO clstr_write_btree VALUES (1002, 1, repeat('y', 10)); +INSERT INTO clstr_write_btree +SELECT 1000 + g, 1, repeat('y', 10) +FROM generate_series(1, 9) AS g; SELECT id, tid_block(new_row.ctid) <= - (SELECT max(tid_block(ctid)) FROM clstr_write_btree WHERE k = 1 AND id NOT IN (1001, 1002)) + (SELECT max(tid_block(ctid)) FROM clstr_write_btree WHERE k = 1 AND id <= 400) AS used_clustered_key_reserve FROM clstr_write_btree AS new_row -WHERE id IN (1001, 1002) +WHERE id BETWEEN 1001 AND 1009 ORDER BY id; id | used_clustered_key_reserve ------+---------------------------- 1001 | t - 1002 | f -(2 rows) + 1002 | t + 1003 | t + 1004 | t + 1005 | t + 1006 | t + 1007 | t + 1008 | t + 1009 | f +(9 rows) DROP TABLE clstr_write_btree; -- Verify that by-reference clustered keys use the same bounded overflow path. @@ -336,19 +345,27 @@ SELECT g, CASE WHEN g <= 200 THEN 'a' ELSE 'b' END, repeat('x', 10) FROM generate_series(1, 400) AS g; CREATE INDEX clstr_write_text_overflow_k_id ON clstr_write_text_overflow (k, id); CLUSTER clstr_write_text_overflow USING clstr_write_text_overflow_k_id; -INSERT INTO clstr_write_text_overflow VALUES (1001, 'a', repeat('y', 10)); -INSERT INTO clstr_write_text_overflow VALUES (1002, 'a', repeat('y', 10)); +INSERT INTO clstr_write_text_overflow +SELECT 1000 + g, 'a', repeat('y', 10) +FROM generate_series(1, 9) AS g; SELECT id, tid_block(new_row.ctid) <= - (SELECT max(tid_block(ctid)) FROM clstr_write_text_overflow WHERE k = 'a' AND id NOT IN (1001, 1002)) + (SELECT max(tid_block(ctid)) FROM clstr_write_text_overflow WHERE k = 'a' AND id <= 400) AS used_clustered_key_reserve FROM clstr_write_text_overflow AS new_row -WHERE id IN (1001, 1002) +WHERE id BETWEEN 1001 AND 1009 ORDER BY id; id | used_clustered_key_reserve ------+---------------------------- 1001 | t - 1002 | f -(2 rows) + 1002 | t + 1003 | t + 1004 | t + 1005 | t + 1006 | t + 1007 | t + 1008 | t + 1009 | f +(9 rows) DROP TABLE clstr_write_text_overflow; -- Verify that reordered clustered COPY batches keep each slot's TID. diff --git a/src/test/regress/sql/cluster.sql b/src/test/regress/sql/cluster.sql index 2edb13f9b13b9..bacd1394c7e74 100644 --- a/src/test/regress/sql/cluster.sql +++ b/src/test/regress/sql/cluster.sql @@ -103,21 +103,23 @@ WHERE pg_class.oid=indexrelid AND pg_class_2.relname = 'clstr_tst' AND indisclustered; --- Verify that clustered writes may use one fillfactor-reserve neighbor for a --- clustered key, but repeated equal-prefix overflow is redirected to the tail. +-- Verify that clustered writes may use a small number of fillfactor-reserve +-- neighbors for a clustered key, but longer equal-prefix overflow is +-- redirected to the tail. CREATE TABLE clstr_write_btree (id int, k int, filler text) WITH (fillfactor = 10); INSERT INTO clstr_write_btree SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10) FROM generate_series(1, 400) AS g; CREATE INDEX clstr_write_btree_k_id ON clstr_write_btree (k, id); CLUSTER clstr_write_btree USING clstr_write_btree_k_id; -INSERT INTO clstr_write_btree VALUES (1001, 1, repeat('y', 10)); -INSERT INTO clstr_write_btree VALUES (1002, 1, repeat('y', 10)); +INSERT INTO clstr_write_btree +SELECT 1000 + g, 1, repeat('y', 10) +FROM generate_series(1, 9) AS g; SELECT id, tid_block(new_row.ctid) <= - (SELECT max(tid_block(ctid)) FROM clstr_write_btree WHERE k = 1 AND id NOT IN (1001, 1002)) + (SELECT max(tid_block(ctid)) FROM clstr_write_btree WHERE k = 1 AND id <= 400) AS used_clustered_key_reserve FROM clstr_write_btree AS new_row -WHERE id IN (1001, 1002) +WHERE id BETWEEN 1001 AND 1009 ORDER BY id; DROP TABLE clstr_write_btree; @@ -128,13 +130,14 @@ SELECT g, CASE WHEN g <= 200 THEN 'a' ELSE 'b' END, repeat('x', 10) FROM generate_series(1, 400) AS g; CREATE INDEX clstr_write_text_overflow_k_id ON clstr_write_text_overflow (k, id); CLUSTER clstr_write_text_overflow USING clstr_write_text_overflow_k_id; -INSERT INTO clstr_write_text_overflow VALUES (1001, 'a', repeat('y', 10)); -INSERT INTO clstr_write_text_overflow VALUES (1002, 'a', repeat('y', 10)); +INSERT INTO clstr_write_text_overflow +SELECT 1000 + g, 'a', repeat('y', 10) +FROM generate_series(1, 9) AS g; SELECT id, tid_block(new_row.ctid) <= - (SELECT max(tid_block(ctid)) FROM clstr_write_text_overflow WHERE k = 'a' AND id NOT IN (1001, 1002)) + (SELECT max(tid_block(ctid)) FROM clstr_write_text_overflow WHERE k = 'a' AND id <= 400) AS used_clustered_key_reserve FROM clstr_write_text_overflow AS new_row -WHERE id IN (1001, 1002) +WHERE id BETWEEN 1001 AND 1009 ORDER BY id; DROP TABLE clstr_write_text_overflow; diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index e9beb5a3085c3..1b70d2095ba1b 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -62,19 +62,25 @@ wasted clustered-index work. For single-row `INSERT ... SELECT` paths, the current page policy also requires clustered candidates to satisfy the normal insert target free-space threshold; when the equal-key neighborhood is below that threshold, the overflow run moves to the relation tail instead of -consuming fillfactor reserve from many old pages. On the scale `1`, hot `0.9` -synthetic run, this reduced clustered `insert_hot` span to the control shape -(`384` blocks at fillfactor `90`, `693` at fillfactor `50`) instead of the -previous scattered spans (`3737` and `6596`). The write-time cost is still -visible: clustered inserts in that run were about `1.1-1.2s` versus `74-76 ms` -for the no-cluster-metadata control. +consuming fillfactor reserve from many old pages. A later reserve-restoration +pass deliberately allowed short equal-prefix runs to use a small number of +below-fillfactor clustered neighbors before the overflow cache activates. That +keeps small grouped diffs local, but means the `insert_hot` span can still look +large because a few hot rows remain in the old clustered range while the rest +append at the tail. The split `insert_hot`/`insert_rest` metrics make that +tradeoff visible instead of hiding it in one aggregate. Set `ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true"` to run the insert diff with an explicit `ORDER BY tile_id, osm_id`. This is a diagnostic upper bound -for "buffer and micro-sort before insert" ideas. In the current hot synthetic -shape it does not improve locality by itself, because the important decision is -whether clustered insertion consumes old fillfactor reserve or starts a compact -overflow run. +for "buffer and micro-sort before insert" ideas. A focused scale `1`, hot `0` +run caught an important interaction: immediate overflow-cache activation after +the first below-fillfactor hit made sorted non-hot input fast but wrong, +dropping clustered insert locality from `83.97-100%` to `20.48%` inside the +base range. Delaying tail overflow until after a small number of reserve hits +restored sorted locality to `84.75%` at fillfactor `90` and `100%` at +fillfactor `50`. In the current hot synthetic shape, ordering alone still does +not make the forced hot rows local; it mainly checks whether a grouped input +shape accidentally trips the overflow policy too early. The wrapper runs the same SQL against `$DBNAME` (`postgres` by default), stores the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, @@ -378,8 +384,10 @@ Do not repeat these paths blindly: still paid an index probe for every row in the same exhausted `tile_id`. Remembering the exhausted prefix and current tail block brought the scale `1`, hot `0.9` integer clustered insert from `1194.27 ms` / `1079.02 ms` at - fillfactor `90` / `50` to `70.85 ms` / `82.54 ms`, while preserving the - compact `insert_hot` spans (`384` / `693` blocks). + fillfactor `90` / `50` to `70.85 ms` / `82.54 ms`. A later reserve + restoration allows a few below-fillfactor clustered neighbours before that + cache activates, so current hot spans are intentionally reported as a + tradeoff rather than as the old compact-span claim. * **By-reference clustered prefixes in the overflow cache:** fixed after the by-value cache left generated text keys on the slow path. The first implementation copied varlena prefixes into separate `CacheMemoryContext` @@ -405,6 +413,19 @@ Do not repeat these paths blindly: composite text fillfactor `90`, and from `317.57 ms` to `398.11 ms` for single text fillfactor `90`. The raw focused run is under `/home/kom/tmp/clustered-write-synthetic/single-row-target-cache-focused-20260430-230337/`. +* **Immediate tail overflow after the first below-fillfactor hit:** fixed after + `ORDER_DIFF_BY_CLUSTER_KEY_VALUES=true` exposed the failure mode. Sorted + non-hot input groups the few rows for each key together; with immediate + overflow activation, those short groups were sent to the relation tail after + one reserve hit, dropping clustered insert locality to `20.48%` inside the + base range. The kept policy counts a small number of reserve hits per + prefix before activating the cached tail target. The focused scale `1`, + hot `0`, repeat `3` run restored sorted locality to `84.75%` at fillfactor + `90` and `100%` at fillfactor `50`, while the hot `0.9/1` regression run + kept write timings in the same broad range as the inline overflow-cache + sweep. Invalid reserve-count entries are deliberately not updated when a + real overflow target extends, so an unrelated tail extension cannot + prematurely activate a counted-but-not-yet-overflowing prefix. The useful lesson from the rejected hot-prefix work is that duplicate-key batches need either a genuinely local page-selection policy for the repeated From 32b0a893aef35c17c9684fb4d1e3f62ee5c6902e Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 23:35:42 +0400 Subject: [PATCH 45/81] docs(heap): record rejected overflow metadata cache --- src/tools/clustered_write_bench/README | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 1b70d2095ba1b..88194e073e48b 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -413,6 +413,22 @@ Do not repeat these paths blindly: composite text fillfactor `90`, and from `317.57 ms` to `398.11 ms` for single text fillfactor `90`. The raw focused run is under `/home/kom/tmp/clustered-write-synthetic/single-row-target-cache-focused-20260430-230337/`. +* **Cached overflow leading-key metadata in `rd_amcache`:** rejected as a + follow-up optimization to the overflow cache. The prototype kept the leading + clustered-index attribute, collation, equality procedure, and type storage + metadata beside the overflow entries so reserve-hit accounting would not open + the clustered index and look up opfamily equality on every remembered target. + It preserved locality, but the focused scale `1`, hot `0`, repeat `3` + ordered/non-ordered matrix was mixed and often slower while control timings + were also noisy. Representative clustered insert averages regressed from + `418.44 ms` to `614.21 ms` for composite integer fillfactor `50` unordered, + from `353.75 ms` to `548.84 ms` for composite text fillfactor `50` ordered, + and from `275.28 ms` to `559.61 ms` for single text fillfactor `90` + unordered. Cases that improved were not enough to justify extra relcache + state. The likely lesson is that this does not remove the dominant per-row + target probe cost, so any saved syscache/index-open work is buried in noise + or outweighed by the extra cache state. The gzipped raw run is under + `/home/kom/tmp/clustered-write-synthetic/overflow-metadata-order-nonhot-20260430-233011/`. * **Immediate tail overflow after the first below-fillfactor hit:** fixed after `ORDER_DIFF_BY_CLUSTER_KEY_VALUES=true` exposed the failure mode. Sorted non-hot input groups the few rows for each key together; with immediate From 7c64e8e7c430825ef3ca0d782a574aefe3b0c0d8 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 23:41:33 +0400 Subject: [PATCH 46/81] docs(heap): record rejected reserve threshold tuning --- src/tools/clustered_write_bench/README | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 88194e073e48b..12dba85698c1a 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -442,6 +442,17 @@ Do not repeat these paths blindly: sweep. Invalid reserve-count entries are deliberately not updated when a real overflow target extends, so an unrelated tail extension cannot prematurely activate a counted-but-not-yet-overflowing prefix. +* **Lowering the reserve-hit threshold from `8` to `4`:** rejected. This + looked like a direct way to reduce the number of old-range outlier rows before + duplicate-key overflow moves to the compact tail, but it reintroduced the + short-group failure that the kept threshold fixed. In the focused scale `1`, + repeat `2`, integer-key matrix, sorted non-hot fillfactor `50` locality fell + to `81.92%` inside the base range, where the kept threshold-`8` run had + restored `100%`. Fillfactor `90` sorted non-hot also stayed below the kept + `84.75%` result (`81.91%` in the threshold-`4` run). Hot span did not + improve, so the more aggressive threshold only spent locality budget without + buying a useful compact-overflow win. The gzipped raw run is under + `/home/kom/tmp/clustered-write-synthetic/reserve-threshold4-focused-20260430-233816/`. The useful lesson from the rejected hot-prefix work is that duplicate-key batches need either a genuinely local page-selection policy for the repeated From f2a97582890a1c152b4e694489eaddf37fa72b64 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 30 Apr 2026 23:47:02 +0400 Subject: [PATCH 47/81] perf(heap): report clustered overflow tail span --- src/tools/clustered_write_bench/README | 21 ++++++++++++++---- .../clustered_write_bench/osm2pgsql_diff.sql | 5 +++++ .../run_synthetic_bench.sh | 22 ++++++++++--------- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 12dba85698c1a..3dab5ece5cc1e 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -69,6 +69,13 @@ keeps small grouped diffs local, but means the `insert_hot` span can still look large because a few hot rows remain in the old clustered range while the rest append at the tail. The split `insert_hot`/`insert_rest` metrics make that tradeoff visible instead of hiding it in one aggregate. +The later outside-base span metric makes this sharper: 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`, exactly +matching the control tail span. At fillfactor `50`, the clustered outside-base +span was `692` versus control `693`. The overflow run is therefore compact +after it leaves the old range; the large full span means "reserve rows plus +compact tail", not scattered tail placement. Set `ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true"` to run the insert diff with an explicit `ORDER BY tile_id, osm_id`. This is a diagnostic upper bound @@ -88,10 +95,12 @@ the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, preferred way to compare small hot-path changes because single runs can be noisy even with `fsync=off`. It also writes `server_version.txt`; check that file before comparing runs that were not started with `USE_TEMP_INSTANCE=true`. -`locality.tsv` reports both `heap_blocks_touched` and `heap_block_span`. -The span is useful for duplicate-key overflow experiments: a huge hot diff may -not fit back inside the original key range, but it can still be compact if the -new rows occupy a short contiguous run of heap blocks. +`locality.tsv` reports `heap_blocks_touched`, `heap_block_span`, and +`outside_base_heap_block_span`. The full span is useful for duplicate-key +overflow experiments, but it can overstate a mixed shape where a few rows still +fit inside the old clustered range and the rest append compactly at the tail. +The outside-base span measures only rows outside the original key range, making +the actual overflow tail compactness visible. Raw `psql` outputs are compressed with gzip by default; set `COMPRESS_RAW=false` to leave them as plain `.out` files. @@ -491,5 +500,9 @@ Important output columns: * `heap_block_span`: lower is better for the measured diff rows themselves; this is `max(heap_block) - min(heap_block) + 1`, so it catches whether an overflow tail is compact even when it cannot fit inside the old base range. +* `outside_base_heap_block_span`: lower is better for rows that did not fit + inside the old clustered key range. This helps separate a genuinely scattered + overflow tail from the expected mixed case where a few reserve-space rows + remain local and the rest append compactly. * `heap_blocks_touched`: lower can be better for locality, but interpret it with the drift metrics because very small values can also mean hot spots. diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index 6e86a0ee65638..0abbd58c4cc3b 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -383,6 +383,11 @@ select s.brin_enabled, count(*) as rows_measured, count(distinct heap_block) as heap_blocks_touched, max(heap_block) - min(heap_block) + 1 as heap_block_span, + coalesce( + max(heap_block) filter (where block_drift > 0) - + min(heap_block) filter (where block_drift > 0) + 1, + 0 + ) as outside_base_heap_block_span, round(100.0 * avg((block_drift = 0)::int), 2) as pct_inside_base_range, round(avg(block_drift)::numeric, 2) as avg_block_drift, round((percentile_cont(0.95) within group (order by block_drift))::numeric, 2) as p95_block_drift, diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index 3470166be85b7..42a228ec99ada 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -62,7 +62,7 @@ timing_summary_tsv="$OUTDIR/timing_summary.tsv" locality_summary_tsv="$OUTDIR/locality_summary.tsv" printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\torder_diff_by_cluster_key\tstep\telapsed_ms\n' >"$timings_tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\torder_diff_by_cluster_key\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\theap_block_span\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\torder_diff_by_cluster_key\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\theap_block_span\toutside_base_heap_block_span\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" for scale in $SCALE_VALUES; do for brin in $BRIN_VALUES; do @@ -118,8 +118,8 @@ SQL -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \ '$4 == "clustered_write" || $4 == "without_cluster_metadata" { - printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, order_diff_by_cluster_key, $4, $5, $6, $7, $8, $9, $10, $11, $12 + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, order_diff_by_cluster_key, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 }' "$raw" >>"$locality_tsv" if [[ "$COMPRESS_RAW" == "true" ]]; then @@ -169,23 +169,25 @@ awk -F'\t' ' print "scale", "brin_enabled", "single_key_cluster", "text_cluster_key", "heap_fillfactor", "hot_tile_fraction", "order_diff_by_cluster_key", "variant", "diff_kind", "runs", - "avg_heap_block_span", "avg_pct_inside_base_range", + "avg_heap_block_span", "avg_outside_base_heap_block_span", + "avg_pct_inside_base_range", "avg_block_drift", "avg_p95_block_drift" } NR > 1 { key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 span[key] += $13 - pct[key] += $14 - avg[key] += $15 - p95[key] += $16 + outside_span[key] += $14 + pct[key] += $15 + avg[key] += $16 + p95[key] += $17 count[key]++ } END { for (key in count) - printf "%s\t%d\t%.2f\t%.2f\t%.2f\t%.2f\n", + printf "%s\t%d\t%.2f\t%.2f\t%.2f\t%.2f\t%.2f\n", key, count[key], span[key] / count[key], - pct[key] / count[key], avg[key] / count[key], - p95[key] / count[key] + outside_span[key] / count[key], pct[key] / count[key], + avg[key] / count[key], p95[key] / count[key] } ' "$locality_tsv" >"$locality_summary_tsv" { From 2272e8fe014ee43416edcec3e4a3973dae55708e Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 01:29:25 +0400 Subject: [PATCH 48/81] perf(heap): capture synthetic bench environment --- src/tools/clustered_write_bench/README | 3 ++ .../run_synthetic_bench.sh | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 3dab5ece5cc1e..1a262103a7db1 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -95,6 +95,9 @@ the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, preferred way to compare small hot-path changes because single runs can be noisy even with `fsync=off`. It also writes `server_version.txt`; check that file before comparing runs that were not started with `USE_TEMP_INSTANCE=true`. +Each run also writes `run_environment.txt` with the git head, benchmark +settings, uptime/load, filesystem space, and selected PostgreSQL bindir, so +noisy timing comparisons can be traced back to the build and host state. `locality.tsv` reports `heap_blocks_touched`, `heap_block_span`, and `outside_base_heap_block_span`. The full span is useful for duplicate-key overflow experiments, but it can overstate a mixed shape where a few rows still diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index 42a228ec99ada..7c61f2b5c57b2 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -2,6 +2,7 @@ set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/../../.." && pwd) DBNAME=${DBNAME:-postgres} PSQL=${PSQL:-psql} @@ -23,6 +24,37 @@ KEEP_TEMP_INSTANCE_DATA=${KEEP_TEMP_INSTANCE_DATA:-false} mkdir -p "$OUTDIR/raw" +{ + printf 'date: ' + date -Is + printf 'script_dir: %s\n' "$SCRIPT_DIR" + printf 'repo_root: %s\n' "$REPO_ROOT" + if git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + printf 'git_head: %s\n' "$(git -C "$REPO_ROOT" rev-parse HEAD)" + printf 'git_branch: %s\n' "$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)" + fi + printf 'dbname: %s\n' "$DBNAME" + printf 'pg_bindir: %s\n' "$PG_BINDIR" + printf 'use_temp_instance: %s\n' "$USE_TEMP_INSTANCE" + printf 'pgport: %s\n' "$PGPORT" + printf 'pg_opts: %s\n' "$PG_OPTS" + printf 'repeats: %s\n' "$REPEATS" + printf 'scale_values: %s\n' "$SCALE_VALUES" + printf 'brin_values: %s\n' "$BRIN_VALUES" + printf 'single_key_values: %s\n' "$SINGLE_KEY_VALUES" + printf 'text_key_values: %s\n' "$TEXT_KEY_VALUES" + printf 'hot_tile_fraction_values: %s\n' "$HOT_TILE_FRACTION_VALUES" + printf 'heap_fillfactor_values: %s\n' "$HEAP_FILLFACTOR_VALUES" + printf 'order_diff_by_cluster_key_values: %s\n' "$ORDER_DIFF_BY_CLUSTER_KEY_VALUES" + printf 'compress_raw: %s\n' "$COMPRESS_RAW" + printf 'keep_temp_instance_data: %s\n' "$KEEP_TEMP_INSTANCE_DATA" + printf 'uname: ' + uname -a + printf 'uptime: ' + uptime + df -h "$OUTDIR" "${TMPDIR:-/tmp}" 2>/dev/null || true +} >"$OUTDIR/run_environment.txt" + if [[ "$USE_TEMP_INSTANCE" == "true" ]]; then if [[ -z "$PG_BINDIR" ]]; then printf 'USE_TEMP_INSTANCE=true requires PG_BINDIR=/path/to/postgres/bin\n' >&2 From 73de84d6333821ee2b22cb3555c4a034186ef884 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 01:33:13 +0400 Subject: [PATCH 49/81] perf(heap): report benchmark timing medians --- src/tools/clustered_write_bench/README | 6 +++++ .../run_synthetic_bench.sh | 27 ++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 1a262103a7db1..b0fbe1e5d51d2 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -95,6 +95,9 @@ the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, preferred way to compare small hot-path changes because single runs can be noisy even with `fsync=off`. It also writes `server_version.txt`; check that file before comparing runs that were not started with `USE_TEMP_INSTANCE=true`. +`timing_summary.tsv` reports average, median, minimum, and maximum elapsed +milliseconds for each step, so a single scheduler hiccup is visible instead of +silently moving the headline number. Each run also writes `run_environment.txt` with the git head, benchmark settings, uptime/load, filesystem space, and selected PostgreSQL bindir, so noisy timing comparisons can be traced back to the build and host state. @@ -496,6 +499,9 @@ Important output columns: * `step` and `elapsed_ms`: structured timing rows for the diff insert/update statements, emitted before the locality summary so runs can be compared without scraping `psql` timing chatter. +* `avg_elapsed_ms`, `median_elapsed_ms`, `min_elapsed_ms`, `max_elapsed_ms`: + aggregate timing columns in `timing_summary.tsv`; prefer the median when the + run environment shows high load or a small number of outliers. * `pct_inside_base_range`: higher is better; percent of diff tuples still inside the tile's original clustered heap block range. * `avg_block_drift`, `p95_block_drift`, `max_block_drift`: lower is better; diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index 7c61f2b5c57b2..a85a97c8b1acd 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -172,21 +172,42 @@ awk -F'\t' ' print "scale", "brin_enabled", "single_key_cluster", "text_cluster_key", "heap_fillfactor", "hot_tile_fraction", "order_diff_by_cluster_key", "step", "runs", - "avg_elapsed_ms", "min_elapsed_ms", "max_elapsed_ms" + "avg_elapsed_ms", "median_elapsed_ms", "min_elapsed_ms", + "max_elapsed_ms" } NR > 1 { key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 sum[key] += $10 count[key]++ + sample[key, count[key]] = $10 if (!(key in min) || $10 < min[key]) min[key] = $10 if (!(key in max) || $10 > max[key]) max[key] = $10 } END { + for (key in count) { + for (i = 1; i <= count[key]; i++) + value[i] = sample[key, i] + 0 + for (i = 1; i <= count[key]; i++) { + for (j = i + 1; j <= count[key]; j++) { + if (value[i] <= value[j]) + continue + tmp = value[i] + value[i] = value[j] + value[j] = tmp + } + } + median_pos = int((count[key] + 1) / 2) + if (count[key] % 2) + median[key] = value[median_pos] + else + median[key] = (value[median_pos] + value[median_pos + 1]) / 2 + } for (key in count) - printf "%s\t%d\t%.2f\t%.2f\t%.2f\n", - key, count[key], sum[key] / count[key], min[key], max[key] + printf "%s\t%d\t%.2f\t%.2f\t%.2f\t%.2f\n", + key, count[key], sum[key] / count[key], median[key], + min[key], max[key] } ' "$timings_tsv" >"$timing_summary_tsv" { From 92134398bfdad65e2ff1e2d4d46bd4a3257cf451 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 01:47:11 +0400 Subject: [PATCH 50/81] docs(heap): record current median benchmark baseline --- src/tools/clustered_write_bench/README | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index b0fbe1e5d51d2..04a506b67eb99 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -77,6 +77,18 @@ span was `692` versus control `693`. The overflow run is therefore compact after it leaves the old range; the large full span means "reserve rows plus compact tail", not scattered tail placement. +A current scale `1`, repeat `3`, single-key median run shows that the hot +duplicate-key cost is no longer the main problem: integer fillfactor `90` +`hot=0.9` clustered insert was `165.92 ms` median versus `120.71 ms` for the +control, while fillfactor `50` was `216.97 ms` versus `151.09 ms`. Generated +text keys stayed in the same class (`222.00 ms` versus `175.87 ms` at +fillfactor `90`, `273.00 ms` versus `200.61 ms` at fillfactor `50`). The +ordinary non-hot single-key case is still the expensive one: it preserves +`83.97%` to `100%` locality, but clustered insert medians were roughly +`614-1205 ms` depending on key type and fillfactor, compared to `158-296 ms` +for the control. Treat further work as a cost-reduction problem for +clustered target probes, not primarily as another hot-prefix overflow fix. + Set `ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true"` to run the insert diff with an explicit `ORDER BY tile_id, osm_id`. This is a diagnostic upper bound for "buffer and micro-sort before insert" ideas. A focused scale `1`, hot `0` @@ -88,6 +100,14 @@ restored sorted locality to `84.75%` at fillfactor `90` and `100%` at fillfactor `50`. In the current hot synthetic shape, ordering alone still does not make the forced hot rows local; it mainly checks whether a grouped input shape accidentally trips the overflow policy too early. +As a non-hot cost fix, ordered input is also not enough: a later scale `1`, +repeat `3`, single-key hot `0` run had mixed clustered insert medians, from a +small fillfactor `90` integer improvement (`970.20 ms` unordered to +`804.86 ms` ordered in that noisy run) to fillfactor `50` and text-key +regressions (`850.61 ms` to `917.17 ms` for integer fillfactor `50`, +`738.09 ms` to `812.56 ms` for text fillfactor `50`). Locality stayed in the +same shape, so ordering the input by itself is not a reliable substitute for +reducing target-probe cost. The wrapper runs the same SQL against `$DBNAME` (`postgres` by default), stores the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, @@ -468,6 +488,13 @@ Do not repeat these paths blindly: improve, so the more aggressive threshold only spent locality budget without buying a useful compact-overflow win. The gzipped raw run is under `/home/kom/tmp/clustered-write-synthetic/reserve-threshold4-focused-20260430-233816/`. +* **Relying on ordered diff input as the non-hot cost fix:** rejected as a + standalone answer. A scale `1`, repeat `3`, single-key `hot=0` run with + `ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true"` kept the same locality shape + (`84.75%` instead of `83.97%` inside base range at fillfactor `90`, `100%` at + fillfactor `50`), but clustered insert medians were mixed and remained far + above the control. The raw gzip run is under + `/home/kom/tmp/clustered-write-synthetic/current-nonhot-ordered-median-20260501-014236/`. The useful lesson from the rejected hot-prefix work is that duplicate-key batches need either a genuinely local page-selection policy for the repeated From 2ce006042a74dbb863f0af2297c499503011ec7a Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 01:53:11 +0400 Subject: [PATCH 51/81] docs(heap): record rejected singleton prefix skip --- src/tools/clustered_write_bench/README | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 04a506b67eb99..c1da143e5a6b2 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -495,6 +495,17 @@ Do not repeat these paths blindly: fillfactor `50`), but clustered insert medians were mixed and remained far above the control. The raw gzip run is under `/home/kom/tmp/clustered-write-synthetic/current-nonhot-ordered-median-20260501-014236/`. +* **Skipping clustered probes for singleton prefixes in a batch:** rejected. + This tried to reduce ordinary non-hot single-key cost by not probing the + clustered btree when a prefix appeared only once in the current + `heap_multi_insert` batch. The focused scale `1`, repeat `2`, single-key + `hot=0` run did not change locality in the synthetic matrix and timings were + mixed/noisy rather than a reliable win (`ff90` integer ordered improved in + that run, while generated text cases remained mixed). The policy is also + risky for genuinely sparse real diffs, where a singleton may still be the + only chance to preserve locality for that key. The code experiment was + reverted; raw gzip output and the reverted patch are under + `/home/kom/tmp/clustered-write-synthetic/skip-singleton-prefix-experiment-20260501-015018/`. The useful lesson from the rejected hot-prefix work is that duplicate-key batches need either a genuinely local page-selection policy for the repeated From fb4885bf8b3ce02eff060415f7a0a335b0510c8b Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 02:06:51 +0400 Subject: [PATCH 52/81] perf(heap): compare copy and insert synthetic diffs --- src/tools/clustered_write_bench/README | 49 ++++-- .../clustered_write_bench/osm2pgsql_diff.sql | 55 ++++++- .../run_synthetic_bench.sh | 142 ++++++++++-------- 3 files changed, 170 insertions(+), 76 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index c1da143e5a6b2..fe049ca95a7af 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -109,6 +109,31 @@ regressions (`850.61 ms` to `917.17 ms` for integer fillfactor `50`, same shape, so ordering the input by itself is not a reliable substitute for reducing target-probe cost. +Set `COPY_DIFF_FROM_FILE_VALUES="false true"` to compare the default +`INSERT ... SELECT` diff path with a server-side `COPY ... FROM` path. The +wrapper first writes the generated diff rows to a per-run tab-separated file +with `COPY (SELECT ...) TO`, then measures only the target-table +`COPY ... FROM`. This keeps the timed section closer to an osm2pgsql-style +bulk load and lets the benchmark exercise `heap_multi_insert` instead of only +row-at-a-time `heap_insert`. The intermediate file path must be writable by +the PostgreSQL server process; the wrapper removes it after each run, including +failed `psql` runs. + +A focused scale `1`, repeat `3`, single-key hot `0` comparison showed why this +needs its own axis. Integer COPY was usually faster than `INSERT ... SELECT` +(`ff90` unordered median `155.30 ms` versus `267.16 ms`, `ff50` ordered +`176.89 ms` versus `439.86 ms`), but at fillfactor `90` it also reduced +clustered insert locality from `83.97-84.75%` inside the base range to +`45.65-58.60%`. Fillfactor `50` kept `100%` locality for both INSERT and COPY. +Generated text keys were more expensive and noisier: unordered COPY regressed +badly (`ff90` median `909.14 ms` versus `392.73 ms`, `ff50` `1672.26 ms` +versus `807.77 ms`), while ordered COPY improved over ordered INSERT but still +did not make the text-key path cheap (`ff90` `469.63 ms` versus `557.44 ms`, +`ff50` `547.09 ms` versus `859.63 ms`). Treat COPY/multi-insert as a separate +cost and locality problem; it is not just a faster version of the row-at-a-time +path. The raw gzip output is under +`/home/kom/tmp/clustered-write-synthetic/copy-vs-insert-single-key-20260501-020036/`. + The wrapper runs the same SQL against `$DBNAME` (`postgres` by default), stores the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, `locality.tsv`, `timing_summary.tsv`, and `locality_summary.tsv`. This is the @@ -496,15 +521,17 @@ Do not repeat these paths blindly: above the control. The raw gzip run is under `/home/kom/tmp/clustered-write-synthetic/current-nonhot-ordered-median-20260501-014236/`. * **Skipping clustered probes for singleton prefixes in a batch:** rejected. - This tried to reduce ordinary non-hot single-key cost by not probing the - clustered btree when a prefix appeared only once in the current - `heap_multi_insert` batch. The focused scale `1`, repeat `2`, single-key - `hot=0` run did not change locality in the synthetic matrix and timings were - mixed/noisy rather than a reliable win (`ff90` integer ordered improved in - that run, while generated text cases remained mixed). The policy is also - risky for genuinely sparse real diffs, where a singleton may still be the - only chance to preserve locality for that key. The code experiment was - reverted; raw gzip output and the reverted patch are under + This tried to reduce ordinary non-hot single-key cost in `heap_multi_insert` + by not probing the clustered btree when a prefix appeared only once in the + current batch. The focused scale `1`, repeat `2`, single-key `hot=0` run did + not change locality in the synthetic matrix because the benchmark's + `INSERT ... SELECT` path uses `ExecInsert`/`table_tuple_insert()`/`heap_insert`, + not `heap_multi_insert`. This makes the experiment useful only as a layer + check: batch-level guards can help COPY, but they do not attack the measured + synthetic row-at-a-time insert cost. The policy is also risky for genuinely + sparse real COPY diffs, where a singleton may still be the only chance to + preserve locality for that key. The code experiment was reverted; raw gzip + output and the reverted patch are under `/home/kom/tmp/clustered-write-synthetic/skip-singleton-prefix-experiment-20260501-015018/`. The useful lesson from the rejected hot-prefix work is that duplicate-key @@ -530,6 +557,10 @@ Important output columns: to stress duplicate-key placement over already-full equal-key pages. * `order_diff_by_cluster_key`: whether the synthetic diff insert was explicitly ordered by `(tile_id, osm_id)` before insertion. +* `copy_diff_from_file`: whether the insert diff used the server-side + `COPY ... FROM` benchmark path instead of the default `INSERT ... SELECT` + path. The timed insert excludes the preceding per-run `COPY (SELECT ...) TO` + staging step. * `variant`: either the clustered-write path or the in-script control without remembered clustered-index metadata. * `diff_kind`: `insert`/`update` normally, or `insert_hot`/`insert_rest` when diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index 0abbd58c4cc3b..62bc9ef44d58f 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -35,6 +35,16 @@ \set order_diff_by_cluster_key false \endif +\if :{?copy_diff_from_file} +\else +\set copy_diff_from_file false +\endif + +\if :{?diff_copy_path} +\else +\set diff_copy_path '/tmp/clustered_write_diff_inserts.tsv' +\endif + \timing on drop table if exists clustered_write_osm_diff cascade; @@ -51,7 +61,8 @@ select (200000 * :scale)::int as base_rows, (:'text_cluster_key')::boolean as text_cluster_key, (:heap_fillfactor)::int as heap_fillfactor, (:hot_tile_fraction)::numeric as hot_tile_fraction, - (:'order_diff_by_cluster_key')::boolean as order_diff_by_cluster_key; + (:'order_diff_by_cluster_key')::boolean as order_diff_by_cluster_key, + (:'copy_diff_from_file')::boolean as copy_diff_from_file; create unlogged table clustered_write_osm_diff_on ( @@ -166,7 +177,8 @@ select (200000 * :scale)::int as base_rows, (:'text_cluster_key')::boolean as text_cluster_key, (:heap_fillfactor)::int as heap_fillfactor, (:hot_tile_fraction)::numeric as hot_tile_fraction, - (:'order_diff_by_cluster_key')::boolean as order_diff_by_cluster_key; + (:'order_diff_by_cluster_key')::boolean as order_diff_by_cluster_key, + (:'copy_diff_from_file')::boolean as copy_diff_from_file; create temp table clustered_write_step_timings ( @@ -202,9 +214,34 @@ select s.base_rows + g as osm_id, from clustered_write_settings as s, generate_series(1, s.insert_rows) as g; +\if :copy_diff_from_file +\if :order_diff_by_cluster_key +copy ( + select d.osm_id, + d.tile_id, + 1 as version, + repeat('insert', 16) as payload + from clustered_write_diff_inserts as d + order by d.tile_id, d.osm_id +) to :'diff_copy_path' with (format csv, delimiter E'\t'); +\else +copy ( + select d.osm_id, + d.tile_id, + 1 as version, + repeat('insert', 16) as payload + from clustered_write_diff_inserts as d +) to :'diff_copy_path' with (format csv, delimiter E'\t'); +\endif +\endif + insert into clustered_write_step_timings values ('clustered_write_insert', clock_timestamp(), null); +\if :copy_diff_from_file +copy clustered_write_osm_diff_on (osm_id, tile_id, version, payload) +from :'diff_copy_path' with (format csv, delimiter E'\t'); +\else \if :order_diff_by_cluster_key insert into clustered_write_osm_diff_on (osm_id, tile_id, version, payload) select d.osm_id, @@ -221,6 +258,7 @@ select d.osm_id, repeat('insert', 16) from clustered_write_diff_inserts as d; \endif +\endif update clustered_write_step_timings set finished_at = clock_timestamp() @@ -229,6 +267,10 @@ where step = 'clustered_write_insert'; insert into clustered_write_step_timings values ('without_cluster_metadata_insert', clock_timestamp(), null); +\if :copy_diff_from_file +copy clustered_write_osm_diff_off (osm_id, tile_id, version, payload) +from :'diff_copy_path' with (format csv, delimiter E'\t'); +\else \if :order_diff_by_cluster_key insert into clustered_write_osm_diff_off (osm_id, tile_id, version, payload) select d.osm_id, @@ -245,6 +287,7 @@ select d.osm_id, repeat('insert', 16) from clustered_write_diff_inserts as d; \endif +\endif update clustered_write_step_timings set finished_at = clock_timestamp() @@ -287,6 +330,7 @@ analyze clustered_write_osm_diff_off; select s.brin_enabled, s.text_cluster_key, s.heap_fillfactor, + s.copy_diff_from_file, t.step, round((extract(epoch from t.finished_at - t.started_at) * 1000)::numeric, 2) as elapsed_ms from clustered_write_step_timings as t @@ -378,6 +422,7 @@ drift as select s.brin_enabled, s.text_cluster_key, s.heap_fillfactor, + s.copy_diff_from_file, variant, diff_kind, count(*) as rows_measured, @@ -394,5 +439,7 @@ select s.brin_enabled, max(block_drift) as max_block_drift from drift join clustered_write_settings as s on true -group by s.brin_enabled, s.text_cluster_key, s.heap_fillfactor, variant, diff_kind -order by s.brin_enabled, s.text_cluster_key, s.heap_fillfactor, variant, diff_kind; +group by s.brin_enabled, s.text_cluster_key, s.heap_fillfactor, + s.copy_diff_from_file, variant, diff_kind +order by s.brin_enabled, s.text_cluster_key, s.heap_fillfactor, + s.copy_diff_from_file, variant, diff_kind; diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index a85a97c8b1acd..3ad1f5b9460c1 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -18,6 +18,7 @@ TEXT_KEY_VALUES=${TEXT_KEY_VALUES:-"false"} HOT_TILE_FRACTION_VALUES=${HOT_TILE_FRACTION_VALUES:-"0"} HEAP_FILLFACTOR_VALUES=${HEAP_FILLFACTOR_VALUES:-"90"} ORDER_DIFF_BY_CLUSTER_KEY_VALUES=${ORDER_DIFF_BY_CLUSTER_KEY_VALUES:-"false"} +COPY_DIFF_FROM_FILE_VALUES=${COPY_DIFF_FROM_FILE_VALUES:-"false"} OUTDIR=${OUTDIR:-"$HOME/tmp/clustered-write-synthetic/$(date +%Y%m%d-%H%M%S)"} COMPRESS_RAW=${COMPRESS_RAW:-true} KEEP_TEMP_INSTANCE_DATA=${KEEP_TEMP_INSTANCE_DATA:-false} @@ -46,6 +47,7 @@ mkdir -p "$OUTDIR/raw" printf 'hot_tile_fraction_values: %s\n' "$HOT_TILE_FRACTION_VALUES" printf 'heap_fillfactor_values: %s\n' "$HEAP_FILLFACTOR_VALUES" printf 'order_diff_by_cluster_key_values: %s\n' "$ORDER_DIFF_BY_CLUSTER_KEY_VALUES" + printf 'copy_diff_from_file_values: %s\n' "$COPY_DIFF_FROM_FILE_VALUES" printf 'compress_raw: %s\n' "$COMPRESS_RAW" printf 'keep_temp_instance_data: %s\n' "$KEEP_TEMP_INSTANCE_DATA" printf 'uname: ' @@ -93,8 +95,8 @@ locality_tsv="$OUTDIR/locality.tsv" timing_summary_tsv="$OUTDIR/timing_summary.tsv" locality_summary_tsv="$OUTDIR/locality_summary.tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\torder_diff_by_cluster_key\tstep\telapsed_ms\n' >"$timings_tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\torder_diff_by_cluster_key\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\theap_block_span\toutside_base_heap_block_span\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\torder_diff_by_cluster_key\tcopy_diff_from_file\tstep\telapsed_ms\n' >"$timings_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\torder_diff_by_cluster_key\tcopy_diff_from_file\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\theap_block_span\toutside_base_heap_block_span\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" for scale in $SCALE_VALUES; do for brin in $BRIN_VALUES; do @@ -103,60 +105,72 @@ for scale in $SCALE_VALUES; do for heap_fillfactor in $HEAP_FILLFACTOR_VALUES; do for hot_tile_fraction in $HOT_TILE_FRACTION_VALUES; do for order_diff_by_cluster_key in $ORDER_DIFF_BY_CLUSTER_KEY_VALUES; do - for run in $(seq 1 "$REPEATS"); do - raw="$OUTDIR/raw/scale-${scale}_brin-${brin}_single-key-${single_key}_text-key-${text_cluster_key}_fillfactor-${heap_fillfactor}_hot-tile-${hot_tile_fraction}_order-diff-${order_diff_by_cluster_key}_run-${run}.out" + for copy_diff_from_file in $COPY_DIFF_FROM_FILE_VALUES; do + for run in $(seq 1 "$REPEATS"); do + raw="$OUTDIR/raw/scale-${scale}_brin-${brin}_single-key-${single_key}_text-key-${text_cluster_key}_fillfactor-${heap_fillfactor}_hot-tile-${hot_tile_fraction}_order-diff-${order_diff_by_cluster_key}_copy-${copy_diff_from_file}_run-${run}.out" + diff_copy_path="${raw%.out}.copy.tsv" - "$PSQL" -X -v ON_ERROR_STOP=1 \ - -v scale="$scale" \ - -v use_brin="$brin" \ - -v single_key_cluster="$single_key" \ - -v text_cluster_key="$text_cluster_key" \ - -v heap_fillfactor="$heap_fillfactor" \ - -v hot_tile_fraction="$hot_tile_fraction" \ - -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \ - -d "$DBNAME" >"$raw" <"$raw" <>"$timings_tsv" + awk -F'|' \ + -v run="$run" \ + -v scale="$scale" \ + -v brin="$brin" \ + -v single_key="$single_key" \ + -v text_cluster_key="$text_cluster_key" \ + -v heap_fillfactor="$heap_fillfactor" \ + -v hot_tile_fraction="$hot_tile_fraction" \ + -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \ + -v copy_diff_from_file="$copy_diff_from_file" \ + '$5 == "clustered_write_insert" || + $5 == "clustered_write_update" || + $5 == "without_cluster_metadata_insert" || + $5 == "without_cluster_metadata_update" { + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, order_diff_by_cluster_key, copy_diff_from_file, $5, $6 + }' "$raw" >>"$timings_tsv" - awk -F'|' \ - -v run="$run" \ - -v scale="$scale" \ - -v brin="$brin" \ - -v single_key="$single_key" \ - -v text_cluster_key="$text_cluster_key" \ - -v heap_fillfactor="$heap_fillfactor" \ - -v hot_tile_fraction="$hot_tile_fraction" \ - -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \ - '$4 == "clustered_write" || - $4 == "without_cluster_metadata" { - printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, order_diff_by_cluster_key, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 - }' "$raw" >>"$locality_tsv" + awk -F'|' \ + -v run="$run" \ + -v scale="$scale" \ + -v brin="$brin" \ + -v single_key="$single_key" \ + -v text_cluster_key="$text_cluster_key" \ + -v heap_fillfactor="$heap_fillfactor" \ + -v hot_tile_fraction="$hot_tile_fraction" \ + -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \ + -v copy_diff_from_file="$copy_diff_from_file" \ + '$5 == "clustered_write" || + $5 == "without_cluster_metadata" { + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, order_diff_by_cluster_key, copy_diff_from_file, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 + }' "$raw" >>"$locality_tsv" - if [[ "$COMPRESS_RAW" == "true" ]]; then - gzip -f "$raw" - fi + if [[ "$COMPRESS_RAW" == "true" ]]; then + gzip -f "$raw" + fi + done done done done @@ -171,19 +185,20 @@ awk -F'\t' ' OFS = "\t" print "scale", "brin_enabled", "single_key_cluster", "text_cluster_key", "heap_fillfactor", "hot_tile_fraction", - "order_diff_by_cluster_key", "step", "runs", + "order_diff_by_cluster_key", "copy_diff_from_file", + "step", "runs", "avg_elapsed_ms", "median_elapsed_ms", "min_elapsed_ms", "max_elapsed_ms" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 - sum[key] += $10 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 + sum[key] += $11 count[key]++ - sample[key, count[key]] = $10 - if (!(key in min) || $10 < min[key]) - min[key] = $10 - if (!(key in max) || $10 > max[key]) - max[key] = $10 + sample[key, count[key]] = $11 + if (!(key in min) || $11 < min[key]) + min[key] = $11 + if (!(key in max) || $11 > max[key]) + max[key] = $11 } END { for (key in count) { @@ -212,7 +227,7 @@ awk -F'\t' ' ' "$timings_tsv" >"$timing_summary_tsv" { head -n 1 "$timing_summary_tsv" - tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7 -k8,8 + tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7 -k8,8 -k9,9 } >"$timing_summary_tsv.tmp" mv "$timing_summary_tsv.tmp" "$timing_summary_tsv" @@ -221,18 +236,19 @@ awk -F'\t' ' OFS = "\t" print "scale", "brin_enabled", "single_key_cluster", "text_cluster_key", "heap_fillfactor", "hot_tile_fraction", - "order_diff_by_cluster_key", "variant", "diff_kind", "runs", + "order_diff_by_cluster_key", "copy_diff_from_file", + "variant", "diff_kind", "runs", "avg_heap_block_span", "avg_outside_base_heap_block_span", "avg_pct_inside_base_range", "avg_block_drift", "avg_p95_block_drift" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 - span[key] += $13 - outside_span[key] += $14 - pct[key] += $15 - avg[key] += $16 - p95[key] += $17 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 OFS $11 + span[key] += $14 + outside_span[key] += $15 + pct[key] += $16 + avg[key] += $17 + p95[key] += $18 count[key]++ } END { @@ -245,7 +261,7 @@ awk -F'\t' ' ' "$locality_tsv" >"$locality_summary_tsv" { head -n 1 "$locality_summary_tsv" - tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7 -k8,8 -k9,9 + tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7 -k8,8 -k9,9 -k10,10 } >"$locality_summary_tsv.tmp" mv "$locality_summary_tsv.tmp" "$locality_summary_tsv" From 92274de9f907fd588b766e274f4178699293081a Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 02:17:54 +0400 Subject: [PATCH 53/81] fix(heap): keep clustered copy page boundaries --- src/backend/access/heap/heapam.c | 24 ++++++++++++++++ src/test/regress/expected/cluster.out | 25 +++++++++++++++++ src/test/regress/sql/cluster.sql | 38 ++++++++++++++++++++++++++ src/tools/clustered_write_bench/README | 21 ++++++++++++++ 4 files changed, 108 insertions(+) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index dda7d4feb9cea..b7d3874e18001 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -3222,6 +3222,30 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, { HeapTuple heaptup = heaptuples[ndone + nthispage]; + /* + * Clustered batch sorting groups tuples by a precomputed target + * block, but the normal multi-insert page packer would otherwise + * keep filling the first selected page with later tuples that were + * aiming at a different clustered neighborhood. Keep that + * high-throughput packing behavior for ordinary tuples, while + * preserving clustered placement boundaries when the batch has + * remembered targets. + */ + if (heaptuple_clustered_target_blocks != NULL) + { + BlockNumber currentBlock = BufferGetBlockNumber(buffer); + BlockNumber nextTargetBlock = + heaptuple_clustered_target_blocks[ndone + nthispage]; + + if (BlockNumberIsValid(clustered_target_block)) + { + if (nextTargetBlock != currentBlock) + break; + } + else if (BlockNumberIsValid(nextTargetBlock)) + break; + } + if (PageGetHeapFreeSpace(page) < MAXALIGN(heaptup->t_len) + saveFreeSpace) break; diff --git a/src/test/regress/expected/cluster.out b/src/test/regress/expected/cluster.out index 2825dc5bfa6e8..60b0b122545e1 100644 --- a/src/test/regress/expected/cluster.out +++ b/src/test/regress/expected/cluster.out @@ -400,6 +400,31 @@ ORDER BY id; RESET enable_bitmapscan; RESET enable_seqscan; DROP TABLE clstr_write_copy_tid; +-- Verify clustered COPY packing stops at clustered target boundaries. +CREATE TABLE clstr_write_copy_boundaries (id int, k int, filler text) WITH (fillfactor = 90); +INSERT INTO clstr_write_copy_boundaries +SELECT g, ((g - 1) % 32) + 1, repeat('x', 64) +FROM generate_series(1, 1600) AS g; +CREATE INDEX clstr_write_copy_boundaries_k_id ON clstr_write_copy_boundaries (k, id); +CLUSTER clstr_write_copy_boundaries USING clstr_write_copy_boundaries_k_id; +CREATE TEMP TABLE clstr_write_copy_boundaries_base AS +SELECT k, min(tid_block(ctid)) AS min_block, max(tid_block(ctid)) AS max_block +FROM clstr_write_copy_boundaries +GROUP BY k; +COPY clstr_write_copy_boundaries (id, k, filler) FROM stdin; +SELECT count(*) FILTER (WHERE tid_block(c.ctid) BETWEEN b.min_block AND b.max_block) AS inside, + count(*) AS total, + bool_and(tid_block(c.ctid) BETWEEN b.min_block AND b.max_block) AS all_inside +FROM clstr_write_copy_boundaries AS c +JOIN clstr_write_copy_boundaries_base AS b USING (k) +WHERE c.id >= 100001; + inside | total | all_inside +--------+-------+------------ + 16 | 16 | t +(1 row) + +DROP TABLE clstr_write_copy_boundaries; +DROP TABLE clstr_write_copy_boundaries_base; -- Verify all-equal clustered COPY batches also work with generated text keys. CREATE TABLE clstr_write_text_key ( id int PRIMARY KEY, diff --git a/src/test/regress/sql/cluster.sql b/src/test/regress/sql/cluster.sql index bacd1394c7e74..5a68de7e10f3e 100644 --- a/src/test/regress/sql/cluster.sql +++ b/src/test/regress/sql/cluster.sql @@ -171,6 +171,44 @@ RESET enable_bitmapscan; RESET enable_seqscan; DROP TABLE clstr_write_copy_tid; +-- Verify clustered COPY packing stops at clustered target boundaries. +CREATE TABLE clstr_write_copy_boundaries (id int, k int, filler text) WITH (fillfactor = 90); +INSERT INTO clstr_write_copy_boundaries +SELECT g, ((g - 1) % 32) + 1, repeat('x', 64) +FROM generate_series(1, 1600) AS g; +CREATE INDEX clstr_write_copy_boundaries_k_id ON clstr_write_copy_boundaries (k, id); +CLUSTER clstr_write_copy_boundaries USING clstr_write_copy_boundaries_k_id; +CREATE TEMP TABLE clstr_write_copy_boundaries_base AS +SELECT k, min(tid_block(ctid)) AS min_block, max(tid_block(ctid)) AS max_block +FROM clstr_write_copy_boundaries +GROUP BY k; +COPY clstr_write_copy_boundaries (id, k, filler) FROM stdin; +100001 1 y +100002 2 y +100003 3 y +100004 4 y +100005 5 y +100006 6 y +100007 7 y +100008 8 y +100009 9 y +100010 10 y +100011 11 y +100012 12 y +100013 13 y +100014 14 y +100015 15 y +100016 16 y +\. +SELECT count(*) FILTER (WHERE tid_block(c.ctid) BETWEEN b.min_block AND b.max_block) AS inside, + count(*) AS total, + bool_and(tid_block(c.ctid) BETWEEN b.min_block AND b.max_block) AS all_inside +FROM clstr_write_copy_boundaries AS c +JOIN clstr_write_copy_boundaries_base AS b USING (k) +WHERE c.id >= 100001; +DROP TABLE clstr_write_copy_boundaries; +DROP TABLE clstr_write_copy_boundaries_base; + -- Verify all-equal clustered COPY batches also work with generated text keys. CREATE TABLE clstr_write_text_key ( id int PRIMARY KEY, diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index fe049ca95a7af..1e824cc23e908 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -134,6 +134,27 @@ cost and locality problem; it is not just a faster version of the row-at-a-time path. The raw gzip output is under `/home/kom/tmp/clustered-write-synthetic/copy-vs-insert-single-key-20260501-020036/`. +That COPY locality loss was fixed by stopping the multi-insert page packer at +clustered target boundaries. The pre-fix batch sort grouped tuples by their +precomputed clustered target block, but once the first tuple selected a page, +the normal packer could still fill that page with later tuples that were aiming +at a different clustered neighborhood. A scale `0.02` smoke moved COPY +fillfactor `90` locality back to `84-85%` inside the base range. A focused +scale `1`, repeat `3`, integer-key run restored fillfactor `90` COPY locality +to `83.97%` unordered and `84.75%` ordered, matching the `INSERT ... SELECT` +shape; fillfactor `50` stayed `100%`. The write-time tradeoff is visible: +unordered integer COPY remained faster than INSERT (`ff90` median `311.79 ms` +versus `522.57 ms`, `ff50` `387.20 ms` versus `908.74 ms` in that run), while +ordered `ff90` COPY regressed (`483.56 ms` versus `296.00 ms`). A repeat `2` +text-key COPY check kept the same locality shape (`83.97-84.75%` at +fillfactor `90`, `100%` at `50`) but remained CPU-heavy (`ff90` medians +`853.26 ms` unordered, `507.18 ms` ordered). Treat the boundary guard as a +locality correctness fix for COPY; it does not by itself solve text-key probe +cost or every ordered-input timing case. Raw gzip output is under +`/home/kom/tmp/clustered-write-synthetic/copy-boundary-integer-focused-20260501-021034/` +and +`/home/kom/tmp/clustered-write-synthetic/copy-boundary-text-focused-20260501-021405/`. + The wrapper runs the same SQL against `$DBNAME` (`postgres` by default), stores the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, `locality.tsv`, `timing_summary.tsv`, and `locality_summary.tsv`. This is the From c735945e8ddc337374d3d33d2d4862fdbfd606b1 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 02:27:26 +0400 Subject: [PATCH 54/81] perf(heap): hash text clustered copy prefixes --- src/backend/access/heap/heapam.c | 60 +++++++++++++++++++++++--- src/test/regress/expected/cluster.out | 30 +++++++++++++ src/test/regress/sql/cluster.sql | 43 ++++++++++++++++++ src/tools/clustered_write_bench/README | 17 ++++++++ 4 files changed, 145 insertions(+), 5 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index b7d3874e18001..3f0383014f040 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -127,6 +127,8 @@ static HeapTuple heap_prepare_insert(Relation relation, HeapTuple tup, static int heap_clustered_write_item_cmp(const void *a, const void *b, void *arg); static bool heap_clustered_write_prefix_can_compare_all_equal(Oid typeOid); static bool heap_clustered_write_prefix_can_compare_by_datum(Oid typeOid); +static bool heap_clustered_write_prefix_can_hash(Oid typeOid); +static uint32 heap_clustered_write_prefix_hash(Datum value, Oid typeOid); static bool heap_clustered_write_index_can_sort(Relation relation, Relation indexRelation); static int heap_prepare_clustered_write_sort(Relation relation, @@ -271,6 +273,51 @@ heap_clustered_write_prefix_can_compare_by_datum(Oid typeOid) } } +static bool +heap_clustered_write_prefix_can_hash(Oid typeOid) +{ + if (get_typbyval(typeOid)) + return true; + + switch (typeOid) + { + case TEXTOID: + return true; + default: + return false; + } +} + +static uint32 +heap_clustered_write_prefix_hash(Datum value, Oid typeOid) +{ + if (get_typbyval(typeOid)) + return hash_bytes((unsigned char *) &value, sizeof(Datum)); + + switch (typeOid) + { + case TEXTOID: + { + struct varlena *text = (struct varlena *) + PG_DETOAST_DATUM_PACKED(value); + uint32 hash; + + /* + * This hash only selects a direct-mapped cache slot. Callers + * still verify hits with the clustered index equality operator, + * so different payloads can only cause cache misses or evictions. + */ + hash = hash_bytes((unsigned char *) VARDATA_ANY(text), + VARSIZE_ANY_EXHDR(text)); + if ((Pointer) text != DatumGetPointer(value)) + pfree(text); + return hash; + } + default: + pg_unreachable(); + } +} + static bool heap_clustered_write_index_can_sort(Relation relation, Relation indexRelation) { @@ -2794,7 +2841,8 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, ntuples * CLUSTERED_WRITE_PREFIX_TARGET_HASH_FACTOR)); usePrefixHashCache = - get_typbyval(clusteredIndexRelation->rd_opcintype[0]); + heap_clustered_write_prefix_can_hash( + clusteredIndexRelation->rd_opcintype[0]); if (!usePrefixHashCache) prefixTargetCacheLimit = ntuples; @@ -2834,8 +2882,9 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, { uint32 prefixHash; - prefixHash = hash_bytes((unsigned char *) &prefixValue, - sizeof(Datum)); + prefixHash = + heap_clustered_write_prefix_hash(prefixValue, + clusteredIndexRelation->rd_opcintype[0]); prefixCacheSlot = prefixHash & prefixTargetCacheMask; ncacheEntries = @@ -2973,8 +3022,9 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, { uint32 prefixHash; - prefixHash = hash_bytes((unsigned char *) &prefixValue, - sizeof(Datum)); + prefixHash = + heap_clustered_write_prefix_hash(prefixValue, + clusteredIndexRelation->rd_opcintype[0]); prefixCacheSlot = prefixHash & prefixTargetCacheMask; ncacheEntries = diff --git a/src/test/regress/expected/cluster.out b/src/test/regress/expected/cluster.out index 60b0b122545e1..5c5d3b22917c2 100644 --- a/src/test/regress/expected/cluster.out +++ b/src/test/regress/expected/cluster.out @@ -425,6 +425,36 @@ WHERE c.id >= 100001; DROP TABLE clstr_write_copy_boundaries; DROP TABLE clstr_write_copy_boundaries_base; +-- Verify mixed generated text clustered COPY batches use the prefix cache safely. +CREATE TABLE clstr_write_text_copy_boundaries ( + id int PRIMARY KEY, + tile_id int, + cluster_key text COLLATE "C" GENERATED ALWAYS AS ('g' || lpad(tile_id::text, 8, '0')) STORED, + filler text +) WITH (fillfactor = 90); +INSERT INTO clstr_write_text_copy_boundaries (id, tile_id, filler) +SELECT g, ((g - 1) % 32) + 1, repeat('x', 64) +FROM generate_series(1, 1600) AS g; +CREATE INDEX clstr_write_text_copy_boundaries_cluster ON clstr_write_text_copy_boundaries (cluster_key); +CLUSTER clstr_write_text_copy_boundaries USING clstr_write_text_copy_boundaries_cluster; +CREATE TEMP TABLE clstr_write_text_copy_boundaries_base AS +SELECT cluster_key, min(tid_block(ctid)) AS min_block, max(tid_block(ctid)) AS max_block +FROM clstr_write_text_copy_boundaries +GROUP BY cluster_key; +COPY clstr_write_text_copy_boundaries (id, tile_id, filler) FROM stdin; +SELECT count(*) FILTER (WHERE tid_block(c.ctid) BETWEEN b.min_block AND b.max_block) AS inside, + count(*) AS total, + bool_and(tid_block(c.ctid) BETWEEN b.min_block AND b.max_block) AS all_inside +FROM clstr_write_text_copy_boundaries AS c +JOIN clstr_write_text_copy_boundaries_base AS b USING (cluster_key) +WHERE c.id >= 100001; + inside | total | all_inside +--------+-------+------------ + 16 | 16 | t +(1 row) + +DROP TABLE clstr_write_text_copy_boundaries; +DROP TABLE clstr_write_text_copy_boundaries_base; -- Verify all-equal clustered COPY batches also work with generated text keys. CREATE TABLE clstr_write_text_key ( id int PRIMARY KEY, diff --git a/src/test/regress/sql/cluster.sql b/src/test/regress/sql/cluster.sql index 5a68de7e10f3e..2cfd7d670d04f 100644 --- a/src/test/regress/sql/cluster.sql +++ b/src/test/regress/sql/cluster.sql @@ -209,6 +209,49 @@ WHERE c.id >= 100001; DROP TABLE clstr_write_copy_boundaries; DROP TABLE clstr_write_copy_boundaries_base; +-- Verify mixed generated text clustered COPY batches use the prefix cache safely. +CREATE TABLE clstr_write_text_copy_boundaries ( + id int PRIMARY KEY, + tile_id int, + cluster_key text COLLATE "C" GENERATED ALWAYS AS ('g' || lpad(tile_id::text, 8, '0')) STORED, + filler text +) WITH (fillfactor = 90); +INSERT INTO clstr_write_text_copy_boundaries (id, tile_id, filler) +SELECT g, ((g - 1) % 32) + 1, repeat('x', 64) +FROM generate_series(1, 1600) AS g; +CREATE INDEX clstr_write_text_copy_boundaries_cluster ON clstr_write_text_copy_boundaries (cluster_key); +CLUSTER clstr_write_text_copy_boundaries USING clstr_write_text_copy_boundaries_cluster; +CREATE TEMP TABLE clstr_write_text_copy_boundaries_base AS +SELECT cluster_key, min(tid_block(ctid)) AS min_block, max(tid_block(ctid)) AS max_block +FROM clstr_write_text_copy_boundaries +GROUP BY cluster_key; +COPY clstr_write_text_copy_boundaries (id, tile_id, filler) FROM stdin; +100001 1 y +100002 2 y +100003 3 y +100004 4 y +100005 5 y +100006 6 y +100007 7 y +100008 8 y +100009 9 y +100010 10 y +100011 11 y +100012 12 y +100013 13 y +100014 14 y +100015 15 y +100016 16 y +\. +SELECT count(*) FILTER (WHERE tid_block(c.ctid) BETWEEN b.min_block AND b.max_block) AS inside, + count(*) AS total, + bool_and(tid_block(c.ctid) BETWEEN b.min_block AND b.max_block) AS all_inside +FROM clstr_write_text_copy_boundaries AS c +JOIN clstr_write_text_copy_boundaries_base AS b USING (cluster_key) +WHERE c.id >= 100001; +DROP TABLE clstr_write_text_copy_boundaries; +DROP TABLE clstr_write_text_copy_boundaries_base; + -- Verify all-equal clustered COPY batches also work with generated text keys. CREATE TABLE clstr_write_text_key ( id int PRIMARY KEY, diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 1e824cc23e908..501bd697c3ee6 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -447,6 +447,23 @@ Do not repeat these paths blindly: the same (`line 4/2362.25`, `point 4/1097`, `polygon 9/1289` p95 blocks/span). Exact polygon reads were faster in that single run, but the write path got worse, so the code change was reverted. +* **Normalized text-payload hashing for clustered COPY prefix caches:** fixed + after the safer page-boundary guard exposed the generated-text COPY cost. + Unlike the rejected `datum_image_hash()` prototype, this path hashes only + normalized `text` payload bytes (`VARDATA_ANY` / `VARSIZE_ANY_EXHDR`) and + uses the hash only to choose a direct-mapped cache slot; every cache hit is + still verified by the clustered index equality operator. In the focused + scale `1`, repeat `3`, generated text-key COPY run, median clustered COPY + insert time fell from `1040.17 ms` to `435.25 ms` at fillfactor `50` + unordered, from `400.92 ms` to `275.26 ms` at fillfactor `50` ordered, from + `853.26 ms` to `455.36 ms` at fillfactor `90` unordered, and from + `507.18 ms` to `366.62 ms` at fillfactor `90` ordered. Locality stayed at + `100.00%` inside the base range for fillfactor `50` and `83.97%` / + `84.75%` for unordered / ordered fillfactor `90`. The raw runs are + `/home/kom/tmp/clustered-write-synthetic/copy-boundary-text-focused-20260501-021405/` + before the fix and + `/home/kom/tmp/clustered-write-synthetic/text-prefix-payload-hash-focused-20260501-022213/` + after it. * **One-opened-index-per-`heap_multi_insert` batch as a simplification:** kept out as a performance change because the single Georgia rerun did not beat the prior generated-geohash result (`1:59.29` create, `1:56.57` append). From 9830aaa4af2943ce0b314520129a435ac9e30d32 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 02:36:25 +0400 Subject: [PATCH 55/81] perf(heap): lazily expand single-row clustered probes --- src/backend/access/heap/hio.c | 15 ++++++++++- src/tools/clustered_write_bench/README | 37 ++++++++++++++++++++------ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index 424fabc7a9765..615101214cc6c 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -95,6 +95,7 @@ static int RelationGetClusteredTargetBlocksForTuple(Relation relation, Size len, BlockNumber *targetBlocks, int maxTargetBlocks, + bool firstCandidateOnly, bool *clusteredCandidatesExhausted); static bool ClusteredWriteGetCachedOverflowTarget(Relation relation, HeapTuple tuple, @@ -885,6 +886,7 @@ RelationGetClusteredTargetBlocksForTuple(Relation relation, HeapTuple tuple, Size len, BlockNumber *targetBlocks, int maxTargetBlocks, + bool firstCandidateOnly, bool *clusteredCandidatesExhausted) { Oid indexOid; @@ -907,7 +909,7 @@ RelationGetClusteredTargetBlocksForTuple(Relation relation, HeapTuple tuple, tuple, len, targetBlocks, maxTargetBlocks, - false, + firstCandidateOnly, clusteredCandidatesExhausted); index_close(indexRelation, AccessShareLock); @@ -1447,14 +1449,24 @@ RelationGetBufferForTuple(Relation relation, Size len, usingClusteredOverflowTarget = true; else { + /* + * Start with the same cheap first-candidate probe that + * heap_multi_insert uses during batch preparation. If that + * page is full, the fallback below pays for the full bounded + * candidate window only when it is actually needed. + */ nclusteredTargetBlocks = RelationGetClusteredTargetBlocksForTuple(relation, tuple, clusteredTargetFreeSpace, clusteredTargetBlocks, lengthof(clusteredTargetBlocks), + true, &clusteredCandidatesExhausted); if (nclusteredTargetBlocks > 0) + { targetBlock = clusteredTargetBlocks[clusteredTargetIndex]; + usingPreferredBlock = true; + } else if (clusteredCandidatesExhausted) { BlockNumber nblocks = RelationGetNumberOfBlocks(relation); @@ -1672,6 +1684,7 @@ RelationGetBufferForTuple(Relation relation, Size len, clusteredTargetFreeSpace, clusteredTargetBlocks, lengthof(clusteredTargetBlocks), + false, &clusteredCandidatesExhausted); for (clusteredTargetIndex = 0; clusteredTargetIndex < nclusteredTargetBlocks; diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 501bd697c3ee6..98fe53ddfd7c9 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -82,12 +82,17 @@ duplicate-key cost is no longer the main problem: integer fillfactor `90` `hot=0.9` clustered insert was `165.92 ms` median versus `120.71 ms` for the control, while fillfactor `50` was `216.97 ms` versus `151.09 ms`. Generated text keys stayed in the same class (`222.00 ms` versus `175.87 ms` at -fillfactor `90`, `273.00 ms` versus `200.61 ms` at fillfactor `50`). The -ordinary non-hot single-key case is still the expensive one: it preserves -`83.97%` to `100%` locality, but clustered insert medians were roughly -`614-1205 ms` depending on key type and fillfactor, compared to `158-296 ms` -for the control. Treat further work as a cost-reduction problem for -clustered target probes, not primarily as another hot-prefix overflow fix. +fillfactor `90`, `273.00 ms` versus `200.61 ms` at fillfactor `50`). After +making single-row `INSERT ... SELECT` use a lazy first-candidate target probe, +the ordinary non-hot single-key case is also in a better cost class while +preserving the same locality: integer clustered insert medians were +`227.90 ms` / `218.98 ms` at fillfactor `90` / `50` unordered and +`329.28 ms` / `212.22 ms` ordered; generated text keys were `300.35 ms` / +`446.83 ms` unordered and `397.13 ms` / `408.36 ms` ordered. The matching +control medians were still lower (`83.67-199.65 ms`), but the previous +`614-1205 ms` clustered range is gone without losing `83.97%` to `100%` +inside-base locality. The focused raw run is under +`/home/kom/tmp/clustered-write-synthetic/lazy-single-row-first-candidate-focused-20260501-023105/`. Set `ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true"` to run the insert diff with an explicit `ORDER BY tile_id, osm_id`. This is a diagnostic upper bound @@ -363,8 +368,9 @@ Do not repeat these paths blindly: multi-insert preparation path only needs a preferred first block. Computing the full bounded target-page list before the insertion loop moved too much index work onto every tuple. The kept design does a cheap first-candidate - batch probe and leaves the full bounded candidate window for the lazy - full-page fallback in `RelationGetBufferForTuple()`. + probe for both batch preparation and single-row insert setup, then leaves the + full bounded candidate window for the lazy full-page fallback in + `RelationGetBufferForTuple()`. * **Repointing the rest of a batch after a preferred page is full:** rejected on hot duplicate-key smokes. It did not produce a useful locality gain and made scale `0.1` hot-tile insert timings worse. @@ -511,6 +517,21 @@ Do not repeat these paths blindly: composite text fillfactor `90`, and from `317.57 ms` to `398.11 ms` for single text fillfactor `90`. The raw focused run is under `/home/kom/tmp/clustered-write-synthetic/single-row-target-cache-focused-20260430-230337/`. +* **Lazy first-candidate single-row target probes:** fixed the later non-hot + single-row cost problem without adding persistent target-block cache state. + `RelationGetBufferForTuple()` now starts `INSERT ... SELECT` clustered + placement with one cheap candidate, just like `heap_multi_insert` batch + preparation, and pays for the full bounded forward/backward candidate window + only if that first page is actually full. In the focused scale `1`, repeat + `3`, single-key hot `0` matrix, clustered insert medians fell into the + `212.22-446.83 ms` range while preserving the same `83.97-100%` locality. + A hot `0.9/1.0` repeat `2` follow-up kept insert medians near the control + (`90.14-147.79 ms` clustered versus `62.39-115.53 ms` control) and retained + the previous compact-tail shape for rows outside the base range. 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/`. * **Cached overflow leading-key metadata in `rd_amcache`:** rejected as a follow-up optimization to the overflow cache. The prototype kept the leading clustered-index attribute, collation, equality procedure, and type storage From dd02106abd3bf4f42d9520e01ffcb992e5ec6d87 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 02:45:36 +0400 Subject: [PATCH 56/81] docs(heap): record current copy benchmark results --- src/tools/clustered_write_bench/README | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 98fe53ddfd7c9..a73c0da287709 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -148,6 +148,12 @@ fillfactor `90` locality back to `84-85%` inside the base range. A focused scale `1`, repeat `3`, integer-key run restored fillfactor `90` COPY locality to `83.97%` unordered and `84.75%` ordered, matching the `INSERT ... SELECT` shape; fillfactor `50` stayed `100%`. The write-time tradeoff is visible: +after the lazy single-row target-probe fix, a scale `1`, repeat `2`, +single-key matrix still showed COPY as a separate cost problem. Integer COPY +clustered insert medians ranged from `228.15 ms` to `520.25 ms`; generated +text-key COPY ranged from `183.49 ms` to `546.30 ms`, with unchanged +`83.97-100%` insert locality. The raw gzip run is under +`/home/kom/tmp/clustered-write-synthetic/current-insert-copy-after-lazy-single-row-20260501-023819/`. unordered integer COPY remained faster than INSERT (`ff90` median `311.79 ms` versus `522.57 ms`, `ff50` `387.20 ms` versus `908.74 ms` in that run), while ordered `ff90` COPY regressed (`483.56 ms` versus `296.00 ms`). A repeat `2` @@ -532,6 +538,19 @@ Do not repeat these paths blindly: `/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/`. +* **Raising COPY's buffered byte limit to reach the tuple limit:** rejected. + The idea was to let clustered COPY batches get closer to the existing + `1000`-tuple cap instead of flushing around `65535` bytes, giving the + clustered batch sort more rows to group. A local `MAX_BUFFERED_BYTES = + 262144` prototype preserved locality but produced mixed or worse insert + medians versus the current clean head: integer fillfactor `90` unordered COPY + regressed from `228.15 ms` to `423.06 ms`, ordered from `259.51 ms` to + `410.31 ms`; generated text fillfactor `90` ordered regressed from + `488.15 ms` to `556.99 ms`. A few cases improved, such as generated text + fillfactor `90` unordered (`546.30 ms` to `375.72 ms`), but the matrix was + not reliable enough to justify a broader memory-behaviour change. The code + experiment was reverted; raw gzip output is under + `/home/kom/tmp/clustered-write-synthetic/copy-buffer-256k-experiment-20260501-024252/`. * **Cached overflow leading-key metadata in `rd_amcache`:** rejected as a follow-up optimization to the overflow cache. The prototype kept the leading clustered-index attribute, collation, equality procedure, and type storage From d32e89ea20a33f403ee4f58d715c6b97110b9001 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 02:54:16 +0400 Subject: [PATCH 57/81] perf(heap): keep clustered copy fallback runs packed --- src/backend/access/heap/heapam.c | 9 +++++-- src/tools/clustered_write_bench/README | 35 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 3f0383014f040..664b995418867 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -3283,13 +3283,18 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, */ if (heaptuple_clustered_target_blocks != NULL) { - BlockNumber currentBlock = BufferGetBlockNumber(buffer); BlockNumber nextTargetBlock = heaptuple_clustered_target_blocks[ndone + nthispage]; if (BlockNumberIsValid(clustered_target_block)) { - if (nextTargetBlock != currentBlock) + /* + * Compare against the remembered clustered target, not + * the actual buffer. A full clustered neighbour may + * redirect the run to the tail, where same-target tuples + * should still be packed together. + */ + if (nextTargetBlock != clustered_target_block) break; } else if (BlockNumberIsValid(nextTargetBlock)) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index a73c0da287709..a7a4f75188d8a 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -154,6 +154,23 @@ clustered insert medians ranged from `228.15 ms` to `520.25 ms`; generated text-key COPY ranged from `183.49 ms` to `546.30 ms`, with unchanged `83.97-100%` insert locality. The raw gzip run is under `/home/kom/tmp/clustered-write-synthetic/current-insert-copy-after-lazy-single-row-20260501-023819/`. +The next fix keeps same-target COPY runs packed even when a full clustered +neighbour redirects the insertion to the relation tail. The previous boundary +guard compared later tuples against the actual selected buffer; when the first +tuple in a group fell back from its remembered clustered page to the tail, the +rest of the same-target group stopped packing with it and paid the multi-insert +page-selection cost row by row. A scale `1`, repeat `3`, copy-only matrix kept +the same locality and improved most clustered COPY medians: integer `ff50` +unordered/ordered moved from `520.25/291.35 ms` to `376.68/163.09 ms`, text +`ff90` unordered/ordered moved from `546.30/488.15 ms` to +`324.91/257.94 ms`, and text `ff50` unordered moved from `530.62 ms` to +`414.22 ms`. Integer `ff90` improved modestly (`228.15/259.51 ms` to +`212.44/220.67 ms`). The only initially suspicious case, text `ff50` ordered, +was rechecked with repeat `6` and had a `190.97 ms` median while preserving +`100%` inside-base locality. 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/`. unordered integer COPY remained faster than INSERT (`ff90` median `311.79 ms` versus `522.57 ms`, `ff50` `387.20 ms` versus `908.74 ms` in that run), while ordered `ff90` COPY regressed (`483.56 ms` versus `296.00 ms`). A repeat `2` @@ -538,6 +555,24 @@ Do not repeat these paths blindly: `/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/`. +* **Same-target COPY packing after tail fallback:** fixed. The first COPY + boundary guard preserved locality by stopping a multi-insert page when the + next tuple targeted a different clustered neighbourhood, but it compared + later tuples with the actual selected buffer. When the remembered clustered + page was full and `RelationGetBufferForTuple()` redirected the run to the + relation tail, tuples that shared the same remembered target stopped packing + together. The fix compares the next tuple with the remembered clustered + target instead, so same-target groups still use normal multi-insert packing + on the fallback page. A scale `1`, repeat `3`, copy-only matrix preserved + locality and improved most clustered COPY medians, including integer `ff50` + unordered/ordered (`520.25/291.35 ms` to `376.68/163.09 ms`) and text `ff90` + unordered/ordered (`546.30/488.15 ms` to `324.91/257.94 ms`). The suspected + text `ff50` ordered regression was re-run with repeat `6` and held a + `190.97 ms` median with `100%` inside-base locality. Raw gzip output is + 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/`. * **Raising COPY's buffered byte limit to reach the tuple limit:** rejected. The idea was to let clustered COPY batches get closer to the existing `1000`-tuple cap instead of flushing around `65535` bytes, giving the From 01d0ac680b34b22cc040ed0823a20424b2a52ce0 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 03:00:32 +0400 Subject: [PATCH 58/81] docs(heap): record rejected copy target cache shortcut --- src/tools/clustered_write_bench/README | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index a7a4f75188d8a..8b579d56d51f1 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -573,6 +573,20 @@ Do not repeat these paths blindly: `/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/`. +* **Reusing `prefixCountSlots` directly for the COPY target cache:** rejected. + The counting pass already verifies leading-key equality and records a + per-tuple count-cache slot, so a prototype reused that slot for target-cache + lookups instead of extracting the prefix and comparing against the target + cache again. That helped most generated text-key COPY medians in a focused + scale `1`, repeat `3` matrix (`ff90` unordered/ordered moved from + `324.91/257.94 ms` to `205.70/179.89 ms`, and `ff50` ordered moved from + `220.71 ms` to `190.23 ms`), but it regressed the `ff50` unordered text-key + case. A repeat `6` recheck confirmed that corner at `483.62 ms` median + versus the previous `414.22 ms`, with unchanged `100%` inside-base locality. + The code experiment was reverted; 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/`. * **Raising COPY's buffered byte limit to reach the tuple limit:** rejected. The idea was to let clustered COPY batches get closer to the existing `1000`-tuple cap instead of flushing around `65535` bytes, giving the From 2bc6e0046707972b633b5a4807b7762339f56c14 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 03:08:06 +0400 Subject: [PATCH 59/81] docs(heap): record rejected lazy prefix count experiment --- src/tools/clustered_write_bench/README | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 8b579d56d51f1..9c9c8ef5156ac 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -587,6 +587,17 @@ Do not repeat these paths blindly: `/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/`. +* **Lazy prefix-count allocation based on input-order runs:** rejected. The + idea was to avoid the full prefix-count pass for ordinary non-hot COPY + batches, allocating it only when adjacent input tuples already showed a + same-prefix run longer than `CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES`. The + prototype first needed extra compile fixes around the moved comparator and an + uninitialized comparator warning, then crashed with SIGSEGV on the first + scale `1` non-hot integer fillfactor `90` unordered COPY case. Since the + run detector added control-flow risk before proving any timing win, the code + experiment was reverted and `tmp_install` was rebuilt from the clean branch. + The crash log and compressed raw output are under + `/home/kom/tmp/clustered-write-synthetic/lazy-prefix-count-nonhot-copy-20260501-030413/`. * **Raising COPY's buffered byte limit to reach the tuple limit:** rejected. The idea was to let clustered COPY batches get closer to the existing `1000`-tuple cap instead of flushing around `65535` bytes, giving the From 321e2e1133e55dfd9b4384ead5b92b9bc5feacfa Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 03:17:59 +0400 Subject: [PATCH 60/81] docs(heap): record rejected prefix hash slot reuse --- src/tools/clustered_write_bench/README | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 9c9c8ef5156ac..028329cba278f 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -598,6 +598,22 @@ Do not repeat these paths blindly: experiment was reverted and `tmp_install` was rebuilt from the clean branch. The crash log and compressed raw output are under `/home/kom/tmp/clustered-write-synthetic/lazy-prefix-count-nonhot-copy-20260501-030413/`. +* **Reusing the prefix-count hash slot for COPY target-cache lookup:** + rejected. This was a narrower follow-up to the direct `prefixCountSlots` + target-cache shortcut: it reused only the already computed direct-mapped hash + slot, while still extracting the tuple prefix and verifying every target-cache + hit with the clustered btree equality function. That kept locality unchanged + and looked promising in a scale `1`, repeat `3` matrix for some text cases + (`ff50` unordered `414.22 ms` to `319.11 ms`, `ff90` ordered `257.94 ms` to + `198.32 ms`), but it regressed several integer cases and did not survive the + focused text unordered recheck. A repeat `6` text-key run measured `ff50` + unordered at `405.01 ms`, effectively the old shape, and `ff90` unordered at + `410.60 ms` versus the kept `324.91 ms`. The code experiment was reverted + and `tmp_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/`. * **Raising COPY's buffered byte limit to reach the tuple limit:** rejected. The idea was to let clustered COPY batches get closer to the existing `1000`-tuple cap instead of flushing around `65535` bytes, giving the From abe225cd63c7a92dee283ac5c578919ee3cde978 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 03:27:59 +0400 Subject: [PATCH 61/81] docs(heap): record rejected presorted qsort skip --- src/tools/clustered_write_bench/README | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 028329cba278f..c81b1c913ad3d 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -614,6 +614,25 @@ Do not repeat these paths blindly: `/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/`. +* **Skipping the clustered COPY qsort when target blocks are already ordered:** + rejected. The prototype detected whether the precomputed btree target blocks + were already in the same order as the clustered multi-insert comparator, and + skipped `qsort_arg()` in that case while keeping invalid targets after valid + ones. A scale `1`, repeat `3` COPY matrix looked broadly promising, with + seven of eight clustered COPY medians improving or staying near the kept + same-target-packing result (`ff50` integer unordered `376.68 ms` to + `306.69 ms`, `ff90` text unordered `324.91 ms` to `229.77 ms`), but + fillfactor `90` integer unordered already regressed. The focused repeat `6` + fillfactor `90` recheck confirmed the shortcut was not stable: integer + unordered/ordered regressed to `287.08/277.57 ms` from `212.44/220.67 ms`, + and text ordered regressed to `350.14 ms` from `257.94 ms`; only text + unordered improved (`324.91 ms` to `287.65 ms`). The code experiment was + reverted, `tmp_install` was rebuilt from the clean branch, and a tiny + post-revert COPY smoke passed. Raw gzip outputs are under + `/home/kom/tmp/clustered-write-synthetic/skip-presorted-target-qsort-copy-20260501-032018/`, + `/home/kom/tmp/clustered-write-synthetic/skip-presorted-target-qsort-ff90-rerun-20260501-032243/`, + and + `/home/kom/tmp/clustered-write-synthetic/post-qsort-skip-clean-copy-smoke-20260501-032659/`. * **Raising COPY's buffered byte limit to reach the tuple limit:** rejected. The idea was to let clustered COPY batches get closer to the existing `1000`-tuple cap instead of flushing around `65535` bytes, giving the From 9dcbe8e14791a12dd2a0a2081b126bb2b46fda00 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 03:41:58 +0400 Subject: [PATCH 62/81] fix(heap): avoid multi-hot prefix cleanup crash --- src/backend/access/heap/heapam.c | 3 +- src/tools/clustered_write_bench/README | 25 ++++++ .../clustered_write_bench/osm2pgsql_diff.sql | 9 +- .../run_synthetic_bench.sh | 87 ++++++++++--------- 4 files changed, 83 insertions(+), 41 deletions(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 664b995418867..6575903df3fce 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -3154,7 +3154,8 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, pfree(prefixCountSlots); pfree(prefixCountCache); pfree(prefixTargetCache); - MemoryContextDelete(prefixCacheCompareCxt); + if (prefixCacheCompareCxt != NULL) + MemoryContextDelete(prefixCacheCompareCxt); } } diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index c81b1c913ad3d..4ed4569c7150d 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -46,6 +46,11 @@ Set `HOT_TILE_FRACTION_VALUES="0 0.9"` to stress duplicate-key diffs. A value of `0.9` sends 90% of inserted diff rows to tile `1`, which exercises the case where the first equal-key pages are already full and later inserts must advance to newer nearby heap pages. +Set `HOT_TILE_COUNT_VALUES="1 8 128"` together with a non-zero hot fraction to +spread those forced duplicate-key rows across multiple hot tile keys. This +keeps the original single-hot-key cliff visible while also exercising batches +where several prefixes are hot at once, including the multi-hot cleanup path +that is easy to miss with only tile `1`. When this value is greater than zero, locality output splits inserts into `insert_hot` for the forced duplicate-key rows and `insert_rest` for the remaining rows. This keeps a hot-key cliff from being hidden by a small @@ -77,6 +82,24 @@ span was `692` versus control `693`. The overflow run is therefore compact after it leaves the old range; the large full span means "reserve rows plus compact tail", not scattered tail placement. +The multi-hot axis caught a COPY crash in the hot-prefix skip cleanup. When a +batch contained several prefixes whose per-prefix counts all exceeded +`CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES`, the count/target caches had already +allocated a comparison memory context before the batch chose to skip clustered +target lookups entirely. That skip path deleted the context immediately, and +the common cache cleanup then tried to delete it again. The cleanup now checks +the pointer before deleting it. A scale `1`, repeat `3`, +`hot_tile_fraction=0.9`, `hot_tile_count=1 8`, COPY run now completes; clustered +insert medians were `63.17 ms` for one hot key and `86.04 ms` for eight hot +keys, with compressed raw output under +`/home/kom/tmp/clustered-write-synthetic/multi-hot-cache-update-copy-fixed-20260501-033540/`. +The pre-fix crash log is under +`/home/kom/tmp/clustered-write-synthetic/multi-hot-cache-update-copy-20260501-033322/`. +A `hot_tile_count=128` follow-up exercised the non-skip multi-hot overflow shape +and completed without server errors; it measured clustered COPY insert at +`187.79 ms` median versus `59.78 ms` control and kept the non-hot tail +`96.85%` inside the base range. + A current scale `1`, repeat `3`, single-key median run shows that the hot duplicate-key cost is no longer the main problem: integer fillfactor `90` `hot=0.9` clustered insert was `165.92 ms` median versus `120.71 ms` for the @@ -728,6 +751,8 @@ Important output columns: the control copy. * `hot_tile_fraction`: fraction of inserted diff rows forced onto one tile key to stress duplicate-key placement over already-full equal-key pages. +* `hot_tile_count`: number of tile keys used for the forced duplicate-key rows + when `hot_tile_fraction` is non-zero. * `order_diff_by_cluster_key`: whether the synthetic diff insert was explicitly ordered by `(tile_id, osm_id)` before insertion. * `copy_diff_from_file`: whether the insert diff used the server-side diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index 62bc9ef44d58f..2a60b6327c357 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -25,6 +25,11 @@ \set hot_tile_fraction 0 \endif +\if :{?hot_tile_count} +\else +\set hot_tile_count 1 +\endif + \if :{?heap_fillfactor} \else \set heap_fillfactor 90 @@ -61,6 +66,7 @@ select (200000 * :scale)::int as base_rows, (:'text_cluster_key')::boolean as text_cluster_key, (:heap_fillfactor)::int as heap_fillfactor, (:hot_tile_fraction)::numeric as hot_tile_fraction, + greatest(1, least((4096 * :scale)::int, (:hot_tile_count)::int)) as hot_tile_count, (:'order_diff_by_cluster_key')::boolean as order_diff_by_cluster_key, (:'copy_diff_from_file')::boolean as copy_diff_from_file; @@ -177,6 +183,7 @@ select (200000 * :scale)::int as base_rows, (:'text_cluster_key')::boolean as text_cluster_key, (:heap_fillfactor)::int as heap_fillfactor, (:hot_tile_fraction)::numeric as hot_tile_fraction, + greatest(1, least((4096 * :scale)::int, (:hot_tile_count)::int)) as hot_tile_count, (:'order_diff_by_cluster_key')::boolean as order_diff_by_cluster_key, (:'copy_diff_from_file')::boolean as copy_diff_from_file; @@ -208,7 +215,7 @@ create temp table clustered_write_diff_inserts as select s.base_rows + g as osm_id, (g <= (s.insert_rows * s.hot_tile_fraction)::int) as is_hot_insert, case - when g <= (s.insert_rows * s.hot_tile_fraction)::int then 1 + when g <= (s.insert_rows * s.hot_tile_fraction)::int then ((g - 1) % s.hot_tile_count) + 1 else (((g::bigint * 1103515245 + 12345) % s.tile_count) + 1)::int end as tile_id from clustered_write_settings as s, diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index 3ad1f5b9460c1..13cd0b9c7ae49 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -16,6 +16,7 @@ BRIN_VALUES=${BRIN_VALUES:-"false true"} SINGLE_KEY_VALUES=${SINGLE_KEY_VALUES:-"false"} TEXT_KEY_VALUES=${TEXT_KEY_VALUES:-"false"} HOT_TILE_FRACTION_VALUES=${HOT_TILE_FRACTION_VALUES:-"0"} +HOT_TILE_COUNT_VALUES=${HOT_TILE_COUNT_VALUES:-"1"} HEAP_FILLFACTOR_VALUES=${HEAP_FILLFACTOR_VALUES:-"90"} ORDER_DIFF_BY_CLUSTER_KEY_VALUES=${ORDER_DIFF_BY_CLUSTER_KEY_VALUES:-"false"} COPY_DIFF_FROM_FILE_VALUES=${COPY_DIFF_FROM_FILE_VALUES:-"false"} @@ -45,6 +46,7 @@ mkdir -p "$OUTDIR/raw" printf 'single_key_values: %s\n' "$SINGLE_KEY_VALUES" printf 'text_key_values: %s\n' "$TEXT_KEY_VALUES" printf 'hot_tile_fraction_values: %s\n' "$HOT_TILE_FRACTION_VALUES" + printf 'hot_tile_count_values: %s\n' "$HOT_TILE_COUNT_VALUES" printf 'heap_fillfactor_values: %s\n' "$HEAP_FILLFACTOR_VALUES" printf 'order_diff_by_cluster_key_values: %s\n' "$ORDER_DIFF_BY_CLUSTER_KEY_VALUES" printf 'copy_diff_from_file_values: %s\n' "$COPY_DIFF_FROM_FILE_VALUES" @@ -95,8 +97,8 @@ locality_tsv="$OUTDIR/locality.tsv" timing_summary_tsv="$OUTDIR/timing_summary.tsv" locality_summary_tsv="$OUTDIR/locality_summary.tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\torder_diff_by_cluster_key\tcopy_diff_from_file\tstep\telapsed_ms\n' >"$timings_tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\torder_diff_by_cluster_key\tcopy_diff_from_file\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\theap_block_span\toutside_base_heap_block_span\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\thot_tile_count\torder_diff_by_cluster_key\tcopy_diff_from_file\tstep\telapsed_ms\n' >"$timings_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\thot_tile_count\torder_diff_by_cluster_key\tcopy_diff_from_file\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\theap_block_span\toutside_base_heap_block_span\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" for scale in $SCALE_VALUES; do for brin in $BRIN_VALUES; do @@ -104,23 +106,25 @@ for scale in $SCALE_VALUES; do for text_cluster_key in $TEXT_KEY_VALUES; do for heap_fillfactor in $HEAP_FILLFACTOR_VALUES; do for hot_tile_fraction in $HOT_TILE_FRACTION_VALUES; do - for order_diff_by_cluster_key in $ORDER_DIFF_BY_CLUSTER_KEY_VALUES; do - for copy_diff_from_file in $COPY_DIFF_FROM_FILE_VALUES; do - for run in $(seq 1 "$REPEATS"); do - raw="$OUTDIR/raw/scale-${scale}_brin-${brin}_single-key-${single_key}_text-key-${text_cluster_key}_fillfactor-${heap_fillfactor}_hot-tile-${hot_tile_fraction}_order-diff-${order_diff_by_cluster_key}_copy-${copy_diff_from_file}_run-${run}.out" - diff_copy_path="${raw%.out}.copy.tsv" + for hot_tile_count in $HOT_TILE_COUNT_VALUES; do + for order_diff_by_cluster_key in $ORDER_DIFF_BY_CLUSTER_KEY_VALUES; do + for copy_diff_from_file in $COPY_DIFF_FROM_FILE_VALUES; do + for run in $(seq 1 "$REPEATS"); do + raw="$OUTDIR/raw/scale-${scale}_brin-${brin}_single-key-${single_key}_text-key-${text_cluster_key}_fillfactor-${heap_fillfactor}_hot-tile-${hot_tile_fraction}_hot-tile-count-${hot_tile_count}_order-diff-${order_diff_by_cluster_key}_copy-${copy_diff_from_file}_run-${run}.out" + diff_copy_path="${raw%.out}.copy.tsv" - if ! "$PSQL" -X -v ON_ERROR_STOP=1 \ - -v scale="$scale" \ - -v use_brin="$brin" \ - -v single_key_cluster="$single_key" \ - -v text_cluster_key="$text_cluster_key" \ - -v heap_fillfactor="$heap_fillfactor" \ - -v hot_tile_fraction="$hot_tile_fraction" \ - -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \ - -v copy_diff_from_file="$copy_diff_from_file" \ - -v diff_copy_path="$diff_copy_path" \ - -d "$DBNAME" >"$raw" <"$raw" <>"$timings_tsv" awk -F'|' \ @@ -159,17 +164,19 @@ SQL -v text_cluster_key="$text_cluster_key" \ -v heap_fillfactor="$heap_fillfactor" \ -v hot_tile_fraction="$hot_tile_fraction" \ + -v hot_tile_count="$hot_tile_count" \ -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \ -v copy_diff_from_file="$copy_diff_from_file" \ '$5 == "clustered_write" || $5 == "without_cluster_metadata" { - printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, order_diff_by_cluster_key, copy_diff_from_file, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, hot_tile_count, order_diff_by_cluster_key, copy_diff_from_file, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 }' "$raw" >>"$locality_tsv" if [[ "$COMPRESS_RAW" == "true" ]]; then gzip -f "$raw" fi + done done done done @@ -185,20 +192,21 @@ awk -F'\t' ' OFS = "\t" print "scale", "brin_enabled", "single_key_cluster", "text_cluster_key", "heap_fillfactor", "hot_tile_fraction", - "order_diff_by_cluster_key", "copy_diff_from_file", + "hot_tile_count", "order_diff_by_cluster_key", + "copy_diff_from_file", "step", "runs", "avg_elapsed_ms", "median_elapsed_ms", "min_elapsed_ms", "max_elapsed_ms" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 - sum[key] += $11 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 OFS $11 + sum[key] += $12 count[key]++ - sample[key, count[key]] = $11 - if (!(key in min) || $11 < min[key]) - min[key] = $11 - if (!(key in max) || $11 > max[key]) - max[key] = $11 + sample[key, count[key]] = $12 + if (!(key in min) || $12 < min[key]) + min[key] = $12 + if (!(key in max) || $12 > max[key]) + max[key] = $12 } END { for (key in count) { @@ -227,7 +235,7 @@ awk -F'\t' ' ' "$timings_tsv" >"$timing_summary_tsv" { head -n 1 "$timing_summary_tsv" - tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7 -k8,8 -k9,9 + tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7n -k8,8 -k9,9 -k10,10 } >"$timing_summary_tsv.tmp" mv "$timing_summary_tsv.tmp" "$timing_summary_tsv" @@ -236,19 +244,20 @@ awk -F'\t' ' OFS = "\t" print "scale", "brin_enabled", "single_key_cluster", "text_cluster_key", "heap_fillfactor", "hot_tile_fraction", - "order_diff_by_cluster_key", "copy_diff_from_file", + "hot_tile_count", "order_diff_by_cluster_key", + "copy_diff_from_file", "variant", "diff_kind", "runs", "avg_heap_block_span", "avg_outside_base_heap_block_span", "avg_pct_inside_base_range", "avg_block_drift", "avg_p95_block_drift" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 OFS $11 - span[key] += $14 - outside_span[key] += $15 - pct[key] += $16 - avg[key] += $17 - p95[key] += $18 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 OFS $11 OFS $12 + span[key] += $15 + outside_span[key] += $16 + pct[key] += $17 + avg[key] += $18 + p95[key] += $19 count[key]++ } END { @@ -261,7 +270,7 @@ awk -F'\t' ' ' "$locality_tsv" >"$locality_summary_tsv" { head -n 1 "$locality_summary_tsv" - tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7 -k8,8 -k9,9 -k10,10 + tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7n -k8,8 -k9,9 -k10,10 -k11,11 } >"$locality_summary_tsv.tmp" mv "$locality_summary_tsv.tmp" "$locality_summary_tsv" From 5c8bbc6d1e59e149614f9fdf4fb4843481de56bb Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 03:48:04 +0400 Subject: [PATCH 63/81] docs(clustered-write): record rejected prefix threshold experiment --- src/tools/clustered_write_bench/README | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 4ed4569c7150d..dce9826d4bf9b 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -709,6 +709,17 @@ Do not repeat these paths blindly: improve, so the more aggressive threshold only spent locality budget without buying a useful compact-overflow win. The gzipped raw run is under `/home/kom/tmp/clustered-write-synthetic/reserve-threshold4-focused-20260430-233816/`. +* **Lowering `CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES` from `16` to `4`:** + rejected. This tried to make dense multi-hot COPY batches stop spending + clustered-target work sooner, using a scale `1`, repeat `3`, + `hot_tile_fraction=0.9`, `hot_tile_count=128` run with unordered and + clustered-key ordered COPY input. It preserved the same broad locality shape + but made both clustered COPY medians worse versus the clean threshold-`16` + head: unordered regressed from `140.79 ms` to `200.88 ms`, and ordered + regressed from `86.61 ms` to `129.12 ms`. The code experiment was reverted + and `tmp_install` was rebuilt from the clean branch. The gzipped raw run is + under + `/home/kom/tmp/clustered-write-synthetic/prefix-threshold4-multi-hot-128-copy-20260501-034550/`. * **Relying on ordered diff input as the non-hot cost fix:** rejected as a standalone answer. A scale `1`, repeat `3`, single-key `hot=0` run with `ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true"` kept the same locality shape From 057cf4809b7a67b8c76438e09c5f46acfc7e0236 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 03:58:54 +0400 Subject: [PATCH 64/81] perf(clustered-write): measure hot read benchmark --- src/tools/clustered_write_bench/README | 26 ++++++++++ .../clustered_write_bench/osm2pgsql_diff.sql | 52 +++++++++++++++++++ .../run_synthetic_bench.sh | 4 +- 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index dce9826d4bf9b..ff2228069c210 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -206,6 +206,14 @@ cost or every ordered-input timing case. Raw gzip output is under and `/home/kom/tmp/clustered-write-synthetic/copy-boundary-text-focused-20260501-021405/`. +After `analyze`, the synthetic workload also records +`clustered_write_read_hot` and `without_cluster_metadata_read_hot`. These +steps materialize a simple indexed read over the configured hot tile range: +integer-key runs filter by `tile_id`, while generated text-key runs filter by +the stored `cluster_key`. This keeps read cost visible next to write cost when +testing micro-sort or overflow-placement ideas; locality summaries alone cannot +show whether a more compact tail is actually worth extra insert work. + The wrapper runs the same SQL against `$DBNAME` (`postgres` by default), stores the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, `locality.tsv`, `timing_summary.tsv`, and `locality_summary.tsv`. This is the @@ -720,6 +728,24 @@ Do not repeat these paths blindly: and `tmp_install` was rebuilt from the clean branch. The gzipped raw run is under `/home/kom/tmp/clustered-write-synthetic/prefix-threshold4-multi-hot-128-copy-20260501-034550/`. +* **Btree leading-key sorting for skipped too-hot COPY prefixes:** rejected + for now. The prototype left valid clustered target blocks alone, but used + btree sort support to order only the tuples whose clustered target probe had + already been skipped as too-hot. It made the unordered multi-hot tail more + compact in the scale `1`, repeat `3`, `hot_tile_count=128` COPY run + (`insert_hot` outside-base span `392` blocks, `insert_rest` `113`), and the + new read step showed a possible unordered read win (`12.92 ms` prototype + median versus `15.70 ms` in a clean-code rerun). However, the same prototype + added qsort/sortsupport work to the hot COPY path, did not beat the earlier + clean write baseline for unordered inserts (`157.76 ms` versus `140.79 ms`), + and the clean rerun was noisy enough that this needs a better read/write + benchmark shape before carrying more heap comparator complexity. The code + experiment was reverted; the read timing benchmark was kept. Raw gzip + outputs are under + `/home/kom/tmp/clustered-write-synthetic/btree-prefix-sort-read-ab-proto-20260501-035531/`, + `/home/kom/tmp/clustered-write-synthetic/btree-prefix-sort-read-ab-clean-20260501-035714/`, + and + `/home/kom/tmp/clustered-write-synthetic/multi-hot-128-ordered-copy-20260501-034338/`. * **Relying on ordered diff input as the non-hot cost fix:** rejected as a standalone answer. A scale `1`, repeat `3`, single-key `hot=0` run with `ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true"` kept the same locality shape diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index 2a60b6327c357..507db36b88f20 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -334,6 +334,58 @@ where step = 'without_cluster_metadata_update'; analyze clustered_write_osm_diff_on; analyze clustered_write_osm_diff_off; +insert into clustered_write_step_timings +values ('clustered_write_read_hot', clock_timestamp(), null); + +\if :text_cluster_key +create temp table clustered_write_read_hot_on as +select count(*) as rows_read, + sum(length(o.payload)) as payload_bytes +from clustered_write_osm_diff_on as o +join clustered_write_settings as s on true +where o.cluster_key in ( + select 'g' || lpad(g::text, 8, '0') + from generate_series(1, s.hot_tile_count) as g +); +\else +create temp table clustered_write_read_hot_on as +select count(*) as rows_read, + sum(length(o.payload)) as payload_bytes +from clustered_write_osm_diff_on as o +join clustered_write_settings as s on true +where o.tile_id between 1 and s.hot_tile_count; +\endif + +update clustered_write_step_timings +set finished_at = clock_timestamp() +where step = 'clustered_write_read_hot'; + +insert into clustered_write_step_timings +values ('without_cluster_metadata_read_hot', clock_timestamp(), null); + +\if :text_cluster_key +create temp table clustered_write_read_hot_off as +select count(*) as rows_read, + sum(length(o.payload)) as payload_bytes +from clustered_write_osm_diff_off as o +join clustered_write_settings as s on true +where o.cluster_key in ( + select 'g' || lpad(g::text, 8, '0') + from generate_series(1, s.hot_tile_count) as g +); +\else +create temp table clustered_write_read_hot_off as +select count(*) as rows_read, + sum(length(o.payload)) as payload_bytes +from clustered_write_osm_diff_off as o +join clustered_write_settings as s on true +where o.tile_id between 1 and s.hot_tile_count; +\endif + +update clustered_write_step_timings +set finished_at = clock_timestamp() +where step = 'without_cluster_metadata_read_hot'; + select s.brin_enabled, s.text_cluster_key, s.heap_fillfactor, diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index 13cd0b9c7ae49..aba94f0924bad 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -150,8 +150,10 @@ SQL -v copy_diff_from_file="$copy_diff_from_file" \ '$5 == "clustered_write_insert" || $5 == "clustered_write_update" || + $5 == "clustered_write_read_hot" || $5 == "without_cluster_metadata_insert" || - $5 == "without_cluster_metadata_update" { + $5 == "without_cluster_metadata_update" || + $5 == "without_cluster_metadata_read_hot" { printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, hot_tile_count, order_diff_by_cluster_key, copy_diff_from_file, $5, $6 }' "$raw" >>"$timings_tsv" From ad5842a4558f19c97eaa1a43f8fdf369e2614154 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 04:09:14 +0400 Subject: [PATCH 65/81] perf(clustered-write): measure updated hot reads --- src/tools/clustered_write_bench/README | 18 ++++++ .../clustered_write_bench/osm2pgsql_diff.sql | 56 +++++++++++++++++++ .../run_synthetic_bench.sh | 4 +- 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index ff2228069c210..11b905bc13695 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -213,6 +213,10 @@ integer-key runs filter by `tile_id`, while generated text-key runs filter by the stored `cluster_key`. This keeps read cost visible next to write cost when testing micro-sort or overflow-placement ideas; locality summaries alone cannot show whether a more compact tail is actually worth extra insert work. +The companion `clustered_write_read_updated_hot` and +`without_cluster_metadata_read_updated_hot` steps apply the same hot-range +read shape to rows whose diff update bumped `version`, making moved-update +read cost visible separately from the broader hot-tile read. The wrapper runs the same SQL against `$DBNAME` (`postgres` by default), stores the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, @@ -746,6 +750,20 @@ Do not repeat these paths blindly: `/home/kom/tmp/clustered-write-synthetic/btree-prefix-sort-read-ab-clean-20260501-035714/`, and `/home/kom/tmp/clustered-write-synthetic/multi-hot-128-ordered-copy-20260501-034338/`. +* **Using clustered target selection for moved heap updates:** rejected for + now. The prototype passed the updated tuple from `heap_update()` into + `RelationGetBufferForTuple()` and allowed the existing clustered neighbor + search when `otherBuffer` was set, relying on the existing lower-block-first + buffer lock ordering. A scale `1`, repeat `3`, `hot_tile_fraction=0.9`, + `hot_tile_count=128` run did not improve the meaningful locality shape: + `pct_inside_base_range` stayed at `7.41/7.91%` for unordered/ordered + clustered updates, while update write cost regressed badly. Clustered update + medians rose to `1191.28 ms` unordered and `1072.18 ms` ordered, versus the + in-script control at `784.22 ms` and `889.52 ms`. The new + `read_updated_hot` step also showed no decisive read win (`37.64/39.88 ms` + clustered versus `39.05/35.88 ms` control). The code experiment was + reverted; the update-focused read timing was kept. Raw gzip output is under + `/home/kom/tmp/clustered-write-synthetic/update-placement-proto-20260501-040654/`. * **Relying on ordered diff input as the non-hot cost fix:** rejected as a standalone answer. A scale `1`, repeat `3`, single-key `hot=0` run with `ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true"` kept the same locality shape diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index 507db36b88f20..75b8dfd5f86c0 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -386,6 +386,62 @@ update clustered_write_step_timings set finished_at = clock_timestamp() where step = 'without_cluster_metadata_read_hot'; +insert into clustered_write_step_timings +values ('clustered_write_read_updated_hot', clock_timestamp(), null); + +\if :text_cluster_key +create temp table clustered_write_read_updated_hot_on as +select count(*) as rows_read, + sum(length(o.payload)) as payload_bytes +from clustered_write_osm_diff_on as o +join clustered_write_settings as s on true +where o.version > 1 + and o.cluster_key in ( + select 'g' || lpad(g::text, 8, '0') + from generate_series(1, s.hot_tile_count) as g +); +\else +create temp table clustered_write_read_updated_hot_on as +select count(*) as rows_read, + sum(length(o.payload)) as payload_bytes +from clustered_write_osm_diff_on as o +join clustered_write_settings as s on true +where o.version > 1 + and o.tile_id between 1 and s.hot_tile_count; +\endif + +update clustered_write_step_timings +set finished_at = clock_timestamp() +where step = 'clustered_write_read_updated_hot'; + +insert into clustered_write_step_timings +values ('without_cluster_metadata_read_updated_hot', clock_timestamp(), null); + +\if :text_cluster_key +create temp table clustered_write_read_updated_hot_off as +select count(*) as rows_read, + sum(length(o.payload)) as payload_bytes +from clustered_write_osm_diff_off as o +join clustered_write_settings as s on true +where o.version > 1 + and o.cluster_key in ( + select 'g' || lpad(g::text, 8, '0') + from generate_series(1, s.hot_tile_count) as g +); +\else +create temp table clustered_write_read_updated_hot_off as +select count(*) as rows_read, + sum(length(o.payload)) as payload_bytes +from clustered_write_osm_diff_off as o +join clustered_write_settings as s on true +where o.version > 1 + and o.tile_id between 1 and s.hot_tile_count; +\endif + +update clustered_write_step_timings +set finished_at = clock_timestamp() +where step = 'without_cluster_metadata_read_updated_hot'; + select s.brin_enabled, s.text_cluster_key, s.heap_fillfactor, diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index aba94f0924bad..1e8b73d05099b 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -151,9 +151,11 @@ SQL '$5 == "clustered_write_insert" || $5 == "clustered_write_update" || $5 == "clustered_write_read_hot" || + $5 == "clustered_write_read_updated_hot" || $5 == "without_cluster_metadata_insert" || $5 == "without_cluster_metadata_update" || - $5 == "without_cluster_metadata_read_hot" { + $5 == "without_cluster_metadata_read_hot" || + $5 == "without_cluster_metadata_read_updated_hot" { printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, hot_tile_count, order_diff_by_cluster_key, copy_diff_from_file, $5, $6 }' "$raw" >>"$timings_tsv" From 95a48aaea7e5fe9c3abb4884b9649e410edd90a1 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 04:21:14 +0400 Subject: [PATCH 66/81] perf(clustered-write): benchmark hot update diffs --- src/tools/clustered_write_bench/README | 47 +++++++- .../clustered_write_bench/osm2pgsql_diff.sql | 114 ++++++++++++++++-- .../run_synthetic_bench.sh | 72 ++++++----- 3 files changed, 193 insertions(+), 40 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 11b905bc13695..9e93e5d0836c4 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -51,8 +51,17 @@ spread those forced duplicate-key rows across multiple hot tile keys. This keeps the original single-hot-key cliff visible while also exercising batches where several prefixes are hot at once, including the multi-hot cleanup path that is easy to miss with only tile `1`. -When this value is greater than zero, locality output splits inserts into -`insert_hot` for the forced duplicate-key rows and `insert_rest` for the +Set `HOT_UPDATE_FRACTION_VALUES="0 0.9"` to also bias the enlarged update +phase toward the configured hot tile range. This is separate from +`HOT_TILE_FRACTION_VALUES` because real diffs can have hot inserts, hot +updates, both, or neither. When the value is non-zero, locality output splits +updates into `update_hot` and `update_rest`. +Set `UPDATES_BEFORE_INSERTS_VALUES="false true"` to test whether applying +enlarging updates before new-row inserts preserves more clustered reserve +space for existing rows. This is a workload-order diagnostic, not a PostgreSQL +placement change. +When `hot_tile_fraction` is greater than zero, locality output splits inserts +into `insert_hot` for the forced duplicate-key rows and `insert_rest` for the remaining rows. This keeps a hot-key cliff from being hidden by a small well-placed non-hot tail. The current all-equal batch guard deliberately treats a fully hot simple prefix @@ -218,6 +227,18 @@ The companion `clustered_write_read_updated_hot` and read shape to rows whose diff update bumped `version`, making moved-update read cost visible separately from the broader hot-tile read. +A scale `1`, repeat `3`, single-key COPY run with +`hot_tile_fraction=0.9`, `hot_tile_count=128`, and +`HOT_UPDATE_FRACTION_VALUES="0 0.9"` showed why update-hot needs its own axis. +At fillfactor `50`, ordinary updates stayed mostly local (`95.13%` inside the +base range for clustered-write, `98.20%` for the control), while hot-biased +updates split the result: `update_rest` stayed local (`99.97%`), but +`update_hot` did not (`0%` clustered-write, `15.63%` control). The clustered +insert path still kept the non-hot inserted tail local (`96.90%`) while forced +hot inserts remained a compact overflow/tail tradeoff. Raw gzip output is +under +`/home/kom/tmp/clustered-write-synthetic/hot-update-fillfactor-20260501-041447/`. + The wrapper runs the same SQL against `$DBNAME` (`postgres` by default), stores the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`, `locality.tsv`, `timing_summary.tsv`, and `locality_summary.tsv`. This is the @@ -764,6 +785,19 @@ Do not repeat these paths blindly: clustered versus `39.05/35.88 ms` control). The code experiment was reverted; the update-focused read timing was kept. Raw gzip output is under `/home/kom/tmp/clustered-write-synthetic/update-placement-proto-20260501-040654/`. +* **Applying enlarged updates before new hot inserts:** rejected as a general + workload-order fix. The new `UPDATES_BEFORE_INSERTS_VALUES` axis tested the + tempting osm2pgsql-style idea that existing rows should consume fillfactor + reserve before new duplicate-key rows arrive. In the focused scale `1`, + repeat `3`, fillfactor `50`, `hot_tile_fraction=0.9`, + `hot_update_fraction=0.9`, `hot_tile_count=128`, COPY run, update-first did + improve clustered `update_hot` locality from `0%` to `15.63%`, but it moved + the damage into inserts: clustered `insert_hot` locality fell from `28.31%` + to `1.42%`, outside-base span grew from `304` to `1082` blocks, and + clustered insert median slowed from `204.08 ms` to `345.64 ms`. The code + path is still useful as a diagnostic benchmark axis, but not as a fix by + itself. Raw gzip output is under + `/home/kom/tmp/clustered-write-synthetic/update-before-ab-20260501-041913/`. * **Relying on ordered diff input as the non-hot cost fix:** rejected as a standalone answer. A scale `1`, repeat `3`, single-key `hot=0` run with `ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true"` kept the same locality shape @@ -808,6 +842,10 @@ Important output columns: to stress duplicate-key placement over already-full equal-key pages. * `hot_tile_count`: number of tile keys used for the forced duplicate-key rows when `hot_tile_fraction` is non-zero. +* `hot_update_fraction`: fraction of enlarged update rows biased toward the + configured hot tile range. +* `updates_before_inserts`: whether the update phase ran before the insert + phase in this synthetic workload. * `order_diff_by_cluster_key`: whether the synthetic diff insert was explicitly ordered by `(tile_id, osm_id)` before insertion. * `copy_diff_from_file`: whether the insert diff used the server-side @@ -816,8 +854,9 @@ Important output columns: staging step. * `variant`: either the clustered-write path or the in-script control without remembered clustered-index metadata. -* `diff_kind`: `insert`/`update` normally, or `insert_hot`/`insert_rest` when - `hot_tile_fraction` is nonzero. +* `diff_kind`: `insert`/`update` normally; `insert_hot`/`insert_rest` when + `hot_tile_fraction` is nonzero; and `update_hot`/`update_rest` when + `hot_update_fraction` is nonzero. * `step` and `elapsed_ms`: structured timing rows for the diff insert/update statements, emitted before the locality summary so runs can be compared without scraping `psql` timing chatter. diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index 75b8dfd5f86c0..0c32ecedd80d5 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -30,6 +30,11 @@ \set hot_tile_count 1 \endif +\if :{?hot_update_fraction} +\else +\set hot_update_fraction 0 +\endif + \if :{?heap_fillfactor} \else \set heap_fillfactor 90 @@ -45,6 +50,11 @@ \set copy_diff_from_file false \endif +\if :{?updates_before_inserts} +\else +\set updates_before_inserts false +\endif + \if :{?diff_copy_path} \else \set diff_copy_path '/tmp/clustered_write_diff_inserts.tsv' @@ -66,9 +76,11 @@ select (200000 * :scale)::int as base_rows, (:'text_cluster_key')::boolean as text_cluster_key, (:heap_fillfactor)::int as heap_fillfactor, (:hot_tile_fraction)::numeric as hot_tile_fraction, + (:hot_update_fraction)::numeric as hot_update_fraction, greatest(1, least((4096 * :scale)::int, (:hot_tile_count)::int)) as hot_tile_count, (:'order_diff_by_cluster_key')::boolean as order_diff_by_cluster_key, - (:'copy_diff_from_file')::boolean as copy_diff_from_file; + (:'copy_diff_from_file')::boolean as copy_diff_from_file, + (:'updates_before_inserts')::boolean as updates_before_inserts; create unlogged table clustered_write_osm_diff_on ( @@ -183,9 +195,11 @@ select (200000 * :scale)::int as base_rows, (:'text_cluster_key')::boolean as text_cluster_key, (:heap_fillfactor)::int as heap_fillfactor, (:hot_tile_fraction)::numeric as hot_tile_fraction, + (:hot_update_fraction)::numeric as hot_update_fraction, greatest(1, least((4096 * :scale)::int, (:hot_tile_count)::int)) as hot_tile_count, (:'order_diff_by_cluster_key')::boolean as order_diff_by_cluster_key, - (:'copy_diff_from_file')::boolean as copy_diff_from_file; + (:'copy_diff_from_file')::boolean as copy_diff_from_file, + (:'updates_before_inserts')::boolean as updates_before_inserts; create temp table clustered_write_step_timings ( @@ -221,6 +235,50 @@ select s.base_rows + g as osm_id, from clustered_write_settings as s, generate_series(1, s.insert_rows) as g; +create temp table clustered_write_diff_updates as +with hot_candidates as +( + select g as osm_id, + row_number() over (order by (g::bigint * 2654435761) % s.base_rows) as candidate_number + from clustered_write_settings as s, + generate_series(1, s.base_rows) as g + where ((g - 1) % s.tile_count) + 1 <= s.hot_tile_count +), +hot_updates as +( + select h.osm_id, + true as is_hot_update + from hot_candidates as h + join clustered_write_settings as s on true + where h.candidate_number <= (s.update_rows * s.hot_update_fraction)::int +), +rest_candidates as +( + select g as osm_id, + row_number() over (order by (g::bigint * 2654435761) % s.base_rows) as candidate_number + from clustered_write_settings as s, + generate_series(1, s.base_rows) as g + where s.hot_update_fraction = 0 + or ((g - 1) % s.tile_count) + 1 > s.hot_tile_count +), +rest_updates as +( + select r.osm_id, + false as is_hot_update + from rest_candidates as r + join clustered_write_settings as s on true + where r.candidate_number <= s.update_rows - (select count(*) from hot_updates) +) +select osm_id, + is_hot_update +from hot_updates + +union all + +select osm_id, + is_hot_update +from rest_updates; + \if :copy_diff_from_file \if :order_diff_by_cluster_key copy ( @@ -242,6 +300,34 @@ copy ( \endif \endif +\if :updates_before_inserts +insert into clustered_write_step_timings +values ('clustered_write_update', clock_timestamp(), null); + +update clustered_write_osm_diff_on as o +set version = o.version + 1, + payload = repeat('updated-row', 64) +from clustered_write_diff_updates as u +where o.osm_id = u.osm_id; + +update clustered_write_step_timings +set finished_at = clock_timestamp() +where step = 'clustered_write_update'; + +insert into clustered_write_step_timings +values ('without_cluster_metadata_update', clock_timestamp(), null); + +update clustered_write_osm_diff_off as o +set version = o.version + 1, + payload = repeat('updated-row', 64) +from clustered_write_diff_updates as u +where o.osm_id = u.osm_id; + +update clustered_write_step_timings +set finished_at = clock_timestamp() +where step = 'without_cluster_metadata_update'; +\endif + insert into clustered_write_step_timings values ('clustered_write_insert', clock_timestamp(), null); @@ -300,11 +386,8 @@ update clustered_write_step_timings set finished_at = clock_timestamp() where step = 'without_cluster_metadata_insert'; -create temp table clustered_write_diff_updates as -select distinct (((g::bigint * 2654435761) % s.base_rows) + 1)::bigint as osm_id -from clustered_write_settings as s, - generate_series(1, s.update_rows) as g; - +\if :updates_before_inserts +\else insert into clustered_write_step_timings values ('clustered_write_update', clock_timestamp(), null); @@ -330,6 +413,7 @@ where o.osm_id = u.osm_id; update clustered_write_step_timings set finished_at = clock_timestamp() where step = 'without_cluster_metadata_update'; +\endif analyze clustered_write_osm_diff_on; analyze clustered_write_osm_diff_off; @@ -475,12 +559,18 @@ with measured as union all select 'clustered_write'::text as variant, - 'update'::text as diff_kind, + case + when s.hot_update_fraction > 0 and u.is_hot_update then 'update_hot' + when s.hot_update_fraction > 0 then 'update_rest' + else 'update' + end as diff_kind, pg_temp.tid_block(o.ctid) as heap_block, r.min_block, r.max_block from clustered_write_osm_diff_on as o join clustered_write_settings as s on true + join clustered_write_diff_updates as u + on u.osm_id = o.osm_id join clustered_write_base_ranges as r on r.variant = 'clustered_write' and r.tile_id = o.tile_id @@ -510,12 +600,18 @@ with measured as union all select 'without_cluster_metadata'::text as variant, - 'update'::text as diff_kind, + case + when s.hot_update_fraction > 0 and u.is_hot_update then 'update_hot' + when s.hot_update_fraction > 0 then 'update_rest' + else 'update' + end as diff_kind, pg_temp.tid_block(o.ctid) as heap_block, r.min_block, r.max_block from clustered_write_osm_diff_off as o join clustered_write_settings as s on true + join clustered_write_diff_updates as u + on u.osm_id = o.osm_id join clustered_write_base_ranges as r on r.variant = 'without_cluster_metadata' and r.tile_id = o.tile_id diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index 1e8b73d05099b..9ab7bc7e587b0 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -17,9 +17,11 @@ SINGLE_KEY_VALUES=${SINGLE_KEY_VALUES:-"false"} TEXT_KEY_VALUES=${TEXT_KEY_VALUES:-"false"} HOT_TILE_FRACTION_VALUES=${HOT_TILE_FRACTION_VALUES:-"0"} HOT_TILE_COUNT_VALUES=${HOT_TILE_COUNT_VALUES:-"1"} +HOT_UPDATE_FRACTION_VALUES=${HOT_UPDATE_FRACTION_VALUES:-"0"} HEAP_FILLFACTOR_VALUES=${HEAP_FILLFACTOR_VALUES:-"90"} ORDER_DIFF_BY_CLUSTER_KEY_VALUES=${ORDER_DIFF_BY_CLUSTER_KEY_VALUES:-"false"} COPY_DIFF_FROM_FILE_VALUES=${COPY_DIFF_FROM_FILE_VALUES:-"false"} +UPDATES_BEFORE_INSERTS_VALUES=${UPDATES_BEFORE_INSERTS_VALUES:-"false"} OUTDIR=${OUTDIR:-"$HOME/tmp/clustered-write-synthetic/$(date +%Y%m%d-%H%M%S)"} COMPRESS_RAW=${COMPRESS_RAW:-true} KEEP_TEMP_INSTANCE_DATA=${KEEP_TEMP_INSTANCE_DATA:-false} @@ -47,9 +49,11 @@ mkdir -p "$OUTDIR/raw" printf 'text_key_values: %s\n' "$TEXT_KEY_VALUES" printf 'hot_tile_fraction_values: %s\n' "$HOT_TILE_FRACTION_VALUES" printf 'hot_tile_count_values: %s\n' "$HOT_TILE_COUNT_VALUES" + printf 'hot_update_fraction_values: %s\n' "$HOT_UPDATE_FRACTION_VALUES" printf 'heap_fillfactor_values: %s\n' "$HEAP_FILLFACTOR_VALUES" printf 'order_diff_by_cluster_key_values: %s\n' "$ORDER_DIFF_BY_CLUSTER_KEY_VALUES" printf 'copy_diff_from_file_values: %s\n' "$COPY_DIFF_FROM_FILE_VALUES" + printf 'updates_before_inserts_values: %s\n' "$UPDATES_BEFORE_INSERTS_VALUES" printf 'compress_raw: %s\n' "$COMPRESS_RAW" printf 'keep_temp_instance_data: %s\n' "$KEEP_TEMP_INSTANCE_DATA" printf 'uname: ' @@ -97,8 +101,8 @@ locality_tsv="$OUTDIR/locality.tsv" timing_summary_tsv="$OUTDIR/timing_summary.tsv" locality_summary_tsv="$OUTDIR/locality_summary.tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\thot_tile_count\torder_diff_by_cluster_key\tcopy_diff_from_file\tstep\telapsed_ms\n' >"$timings_tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\thot_tile_count\torder_diff_by_cluster_key\tcopy_diff_from_file\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\theap_block_span\toutside_base_heap_block_span\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\thot_tile_count\thot_update_fraction\tupdates_before_inserts\torder_diff_by_cluster_key\tcopy_diff_from_file\tstep\telapsed_ms\n' >"$timings_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\thot_tile_count\thot_update_fraction\tupdates_before_inserts\torder_diff_by_cluster_key\tcopy_diff_from_file\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\theap_block_span\toutside_base_heap_block_span\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" for scale in $SCALE_VALUES; do for brin in $BRIN_VALUES; do @@ -107,10 +111,12 @@ for scale in $SCALE_VALUES; do for heap_fillfactor in $HEAP_FILLFACTOR_VALUES; do for hot_tile_fraction in $HOT_TILE_FRACTION_VALUES; do for hot_tile_count in $HOT_TILE_COUNT_VALUES; do - for order_diff_by_cluster_key in $ORDER_DIFF_BY_CLUSTER_KEY_VALUES; do - for copy_diff_from_file in $COPY_DIFF_FROM_FILE_VALUES; do - for run in $(seq 1 "$REPEATS"); do - raw="$OUTDIR/raw/scale-${scale}_brin-${brin}_single-key-${single_key}_text-key-${text_cluster_key}_fillfactor-${heap_fillfactor}_hot-tile-${hot_tile_fraction}_hot-tile-count-${hot_tile_count}_order-diff-${order_diff_by_cluster_key}_copy-${copy_diff_from_file}_run-${run}.out" + for hot_update_fraction in $HOT_UPDATE_FRACTION_VALUES; do + for updates_before_inserts in $UPDATES_BEFORE_INSERTS_VALUES; do + for order_diff_by_cluster_key in $ORDER_DIFF_BY_CLUSTER_KEY_VALUES; do + for copy_diff_from_file in $COPY_DIFF_FROM_FILE_VALUES; do + for run in $(seq 1 "$REPEATS"); do + raw="$OUTDIR/raw/scale-${scale}_brin-${brin}_single-key-${single_key}_text-key-${text_cluster_key}_fillfactor-${heap_fillfactor}_hot-tile-${hot_tile_fraction}_hot-tile-count-${hot_tile_count}_hot-update-${hot_update_fraction}_updates-before-${updates_before_inserts}_order-diff-${order_diff_by_cluster_key}_copy-${copy_diff_from_file}_run-${run}.out" diff_copy_path="${raw%.out}.copy.tsv" if ! "$PSQL" -X -v ON_ERROR_STOP=1 \ @@ -121,6 +127,8 @@ for scale in $SCALE_VALUES; do -v heap_fillfactor="$heap_fillfactor" \ -v hot_tile_fraction="$hot_tile_fraction" \ -v hot_tile_count="$hot_tile_count" \ + -v hot_update_fraction="$hot_update_fraction" \ + -v updates_before_inserts="$updates_before_inserts" \ -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \ -v copy_diff_from_file="$copy_diff_from_file" \ -v diff_copy_path="$diff_copy_path" \ @@ -146,6 +154,8 @@ SQL -v heap_fillfactor="$heap_fillfactor" \ -v hot_tile_fraction="$hot_tile_fraction" \ -v hot_tile_count="$hot_tile_count" \ + -v hot_update_fraction="$hot_update_fraction" \ + -v updates_before_inserts="$updates_before_inserts" \ -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \ -v copy_diff_from_file="$copy_diff_from_file" \ '$5 == "clustered_write_insert" || @@ -156,8 +166,8 @@ SQL $5 == "without_cluster_metadata_update" || $5 == "without_cluster_metadata_read_hot" || $5 == "without_cluster_metadata_read_updated_hot" { - printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, hot_tile_count, order_diff_by_cluster_key, copy_diff_from_file, $5, $6 + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, hot_tile_count, hot_update_fraction, updates_before_inserts, order_diff_by_cluster_key, copy_diff_from_file, $5, $6 }' "$raw" >>"$timings_tsv" awk -F'|' \ @@ -169,12 +179,14 @@ SQL -v heap_fillfactor="$heap_fillfactor" \ -v hot_tile_fraction="$hot_tile_fraction" \ -v hot_tile_count="$hot_tile_count" \ + -v hot_update_fraction="$hot_update_fraction" \ + -v updates_before_inserts="$updates_before_inserts" \ -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \ -v copy_diff_from_file="$copy_diff_from_file" \ '$5 == "clustered_write" || $5 == "without_cluster_metadata" { - printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, hot_tile_count, order_diff_by_cluster_key, copy_diff_from_file, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, hot_tile_count, hot_update_fraction, updates_before_inserts, order_diff_by_cluster_key, copy_diff_from_file, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 }' "$raw" >>"$locality_tsv" if [[ "$COMPRESS_RAW" == "true" ]]; then @@ -183,6 +195,8 @@ SQL done done done + done + done done done done @@ -196,21 +210,23 @@ awk -F'\t' ' OFS = "\t" print "scale", "brin_enabled", "single_key_cluster", "text_cluster_key", "heap_fillfactor", "hot_tile_fraction", - "hot_tile_count", "order_diff_by_cluster_key", + "hot_tile_count", "hot_update_fraction", + "updates_before_inserts", + "order_diff_by_cluster_key", "copy_diff_from_file", "step", "runs", "avg_elapsed_ms", "median_elapsed_ms", "min_elapsed_ms", "max_elapsed_ms" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 OFS $11 - sum[key] += $12 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 OFS $11 OFS $12 OFS $13 + sum[key] += $14 count[key]++ - sample[key, count[key]] = $12 - if (!(key in min) || $12 < min[key]) - min[key] = $12 - if (!(key in max) || $12 > max[key]) - max[key] = $12 + sample[key, count[key]] = $14 + if (!(key in min) || $14 < min[key]) + min[key] = $14 + if (!(key in max) || $14 > max[key]) + max[key] = $14 } END { for (key in count) { @@ -239,7 +255,7 @@ awk -F'\t' ' ' "$timings_tsv" >"$timing_summary_tsv" { head -n 1 "$timing_summary_tsv" - tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7n -k8,8 -k9,9 -k10,10 + tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7n -k8,8 -k9,9 -k10,10 -k11,11 -k12,12 } >"$timing_summary_tsv.tmp" mv "$timing_summary_tsv.tmp" "$timing_summary_tsv" @@ -248,7 +264,9 @@ awk -F'\t' ' OFS = "\t" print "scale", "brin_enabled", "single_key_cluster", "text_cluster_key", "heap_fillfactor", "hot_tile_fraction", - "hot_tile_count", "order_diff_by_cluster_key", + "hot_tile_count", "hot_update_fraction", + "updates_before_inserts", + "order_diff_by_cluster_key", "copy_diff_from_file", "variant", "diff_kind", "runs", "avg_heap_block_span", "avg_outside_base_heap_block_span", @@ -256,12 +274,12 @@ awk -F'\t' ' "avg_block_drift", "avg_p95_block_drift" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 OFS $11 OFS $12 - span[key] += $15 - outside_span[key] += $16 - pct[key] += $17 - avg[key] += $18 - p95[key] += $19 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 OFS $11 OFS $12 OFS $13 OFS $14 + span[key] += $17 + outside_span[key] += $18 + pct[key] += $19 + avg[key] += $20 + p95[key] += $21 count[key]++ } END { @@ -274,7 +292,7 @@ awk -F'\t' ' ' "$locality_tsv" >"$locality_summary_tsv" { head -n 1 "$locality_summary_tsv" - tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7n -k8,8 -k9,9 -k10,10 -k11,11 + tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7n -k8,8 -k9,9 -k10,10 -k11,11 -k12,12 -k13,13 } >"$locality_summary_tsv.tmp" mv "$locality_summary_tsv.tmp" "$locality_summary_tsv" From 3d7bf1ccdb8839c2bc60821a187821747ad7d0d2 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 13:14:30 +0400 Subject: [PATCH 67/81] perf(heap): skip fsm for too-hot clustered copy --- src/backend/access/heap/heapam.c | 14 +++++++++++++- src/tools/clustered_write_bench/README | 11 +++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 6575903df3fce..856a66ebad288 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -3194,6 +3194,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, Buffer buffer; HeapTuple clustered_target_tuple = heaptuples[ndone]; BlockNumber clustered_target_block = InvalidBlockNumber; + uint32 buffer_options = options; bool all_visible_cleared = false; bool all_frozen_set = false; int nthispage; @@ -3227,7 +3228,18 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, * empty page. See all_frozen_set below. */ if (heaptuple_skip_clustered_target_lookup) + { clustered_target_tuple = NULL; + + /* + * This COPY batch was already classified as too dense for + * clustered target probing. Do not ask the FSM for old pages + * either: those pages are likely fillfactor reserve that should + * stay available for future updates, and scanning them just adds + * work before the dense run appends anyway. + */ + buffer_options |= HEAP_INSERT_SKIP_FSM; + } else if (heaptuple_clustered_target_blocks != NULL) { clustered_target_block = @@ -3239,7 +3251,7 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, buffer = RelationGetBufferForTuple(relation, heaptuples[ndone]->t_len, clustered_target_tuple, clustered_target_block, - InvalidBuffer, options, bistate, + InvalidBuffer, buffer_options, bistate, &vmbuffer, NULL, npages - npages_used); page = BufferGetPage(buffer); diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 9e93e5d0836c4..acbbe1ac56112 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -90,6 +90,17 @@ matching the control tail span. At fillfactor `50`, the clustered outside-base span was `692` versus control `693`. The overflow run is therefore compact after it leaves the old range; the large full span means "reserve rows plus compact tail", not scattered tail placement. +For COPY batches that are already classified as too-hot and skip clustered +target probing, the insert path now also skips the FSM page search. This keeps +those dense runs from spending time on old fillfactor-reserve pages that are +more useful for future updates. In a scale `1`, repeat `3`, fillfactor `50`, +`hot_tile_count=128`, `hot_update_fraction=0.9` run, clustered insert median +fell from `204.08 ms` to `163.53 ms` with unchanged locality shape; in the +single-hot-key fillfactor `90` check, clustered insert median was `49.96 ms` +versus `38.81 ms` for the control. Raw gzip output is 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/`. The multi-hot axis caught a COPY crash in the hot-prefix skip cleanup. When a batch contained several prefixes whose per-prefix counts all exceeded From 3a09c08483723039ec1f4c03c310d39823244292 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 13:31:29 +0400 Subject: [PATCH 68/81] perf(clustered-write): benchmark update payload size --- src/tools/clustered_write_bench/README | 31 +++ .../clustered_write_bench/osm2pgsql_diff.sql | 19 +- .../run_synthetic_bench.sh | 198 +++++++++--------- 3 files changed, 150 insertions(+), 98 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index acbbe1ac56112..208a0cea55aef 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -56,6 +56,11 @@ phase toward the configured hot tile range. This is separate from `HOT_TILE_FRACTION_VALUES` because real diffs can have hot inserts, hot updates, both, or neither. When the value is non-zero, locality output splits updates into `update_hot` and `update_rest`. +Set `UPDATE_PAYLOAD_REPEAT_VALUES="8 16 32 64"` to change the size of the +enlarged update payload. This is a reserve-capacity diagnostic: if smaller +updates stay local but larger updates do not, the issue is page free space; if +both fail after hot inserts, the insert/update phase interaction is consuming +or bypassing the useful reserve. Set `UPDATES_BEFORE_INSERTS_VALUES="false true"` to test whether applying enlarging updates before new-row inserts preserves more clustered reserve space for existing rows. This is a workload-order diagnostic, not a PostgreSQL @@ -809,6 +814,25 @@ Do not repeat these paths blindly: path is still useful as a diagnostic benchmark axis, but not as a fix by itself. Raw gzip output is under `/home/kom/tmp/clustered-write-synthetic/update-before-ab-20260501-041913/`. +* **Treating hot-update failure as only a payload-size problem:** rejected. + The `UPDATE_PAYLOAD_REPEAT_VALUES` axis separates moved-update reserve + capacity from the insert/update interaction. In the scale `1`, repeat `2`, + fillfactor `50`, `hot_tile_fraction=0.9`, `hot_update_fraction=0.9`, + `hot_tile_count=128`, COPY run, the clustered table still kept `update_hot` + at `0%` inside the original base range for payload repeats `8`, `16`, `32`, + and `64` when hot inserts ran first. The control showed that payload size + matters for ordinary heap behaviour (`87.50%`, `53.13%`, `31.25%`, + `15.63%` inside base as payload grew), but clustered hot inserts had already + made the useful reserve unavailable to later updates. Reversing the phase + order with payload `8` restored clustered `update_hot` to `87.50%`, while + clustered `insert_hot` fell from `28.31%` to `0%`; payload `64` repeated the + earlier partial update win (`15.63%`) and insert regression (`1.42%`). So + the next fix should not merely shrink payloads or reorder phases; it needs a + policy that preserves update reserve while keeping duplicate-key insert + overflow compact. Raw gzip outputs are under + `/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/`. * **Relying on ordered diff input as the non-hot cost fix:** rejected as a standalone answer. A scale `1`, repeat `3`, single-key `hot=0` run with `ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true"` kept the same locality shape @@ -855,6 +879,9 @@ Important output columns: when `hot_tile_fraction` is non-zero. * `hot_update_fraction`: fraction of enlarged update rows biased toward the configured hot tile range. +* `update_payload_repeat`: multiplier for the `updated-row` payload used by + enlarged updates. Lower values test whether fillfactor reserve is sufficient + when moved rows are small; higher values stress reserve exhaustion. * `updates_before_inserts`: whether the update phase ran before the insert phase in this synthetic workload. * `order_diff_by_cluster_key`: whether the synthetic diff insert was explicitly @@ -871,6 +898,10 @@ Important output columns: * `step` and `elapsed_ms`: structured timing rows for the diff insert/update statements, emitted before the locality summary so runs can be compared without scraping `psql` timing chatter. +* `avg_rows_measured`: average number of rows behind the locality row in + `locality_summary.tsv`. This is important for hot-update runs because the + configured fraction can be capped by the number of base rows that actually + belong to the hot tile range. * `avg_elapsed_ms`, `median_elapsed_ms`, `min_elapsed_ms`, `max_elapsed_ms`: aggregate timing columns in `timing_summary.tsv`; prefer the median when the run environment shows high load or a small number of outliers. diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql index 0c32ecedd80d5..f28a7ac17ba25 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_diff.sql +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -35,6 +35,11 @@ \set hot_update_fraction 0 \endif +\if :{?update_payload_repeat} +\else +\set update_payload_repeat 64 +\endif + \if :{?heap_fillfactor} \else \set heap_fillfactor 90 @@ -77,6 +82,7 @@ select (200000 * :scale)::int as base_rows, (:heap_fillfactor)::int as heap_fillfactor, (:hot_tile_fraction)::numeric as hot_tile_fraction, (:hot_update_fraction)::numeric as hot_update_fraction, + (:update_payload_repeat)::int as update_payload_repeat, greatest(1, least((4096 * :scale)::int, (:hot_tile_count)::int)) as hot_tile_count, (:'order_diff_by_cluster_key')::boolean as order_diff_by_cluster_key, (:'copy_diff_from_file')::boolean as copy_diff_from_file, @@ -196,6 +202,7 @@ select (200000 * :scale)::int as base_rows, (:heap_fillfactor)::int as heap_fillfactor, (:hot_tile_fraction)::numeric as hot_tile_fraction, (:hot_update_fraction)::numeric as hot_update_fraction, + (:update_payload_repeat)::int as update_payload_repeat, greatest(1, least((4096 * :scale)::int, (:hot_tile_count)::int)) as hot_tile_count, (:'order_diff_by_cluster_key')::boolean as order_diff_by_cluster_key, (:'copy_diff_from_file')::boolean as copy_diff_from_file, @@ -306,8 +313,9 @@ values ('clustered_write_update', clock_timestamp(), null); update clustered_write_osm_diff_on as o set version = o.version + 1, - payload = repeat('updated-row', 64) + payload = repeat('updated-row', s.update_payload_repeat) from clustered_write_diff_updates as u +join clustered_write_settings as s on true where o.osm_id = u.osm_id; update clustered_write_step_timings @@ -319,8 +327,9 @@ values ('without_cluster_metadata_update', clock_timestamp(), null); update clustered_write_osm_diff_off as o set version = o.version + 1, - payload = repeat('updated-row', 64) + payload = repeat('updated-row', s.update_payload_repeat) from clustered_write_diff_updates as u +join clustered_write_settings as s on true where o.osm_id = u.osm_id; update clustered_write_step_timings @@ -393,8 +402,9 @@ values ('clustered_write_update', clock_timestamp(), null); update clustered_write_osm_diff_on as o set version = o.version + 1, - payload = repeat('updated-row', 64) + payload = repeat('updated-row', s.update_payload_repeat) from clustered_write_diff_updates as u +join clustered_write_settings as s on true where o.osm_id = u.osm_id; update clustered_write_step_timings @@ -406,8 +416,9 @@ values ('without_cluster_metadata_update', clock_timestamp(), null); update clustered_write_osm_diff_off as o set version = o.version + 1, - payload = repeat('updated-row', 64) + payload = repeat('updated-row', s.update_payload_repeat) from clustered_write_diff_updates as u +join clustered_write_settings as s on true where o.osm_id = u.osm_id; update clustered_write_step_timings diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh index 9ab7bc7e587b0..1210d13bd4871 100755 --- a/src/tools/clustered_write_bench/run_synthetic_bench.sh +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -18,6 +18,7 @@ TEXT_KEY_VALUES=${TEXT_KEY_VALUES:-"false"} HOT_TILE_FRACTION_VALUES=${HOT_TILE_FRACTION_VALUES:-"0"} HOT_TILE_COUNT_VALUES=${HOT_TILE_COUNT_VALUES:-"1"} HOT_UPDATE_FRACTION_VALUES=${HOT_UPDATE_FRACTION_VALUES:-"0"} +UPDATE_PAYLOAD_REPEAT_VALUES=${UPDATE_PAYLOAD_REPEAT_VALUES:-"64"} HEAP_FILLFACTOR_VALUES=${HEAP_FILLFACTOR_VALUES:-"90"} ORDER_DIFF_BY_CLUSTER_KEY_VALUES=${ORDER_DIFF_BY_CLUSTER_KEY_VALUES:-"false"} COPY_DIFF_FROM_FILE_VALUES=${COPY_DIFF_FROM_FILE_VALUES:-"false"} @@ -50,6 +51,7 @@ mkdir -p "$OUTDIR/raw" printf 'hot_tile_fraction_values: %s\n' "$HOT_TILE_FRACTION_VALUES" printf 'hot_tile_count_values: %s\n' "$HOT_TILE_COUNT_VALUES" printf 'hot_update_fraction_values: %s\n' "$HOT_UPDATE_FRACTION_VALUES" + printf 'update_payload_repeat_values: %s\n' "$UPDATE_PAYLOAD_REPEAT_VALUES" printf 'heap_fillfactor_values: %s\n' "$HEAP_FILLFACTOR_VALUES" printf 'order_diff_by_cluster_key_values: %s\n' "$ORDER_DIFF_BY_CLUSTER_KEY_VALUES" printf 'copy_diff_from_file_values: %s\n' "$COPY_DIFF_FROM_FILE_VALUES" @@ -101,8 +103,8 @@ locality_tsv="$OUTDIR/locality.tsv" timing_summary_tsv="$OUTDIR/timing_summary.tsv" locality_summary_tsv="$OUTDIR/locality_summary.tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\thot_tile_count\thot_update_fraction\tupdates_before_inserts\torder_diff_by_cluster_key\tcopy_diff_from_file\tstep\telapsed_ms\n' >"$timings_tsv" -printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\thot_tile_count\thot_update_fraction\tupdates_before_inserts\torder_diff_by_cluster_key\tcopy_diff_from_file\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\theap_block_span\toutside_base_heap_block_span\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\thot_tile_count\thot_update_fraction\tupdate_payload_repeat\tupdates_before_inserts\torder_diff_by_cluster_key\tcopy_diff_from_file\tstep\telapsed_ms\n' >"$timings_tsv" +printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\thot_tile_count\thot_update_fraction\tupdate_payload_repeat\tupdates_before_inserts\torder_diff_by_cluster_key\tcopy_diff_from_file\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\theap_block_span\toutside_base_heap_block_span\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv" for scale in $SCALE_VALUES; do for brin in $BRIN_VALUES; do @@ -112,96 +114,101 @@ for scale in $SCALE_VALUES; do for hot_tile_fraction in $HOT_TILE_FRACTION_VALUES; do for hot_tile_count in $HOT_TILE_COUNT_VALUES; do for hot_update_fraction in $HOT_UPDATE_FRACTION_VALUES; do - for updates_before_inserts in $UPDATES_BEFORE_INSERTS_VALUES; do - for order_diff_by_cluster_key in $ORDER_DIFF_BY_CLUSTER_KEY_VALUES; do - for copy_diff_from_file in $COPY_DIFF_FROM_FILE_VALUES; do - for run in $(seq 1 "$REPEATS"); do - raw="$OUTDIR/raw/scale-${scale}_brin-${brin}_single-key-${single_key}_text-key-${text_cluster_key}_fillfactor-${heap_fillfactor}_hot-tile-${hot_tile_fraction}_hot-tile-count-${hot_tile_count}_hot-update-${hot_update_fraction}_updates-before-${updates_before_inserts}_order-diff-${order_diff_by_cluster_key}_copy-${copy_diff_from_file}_run-${run}.out" - diff_copy_path="${raw%.out}.copy.tsv" + for update_payload_repeat in $UPDATE_PAYLOAD_REPEAT_VALUES; do + for updates_before_inserts in $UPDATES_BEFORE_INSERTS_VALUES; do + for order_diff_by_cluster_key in $ORDER_DIFF_BY_CLUSTER_KEY_VALUES; do + for copy_diff_from_file in $COPY_DIFF_FROM_FILE_VALUES; do + for run in $(seq 1 "$REPEATS"); do + raw="$OUTDIR/raw/scale-${scale}_brin-${brin}_single-key-${single_key}_text-key-${text_cluster_key}_fillfactor-${heap_fillfactor}_hot-tile-${hot_tile_fraction}_hot-tile-count-${hot_tile_count}_hot-update-${hot_update_fraction}_update-payload-${update_payload_repeat}_updates-before-${updates_before_inserts}_order-diff-${order_diff_by_cluster_key}_copy-${copy_diff_from_file}_run-${run}.out" + diff_copy_path="${raw%.out}.copy.tsv" - if ! "$PSQL" -X -v ON_ERROR_STOP=1 \ - -v scale="$scale" \ - -v use_brin="$brin" \ - -v single_key_cluster="$single_key" \ - -v text_cluster_key="$text_cluster_key" \ - -v heap_fillfactor="$heap_fillfactor" \ - -v hot_tile_fraction="$hot_tile_fraction" \ - -v hot_tile_count="$hot_tile_count" \ - -v hot_update_fraction="$hot_update_fraction" \ - -v updates_before_inserts="$updates_before_inserts" \ - -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \ - -v copy_diff_from_file="$copy_diff_from_file" \ - -v diff_copy_path="$diff_copy_path" \ - -d "$DBNAME" >"$raw" <"$raw" <>"$timings_tsv" + awk -F'|' \ + -v run="$run" \ + -v scale="$scale" \ + -v brin="$brin" \ + -v single_key="$single_key" \ + -v text_cluster_key="$text_cluster_key" \ + -v heap_fillfactor="$heap_fillfactor" \ + -v hot_tile_fraction="$hot_tile_fraction" \ + -v hot_tile_count="$hot_tile_count" \ + -v hot_update_fraction="$hot_update_fraction" \ + -v update_payload_repeat="$update_payload_repeat" \ + -v updates_before_inserts="$updates_before_inserts" \ + -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \ + -v copy_diff_from_file="$copy_diff_from_file" \ + '$5 == "clustered_write_insert" || + $5 == "clustered_write_update" || + $5 == "clustered_write_read_hot" || + $5 == "clustered_write_read_updated_hot" || + $5 == "without_cluster_metadata_insert" || + $5 == "without_cluster_metadata_update" || + $5 == "without_cluster_metadata_read_hot" || + $5 == "without_cluster_metadata_read_updated_hot" { + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, hot_tile_count, hot_update_fraction, update_payload_repeat, updates_before_inserts, order_diff_by_cluster_key, copy_diff_from_file, $5, $6 + }' "$raw" >>"$timings_tsv" - awk -F'|' \ - -v run="$run" \ - -v scale="$scale" \ - -v brin="$brin" \ - -v single_key="$single_key" \ - -v text_cluster_key="$text_cluster_key" \ - -v heap_fillfactor="$heap_fillfactor" \ - -v hot_tile_fraction="$hot_tile_fraction" \ - -v hot_tile_count="$hot_tile_count" \ - -v hot_update_fraction="$hot_update_fraction" \ - -v updates_before_inserts="$updates_before_inserts" \ - -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \ - -v copy_diff_from_file="$copy_diff_from_file" \ - '$5 == "clustered_write" || - $5 == "without_cluster_metadata" { - printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, hot_tile_count, hot_update_fraction, updates_before_inserts, order_diff_by_cluster_key, copy_diff_from_file, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 - }' "$raw" >>"$locality_tsv" + awk -F'|' \ + -v run="$run" \ + -v scale="$scale" \ + -v brin="$brin" \ + -v single_key="$single_key" \ + -v text_cluster_key="$text_cluster_key" \ + -v heap_fillfactor="$heap_fillfactor" \ + -v hot_tile_fraction="$hot_tile_fraction" \ + -v hot_tile_count="$hot_tile_count" \ + -v hot_update_fraction="$hot_update_fraction" \ + -v update_payload_repeat="$update_payload_repeat" \ + -v updates_before_inserts="$updates_before_inserts" \ + -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \ + -v copy_diff_from_file="$copy_diff_from_file" \ + '$5 == "clustered_write" || + $5 == "without_cluster_metadata" { + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, hot_tile_count, hot_update_fraction, update_payload_repeat, updates_before_inserts, order_diff_by_cluster_key, copy_diff_from_file, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 + }' "$raw" >>"$locality_tsv" - if [[ "$COMPRESS_RAW" == "true" ]]; then - gzip -f "$raw" - fi - done - done - done - done + if [[ "$COMPRESS_RAW" == "true" ]]; then + gzip -f "$raw" + fi done done done done done done + done + done + done + done + done done done @@ -211,6 +218,7 @@ awk -F'\t' ' print "scale", "brin_enabled", "single_key_cluster", "text_cluster_key", "heap_fillfactor", "hot_tile_fraction", "hot_tile_count", "hot_update_fraction", + "update_payload_repeat", "updates_before_inserts", "order_diff_by_cluster_key", "copy_diff_from_file", @@ -219,14 +227,14 @@ awk -F'\t' ' "max_elapsed_ms" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 OFS $11 OFS $12 OFS $13 - sum[key] += $14 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 OFS $11 OFS $12 OFS $13 OFS $14 + sum[key] += $15 count[key]++ - sample[key, count[key]] = $14 - if (!(key in min) || $14 < min[key]) - min[key] = $14 - if (!(key in max) || $14 > max[key]) - max[key] = $14 + sample[key, count[key]] = $15 + if (!(key in min) || $15 < min[key]) + min[key] = $15 + if (!(key in max) || $15 > max[key]) + max[key] = $15 } END { for (key in count) { @@ -255,7 +263,7 @@ awk -F'\t' ' ' "$timings_tsv" >"$timing_summary_tsv" { head -n 1 "$timing_summary_tsv" - tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7n -k8,8 -k9,9 -k10,10 -k11,11 -k12,12 + tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7n -k8,8 -k9,9 -k10,10n -k11,11 -k12,12 -k13,13 } >"$timing_summary_tsv.tmp" mv "$timing_summary_tsv.tmp" "$timing_summary_tsv" @@ -265,34 +273,36 @@ awk -F'\t' ' print "scale", "brin_enabled", "single_key_cluster", "text_cluster_key", "heap_fillfactor", "hot_tile_fraction", "hot_tile_count", "hot_update_fraction", + "update_payload_repeat", "updates_before_inserts", "order_diff_by_cluster_key", "copy_diff_from_file", - "variant", "diff_kind", "runs", + "variant", "diff_kind", "runs", "avg_rows_measured", "avg_heap_block_span", "avg_outside_base_heap_block_span", "avg_pct_inside_base_range", "avg_block_drift", "avg_p95_block_drift" } NR > 1 { - key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 OFS $11 OFS $12 OFS $13 OFS $14 - span[key] += $17 - outside_span[key] += $18 - pct[key] += $19 - avg[key] += $20 - p95[key] += $21 + key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 OFS $11 OFS $12 OFS $13 OFS $14 OFS $15 + rows[key] += $16 + span[key] += $18 + outside_span[key] += $19 + pct[key] += $20 + avg[key] += $21 + p95[key] += $22 count[key]++ } END { for (key in count) - printf "%s\t%d\t%.2f\t%.2f\t%.2f\t%.2f\t%.2f\n", - key, count[key], span[key] / count[key], + printf "%s\t%d\t%.2f\t%.2f\t%.2f\t%.2f\t%.2f\t%.2f\n", + key, count[key], rows[key] / count[key], span[key] / count[key], outside_span[key] / count[key], pct[key] / count[key], avg[key] / count[key], p95[key] / count[key] } ' "$locality_tsv" >"$locality_summary_tsv" { head -n 1 "$locality_summary_tsv" - tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7n -k8,8 -k9,9 -k10,10 -k11,11 -k12,12 -k13,13 + tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7n -k8,8 -k9,9 -k10,10n -k11,11 -k12,12 -k13,13 -k14,14 } >"$locality_summary_tsv.tmp" mv "$locality_summary_tsv.tmp" "$locality_summary_tsv" From 1693243e3cd8b237d6c461a4c1f6bf0e0cf854d1 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 13:38:27 +0400 Subject: [PATCH 69/81] docs(clustered-write): record bulk reserve experiment --- src/tools/clustered_write_bench/README | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 208a0cea55aef..cb197e4baa81f 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -833,6 +833,25 @@ Do not repeat these paths blindly: `/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/`. +* **Disabling below-fillfactor clustered reserve for all bulk/COPY clustered + targets:** rejected. The prototype changed `RelationGetBufferForTuple()` so + bulk insert callers could only use clustered neighbour pages that satisfied + the normal fillfactor-aware target free-space threshold, leaving reserve + space for future moved updates. It proved the reserve-conflict diagnosis: + in a scale `1`, repeat `2`, fillfactor `50`, `hot_tile_count=128`, + `hot_update_fraction=0.9`, COPY run, payload `8` recovered clustered + `update_hot` from `0%` to `87.50%`, and payload `64` recovered it to the + control-like `15.63%`. But the price was too broad: clustered `insert_hot` + fell to `0%` inside base, and a non-hot/hot COPY insert sweep also drove + ordinary clustered insert locality to `0%` for fillfactor `90` and `50`, + with worse spans than the control in several cases. The code was reverted; + the useful lesson is narrower than "bulk inserts must never consume reserve". + A viable fix needs to identify update-pressure or massive duplicate-key + insert phases more specifically, not remove clustered reserve use from every + COPY path. Raw gzip outputs are under + `/home/kom/tmp/clustered-write-synthetic/no-bulk-reserve-proto-20260501-133332/` + and + `/home/kom/tmp/clustered-write-synthetic/no-bulk-reserve-nonhot-proto-20260501-133415/`. * **Relying on ordered diff input as the non-hot cost fix:** rejected as a standalone answer. A scale `1`, repeat `3`, single-key `hot=0` run with `ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true"` kept the same locality shape From 181ee004d03490c09e9779d772f0b498e23bcf03 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 13:42:45 +0400 Subject: [PATCH 70/81] docs(clustered-write): record ordered hot update copy --- src/tools/clustered_write_bench/README | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index cb197e4baa81f..1338ced50f252 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -161,6 +161,21 @@ regressions (`850.61 ms` to `917.17 ms` for integer fillfactor `50`, `738.09 ms` to `812.56 ms` for text fillfactor `50`). Locality stayed in the same shape, so ordering the input by itself is not a reliable substitute for reducing target-probe cost. +For hot insert+update COPY pressure, though, ordered input is now a promising +profile rather than a dead end. In a scale `1`, repeat `3`, fillfactor `50`, +`hot_tile_count=128`, `hot_update_fraction=0.9` run, ordering by clustered key +changed the workload shape from "reserve consumed in many small per-batch +prefixes" to "dense prefixes overflow compactly". Payload `8` recovered +clustered `update_hot` from `0%` to `86.88%` inside base, cut clustered insert +median from `350.89 ms` to `107.58 ms`, and improved broad hot reads +(`read_hot` median `22.02 ms` to `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 tradeoff is explicit: `insert_hot` mostly +moves to a compact tail (`0.21%` inside base), so this is an osm2pgsql diff +ordering strategy to benchmark against real append/read workloads, not a claim +that new duplicate-key rows stay in the original clustered range. Raw gzip +output is under +`/home/kom/tmp/clustered-write-synthetic/hot-update-order-copy-ab-20260501-133942/`. Set `COPY_DIFF_FROM_FILE_VALUES="false true"` to compare the default `INSERT ... SELECT` diff path with a server-side `COPY ... FROM` path. The From fcf3b21fbbb5d7217e108a8b2235f5c138d9ddbf Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 13:47:12 +0400 Subject: [PATCH 71/81] docs(clustered-write): record ordered text hot update copy --- src/tools/clustered_write_bench/README | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 1338ced50f252..e76878b9b1e05 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -176,6 +176,18 @@ ordering strategy to benchmark against real append/read workloads, not a claim that new duplicate-key rows stay in the original clustered range. Raw gzip output is under `/home/kom/tmp/clustered-write-synthetic/hot-update-order-copy-ab-20260501-133942/`. +A follow-up generated text-key run, matching the osm2pgsql geohash-key shape +more closely, kept the same locality result. At scale `1`, repeat `2`, +fillfactor `50`, `text_cluster_key=true`, payload `8`, ordered COPY 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` median 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 run +should measure both append write time and read slices, but the update-locality +and write-cost direction transfers from integer keys to generated text keys. +Raw gzip output is under +`/home/kom/tmp/clustered-write-synthetic/hot-update-order-text-copy-ab-20260501-134352/`. Set `COPY_DIFF_FROM_FILE_VALUES="false true"` to compare the default `INSERT ... SELECT` diff path with a server-side `COPY ... FROM` path. The From 093a028eeb8173ee5c36c93453b7cf4e10cb5891 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 14:35:34 +0400 Subject: [PATCH 72/81] perf(clustered-write): stage ordered osm2pgsql copy --- src/tools/clustered_write_bench/README | 59 +++-- .../osm2pgsql_cluster_during_import.patch | 242 +++++++++++++++--- .../run_osm2pgsql_georgia_bench.sh | 26 +- 3 files changed, 266 insertions(+), 61 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index e76878b9b1e05..6bf9fefd1e7d2 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -446,28 +446,44 @@ the long p95 heap span while the initial COPY stream stayed unordered, and Rewriting the final heap by `osm2pgsql_cluster_key, osm_id` fixed the heap-span diagnosis, but the first `CREATE TABLE AS SELECT *` prototype silently turned -the generated key into a plain text column. The checked-in experiment now -rebuilds the final heap with `CREATE TABLE ... LIKE ... INCLUDING GENERATED` -and ordered `INSERT`, so later diff rows keep computing the key. A catalog -check after append confirmed all four OSM tables still have stored generated -`osm2pgsql_cluster_key` columns and zero NULL keys. This is a correctness win -with a mixed timing profile: initial import is faster than the CTAS prototype -and append remains faster than the stock baseline, but exact polygon reads were -noisier/slower in this single run. The cost is still a real table rewrite, so -this is best read as the correctness/performance target for a future global or -external sort before COPY rather than as proof that the pre-COPY btree alone is -enough. Small bounded in-memory COPY sort windows were tested after this point -and rejected: they lowered or raised initial import time depending on the -window, but left `osm2pgsql_cluster_key` correlation poor and made diff append -much slower. +the generated key into a plain text column. Rebuilding the final heap with +`CREATE TABLE ... LIKE ... INCLUDING GENERATED` and ordered `INSERT` preserved +the generated key after append, but it was still a full post-import rewrite. + +The current osm2pgsql experiment moves that ordered step into the COPY path. +Rows are copied into per-target temporary tables created `LIKE` the real target +`INCLUDING GENERATED`; on sync, each target drains into the real heap with +`ORDER BY osm2pgsql_cluster_key, osm_id`. A first attempt that used a single +temporary staging table was rejected because target-table switches drained +many small ordered fragments, leaving poor key correlation and long spans +(`6:06` create, `6:08` append, p95 spans `15301/1776/20334.5` for +line/point/polygon). + +The per-target staging run on Georgia + planet daily diff `978` is more +useful, but still not a win over the previous rewrite target. With patched +PostgreSQL `19devel`, PostGIS `3.7`, osm2pgsql `2.2.0`, +`OSM2PGSQL_CACHE_MB=1024`, and `OSM2PGSQL_PROCS=2`, it measured `3:15.36` +create, `3:10.19` append, and `2793 MB` database size. Read timings were +`329.192 ms` point bbox, `1140.398 ms` line bbox, `1849.375 ms` polygon bbox, +`182.531 ms` roads exact, and `3151.490 ms` polygon exact. Locality improved +over fragment staging to p95 block/span `3/2647.75` line, `3/1069` point, and +`3/1657` polygon, but generated-key correlations remained mixed +(`line=0.4769`, `point=-0.0581`, `polygon=-0.5369`, `roads=0.4759`). This is +a working external-sort-shaped prototype, not yet the desired production +shape: write time is still too high, and bbox reads regressed versus the +generated-preserving rewrite run. Small bounded in-memory COPY sort windows +were tested before this point and rejected: they lowered or raised initial +import time depending on the window, but left `osm2pgsql_cluster_key` +correlation poor and made diff append much slower. The experimental osm2pgsql variant is not part of PostgreSQL. For local testing, apply `osm2pgsql_cluster_during_import.patch` to a fresh osm2pgsql checkout and point `CLUSTERED_OSM2PGSQL` at the resulting binary. The patch adds `--cluster-during-import`, creates a generated geometry-derived geohash key and a clustered btree index before the first COPY, creates the heap with -`fillfactor=90`, rewrites the loaded heap by that key while preserving the -generated column, and restores the clustered btree marker before diff append. +`fillfactor=90`, stages COPY rows in per-target temporary tables, and drains +those staged rows ordered by the generated key before final index creation or +diff append. Directly clustering the import tables with a pre-COPY GiST index on `way` was also tested as a negative control. It worked functionally, but maintaining the @@ -561,12 +577,19 @@ Do not repeat these paths blindly: repair correlation (`line≈0`, `point=-0.1958`, `polygon=-0.6488`, `roads=0.4155`). The copy stream needs a global/external sort, not this small micro-sort. +* **Single temporary table for ordered staged COPY:** rejected. It was + functionally correct, but every target-table switch closed and drained the + staging table, so the real heap was a sequence of small ordered fragments + instead of one ordered run per target. Georgia `978` measured `6:06` create, + `6:08` append, worse reads, and long p95 spans (`15301`, `1776`, + `20334.5` for line/point/polygon). The current experiment keeps separate + temporary staging tables per target and drains them on sync. * **CTAS final rewrite for the generated-key osm2pgsql experiment:** rejected as a correctness shape even though it had the best single-run append timing (`1:54.96`). `CREATE TABLE AS SELECT *` does not preserve the generated column, so later append rows would stop computing `osm2pgsql_cluster_key`. - The checked-in experiment uses `LIKE ... INCLUDING GENERATED` plus ordered - `INSERT` instead. + Later experiments use `LIKE ... INCLUDING GENERATED` plus ordered `INSERT` + semantics instead. * **`datum_image_hash()` prefix cache for varlena leading keys:** rejected. This tried to make the clustered btree prefix cache direct-mapped for the generated text geohash key instead of doing linear opfamily equality checks. diff --git a/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch index 3a201658178d4..a353b01e9f401 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch +++ b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch @@ -15,6 +15,189 @@ index 232a70a..4a98592 100644 // --keep-coastlines app.add_flag("-K,--keep-coastlines", options.keep_coastlines) ->description("Keep coastline data (default: discard objects tagged" +diff --git a/src/db-copy.cpp b/src/db-copy.cpp +index 1a7dede..eda2881 100644 +--- a/src/db-copy.cpp ++++ b/src/db-copy.cpp +@@ -165,6 +165,7 @@ void db_copy_thread_t::thread_t::operator()() + } + + finish_copy(); ++ drain_ordered_copy_tables(); + } catch (std::runtime_error const &e) { + log_error("DB copy thread failed: {}", e.what()); + std::exit(2); // NOLINT(concurrency-mt-unsafe) +@@ -177,6 +178,9 @@ bool db_copy_thread_t::thread_t::execute(db_cmd_copy_delete_t &cmd) + if (cmd.has_deletables() || + (m_inflight && !cmd.target->same_copy_target(*m_inflight))) { + finish_copy(); ++ if (cmd.has_deletables() && cmd.target->uses_ordered_copy()) { ++ drain_ordered_copy_table(cmd.target); ++ } + } + + cmd.delete_data(m_db_connection); +@@ -199,6 +203,7 @@ bool db_copy_thread_t::thread_t::execute(db_cmd_end_copy_t &) + bool db_copy_thread_t::thread_t::execute(db_cmd_sync_t &cmd) + { + finish_copy(); ++ drain_ordered_copy_tables(); + cmd.barrier.set_value(); + return false; + } +@@ -209,14 +214,21 @@ void db_copy_thread_t::thread_t::start_copy( + assert(!m_inflight); + + auto const qname = qualified_name(target->schema(), target->name()); ++ auto copy_name = qname; ++ ++ if (target->uses_ordered_copy()) { ++ ensure_ordered_copy_table(target); ++ copy_name = fmt::format(R"("{}")", target->ordered_copy_table()); ++ } ++ + fmt::memory_buffer sql; +- sql.reserve(qname.size() + target->rows().size() + 20); ++ sql.reserve(copy_name.size() + target->rows().size() + 20); + if (target->rows().empty()) { + fmt::format_to(std::back_inserter(sql), +- FMT_STRING("COPY {} FROM STDIN"), qname); ++ FMT_STRING("COPY {} FROM STDIN"), copy_name); + } else { + fmt::format_to(std::back_inserter(sql), +- FMT_STRING("COPY {} ({}) FROM STDIN"), qname, ++ FMT_STRING("COPY {} ({}) FROM STDIN"), copy_name, + target->rows()); + } + +@@ -233,3 +245,51 @@ void db_copy_thread_t::thread_t::finish_copy() + m_inflight.reset(); + } + } ++ ++void db_copy_thread_t::thread_t::ensure_ordered_copy_table( ++ std::shared_ptr const &target) ++{ ++ for (auto const &known : m_ordered_copy_targets) { ++ if (target->same_copy_target(*known)) { ++ return; ++ } ++ } ++ ++ auto const qname = qualified_name(target->schema(), target->name()); ++ m_db_connection.exec( ++ R"(CREATE TEMP TABLE "{}" (LIKE {} INCLUDING GENERATED) )" ++ "ON COMMIT PRESERVE ROWS", ++ target->ordered_copy_table(), qname); ++ m_ordered_copy_targets.push_back(target); ++} ++ ++void db_copy_thread_t::thread_t::drain_ordered_copy_table( ++ std::shared_ptr const &target) ++{ ++ bool found = false; ++ for (auto const &known : m_ordered_copy_targets) { ++ if (target->same_copy_target(*known)) { ++ found = true; ++ break; ++ } ++ } ++ if (!found) { ++ return; ++ } ++ ++ auto const qname = qualified_name(target->schema(), target->name()); ++ ++ m_db_connection.exec( ++ R"(INSERT INTO {} ({}) SELECT {} FROM "{}" ORDER BY {})", qname, ++ target->rows(), target->rows(), target->ordered_copy_table(), ++ target->ordered_copy_by()); ++ m_db_connection.exec(R"(TRUNCATE TABLE "{}")", ++ target->ordered_copy_table()); ++} ++ ++void db_copy_thread_t::thread_t::drain_ordered_copy_tables() ++{ ++ for (auto const &target : m_ordered_copy_targets) { ++ drain_ordered_copy_table(target); ++ } ++} +diff --git a/src/db-copy.hpp b/src/db-copy.hpp +index 3a9d3f0..3bd921d 100644 +--- a/src/db-copy.hpp ++++ b/src/db-copy.hpp +@@ -46,8 +46,29 @@ public: + std::string const &name() const noexcept { return m_name; } + std::string const &id() const noexcept { return m_id; } + std::string const &rows() const noexcept { return m_rows; } ++ std::string const &ordered_copy_by() const noexcept ++ { ++ return m_ordered_copy_by; ++ } ++ std::string const &ordered_copy_table() const noexcept ++ { ++ return m_ordered_copy_table; ++ } + + void set_rows(std::string rows) { m_rows = std::move(rows); } ++ void set_ordered_copy_by(std::string order_by) ++ { ++ m_ordered_copy_by = std::move(order_by); ++ } ++ void set_ordered_copy_table(std::string table) ++ { ++ m_ordered_copy_table = std::move(table); ++ } ++ ++ bool uses_ordered_copy() const noexcept ++ { ++ return !m_ordered_copy_by.empty(); ++ } + + /** + * Check if the buffer would use exactly the same copy operation. +@@ -56,7 +77,9 @@ public: + { + return (this == &other) || + (m_schema == other.m_schema && m_name == other.m_name && +- m_id == other.m_id && m_rows == other.m_rows); ++ m_id == other.m_id && m_rows == other.m_rows && ++ m_ordered_copy_by == other.m_ordered_copy_by && ++ m_ordered_copy_table == other.m_ordered_copy_table); + } + + private: +@@ -68,6 +91,10 @@ private: + std::string m_id; + /// Comma-separated list of rows for copy operation (when empty: all rows) + std::string m_rows; ++ /// Optional ORDER BY clause for a temporary staged COPY before insertion. ++ std::string m_ordered_copy_by; ++ /// Temporary table name used for staged ordered COPY. ++ std::string m_ordered_copy_table; + }; + + /** +@@ -293,6 +320,11 @@ private: + + void start_copy(std::shared_ptr const &target); + void finish_copy(); ++ void ensure_ordered_copy_table( ++ std::shared_ptr const &target); ++ void drain_ordered_copy_table( ++ std::shared_ptr const &target); ++ void drain_ordered_copy_tables(); + void delete_rows(db_cmd_copy_t *buffer); + + connection_params_t m_connection_params; +@@ -300,6 +332,7 @@ private: + + // Target for copy operation currently ongoing. + std::shared_ptr m_inflight; ++ std::vector> m_ordered_copy_targets; + + // These are shared with the db_copy_thread_t in the main program. + shared *m_shared; diff --git a/src/options.hpp b/src/options.hpp index 4f56bfb..4cc115b 100644 --- a/src/options.hpp @@ -44,7 +227,7 @@ index 6b250a6..4b38667 100644 } diff --git a/src/table.cpp b/src/table.cpp -index 6b65eb9..581428a 100644 +index 6b65eb9..9e3b095 100644 --- a/src/table.cpp +++ b/src/table.cpp @@ -27,10 +27,11 @@ table_t::table_t(std::string const &name, std::string type, columns_t columns, @@ -61,7 +244,18 @@ index 6b65eb9..581428a 100644 m_hstore_columns(std::move(hstore_columns)), m_copy(copy_thread) { // if we dont have any columns -@@ -45,7 +46,9 @@ table_t::table_t(table_t const &other, +@@ -39,13 +40,20 @@ table_t::table_t(std::string const &name, std::string type, columns_t columns, + } + + generate_copy_column_list(); ++ ++ if (m_cluster_during_import) { ++ m_target->set_ordered_copy_by("osm2pgsql_cluster_key, osm_id"); ++ m_target->set_ordered_copy_table("osm2pgsql_ordered_copy_" + name); ++ } + } + + table_t::table_t(table_t const &other, std::shared_ptr const ©_thread) : m_connection_params(other.m_connection_params), m_target(other.m_target), m_type(other.m_type), m_srid(other.m_srid), m_append(other.m_append), @@ -72,7 +266,7 @@ index 6b65eb9..581428a 100644 m_hstore_columns(other.m_hstore_columns), m_table_space(other.m_table_space), m_copy(copy_thread) { -@@ -118,18 +121,52 @@ void table_t::start(connection_params_t const &connection_params, +@@ -118,18 +126,52 @@ void table_t::start(connection_params_t const &connection_params, sql += "\"tags\" hstore,"; } @@ -128,60 +322,30 @@ index 6b65eb9..581428a 100644 if (m_srid != "4326") { create_geom_check_trigger(*m_db_connection, m_target->schema(), m_target->name(), "ST_IsValid(NEW.way)"); -@@ -190,17 +227,52 @@ void table_t::stop(bool updateable, bool enable_hstore_index, +@@ -190,17 +232,22 @@ void table_t::stop(bool updateable, bool enable_hstore_index, m_target->name()); } - log_info("Clustering table '{}' by geometry...", m_target->name()); + if (m_cluster_during_import) { -+ log_info("Rewriting table '{}' by generated clustered key...", ++ log_info("Keeping ordered staged COPY heap for table '{}'.", + m_target->name()); ++ } else { ++ log_info("Clustering table '{}' by geometry...", m_target->name()); - std::string const sql = - fmt::format("CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way", - qual_tmp_name, m_table_space, qual_name); + std::string const sql = fmt::format( -+ "CREATE TABLE {} (LIKE {} INCLUDING GENERATED) " -+ "WITH (autovacuum_enabled = off, fillfactor = 90) {}", -+ qual_tmp_name, qual_name, m_table_space); ++ "CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way", ++ qual_tmp_name, m_table_space, qual_name); - m_db_connection->exec(sql); + m_db_connection->exec(sql); -+ m_db_connection->exec( -+ "INSERT INTO {} ({}) SELECT {} FROM {} " -+ "ORDER BY osm2pgsql_cluster_key, osm_id", -+ qual_tmp_name, m_target->rows(), m_target->rows(), -+ qual_name); -+ -+ m_db_connection->exec("DROP TABLE {}", qual_name); -+ m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")", -+ qual_tmp_name, m_target->name()); -+ -+ auto const idx_name = m_target->name() + "_cluster_key_idx"; -+ auto const quoted_idx_name = fmt::format(R"("{}")", idx_name); -+ -+ check_identifier(idx_name, "index names"); -+ log_info("Restoring clustered geometry key index on table '{}'...", -+ m_target->name()); -+ m_db_connection->exec( -+ "CREATE INDEX {} ON {} USING BTREE " -+ "(osm2pgsql_cluster_key) {}", -+ quoted_idx_name, qual_name, -+ tablespace_clause(table_space_index)); -+ m_db_connection->exec("CLUSTER {} USING {}", qual_name, -+ quoted_idx_name); -+ } else { -+ log_info("Clustering table '{}' by geometry...", m_target->name()); -+ -+ std::string const sql = fmt::format( -+ "CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way", -+ qual_tmp_name, m_table_space, qual_name); - m_db_connection->exec("DROP TABLE {}", qual_name); - m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")", qual_tmp_name, - m_target->name()); -+ m_db_connection->exec(sql); -+ + m_db_connection->exec("DROP TABLE {}", qual_name); + m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")", + qual_tmp_name, m_target->name()); diff --git a/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh index 6a404b71bdbad..b86215b823d30 100755 --- a/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh +++ b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh @@ -152,10 +152,16 @@ copy_postgis_into_install() pg_lib_dir="$(cd "$pg_home/lib" && pwd -P)" if [[ "$(cd "$POSTGIS_SHARE" && pwd -P)" != "$pg_extension_dir" ]]; then + rm -f "$pg_home/share/extension"/postgis* + rm -f "$pg_home/share/extension"/address_standardizer* cp -a "$POSTGIS_SHARE"/postgis* "$pg_home/share/extension/" + cp -a "$POSTGIS_SHARE"/sql/postgis* "$pg_home/share/extension/" 2>/dev/null || true cp -a "$POSTGIS_SHARE"/address_standardizer* "$pg_home/share/extension/" 2>/dev/null || true + cp -a "$POSTGIS_SHARE"/sql/address_standardizer* "$pg_home/share/extension/" 2>/dev/null || true fi if [[ "$(cd "$POSTGIS_LIB" && pwd -P)" != "$pg_lib_dir" ]]; then + rm -f "$pg_home/lib"/postgis-*.so + rm -f "$pg_home/lib"/postgis_raster-*.so cp -a "$POSTGIS_LIB"/postgis-*.so "$pg_home/lib/" cp -a "$POSTGIS_LIB"/postgis_raster-*.so "$pg_home/lib/" 2>/dev/null || true fi @@ -168,8 +174,11 @@ start_server() local port="$3" local data_dir="$WORKDIR/pgdata/$name" local log_file="$WORKDIR/logs/$name-postgres.log" + local socket_dir="${TMPDIR:-/tmp}/clustered-write-osm2pgsql-$port" rm -rf "$data_dir" + rm -rf "$socket_dir" + mkdir -p "$socket_dir" "$pg_bin/initdb" -D "$data_dir" >"$WORKDIR/logs/$name-initdb.log" cat >>"$data_dir/postgresql.conf" </dev/null 2>&1 || true + if [[ -n "$port" ]]; then + rm -rf "${TMPDIR:-/tmp}/clustered-write-osm2pgsql-$port" + fi } run_psql() @@ -222,7 +235,7 @@ run_variant() log "starting PostgreSQL for $name on port $port" start_server "$name" "$pg_bin" "$port" - trap "stop_server '$pg_bin' '$name'" EXIT + trap "stop_server '$pg_bin' '$name' '$port'" EXIT "$pg_bin/createdb" -h 127.0.0.1 -p "$port" osm run_psql "$pg_bin" "$port" osm -c 'create extension postgis; create extension hstore;' @@ -248,9 +261,14 @@ run_variant() >"$WORKDIR/logs/$name-create.stdout" \ 2>"$WORKDIR/logs/$name-create.stderr" + local append_args=(--append) + if [[ "$mode" == "clustered_import" ]]; then + append_args+=(--cluster-during-import) + fi + log "daily diff append for $name" /usr/bin/time -v -o "$WORKDIR/logs/$name-append.time" \ - "$osm2pgsql" "${common_args[@]}" --append "$osc" \ + "$osm2pgsql" "${common_args[@]}" "${append_args[@]}" "$osc" \ >"$WORKDIR/logs/$name-append.stdout" \ 2>"$WORKDIR/logs/$name-append.stderr" @@ -264,7 +282,7 @@ run_variant() -c "select current_database() as db, pg_size_pretty(pg_database_size(current_database())) as database_size;" \ >"$WORKDIR/logs/$name-size.sqlout" - stop_server "$pg_bin" "$name" + stop_server "$pg_bin" "$name" "$port" trap - EXIT } From 20691982be5bd9beead4e3599a45de670e48c3af Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 14:47:50 +0400 Subject: [PATCH 73/81] perf(clustered-write): defer osm2pgsql staged index --- src/tools/clustered_write_bench/README | 28 +++++++++-- .../osm2pgsql_cluster_during_import.patch | 50 ++++++++----------- 2 files changed, 45 insertions(+), 33 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 6bf9fefd1e7d2..b93f566188f59 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -476,14 +476,34 @@ were tested before this point and rejected: they lowered or raised initial import time depending on the window, but left `osm2pgsql_cluster_key` correlation poor and made diff append much slower. +The next kept staging improvement defers the clustered btree index until after +the initial staged drain. The initial load does not need that index while rows +are already draining in generated-key order; it is only needed afterwards so +future append runs remember the clustered order. On the same Georgia + planet +daily diff `978` setup this measured `2:10.01` create, `2:45.90` append, and +`2837 MB` database size. Read timings were `190.026 ms` point bbox, +`666.042 ms` line bbox, `1106.330 ms` polygon bbox, `1890.598 ms` roads exact, +and `1976.231 ms` polygon exact. Locality stayed in the same compact-block +shape with p95 block/span `3/2367.75` line, `3/1024` point, and `3/1643` +polygon; generated-key correlations stayed mixed (`line=0.4716`, +`point=-0.0678`, `polygon=-0.5425`, `roads=0.4918`). This is enough to keep +the deferred-index change in the osm2pgsql experiment: it removes obvious +btree-maintenance cost from initial import, improves bbox reads and polygon +exact reads versus the previous staged run, but regresses roads exact. It still +does not beat the earlier +generated-preserving rewrite target on write time, so the open problem remains +the append/drain cost rather than correctness or heap compactness. Raw gzip +logs are under +`/home/kom/tmp/clustered-write-osm2pgsql-ordered-deferred-index-20260501-143958/`. + The experimental osm2pgsql variant is not part of PostgreSQL. For local testing, apply `osm2pgsql_cluster_during_import.patch` to a fresh osm2pgsql checkout and point `CLUSTERED_OSM2PGSQL` at the resulting binary. The patch adds `--cluster-during-import`, creates a generated geometry-derived geohash key -and a clustered btree index before the first COPY, creates the heap with -`fillfactor=90`, stages COPY rows in per-target temporary tables, and drains -those staged rows ordered by the generated key before final index creation or -diff append. +and a heap with `fillfactor=90`, stages COPY rows in per-target temporary +tables, drains those staged rows ordered by the generated key, then creates and +marks the clustered btree index before final GiST/osm_id index creation. Append +runs keep using the existing generated-key clustered index. Directly clustering the import tables with a pre-COPY GiST index on `way` was also tested as a negative control. It worked functionally, but maintaining the diff --git a/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch index a353b01e9f401..3303a664a6d5d 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch +++ b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch @@ -227,7 +227,7 @@ index 6b250a6..4b38667 100644 } diff --git a/src/table.cpp b/src/table.cpp -index 6b65eb9..9e3b095 100644 +index 6b65eb9..67bf1cf 100644 --- a/src/table.cpp +++ b/src/table.cpp @@ -27,10 +27,11 @@ table_t::table_t(std::string const &name, std::string type, columns_t columns, @@ -266,7 +266,7 @@ index 6b65eb9..9e3b095 100644 m_hstore_columns(other.m_hstore_columns), m_table_space(other.m_table_space), m_copy(copy_thread) { -@@ -118,18 +126,52 @@ void table_t::start(connection_params_t const &connection_params, +@@ -118,12 +126,28 @@ void table_t::start(connection_params_t const &connection_params, sql += "\"tags\" hstore,"; } @@ -298,54 +298,46 @@ index 6b65eb9..9e3b095 100644 //add the main table space sql += m_table_space; - //create the table - m_db_connection->exec(sql); +@@ -190,17 +214,38 @@ void table_t::stop(bool updateable, bool enable_hstore_index, + m_target->name()); + } +- log_info("Clustering table '{}' by geometry...", m_target->name()); + if (m_cluster_during_import) { ++ log_info("Keeping ordered staged COPY heap for table '{}'.", ++ m_target->name()); + auto const idx_name = m_target->name() + "_cluster_key_idx"; + auto const quoted_idx_name = fmt::format(R"("{}")", idx_name); -+ + +- std::string const sql = +- fmt::format("CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way", +- qual_tmp_name, m_table_space, qual_name); + check_identifier(idx_name, "index names"); + log_info("Creating clustered geometry key index on table '{}'...", + m_target->name()); + m_db_connection->exec( + "CREATE INDEX {} ON {} USING BTREE " + "(osm2pgsql_cluster_key) {}", -+ quoted_idx_name, qual_name, tablespace_clause(table_space)); -+ ++ quoted_idx_name, qual_name, ++ tablespace_clause(table_space_index)); + +- m_db_connection->exec(sql); + log_info("Remembering clustered geometry key order for table '{}'...", + m_target->name()); + m_db_connection->exec("CLUSTER {} USING {}", qual_name, + quoted_idx_name); -+ } -+ - if (m_srid != "4326") { - create_geom_check_trigger(*m_db_connection, m_target->schema(), - m_target->name(), "ST_IsValid(NEW.way)"); -@@ -190,17 +232,22 @@ void table_t::stop(bool updateable, bool enable_hstore_index, - m_target->name()); - } - -- log_info("Clustering table '{}' by geometry...", m_target->name()); -+ if (m_cluster_during_import) { -+ log_info("Keeping ordered staged COPY heap for table '{}'.", -+ m_target->name()); + } else { + log_info("Clustering table '{}' by geometry...", m_target->name()); -- std::string const sql = -- fmt::format("CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way", -- qual_tmp_name, m_table_space, qual_name); +- m_db_connection->exec("DROP TABLE {}", qual_name); +- m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")", qual_tmp_name, +- m_target->name()); + std::string const sql = fmt::format( + "CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way", + qual_tmp_name, m_table_space, qual_name); - -- m_db_connection->exec(sql); ++ + m_db_connection->exec(sql); - -- m_db_connection->exec("DROP TABLE {}", qual_name); -- m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")", qual_tmp_name, -- m_target->name()); ++ + m_db_connection->exec("DROP TABLE {}", qual_name); + m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")", + qual_tmp_name, m_target->name()); From 4277197927242cf1fcb3c5f6b4aaabc967071e16 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 15:17:48 +0400 Subject: [PATCH 74/81] docs(clustered-write): record rejected staged plain key --- src/tools/clustered_write_bench/README | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index b93f566188f59..f701bdf733671 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -604,6 +604,20 @@ Do not repeat these paths blindly: `6:08` append, worse reads, and long p95 spans (`15301`, `1776`, `20334.5` for line/point/polygon). The current experiment keeps separate temporary staging tables per target and drains them on sync. +* **Plain real-table geohash key with generated staging key:** rejected. This + tried to avoid computing `ST_GeoHash(ST_Transform(ST_Envelope(way), 4326), 7)` + twice by making the real table's `osm2pgsql_cluster_key` a plain text column, + while the temporary staging table computed the generated key and copied it + into the real heap during the ordered drain. Correctness was fine after + append (`0` NULL keys and `0` mismatches on point/line/polygon/roads; the + real columns were plain, not generated), but the Georgia `978` run regressed + badly to `3:42.88` create and `4:18.57` append with unchanged locality + (`3/2367.75`, `3/1024`, `3/1643` p95 block/span). Read timings were mixed: + roads exact improved to `462.948 ms`, but polygon exact regressed to + `3509.093 ms`, and bbox reads were slower than the deferred-index generated + key run. The extra real-column/staging plumbing is therefore not the missing + cost center. Raw gzip logs are under + `/home/kom/tmp/clustered-write-osm2pgsql-plain-key-stage-20260501-145922/`. * **CTAS final rewrite for the generated-key osm2pgsql experiment:** rejected as a correctness shape even though it had the best single-run append timing (`1:54.96`). `CREATE TABLE AS SELECT *` does not preserve the generated From 0b1c2f8a942e4119c91be4d40463657946d595ad Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 15:51:28 +0400 Subject: [PATCH 75/81] perf(clustered-write): index staged osm2pgsql deletes --- src/tools/clustered_write_bench/README | 37 +++++- .../osm2pgsql_cluster_during_import.patch | 123 ++++++++++++++---- 2 files changed, 129 insertions(+), 31 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index f701bdf733671..18d44bf9632bc 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -496,14 +496,38 @@ the append/drain cost rather than correctness or heap compactness. Raw gzip logs are under `/home/kom/tmp/clustered-write-osm2pgsql-ordered-deferred-index-20260501-143958/`. +The current staging shape keeps DELETEs from fragmenting that ordered run. When +append-mode osm2pgsql receives deletes for a target table, the copy thread now +removes matching ids from the target's temporary staging table instead of +draining the whole staging table before the DELETE. Append-mode staging tables +also get a temporary btree index on the delete key (`osm_id`, or the existing +type/id key) so those temp-table deletes do not become full scans as the staged +batch grows. In a same-environment A/B against stock osm2pgsql on patched +PostgreSQL, stock measured `1:58.33` create and `2:05.22` append, while the +clustered import measured `1:30.30` create and `2:13.45` append. The clustered +run had a larger heap (`2840 MB` versus `2727 MB`) but much tighter locality: +p95 block/span was `2/2` line, `2/1` point, and `3/2` polygon, compared with +stock `7/2381`, `6/1002`, and `32/1208`. Most read timings improved or stayed +close (`627.029 ms` line bbox, `954.597 ms` polygon bbox, `184.525 ms` roads +exact, `1251.008 ms` polygon exact), while one point bbox timing spiked to +`3163.663 ms` in that run and should be treated as read noise until repeated. +The rejected intermediate variant without the temporary delete-key index proved +why the index is needed: it preserved the same tight locality but append +regressed to `3:24.06` because deletes scanned the growing temp staging tables. +Raw gzip logs are under +`/home/kom/tmp/clustered-write-osm2pgsql-current-ab-20260501-151937/`, +`/home/kom/tmp/clustered-write-osm2pgsql-staged-delete-20260501-153348/`, and +`/home/kom/tmp/clustered-write-osm2pgsql-staged-delete-index-20260501-154406/`. + The experimental osm2pgsql variant is not part of PostgreSQL. For local testing, apply `osm2pgsql_cluster_during_import.patch` to a fresh osm2pgsql checkout and point `CLUSTERED_OSM2PGSQL` at the resulting binary. The patch adds `--cluster-during-import`, creates a generated geometry-derived geohash key and a heap with `fillfactor=90`, stages COPY rows in per-target temporary -tables, drains those staged rows ordered by the generated key, then creates and -marks the clustered btree index before final GiST/osm_id index creation. Append -runs keep using the existing generated-key clustered index. +tables, removes matching staged rows on append DELETEs, drains those staged rows +ordered by the generated key, then creates and marks the clustered btree index +before final GiST/osm_id index creation. Append runs keep using the existing +generated-key clustered index. Directly clustering the import tables with a pre-COPY GiST index on `way` was also tested as a negative control. It worked functionally, but maintaining the @@ -604,6 +628,13 @@ Do not repeat these paths blindly: `6:08` append, worse reads, and long p95 spans (`15301`, `1776`, `20334.5` for line/point/polygon). The current experiment keeps separate temporary staging tables per target and drains them on sync. +* **Deleting from staged COPY tables without a temp delete-key index:** rejected + as a standalone fix. It avoided the correctness-preserving drain before each + DELETE and achieved the desired heap shape (`2/2`, `2/1`, `3/2` p95 + block/span for line/point/polygon), but each DELETE scanned the growing + staging table. Georgia `978` regressed to `3:24.06` append. The kept + variant adds a temporary delete-key index for append staging tables and brings + append back to `2:13.45` while keeping the tight heap shape. * **Plain real-table geohash key with generated staging key:** rejected. This tried to avoid computing `ST_GeoHash(ST_Transform(ST_Envelope(way), 4326), 7)` twice by making the real table's `osm2pgsql_cluster_key` a plain text column, diff --git a/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch index 3303a664a6d5d..5df4153384be2 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch +++ b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch @@ -16,7 +16,7 @@ index 232a70a..4a98592 100644 app.add_flag("-K,--keep-coastlines", options.keep_coastlines) ->description("Keep coastline data (default: discard objects tagged" diff --git a/src/db-copy.cpp b/src/db-copy.cpp -index 1a7dede..eda2881 100644 +index 1a7dede..a5886e3 100644 --- a/src/db-copy.cpp +++ b/src/db-copy.cpp @@ -165,6 +165,7 @@ void db_copy_thread_t::thread_t::operator()() @@ -32,7 +32,7 @@ index 1a7dede..eda2881 100644 (m_inflight && !cmd.target->same_copy_target(*m_inflight))) { finish_copy(); + if (cmd.has_deletables() && cmd.target->uses_ordered_copy()) { -+ drain_ordered_copy_table(cmd.target); ++ delete_from_ordered_copy_table(cmd); + } } @@ -70,7 +70,7 @@ index 1a7dede..eda2881 100644 target->rows()); } -@@ -233,3 +245,51 @@ void db_copy_thread_t::thread_t::finish_copy() +@@ -233,3 +245,78 @@ void db_copy_thread_t::thread_t::finish_copy() m_inflight.reset(); } } @@ -78,10 +78,8 @@ index 1a7dede..eda2881 100644 +void db_copy_thread_t::thread_t::ensure_ordered_copy_table( + std::shared_ptr const &target) +{ -+ for (auto const &known : m_ordered_copy_targets) { -+ if (target->same_copy_target(*known)) { -+ return; -+ } ++ if (has_ordered_copy_table(target)) { ++ return; + } + + auto const qname = qualified_name(target->schema(), target->name()); @@ -89,20 +87,49 @@ index 1a7dede..eda2881 100644 + R"(CREATE TEMP TABLE "{}" (LIKE {} INCLUDING GENERATED) )" + "ON COMMIT PRESERVE ROWS", + target->ordered_copy_table(), qname); ++ if (target->has_ordered_copy_delete_index()) { ++ m_db_connection.exec(R"(CREATE INDEX ON "{}" ({}))", ++ target->ordered_copy_table(), ++ target->ordered_copy_delete_index_by()); ++ } + m_ordered_copy_targets.push_back(target); +} + -+void db_copy_thread_t::thread_t::drain_ordered_copy_table( -+ std::shared_ptr const &target) ++bool db_copy_thread_t::thread_t::has_ordered_copy_table( ++ std::shared_ptr const &target) const +{ -+ bool found = false; + for (auto const &known : m_ordered_copy_targets) { + if (target->same_copy_target(*known)) { -+ found = true; -+ break; ++ return true; + } + } -+ if (!found) { ++ return false; ++} ++ ++void db_copy_thread_t::thread_t::delete_from_ordered_copy_table( ++ db_cmd_copy_delete_t &cmd) ++{ ++ if (has_ordered_copy_table(cmd.target)) { ++ cmd.delete_data_from(fmt::format(R"("{}")", ++ cmd.target->ordered_copy_table()), ++ m_db_connection); ++ } ++} ++ ++void db_copy_thread_t::thread_t::delete_from_ordered_copy_table( ++ db_cmd_copy_delete_t &cmd) ++{ ++ if (has_ordered_copy_table(cmd.target)) { ++ cmd.delete_data_from(fmt::format(R"("{}")", ++ cmd.target->ordered_copy_table()), ++ m_db_connection); ++ } ++} ++ ++void db_copy_thread_t::thread_t::drain_ordered_copy_table( ++ std::shared_ptr const &target) ++{ ++ if (!has_ordered_copy_table(target)) { + return; + } + @@ -123,10 +150,10 @@ index 1a7dede..eda2881 100644 + } +} diff --git a/src/db-copy.hpp b/src/db-copy.hpp -index 3a9d3f0..3bd921d 100644 +index 3a9d3f0..7b90bc8 100644 --- a/src/db-copy.hpp +++ b/src/db-copy.hpp -@@ -46,8 +46,29 @@ public: +@@ -46,8 +46,41 @@ public: std::string const &name() const noexcept { return m_name; } std::string const &id() const noexcept { return m_id; } std::string const &rows() const noexcept { return m_rows; } @@ -137,6 +164,10 @@ index 3a9d3f0..3bd921d 100644 + std::string const &ordered_copy_table() const noexcept + { + return m_ordered_copy_table; ++ } ++ std::string const &ordered_copy_delete_index_by() const noexcept ++ { ++ return m_ordered_copy_delete_index_by; + } void set_rows(std::string rows) { m_rows = std::move(rows); } @@ -148,26 +179,36 @@ index 3a9d3f0..3bd921d 100644 + { + m_ordered_copy_table = std::move(table); + } ++ void set_ordered_copy_delete_index_by(std::string columns) ++ { ++ m_ordered_copy_delete_index_by = std::move(columns); ++ } + + bool uses_ordered_copy() const noexcept + { + return !m_ordered_copy_by.empty(); ++ } ++ bool has_ordered_copy_delete_index() const noexcept ++ { ++ return !m_ordered_copy_delete_index_by.empty(); + } /** * Check if the buffer would use exactly the same copy operation. -@@ -56,7 +77,9 @@ public: +@@ -56,7 +89,11 @@ public: { return (this == &other) || (m_schema == other.m_schema && m_name == other.m_name && - m_id == other.m_id && m_rows == other.m_rows); + m_id == other.m_id && m_rows == other.m_rows && + m_ordered_copy_by == other.m_ordered_copy_by && -+ m_ordered_copy_table == other.m_ordered_copy_table); ++ m_ordered_copy_table == other.m_ordered_copy_table && ++ m_ordered_copy_delete_index_by == ++ other.m_ordered_copy_delete_index_by); } private: -@@ -68,6 +91,10 @@ private: +@@ -68,6 +105,12 @@ private: std::string m_id; /// Comma-separated list of rows for copy operation (when empty: all rows) std::string m_rows; @@ -175,22 +216,45 @@ index 3a9d3f0..3bd921d 100644 + std::string m_ordered_copy_by; + /// Temporary table name used for staged ordered COPY. + std::string m_ordered_copy_table; ++ /// Optional index columns for fast DELETEs from a staged COPY table. ++ std::string m_ordered_copy_delete_index_by; }; /** -@@ -293,6 +320,11 @@ private: +@@ -196,6 +239,14 @@ public: + } + } + ++ void delete_data_from(std::string const &table, ++ pg_conn_t const &db_connection) ++ { ++ if (m_deleter.has_data()) { ++ m_deleter.delete_rows(table, target->id(), db_connection); ++ } ++ } ++ + template + void add_deletable(ARGS &&...args) + { +@@ -293,6 +344,17 @@ private: void start_copy(std::shared_ptr const &target); void finish_copy(); + void ensure_ordered_copy_table( + std::shared_ptr const &target); ++ bool has_ordered_copy_table( ++ std::shared_ptr const &target) const; ++ void delete_from_ordered_copy_table( ++ db_cmd_copy_delete_t &cmd); ++ void delete_from_ordered_copy_table( ++ db_cmd_copy_delete_t &cmd); + void drain_ordered_copy_table( + std::shared_ptr const &target); + void drain_ordered_copy_tables(); void delete_rows(db_cmd_copy_t *buffer); connection_params_t m_connection_params; -@@ -300,6 +332,7 @@ private: +@@ -300,6 +362,7 @@ private: // Target for copy operation currently ongoing. std::shared_ptr m_inflight; @@ -227,7 +291,7 @@ index 6b250a6..4b38667 100644 } diff --git a/src/table.cpp b/src/table.cpp -index 6b65eb9..67bf1cf 100644 +index 6b65eb9..6794fe5 100644 --- a/src/table.cpp +++ b/src/table.cpp @@ -27,10 +27,11 @@ table_t::table_t(std::string const &name, std::string type, columns_t columns, @@ -244,7 +308,7 @@ index 6b65eb9..67bf1cf 100644 m_hstore_columns(std::move(hstore_columns)), m_copy(copy_thread) { // if we dont have any columns -@@ -39,13 +40,20 @@ table_t::table_t(std::string const &name, std::string type, columns_t columns, +@@ -39,13 +40,23 @@ table_t::table_t(std::string const &name, std::string type, columns_t columns, } generate_copy_column_list(); @@ -252,6 +316,9 @@ index 6b65eb9..67bf1cf 100644 + if (m_cluster_during_import) { + m_target->set_ordered_copy_by("osm2pgsql_cluster_key, osm_id"); + m_target->set_ordered_copy_table("osm2pgsql_ordered_copy_" + name); ++ if (m_append) { ++ m_target->set_ordered_copy_delete_index_by(m_target->id()); ++ } + } } @@ -266,7 +333,7 @@ index 6b65eb9..67bf1cf 100644 m_hstore_columns(other.m_hstore_columns), m_table_space(other.m_table_space), m_copy(copy_thread) { -@@ -118,12 +126,28 @@ void table_t::start(connection_params_t const &connection_params, +@@ -118,12 +129,28 @@ void table_t::start(connection_params_t const &connection_params, sql += "\"tags\" hstore,"; } @@ -298,7 +365,7 @@ index 6b65eb9..67bf1cf 100644 //add the main table space sql += m_table_space; -@@ -190,17 +214,38 @@ void table_t::stop(bool updateable, bool enable_hstore_index, +@@ -190,17 +217,38 @@ void table_t::stop(bool updateable, bool enable_hstore_index, m_target->name()); } @@ -328,14 +395,14 @@ index 6b65eb9..67bf1cf 100644 + quoted_idx_name); + } else { + log_info("Clustering table '{}' by geometry...", m_target->name()); ++ ++ std::string const sql = fmt::format( ++ "CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way", ++ qual_tmp_name, m_table_space, qual_name); - m_db_connection->exec("DROP TABLE {}", qual_name); - m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")", qual_tmp_name, - m_target->name()); -+ std::string const sql = fmt::format( -+ "CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way", -+ qual_tmp_name, m_table_space, qual_name); -+ + m_db_connection->exec(sql); + + m_db_connection->exec("DROP TABLE {}", qual_name); From 5a2752e8ef97aae97c7cf267653c52b14e90d69d Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 16:01:32 +0400 Subject: [PATCH 76/81] chore(clustered-write): bound osm2pgsql benchmark artifacts --- src/tools/clustered_write_bench/README | 23 ++++++ .../run_osm2pgsql_georgia_bench.sh | 74 ++++++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 18d44bf9632bc..fbe8e2ff708ff 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -375,6 +375,11 @@ daily diff append. The read side runs spatial bbox and exact-intersection queries from `osm2pgsql_georgia_read.sql`, with `EXPLAIN (ANALYZE, BUFFERS)`, and also reports a geohash-to-heap-block locality summary. All output goes under `$WORKDIR/logs` (`$HOME/tmp/clustered-write-osm2pgsql/logs` by default). +The real-workload harness now mirrors the synthetic wrapper's disk hygiene: +`run_environment.txt` records the selected git head, binaries, benchmark +settings, host load, and filesystem space; per-variant logs are gzip-compressed +by default; and each variant's `pgdata` is removed after the server stops so a +failed long run does not quietly fill the disk. Useful overrides: @@ -385,6 +390,10 @@ Useful overrides: * `OSM2PGSQL_STYLE=/path/to/default.style` selects the pgsql style file. * `OSC_URL=https://...osc.gz` pins a specific planet daily diff. * `BENCH_VARIANTS=baseline_stock,patched_stock` runs only selected variants. +* `COMPRESS_LOGS=false` leaves per-variant logs uncompressed. +* `KEEP_PGDATA=true` keeps finished or failed PostgreSQL data directories for + manual inspection. The default is to delete them; use a fresh `$WORKDIR` for + repeat runs if you keep data around. Only selected variants need their PostgreSQL and osm2pgsql binaries to exist. Common input-preparation tools such as curl, osmium, awk, sed, and the @@ -519,6 +528,20 @@ Raw gzip logs are under `/home/kom/tmp/clustered-write-osm2pgsql-staged-delete-20260501-153348/`, and `/home/kom/tmp/clustered-write-osm2pgsql-staged-delete-index-20260501-154406/`. +A repeat of only `patched_clustered_import` with the same Georgia `978` inputs +confirmed the point bbox spike was not stable: point bbox read was +`978.990 ms`, not `3163.663 ms`. The heap shape stayed tight (`2/2` line, +`2/1` point, `3/2` polygon p95 block/span), database size stayed `2840 MB`, +and reads were `1146.925 ms` line bbox, `1912.456 ms` polygon bbox, +`293.356 ms` roads exact, and `2053.134 ms` polygon exact. Write timings were +noisier (`1:07.54` create, `2:51.11` append), so the honest conclusion is that +the staged-delete-index shape is a real locality win and a plausible append +shape, but final read/write claims need repeat medians rather than a single +host-load-sensitive run. This repeat also verified the real-workload harness +cleanup path: per-variant logs were gzip-compressed and `pgdata` was removed +with `KEEP_PGDATA=false`. Raw gzip logs and `run_environment.txt` are under +`/home/kom/tmp/clustered-write-osm2pgsql-staged-delete-index-rerun-20260501-155455/`. + The experimental osm2pgsql variant is not part of PostgreSQL. For local testing, apply `osm2pgsql_cluster_during_import.patch` to a fresh osm2pgsql checkout and point `CLUSTERED_OSM2PGSQL` at the resulting binary. The patch diff --git a/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh index b86215b823d30..473c169870740 100755 --- a/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh +++ b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh @@ -23,6 +23,8 @@ PG_PORT_BASE="${PG_PORT_BASE:-55432}" OSM2PGSQL_CACHE_MB="${OSM2PGSQL_CACHE_MB:-2048}" OSM2PGSQL_PROCS="${OSM2PGSQL_PROCS:-4}" BENCH_VARIANTS="${BENCH_VARIANTS:-baseline_stock,patched_stock,patched_clustered_import}" +COMPRESS_LOGS="${COMPRESS_LOGS:-true}" +KEEP_PGDATA="${KEEP_PGDATA:-false}" mkdir -p "$WORKDIR"/{data,logs,pgdata} @@ -207,6 +209,33 @@ stop_server() fi } +compress_variant_logs() +{ + local name="$1" + + if [[ "$COMPRESS_LOGS" != "true" ]]; then + return + fi + + find "$WORKDIR/logs" -maxdepth 1 -type f -name "$name-*" \ + ! -name '*.gz' -exec gzip -f {} + +} + +cleanup_variant() +{ + local pg_bin="$1" + local name="$2" + local port="$3" + local data_dir="$WORKDIR/pgdata/$name" + + stop_server "$pg_bin" "$name" "$port" + compress_variant_logs "$name" + + if [[ "$KEEP_PGDATA" != "true" ]]; then + rm -rf "$data_dir" + fi +} + run_psql() { local pg_bin="$1" @@ -235,7 +264,7 @@ run_variant() log "starting PostgreSQL for $name on port $port" start_server "$name" "$pg_bin" "$port" - trap "stop_server '$pg_bin' '$name' '$port'" EXIT + trap "cleanup_variant '$pg_bin' '$name' '$port'" EXIT "$pg_bin/createdb" -h 127.0.0.1 -p "$port" osm run_psql "$pg_bin" "$port" osm -c 'create extension postgis; create extension hstore;' @@ -282,10 +311,47 @@ run_variant() -c "select current_database() as db, pg_size_pretty(pg_database_size(current_database())) as database_size;" \ >"$WORKDIR/logs/$name-size.sqlout" - stop_server "$pg_bin" "$name" "$port" + cleanup_variant "$pg_bin" "$name" "$port" trap - EXIT } +write_run_environment() +{ + { + printf 'date: ' + date -Is + printf 'root_dir: %s\n' "$ROOT_DIR" + printf 'bench_dir: %s\n' "$BENCH_DIR" + if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + printf 'git_head: %s\n' "$(git -C "$ROOT_DIR" rev-parse HEAD)" + printf 'git_branch: %s\n' "$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD)" + fi + printf 'workdir: %s\n' "$WORKDIR" + printf 'georgia_url: %s\n' "$GEORGIA_URL" + printf 'planet_day_state_url: %s\n' "$PLANET_DAY_STATE_URL" + printf 'osc_url: %s\n' "$OSC_URL" + printf 'patched_pg_bin: %s\n' "$PATCHED_PG_BIN" + printf 'baseline_pg_bin: %s\n' "$BASELINE_PG_BIN" + printf 'stock_osm2pgsql: %s\n' "$STOCK_OSM2PGSQL" + printf 'clustered_osm2pgsql: %s\n' "$CLUSTERED_OSM2PGSQL" + printf 'osm2pgsql_style: %s\n' "$OSM2PGSQL_STYLE" + printf 'osmium: %s\n' "$OSMIUM" + printf 'postgis_share: %s\n' "$POSTGIS_SHARE" + printf 'postgis_lib: %s\n' "$POSTGIS_LIB" + printf 'pg_port_base: %s\n' "$PG_PORT_BASE" + printf 'osm2pgsql_cache_mb: %s\n' "$OSM2PGSQL_CACHE_MB" + printf 'osm2pgsql_procs: %s\n' "$OSM2PGSQL_PROCS" + printf 'bench_variants: %s\n' "$BENCH_VARIANTS" + printf 'compress_logs: %s\n' "$COMPRESS_LOGS" + printf 'keep_pgdata: %s\n' "$KEEP_PGDATA" + printf 'uname: ' + uname -a + printf 'uptime: ' + uptime + df -h "$WORKDIR" "${TMPDIR:-/tmp}" 2>/dev/null || true + } >"$WORKDIR/run_environment.txt" +} + require_executable curl require_executable awk require_executable sed @@ -322,6 +388,10 @@ log " daily_diff=$daily_diff_url" log " simplified_daily_diff=$simplified_daily_diff" log " variants=$BENCH_VARIANTS" log " workdir=$WORKDIR" +log " compress_logs=$COMPRESS_LOGS" +log " keep_pgdata=$KEEP_PGDATA" + +write_run_environment if variant_enabled baseline_stock; then run_variant baseline_stock "$BASELINE_PG_BIN" "$((PG_PORT_BASE + 1))" "$stock_osm2pgsql_path" stock "$georgia_pbf" "$simplified_daily_diff" From 1f727179c079d1223cc9b19d9ec6bebd18d1ae48 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 16:15:44 +0400 Subject: [PATCH 77/81] perf(clustered-write): repeat osm2pgsql read benchmark --- src/tools/clustered_write_bench/README | 20 ++++++++++++ .../run_osm2pgsql_georgia_bench.sh | 32 ++++++++++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index fbe8e2ff708ff..dc0eed254f286 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -390,6 +390,9 @@ Useful overrides: * `OSM2PGSQL_STYLE=/path/to/default.style` selects the pgsql style file. * `OSC_URL=https://...osc.gz` pins a specific planet daily diff. * `BENCH_VARIANTS=baseline_stock,patched_stock` runs only selected variants. +* `READ_REPEATS=3` runs the read benchmark several times against the same + imported database. This is much cheaper than repeating the full import and + makes cache/scheduler outliers visible in the read numbers. * `COMPRESS_LOGS=false` leaves per-variant logs uncompressed. * `KEEP_PGDATA=true` keeps finished or failed PostgreSQL data directories for manual inspection. The default is to delete them; use a fresh `$WORKDIR` for @@ -542,6 +545,23 @@ cleanup path: per-variant logs were gzip-compressed and `pgdata` was removed with `KEEP_PGDATA=false`. Raw gzip logs and `run_environment.txt` are under `/home/kom/tmp/clustered-write-osm2pgsql-staged-delete-index-rerun-20260501-155455/`. +Adding `READ_REPEATS=3` made the read comparison less dependent on one cold +query pass. A same-input patched PostgreSQL run compared stock osm2pgsql with +the staged clustered import while keeping the generated `pgdata` cleanup and +gzip compression enabled: + +| variant | create elapsed | append elapsed | size | point bbox median | line bbox median | polygon bbox median | roads exact median | polygon exact median | locality summary median | p95 block/span | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| patched_stock | 1:47.47 | 2:20.95 | 2728 MB | 130.545 ms | 193.198 ms | 330.994 ms | 76.100 ms | 1852.406 ms | 8123.631 ms | line 7/2381, point 6/1002, polygon 32/1208 | +| patched_clustered_import | 1:21.41 | 3:05.01 | 2841 MB | 110.095 ms | 200.126 ms | 345.462 ms | 46.813 ms | 1695.422 ms | 6832.766 ms | line 2/2, point 2/1, polygon 3/2 | + +This is a useful but mixed result: the clustered import is faster to create, +has far tighter heap locality, and improves point bbox plus exact reads in the +warm median; line/polygon bbox are roughly tied or slightly worse, and append +is still slower in this run. Raw gzip logs are under +`/home/kom/tmp/clustered-write-osm2pgsql-read-repeats-20260501-160342/` and +`/home/kom/tmp/clustered-write-osm2pgsql-stock-read-repeats-20260501-160927/`. + The experimental osm2pgsql variant is not part of PostgreSQL. For local testing, apply `osm2pgsql_cluster_during_import.patch` to a fresh osm2pgsql checkout and point `CLUSTERED_OSM2PGSQL` at the resulting binary. The patch diff --git a/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh index 473c169870740..146fabd28ad8c 100755 --- a/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh +++ b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh @@ -23,6 +23,7 @@ PG_PORT_BASE="${PG_PORT_BASE:-55432}" OSM2PGSQL_CACHE_MB="${OSM2PGSQL_CACHE_MB:-2048}" OSM2PGSQL_PROCS="${OSM2PGSQL_PROCS:-4}" BENCH_VARIANTS="${BENCH_VARIANTS:-baseline_stock,patched_stock,patched_clustered_import}" +READ_REPEATS="${READ_REPEATS:-1}" COMPRESS_LOGS="${COMPRESS_LOGS:-true}" KEEP_PGDATA="${KEEP_PGDATA:-false}" @@ -246,6 +247,29 @@ run_psql() "$pg_bin/psql" -v ON_ERROR_STOP=1 -h 127.0.0.1 -p "$port" -d "$db" "$@" } +run_read_benchmarks() +{ + local name="$1" + local pg_bin="$2" + local port="$3" + local read_run + local out_prefix + + for read_run in $(seq 1 "$READ_REPEATS"); do + if [[ "$READ_REPEATS" == "1" ]]; then + out_prefix="$WORKDIR/logs/$name-read" + else + out_prefix="$WORKDIR/logs/$name-read-run-$read_run" + fi + + log "read benchmark for $name ($read_run/$READ_REPEATS)" + run_psql "$pg_bin" "$port" osm \ + -f "$BENCH_DIR/osm2pgsql_georgia_read.sql" \ + >"$out_prefix.sqlout" \ + 2>"$out_prefix.sqlerr" + done +} + run_variant() { local name="$1" @@ -301,11 +325,7 @@ run_variant() >"$WORKDIR/logs/$name-append.stdout" \ 2>"$WORKDIR/logs/$name-append.stderr" - log "read benchmark for $name" - run_psql "$pg_bin" "$port" osm \ - -f "$BENCH_DIR/osm2pgsql_georgia_read.sql" \ - >"$WORKDIR/logs/$name-read.sqlout" \ - 2>"$WORKDIR/logs/$name-read.sqlerr" + run_read_benchmarks "$name" "$pg_bin" "$port" run_psql "$pg_bin" "$port" osm \ -c "select current_database() as db, pg_size_pretty(pg_database_size(current_database())) as database_size;" \ @@ -342,6 +362,7 @@ write_run_environment() printf 'osm2pgsql_cache_mb: %s\n' "$OSM2PGSQL_CACHE_MB" printf 'osm2pgsql_procs: %s\n' "$OSM2PGSQL_PROCS" printf 'bench_variants: %s\n' "$BENCH_VARIANTS" + printf 'read_repeats: %s\n' "$READ_REPEATS" printf 'compress_logs: %s\n' "$COMPRESS_LOGS" printf 'keep_pgdata: %s\n' "$KEEP_PGDATA" printf 'uname: ' @@ -388,6 +409,7 @@ log " daily_diff=$daily_diff_url" log " simplified_daily_diff=$simplified_daily_diff" log " variants=$BENCH_VARIANTS" log " workdir=$WORKDIR" +log " read_repeats=$READ_REPEATS" log " compress_logs=$COMPRESS_LOGS" log " keep_pgdata=$KEEP_PGDATA" From 6f64870d506c7b7b08f04def10c92c900e58cc7c Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 16:30:58 +0400 Subject: [PATCH 78/81] perf(clustered-write): create staged delete index lazily --- src/tools/clustered_write_bench/README | 32 ++++++++++--- .../osm2pgsql_cluster_during_import.patch | 48 +++++++++++++++---- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index dc0eed254f286..b4fc2f94be72f 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -512,9 +512,10 @@ The current staging shape keeps DELETEs from fragmenting that ordered run. When append-mode osm2pgsql receives deletes for a target table, the copy thread now removes matching ids from the target's temporary staging table instead of draining the whole staging table before the DELETE. Append-mode staging tables -also get a temporary btree index on the delete key (`osm_id`, or the existing -type/id key) so those temp-table deletes do not become full scans as the staged -batch grows. In a same-environment A/B against stock osm2pgsql on patched +create a temporary btree index on the delete key (`osm_id`, or the existing +type/id key) lazily, just before the first staged DELETE for that target, so +those temp-table deletes do not become full scans as the staged batch grows. +In a same-environment A/B against stock osm2pgsql on patched PostgreSQL, stock measured `1:58.33` create and `2:05.22` append, while the clustered import measured `1:30.30` create and `2:13.45` append. The clustered run had a larger heap (`2840 MB` versus `2727 MB`) but much tighter locality: @@ -562,15 +563,32 @@ is still slower in this run. Raw gzip logs are under `/home/kom/tmp/clustered-write-osm2pgsql-read-repeats-20260501-160342/` and `/home/kom/tmp/clustered-write-osm2pgsql-stock-read-repeats-20260501-160927/`. +The staged delete-key index is now created lazily rather than at temporary +table creation time. This keeps the protection against full temp-table scans +when append DELETEs arrive, but avoids paying btree maintenance for targets +that only receive staged inserts. A same-input `READ_REPEATS=3` Georgia `978` +run with the lazy index measured `1:42.58` create, `2:46.88` append, +`2841 MB` size, warm read 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 locality still tight at p95 block/span `2/2` line, `2/1` +point, and `3/2` polygon. The append `Reading input files` phase took `163s`, +down from the previous repeated clustered run's `181s` but still above the +stock run's `131s`; treat this as a modest cleanup, not the final append-cost +fix. The harness removed the per-variant data directory with +`KEEP_PGDATA=false`; only the empty `$WORKDIR/pgdata` parent remains. Raw gzip +logs are under +`/home/kom/tmp/clustered-write-osm2pgsql-lazy-delete-index-20260501-162417/`. + The experimental osm2pgsql variant is not part of PostgreSQL. For local testing, apply `osm2pgsql_cluster_during_import.patch` to a fresh osm2pgsql checkout and point `CLUSTERED_OSM2PGSQL` at the resulting binary. The patch adds `--cluster-during-import`, creates a generated geometry-derived geohash key and a heap with `fillfactor=90`, stages COPY rows in per-target temporary -tables, removes matching staged rows on append DELETEs, drains those staged rows -ordered by the generated key, then creates and marks the clustered btree index -before final GiST/osm_id index creation. Append runs keep using the existing -generated-key clustered index. +tables, removes matching staged rows on append DELETEs using a lazily-created +temporary delete-key index when needed, drains those staged rows ordered by the +generated key, then creates and marks the clustered btree index before final +GiST/osm_id index creation. Append runs keep using the existing generated-key +clustered index. Directly clustering the import tables with a pre-COPY GiST index on `way` was also tested as a negative control. It worked functionally, but maintaining the diff --git a/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch index 5df4153384be2..75ffb74427da8 100644 --- a/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch +++ b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch @@ -16,7 +16,7 @@ index 232a70a..4a98592 100644 app.add_flag("-K,--keep-coastlines", options.keep_coastlines) ->description("Keep coastline data (default: discard objects tagged" diff --git a/src/db-copy.cpp b/src/db-copy.cpp -index 1a7dede..a5886e3 100644 +index 1a7dede..0f66444 100644 --- a/src/db-copy.cpp +++ b/src/db-copy.cpp @@ -165,6 +165,7 @@ void db_copy_thread_t::thread_t::operator()() @@ -70,7 +70,7 @@ index 1a7dede..a5886e3 100644 target->rows()); } -@@ -233,3 +245,78 @@ void db_copy_thread_t::thread_t::finish_copy() +@@ -233,3 +245,100 @@ void db_copy_thread_t::thread_t::finish_copy() m_inflight.reset(); } } @@ -87,11 +87,6 @@ index 1a7dede..a5886e3 100644 + R"(CREATE TEMP TABLE "{}" (LIKE {} INCLUDING GENERATED) )" + "ON COMMIT PRESERVE ROWS", + target->ordered_copy_table(), qname); -+ if (target->has_ordered_copy_delete_index()) { -+ m_db_connection.exec(R"(CREATE INDEX ON "{}" ({}))", -+ target->ordered_copy_table(), -+ target->ordered_copy_delete_index_by()); -+ } + m_ordered_copy_targets.push_back(target); +} + @@ -106,10 +101,36 @@ index 1a7dede..a5886e3 100644 + return false; +} + ++bool db_copy_thread_t::thread_t::has_ordered_copy_delete_index( ++ std::shared_ptr const &target) const ++{ ++ for (auto const &known : m_ordered_copy_delete_index_targets) { ++ if (target->same_copy_target(*known)) { ++ return true; ++ } ++ } ++ return false; ++} ++ ++void db_copy_thread_t::thread_t::ensure_ordered_copy_delete_index( ++ std::shared_ptr const &target) ++{ ++ if (!target->has_ordered_copy_delete_index() || ++ has_ordered_copy_delete_index(target)) { ++ return; ++ } ++ ++ m_db_connection.exec(R"(CREATE INDEX ON "{}" ({}))", ++ target->ordered_copy_table(), ++ target->ordered_copy_delete_index_by()); ++ m_ordered_copy_delete_index_targets.push_back(target); ++} ++ +void db_copy_thread_t::thread_t::delete_from_ordered_copy_table( + db_cmd_copy_delete_t &cmd) +{ + if (has_ordered_copy_table(cmd.target)) { ++ ensure_ordered_copy_delete_index(cmd.target); + cmd.delete_data_from(fmt::format(R"("{}")", + cmd.target->ordered_copy_table()), + m_db_connection); @@ -120,6 +141,7 @@ index 1a7dede..a5886e3 100644 + db_cmd_copy_delete_t &cmd) +{ + if (has_ordered_copy_table(cmd.target)) { ++ ensure_ordered_copy_delete_index(cmd.target); + cmd.delete_data_from(fmt::format(R"("{}")", + cmd.target->ordered_copy_table()), + m_db_connection); @@ -150,7 +172,7 @@ index 1a7dede..a5886e3 100644 + } +} diff --git a/src/db-copy.hpp b/src/db-copy.hpp -index 3a9d3f0..7b90bc8 100644 +index 3a9d3f0..95e85d7 100644 --- a/src/db-copy.hpp +++ b/src/db-copy.hpp @@ -46,8 +46,41 @@ public: @@ -236,7 +258,7 @@ index 3a9d3f0..7b90bc8 100644 template void add_deletable(ARGS &&...args) { -@@ -293,6 +344,17 @@ private: +@@ -293,6 +344,21 @@ private: void start_copy(std::shared_ptr const &target); void finish_copy(); @@ -244,6 +266,10 @@ index 3a9d3f0..7b90bc8 100644 + std::shared_ptr const &target); + bool has_ordered_copy_table( + std::shared_ptr const &target) const; ++ void ensure_ordered_copy_delete_index( ++ std::shared_ptr const &target); ++ bool has_ordered_copy_delete_index( ++ std::shared_ptr const &target) const; + void delete_from_ordered_copy_table( + db_cmd_copy_delete_t &cmd); + void delete_from_ordered_copy_table( @@ -254,11 +280,13 @@ index 3a9d3f0..7b90bc8 100644 void delete_rows(db_cmd_copy_t *buffer); connection_params_t m_connection_params; -@@ -300,6 +362,7 @@ private: +@@ -300,6 +366,9 @@ private: // Target for copy operation currently ongoing. std::shared_ptr m_inflight; + std::vector> m_ordered_copy_targets; ++ std::vector> ++ m_ordered_copy_delete_index_targets; // These are shared with the db_copy_thread_t in the main program. shared *m_shared; From 7bf7cc467abd6d1cbccf8a0b5c2a6de65508e805 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 1 May 2026 16:51:41 +0400 Subject: [PATCH 79/81] perf(clustered-write): diagnose osm2pgsql append cost --- src/tools/clustered_write_bench/README | 18 +++++++++++++++++- .../run_osm2pgsql_georgia_bench.sh | 13 +++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index b4fc2f94be72f..63b7c1caf5dae 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -392,11 +392,14 @@ Useful overrides: * `BENCH_VARIANTS=baseline_stock,patched_stock` runs only selected variants. * `READ_REPEATS=3` runs the read benchmark several times against the same imported database. This is much cheaper than repeating the full import and - makes cache/scheduler outliers visible in the read numbers. + makes cache/scheduler outliers visible in the read numbers. Set + `READ_REPEATS=0` for append-only diagnostic runs. * `COMPRESS_LOGS=false` leaves per-variant logs uncompressed. * `KEEP_PGDATA=true` keeps finished or failed PostgreSQL data directories for manual inspection. The default is to delete them; use a fresh `$WORKDIR` for repeat runs if you keep data around. +* `PG_LOG_MIN_DURATION_MS=500` enables PostgreSQL statement-duration logging + for slow-statement diagnosis in the per-variant `*-postgres.log`. Only selected variants need their PostgreSQL and osm2pgsql binaries to exist. Common input-preparation tools such as curl, osmium, awk, sed, and the @@ -696,6 +699,19 @@ Do not repeat these paths blindly: staging table. Georgia `978` regressed to `3:24.06` append. The kept variant adds a temporary delete-key index for append staging tables and brings append back to `2:13.45` while keeping the tight heap shape. +* **Direct append into clustered real tables:** rejected. The idea was to use + ordered staging only for initial import, then let append COPY directly into + the already-clustered real heap and rely on PostgreSQL clustered-write + placement. This removed the expensive staged `INSERT ... SELECT ... ORDER + BY` drain and improved Georgia `978` append to `2:05.19`, with `Reading input + files` down to `121s`, but it destroyed the storage/read shape: database size + grew to `3370 MB`, p95 spans became `8249` line, `7526` point, and `6653.5` + polygon, and read timings regressed to `224.320 ms` point bbox, + `1005.170 ms` line bbox, `1612.676 ms` polygon bbox, `1224.795 ms` roads + exact, and `3732.839 ms` polygon exact. The kept append path still needs the + ordered drain, even though it is now the obvious remaining cost center. Raw + gzip logs are under + `/home/kom/tmp/clustered-write-osm2pgsql-direct-append-read-20260501-164635/`. * **Plain real-table geohash key with generated staging key:** rejected. This tried to avoid computing `ST_GeoHash(ST_Transform(ST_Envelope(way), 4326), 7)` twice by making the real table's `osm2pgsql_cluster_key` a plain text column, diff --git a/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh index 146fabd28ad8c..111aab680c5ce 100755 --- a/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh +++ b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh @@ -26,6 +26,7 @@ BENCH_VARIANTS="${BENCH_VARIANTS:-baseline_stock,patched_stock,patched_clustered READ_REPEATS="${READ_REPEATS:-1}" COMPRESS_LOGS="${COMPRESS_LOGS:-true}" KEEP_PGDATA="${KEEP_PGDATA:-false}" +PG_LOG_MIN_DURATION_MS="${PG_LOG_MIN_DURATION_MS:-}" mkdir -p "$WORKDIR"/{data,logs,pgdata} @@ -194,6 +195,11 @@ synchronous_commit = off full_page_writes = off autovacuum = off EOF + if [[ -n "$PG_LOG_MIN_DURATION_MS" ]]; then + cat >>"$data_dir/postgresql.conf" < Date: Fri, 1 May 2026 17:03:23 +0400 Subject: [PATCH 80/81] perf(clustered-write): expose osm2pgsql work_mem knob --- src/tools/clustered_write_bench/README | 18 ++++++++++++++++++ .../run_osm2pgsql_georgia_bench.sh | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README index 63b7c1caf5dae..ad9d8af4b6e0f 100644 --- a/src/tools/clustered_write_bench/README +++ b/src/tools/clustered_write_bench/README @@ -390,6 +390,8 @@ Useful overrides: * `OSM2PGSQL_STYLE=/path/to/default.style` selects the pgsql style file. * `OSC_URL=https://...osc.gz` pins a specific planet daily diff. * `BENCH_VARIANTS=baseline_stock,patched_stock` runs only selected variants. +* `PG_WORK_MEM=256MB` changes the benchmark cluster `work_mem`. The default is + `64MB`, matching the earlier runs. * `READ_REPEATS=3` runs the read benchmark several times against the same imported database. This is much cheaper than repeating the full import and makes cache/scheduler outliers visible in the read numbers. Set @@ -582,6 +584,22 @@ fix. The harness removed the per-variant data directory with logs are under `/home/kom/tmp/clustered-write-osm2pgsql-lazy-delete-index-20260501-162417/`. +An append-only slow-statement run showed the remaining clustered append cost is +mostly the ordered staging path, especially the final `INSERT ... SELECT ... +ORDER BY` drain into the real heap. Raising the benchmark `work_mem` from +`64MB` to `256MB` is a useful near-term knob for that drain. With the same +kept staged/lazy-index shape, an append-only Georgia `978` run measured +`1:23.89` create, `2:10.95` append, `2840 MB` size, and reduced slow logged +staged-drain time above `1s` from `59.6s` to `43.3s`. A follow-up single-read +run measured `1:18.84` create, `2:13.68` append, `2840 MB`, read timings +`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 +p95 block/span `2/2` line, `2/1` point, and `3/2` polygon. This is not a new +algorithmic fix, but it is a practical benchmark and deployment knob for the +current ordered-drain cost center. Raw gzip logs are under +`/home/kom/tmp/clustered-write-osm2pgsql-workmem256-20260501-165334/` and +`/home/kom/tmp/clustered-write-osm2pgsql-workmem256-read-20260501-165743/`. + The experimental osm2pgsql variant is not part of PostgreSQL. For local testing, apply `osm2pgsql_cluster_during_import.patch` to a fresh osm2pgsql checkout and point `CLUSTERED_OSM2PGSQL` at the resulting binary. The patch diff --git a/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh index 111aab680c5ce..5c44c8f091101 100755 --- a/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh +++ b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh @@ -22,6 +22,7 @@ POSTGIS_LIB="${POSTGIS_LIB:-/usr/lib/postgresql/18/lib}" PG_PORT_BASE="${PG_PORT_BASE:-55432}" OSM2PGSQL_CACHE_MB="${OSM2PGSQL_CACHE_MB:-2048}" OSM2PGSQL_PROCS="${OSM2PGSQL_PROCS:-4}" +PG_WORK_MEM="${PG_WORK_MEM:-64MB}" BENCH_VARIANTS="${BENCH_VARIANTS:-baseline_stock,patched_stock,patched_clustered_import}" READ_REPEATS="${READ_REPEATS:-1}" COMPRESS_LOGS="${COMPRESS_LOGS:-true}" @@ -187,7 +188,7 @@ start_server() cat >>"$data_dir/postgresql.conf" < Date: Thu, 14 May 2026 07:10:48 +0400 Subject: [PATCH 81/81] fix(heap): keep clustered overflow probes scoped --- src/backend/access/heap/hio.c | 81 ++++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 25 deletions(-) diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index 615101214cc6c..a877fd90d6786 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -107,6 +107,7 @@ static void ClusteredWriteRememberReserveUse(Relation relation, HeapTuple tuple, BlockNumber targetBlock); static void ClusteredWriteUpdateCachedOverflowTarget(Relation relation, + HeapTuple tuple, BlockNumber targetBlock); static bool ClusteredWriteStoreOverflowValue(HeapClusteredWriteOverflowEntry *entry, Datum value, @@ -404,11 +405,14 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, if (!RegProcedureIsValid(eqProcedure)) break; - ScanKeyInit(&skey[i], - i + 1, - BTEqualStrategyNumber, - eqProcedure, - value); + ScanKeyEntryInitialize(&skey[i], + 0, + i + 1, + BTEqualStrategyNumber, + InvalidOid, + indexRelation->rd_indcollation[i], + eqProcedure, + value); nscankeys++; } @@ -509,11 +513,14 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, get_opcode(rangeOperator) : InvalidOid; if (RegProcedureIsValid(rangeProcedure)) { - ScanKeyInit(&rangeKey, - 1, - BTGreaterEqualStrategyNumber, - rangeProcedure, - skey[0].sk_argument); + ScanKeyEntryInitialize(&rangeKey, + 0, + 1, + BTGreaterEqualStrategyNumber, + InvalidOid, + indexRelation->rd_indcollation[0], + rangeProcedure, + skey[0].sk_argument); scan = index_beginscan(relation, indexRelation, SnapshotAny, NULL, 1, 0, 0); index_rescan(scan, &rangeKey, 1, NULL, 0); @@ -538,11 +545,14 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, if (RegProcedureIsValid(rangeProcedure) && ncandidates < CLUSTERED_WRITE_MAX_HEAP_BLOCKS) { - ScanKeyInit(&rangeKey, - 1, - BTLessEqualStrategyNumber, - rangeProcedure, - skey[0].sk_argument); + ScanKeyEntryInitialize(&rangeKey, + 0, + 1, + BTLessEqualStrategyNumber, + InvalidOid, + indexRelation->rd_indcollation[0], + rangeProcedure, + skey[0].sk_argument); scan = index_beginscan(relation, indexRelation, SnapshotAny, NULL, 1, 0, 0); index_rescan(scan, &rangeKey, 1, NULL, 0); @@ -578,6 +588,10 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, if (candidateFreeSpace[i] >= len) targetBlocks[ntargets++] = candidates[i]; } + if (ntargets == 0 && ncandidates > 0 && + clusteredCandidatesExhausted != NULL) + *clusteredCandidatesExhausted = true; + if (ntargets == 0) { /* @@ -591,10 +605,6 @@ RelationGetClusteredTargetBlocksFromIndex(Relation relation, targetBlocks[ntargets++] = candidates[i]; } - if (ntargets == 0 && ncandidates > 0 && - clusteredCandidatesExhausted != NULL) - *clusteredCandidatesExhausted = true; - return ntargets; } @@ -859,21 +869,42 @@ ClusteredWriteRememberReserveUse(Relation relation, HeapTuple tuple, } static void -ClusteredWriteUpdateCachedOverflowTarget(Relation relation, +ClusteredWriteUpdateCachedOverflowTarget(Relation relation, HeapTuple tuple, BlockNumber targetBlock) { HeapClusteredWriteCache *cache; + Datum prefixValue; + bool prefixIsNull; cache = (HeapClusteredWriteCache *) relation->rd_amcache; if (cache == NULL || - cache->magic != CLUSTERED_WRITE_CACHE_MAGIC) + cache->magic != CLUSTERED_WRITE_CACHE_MAGIC || + tuple == NULL) return; for (int i = 0; i < CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; i++) { - if (cache->overflowEntries[i].active && - BlockNumberIsValid(cache->overflowEntries[i].overflowTargetBlock)) - cache->overflowEntries[i].overflowTargetBlock = targetBlock; + HeapClusteredWriteOverflowEntry *entry = &cache->overflowEntries[i]; + bool equal; + + if (!entry->active || + !BlockNumberIsValid(entry->overflowTargetBlock)) + continue; + + prefixValue = heap_getattr(tuple, entry->overflowAttnum, + relation->rd_att, &prefixIsNull); + if (prefixIsNull) + continue; + + equal = DatumGetBool(OidFunctionCall2Coll(entry->overflowEqProc, + entry->overflowCollation, + entry->overflowValue, + prefixValue)); + if (equal) + { + entry->overflowTargetBlock = targetBlock; + return; + } } } @@ -1919,7 +1950,7 @@ RelationGetBufferForTuple(Relation relation, Size len, */ RelationSetTargetBlock(relation, targetBlock); if (usingClusteredOverflowTarget) - ClusteredWriteUpdateCachedOverflowTarget(relation, targetBlock); + ClusteredWriteUpdateCachedOverflowTarget(relation, tuple, targetBlock); return buffer; }