Problem
PgColumnarBloomBuild sizes each filter from the value count, not the distinct count:
/* columnar_bloom.c */
#define BLOOM_BITS_PER_VALUE 10
#define BLOOM_MAX_BITS (1u << 21) /* 256 KB cap per chunk */
...
nbits = next_pow2(n * BLOOM_BITS_PER_VALUE); /* n = number of hashes, not distinct */
A filter is stored per column per stripe, so n is pgcolumnar.stripe_row_limit
(default 150,000). That gives 1.5 Mbit, which next_pow2 rounds to exactly 2^21 —
256 KB for every bloomable column of every stripe, regardless of that column's
cardinality. A column with 5 distinct values gets the same 256 KB filter as a unique one.
Measured on 2M ClickBench hits rows (105 columns), distinct values per 150,000-row
stripe:
| distinct per stripe |
columns |
filter utilisation |
| < 64 |
68 |
~99.98% empty, still 256 KB each |
| 64 – 1k |
11 |
>99% empty |
| 1k – 10k |
10 |
>93% empty |
| 10k – 50k |
4 |
>66% empty |
| >= 50k |
12 |
reasonably used |
Sized by distinct count these filters need 11.4 Mbit; they are allocated 220 Mbit
— 19.3x over-provisioned. (The next_pow2 rounding is a further ~40% on top: 1.5 Mbit
of intent becomes 2.097 Mbit allocated, i.e. 14 bits/value rather than the 10 BLOOM_K = 6
was chosen for.)
Note this is not the n < 64 early-out or the BLOOM_MAX_BITS refusal doing their job —
both test the value count, which is 150,000 here, so neither fires. Every column gets a
filter.
Cost
All arms encode_effort = fast (FSST out of the picture), serial COPY, 2M rows, every
arm asserted at exactly 2,000,000 rows:
| arm |
load |
bloom TOAST written |
pglz_compress in profile |
| bloom on (default) |
44.6s |
58 MB |
10.4% |
| bloom off |
31.7s |
0 MB |
absent |
bloom on, filter SET STORAGE EXTERNAL |
40.7s |
361 MB |
absent |
- Bloom costs 12.9s of a 44.6s load — 29%.
- Uncompressed the filters are 361 MB against a 262 MB table — the index is larger than
the data.
How this was found, and a correction worth recording
This started as "why is pglz_compress at 7.7% when pgcolumnar.compression = zstd?"
Answer: pgcolumnar.bloom.filter is bytea storage=x, so TOAST compresses it with
default_toast_compression, which is stock pglz. Turning bloom off makes pglz_compress
vanish from the profile entirely — that is the caller, established by ablation.
My first hypothesis was that pglz on bloom filters was waste, since bloom filters are
normally high-entropy and near-incompressible. SET STORAGE EXTERNAL refuted it: pglz
takes 361 MB down to 58 MB, a 6.2x ratio, for 3.9s. That is one of the better trades in
the write path and should be left alone.
The 6.2x is the tell: filters compress that well only because they are almost entirely
zeros. pglz was masking the over-provisioning, not causing it. Correctly sized filters
would compress far less and this would never have shown up in a profile.
So: do not change default_toast_compression or the column's storage. (lz4 is not
even available — this build lacks --with-lz4.) The problem is upstream of compression.
Suggested direction
Deduplicate the hashes before sizing. A bloom filter's membership set is the distinct
set, so a filter built over distinct hashes answers every probe identically — this is a
size/time change, not a semantic one. PgColumnarFsstDictWins already has a cheap
open-addressing distinct probe over a corpus that could be modelled on.
Two things to measure before committing, not assume:
- Does dedup cost more than it saves on the 12 genuinely high-cardinality columns?
Those are the ones where the current sizing is roughly right and a dedup pass is pure
overhead.
- What happens to read-side skip rates? A smaller filter has a higher false-positive
rate for the same k. Sizing by distinct count at 10 bits/value should preserve the ~1%
target, but that needs verifying against actual chunk-group skip counts, not arithmetic.
A secondary, independent win: next_pow2 rounding wastes up to 2x. Allocating
ceil(n_distinct * 10 / 8) bytes directly, or rounding to a byte rather than a power of
two, removes that — the modulo-free indexing a power of two buys is not obviously worth 40%
of the space.
Caveats
- One fixture shape (ClickBench
hits), which is wide and heavily low-cardinality. A
narrow high-cardinality table would show much less over-provisioning — possibly none.
- Two foreign CPU-bound backends (2 of 16 cores) were present for all arms equally.
- Bloom sizing is not user-tunable today:
pgcolumnar.enable_bloom_filter is on/off only.
The comment at columnar_bloom.c:101 already anticipates a reloption.
Problem
PgColumnarBloomBuildsizes each filter from the value count, not the distinct count:A filter is stored per column per stripe, so
nispgcolumnar.stripe_row_limit(default 150,000). That gives 1.5 Mbit, which
next_pow2rounds to exactly 2^21 —256 KB for every bloomable column of every stripe, regardless of that column's
cardinality. A column with 5 distinct values gets the same 256 KB filter as a unique one.
Measured on 2M ClickBench
hitsrows (105 columns), distinct values per 150,000-rowstripe:
Sized by distinct count these filters need 11.4 Mbit; they are allocated 220 Mbit
— 19.3x over-provisioned. (The
next_pow2rounding is a further ~40% on top: 1.5 Mbitof intent becomes 2.097 Mbit allocated, i.e. 14 bits/value rather than the 10
BLOOM_K = 6was chosen for.)
Note this is not the
n < 64early-out or theBLOOM_MAX_BITSrefusal doing their job —both test the value count, which is 150,000 here, so neither fires. Every column gets a
filter.
Cost
All arms
encode_effort = fast(FSST out of the picture), serialCOPY, 2M rows, everyarm asserted at exactly 2,000,000 rows:
pglz_compressin profilefilterSET STORAGE EXTERNALthe data.
How this was found, and a correction worth recording
This started as "why is
pglz_compressat 7.7% whenpgcolumnar.compression = zstd?"Answer:
pgcolumnar.bloom.filterisbytea storage=x, so TOAST compresses it withdefault_toast_compression, which is stockpglz. Turning bloom off makespglz_compressvanish from the profile entirely — that is the caller, established by ablation.
My first hypothesis was that pglz on bloom filters was waste, since bloom filters are
normally high-entropy and near-incompressible.
SET STORAGE EXTERNALrefuted it: pglztakes 361 MB down to 58 MB, a 6.2x ratio, for 3.9s. That is one of the better trades in
the write path and should be left alone.
The 6.2x is the tell: filters compress that well only because they are almost entirely
zeros. pglz was masking the over-provisioning, not causing it. Correctly sized filters
would compress far less and this would never have shown up in a profile.
So: do not change
default_toast_compressionor the column's storage. (lz4is noteven available — this build lacks
--with-lz4.) The problem is upstream of compression.Suggested direction
Deduplicate the hashes before sizing. A bloom filter's membership set is the distinct
set, so a filter built over distinct hashes answers every probe identically — this is a
size/time change, not a semantic one.
PgColumnarFsstDictWinsalready has a cheapopen-addressing distinct probe over a corpus that could be modelled on.
Two things to measure before committing, not assume:
Those are the ones where the current sizing is roughly right and a dedup pass is pure
overhead.
rate for the same k. Sizing by distinct count at 10 bits/value should preserve the ~1%
target, but that needs verifying against actual chunk-group skip counts, not arithmetic.
A secondary, independent win:
next_pow2rounding wastes up to 2x. Allocatingceil(n_distinct * 10 / 8)bytes directly, or rounding to a byte rather than a power oftwo, removes that — the modulo-free indexing a power of two buys is not obviously worth 40%
of the space.
Caveats
hits), which is wide and heavily low-cardinality. Anarrow high-cardinality table would show much less over-provisioning — possibly none.
pgcolumnar.enable_bloom_filteris on/off only.The comment at
columnar_bloom.c:101already anticipates a reloption.