Skip to content

Commit 09fb46e

Browse files
bitnerclaude
andcommitted
Correct the performance story; the index overhead was a measurement artifact
While investigating the "per-partition index planning overhead," controlled same-session A/B measurements showed my earlier numbers were confounded by relcache warmth and cross-session machine load: - The index-validity effect on planning is ~3 ms on 500 partitions (warm), not the ~85 ms I previously reported. So the invalid-index event trigger I added is not worth its cost (a confusing INVALID display) and is reverted, along with the ineffective get_relation_info_hook. - The real, clean benefit is at EXECUTION: on 100 partitions x 30k rows (3M rows, warm), a selective non-key predicate runs in ~18 ms with pruning vs ~125 ms without (~7x). Pruning adds a small per-plan overhead and can be a slight net cost on tiny partitions; it wins when eliminated partitions are large. - Rewrote bench/planning_benchmark.sql to measure total (plan+exec) time, warm, on realistic partition sizes, and rewrote the README performance section to match. - Gated the test-only cache-load diagnostics so production builds are warning-free. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a0143a0 commit 09fb46e

3 files changed

Lines changed: 48 additions & 42 deletions

File tree

README.md

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -78,21 +78,23 @@ EXPLAIN (COSTS OFF) SELECT * FROM places WHERE geom && ST_MakeEnvelope(0,0,10,10
7878

7979
## Performance
8080

81-
The win is at **planning time** on wide trees, where the planner would otherwise build
82-
paths for every partition. On a 1000-partition table queried by a non-key column
83-
(`bench/planning_benchmark.sql`, PostgreSQL 18):
84-
85-
| | Planning time | Result |
86-
|---|---|---|
87-
| pruning off | ~210 ms | 50 rows |
88-
| pruning on | ~100 ms | 50 rows |
89-
90-
Pruning removes ~110 ms of child-path planning here. Note the absolute numbers are higher
91-
than they could be: because summaries are owned by a real index, PostgreSQL loads index
92-
metadata for every partition during planning (a flat overhead, ~85 ms on this bare
93-
1000-partition table — proportionally smaller when partitions already carry indexes).
94-
95-
Summaries themselves are loaded **once per plan** (not per partition); the
81+
The benefit is at **execution**: a selective predicate on a non-key column scans only the
82+
matching partition instead of every partition. On 100 partitions × 30k rows = 3M rows
83+
(`bench/planning_benchmark.sql`, PostgreSQL 18, warm):
84+
85+
| | Total query time (plan + exec) |
86+
|---|---|
87+
| pruning off (scans all 100 partitions) | ~125 ms |
88+
| pruning on (scans 1 partition) | ~18 ms |
89+
90+
Pruning is **not** a free planning-time win: it adds a small per-plan overhead (loading
91+
summaries once, then evaluating each partition — single-digit to low-tens of ms on
92+
hundreds of partitions). It pays off when the partitions it eliminates are large enough
93+
that avoiding their scan outweighs that overhead — so it helps most on **large
94+
partitions with a selective non-key predicate**, and can be a slight net cost on tiny
95+
partitions. Use `table_range.enable_pruning` to measure both ways on your workload.
96+
97+
Summaries are loaded **once per plan** (not per partition); the
9698
`e2e_per_plan_cache_loads_once_regardless_of_partitions` test asserts exactly one
9799
catalog load for a 64-partition query.
98100

bench/planning_benchmark.sql

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,51 @@
1-
-- Planning-time benchmark for table_range pruning.
1+
-- Benchmark for table_range pruning.
22
--
3-
-- Builds a wide partition tree where the queried column is NOT the partition key, so
4-
-- native PostgreSQL pruning cannot help, and compares planning time + plan size with
5-
-- table_range pruning off vs. on. Run with:
3+
-- Measures end-to-end query time (planning + execution, warm) for a selective predicate
4+
-- on a NON-partition-key column, with table_range pruning on vs. off. Native PostgreSQL
5+
-- cannot prune on a non-key column, so without pruning the query scans every partition.
66
--
77
-- cargo pgrx run pg18
88
-- \i bench/planning_benchmark.sql
99
--
10-
-- Look at the "Planning Time" line and the number of child plans in each EXPLAIN.
10+
-- Pruning trades a small per-plan overhead (loading summaries + evaluating each
11+
-- partition) for skipping the scan of non-matching partitions, so it wins when the
12+
-- partitions it eliminates are large enough to outweigh that overhead.
1113

12-
\set part_count 1000
14+
\set part_count 100
15+
\set rows_per_part 30000
1316

1417
DROP TABLE IF EXISTS bench_events CASCADE;
15-
CREATE TABLE bench_events (region int, val bigint) PARTITION BY LIST (region);
18+
CREATE TABLE bench_events (region int, val bigint, pad text) PARTITION BY LIST (region);
1619

17-
-- One partition per region; each holds a disjoint 1000-wide band of `val`.
1820
SELECT format(
19-
'CREATE TABLE bench_events_%s PARTITION OF bench_events FOR VALUES IN (%s);',
20-
g, g
21-
)
21+
'CREATE TABLE bench_events_%s PARTITION OF bench_events FOR VALUES IN (%s);', g, g)
2222
FROM generate_series(1, :part_count) g \gexec
2323

24+
-- region is the partition key; val is a disjoint band per partition (the queried,
25+
-- non-key column).
2426
INSERT INTO bench_events
25-
SELECT g, (g * 1000) + s
26-
FROM generate_series(1, :part_count) g,
27-
generate_series(0, 49) s;
28-
29-
ANALYZE bench_events;
27+
SELECT g, g * 1000000 + s, repeat('x', 50)
28+
FROM generate_series(1, :part_count) g, generate_series(0, :rows_per_part - 1) s;
3029

30+
VACUUM ANALYZE bench_events;
3131
CREATE INDEX bench_events_tr ON bench_events USING table_range (val);
3232

33-
\echo '==================== pruning OFF ===================='
34-
SET table_range.enable_pruning = off;
35-
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY ON)
36-
SELECT * FROM bench_events WHERE val BETWEEN 500000 AND 500049;
33+
\timing on
3734

38-
\echo '==================== pruning ON ===================='
35+
-- Warm the relation cache first so the numbers reflect steady state, not first-touch
36+
-- partition-metadata loading (which both modes pay equally).
3937
SET table_range.enable_pruning = on;
40-
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY ON)
41-
SELECT * FROM bench_events WHERE val BETWEEN 500000 AND 500049;
38+
SELECT count(*) FROM bench_events WHERE val = 50000000;
39+
40+
\echo '==================== pruning ON (warm) ===================='
41+
SELECT count(*) FROM bench_events WHERE val = 50000000;
42+
SELECT count(*) FROM bench_events WHERE val = 50000000;
4243

43-
\echo '==================== correctness check (must match) ===================='
4444
SET table_range.enable_pruning = off;
45-
SELECT count(*) AS off_count FROM bench_events WHERE val BETWEEN 500000 AND 500049;
46-
SET table_range.enable_pruning = on;
47-
SELECT count(*) AS on_count FROM bench_events WHERE val BETWEEN 500000 AND 500049;
45+
SELECT count(*) FROM bench_events WHERE val = 50000000;
46+
47+
\echo '==================== pruning OFF (warm) ===================='
48+
SELECT count(*) FROM bench_events WHERE val = 50000000;
49+
SELECT count(*) FROM bench_events WHERE val = 50000000;
4850

4951
DROP TABLE bench_events CASCADE;

src/prune_hook.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ use crate::{TABLE_RANGE_ENABLE_PRUNING, TABLE_RANGE_LOG_PRUNING_DEBUG};
1111
/// One load per top-level plan (regardless of partition count) proves the per-plan cache.
1212
static CACHE_LOADS: AtomicU64 = AtomicU64::new(0);
1313

14+
#[cfg(any(test, feature = "pg_test"))]
1415
pub fn cache_load_count() -> u64 {
1516
CACHE_LOADS.load(Ordering::Relaxed)
1617
}
1718

19+
#[cfg(any(test, feature = "pg_test"))]
1820
pub fn reset_cache_load_count() {
1921
CACHE_LOADS.store(0, Ordering::Relaxed);
2022
}

0 commit comments

Comments
 (0)