Hold what fits in the fetch cache instead of dropping it all (#359) - #361
Conversation
The fetch cache dropped an entry whole when it exceeded COLUMNAR_FETCH_CACHE_MAX_BYTES, so an entry one byte over was not retained at all and every fetch re-read the group and re-decoded every column it touched. On the 100M fixture that is 2,833 ms at four aggregate columns and 134,147 ms at five, flat either side: a 47x step inside the space of ordinary queries. #357 shrank entries ~3x by moving decode scratch out, which moved the threshold from "any wide table" to "five or more aggregate columns" without changing the shape. Raising the cap would move it again. Decoding was already per column and lazy; only the eviction was per entry. Make the eviction as granular as the admission: each column decodes into its own child context, and a column that takes the entry over the cap is released after its value has been extracted and decodes into per-fetch scratch from then on. The columns admitted before it stay resident, and groupBuffer stays either way, so no fetch re-reads the group from disk. The resident set is first-fit and never rotates. That is load-bearing rather than incidental: the access pattern is cyclic, every fetch touching the same projected columns in attribute order, and LRU against a cyclic pattern evicts precisely the column about to be needed -- a 100% miss rate, which is the behaviour being removed. The position indexes from #143 stay in the entry context so a released column still reaches its row in constant time. A group whose raw bytes alone exceed the cap is still dropped whole, which keeps the cache bounded by 4 x the cap. columnar_index_fetch_penalty modelled this cliff by treating every group as re-decoded once the projection crossed the cap. It now scales by the overflow fraction, so the cost model and the cache agree (#355). Measured, PG18 assert, four to five projected columns: before 77 ms -> 1902 ms (24.7x) after 64 ms -> 368 ms (5.8x) test/native_fetch_cache.sh gains a projection-width case. The existing #353 case varies group size at a fixed two-column projection, so it never crosses the relocated cap and went green on the query family that still cliffed. The new check fails on unmodified main (24.9x against a 12x bound) while its two correctness checks pass there, so the timing check is what detects the fix. Gate: full suite matrix, PG18 and PG19 assert, in the pgcolumnar-dev container. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ChronicallyJD
left a comment
There was a problem hiding this comment.
Reviewed. The approach is right and the reasoning in the design doc is the good kind
— it argues the decisions that could plausibly have gone the other way rather than
narrating the diff. I have one thing I want measured before merge, one correction to
something in the body that is my fault rather than yours, and one suspicion of mine
that I chased and disproved, which I am reporting because a reviewer's dead ends
are worth as much as their hits.
Verified here
-
Builds clean.
pg18n(-O2) andpg18a(assert), 0 lines matching
: warning:on either. (My first pass grepped forwarning|errorand "found" 20 —
they were all-Werror=vlain the command line. Mentioning it because the same
false positive will bite anyone eyeballing a build log.) -
The defect is real on current main, independently of the 100M fixture. I built a
controlled one before you posted: 100k rows x 16textcolumns + key, forced index
scan over the same 200 rows every time, varying only the text byte width so the
decoded group size crosses the cap. Stored size barely moves (this text compresses
~170x), so it isolates decoded bytes from I/O:text width time 25 B 72 ms 50 B 129 ms 100 B 221 ms 150 B 1,848 ms 200 B 2,180 ms 400 B 46,971 ms A step, not a slope, 650x end to end — and with
float8columns instead (under the
cap) the same sweep across 1→16 aggregated columns is flat at 29–44 ms. So the
trigger is decoded bytes per group and not aggregate-column count, which is what
your projection-width test axis is getting at, and it is the right axis. -
Your two new correctness checks do exercise the fetch path. This was my
suspicion and it was wrong, so: I noticedw359_ms()sets
enable_seqscan=off/enable_bitmapscan=offwhile the correctness checks go through
q(), which is a barepsql -cwith no GUCs, and I expected them to fall onto the
columnar scan and never entercolumnar_fetch_row— a check that passes without
running the code it names. I built your fixture verbatim and printed the plans.
Both takeIndex Scan using fc_w359_h, becauseh='h7'is selective enough on its
own. The checks are sound as written. (I have shipped exactly that bug myself in
#321, which is why I went looking.)
The one thing I want measured before merge: the memory bound
"Memory stays bounded ... keeps the cache bounded by 4 x the cap" is the claim I am
least sure of, because the PR deliberately makes two things non-releasable and then
stops measuring the entry as a whole:
rankPrefix[c]andvalOffset[c]stay inentry->cxby design, so they
outlive a released column. Agreed with the reasoning — without it #143's quadratic
returns through the overflow path.- but the whole-entry drop is now keyed on raw bytes:
if (rg->byteLength > COLUMNAR_FETCH_CACHE_MAX_BYTES).
The design doc justifies retention with "valOffset is 4 bytes per value against a
value that is typically wider". That holds while the column is resident. Once the
column is released the stream goes and the index stays, so the ratio it is being
compared against is no longer there. valOffset is 4 bytes per value per varlena
column, and at the default stripe_row_limit of 150,000 that is ~600 KB of
permanently-resident state per varlena column, independent of the cap.
So on a wide varlena table the retained index state alone can exceed 32 MB, and I
think two things follow, neither of which the current tests would show:
- the entry is no longer bounded by the cap — it is bounded by
cap + sum(retained indexes) + groupBuffer, and only the raw-bytes check can drop
it; and - once the non-releasable state alone exceeds the cap,
MemoryContextMemAllocated
is over the limit on every decode, so every newly decoded column is released
immediately — 100% overflow, i.e. the pre-#361 behaviour, but now while pinning
memory instead of dropping it.
I am measuring this directly with pg_backend_memory_contexts (in-transaction, so
the statement-scoped contexts are still alive) on 150,000 rows x 60 text columns,
against main as the control. I will post the numbers rather than leave this as an
argument. If it holds, I do not think it changes the design — the cheapest fix is
probably to include a whole-entry drop when the retained state exceeds the cap, or
to release valOffset[c] alongside a column that has overflowed twice — but the
"4 x the cap" sentence in the body would need to come out.
Correction: the cost-model half of this is undermined by my bug, not yours
The PR says the columnar_index_fetch_penalty change makes "the cost model and the
cache agree rather than the planner baking in a cliff this PR removes". The edit
itself is right. But on the motivating query the model does not get to decide
anything, and that is my fault, from #360 — filed as #362.
Measured on cpu_pgc with its index restored, five aggregate columns:
| arm | plan | estimated | actual |
|---|---|---|---|
| penalty on (default) | Index Scan | 13,954,742 | 224,055 ms |
| penalty off | Index Scan | 5,090 | 224,946 ms |
penalty on, enable_indexscan=off |
Parallel Custom Scan | 589,348 | 4,728 ms |
The penalty fires — 4,975 → 13,954,348 — and the plan does not change, because
ColumnarSetRelPathlist offers the columnar path to add_path before applying the
penalty, and add_path frees it as dominated while the index path still looks cheap.
Your hook's own comment documents that hazard for the seqscan; I reasoned about list
ordering in #360's body and missed path rejection.
Two consequences for this PR, neither of them blocking:
-
The "consistency with #355" claim is true of the arithmetic and not yet true of the
behaviour. I would soften that sentence, or point it at #362. -
More substantively: this PR changes that branch from a hard snap to
groups_max
into a proportional blend. Softening a penalty is the right direction given the
cache change — but it is being softened in a model that already under-fires, and
there is a second reason it under-fires, also mine:rel->reltarget->widthis the
width of the columns emitted, while the deferred slot decodes the attribute
prefix0..max-referenced. Ten-text-column table, same 300 fetched rows, same
emitted width, same plan, varying only which column is referenced:max(a1): 975 ms max(a10): 194,798 ms200x, entirely invisible to
reltarget->width. Under the old snap branch that
understatement was partly masked; under a proportionalresidentfraction it
silently scales the penalty down. I would not hold this PR for it — it is my defect
and it is in #362 — but it is worth a comment in the new branch saying the width it
keys on is known to understate the decode, so the next person does not trust it.
Smaller notes
w359_ms()guards with[ "$under" -gt 0 ], andunderis a whole-millisecond
integer. On a fast box a four-column run could round to 0 and the check would report
no (four=0ms five=...)— a failure that means "too fast", which is a confusing
red. Worth a floor of 1 rather than a guard that fails.- The
valOffsetreuse across a re-decode rests on decode determinism, which the
design doc argues and I believe. It is an invariant a future encoding could break
silently and wrongly (wrong values, not a crash), so it is worth anAsserton the
re-decode path comparing the recomputed length against the retained one, rather than
only a comment.
Nothing here is a correctness objection to the cache change, which I think is right.
Once the memory numbers are in I expect this to be a straightforward approve.
|
Memory numbers, as promised. The bound does not hold, and the shape it fails on also Fixture: 150,000 rows x 60 Memory, read from One entry is 62 MB against a 32 MB cap, and only two columns are resident inside So the ceiling is not Timing, same fixture, same 200 fetches:
1.14x — against the 13.6x this PR reports at its own crossing. That is the second I want to be clear about what this is and is not. It is not an argument against Options, cheapest first, and I have not measured any of them:
Either way the "keeps the cache bounded by 4 x the cap" sentence in the body and the Happy to take this as a follow-up patch on top of #361 rather than holding the PR — |
#361 states, in its commit message and in the design doc, that the per-column release keeps the cache bounded by 4 x the cap. It does not, and the claim is the kind a later change gets designed against. The release trims the decoded streams. It cannot trim rankPrefix and valOffset, which stay in the entry context deliberately so a released column keeps constant-time row reach. valOffset is four bytes per value per varlena column, ~600 KB per column at the default stripe_row_limit, so enough varlena columns put the retained indexes alone over the cap and the entry stops shrinking. Measured on 150,000 rows x 60 text columns, forced index scan over 200 rows: one entry held 62 MB against a 32 MB cap, and the speedup on that shape is 1.14x. The real bound is 4 x (cap + retained position indexes + groupBuffer). Releasing valOffset with the stream was built and measured and is a worse trade: it holds the bound (62 MB -> 28 MB) at 47% in time (127.7 s -> 187.5 s), because rebuilding offsets is a second walk of the value stream per fetch rather than a constant factor on the decode. No behaviour change. The design keeps choosing speed over the bound; this records the trade instead of asserting a bound that does not hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
Closes #359. The fetch cache dropped an entry whole once it exceeded
COLUMNAR_FETCH_CACHE_MAX_BYTES, so an entry one byte over was not retained at alland every fetch re-read the group and re-decoded every column it touched. On the
100M fixture that is 2,833 ms at four aggregate columns and 134,147 ms at five,
flat either side: a 47x step inside the space of ordinary queries.
#357 shrank entries ~3x by moving decode scratch out. That moved the threshold from
"any wide table" to "five or more aggregate columns" without changing the shape.
Raising the cap would move it again.
Approach
Decoding was already per column and lazy --
entry->rawBuf[c]is filled onlywhen column
cis touched. Only the eviction was per entry. The fix makes theeviction as granular as the admission already was:
extracted, and decodes into per-fetch scratch from then on;
groupBufferstays either way,so no fetch re-reads the group from disk.
Cost becomes the overflow fraction rather than everything.
Design decisions worth a reviewer's eye
incidental. The access pattern is cyclic -- every fetch touches the same projected
columns in attribute order -- and LRU against a cyclic working set larger than the
cache evicts precisely the entry about to be needed: fetch 2 wants column 0, which
fetch 1 just evicted, and so on for a 100% miss rate. That is the behaviour being
removed, with extra bookkeeping. "Retain what fits" has to mean first fit then
stop, not keep the hottest.
outlive a released column. Decoding the same chunk bytes is deterministic and
yields the same layout, so the offsets stay valid across a re-decode: an
overflowed column pays its decode again but still reaches its row in constant
time. Without this, Fetch by row number re-reads and re-decodes the whole row group, making UPDATE and DELETE quadratic within a group #143's quadratic returns through the overflow path. They are
small next to the stream (
rankPrefix4 bytes per 64 rows,valOffset4 pervalue).
pointer into
groupBufferrather than an allocation, so they cost the entrynothing and releasing one would free an interior pointer. Guarded on
colCx[c] != NULL.cap. The one case it cannot help is a group whose raw bytes alone exceed the cap
-- every column would overflow and the entry would still pin
groupBuffer-- sothat is still dropped whole, keeping the cache bounded by 4 x the cap.
MemoryContextMemAllocatedmoved off the per-fetch path. It is now calledonly when a column was newly decoded, so a fully-resident hit makes zero calls
where it previously made one per fetch.
Consistency with #355
columnar_index_fetch_penaltymodelled this cliff directly: once the projectioncrossed the cap it set
groups_decoded = groups_max, i.e. every group re-decoded.It now scales by the overflow fraction, so the cost model and the cache agree rather
than the planner baking in a cliff this PR removes. As promised in #360's body.
Measurements
PG18 assert,
pgcolumnar-dev, holding rows / group size / plan constant and varyingonly the number of projected columns. The 32 MB cap falls between four and five.
A cliff becomes a ramp; 13.6x faster at the crossing.
Tests
test/native_fetch_cache.shgains a projection-width case. The existing #353 casevaries group size at a fixed two-column projection, so it never crosses the
relocated cap -- it went green on exactly the query family that still cliffed. Both
the issue author and the reviewer generalised from one projection width; the new
case varies the axis that was held constant.
Per removal proof, on unmodified
mainthe new timing check fails (74 ms ->1842 ms, 24.9x against a 12x bound) while its two correctness checks pass there
-- so the timing check is what detects the fix, not the fixture.
Both figures in the test comment are measured in this suite rather than
standalone: the ~600 MB of fixtures built above leave the box in a different state,
and the same fixed build measures 2.2x standalone and 5.8x in-suite. Comparing a
suite number against a standalone number would compare two machines. Four in-suite
runs gave 5.6x, 5.6x, 5.9x, 6.4x; the bound is 12x, roughly a factor of two clear of
either build.
Gate
ALL VERSIONS PASSED, zero failures.native_fetch_cache. Run because this change frees memory contexts while decodedpointers are live, which is exactly the class ASAN catches.
Design notes in
design/ISSUE_359_FETCH_CACHE_PARTIAL.md.🤖 Generated with Claude Code