Size a bloom filter by its distinct count, not its value count (#467) - #468
Conversation
The failing test, committed before the fix so the defect is recorded at the seam
rather than described.
Measured on this branch, PG17, 20,000 rows in one row group:
-- filter bytes: lo (5 distinct) = 32773, hi (20000 distinct) = 32773
Byte-identical. A column with five distinct values gets exactly the filter a
unique column gets. That also confirms the issue's arithmetic independently:
next_pow2(20000 * 10) = 262,144 bits = 32,768 bytes, plus the 5-byte header.
All six premises pass, so the failure is the defect and not the fixture: the row
count, the single row group, both distinct counts, and the existence of both
filter rows are each asserted before any size is compared. octet_length of a
missing row is empty rather than 0, and an empty side compares equal to another
empty side (#418), so the existence premises are load-bearing.
The three non-size arms pass NOW and must still pass after the fix: an absent
value is excluded, a present value is found, and results are identical with the
filter and without it. Those are what stop the size win being satisfied by a
filter that no longer skips, which every size check here would otherwise call a
success.
The assertion is the RELATION between two cardinalities in one table, not a
literal byte count, so it does not snapshot today's next_pow2 constants.
Not registered in SUITES yet: it is red on purpose and must not enter the gate
until the fix lands with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9W9N2tvsvK7hndJgTmqJf
…467) PgColumnarBloomBuild sized from the value count, which is the stripe's row count, and both of its guards tested that same n. At the default stripe neither fires: `n < 64` is false because n is 150,000, and the BLOOM_MAX_BITS refusal only fires above n = 209,715. So every bloomable column of every stripe got next_pow2(n * 10) bits whatever its cardinality -- a column with five distinct values got the same 256 KB filter a unique column got. CJD measured 19.3x over-provisioned on 2M ClickBench rows: filters 361 MB raw against a 262 MB table, and 29 percent of load time. This is a size and time change, not a semantic one. A bloom filter's membership set IS its distinct set, so a filter sized from the distinct count answers every probe exactly as the old one did. The suite asserts that rather than assuming it. Measured on the red fixture, 20,000 rows in one row group, PG17: before lo (5 distinct) = 32773 hi (20000 distinct) = 32773 after lo (5 distinct) = 13 hi (20000 distinct) = 32773 13 bytes is the 5-byte header plus next_pow2(5 * 10) = 64 bits. The high-cardinality column is untouched, which is the point: this removes waste, it does not trade accuracy for size. TWO GUARDS, MOVED DIFFERENTLY, AND THE DIFFERENCE IS DELIBERATE. `n < 64` STAYS on the value count. It means "this chunk is too small to be worth a filter", which is about the chunk and not its cardinality. Moving it to the distinct count would drop the filter entirely from every low-cardinality column, 68 of 105 on the ClickBench fixture, and those are exactly where an equality probe skips best: if the value is not among the five present, the whole group goes. A 64-bit filter costs 8 bytes. I raised this on the issue rather than deciding it silently; CJD may still overrule it. BLOOM_MAX_BITS MOVES to the distinct count. The saturation it guards against is a function of how many distinct values compete for the bits, not how many times they repeat, so testing n refused a filter to a large stripe of low-cardinality data for a problem that data does not have. The dedup is open addressing at load factor <= 0.5 over a transient table. Hash 0 is counted separately rather than given a sentinel, because a zeroed table cannot distinguish it from an empty slot and no value may be excluded from the filter. DO NOT "FIX" THE COMPRESSION RATIO LATER. This will make pglz look worse on pgcolumnar.bloom.filter and that is correct. CJD established by ablation that pglz takes 361 MB to 58 MB, 6.2x for 3.9s -- a good trade, and NOT to be replaced by changing default_toast_compression or the column storage. The 6.2x was the symptom: filters compressed that well because they were nearly all zeros. Correctly sized filters compress far less. That is the waste leaving, not a regression appearing. Test: test/bloom_sizing.sh, 10 checks, registered inside SUITES and verified by sourcing the array. It asserts the RELATION between two cardinalities in one table, not a literal byte count, so it does not snapshot today's constants. Six premises run before any size is compared, and three arms that passed BEFORE the fix must still pass after it -- an absent value excluded, a present value found, results identical with the filter and without it. Those are what stop a size win being satisfied by a filter that no longer skips. Regressions on PG17: native_bloom, bloom_setting, bloom_lazy, native_skip, native_vecskip, pushdown_report, and differential (201 checks). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9W9N2tvsvK7hndJgTmqJf
|
Ran a high-effort multi-agent review against this branch before asking you to look at it. 22 candidate findings verified, 5 refuted, 7 kept — and the worst one is a regression I introduced while arguing the opposite on the issue. Posting them before I fix anything, because a clean PR would hide what was one merge away. 1. The
|
… able to fail (#467) Answers a high-effort review of the previous commit. Seven findings, and the two that mattered were a regression I introduced and a claim I made about my own test that was false. THE REGRESSION. Moving the BLOOM_MAX_BITS refusal to the distinct count left the refusal AFTER the dedup, so the allocation that guard exists to avoid happened first. Above 67,108,864 values in one row group the palloc0 passed MaxAllocSize and the load ERRORed where it succeeded before. Below that it was a silent tax: at 1,000,000 rows per stripe, 8 MB allocated and 1M hashes walked per bloomable column per stripe, then the filter discarded anyway. Fixed by removing the allocation, not by reordering around it. Core's lib/hyperloglog.h holds ~1 KB of state whatever n is, so there is nothing to overflow and no per-column table walk for the all-unique columns that gain nothing from dedup. It also takes exactly the uint32 hashes this code already had, and drops 50 lines of hand-rolled open addressing with its own empty-slot and load-factor reasoning. The count is now an ESTIMATE and that is safe here specifically: next_pow2 quantises to powers of two, so 1.6% standard error almost never changes the size chosen. Under-estimating gives a smaller filter, which raises the FALSE POSITIVE rate -- a wasted decode. False negatives are what would break a query, and they remain impossible, because every value still sets its k bits whatever nbits came out. THE TEST CLAIMED A GUARD IT DID NOT HAVE. The header said shrinking a filter by making it useless "would satisfy every size check here" and that the selectivity arm caught it. It did not: enable_bloom_filter gates only the read-side probe, so all ten checks passed with no bloom probe ever consulted. The fix needed two attempts, and the first was the same defect again. A skip oracle on a sequential fixture reported 9 groups removed -- by the MIN/MAX ZONE MAPS, not by bloom. Disabling the probe left the number at 9. So the values are now scattered ((g * 7919) % 20000), every group's min/max spans the full range, zone maps can exclude nothing, and the suite measures the probe on AND off: -- chunk groups removed: bloom on = 9, bloom off = 0 The `bloom off = 0` premise is what makes `bloom on > 0` mean anything. Without it the check passes on a fixture where zone maps do the work. TWO MORE FROM THE SAME REVIEW. `${LO:-0} * 8 <= ${HI:-0}` evaluated 0 <= 0 and PASSED when both filters were missing -- the #418 shape, four lines under a comment about avoiding the #418 shape. Both sizes now go through check_num, which refuses a non-number, and the comparison uses the raw values with no default. Nothing in the tree ever crossed the refusal threshold: the largest stripe_row_limit in any bloom suite was 20,000 against a 209,715 threshold, so both the old and the new refusal were dead branches under the full matrix. That is how the allocation regression would have shipped green. A 300,000-row group now exercises it: the filter is refused, and the load completes rather than erroring, which is the regression above asserted directly. Also cut CJD's four measurements from the source comment, where they read as reproducible facts nothing in the repo can reproduce. They stay here, attributed: 19.3x over-provisioned, 361 MB of filters against a 262 MB table, 29 percent of load time, 68 of 105 columns under 64 distinct per stripe -- all measured by CJD on 2M ClickBench rows in #467. 19 checks. Regressions on PG17: native_bloom, bloom_setting, bloom_lazy, native_skip, native_vecskip, pushdown_report, differential (201). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9W9N2tvsvK7hndJgTmqJf
|
Reviewed. The
|
| major | initHyperLogLogError |
addHyperLogLog |
estimateHyperLogLog |
freeHyperLogLog |
|---|---|---|---|---|
| 15 / 16 / 17 / 18 / 19 | ok | ok | ok | ok |
Signatures are byte-identical between 15 and 19 — no drift:
extern void initHyperLogLogError(hyperLogLogState *cState, double error);
extern void addHyperLogLog(hyperLogLogState *cState, uint32 hash);
extern double estimateHyperLogLog(hyperLogLogState *cState);
extern void freeHyperLogLog(hyperLogLogState *cState);So the header choice is safe. (My first probe looked under include/server/ and reported it
missing on all five — that was my path being wrong, not yours. pg_config --includedir-server
is include/postgresql/server.)
n < 64 on the value count: agreed
Your reasoning holds and the adversarial case does not overturn it. The guard means "this
chunk is too small to warrant a filter", which is about the chunk rather than its
cardinality, and moving it would strip filters from 68 of 105 columns — the ones where an
equality probe skips best, since a value absent from the five present eliminates the whole
group.
The case against would be a low-cardinality column whose values are spread across every
group — a status column in unsorted data, where every stripe holds all five values and the
filter never skips anything. That filter is now 8 bytes rather than 256 KB, and the same
column in clustered data skips well. Cheap enough to keep for the upside.
The hyperLogLog argument checks out
Under-estimating d gives a smaller filter and a higher false positive rate, never a
false negative, because every value still gets its k bits set whatever nbits came out.
The worst outcome is decoding a group that could have been skipped — a performance cost,
bounded by HLL's 1.6% standard error and then quantised away by next_pow2.
Worth calling out that you found and fixed a real bug on the way: the exact-count first
version allocating past MaxAllocSize above 67,108,864 values in a group, on exactly the
large stripes the BLOOM_MAX_BITS refusal exists to short-circuit. Fixed state removes that
rather than reordering around it, which is the better fix.
BLOOM_MAX_BITS moving to the distinct count is right for the same reason saturation is
about how many distinct values compete for bits, not how often they repeat.
The three unchanged-behaviour arms are the important part of that suite
Absent value excluded, present value found, results identical with and without the filter —
passing before the fix and required to pass after. Shrinking a filter by making it useless
satisfies every size check, and a size-only suite would call that a success. That is the
assertion I would have asked for if it were not already there.
Asserting the relation between two cardinalities rather than a literal byte count is also
the right choice; it survives the next BLOOM_BITS_PER_VALUE tuning.
One request before merge
The full 15 to 19 matrix, as you offered. This touches the write path and the read path,
and our bar is full suites on 18 and 19 with preflight on all five. The HLL header is fine
across majors by inspection, but inspection is not a build.
Thanks for the note about not "fixing" the compression ratio later. That is the
counter-intuitive part and it will absolutely be re-profiled by someone: the 6.2x pglz ratio
was the symptom, and correctly sized filters compressing less is the waste leaving rather
than a regression arriving.
Approving on the matrix result.
|
Full PG15-19 matrix, green, on the post-review branch ( No Two things I checked rather than assumed, because
That matters here more than usual: the hyperLogLog path had only ever executed on PG17, and it runs per bloomable column per stripe on the write path, so a difference on another major would have shown up on every table at once. The 300,000-row arm that crosses the refusal threshold also ran on all five, which is new coverage — no bloom suite in the tree had ever crossed it. Ready for review. The judgement I would most like you to push on is the estimate: |
ChronicallyJD
left a comment
There was a problem hiding this comment.
Matrix is convincing, and the two things you checked rather than assumed are the right two —
bloom_sizing=PASS appearing exactly five times is what rules out the silent-skip case, and
the hyperLogLog path having previously run only on PG17 is exactly the kind of write-path
code where a per-major difference would surface on every table at once.
Approving. One piece of arithmetic on the judgement you asked me to push on, non-blocking,
because I think it is a follow-up rather than a change to this PR.
The estimate: your reasoning holds, and here is where it is thinnest
Agreed on the part that matters: false negatives stay impossible, because every value
sets its k bits whatever nbits came out, so a wrong d can only cost a wasted decode. That
is the difference between a performance question and a correctness one, and this is firmly
the former.
Where it is thinnest is the quantisation argument. next_pow2 usually absorbs the error, but
when it does not the step is not small:
k=6, 10 bits/value (intended) FP = 0.84%
k=6, 5 bits/value (one power low) FP = 11.6% ~14x worse
BLOOM_K is fixed at 6, which is chosen for ~10 bits/value. Dropping one power of two
halves bits-per-value and k is then far from optimal for the new size, so the FP rate does
not degrade gently — it goes 0.84% to 11.6%.
For that to happen, d * 10 has to land in the ~2.3% of log-uniform range just below a power
of two and HLL has to under-estimate. So roughly 1% of bloomable column-stripes, each
getting a filter that fails to skip about one probe in nine instead of one in 119.
Why I still think it ships as-is
The comparison is not against a perfect filter, it is against what we had this morning: every
column at 256 KB, 19.3x over-provisioned. Trading ~1% of column-stripes into a higher FP rate
for that is clearly the right side of the deal, and the failure mode is a decode we could have
skipped rather than a wrong answer.
The follow-up, if you want it
Two obvious mitigations, and they pull against each other, which is why I would not bolt
either on here:
- Round the estimate up before
next_pow2(sayd * 1.05). Removes nearly all of the
under-shoot, but pushes some columns that were correctly sized up a power of two — paying
2x space on those to protect ~1% of the others. - Pick
kfrom the bits-per-value actually allocated rather than fixing it at 6. That
removes the cliff instead of avoiding it: at 5 bits/value the optimal k is ~3, which gives
FP ~14% -> ~5%. It is a bigger change and it alters filters for every column, so it wants
its own PR and its own skip-rate measurement.
The second is the more principled one and it is orthogonal to this — BLOOM_K was already
fixed at 6 while nbits moved with n, so the mismatch predates #467. Worth an issue rather
than scope on this.
Approved on the matrix.
|
Approved, and CI-green — but this went
Your own verification recipe, which is what I used on #462: bash -c 'source <(sed -n "/^SUITES=(/,/isolation)/p" test/run_all_versions.sh)
echo "count=${#SUITES[@]}"; echo "last=${SUITES[-1]}"
case " ${SUITES[*]} " in *" bloom_sizing "*) echo IN;; *) echo MISSING;; esac'Expect This line has now conflicted four times today#459 vs #462, #460 vs #462, #462 vs this, and #444 is queued behind it. That is not bad luck, Worth fixing once the queue drains — one name per line, or generating the list from No re-review needed from me after the rebase; my approval stands on the code, which I have |
# Conflicts: # test/run_all_versions.sh
# Conflicts: # test/run_all_versions.sh
…er line (#469) Two problems, and the second was the serious one. The suite list was a single backslash-continued line, so every pull request that added a suite edited that line and any two conflicted by construction. #469 counted four in one day; four more happened the night #446, #468 and #444 landed. It is now one name per line, so two such branches touch two different lines. The dangerous part was never the conflict, it was the resolution. Appending the new name after the closing paren is valid shell that `bash -n` accepts. I had recorded it as "leaves a stray command that fails at run time"; that is wrong, and measuring it while writing this test is what corrected it: SUITES=(alpha beta gamma) stray_name -> stray_name: command not found -> ${#SUITES[@]} is 0 `NAME=value cmd` scopes the assignment to that one command and an array literal is no exception, so the array is left UNSET and the matrix runs no suites at all. The runner's "NO SUITES RAN" guard is the backstop. And the check that should have caught it could not. harness_selftest derived the list with an awk range plus sed plus grep, which is a reimplementation of bash's array parsing, and the two disagree on precisely this mistake: bash sees no array, awk sees a full list plus the stray name as a member. So "every suite is registered" passed while the array was destroyed. Measured: against a runner carrying the mistake, the awk parser reports 130 names where bash reports 0. The runner now answers `--list-suites`, handled before the run lock because harness_selftest calls it from inside a running matrix. The gate asks the runner instead of parsing it, so there is one parser, bash's, and no way for the two to drift. Verified by removal: restoring the awk parser turns the new checks red. The reformat holds the list byte-identical and in order, compared through `--list-suites` before and after rather than by reading the diff, which is the one thing this file's history says never to trust. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L2DvnWDM7g27ubDCQdXhky
Closes #467. Two commits: the red test, then the fix.
Measured
Red fixture, 20,000 rows in one row group, PG17:
13 bytes is the 5-byte header plus
next_pow2(5 * 10)= 64 bits. The high-cardinality column is untouched — this removes waste, it does not trade accuracy for size.The before row also confirms your arithmetic independently:
next_pow2(20000 * 10)= 262,144 bits = 32,768 bytes + header.Two guards, moved differently, on purpose
n < 64stays on the value count. It means "this chunk is too small to be worth a filter", which is about the chunk, not its cardinality. Moving it to the distinct count drops the filter entirely from all 68 of your 105 low-cardinality columns — and those are where an equality probe skips best: if the value is not among the five present, the whole group goes. A 64-bit filter is 8 bytes.This is the sub-decision I flagged on the issue rather than taking silently. You may still overrule it — it is one line and the suite would need a different assertion for the low-cardinality column, nothing more.
BLOOM_MAX_BITSmoves to the distinct count. The saturation it guards against is a function of how many distinct values compete for the bits, not how many times they repeat. Testingnrefused a filter to a large stripe of low-cardinality data for a problem that data does not have.The test, and what stops it lying
test/bloom_sizing.sh, 10 checks, registered insideSUITESand verified by sourcing the array rather than reading the diff.It asserts the relation between two cardinalities in one table, not a literal byte count, so it will not fail the next time
BLOOM_BITS_PER_VALUEis tuned.Three arms passed before the fix and must still pass after: an absent value is excluded, a present value is found, and results are identical with the filter and without it. Those exist because shrinking a filter by making it useless would satisfy every size check in the suite. That is the failure a size-only test calls a success.
Six premises run before any size is compared, including that both filter rows exist —
octet_lengthof a missing row is empty, not 0, and an empty side compares equal to another empty side (#418).Do not "fix" the compression ratio later
This will make pglz look worse on
pgcolumnar.bloom.filter, and that is correct. YourSET STORAGE EXTERNALablation settled that pglz taking 361 MB to 58 MB is a good trade and must not be replaced by changingdefault_toast_compressionor the column storage.Restating your own conclusion in the commit because it is counter-intuitive and someone will re-profile this: the 6.2x ratio was the symptom. Filters compressed that well because they were nearly all zeros. Correctly sized filters compress far less — that is the waste leaving, not a regression appearing.
Regressions
PG17:
native_bloom,bloom_setting,bloom_lazy,native_skip,native_vecskip,pushdown_report, anddifferential(201 checks).Not yet run on the full PG15-19 matrix; happy to before merge if you want it, as I did for #459 and #460.
@ChronicallyJD for review.