Skip to content

perf(prefilter): skip 0-cardinality auto-promoted prefilters - #226

Merged
JustMaier merged 1 commit into
mainfrom
perf/auto-prefilter-skip-zero-card
Apr 25, 2026
Merged

perf(prefilter): skip 0-cardinality auto-promoted prefilters#226
JustMaier merged 1 commit into
mainfrom
perf/auto-prefilter-skip-zero-card

Conversation

@JustMaier

Copy link
Copy Markdown
Contributor

Summary

Auto-prefilter promotion was registering every hot filter shape whose compute_filters returned Ok(bitmap) — including bitmaps with 0 slots. A 0-cardinality prefilter can never substitute a real query (substitute path produces empty results, same as running the query directly), so registering it just:

  1. Burns a slot in the bounded prefilter registry (default cap = 32). With cap reached, useful candidates get rejected with prefilter registry full.
  2. Adds a stale entry to the merge thread's prefilter-refresh loop. Each refresh holds the FilterField read lock for 80–180 ms while compute_filters re-runs.

Impact (when paired with #224)

Pre-#224 the auto-promotion code was dead, so this never fired. After #224 unlocks it, registry fills with garbage within minutes of startup. Measured locally: flush apply mean regressed from 5.3 ms (post-#222 alone) back to ~146 ms (full stack), with top_filter=[userId=…] indicating the apply write lock was waiting on prefilter-refresh read locks.

Filter 0-card shapes at promotion time → registry stops filling with garbage → fewer stale prefilters per cycle → less read-lock-hold per merge cycle.

Change

Single guard:

let card = bitmap.len();
if card == 0 {
    tracing::debug!("auto-prefilter: skipped '{}' — 0 slots match (freq={})", name, hfs.total_frequency);
    continue;
}

before the merge_prefilter_registry.insert(…) call. 16 lines total including the explanatory comment.

Stacking

Test plan

🤖 Generated with Claude Code

The auto-prefilter promotion block at concurrent_engine.rs:2831 inserts every
hot filter shape that compute_filters succeeds on, including ones whose
resulting bitmap matches 0 slots. A 0-cardinality prefilter can never substitute
a real query (the substitute path produces an empty result, equivalent to
running the query and finding nothing), so registering it just:

1. Burns a slot in the bounded prefilter registry (default cap = 32). With
   cap reached, useful candidates get rejected with `prefilter registry full`.
2. Adds another stale entry to the merge thread's prefilter-refresh loop,
   each refresh holding the FilterField read lock for 80-180 ms while
   compute_filters re-runs against the current snapshot.

After PR #224 unlocked the previously-dead auto-promotion + refresh blocks
in the merge thread, this manifested as a measurable apply-path regression:
flush apply mean climbed from 5.3 ms (post-#222 alone) back to ~146 ms when
all four PRs stacked, with `top_filter=[userId=...]` indicating the apply
write lock was waiting on prefilter-refresh read locks. Filtering 0-card
shapes at promotion time stops the registry filling with garbage and lets
auto-promotion converge on actually-useful prefixes instead.

Add a `if card == 0 { continue; }` guard immediately after compute_filters
returns Ok(bitmap), before the insert call. Logs at tracing::debug level so
it's discoverable but not noisy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JustMaier

Copy link
Copy Markdown
Contributor Author

Reviewed. Approved.

Surgical 16-line guard: if card == 0 { continue } before the registry insert. tracing::debug! for visibility, captures the freq for triage. The comment captures WHY (post-#224 observation, registry pollution → 80-180 ms refresh lock-holds) — exactly the kind of WHY-not-WHAT comment that earns its keep.

No correctness risk: a 0-cardinality prefilter can never substitute a real query (intersection with anything is still empty), so skipping registration is strictly correct.

Same self-approve caveat — treat this comment as approval. Ready to merge after Aidan tag/CI confirm. Pairs with PR #227 to address the regression #224 exposes.

@JustMaier
JustMaier merged commit 0904444 into main Apr 25, 2026
4 of 5 checks passed
@JustMaier
JustMaier deleted the perf/auto-prefilter-skip-zero-card branch April 25, 2026 08:30
JustMaier added a commit that referenced this pull request Apr 25, 2026
Captures the full causal chain + measurement data for the post-#224
apply-mean regression and its resolution via PRs #226/#227/#228:

- Bisect (mut_us 4-15μs vs lock_us 8-235ms in [remove_bulk_slow])
  showed apply was waiting for a long-held FilterField read.
- bitmap_memory_cache::scan_tick → FilterField::bitmap_bytes() iterating
  769K userId entries under one read lock was the holder.
- Pre-#224 the merge thread's mark_stale calls were unreachable, so
  the scanner had nothing to re-read and bitmap_bytes was effectively
  dormant. Post-#224 it ran at full cadence.
- PR #227 chunked bitmap_bytes to bound the read-lock-hold; lock_us
  dropped 80-235ms → 5-11ms.
- PR #228 fixed a separate latent bug in the warm-persist path where
  query.filters had been overwritten with the post-prefilter-substitution
  form (containing FilterClause::BucketBitmap, #[serde(skip)]) and
  serialization was silently failing every batch.
- Final time-to-first-warm-restore: 163s total (159s dump restore
  + 4.6s auto-warm of 187 shapes), down from the handoff's reported
  30-60min — a 100-300x reduction.

Full session ship list captured at the bottom (v1.0.165 through
v1.0.171 — seven perf PRs all stacked cleanly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant