From 9287c3ffea38901654c33a3c20dfb90436df770e Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Thu, 29 Oct 2026 08:50:53 -0400 Subject: [PATCH v2 3/3] Experimental: copy bounded-sort survivors into a fresh context At each quickselect prune, copy the bound survivors into a spare tuple context and reset the old one, instead of pfree'ing each discarded tuple. The reset drops the losers and every tuple the admission check freed since the last prune, and leaves the survivors packed together. Only the MinimalTuple and Datum variants, the only ones used by bounded sorts, provide the new tuplelen callback that this needs; others keep the pfree path. datum1 is re-derived with removeabbrev after the move, which is valid because bounded sorts never abbreviate. This is a step toward a bump context for bounded sorts, which would also need the rejected tuples not to be copied in the first place. debug_topn_context_swap turns it off for A/B testing. --- src/backend/utils/misc/guc_parameters.dat | 7 +++ src/backend/utils/sort/tuplesort.c | 69 +++++++++++++++++++++- src/backend/utils/sort/tuplesortvariants.c | 18 ++++++ src/include/utils/guc.h | 1 + src/include/utils/tuplesort.h | 8 +++ 5 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index 756d19a6680..c5d99a4e993 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -731,6 +731,13 @@ ifdef => 'DEBUG_NODE_TESTS_ENABLED', }, +{ name => 'debug_topn_context_swap', type => 'bool', context => 'PGC_USERSET', group => 'DEVELOPER_OPTIONS', + short_desc => 'Copies bounded-sort survivors into a fresh memory context at each quickselect prune.', + flags => 'GUC_NOT_IN_SAMPLE', + variable => 'debug_topn_context_swap', + boot_val => 'true', +}, + { name => 'debug_topn_quickselect', type => 'bool', context => 'PGC_USERSET', group => 'DEVELOPER_OPTIONS', short_desc => 'Uses quickselect instead of a heap for large bounded (top-N) sorts.', flags => 'GUC_NOT_IN_SAMPLE', diff --git a/src/backend/utils/sort/tuplesort.c b/src/backend/utils/sort/tuplesort.c index 7de34da217a..4f862a5fecc 100644 --- a/src/backend/utils/sort/tuplesort.c +++ b/src/backend/utils/sort/tuplesort.c @@ -132,6 +132,7 @@ StaticAssertDecl(TOPN_SELECT_MIN_BUFFER < INITIAL_MEMTUPSIZE, /* GUC variables */ bool trace_sort = false; bool debug_topn_quickselect = true; +bool debug_topn_context_swap = true; #ifdef DEBUG_BOUNDED_SORT bool optimize_bounded_sort = true; @@ -201,6 +202,8 @@ struct Tuplesortstate bool bounded; /* did caller specify a maximum number of * tuples to return? */ bool boundUsed; /* true if we made use of a bounded heap */ + MemoryContext sparetuplecontext; /* bounded quickselect copies the + * survivors here at each prune */ int bound; /* if bounded, the maximum number of tuples */ int64 tupleMem; /* memory consumed by individual tuples. * storing this separately from what we track @@ -473,6 +476,10 @@ static void dumptuples(Tuplesortstate *state, bool alltuples); static void make_bounded_heap(Tuplesortstate *state); static void sort_bounded_heap(Tuplesortstate *state); static void prune_bounded_select(Tuplesortstate *state); +static void prune_bounded_select_swap(Tuplesortstate *state); +#define PRUNE_BOUNDED(state) \ + ((state)->sparetuplecontext ? prune_bounded_select_swap(state) : \ + prune_bounded_select(state)) static void tuplesort_sort_memtuples(Tuplesortstate *state); static void tuplesort_heap_insert(Tuplesortstate *state, SortTuple *tuple); static void tuplesort_heap_replace_top(Tuplesortstate *state, SortTuple *tuple); @@ -693,6 +700,7 @@ tuplesort_begin_batch(Tuplesortstate *state) state->status = TSS_INITIAL; state->bounded = false; state->boundUsed = false; + state->sparetuplecontext = NULL; state->availMem = state->allowedMem; @@ -1160,7 +1168,12 @@ tuplesort_puttuple_common(Tuplesortstate *state, SortTuple *tuple, elog(LOG, "switching to bounded quickselect at %d tuples: %s", state->memtupcount, pg_rusage_show(&state->ru_start)); - prune_bounded_select(state); + if (debug_topn_context_swap && state->base.tuplelen != NULL) + state->sparetuplecontext = + AllocSetContextCreate(state->base.sortcontext, + "Caller tuples", + ALLOCSET_DEFAULT_SIZES); + PRUNE_BOUNDED(state); state->status = TSS_BOUNDED_SELECT; MemoryContextSwitchTo(oldcontext); return; @@ -1235,7 +1248,7 @@ tuplesort_puttuple_common(Tuplesortstate *state, SortTuple *tuple, Assert(state->memtupcount < state->memtupsize); state->memtuples[state->memtupcount++] = *tuple; if (state->memtupcount > Max(state->bound * 2, TOPN_SELECT_MIN_BUFFER)) - prune_bounded_select(state); + PRUNE_BOUNDED(state); else if (LACKMEM(state) && state->memtupcount > state->bound * 2) { /* @@ -1243,7 +1256,7 @@ tuplesort_puttuple_common(Tuplesortstate *state, SortTuple *tuple, * tuples arrived since the last prune, so pruning early still * costs O(1) per tuple. */ - prune_bounded_select(state); + PRUNE_BOUNDED(state); } else if (LACKMEM(state)) { @@ -2766,6 +2779,56 @@ prune_bounded_select(Tuplesortstate *state) state->memtupcount = state->bound; } +/* + * Keep the bound smallest memtuples like prune_bounded_select(), but instead + * of freeing each discarded tuple, copy the survivors into the spare tuple + * context and reset the old one. That drops the losers, and every tuple the + * admission check freed since the last prune, in one step, and leaves the + * survivors packed together. + */ +static void +prune_bounded_select_swap(Tuplesortstate *state) +{ + MemoryContext from = state->base.tuplecontext; + MemoryContext to = state->sparetuplecontext; + + Assert(state->base.tuplelen != NULL && to != NULL); + + tuplesort_select(state, state->memtupcount, state->bound - 1); + + for (int i = 0; i < state->memtupcount; i++) + { + SortTuple *stup = &state->memtuples[i]; + char *old = stup->tuple; + char *new; + Size len; + + if (old == NULL) + continue; + FREEMEM(state, GetMemoryChunkSpace(old)); + if (i >= state->bound) + continue; + + len = state->base.tuplelen(state, stup); + new = MemoryContextAlloc(to, len); + memcpy(new, old, len); + USEMEM(state, GetMemoryChunkSpace(new)); + stup->tuple = new; + + /* + * datum1 may point into the old copy. Bounded sorts never abbreviate + * (see tuplesort_set_bound), so removeabbrev just re-derives it from + * the new copy. + */ + REMOVEABBREV(state, stup, 1); + } + state->memtupcount = state->bound; + + MemoryContextReset(from); + state->base.tuplecontext = to; + state->sparetuplecontext = from; +} + /* radix sort routines */ diff --git a/src/backend/utils/sort/tuplesortvariants.c b/src/backend/utils/sort/tuplesortvariants.c index c648c78455a..4c163086723 100644 --- a/src/backend/utils/sort/tuplesortvariants.c +++ b/src/backend/utils/sort/tuplesortvariants.c @@ -59,6 +59,8 @@ static int comparetup_heap(const SortTuple *a, const SortTuple *b, Tuplesortstate *state); static int comparetup_heap_tiebreak(const SortTuple *a, const SortTuple *b, Tuplesortstate *state); +static Size tuplelen_heap(Tuplesortstate *state, SortTuple *stup); +static Size tuplelen_datum(Tuplesortstate *state, SortTuple *stup); static void writetup_heap(Tuplesortstate *state, LogicalTape *tape, SortTuple *stup); static void readtup_heap(Tuplesortstate *state, SortTuple *stup, @@ -213,6 +215,7 @@ tuplesort_begin_heap(TupleDesc tupDesc, base->comparetup_tiebreak = comparetup_heap_tiebreak; base->writetup = writetup_heap; base->readtup = readtup_heap; + base->tuplelen = tuplelen_heap; base->haveDatum1 = true; base->arg = tupDesc; /* assume we need not copy tupDesc */ @@ -696,6 +699,7 @@ tuplesort_begin_datum(Oid datumType, Oid sortOperator, Oid sortCollation, base->comparetup_tiebreak = comparetup_datum_tiebreak; base->writetup = writetup_datum; base->readtup = readtup_datum; + base->tuplelen = tuplelen_datum; base->haveDatum1 = true; base->arg = arg; @@ -1296,6 +1300,12 @@ comparetup_heap_tiebreak(const SortTuple *a, const SortTuple *b, Tuplesortstate return 0; } +static Size +tuplelen_heap(Tuplesortstate *state, SortTuple *stup) +{ + return ((MinimalTuple) stup->tuple)->t_len; +} + static void writetup_heap(Tuplesortstate *state, LogicalTape *tape, SortTuple *stup) { @@ -2025,6 +2035,14 @@ comparetup_datum_tiebreak(const SortTuple *a, const SortTuple *b, Tuplesortstate return compare; } +static Size +tuplelen_datum(Tuplesortstate *state, SortTuple *stup) +{ + TuplesortDatumArg *arg = (TuplesortDatumArg *) TuplesortstateGetPublic(state)->arg; + + return datumGetSize(PointerGetDatum(stup->tuple), false, arg->datumTypeLen); +} + static void writetup_datum(Tuplesortstate *state, LogicalTape *tape, SortTuple *stup) { diff --git a/src/include/utils/guc.h b/src/include/utils/guc.h index 13171f97c3c..453ea182924 100644 --- a/src/include/utils/guc.h +++ b/src/include/utils/guc.h @@ -328,6 +328,7 @@ extern PGDLLIMPORT char *role_string; extern PGDLLIMPORT bool in_hot_standby_guc; extern PGDLLIMPORT bool trace_sort; extern PGDLLIMPORT bool debug_topn_quickselect; +extern PGDLLIMPORT bool debug_topn_context_swap; #ifdef DEBUG_BOUNDED_SORT extern PGDLLIMPORT bool optimize_bounded_sort; diff --git a/src/include/utils/tuplesort.h b/src/include/utils/tuplesort.h index da68f45acf2..14629fbc593 100644 --- a/src/include/utils/tuplesort.h +++ b/src/include/utils/tuplesort.h @@ -179,6 +179,14 @@ typedef struct */ void (*freestate) (Tuplesortstate *state); + /* + * Optional: size in bytes of the self-contained allocation holding + * stup->tuple, so a bounded sort can copy survivors into a fresh memory + * context instead of freeing each discarded tuple. NULL if the variant's + * tuples cannot be moved with a plain memcpy. + */ + Size (*tuplelen) (Tuplesortstate *state, SortTuple *stup); + /* * The subsequent fields are used in the implementations of the functions * above. -- 2.54.0