Skip to content

Bloom filters are sized by value count, not distinct count: 19.3x over-provisioned, 29% of load time, 361 MB raw against a 262 MB table #467

Description

@ChronicallyJD

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:

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions