Skip to content

Encoding: choose candidates from a strided sample - #124

Merged
jdatcmd merged 2 commits into
mainfrom
encoding/sample-selection
Jul 25, 2026
Merged

Encoding: choose candidates from a strided sample#124
jdatcmd merged 2 commits into
mainfrom
encoding/sample-selection

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

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_rows controls it (default 2048); 0 restores the previous exhaustive behaviour.

  • Strided, not a prefix. A prefix of a sorted or clustered column describes the head and not the tail: an id column's first 2048 values look perfectly delta-encodable whether or not the rest is, and a column sorted on another key has runs at the front that do not continue. Striding costs one multiply per value.
  • Two candidates, not one, because the sample ranks closely-matched candidates unreliably and the second full application is cheap next to the three it replaces.
  • Fixed-width only. A varlena stream is length-prefixed, so striding it means walking it anyway, and its candidate set is two (dictionary, FSST) rather than five.

Result

load stored size
encoding_sample_rows = 0 20.9 s 8,585,216
encoding_sample_rows = 2048 15.7 s 8,585,216

1.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:

  • 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 (20,000 constant values then random), the sampled size stays within 20% of exhaustive;
  • a vector smaller than the sample window has no stride and falls back to applying every candidate, which still has to work.

Writing that ratio check turned up a bug in the check rather than the code: (sum * 12) / 10 returns 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) and fuzz (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

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 ChronicallyJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 against bestLen on 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 most want distinct
    values, so encode_dict always looks better on the sample than it is. Because
    the winner is re-encoded in full, that shows up as ok == false or a losing
    length rather than a truncated dictionary.
  • The index arithmetic cannot overflow. i * stride is bounded by
    (want-1) * (n/want) < n, and the allocation is want * w only on the branch
    where want < 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, and pfree(buf) on every sample try
    leaks nothing.
  • Sampling engages only at n >= 2 * want (stride < 2 bails), so small
    chunks keep the exhaustive path, and 0 restores it entirely.
  • Varlena is genuinely untouched -- the branch is under w > 0 and 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>
@jdatcmd

jdatcmd commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

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 (g / 20) % 50 at the default vector size, where the stride is 4, so runs of 20 still appear as runs of 5 and rank fine: it passed with pure striding, so it proved nothing. The fixture now does what your analysis describes and raises chunk_group_row_limit to 100000, where the stride is 48 and its runs of 30 vanish entirely while remaining the best encoding by a wide margin. Verified to discriminate: replacing the windowed sample with the pure stride fails that check and only that check.

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 $(...) compared as a string would have silently passed. That is the shape to watch.

Gate: full 15 through 19 matrix on the windowed sampler, ALL VERSIONS PASSED.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants