Skip to content

Commit 85b6926

Browse files
bitnerclaude
andcommitted
Docs: rewrite performance/scaling sections with planning-vs-execution and native comparison
- Performance section now reports planning and execution separately (the core tradeoff: slower planning, much faster execution), with reproducible numbers. - Add an honest head-to-head against native partition pruning (two identical columns, one the partition key, one not) showing native is hundreds of times cheaper to plan and effectively constant in partition count. - New 'Scaling and partition count' section: O(n) planning, the ~10k-partition lock-table wall, and mitigations (max_locks_per_transaction, prepared statements, fewer/larger partitions, key alignment). - Fix stale 'loaded once per plan' claim (summaries are read per partition from the index page, cached per plan; compare proc + constant memoized per plan). - Replace bench/planning_benchmark.sql with bench/benchmark.sql that reproduces the documented tables via EXPLAIN (ANALYZE). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5e26e77 commit 85b6926

3 files changed

Lines changed: 182 additions & 70 deletions

File tree

README.md

Lines changed: 85 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -80,25 +80,80 @@ EXPLAIN (COSTS OFF) SELECT * FROM places WHERE geom && ST_MakeEnvelope(0,0,10,10
8080

8181
## Performance
8282

83-
The benefit is at **execution**: a selective predicate on a non-key column scans only the
84-
matching partition instead of every partition. On 100 partitions × 30k rows = 3M rows
85-
(`bench/planning_benchmark.sql`, PostgreSQL 18, warm):
86-
87-
| | Total query time (plan + exec) |
88-
|---|---|
89-
| pruning off (scans all 100 partitions) | ~125 ms |
90-
| pruning on (scans 1 partition) | ~18 ms |
91-
92-
Pruning is **not** a free planning-time win: it adds a small per-plan overhead (loading
93-
summaries once, then evaluating each partition — single-digit to low-tens of ms on
94-
hundreds of partitions). It pays off when the partitions it eliminates are large enough
95-
that avoiding their scan outweighs that overhead — so it helps most on **large
96-
partitions with a selective non-key predicate**, and can be a slight net cost on tiny
97-
partitions. Use `table_range.enable_pruning` to measure both ways on your workload.
98-
99-
Summaries are loaded **once per plan** (not per partition); the
100-
`e2e_per_plan_cache_loads_once_regardless_of_partitions` test asserts exactly one
101-
catalog load for a 64-partition query.
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.
87+
88+
The numbers below are reproducible with `bench/benchmark.sql` (`cargo pgrx run pg18`, then
89+
`\i bench/benchmark.sql`); they report `EXPLAIN (ANALYZE)` planning and execution time
90+
separately, warm.
91+
92+
**Faster execution.** 300 partitions × 8,000 rows (2.4M rows), `WHERE nk = <value in one
93+
partition>`, PostgreSQL 18, warm:
94+
95+
| | Planning | Execution | Total |
96+
|---|---|---|---|
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+
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`.
104+
105+
**Honest comparison to native pruning.** When a predicate is on the *partition key*,
106+
PostgreSQL prunes natively — and that path is in a different league, because it eliminates
107+
partitions from a sorted bound array *before* they are ever locked or opened. The table
108+
below uses two identical columns on the same table: `pk` (the range partition key, pruned
109+
natively) and `nk` (the same values, not the key, pruned by table_range):
110+
111+
| Same `=` predicate, 2,000 partitions | Planning | Execution |
112+
|---|---|---|
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 |
116+
117+
Native pruning is *hundreds of times* cheaper to plan and is effectively constant in the
118+
partition count. table_range cannot match that (see
119+
[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.
123+
124+
Each partition's summary is read from its own index page and cached for the duration of
125+
one plan; the per-column compare function and the query constant are resolved once per
126+
plan and reused across partitions (so the per-partition cost is a typed min/max compare,
127+
not repeated catalog lookups).
128+
129+
## Scaling and partition count
130+
131+
table_range's planning cost is **O(number of partitions)**: PostgreSQL builds a planner
132+
node for every partition of the table for a non-key predicate, and table_range evaluates
133+
each one's summary. This is fundamentally different from native partition pruning, which
134+
is ~O(log n) because it prunes on the partition key before expansion. There is no public
135+
planner hook that prunes a non-key column before expansion, so this O(n) cost is inherent.
136+
137+
Two practical consequences and how to handle them:
138+
139+
- **Lock table exhaustion (a hard wall, ~10k partitions on defaults).** Any query touching
140+
a non-key column must lock *every* partition (and its indexes) while planning. With the
141+
default `max_locks_per_transaction = 64`, a query over ~10,000 partitions fails with
142+
`ERROR: out of shared memory` / `You might need to increase "max_locks_per_transaction"`.
143+
This is a PostgreSQL limit on wide non-key scans, not specific to this extension (native
144+
key pruning avoids it by never locking pruned partitions). **Mitigation:** raise
145+
`max_locks_per_transaction` (e.g. to a few thousand) and restart — it preallocates
146+
shared memory for the lock table, pushing the wall out in proportion.
147+
- **Planning time grows with partition count.** Even below the lock wall, planning scales
148+
linearly. **Mitigations:** prefer **fewer, larger partitions** (table_range's sweet spot
149+
— the execution win is biggest there anyway); use **prepared statements** so a plan is
150+
reused across executions and the planning cost is amortized; and where you can,
151+
**align the hot filter column with the partition key** so native pruning handles it.
152+
153+
In short, table_range targets **hundreds to a few thousand sizeable partitions with a
154+
selective non-key predicate**. For tens of thousands of partitions, non-key pruning is not
155+
something an extension can make sub-linear today; that would require pre-expansion pruning
156+
support in PostgreSQL core.
102157

103158
## Supported predicates
104159

@@ -154,8 +209,19 @@ range-type tests, which exercise the same code path.
154209

155210
## Limitations
156211

212+
- **Planning is O(partitions)** for non-key predicates, with a hard lock-table wall around
213+
~10k partitions on default settings. See [Scaling](#scaling-and-partition-count) for the
214+
cause and mitigations (`max_locks_per_transaction`, prepared statements, fewer/larger
215+
partitions). Native partition-key pruning does not have this limit; table_range is for
216+
the cases native pruning cannot handle.
217+
- Pruning is a **planning-time cost / execution-time win** tradeoff: on small partitions
218+
the per-plan overhead can exceed the scan it saves. Measure with
219+
`table_range.enable_pruning`.
157220
- `NOT IN` / `<> ALL`, `NOT (...)`, expression predicates, and parameterized
158221
prepared-statement plans are kept rather than pruned.
159222
- Inserts keep summaries current incrementally, but deletes only relax them (the summary
160223
can stay wider than the live data until a `VACUUM`/`REINDEX` re-tightens it) — always
161224
correct, just potentially less selective.
225+
- Pruning engages only while the index is **valid** (`indisvalid`); the planner ignores
226+
invalid indexes, so anything that invalidates a table_range index silently disables its
227+
pruning until rebuilt.

bench/benchmark.sql

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
-- Reproducible benchmarks for pg_table_range.
2+
--
3+
-- cargo pgrx run pg18
4+
-- \i bench/benchmark.sql
5+
--
6+
-- Reports planning and execution time separately (warm) via EXPLAIN (ANALYZE), for:
7+
-- 1. Plan-vs-execution tradeoff: a selective non-key predicate, pruning on vs. off.
8+
-- 2. Honest comparison to native partition pruning: two identical columns, one the
9+
-- partition key (pruned natively) and one not (pruned by table_range).
10+
--
11+
-- Note: cargo pgrx run reuses its database. If you have previously loaded older versions
12+
-- of this extension, stale event triggers can interfere; this script drops the extension
13+
-- and recreates it for a clean baseline.
14+
15+
\set ECHO none
16+
\pset pager off
17+
18+
DROP EXTENSION IF EXISTS pg_table_range CASCADE;
19+
-- Clear any event triggers left behind by older builds (no-ops on a clean DB).
20+
DROP EVENT TRIGGER IF EXISTS table_range_hide_indexes_trg;
21+
DROP EVENT TRIGGER IF EXISTS table_range_drop_trg;
22+
DROP EVENT TRIGGER IF EXISTS table_range_opclass_sync_trg;
23+
DROP FUNCTION IF EXISTS table_range_hide_indexes() CASCADE;
24+
CREATE EXTENSION pg_table_range;
25+
26+
-- ====================================================================================
27+
-- 1. Plan-vs-execution tradeoff: 300 partitions x 8,000 rows, predicate hits 1 partition.
28+
-- `region` is the partition key; `val` is a disjoint band per partition (non-key).
29+
-- ====================================================================================
30+
DROP TABLE IF EXISTS bench_a CASCADE;
31+
CREATE TABLE bench_a (region int, val bigint, pad text) PARTITION BY LIST (region);
32+
SELECT format('CREATE TABLE bench_a_%s PARTITION OF bench_a FOR VALUES IN (%s);', g, g)
33+
FROM generate_series(1, 300) g \gexec
34+
INSERT INTO bench_a
35+
SELECT g, g * 1000000 + s, repeat('x', 40)
36+
FROM generate_series(1, 300) g, generate_series(0, 7999) s;
37+
VACUUM ANALYZE bench_a;
38+
CREATE INDEX bench_a_tr ON bench_a USING table_range (val);
39+
40+
SET table_range.enable_pruning = on;
41+
SELECT count(*) FROM bench_a WHERE val = 150004000; -- warm
42+
\echo '==== A: pruning ON (scans 1 of 300 partitions) ===='
43+
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT count(*) FROM bench_a WHERE val = 150004000;
44+
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT count(*) FROM bench_a WHERE val = 150004000;
45+
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT count(*) FROM bench_a WHERE val = 150004000;
46+
47+
SET table_range.enable_pruning = off;
48+
SELECT count(*) FROM bench_a WHERE val = 150004000; -- warm
49+
\echo '==== A: pruning OFF (scans all 300 partitions) ===='
50+
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT count(*) FROM bench_a WHERE val = 150004000;
51+
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT count(*) FROM bench_a WHERE val = 150004000;
52+
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT count(*) FROM bench_a WHERE val = 150004000;
53+
DROP TABLE bench_a CASCADE;
54+
55+
-- ====================================================================================
56+
-- 2. table_range vs. native pruning: 2,000 partitions, two identical columns.
57+
-- `pk` is the RANGE partition key (native pruning); `nk` holds the same values but is
58+
-- NOT the key (table_range pruning). Same `=` predicate, so the only difference is
59+
-- which mechanism prunes.
60+
-- ====================================================================================
61+
DROP TABLE IF EXISTS bench_b CASCADE;
62+
CREATE TABLE bench_b (pk bigint, nk bigint) PARTITION BY RANGE (pk);
63+
SELECT format('CREATE TABLE bench_b_%s PARTITION OF bench_b FOR VALUES FROM (%s) TO (%s);',
64+
g, g * 1000, (g + 1) * 1000)
65+
FROM generate_series(1, 2000) g \gexec
66+
INSERT INTO bench_b
67+
SELECT v, v FROM (SELECT g * 1000 + s AS v
68+
FROM generate_series(1, 2000) g, generate_series(0, 49) s) q;
69+
VACUUM ANALYZE bench_b;
70+
CREATE INDEX bench_b_tr ON bench_b USING table_range (nk);
71+
72+
SET table_range.enable_pruning = off;
73+
SELECT count(*) FROM bench_b WHERE pk = 1000500; -- warm
74+
\echo '==== B: NATIVE pruning (predicate on the partition key) ===='
75+
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT count(*) FROM bench_b WHERE pk = 1000500;
76+
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT count(*) FROM bench_b WHERE pk = 1000500;
77+
78+
SET table_range.enable_pruning = on;
79+
SELECT count(*) FROM bench_b WHERE nk = 1000500; -- warm
80+
\echo '==== B: table_range pruning (same predicate, non-key column) ===='
81+
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT count(*) FROM bench_b WHERE nk = 1000500;
82+
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT count(*) FROM bench_b WHERE nk = 1000500;
83+
84+
SET table_range.enable_pruning = off;
85+
SELECT count(*) FROM bench_b WHERE nk = 1000500; -- warm
86+
\echo '==== B: NO pruning (non-key column, scans all 2,000 partitions) ===='
87+
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT count(*) FROM bench_b WHERE nk = 1000500;
88+
DROP TABLE bench_b CASCADE;
89+
90+
-- ====================================================================================
91+
-- 3. (Optional) The lock-table wall. At ~10,000 partitions a non-key predicate exhausts
92+
-- the lock table on default settings:
93+
-- ERROR: out of shared memory
94+
-- HINT: You might need to increase "max_locks_per_transaction".
95+
-- Raise max_locks_per_transaction (e.g. to 4000) and restart to push the wall out.
96+
-- Native key pruning is unaffected because it never locks pruned partitions.
97+
-- ====================================================================================

bench/planning_benchmark.sql

Lines changed: 0 additions & 51 deletions
This file was deleted.

0 commit comments

Comments
 (0)