Skip to content

Commit 5210c50

Browse files
authored
Merge pull request #3 from bitner/prune-perf
Match CHECK constraint exclusion planning speed (per-plan compilation + backend summary cache)
2 parents 65832ff + 3c97821 commit 5210c50

5 files changed

Lines changed: 249 additions & 58 deletions

File tree

README.md

Lines changed: 48 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -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

8890
The 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*,
106107
PostgreSQL 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

117118
Native pruning is *hundreds of times* cheaper to plan and is effectively constant in the
118119
partition 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
125125
column 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

180187
In short, table_range targets **hundreds to a few thousand sizeable partitions with a
181188
selective 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

src/index_am.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ unsafe fn widen_on_insert(
145145
}
146146
if changed {
147147
let _ = index_storage::write_summary(index, &summary);
148+
// The on-page summary just widened; drop any cached (now-too-narrow) copy in every
149+
// backend so planning never prunes away the newly covered values.
150+
crate::summary_cache::note_widened((*index).rd_id);
148151
}
149152
}
150153

src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod index_am;
99
mod index_storage;
1010
mod prune_hook;
1111
mod summary_build;
12+
mod summary_cache;
1213

1314
/// Master switch for planner-side partition pruning.
1415
pub(crate) static TABLE_RANGE_ENABLE_PRUNING: GucSetting<bool> = GucSetting::<bool>::new(true);
@@ -38,6 +39,8 @@ pub extern "C-unwind" fn _PG_init() {
3839

3940
// Install the real planner-time partition pruning hooks.
4041
prune_hook::install();
42+
// Register the relcache callback that keeps the per-index summary cache coherent.
43+
summary_cache::register();
4144
}
4245

4346
#[cfg(any(test, feature = "pg_test"))]

0 commit comments

Comments
 (0)