Contents
Bug report: count(*)’s fast-path visibility gate is O(heap pages), not O(matches)
To: pg_fts maintainers
From: pg_weave (a PostgreSQL-licensed fork of pg_fts, forked from pg_fts 1.5.8,
2026-09-05)
Affects: pg_fts v1.8.3 (166b0b0), function bm25_count_dictdf_fastpath(),
pg_fts_am_scan.c
Date: 2026-09-21
This report does not assume you know pg_weave. Every claim below is checkable in your
own tree; the line numbers are from pg_fts_am_scan.c as it exists in your repository
today (we read it there, not in our fork).
The claim
bm25_count_dictdf_fastpath() answers count(*) for a single plain positive term by
(1) summing the term’s document frequency across segment dictionaries, then (2) proving
the entire heap is all-visible to the snapshot before trusting that sum as an exact
MVCC-correct count. Step (2) is implemented as one VM_ALL_VISIBLE() call per heap
block:
heap = table_open(index->rd_index->indrelid, AccessShareLock);
nblocks = RelationGetNumberOfBlocks(heap);
for (blk = 0; blk < nblocks; blk++)
{
if (!VM_ALL_VISIBLE(heap, blk, &vmbuf))
{
all_visible = false;
break;
}
}
That is at pg_fts_am_scan.c:4402 in your tree today (the if (!VM_ALL_VISIBLE(...))
line inside the for (blk = 0; blk < nblocks; blk++) loop starting a few lines above
it, function bm25_count_dictdf_fastpath, comment block above it says “Gate (4)”).
Please verify this yourselves — quote what is actually at that line in your checkout,
since line numbers drift across commits.
The cost of that loop tracks the number of heap blocks, not the number of matching documents. A term matching zero documents pays for the same per-block scan as a term matching every document in the corpus, because gate (4) runs regardless of the df computed in gate (earlier), as long as the term has any pending-segment presence check that clears.
The measurement
Host c7i.2xlarge, 1M-document corpus, 87,486-page heap, harness
/scratch/pg_weave/g38.sh (ours, not committed to either repository), two passes
agreeing to the last digit:
| query | df | latency | notes |
|---|---|---|---|
count(*) rare |
25 | 0.278 ms | |
count(*) mid |
2,505 | 0.291 ms | |
count(*) common |
196,785 | 0.278 ms | |
count(*) no-match |
0 | 0.280 ms |
Flat from df 0 to df 196,785. EXPLAIN (ANALYZE, BUFFERS) reported shared hit=8 on
every one of these runs — so the ~0.28 ms was never I/O. It was 87,486 function calls
to VM_ALL_VISIBLE(), once per heap block, every time, independent of the term.
The tell that found it, because it will generalize to your own regressions: we
had an apparent 2.2x slowdown between two benchmark runs (0.43 ms -> 0.95 ms at the
same scale) and initially suspected our own code. What ruled that out is that pg_fts
v1.8.3 measured 0.94 ms on the same table, in the same run — agreeing with our
number to 0.01 ms. Two independently-maintained forks agreeing that closely on a number
that looked like a regression is itself evidence the mechanism is shared, not that
either fork’s recent changes are innocent by coincidence. The number that actually
explained it was not the match-count ratio (df 25 vs df 196,785 — no correlation) but
the heap-size ratio: 0.43 ms / 0.95 ms / 3.81 ms at three heap sizes (1,076 MB,
2,357 MB, 9,238 MB — a straight line at roughly 3.2 ns per heap page). If two of your
own benchmark runs ever disagree on a count(*) number and the disagreement doesn’t
track the query, check whether it tracks the table size instead — that is the
signature of this gate.
Why it is a defect, not a trade-off
The path this gate exists to avoid — the ordinary posting-scan count(*) path —
answered df 25 in about 0.007 ms in our measurement. So below roughly df 9,000 on
an 87,486-page heap, the “fast” path was up to 40x slower than the code it exists
to short-circuit, and the crossover point moves with heap size, not with anything a
user can see or tune. A term that matches nothing pays the same cost as a term that
matches nearly every document — the opposite of what a reader would expect from a
function named ..._fastpath.
What we did (for context, not something we are asking you to adopt verbatim)
We shipped two changes: (1) a zero-df early-out — if no segment’s dictionary holds the
term at all, the answer is 0 regardless of visibility, so gate (4) never has to run;
(2) for the nonzero case, replacing the per-block VM_ALL_VISIBLE() loop with
visibilitymap_count(), which reads visibility-map pages and popcounts them,
turning an O(heap_pages) loop of function calls into an O(heap_pages / 32672) loop of
buffer reads. Measured on a separate, smaller heap (683 MB, not the 87,486-page one
above — the two numbers should not be divided against each other), the fixed gate ran
flat at 0.003-0.005 ms across every df, a 69-93x improvement on that heap.
The one hazard that swap introduces: visibilitymap_count() counts bits over the
whole map, including bits belonging to any block past the relation’s current end, so
a count that merely equals nblocks could in principle include stale bits past
RelationGetNumberOfBlocks(). We did not just reason our way past this — we ship the
old per-block scan as a cross-check under USE_ASSERT_CHECKING, so a debug build
re-derives and asserts agreement on every count. Whether that hazard is reachable in
your relation-truncation code path is something you would need to check independently;
in ours it is not (visibilitymap_truncate() runs inside RelationTruncate()’s
critical section under XLOG_SMGR_TRUNCATE), but that reasoning is exactly the kind
doc/CONVENTIONS.md in our tree tells us to distrust without a cross-check, which is
why the assert exists.
What was NOT measured
- We did not measure the fixed gate against pg_fts on the same table/heap — the fix was measured on a different, smaller corpus (683 MB) than the one that produced the 0.278-0.291 ms numbers above (87,486-page heap). No post-fix ratio against pg_fts is claimed anywhere in this report.
- We did not check whether pg_fts has a different code path for
count(*)on a negated term, a phrase, or a boolean combination — this report is about the single plain positive term path only, which is whatbm25_count_dictdf_fastpath()says it handles. - We did not check pg_fts versions before 1.8.3 or any version after it that may exist by the time you read this.
- We did not profile whether the fix’s
visibilitymap_count()approach interacts correctly with your merge/vacuum machinery — that would need to be re-verified in your tree, not assumed from ours, since the two trees' merge/vacuum code has diverged.
What we would need from you
Nothing required. If useful: a confirmation that pg_fts_am_scan.c:4402 (or wherever
it has moved to by the time you read this) is the function you’d want a patch against,
and whether you want the zero-df early-out and the visibilitymap_count() swap as one
patch or two. We are a fork/importer of pg_fts and are happy to send a patch adapted to
your current tree if that is useful; we understand you may have already found and
fixed this independently, or may have reasons not to want the assert-checked fallback
we carry.