You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Raising this as an issue because #361 merged before my review landed, and a measured
defect should not live only in a closed PR thread. Numbers are unchanged from my
comment there; this adds nothing new except a place to track it.
Summary
#361 states, in the PR body and in design/ISSUE_359_FETCH_CACHE_PARTIAL.md, that
the per-column release keeps the fetch cache "bounded by 4 x the cap". It does not.
On a wide varlena table a single entry measures 62 MB against a 32 MB cap, and
nothing can bring it back down.
The same shape gets almost none of the speedup the change is for: 1.14x, against
the 13.6x #361 reports at its own crossing.
Measurement
Fixture: 150,000 rows x 60 text columns of 100 B plus a bigint key — one stripe at
the default stripe_row_limit, index on the key, forced index scan over 200 rows.
Every column varlena, so every one gets a valOffset. pg18n (-O2), one run at a
time on an idle box, main before #361 as the control.
Memory, from pg_backend_memory_contexts read inside the transaction so the
statement-scoped contexts are still alive:
columnar fetch column | n=2 | total=18 MB <- only two columns stayed resident
columnar fetch group | n=1 | total=44 MB <- groupBuffer + the retained indexes
ALL columnar fetch contexts total: 62 MB
But valOffset is 4 bytes per value per varlena column. At the default stripe_row_limit of 150,000 that is ~600 KB of permanently-resident state per
varlena column, and 60 of them is ~34 MB — over the cap on its own, before a
single decoded byte is retained. That is the 44 MB above.
The whole-entry drop now keys on raw bytes: if (rg->byteLength > COLUMNAR_FETCH_CACHE_MAX_BYTES). This text compresses well,
so byteLength stays small and the drop never fires.
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 it is
released the stream goes and the index stays, so the thing it is being compared
against is no longer there.
The 1.14x follows from the same fact: once the retained indexes alone exceed the cap, MemoryContextMemAllocated(entry->cx, true) is over the limit on every decode, so
every newly decoded column is released immediately. Two columns got in before the
entry crossed; the other 58 overflow on every fetch. It degrades to roughly the
pre-#361 behaviour while now pinning 62 MB instead of dropping it.
Real ceiling is 4 x (cap + retained indexes + groupBuffer) — ~248 MB on this shape,
not 128 MB.
Fix direction
Cheapest first; I have measured none of them.
Release valOffset[c] for a column that has overflowed, keep rankPrefix[c]. rankPrefix is 4 bytes per 64 rows (~9 KB per column here) against ~600 KB for valOffset, so this keeps the constant-time row reach that matters for Fetch by row number re-reads and re-decodes the whole row group, making UPDATE and DELETE quadratic within a group #143 at
~1.5% of the memory. The cost is that an overflowed varlena column re-walks its
value stream to rebuild offsets — a constant factor on a decode that is already
happening per fetch, not a return of the quadratic. This is the one I would try.
Count the retained indexes against the cap when deciding admission, so
admission stops before retention eats the budget.
A whole-entry drop keyed on the entry's actual footprint rather than on raw
bytes, as a backstop for whatever (1) and (2) do.
Whatever lands, the "bounded by 4 x the cap" sentence in the body and the matching
line in the design doc need to change, and the test wants to assert the bound
(read pg_backend_memory_contexts in-transaction) rather than only the timing ratio —
a timing test cannot distinguish "proportional" from "degraded back to total but now
holding memory".
Not a regression
Worth saying plainly: before #361 this shape pinned nothing and re-decoded
everything, so the memory ceiling is new but the time is not — 145 s to 128 s is an
improvement, just a marginal one. #361 is a clear win on the shapes it targets. This
is the shape it does not reach.
Provenance
Found while reviewing #361 (comment on the merged PR has the same numbers). Fixture
and method are in that comment; happy to take the patch.
Raising this as an issue because #361 merged before my review landed, and a measured
defect should not live only in a closed PR thread. Numbers are unchanged from my
comment there; this adds nothing new except a place to track it.
Summary
#361 states, in the PR body and in
design/ISSUE_359_FETCH_CACHE_PARTIAL.md, thatthe per-column release keeps the fetch cache "bounded by 4 x the cap". It does not.
On a wide varlena table a single entry measures 62 MB against a 32 MB cap, and
nothing can bring it back down.
The same shape gets almost none of the speedup the change is for: 1.14x, against
the 13.6x #361 reports at its own crossing.
Measurement
Fixture: 150,000 rows x 60
textcolumns of 100 B plus abigintkey — one stripe atthe default
stripe_row_limit, index on the key, forced index scan over 200 rows.Every column varlena, so every one gets a
valOffset.pg18n(-O2), one run at atime on an idle box,
mainbefore #361 as the control.Memory, from
pg_backend_memory_contextsread inside the transaction so thestatement-scoped contexts are still alive:
Timing, same fixture, same 200 fetches:
Why
Two things #361 makes non-releasable, plus a whole-entry drop that can no longer see
them:
rankPrefix[c]andvalOffset[c]stay inentry->cxby design, so theyoutlive a released column. That part is right — without it 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, and the design doc argues it well.
valOffsetis 4 bytes per value per varlena column. At the defaultstripe_row_limitof 150,000 that is ~600 KB of permanently-resident state pervarlena column, and 60 of them is ~34 MB — over the cap on its own, before a
single decoded byte is retained. That is the 44 MB above.
if (rg->byteLength > COLUMNAR_FETCH_CACHE_MAX_BYTES). This text compresses well,so
byteLengthstays small and the drop never fires.The design doc justifies retention with "
valOffsetis 4 bytes per value against avalue that is typically wider". That holds while the column is resident. Once it is
released the stream goes and the index stays, so the thing it is being compared
against is no longer there.
The 1.14x follows from the same fact: once the retained indexes alone exceed the cap,
MemoryContextMemAllocated(entry->cx, true)is over the limit on every decode, soevery newly decoded column is released immediately. Two columns got in before the
entry crossed; the other 58 overflow on every fetch. It degrades to roughly the
pre-#361 behaviour while now pinning 62 MB instead of dropping it.
Real ceiling is
4 x (cap + retained indexes + groupBuffer)— ~248 MB on this shape,not 128 MB.
Fix direction
Cheapest first; I have measured none of them.
valOffset[c]for a column that has overflowed, keeprankPrefix[c].rankPrefixis 4 bytes per 64 rows (~9 KB per column here) against ~600 KB forvalOffset, so this keeps the constant-time row reach that matters for Fetch by row number re-reads and re-decodes the whole row group, making UPDATE and DELETE quadratic within a group #143 at~1.5% of the memory. The cost is that an overflowed varlena column re-walks its
value stream to rebuild offsets — a constant factor on a decode that is already
happening per fetch, not a return of the quadratic. This is the one I would try.
admission stops before retention eats the budget.
bytes, as a backstop for whatever (1) and (2) do.
Whatever lands, the "bounded by 4 x the cap" sentence in the body and the matching
line in the design doc need to change, and the test wants to assert the bound
(read
pg_backend_memory_contextsin-transaction) rather than only the timing ratio —a timing test cannot distinguish "proportional" from "degraded back to total but now
holding memory".
Not a regression
Worth saying plainly: before #361 this shape pinned nothing and re-decoded
everything, so the memory ceiling is new but the time is not — 145 s to 128 s is an
improvement, just a marginal one. #361 is a clear win on the shapes it targets. This
is the shape it does not reach.
Provenance
Found while reviewing #361 (comment on the merged PR has the same numbers). Fixture
and method are in that comment; happy to take the patch.