Summary
The default stripe_row_limit of 150,000 puts a realistically wide table over the 32 MB fetch cache cap, so every index fetch re-decodes the entire row group. Indexed point queries cost ~33 ms per row at the default and ~0.18 ms per row one step below it: a 186x difference produced by nothing but the shipped default.
Against heap on the same data with the same index and the same plan, this is roughly 1,700x slower.
Reproduction
Self-contained, 300,000 rows, 21 columns (TSBS-cpu shape). Identical data and identical query in all three arms; only stripe_row_limit differs. All three take an Index Scan.
SELECT count(*), max(usage_user) FROM t WHERE hostname='host_1' -- 600 rows.
stripe_row_limit |
groups |
plan |
time |
per row |
| 150000 (default) |
2 |
Index Scan |
19,788 ms |
32.98 ms |
| 100000 |
3 |
Index Scan |
106.5 ms |
0.177 ms |
| 50000 |
6 |
Index Scan |
75.4 ms |
0.125 ms |
Script: /root/ct3.sh on pgcolumnar-audit (runs in a throwaway database).
Mechanism
COLUMNAR_FETCH_CACHE_MAX_BYTES is 32 MB (src/columnar_reader.c:1778). A fetch by row number decodes the row group and caches it; when the decoded group exceeds that cap the entry cannot be retained, so the next fetch decodes the whole group again. With 150,000 rows and 21 columns the decoded group is comfortably over 32 MB, so the cache never survives a single fetch and every row costs a full group decode.
The threshold behaviour is sharp, and an independent measurement during an unrelated audit put the cliff at 145,001 rows for a narrower fixture -- consistent with a byte cap rather than a row cap.
Why this matters more than it looks
It is the default. stripe_row_limit defaults to 150,000, so a user who creates a columnar table, adds an index, and runs a selective query gets the pathological path with no indication that a configuration choice caused it.
It scales with table width, not row count. The wider the table, the fewer rows fit under 32 MB, so the more likely the default exceeds it. A 21-column time-series table -- the workload this project targets -- is past it.
It is not the closed quadratic-fetch issue. #143 and #157 both addressed fetch-by-row-number cost and are closed. This is the cache being unable to hold a default-sized group at all.
Measured impact on the 100M bench
Same shape at scale, cpu_pgc (100M rows, 12 columns queried, groups averaging 149,925 rows), against cpu_heap with an equivalent (hostname, time DESC) index and an identical plan:
| query |
rows returned |
pgColumnar |
heap |
per row (pgc) |
| one host, 1 hour |
360 |
10,335 ms |
6 ms |
28.7 ms |
| one host, 12 hours |
4,320 |
123,546 ms |
8 ms |
28.6 ms |
The per-row cost is flat across a 12x change in row count, which is the signature of a fixed per-fetch cost rather than anything proportional to the result.
Options, roughly in order of appeal
- Cache the decoded group per column rather than whole, so the cap applies to what the fetch actually needs. A point query touching 3 of 21 columns would then fit comfortably.
- Raise or make configurable
COLUMNAR_FETCH_CACHE_MAX_BYTES. Simple, but trades a silent performance cliff for silent memory growth, and the right value still depends on table width.
- Lower the default
stripe_row_limit. Cheapest, but it is the wrong lever: it degrades scan and compression to fix a fetch-path problem, and the cliff would still be there for wide-enough tables.
- Warn when a table's configuration puts it over the cap. Not a fix, but it converts an invisible cliff into a visible one.
(1) is the only option that removes the cliff rather than moving it.
Note on scope
Found while re-measuring the cross-engine benchmark for #348. It is the dominant term in q1/q2/q3 there, and those queries cannot be honestly published without it. Filing separately because it is a product defect rather than a documentation one.
Summary
The default
stripe_row_limitof 150,000 puts a realistically wide table over the 32 MB fetch cache cap, so every index fetch re-decodes the entire row group. Indexed point queries cost ~33 ms per row at the default and ~0.18 ms per row one step below it: a 186x difference produced by nothing but the shipped default.Against heap on the same data with the same index and the same plan, this is roughly 1,700x slower.
Reproduction
Self-contained, 300,000 rows, 21 columns (TSBS-cpu shape). Identical data and identical query in all three arms; only
stripe_row_limitdiffers. All three take anIndex Scan.SELECT count(*), max(usage_user) FROM t WHERE hostname='host_1'-- 600 rows.stripe_row_limitScript:
/root/ct3.shonpgcolumnar-audit(runs in a throwaway database).Mechanism
COLUMNAR_FETCH_CACHE_MAX_BYTESis 32 MB (src/columnar_reader.c:1778). A fetch by row number decodes the row group and caches it; when the decoded group exceeds that cap the entry cannot be retained, so the next fetch decodes the whole group again. With 150,000 rows and 21 columns the decoded group is comfortably over 32 MB, so the cache never survives a single fetch and every row costs a full group decode.The threshold behaviour is sharp, and an independent measurement during an unrelated audit put the cliff at 145,001 rows for a narrower fixture -- consistent with a byte cap rather than a row cap.
Why this matters more than it looks
It is the default.
stripe_row_limitdefaults to 150,000, so a user who creates a columnar table, adds an index, and runs a selective query gets the pathological path with no indication that a configuration choice caused it.It scales with table width, not row count. The wider the table, the fewer rows fit under 32 MB, so the more likely the default exceeds it. A 21-column time-series table -- the workload this project targets -- is past it.
It is not the closed quadratic-fetch issue. #143 and #157 both addressed fetch-by-row-number cost and are closed. This is the cache being unable to hold a default-sized group at all.
Measured impact on the 100M bench
Same shape at scale,
cpu_pgc(100M rows, 12 columns queried, groups averaging 149,925 rows), againstcpu_heapwith an equivalent(hostname, time DESC)index and an identical plan:The per-row cost is flat across a 12x change in row count, which is the signature of a fixed per-fetch cost rather than anything proportional to the result.
Options, roughly in order of appeal
COLUMNAR_FETCH_CACHE_MAX_BYTES. Simple, but trades a silent performance cliff for silent memory growth, and the right value still depends on table width.stripe_row_limit. Cheapest, but it is the wrong lever: it degrades scan and compression to fix a fetch-path problem, and the cliff would still be there for wide-enough tables.(1) is the only option that removes the cliff rather than moving it.
Note on scope
Found while re-measuring the cross-engine benchmark for #348. It is the dominant term in q1/q2/q3 there, and those queries cannot be honestly published without it. Filing separately because it is a product defect rather than a documentation one.