From c5a2932ae70d3bf08fbafb38359adec2cf695f67 Mon Sep 17 00:00:00 2001 From: Dmitrii Dolgov <9erthalion6@gmail.com> Date: Mon, 27 Apr 2026 16:54:36 +0200 Subject: [PATCH v3 3/3] Randomize nbtree split location to avoid oscillating patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The way nbtree page split works can lead to the same split location chosen over and over under certain workloads, usually as long as the data to be ingested follows the same distribution as already existing data. According to [1] (and some one-off experiments) this could lead to the number of splits following an oscillating pattern, which in turn means variability in performance. The intuition behind it is that same split location across many pages lead to same space utilization, which in turn means that at some intervals lots of index pages have to be split at once. A possible workaround is to find some number of equally good split locations, and choose one of them at random. This brings some randomization, while keeping the suffix truncation benefits. The whitepaper mentioned above recommends range of 20%, so we stick with this value as the number of split location to search for. [1]: Glombiewski N., Seeger B., Graefe G. (2019). Waves of Misery After Index Creation. BTW 2019. Gesellschaft für Informatik. doi:10.18420/btw2019-06 Reviewed-by: Peter Geoghegan Reviewed-by: Andrey Borodin --- src/backend/access/nbtree/nbtsplitloc.c | 91 +++++++++++++++++-- src/test/modules/nbtree/Makefile | 7 +- .../expected/nbtree_split_randomization.out | 42 +++++++++ .../nbtree/sql/nbtree_split_randomization.sql | 43 +++++++++ 4 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 src/test/modules/nbtree/expected/nbtree_split_randomization.out create mode 100644 src/test/modules/nbtree/sql/nbtree_split_randomization.sql diff --git a/src/backend/access/nbtree/nbtsplitloc.c b/src/backend/access/nbtree/nbtsplitloc.c index c64fef5ab27..0baec6d767c 100644 --- a/src/backend/access/nbtree/nbtsplitloc.c +++ b/src/backend/access/nbtree/nbtsplitloc.c @@ -18,6 +18,7 @@ #include "access/tableam.h" #include "common/int.h" #include "pg_trace.h" +#include "common/pg_prng.h" typedef enum { @@ -778,7 +779,8 @@ _bt_adjacenthtid(const ItemPointerData *lowhtid, const ItemPointerData *highhtid * points that fall within current/final split interval. Penalty is an * abstract score, with a definition that varies depending on whether we're * splitting a leaf page or an internal page. See _bt_split_penalty() for - * details. + * details. If there are multiple equally good split points, pick up one at + * random to spread the choice. * * "perfectpenalty" is assumed to be the lowest possible penalty among * candidate split points. This allows us to return early without wasting @@ -801,20 +803,89 @@ _bt_bestsplitloc(FindSplitData *state, int perfectpenalty, bestpenalty = INT_MAX; lowsplit = 0; - for (int i = lowsplit; i < highsplit; i++) - { - int penalty; - penalty = _bt_split_penalty(state, state->splits + i); + /* + * There are workloads, where we would find the same best split location + * over and over, even with the suffix truncation introducing some + * variability. According to [1] this leads to the number of splits + * following oscillating pattern, due to the fact that lots of pages will + * have similar space utilization and at some point have to be split all at + * once. The easiest workaround is to introduce some randomness in chosing + * split location, thus making space utilization less uniform across the + * tree. + * + * To achieve that we pick up a split point at random among the list of + * equally good ones using reservoir sampling as described in [2]. + * + * This optimization is applied only in case of SPLIT_DEFAULT strategy, + * as it probably doesn't make sense for others. + * + * [1]: Glombiewski N., Seeger B., Graefe G. (2019). Waves of Misery After + * Index Creation. BTW 2019. Gesellschaft für Informatik. doi:10.18420/btw2019-06 + * + * [2]: Vitter, J. S. (1985). Random sampling with a reservoir. ACM + * Transactions on Mathematical Software (TOMS), 11(1), 37-57. + */ + if (strategy == SPLIT_DEFAULT) + { + int matches = 0; - if (penalty < bestpenalty) + for (int i = lowsplit; i < highsplit; i++) { - bestpenalty = penalty; - lowsplit = i; + int penalty; + + penalty = _bt_split_penalty(state, state->splits + i); + + /* lowsplit is going to be our reservoir with one element in it */ + if (penalty == bestpenalty && + pg_prng_uint64_range(&pg_global_prng_state, 0, matches) == 0) + lowsplit = i; + else if (penalty < bestpenalty) + { + /* + * If we found better penalty than before, reset everything and + * start from scratch + */ + bestpenalty = penalty; + lowsplit = i; + matches = 1; + } + + /* + * Break loop condition for randomization case. We search either + * until all the split points are evaluated, or we've collected 20% + * of all possible locations in the list of equally good split + * points at perfect penalty. + * + * Note that having reached a perfect penalty is mandatory here, as + * we cannot guarantee that there is a better penalty beyond those + * 20%. + */ + if (matches > state->nsplits * 0.2 && bestpenalty == perfectpenalty) + break; } + } + else + { + /* + * Any other strategy does not use randomization. Instead search for + * the first best split location. + */ + for (int i = lowsplit; i < highsplit; i++) + { + int penalty; + + penalty = _bt_split_penalty(state, state->splits + i); + + if (penalty < bestpenalty) + { + bestpenalty = penalty; + lowsplit = i; + } - if (penalty <= perfectpenalty) - break; + if (penalty <= perfectpenalty) + break; + } } final = &state->splits[lowsplit]; diff --git a/src/test/modules/nbtree/Makefile b/src/test/modules/nbtree/Makefile index 20a1ca6a92b..1d4df85805c 100644 --- a/src/test/modules/nbtree/Makefile +++ b/src/test/modules/nbtree/Makefile @@ -1,9 +1,12 @@ # src/test/modules/nbtree/Makefile -EXTRA_INSTALL = src/test/modules/injection_points contrib/amcheck +EXTRA_INSTALL = src/test/modules/injection_points \ + contrib/amcheck \ + contrib/pageinspect REGRESS = nbtree_half_dead_pages \ - nbtree_incomplete_splits + nbtree_incomplete_splits \ + nbtree_split_randomization ISOLATION = backwards-scan-concurrent-splits \ predicate-empty-index diff --git a/src/test/modules/nbtree/expected/nbtree_split_randomization.out b/src/test/modules/nbtree/expected/nbtree_split_randomization.out new file mode 100644 index 00000000000..e9ded02b4bb --- /dev/null +++ b/src/test/modules/nbtree/expected/nbtree_split_randomization.out @@ -0,0 +1,42 @@ +-- +-- Test split location randomization B-tree indexes. +-- +set client_min_messages TO 'warning'; +create extension if not exists pageinspect; +reset client_min_messages; +create table nbtree_split_random(id int) with (autovacuum_enabled = off); +create index nbtree_split_random_id_idx on nbtree_split_random using btree (id); +insert into nbtree_split_random SELECT g from generate_series(1, 150000) g; +-- The idea is to verify that free space across index pages is more variable +-- than without randomization. For that build a histogram of free space and +-- check how many buckets we end up with: +-- +-- * Without randomization the insert above usually produces only two buckets, +-- one after filtering out buckets with only one value in it. +-- +-- * With randomization there are usually about six buckets after filtering out +-- small ones. +-- +-- For the test we define lowest number of buckers to be three as an empirical +-- value, which differentiates two cases. +with range as ( + select + min(free_size) as min, + max(free_size) as max + from bt_multi_page_stats('nbtree_split_random_id_idx', 1, -1) +), hist as ( + select width_bucket( + free_size::float / page_size::float, + min::float / page_size::float, + max::float / page_size::float, 100 + ) as buckets, count(*) + from bt_multi_page_stats('nbtree_split_random_id_idx', 1, -1), range + group by buckets having count(*) > 1 order by buckets +) +select count(*) > 2 from hist; + ?column? +---------- + t +(1 row) + +drop extension pageinspect; diff --git a/src/test/modules/nbtree/sql/nbtree_split_randomization.sql b/src/test/modules/nbtree/sql/nbtree_split_randomization.sql new file mode 100644 index 00000000000..5d5b6d91125 --- /dev/null +++ b/src/test/modules/nbtree/sql/nbtree_split_randomization.sql @@ -0,0 +1,43 @@ +-- +-- Test split location randomization B-tree indexes. +-- + +set client_min_messages TO 'warning'; +create extension if not exists pageinspect; +reset client_min_messages; + +create table nbtree_split_random(id int) with (autovacuum_enabled = off); + +create index nbtree_split_random_id_idx on nbtree_split_random using btree (id); + +insert into nbtree_split_random SELECT g from generate_series(1, 150000) g; + +-- The idea is to verify that free space across index pages is more variable +-- than without randomization. For that build a histogram of free space and +-- check how many buckets we end up with: +-- +-- * Without randomization the insert above usually produces only two buckets, +-- one after filtering out buckets with only one value in it. +-- +-- * With randomization there are usually about six buckets after filtering out +-- small ones. +-- +-- For the test we define lowest number of buckers to be three as an empirical +-- value, which differentiates two cases. +with range as ( + select + min(free_size) as min, + max(free_size) as max + from bt_multi_page_stats('nbtree_split_random_id_idx', 1, -1) +), hist as ( + select width_bucket( + free_size::float / page_size::float, + min::float / page_size::float, + max::float / page_size::float, 100 + ) as buckets, count(*) + from bt_multi_page_stats('nbtree_split_random_id_idx', 1, -1), range + group by buckets having count(*) > 1 order by buckets +) +select count(*) > 2 from hist; + +drop extension pageinspect; -- 2.55.0