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..856a66ebad288 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -31,6 +31,10 @@ */ #include "postgres.h" +#include "common/hashfn.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 +44,11 @@ #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 "catalog/pg_type_d.h" #include "commands/vacuum.h" #include "executor/instrument_node.h" #include "pgstat.h" @@ -53,12 +60,82 @@ #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" #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]; + + /* GiST compressed keys must survive until qsort finishes. */ + MemoryContext sortCxt; + + /* Comparator support functions should not leak into the caller context. */ + MemoryContext compareCxt; +} HeapTupleClusteredSortContext; + +typedef struct HeapTupleClusteredTargetCacheEntry +{ + Datum prefixValue; + BlockNumber targetBlock; + 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 + * 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 + +/* + * 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); +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, + 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 +189,254 @@ 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; + 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; + } + } + 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; + + if (left->inputIndex < right->inputIndex) + return -1; + if (left->inputIndex > right->inputIndex) + return 1; + 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) +{ + if (!get_typbyval(typeOid)) + return false; + + switch (typeOid) + { + case INT2OID: + case INT4OID: + case INT8OID: + case OIDOID: + return true; + default: + return false; + } +} + +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) +{ + int natts; + int nkeys; + + if (IsBootstrapProcessingMode() || indexRelation == NULL) + return false; + + 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, + Anum_pg_index_indexprs, NULL) || + !heap_attisnull(indexRelation->rd_indextuple, + Anum_pg_index_indpred, NULL)) + return false; + + natts = IndexRelationGetNumberOfAttributes(indexRelation); + nkeys = IndexRelationGetNumberOfKeyAttributes(indexRelation); + if (nkeys <= 0 || nkeys > natts || natts > INDEX_MAX_KEYS) + return false; + + for (int i = 0; i < natts; i++) + { + if (indexRelation->rd_index->indkey.values[i] <= 0) + return false; + + if (i < nkeys && + !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; + MemoryContext oldcontext; + int natts; + int nkeys; + + context->nkeys = 0; + context->sortCxt = NULL; + context->compareCxt = NULL; + + if (!heap_clustered_write_index_can_sort(relation, indexRelation)) + return 0; + + natts = IndexRelationGetNumberOfAttributes(indexRelation); + nkeys = IndexRelationGetNumberOfKeyAttributes(indexRelation); + 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++) + { + 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); + giststate->tempCxt = context->sortCxt; + + for (int i = 0; i < ntuples; i++) + { + items[i].hasClusterKey = true; + + for (int key = 0; key < natts; 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); + MemoryContextSwitchTo(oldcontext); + + return nkeys; +} + /* * This table lists the heavyweight lock mode that corresponds to each tuple @@ -2030,6 +2355,8 @@ heap_insert(Relation relation, HeapTuple tup, CommandId cid, * this will also pin the requisite visibility map page. */ buffer = RelationGetBufferForTuple(relation, heaptup->t_len, + heaptup, + InvalidBlockNumber, InvalidBuffer, options, bistate, &vmbuffer, NULL, 0); @@ -2284,6 +2611,9 @@ 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; + bool heaptuple_skip_clustered_target_lookup = false; int i; int ndone; PGAlignedBlock scratch; @@ -2319,6 +2649,520 @@ 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 = {0}; + 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; + 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; + int prefixTargetCacheSize = 0; + int prefixCountCacheSize = 0; + int prefixTargetCacheMask = 0; + int hotPrefixTupleCount = 0; + + if (!IsBootstrapProcessingMode()) + { + clusteredIndexOid = RelationGetClusteredIndex(relation); + if (OidIsValid(clusteredIndexOid)) + clusteredIndexRelation = + try_index_open(clusteredIndexOid, AccessShareLock); + } + if (RelationCanUseClusteredTargetProbe(relation, + clusteredIndexRelation)) + 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 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) + { + 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 + { + /* + * 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_all_equal( + clusteredIndexRelation->rd_opcintype[0])) + { + Datum firstPrefixValue; + Datum lastPrefixValue; + 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], + prefixCacheAttnum, + relation->rd_att, + &firstPrefixIsNull); + lastPrefixValue = + heap_getattr(heaptuples[ntuples - 1], + prefixCacheAttnum, + relation->rd_att, + &lastPrefixIsNull); + + if (!firstPrefixIsNull && !lastPrefixIsNull) + { + if (comparePrefixByDatum) + allSamePrefix = + firstPrefixValue == lastPrefixValue; + else + { + 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); + } + + if (allSamePrefix) + { + for (i = 1; i < ntuples - 1; i++) + { + 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; + } + } + } + } + + 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 * + CLUSTERED_WRITE_PREFIX_TARGET_HASH_FACTOR)); + usePrefixHashCache = + heap_clustered_write_prefix_can_hash( + clusteredIndexRelation->rd_opcintype[0]); + + if (!usePrefixHashCache) + prefixTargetCacheLimit = ntuples; + prefixTargetCache = + palloc0_array(HeapTupleClusteredTargetCacheEntry, + prefixTargetCacheLimit); + prefixCountCache = + palloc0_array(HeapTupleClusteredPrefixCountEntry, + prefixTargetCacheLimit); + prefixCountSlots = palloc_array(int, ntuples); + for (i = 0; i < ntuples; i++) + prefixCountSlots[i] = -1; + prefixTargetCacheMask = prefixTargetCacheLimit - 1; + if (prefixCacheCompareCxt == NULL) + 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 = + heap_clustered_write_prefix_hash(prefixValue, + clusteredIndexRelation->rd_opcintype[0]); + 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++; + prefixCountSlots[i] = cacheSlot; + 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; + 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; + } + } + } + } + +skip_clustered_prefix_cache: + + if (use_clustered_target_probe && + hotPrefixTupleCount == ntuples) + { + heaptuple_skip_clustered_target_lookup = true; + clustered = NULL; + if (prefixCacheCompareCxt != NULL) + { + MemoryContextDelete(prefixCacheCompareCxt); + prefixCacheCompareCxt = NULL; + } + } + else + { + 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; + Datum prefixValue = (Datum) 0; + bool prefixIsNull = true; + bool foundCachedTarget = false; + bool skipClusteredTargetProbe = false; + int prefixCacheSlot = -1; + + if (prefixTargetCache != NULL) + { + 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; + + if (usePrefixHashCache) + { + uint32 prefixHash; + + prefixHash = + heap_clustered_write_prefix_hash(prefixValue, + clusteredIndexRelation->rd_opcintype[0]); + 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[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 (!foundCachedTarget && !skipClusteredTargetProbe) + { + if (RelationGetClusteredTargetBlocksFromIndex(relation, + clusteredIndexRelation, + heaptuples[i], + heaptuples[i]->t_len, + &targetBlock, + 1, + true, + NULL) > 0) + clustered[i].targetBlock = targetBlock; + + if (prefixTargetCache != NULL && !prefixIsNull) + { + int cacheSlot; + + 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; + } + } + + 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) + { + 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); + for (i = 0; i < ntuples; i++) + { + 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); + } + 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); + + if (clustered != NULL) + pfree(clustered); + if (prefixTargetCache != NULL) + { + pfree(prefixCountSlots); + pfree(prefixCountCache); + pfree(prefixTargetCache); + if (prefixCacheCompareCxt != NULL) + MemoryContextDelete(prefixCacheCompareCxt); + } + } + + 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 @@ -2348,6 +3192,9 @@ 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; + uint32 buffer_options = options; bool all_visible_cleared = false; bool all_frozen_set = false; int nthispage; @@ -2380,8 +3227,31 @@ 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_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 = + heaptuple_clustered_target_blocks[ndone]; + if (clustered_target_block == InvalidBlockNumber) + clustered_target_tuple = NULL; + } + buffer = RelationGetBufferForTuple(relation, heaptuples[ndone]->t_len, - InvalidBuffer, options, bistate, + clustered_target_tuple, + clustered_target_block, + InvalidBuffer, buffer_options, bistate, &vmbuffer, NULL, npages - npages_used); page = BufferGetPage(buffer); @@ -2415,6 +3285,35 @@ 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 nextTargetBlock = + heaptuple_clustered_target_blocks[ndone + nthispage]; + + if (BlockNumberIsValid(clustered_target_block)) + { + /* + * 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)) + break; + } + if (PageGetHeapFreeSpace(page) < MAXALIGN(heaptup->t_len) + saveFreeSpace) break; @@ -2640,8 +3539,18 @@ 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); + pfree(heaptuple_clustered_target_blocks); + } + else + { + for (i = 0; i < ntuples; i++) + slots[i]->tts_tid = heaptuples[i]->t_self; + } pgstat_count_heap_insert(relation, ntuples); } @@ -3906,6 +4815,8 @@ 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 e96e0f77d9264..a877fd90d6786 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -15,14 +15,150 @@ #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/datum.h" +#include "utils/lsyscache.h" +#include "utils/memutils.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 +#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 +{ + bool active; + AttrNumber overflowAttnum; + Oid overflowCollation; + RegProcedure overflowEqProc; + Datum overflowValue; + Size overflowValueLen; + int16 overflowTypLen; + uint16 overflowReserveUses; + bool overflowTypByVal; + BlockNumber overflowTargetBlock; + char overflowValueStorage[CLUSTERED_WRITE_OVERFLOW_VALUE_BYTES]; +} HeapClusteredWriteOverflowEntry; + +typedef struct HeapClusteredWriteCache +{ + uint32 magic; + uint32 overflowNextEntry; + HeapClusteredWriteOverflowEntry overflowEntries[CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES]; +} HeapClusteredWriteCache; + +static bool ClusteredWriteRememberCandidate(Relation relation, + BlockNumber nblocks, + ItemPointer tid, + BlockNumber *candidates, + Size *candidateFreeSpace, + int *ncandidates, + int maxCandidates); +static bool ClusteredWriteHasFittingCandidate(Size *candidateFreeSpace, + int ncandidates, + Size len); +static void ClusteredWriteRememberPrefixCandidates(Relation relation, + Relation indexRelation, + BlockNumber nblocks, + ScanKey skey, + int nscankeys, + ScanDirection direction, + BlockNumber *candidates, + Size *candidateFreeSpace, + int *ncandidates, + int *ntuples, + int tupleLimit, + int candidateLimit); +static int RelationGetClusteredTargetBlocksForTuple(Relation relation, + HeapTuple tuple, + Size len, + BlockNumber *targetBlocks, + int maxTargetBlocks, + bool firstCandidateOnly, + bool *clusteredCandidatesExhausted); +static bool ClusteredWriteGetCachedOverflowTarget(Relation relation, + HeapTuple tuple, + BlockNumber *targetBlock); +static void ClusteredWriteRememberOverflowTarget(Relation relation, + HeapTuple tuple, + BlockNumber targetBlock); +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, + bool typByVal, + int16 typLen); + +/* + * 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 @@ -79,6 +215,739 @@ 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, + 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; + + for (int i = 0; i < *ncandidates; i++) + { + if (candidates[i] == candidate) + return false; + } + + candidates[*ncandidates] = candidate; + if (candidateFreeSpace != NULL) + candidateFreeSpace[*ncandidates] = GetRecordedFreeSpace(relation, + candidate); + (*ncandidates)++; + + 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 + * 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, + 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); + index_rescan(scan, skey, nscankeys, NULL, 0); + + while ((tid = index_getnext_tid(scan, direction)) != NULL) + { + if (*ntuples >= tupleLimit) + break; + (*ntuples)++; + + ClusteredWriteRememberCandidate(relation, nblocks, tid, + candidates, candidateFreeSpace, + ncandidates, candidateLimit); + if (*ncandidates >= candidateLimit) + break; + } + + index_endscan(scan); +} + +/* + * 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). + * + * 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 + * 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, + bool firstCandidateOnly, + bool *clusteredCandidatesExhausted) +{ + 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; + int ntuples = 0; + int ncandidates = 0; + int ntargets = 0; + + Assert(maxTargetBlocks > 0); + + if (clusteredCandidatesExhausted != NULL) + *clusteredCandidatesExhausted = false; + + candidateFreeSpacePtr = firstCandidateOnly ? NULL : candidateFreeSpace; + + if (IsBootstrapProcessingMode() || indexRelation == NULL || + tuple == NULL || len > MaxHeapTupleSize) + return 0; + + if (!RelationCanUseClusteredTargetProbe(relation, indexRelation)) + return 0; + + nkeys = indexRelation->rd_index->indnkeyatts; + nblocks = RelationGetNumberOfBlocks(relation); + + for (int i = 0; i < nkeys; i++) + { + AttrNumber attnum = indexRelation->rd_index->indkey.values[i]; + Datum value; + bool isnull; + Oid eqOperator; + RegProcedure eqProcedure; + + Assert(attnum > 0); + + 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; + + ScanKeyEntryInitialize(&skey[i], + 0, + i + 1, + BTEqualStrategyNumber, + InvalidOid, + indexRelation->rd_indcollation[i], + eqProcedure, + value); + nscankeys++; + } + + if (nscankeys == 0) + return 0; + + for (int probeKeys = nscankeys; probeKeys > 0; probeKeys--) + { + int forwardCandidateLimit; + int remainingCandidates; + 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 + * 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. + */ + remainingCandidates = CLUSTERED_WRITE_MAX_HEAP_BLOCKS - ncandidates; + remainingTuples = CLUSTERED_WRITE_MAX_INDEX_TIDS - ntuples; + if (remainingCandidates <= 0 || remainingTuples <= 0) + break; + + /* + * 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. + */ + 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, + 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. + * 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 && + !firstCandidateOnly && + !ClusteredWriteHasFittingCandidate(candidateFreeSpace, + ncandidates, len)) + ClusteredWriteRememberPrefixCandidates(relation, indexRelation, + nblocks, skey, probeKeys, + BackwardScanDirection, + candidates, + candidateFreeSpacePtr, + &ncandidates, &ntuples, + CLUSTERED_WRITE_MAX_INDEX_TIDS, + CLUSTERED_WRITE_MAX_HEAP_BLOCKS); + + if (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 && 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)) + { + 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); + tid = index_getnext_tid(scan, ForwardScanDirection); + if (tid != NULL) + { + ClusteredWriteRememberCandidate(relation, nblocks, tid, + candidates, + candidateFreeSpacePtr, + &ncandidates, + CLUSTERED_WRITE_MAX_HEAP_BLOCKS); + } + 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) + { + 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); + tid = index_getnext_tid(scan, BackwardScanDirection); + if (tid != NULL) + { + ClusteredWriteRememberCandidate(relation, nblocks, tid, + candidates, + candidateFreeSpacePtr, + &ncandidates, + CLUSTERED_WRITE_MAX_HEAP_BLOCKS); + } + index_endscan(scan); + } + } + + /* + * 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) + { + if (ncandidates > 0) + targetBlocks[ntargets++] = candidates[0]; + return ntargets; + } + + for (int i = 0; i < ncandidates && ntargets < maxTargetBlocks; i++) + { + if (candidateFreeSpace[i] >= len) + targetBlocks[ntargets++] = candidates[i]; + } + if (ntargets == 0 && ncandidates > 0 && + clusteredCandidatesExhausted != NULL) + *clusteredCandidatesExhausted = true; + + 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]; + } + + return ntargets; +} + +static HeapClusteredWriteCache * +ClusteredWriteGetCache(Relation relation) +{ + HeapClusteredWriteCache *cache; + + cache = (HeapClusteredWriteCache *) relation->rd_amcache; + if (cache != NULL) + { + 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; + } + + cache = MemoryContextAllocZero(CacheMemoryContext, + sizeof(HeapClusteredWriteCache)); + cache->magic = CLUSTERED_WRITE_CACHE_MAGIC; + relation->rd_amcache = cache; + return cache; +} + +static bool +ClusteredWriteStoreOverflowValue(HeapClusteredWriteOverflowEntry *entry, + Datum value, bool typByVal, int16 typLen) +{ + entry->overflowTypByVal = typByVal; + entry->overflowTypLen = typLen; + + if (typByVal) + { + entry->overflowValue = value; + entry->overflowValueLen = sizeof(Datum); + return true; + } + + /* + * 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 +ClusteredWriteGetCachedOverflowTarget(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 || + tuple == NULL) + return false; + + for (int i = 0; i < CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; i++) + { + 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) + continue; + + *targetBlock = entry->overflowTargetBlock; + return true; + } + + return false; +} + +static void +ClusteredWriteRememberOverflowTargetInternal(Relation relation, HeapTuple tuple, + BlockNumber targetBlock, + bool countReserveUse) +{ + HeapClusteredWriteCache *cache; + Relation indexRelation; + Oid indexOid; + Oid eqOperator; + RegProcedure eqProc; + Oid typeOid; + AttrNumber attnum; + Datum prefixValue; + bool prefixIsNull; + bool typByVal; + int16 typLen; + + 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]; + get_typlenbyval(typeOid, &typLen, &typByVal); + if (attnum <= 0) + { + 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) + { + HeapClusteredWriteOverflowEntry *entry = NULL; + bool matchedEntry = false; + + 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) + { + matchedEntry = true; + 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) + { + uint32 insertAt; + + insertAt = cache->overflowNextEntry++ % + CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; + entry = &cache->overflowEntries[insertAt]; + } + if (!matchedEntry) + entry->overflowReserveUses = 0; + + entry->overflowAttnum = attnum; + entry->overflowCollation = indexRelation->rd_indcollation[0]; + entry->overflowEqProc = eqProc; + if (!ClusteredWriteStoreOverflowValue(entry, prefixValue, + typByVal, typLen)) + { + entry->active = false; + index_close(indexRelation, AccessShareLock); + return; + } + 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, HeapTuple tuple, + BlockNumber targetBlock) +{ + HeapClusteredWriteCache *cache; + Datum prefixValue; + bool prefixIsNull; + + cache = (HeapClusteredWriteCache *) relation->rd_amcache; + if (cache == NULL || + cache->magic != CLUSTERED_WRITE_CACHE_MAGIC || + tuple == NULL) + return; + + for (int i = 0; i < CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; i++) + { + 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; + } + } +} + +/* + * 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, + bool firstCandidateOnly, + bool *clusteredCandidatesExhausted) +{ + 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, + firstCandidateOnly, + clusteredCandidatesExhausted); + + index_close(indexRelation, AccessShareLock); + + return ntargets; +} + /* * Read in a buffer in mode, using bulk-insert strategy if bistate isn't NULL. */ @@ -488,6 +1357,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 @@ -498,6 +1373,8 @@ 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, @@ -507,11 +1384,19 @@ RelationGetBufferForTuple(Relation relation, Size len, Buffer buffer = InvalidBuffer; Page page; Size nearlyEmptyFreeSpace, + clusteredTargetFreeSpace = 0, 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 usingClusteredOverflowTarget = false; + bool clusteredCandidatesExhausted = false; + bool usingPreferredBlock = false; bool unlockedTargetBuffer; bool recheckVmPins; @@ -549,6 +1434,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); @@ -568,7 +1456,72 @@ 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; + + /* + * 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) + { + if (BlockNumberIsValid(preferredBlock)) + { + clusteredTargetBlocks[0] = preferredBlock; + nclusteredTargetBlocks = 1; + targetBlock = preferredBlock; + usingPreferredBlock = true; + } + else if (tuple != NULL) + { + if (ClusteredWriteGetCachedOverflowTarget(relation, tuple, + &targetBlock)) + 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); + + if (nblocks > 0) + { + targetBlock = nblocks - 1; + usingClusteredOverflowTarget = true; + ClusteredWriteRememberOverflowTarget(relation, tuple, + targetBlock); + } + } + } + } + usingClusteredTarget = (targetBlock != InvalidBlockNumber); + if (usingClusteredOverflowTarget) + usingClusteredTarget = false; + } + + 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,10 +1651,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 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) + ClusteredWriteRememberReserveUse(relation, tuple, + nblocks - 1); + } return buffer; } @@ -721,6 +1693,99 @@ RelationGetBufferForTuple(Relation relation, Size len, else LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + if (usingClusteredTarget) + { + BlockNumber attemptedBlock = targetBlock; + + if (use_fsm) + RecordPageWithFreeSpace(relation, targetBlock, pageFreeSpace); + + clusteredTargetIndex++; + if (clusteredTargetIndex < nclusteredTargetBlocks) + { + targetBlock = clusteredTargetBlocks[clusteredTargetIndex]; + continue; + } + + if (usingPreferredBlock && tuple != NULL) + { + usingPreferredBlock = false; + nclusteredTargetBlocks = + RelationGetClusteredTargetBlocksForTuple(relation, tuple, + clusteredTargetFreeSpace, + clusteredTargetBlocks, + lengthof(clusteredTargetBlocks), + false, + &clusteredCandidatesExhausted); + for (clusteredTargetIndex = 0; + clusteredTargetIndex < nclusteredTargetBlocks; + clusteredTargetIndex++) + { + targetBlock = clusteredTargetBlocks[clusteredTargetIndex]; + if (targetBlock != attemptedBlock) + break; + } + 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; + 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 + { + 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); + break; + } + /* Is there an ongoing bulk extension? */ if (bistate && bistate->next_free != InvalidBlockNumber) { @@ -736,6 +1801,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 +1821,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; } } @@ -879,6 +1949,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, tuple, targetBlock); return buffer; } diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c index 9d162957bc35a..d3ef651a63b11 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..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) @@ -4835,6 +4837,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 +4879,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 +4934,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 +5082,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 +5330,7 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind) List *newindexoidlist; Oid relpkindex; Oid relreplindex; + Oid relclusteredindex; ListCell *l; MemoryContext oldcxt; @@ -5340,14 +5369,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 +5510,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 +6529,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..16b658d730173 100644 --- a/src/include/access/hio.h +++ b/src/include/access/hio.h @@ -53,7 +53,19 @@ 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, + Size len, + BlockNumber *targetBlocks, + int maxTargetBlocks, + bool firstCandidateOnly, + bool *clusteredCandidatesExhausted); extern Buffer RelationGetBufferForTuple(Relation relation, Size len, + HeapTuple tuple, + BlockNumber preferredBlock, 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 0317f2f801e08..fbe054540ac6e 100644 --- a/src/test/regress/expected/cluster.out +++ b/src/test/regress/expected/cluster.out @@ -306,10 +306,199 @@ WHERE pg_class.oid=indexrelid --------- (0 rows) +-- 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 +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 <= 400) + AS used_clustered_key_reserve +FROM clstr_write_btree AS new_row +WHERE id BETWEEN 1001 AND 1009 +ORDER BY id; + id | used_clustered_key_reserve +------+---------------------------- + 1001 | t + 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. +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 +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 <= 400) + AS used_clustered_key_reserve +FROM clstr_write_text_overflow AS new_row +WHERE id BETWEEN 1001 AND 1009 +ORDER BY id; + id | used_clustered_key_reserve +------+---------------------------- + 1001 | t + 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. +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 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 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, + 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 +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) 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 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); @@ -857,4 +1046,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 c2f329ecd1bbc..d5779f8fa2857 100644 --- a/src/test/regress/sql/cluster.sql +++ b/src/test/regress/sql/cluster.sql @@ -103,11 +103,213 @@ WHERE pg_class.oid=indexrelid AND pg_class_2.relname = 'clstr_tst' AND indisclustered; +-- 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 +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 <= 400) + AS used_clustered_key_reserve +FROM clstr_write_btree AS new_row +WHERE id BETWEEN 1001 AND 1009 +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 +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 <= 400) + AS used_clustered_key_reserve +FROM clstr_write_text_overflow AS new_row +WHERE id BETWEEN 1001 AND 1009 +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 +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 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 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, + 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 +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) 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 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); @@ -437,4 +639,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..ad9d8af4b6e0f --- /dev/null +++ b/src/tools/clustered_write_bench/README @@ -0,0 +1,1149 @@ +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 + +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 + +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. + +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 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 `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 +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`. +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 `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 +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 +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. 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. 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. +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. +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 +`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 +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`). 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 +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. +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. +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/`. +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 +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/`. + +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: +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/`. +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` +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/`. + +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 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. + +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 +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. +`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. + +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. 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: + +* loads a synthetic table keyed by an OSM-like object id and a tile-like + 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`; +* 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. + +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 +------------------------ + +`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). +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: + +* `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. +* `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 + `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 +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. 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 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 | +| patched_clustered_import, generated-preserving rewrite by key | 1:36.92 | 2:29.90 | 2829 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 | +| patched_clustered_import, no heap fillfactor reserve | 332.595 ms | 631.841 ms | 972.157 ms | 98.546 ms | 1576.143 ms | 6123.307 ms | 13 / 15394 | 16 / 2207 | 10 / 20402 | +| patched_clustered_import, heap fillfactor 90 | 226.063 ms | 619.446 ms | 1040.034 ms | 135.411 ms | 1716.965 ms | 6396.165 ms | 9 / 15396.5 | 13 / 2212 | 9 / 20314.5 | +| patched_clustered_import, generated-preserving rewrite by key | 208.438 ms | 666.939 ms | 1336.187 ms | 266.315 ms | 4152.334 ms | 7671.920 ms | 4 / 2362.25 | 4 / 1097 | 9 / 1289 | + +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 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 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 the heap-span +diagnosis, but the first `CREATE TABLE AS SELECT *` prototype silently turned +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 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 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 +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: +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/`. + +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/`. + +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 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/`. + +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 +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 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 +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. + +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 + 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. +* **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. +* **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 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 + 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. +* **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. +* **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. +* **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. +* **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. +* **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. +* **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, + 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 + column, so later append rows would stop computing `osm2pgsql_cluster_key`. + 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. + 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. +* **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). +* **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. +* **Per-row scans of already-exhausted hot prefixes:** fixed with a small + 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` integer clustered insert from `1194.27 ms` / `1079.02 ms` at + 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` + 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. +* **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/`. +* **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/`. +* **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/`. +* **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/`. +* **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/`. +* **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/`. +* **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 + 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 + 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 + 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. +* **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/`. +* **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/`. +* **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/`. +* **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/`. +* **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/`. +* **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/`. +* **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 + (`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/`. +* **Skipping clustered probes for singleton prefixes in a batch:** rejected. + 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 +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; +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: + +* `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)`. +* `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. +* `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. +* `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 + 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; `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. +* `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. +* `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_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_cluster_during_import.patch b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch new file mode 100644 index 0000000000000..75ffb74427da8 --- /dev/null +++ b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch @@ -0,0 +1,463 @@ +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 +@@ -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 " ++ "loading rows and skip the final geometry sort.") ++ ->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/db-copy.cpp b/src/db-copy.cpp +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()() + } + + 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()) { ++ delete_from_ordered_copy_table(cmd); ++ } + } + + 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,100 @@ 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) ++{ ++ if (has_ordered_copy_table(target)) { ++ 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); ++} ++ ++bool db_copy_thread_t::thread_t::has_ordered_copy_table( ++ std::shared_ptr const &target) const ++{ ++ for (auto const &known : m_ordered_copy_targets) { ++ if (target->same_copy_target(*known)) { ++ return true; ++ } ++ } ++ 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); ++ } ++} ++ ++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); ++ } ++} ++ ++void db_copy_thread_t::thread_t::drain_ordered_copy_table( ++ std::shared_ptr const &target) ++{ ++ if (!has_ordered_copy_table(target)) { ++ 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..95e85d7 100644 +--- a/src/db-copy.hpp ++++ b/src/db-copy.hpp +@@ -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; } ++ 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; ++ } ++ 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); } ++ 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); ++ } ++ 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 +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_delete_index_by == ++ other.m_ordered_copy_delete_index_by); + } + + 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; ++ /// 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; ++ /// Optional index columns for fast DELETEs from a staged COPY table. ++ std::string m_ordered_copy_delete_index_by; + }; + + /** +@@ -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,21 @@ 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 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( ++ 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 +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; +diff --git a/src/options.hpp b/src/options.hpp +index 4f56bfb..4cc115b 100644 +--- a/src/options.hpp ++++ b/src/options.hpp +@@ -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 +@@ -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..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, + 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) + : 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) + { + // if we dont have any columns +@@ -39,13 +40,23 @@ 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); ++ if (m_append) { ++ m_target->set_ordered_copy_delete_index_by(m_target->id()); ++ } ++ } + } + + 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), + m_copy(copy_thread) + { +@@ -118,12 +129,28 @@ 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); ++ ++ 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 += " )"; + + // 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; + +@@ -190,17 +217,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_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); ++ } 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()); ++ } + + 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 +@@ -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); + + 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_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql new file mode 100644 index 0000000000000..f28a7ac17ba25 --- /dev/null +++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql @@ -0,0 +1,667 @@ +\set ON_ERROR_STOP on + +\if :{?scale} +\else +\set scale 1 +\endif + +\if :{?use_brin} +\else +\set use_brin false +\endif + +\if :{?single_key_cluster} +\else +\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 +\endif + +\if :{?hot_tile_count} +\else +\set hot_tile_count 1 +\endif + +\if :{?hot_update_fraction} +\else +\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 +\endif + +\if :{?order_diff_by_cluster_key} +\else +\set order_diff_by_cluster_key false +\endif + +\if :{?copy_diff_from_file} +\else +\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' +\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, + (:'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, + (: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, + (:'updates_before_inserts')::boolean as updates_before_inserts; + +create unlogged table clustered_write_osm_diff_on +( + osm_id bigint primary key, + tile_id int not null, + 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 = :heap_fillfactor); + +insert into clustered_write_osm_diff_on (osm_id, tile_id, version, payload) +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; + +\if :text_cluster_key +create index clustered_write_osm_diff_tile_idx + 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; + +create unlogged table clustered_write_osm_diff_off +( + osm_id bigint primary key, + tile_id int not null, + 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 = :heap_fillfactor); + +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 :text_cluster_key +create index clustered_write_osm_diff_off_tile_idx + 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; +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 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, + (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, + (:'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, + (: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, + (:'updates_before_inserts')::boolean as updates_before_inserts; + +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, + tile_id, + 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 + +union all + +select 'without_cluster_metadata'::text as variant, + tile_id, + 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; + +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 ((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, + 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 ( + 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 + +\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', 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 +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', 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 +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); + +\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, + 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 +\endif + +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); + +\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, + 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 +\endif + +update clustered_write_step_timings +set finished_at = clock_timestamp() +where step = 'without_cluster_metadata_insert'; + +\if :updates_before_inserts +\else +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', 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 +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', 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 +set finished_at = clock_timestamp() +where step = 'without_cluster_metadata_update'; +\endif + +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'; + +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, + 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 +join clustered_write_settings as s on true +order by t.step; + +with measured as +( + select 'clustered_write'::text as variant, + 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 + where o.osm_id > s.base_rows + + union all + + select 'clustered_write'::text as variant, + 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 + where o.osm_id <= s.base_rows + and o.version > 1 + + union all + + select 'without_cluster_metadata'::text as variant, + 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 + where o.osm_id > s.base_rows + + union all + + select 'without_cluster_metadata'::text as variant, + 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 + 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 s.brin_enabled, + s.text_cluster_key, + s.heap_fillfactor, + s.copy_diff_from_file, + variant, + 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, + 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, + 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, + 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/osm2pgsql_georgia_read.sql b/src/tools/clustered_write_bench/osm2pgsql_georgia_read.sql new file mode 100644 index 0000000000000..2b8355deeef5d --- /dev/null +++ b/src/tools/clustered_write_bench/osm2pgsql_georgia_read.sql @@ -0,0 +1,135 @@ +\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; + +\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; 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..5c44c8f091101 --- /dev/null +++ b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh @@ -0,0 +1,446 @@ +#!/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}" +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}" +KEEP_PGDATA="${KEEP_PGDATA:-false}" +PG_LOG_MIN_DURATION_MS="${PG_LOG_MIN_DURATION_MS:-}" + +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" + 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" + printf '%s\n' "$out_file" + return + fi + + log "simplifying daily diff for osm2pgsql append" + 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" +} + +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 + return + fi + + mkdir -p "$pg_home/share/extension" "$pg_home/lib" + 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 + 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 +} + +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" + 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" <>"$data_dir/postgresql.conf" </dev/null 2>&1 || true + if [[ -n "$port" ]]; then + rm -rf "${TMPDIR:-/tmp}/clustered-write-osm2pgsql-$port" + 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" + 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_read_benchmarks() +{ + local name="$1" + local pg_bin="$2" + local port="$3" + local read_run + local out_prefix + + if (( READ_REPEATS <= 0 )); then + log "skipping read benchmark for $name (READ_REPEATS=$READ_REPEATS)" + return + fi + + 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" + 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 "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;' + + 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" + + 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_args[@]}" "$osc" \ + >"$WORKDIR/logs/$name-append.stdout" \ + 2>"$WORKDIR/logs/$name-append.stderr" + + 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;" \ + >"$WORKDIR/logs/$name-size.sqlout" + + 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 'pg_work_mem: %s\n' "$PG_WORK_MEM" + 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 'pg_log_min_duration_ms: %s\n' "${PG_LOG_MIN_DURATION_MS:-off}" + 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 +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" +log " pg_work_mem=$PG_WORK_MEM" +log " read_repeats=$READ_REPEATS" +log " compress_logs=$COMPRESS_LOGS" +log " keep_pgdata=$KEEP_PGDATA" +log " pg_log_min_duration_ms=${PG_LOG_MIN_DURATION_MS:-off}" + +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" +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" 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..1210d13bd4871 --- /dev/null +++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh @@ -0,0 +1,319 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/../../.." && 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"} +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"} +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"} +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} + +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 '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" + 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: ' + 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 + exit 1 + fi + + PGDATA="$OUTDIR/pgdata" + PGHOST=$(mktemp -d "${TMPDIR:-/tmp}/clustered-write-bench-socket.XXXXXX") + export PGHOST PGPORT + 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 + if [[ "$KEEP_TEMP_INSTANCE_DATA" != "true" ]]; then + rm -rf "$PGDATA" + fi + rm -rf "$PGHOST" + } + 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" +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\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 + for single_key in $SINGLE_KEY_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 hot_tile_count in $HOT_TILE_COUNT_VALUES; do + for hot_update_fraction in $HOT_UPDATE_FRACTION_VALUES; do + 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 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" \ + -v diff_copy_path="$diff_copy_path" \ + -d "$DBNAME" >"$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" || + $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 + done + done + done + done + done + done + done + done +done + +awk -F'\t' ' + BEGIN { + OFS = "\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", + "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 OFS $12 OFS $13 OFS $14 + sum[key] += $15 + count[key]++ + 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) { + 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\t%.2f\n", + key, count[key], sum[key] / count[key], median[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 -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" + +awk -F'\t' ' + BEGIN { + OFS = "\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", "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 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\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,10n -k11,11 -k12,12 -k13,13 -k14,14 +} >"$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" +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