@@ -57,9 +57,11 @@ EXPLAIN (COSTS OFF) SELECT * FROM places WHERE geom && ST_MakeEnvelope(0,0,10,10
5757 for range types (` range_merge(range_agg(col)) ` ) or the bounding box for PostGIS geometry
5858 (` ST_Extent(col) ` ).
5959- ** Planning.** For each partition the planner builds, a ` set_rel_pathlist_hook ` reads the
60- summary from that partition's index (cached for the plan) and evaluates the partition's
61- restriction clauses against it, calling ` mark_dummy_rel ` on any partition that provably
62- cannot match — eliminating it before child paths are generated.
60+ summary from that partition's index and evaluates the partition's restriction clauses
61+ against it, calling ` mark_dummy_rel ` on any partition that provably cannot match —
62+ eliminating it before child paths are generated. Deserialized summaries are cached for
63+ the life of the backend (kept coherent by a relcache-invalidation callback), so warm
64+ plans skip the per-partition page read; see [ Performance] ( #performance ) .
6365- ** Typed comparisons.** Min/max vs. constant comparisons use each column type's own
6466 btree compare function, so ** any btree-comparable type works** : ` bigint ` / ` int ` /
6567 ` smallint ` , ` numeric ` , ` real ` / ` double precision ` , ` text ` / ` varchar ` , ` date ` ,
@@ -80,27 +82,26 @@ EXPLAIN (COSTS OFF) SELECT * FROM places WHERE geom && ST_MakeEnvelope(0,0,10,10
8082
8183## Performance
8284
83- The deal is simple and worth stating plainly: ** table_range trades more planning time for
84- much less execution time. ** A selective predicate on a non-key column scans only the
85- matching partition instead of every partition, which is a huge execution win — but the
86- planner pays to evaluate each partition's summary, so planning gets slower .
85+ ** table_range trades a small amount of planning time for a large execution win. ** A
86+ selective predicate on a non-key column scans only the matching partition instead of every
87+ partition. The planner pays a little to evaluate each partition's summary, but that cost is
88+ small and — warm — close to free (see the cache note below) .
8789
8890The numbers below are reproducible with ` bench/benchmark.sql ` (` cargo pgrx run pg18 ` , then
8991` \i bench/benchmark.sql ` ); they report ` EXPLAIN (ANALYZE) ` planning and execution time
90- separately, warm.
92+ separately, warm, on PostgreSQL 18 .
9193
9294** Faster execution.** 300 partitions × 8,000 rows (2.4M rows), `WHERE nk = <value in one
93- partition>`, PostgreSQL 18, warm :
95+ partition>`:
9496
9597| | Planning | Execution | Total |
9698| ---| ---| ---| ---|
97- | pruning ** off** (scans all 300 partitions) | ~ 3 ms | ~ 100 ms | ~ 103 ms |
98- | pruning ** on** (scans 1 partition) | ~ 12 ms | ~ 0.4 ms | ** ~ 12 ms** |
99+ | pruning ** off** (scans all 300 partitions) | ~ 4 ms | ~ 110 ms | ~ 114 ms |
100+ | pruning ** on** (scans 1 partition) | ~ 4 ms | ~ 0.4 ms | ** ~ 4 ms** |
99101
100- Planning is ~ 4× slower, execution is ~ 230× faster, and total time drops ~ 8×. The win
101- grows with how much data the eliminated partitions hold, and shrinks as partitions get
102- smaller — on tiny partitions the planning overhead can exceed the execution it saves, so
103- measure your workload with ` table_range.enable_pruning ` .
102+ Execution is ~ 250× faster, total time drops ~ 25×, and warm the planning overhead is in the
103+ noise. The win grows with how much data the eliminated partitions hold; measure your
104+ workload with ` table_range.enable_pruning ` .
104105
105106** Honest comparison to native pruning.** When a predicate is on the * partition key* ,
106107PostgreSQL prunes natively — and that path is in a different league, because it eliminates
@@ -110,16 +111,15 @@ natively) and `nk` (the same values, not the key, pruned by table_range):
110111
111112| Same ` = ` predicate, 2,000 partitions | Planning | Execution |
112113| ---| ---| ---|
113- | native pruning — column ** is** the partition key | ** ~ 0.1 ms** | ~ 0.02 ms |
114- | table_range — column is ** not** the partition key | ~ 80 ms | ~ 0.06 ms |
115- | no pruning — scans all 2,000 partitions | ~ 30 ms | ~ 26 ms |
114+ | native pruning — column ** is** the partition key | ** ~ 0.15 ms** | ~ 0.05 ms |
115+ | table_range — column is ** not** the partition key | ~ 34 ms | ~ 0.06 ms |
116+ | no pruning — scans all 2,000 partitions | ~ 28 ms | ~ 27 ms |
116117
117118Native pruning is * hundreds of times* cheaper to plan and is effectively constant in the
118119partition count. table_range cannot match that (see
119120[ Scaling] ( #scaling-and-partition-count ) ): its job is the case native pruning ** can't** do
120- — eliminating partitions by a non-key column. Against the realistic alternative for that
121- case (scanning every partition), it still wins on total time whenever the partitions are
122- sizeable.
121+ — eliminating partitions by a non-key column. Note that table_range's overhead over the
122+ no-pruning baseline (~ 28 ms to expand 2,000 partitions) is now small (~ 6 ms, ~ 3 µs/part).
123123
124124** Comparison to ` CHECK ` constraint exclusion.** The built-in way to prune on a non-key
125125column is to put a data-range ` CHECK (col BETWEEN lo AND hi) ` on each partition and let the
@@ -128,14 +128,15 @@ baseline. Same table, 2,000 partitions, same `nk = <value>` predicate:
128128
129129| Same ` = ` predicate, 2,000 partitions | Planning | Execution | Scans |
130130| ---| ---| ---| ---|
131- | ` CHECK ` constraint exclusion (` constraint_exclusion=on ` ) | ~ 32 ms | ~ 0.08 ms | 1 partition |
132- | table_range pruning | ~ 84 ms | ~ 0.08 ms | 1 partition |
133- | no pruning | ~ 22 ms | ~ 24 ms | all 2,000 |
131+ | ` CHECK ` constraint exclusion (` constraint_exclusion=on ` ) | ~ 37 ms | ~ 0.08 ms | 1 partition |
132+ | table_range pruning | ~ 34 ms | ~ 0.08 ms | 1 partition |
133+ | no pruning | ~ 26 ms | ~ 25 ms | all 2,000 |
134134
135- Both are O(partitions) and give the ** identical execution win** . Constraint exclusion plans
136- ~ 2.6× faster — it is C code testing an already-loaded ` CHECK ` expression (~ 5 µs/partition),
137- while table_range reads each partition's index page (~ 31 µs/partition). What table_range
138- buys for that extra planning cost is everything ` CHECK ` constraints make you give up:
135+ Both are O(partitions) and give the ** identical execution win** , and ** table_range now
136+ plans on par with — and warm, slightly faster than — constraint exclusion.** (Constraint
137+ exclusion re-parses each partition's ` CHECK ` expression on every plan; table_range serves
138+ warm plans from a cached summary, see below.) On top of matching the speed, table_range
139+ avoids everything ` CHECK ` constraints make you give up:
139140
140141- ** No manual management** — ` CREATE INDEX ` builds and owns the ranges; you don't compute
141142 and attach a constraint per partition and keep it correct.
@@ -144,14 +145,18 @@ buys for that extra planning cost is everything `CHECK` constraints make you giv
144145- ** Incremental maintenance** — changing a ` CHECK ` means ` DROP ` /` ADD CONSTRAINT ` with a
145146 full-partition revalidation scan; table_range widens in place in ` aminsert ` , no rescan.
146147
147- So table_range offers constraint-exclusion-class pruning without manual, enforced,
148- rescan-on-change constraints. Closing the ~ 2.6× planning gap (the per-partition index read)
149- is an active optimization target.
148+ ** How the per-partition cost got small.** Two optimizations took the per-partition planning
149+ cost from ~ 31 µs to ~ 3–4 µs:
150150
151- Each partition's summary is read from its own index page and cached for the duration of
152- one plan; the per-column compare function and the query constant are resolved once per
153- plan and reused across partitions (so the per-partition cost is a typed min/max compare,
154- not repeated catalog lookups).
151+ 1 . * Per-plan compilation.* The compare function, type-input function, and operator strategy
152+ are identical across a column's partitions, so they are resolved once per plan
153+ (cached ` FmgrInfo ` s) instead of re-looked-up for each partition.
154+ 2 . * Backend summary cache.* Each index's deserialized summary is cached for the life of the
155+ backend, so warm/repeated plans skip the per-partition index open and metapage
156+ read+deserialize entirely. The cache is kept coherent by a relcache invalidation
157+ callback: ` aminsert ` only ever * widens* a summary, and when it does it invalidates the
158+ cached copy everywhere — so a cached summary is never narrower than reality (a wider one
159+ prunes correctly). A cold first plan still reads each page; every plan after is cached.
155160
156161## Scaling and partition count
157162
@@ -172,10 +177,12 @@ Two practical consequences and how to handle them:
172177 ` max_locks_per_transaction ` (e.g. to a few thousand) and restart — it preallocates
173178 shared memory for the lock table, pushing the wall out in proportion.
174179- ** Planning time grows with partition count.** Even below the lock wall, planning scales
175- linearly. ** Mitigations:** prefer ** fewer, larger partitions** (table_range's sweet spot
176- — the execution win is biggest there anyway); use ** prepared statements** so a plan is
177- reused across executions and the planning cost is amortized; and where you can,
178- ** align the hot filter column with the partition key** so native pruning handles it.
180+ linearly — though the per-partition constant is now small (~ 3–4 µs warm, on par with
181+ ` CHECK ` constraint exclusion) thanks to the per-plan compilation and backend summary
182+ cache described above. ** Mitigations:** prefer ** fewer, larger partitions** (table_range's
183+ sweet spot — the execution win is biggest there anyway); use ** prepared statements** so a
184+ plan is reused across executions; and where you can, ** align the hot filter column with
185+ the partition key** so native pruning handles it.
179186
180187In short, table_range targets ** hundreds to a few thousand sizeable partitions with a
181188selective non-key predicate** . For tens of thousands of partitions, non-key pruning is not
@@ -215,7 +222,8 @@ metapage (block 0), written by `ambuild` and updated in place by `aminsert`, lik
215222| ` src/lib.rs ` | GUCs, ` _PG_init ` , test wiring |
216223| ` src/index_storage.rs ` | per-index summary on the metapage: page I/O (Generic WAL) + (de)serialization |
217224| ` src/summary_build.rs ` | build a leaf's summary by scanning its data (used by ` ambuild ` ) |
218- | ` src/prune_hook.rs ` | planner + pathlist hooks, per-plan cache, typed in-memory evaluation |
225+ | ` src/prune_hook.rs ` | planner + pathlist hooks, per-plan compilation cache, typed in-memory evaluation |
226+ | ` src/summary_cache.rs ` | backend-lifetime per-index summary cache + relcache-invalidation coherence |
219227| ` src/index_am.rs ` | ` table_range ` index AM: build, incremental ` aminsert ` widening, opclass provisioning |
220228| ` src/e2e_tests.rs ` , ` src/index_am_tests.rs ` | end-to-end tests |
221229
0 commit comments