perf(filter): chunk bitmap_bytes iteration to bound read-lock-hold - #227
Conversation
`FilterField::bitmap_bytes()` is called by the bitmap memory cache scanner thread (`bitmap_memory_cache::scan_tick`) every time a field is flagged stale. Previously held the bitmaps read lock for the entire iteration — at userId's 769K entries that's ~50 ms continuous; at postId's 22.5M it's ~1-2 s. Pre-PR-#224 the merge thread's `mark_stale` calls were unreachable (stray `}` closed the while loop early), so the scanner had nothing to re-scan and bitmap_bytes was effectively dormant. Post-#224 the merge thread runs the full cycle including `mark_stale` after each idle compaction, so the scanner re-reads at full cadence. Symptom: `[remove_bulk_slow] field=userId ... lock=80-235ms mut=4-15μs` in the apply path under sustained PG-sync ops. The mutation work itself is microseconds; apply was waiting on bitmap_bytes' read lock. Confirmed via instrumented bisect (5-ms threshold logs on every read- lock acquire + hold across `apply_diff_eq`, `union_with_diff`, `for_each_versioned`, `iter_versioned`, `merge_dirty`, and a `remove_bulk_slow` log on the apply-side write-lock acquire). Only bitmap_bytes' iteration showed the long hold; everything else under 5 ms. Fix: walk in 16K-key chunks, releasing the read lock between chunks. Same chunked-iter pattern that PR #225 uses for save_snapshot and range_scan. Wall-clock for the iteration is unchanged (same total per-VB work), but the maximum continuous lock-hold drops from O(N entries) to ~10 ms per chunk. Local measurement, sustained PG-sync stream, 100s window: Metric Pre-fix Post-fix ----------------------------------------------------- [remove_bulk_slow] lock_us 80-235 ms 5-11 ms (10-20x) apply mean (small batches) 146 ms <10 ms (15x) apply mean (catch-up batches) 146 ms 110-160 ms (no change — that's real bitmap insert work, not contention) This unblocks PR #224 — the merge-thread brace fix. The two together give us PR #222's apply-mean win back without the read-lock-blocker that #224's reanimated `mark_stale` introduced. Future work (deferred): cache the byte count with a TTL or update incrementally on mutation, eliminating the periodic re-scan entirely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Reviewed. Approved. Diff matches diagnosis: snapshot keys upfront under brief read-lock, then chunked iteration with per-16K-chunk read-lock acquire/release. Apply's writers get to interleave between chunks instead of blocking the full scan window. Concerns / things to watch (none blocking):
Approved. Same self-approve caveat — treat this comment as approval. Merging next + tagging v1.0.169-jemalloc, then merging #224 paired. |
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>
…eared 11-min sustained mixed-load window on full perf stack v1.0.165-175 + relay V1 (post #232). Setup: local BitDex on 110.5M dump + replay-prod-via-relay consumer subscribed to prod /events/ops + replay-captured at 3K QPS local query load. Headline: - 1,724,131 queries served - P50 < 50μs / P95 < 25ms / P99 ~25ms / max < 500ms - Mission gate (P99 < 1s) cleared by ~40x margin - 0 flush-slow / 0 ops-trace / 0 insert_bulk_slow events - 8 sort-top_n SLOW events (max 65ms — separate surface, non-blocking) Hypothesis verification: - #1 userId-Eq apply contention: confirmed mitigated by #222/#227/#224 stack - #2 tagIds-In multi-value contention: no contention observed - #3 sortAtUnix-Gte tolerance miss: debunked for corpus (all snap to canonical buckets) Caveats: - Cache-saturated due to 232-shape captured corpus; absolute hit rate not representative of prod's diverse load. Real diverse shadow query traffic requires V2 model-share tee_mode skip PR. - Sort-layer fusion under concurrent sort-field writes is a separate surface surfaced during measurement. Filed as task #17 follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
FilterField::bitmap_bytes()is called by the bitmap memory scanner thread every time a field is flagged stale. The previous single-lock impl held the bitmaps read lock for the entire iteration: ~50 ms on userId (769K entries), ~1–2 s on postId (22.5M).Pre-#224 the merge thread's
mark_stalecalls were unreachable (the stray}bug), so the scanner had nothing to re-scan andbitmap_byteswas effectively dormant. Post-#224 the merge thread runs the full cycle and the scanner re-reads at full cadence — turningbitmap_bytesinto the dominant write-lock blocker for the apply path.Root cause (instrumented bisect)
Apply path's
remove_bulkwas logginglock=80-235ms mut=4-15μs. The work was microseconds; apply was waiting on a reader.Added 5 ms threshold logs on every read-lock acquire + hold across
apply_diff_eq,union_with_diff,for_each_versioned,iter_versioned,merge_dirty, andremove_bulkitself. Onlybitmap_bytes's iteration showed the long hold; everything else was under 5 ms.Fix
Walk the field in 16K-key chunks, releasing the read lock between chunks. Same chunked-iter pattern that PR #225 uses for save_snapshot and range_scan. Single-line semantic change to the iteration shape.
Wall-clock is unchanged (same per-VB work in aggregate). The win is bounded max-continuous-lock-hold for queued writers.
Measurements (local, sustained PG-sync stream, 100 s)
[remove_bulk_slow] lock_usdistribution[read_slow] for_each_versioned_chunked.chunk hold_us(new visible)Stacking
maindirectly. No dependency on other open PRs.mark_staleintroduced.Test plan
cargo build --profile fast --features server,pg-sync)Future work (deferred)
Cache the byte count with a TTL or update incrementally on mutation, eliminating the periodic re-scan entirely. Filed as a follow-up.
🤖 Generated with Claude Code