Skip to content

perf(maintenance): back DeletionBitmap with a Roaring bitmap#771

Merged
mosuka merged 1 commit into
mainfrom
perf/684-deletion-roaring
Jun 3, 2026
Merged

perf(maintenance): back DeletionBitmap with a Roaring bitmap#771
mosuka merged 1 commit into
mainfrom
perf/684-deletion-roaring

Conversation

@mosuka
Copy link
Copy Markdown
Owner

@mosuka mosuka commented Jun 3, 2026

Closes #684 (cross-cutting data-structure rewrite, umbrella #537).

Problem

DeletionBitmap (laurus/src/maintenance/deletion.rs) stored deleted ids as RwLock<ahash::AHashSet<u64>>, and the .delmap (v3) wrote them as a raw u64 list. For the dense deletion sets that accumulate over a segment's life this is huge — a 10M-doc / 10%-deleted segment is ~8 MB on disk (and a multi-MB hash table in RAM whose is_deleted probes miss cache). This same bitmap is consumed by both lexical (filter_deleted_soa) and every vector index (HNSW / Flat / IVF is_deleted), which are per-doc / per-neighbour hot paths.

Change

Swap the internal representation to roaring::RoaringTreemap (a dependency since #578; doc ids are the global u64 space):

For a dense set, ~125 KB stays L2-resident (bit test) vs a multi-MB hash table that misses cache on each probe — the RAM/on-disk drop is >50×.

Locking decision

Kept as RwLock<RoaringTreemap> (in-place O(1) delete). An ArcSwap + RCU model would clone the whole bitmap on every delete_documentO(D²) over a segment's deletions (which are applied one-at-a-time at upsert/commit and accumulate to hundreds of thousands) — so it would badly regress the write path. Truly lock-free is_deleted (consumer snapshot-once, or a batched-delete API) is left as a follow-up.

Verification

  • cargo build (full workspace + bindings) ✅
  • cargo clippy --all-targets -- -D warnings — zero warnings ✅
  • cargo fmt --check — clean ✅
  • cargo test -p laurus --lib — 1104 passed / 0 failed (+2); cargo test --workspace — exit 0, 51 binaries ✅
  • markdownlint-cli2 — 0 errors; docs (en + ja) updated ✅

New unit tests: v4 round-trip (write → read yields the same set/order/metadata) and v3 backward-read (a hand-written v3 payload still loads correctly). The existing lexical/vector deletion tests pass unchanged.

Note on benchmarking

The plan called for an is_deleted micro-bench, but DeletionBitmap lives in a private module (maintenance), unreachable from an external Criterion bench — and making it pub purely for a bench contradicts this PR's "no public API change" goal. The RAM/on-disk win is structural (Roaring vs a raw u64 list), and the dense-set is_deleted speedup is the well-known cache-resident-bitmap vs cache-missing-hash-table property; correctness is covered by the tests above.

Follow-up

  • Lock-free is_deleted (consumer snapshot-once or batched delete) — avoids the COW-per-delete regression that naive ArcSwap would cause.

🤖 Generated with Claude Code

`DeletionBitmap` stored deleted ids as `RwLock<ahash::AHashSet<u64>>` and the
`.delmap` (v3) wrote them as a raw `u64` list. For the dense deletion sets that
accumulate over a segment's life this is huge — a 10M-doc/10%-deleted segment is
~8 MB on disk (and a multi-MB hash table in RAM whose `is_deleted` probes miss
cache). This same bitmap is consumed by both lexical (`filter_deleted_soa`) and
every vector index (HNSW/Flat/IVF `is_deleted`), which are per-doc / per-neighbour
hot paths.

Swap the internal representation to `roaring::RoaringTreemap` (already a
dependency since #578; doc ids are the global u64 space):

- `is_deleted` is now a branch-light bit test that stays cache-resident for
  dense sets; `delete_document` uses `RoaringTreemap::insert`'s newly-added
  bool; `get_deleted_docs` returns ascending ids; `memory_usage` reports the
  Roaring `serialized_size`.
- `.delmap` gains **v4** (`RoaringTreemap::serialize_into`), and the reader still
  loads v1/v2/v3 (raw id list) for backward compatibility.
- The public API is unchanged (`is_deleted` / `delete_document` / etc. keep their
  signatures), so the ~20 lexical/vector consumers benefit with no changes.

Locking is kept as `RwLock<RoaringTreemap>` (in-place O(1) delete). An ArcSwap +
RCU model would clone the whole bitmap per delete — O(D²) over a segment's
deletions — so lock-free `is_deleted` (consumer snapshot-once or batched delete)
is left as a follow-up. `#541`/`#625` (per-domain versions) are subsumed.

Tests: v4 round-trip and v3 backward-read compat; existing lexical/vector
deletion tests pass unchanged. Docs (en/ja) updated.

Closes #684
@mosuka mosuka merged commit 61bf467 into main Jun 3, 2026
22 checks passed
@mosuka mosuka deleted the perf/684-deletion-roaring branch June 3, 2026 04:49
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.

perf(maintenance): migrate DeletionBitmap to RoaringBitmap (lexical + vector)

1 participant