Encoding: choose candidates from a strided sample - #124
Conversation
Step 1 of design/CASCADE_ENCODING_PLAN.md, built because the measurement in #123 said it was worth it: trials that lose were 9.0 s of a 20.8 s load. Each candidate is now estimated on a strided sample of the vector and only the best two are applied in full. Two rather than one because the sample ranks closely-matched candidates unreliably and the second application is cheap next to the three it replaces. pgcolumnar.encoding_sample_rows controls it, default 2048, and 0 restores the exhaustive behaviour. Strided, not a prefix. A prefix of a sorted or clustered column says nothing about the rest: an id column's first values look perfectly delta-encodable whether or not the tail is, and a column sorted on another key has runs at the front that do not continue. Striding costs one multiply per value and removes that class of misjudgement. Only fixed-width columns are sampled. A varlena stream is length-prefixed, so a strided sample means walking it anyway, and its candidate set is two rather than five, so there is little to win. Measured on the same 6,000,000-row load: 20.9 s to 15.7 s, a 1.33x improvement, with byte-identical output (8,585,216 both ways) and identical checksums. That recovers about 58% of the discarded-trial cost. Byte-identical output is a property of this data, not a guarantee, so the suite asserts what must always hold: the rows read back are identical with sampling on and off, and both match the heap oracle. For a column whose head does not describe its tail, it also asserts the sampled size stays within 20% of exhaustive. The small-chunk fallback (no stride available) is covered too. Correctness cannot be affected by a bad choice: whatever is chosen is applied in full and recorded per vector in the encoding descriptor, and the reader decodes what the descriptor says. differential (201 checks against the heap oracle) and fuzz (250) both pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ChronicallyJD
left a comment
There was a problem hiding this comment.
Reviewed at 5173040. The structure is right: estimate on a sample, apply the
finalists in full, and never let the sample decide anything a reader depends on.
Approving.
Good to see the ceiling corrected to 9.0 s with the winner's own encode measured
at 6.6 s -- 20.9 to 15.7 is 5.2 s, which is 58% of 9.0 and 1.33x, all consistent.
That is the number the next person should be measured against.
Verified
- Correctness cannot ride on the sample. Both finalists are re-run over the
whole chunk, the result is compared againstbestLenon full-chunk sizes, and
the descriptor records what was actually applied. A sample that misjudges
produces a bigger chunk, never a wrong one. - That also contains the one sampling error that would otherwise bite. A
strided sample of a high-cardinality column can show at mostwantdistinct
values, soencode_dictalways looks better on the sample than it is. Because
the winner is re-encoded in full, that shows up asok == falseor a losing
length rather than a truncated dictionary. - The index arithmetic cannot overflow.
i * strideis bounded by
(want-1) * (n/want) < n, and the allocation iswant * wonly on the branch
wherewant < n, so it is strictly smaller than the raw buffer. - The top-two selection is correct, including the shift on a new best and the
strict<that makes ties deterministic, andpfree(buf)on every sample try
leaks nothing. - Sampling engages only at
n >= 2 * want(stride < 2bails), so small
chunks keep the exhaustive path, and0restores it entirely. - Varlena is genuinely untouched -- the branch is under
w > 0and returns from
inside, so the dict/FSST path below is reached exactly as before.
Finding 1: striding is the wrong sample shape for RLE specifically
The argument for striding over a prefix is right, and it is the correct fix for
delta, DoD, FOR and Gorilla: those read a global property (range, smoothness,
successive difference), and a strided sample preserves it up to a scale factor.
Delta on a stride of 4 sees deltas 4x larger, costing two bits of estimated
width, which shifts every delta-family candidate equally and leaves the ranking
intact.
Run-length is different in kind. It reads a local property, and striding is
precisely the transformation that destroys locality: taking every stride-th
value makes any run shorter than stride disappear entirely. At the default
10,000-row chunk the stride is 4, so a column whose values repeat in runs of two
or three shows no runs at all in the sample, and a column with runs of four to
eight shows them at a quarter length, which is usually enough for RLE to look
worse than the alternatives.
The effect scales with the stride, and the stride is user-tunable:
chunk_group_row_limit is documented and adjustable, and at 100,000 rows the
stride becomes 48. A status or category column in a time-ordered table -- runs of
ten to forty, one of the shapes RLE exists for -- would be invisible to the
sample at that setting, and the column would silently store many times larger
than it does today. No error, no test failure, just a bigger table.
The suite cannot currently see this. se_h.k is g % 977, which cycles with
period 977 and therefore has runs of length one, so RLE is not a contender for it
either way. The only run in any fixture is se_head_h's leading 20,000 constants,
which survives any stride the code can produce. A column of, say, (g / 20) % 50
would be the missing case.
The standard fix is to sample in blocks rather than single values: take k
contiguous windows spread evenly across the vector, totalling want values, e.g.
32 windows of 64. That keeps the global coverage that makes striding better than a
prefix, while preserving enough local structure for run-length to be visible, and
it is the same one-multiply-per-window cost. Delta and friends are unaffected by
the change, since within a window successive values are genuinely successive --
arguably it makes their estimates more faithful, since the stride-scaling
distortion disappears too.
Not blocking: correctness is unaffected, the default stride of 4 limits the damage
today, and the measured result on your data is byte-identical. But I would take it
before anyone tunes chunk_group_row_limit upward, because that is the setting
that turns a small bias into a large one.
Finding 2: small non-zero values of the GUC silently disable encoding
The bound is 0, INT_MAX. 0 is documented as "exhaustive", and large values
fall back to exhaustive because n <= want bails. But the low end has no such
protection: with encoding_sample_rows set to 1 through roughly 16, the sample is
a handful of values, every candidate's fixed header exceeds sampleN * w, no
candidate beats best[]'s initial sampleRawLen, both cand[] entries stay
COLUMNAR_ENCODING_NONE, the apply loop does nothing, and the chunk is stored
raw.
So a plausible tuning attempt -- someone deciding a sample of 8 sounds cheap --
turns encoding off for every large chunk and reports nothing. A floor in
DefineCustomIntVariable (64 or 128) makes that unreachable, and costs a one-word
change. Worth pairing with a sentence in configuration.md, since the current
text explains 0 but not that the useful range has a bottom.
Minor
Nicely caught on the (sum * 12) / 10 numeric-versus-integer comparison in the
test. Worth noting for the next one that -le failing that way is loud, but the
same expression inside a $(...) that gets compared as a string would not have
been -- the shape to watch for is a check whose failure mode is an error rather
than a mismatch.
Verdict
Approving. The design puts the sample where it cannot cost correctness, the
measurement is honest about what it recovered against the corrected ceiling, and
the tests assert the invariant that matters rather than the byte-identity that
happened. Finding 1 is a size risk that grows with a setting users are invited to
change; finding 2 is a one-line bound.
…ting Both from the #124 review. Finding 1: striding is the wrong shape for run-length specifically. The delta family, frame-of-reference and Gorilla read a global property and survive a stride up to a scale factor, but run-length reads a local one, and taking every stride-th value deletes any run shorter than the stride. The stride grows with chunk_group_row_limit, which is documented and adjustable, so at 100000 rows per vector a status column with runs of ten to forty becomes invisible to the sample and stores several times larger, with no error and no test failure. The sample is now windowed: evenly spread windows of 64 consecutive values. Spread keeps the global coverage that made striding better than a prefix; consecutive keeps runs visible at their true length, and removes the stride scaling from the delta family's estimates too. The review also pointed out that no fixture could see this, and the first one I wrote could not either: with the default vector size the stride is 4, so runs of 20 still show as runs of 5 and rank fine. The fixture now raises chunk_group_row_limit to 100000, where the stride is 48 and its runs of 30 disappear entirely while remaining the best encoding by a wide margin. Verified to discriminate: with the windowed sample replaced by the pure stride, that check fails and only that one. Finding 2: a small non-zero setting silently disabled encoding. Between 1 and roughly 16 the sample is a handful of values, every candidate's fixed header exceeds it, nothing beats raw, and the vector was stored unencoded. Rather than reject the value, anything below 128 now means the exhaustive path, so a plausible tuning attempt gets slower selection rather than a silently much larger table. Covered by a test that a setting of 8 still encodes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both findings taken. Finding 1 is right, and the fixture I first wrote could not see it either. The sample is now windowed: evenly spread windows of 64 consecutive values. Spread keeps the global coverage that made striding better than a prefix; consecutive keeps runs visible at their real length, and as you note it removes the stride-scaling distortion from the delta family's estimates as a side effect. Your point about the missing fixture was the more useful half. My first attempt used Finding 2, resolved as a fallback rather than an error: any value below 128 now means the exhaustive path. A plausible tuning attempt then gets slower selection instead of a silently much larger table, which seemed better than rejecting the value outright. A test asserts that a setting of 8 still encodes to within 20% of exhaustive. Your minor is the one I will carry forward: a check whose failure mode is an error rather than a mismatch can hide either way. The numeric-versus-integer comparison was loud, but the same expression inside a Gate: full 15 through 19 matrix on the windowed sampler, ALL VERSIONS PASSED. |
Builds the sampling selector the measurement in #123 justified: trials that lose were 9.0 s of a 20.8 s load, and that is the part sampling removes.
What it does
Each candidate is estimated on a strided sample of the vector, and only the best two are applied in full.
pgcolumnar.encoding_sample_rowscontrols it (default 2048);0restores the previous exhaustive behaviour.Result
encoding_sample_rows = 0encoding_sample_rows = 20481.33x on write with byte-identical output and identical checksums: on this data the sample picks exactly what the exhaustive search picks. That recovers about 58% of the 9.0 s ceiling.
What the tests assert, and what they deliberately do not
Byte-identical output is a property of this data, not a guarantee, so the suite does not assert it. It asserts what must always hold:
Writing that ratio check turned up a bug in the check rather than the code:
(sum * 12) / 10returns numeric, and[ ... -le 183723.600000000000 ]is not an integer comparison, so it errored to a failure. The sizes were identical all along. Cast added.Correctness cannot be affected by a bad choice: whatever is chosen is applied in full and recorded per vector in the encoding descriptor, and the reader decodes what the descriptor says.
differential(201 checks against the heap oracle) andfuzz(250) both pass.Gate
Full 15 through 19 matrix, since this changes the write path for every columnar table: ALL VERSIONS PASSED.
🤖 Generated with Claude Code