Skip to content

AndNot: exact two-pass sizing and sparse-result compaction#45

Merged
aliszka merged 8 commits into
mainfrom
perf/andnot-exact-size
Jul 7, 2026
Merged

AndNot: exact two-pass sizing and sparse-result compaction#45
aliszka merged 8 commits into
mainfrom
perf/andnot-exact-size

Conversation

@amourao

@amourao amourao commented Jul 2, 2026

Copy link
Copy Markdown

Reduces the free AndNot(a, b)'s allocation footprint to exactly its retained result, compacts sparse surviving bitmap containers to array containers, and builds dense results directly inside the result arena. Semantics unchanged. Independent of #44.

What was happening

andNotContainers built its result incrementally, which cost three ways:

  1. Doubling churn: res.data grew container by container through fastExpand's capacity doubling — each growth abandoned the previous backing array to GC. Measured downstream (Weaviate BM25 filtered search, one AndNot per segment per query): ~11.5 reallocations per call, allocating ~2.8× the final result size.
  2. Temp map: unmatched containers were staged in a map[uint64][]uint16, and its random iteration order made setKey insert keys out of order, memmoving existing keys (O(k·m)).
  3. No compaction: a bitmap container whose result dropped to a handful of survivors stayed a full 8KB bitmap container (~15 bytes/value observed on tombstone-heavy data, vs ~2 for an array).

What this PR does

  • Exact two-pass sizing: pass 1 computes each pair's result cardinality with an allocation-free andNotResultCard (sorted-merge count via the existing intersection2by2Cardinality, bit tests, or popcount per container combination — with an O(1) cardinality-header shortcut and an early exit once a result is provably dense), so res is grown exactly once via expandConditionally. Allocations per call are constant (TestAndNotAllocsBounded), and total bytes equal the retained result.
  • Sparse-result compaction: bitmap-container results with cardinality < 1024 are written out as array containers, built directly inside res — for matched and unmatched containers alike. The cutoff is deliberately below the 4096 encoding break-even: in [1024, 4096) an array saves little space (8KB → 2–8KB) yet turns O(1) bit-test probes into binary searches (measured: threshold 4096 gave the same memory win but a +14% probe-side regression that 1024 eliminates).
  • Alignment preserved: compacted sizes are padded to a multiple of 4 uint16s, so every container in the arena keeps the 8-byte alignment that uint16To64SliceUnsafe's *uint64 views rely on (TestAndNotResultAlignment checks every offset, including in-place ops over compacted results).
  • Robust to corrupt input: the compaction enumeration clamps to the sized slots, so a container whose cardinality header understates its popcount (corrupt serialized data) degrades deterministically instead of overrunning (TestAndNotCorruptCardinalityNoPanic).
  • Dense results build in place: provably-dense results (header shortcut) copy the source container into the reserved arena region and subtract there with a fused popcount — no scratch round-trip.
  • Structural coupling: both passes share one ordered walker (andNotWalk) and one density predicate (andNotDenseByHeaders), so sizing and materialization cannot disagree; the temp map is gone and setKey always appends in ascending key order.

Correctness

Full existing suite green. New tests: mixed-shape differential vs brute force (all four container-type combinations, unmatched containers, containers fully erased), compaction threshold boundary, compaction size assertion, arena alignment (fixed shape + 30-trial fuzz + in-place ops over compacted results), corrupt-cardinality no-panic, unmatched-container compaction, and the allocation bound. Downstream, Weaviate's BM25 identity guard (bit-identical top-K vs unmerged filters on a real tombstone-heavy corpus) passes with this implementation.

Benchmarks

2M-value bitmap, vs main (measured on unmodified main via a temp worktree):

heavy shrink (~1% kept, results compact to arrays):
  main:  75.6 us/op   552,449 B/op   8 allocs/op
  PR:   111.7 us/op    49,408 B/op   3 allocs/op    (-91% bytes)

dense (2/3 kept, results stay bitmap containers):
  main:  75.1 us/op   552,449 B/op   8 allocs/op
  PR:    38.4 us/op   262,400 B/op   3 allocs/op    (1.96x faster, -53% bytes)

The heavy-shrink CPU cost is the sizing pass plus the compaction write — in the real downstream workload (AndNot as query setup) it is +5–8% of the merge path, in exchange for:

Weaviate filtered BM25 (one AndNot per segment per query), allocation overhead per query:
  10% filter:  +5.4 MB -> +1.7 MB   (-69%)
  50% filter: +13.6 MB -> +5.3 MB   (-61%)
  90% filter: +23.8 MB -> +7.9 MB   (-67%)

Profile attribution confirms the mechanism: the AndNot chain's allocations went from 779.6 MB of fastExpand doubling churn (whole benchmark run) to 297 MB in a single expandConditionally — exactly the retained results, zero waste.

@orca-security-eu orca-security-eu Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Orca Security Scan Summary

Status Check Issues by priority
Passed Passed Infrastructure as Code high 0   medium 0   low 0   info 0 View in Orca
Passed Passed SAST high 0   medium 0   low 0   info 0 View in Orca
Passed Passed Secrets high 0   medium 0   low 0   info 0 View in Orca
Passed Passed Vulnerabilities high 0   medium 0   low 0   info 0 View in Orca

The free AndNot(a, b) built its result incrementally: res.data grew by
capacity-doubling once per appended container (abandoning each previous
backing array to GC), unmatched containers were staged in a temp map whose
random iteration order made setKey memmove existing keys, and a bitmap
container that shrank to a handful of survivors was still stored as a full
8KB bitmap container.

Changes, all internal to AndNot:

  - Exact sizing: a first pass computes each pair's result cardinality with
    an allocation-free andNotResultCard (sorted-merge count, bit tests, or
    popcount depending on the container combination, with an O(1) header
    shortcut and an early exit once a result is provably dense), so res is
    grown exactly once via expandConditionally. Allocations per call are
    constant regardless of container count; total bytes equal the retained
    result.
  - Sparse-result compaction: a surviving bitmap container whose result
    cardinality drops below 1024 is written out as an array container, built
    directly inside res — for matched and unmatched containers alike. The
    cutoff is deliberately below the 4096 encoding break-even: between 1024
    and 4096 an array saves little space (8KB -> 2-8KB) yet turns the
    container's O(1) bit-test probes into binary searches. Compacted sizes
    are padded to a multiple of 4 uint16s so every container in the arena
    keeps the 8-byte alignment that uint16To64SliceUnsafe's uint64 views
    rely on, and the enumeration clamps to the sized slots so corrupt
    serialized input (a cardinality header understating the popcount)
    degrades deterministically instead of overrunning.
  - Dense results are built directly inside res (copy + subtract in place,
    fused popcount), skipping the scratch round-trip.
  - Both passes share one ordered walker (andNotWalk) and one density
    predicate (andNotDenseByHeaders), so the sizing and materialization
    decisions cannot disagree; the temp map is gone and setKey always
    appends in ascending key order.

Semantics unchanged (full suite; mixed-shape differential, threshold
boundary, alignment, corrupt-input, unmatched-compaction, and
allocation-bound tests).

Benchmarks (2M-value bitmap):
  heavy shrink (~1% kept, results compact):
    before: 75.6 us/op   552,449 B/op   8 allocs/op
    after:  111.7 us/op   49,408 B/op   3 allocs/op   (-91% bytes)
  dense (2/3 kept, results stay bitmaps):
    before: 75.1 us/op   552,449 B/op   8 allocs/op
    after:   38.4 us/op  262,400 B/op   3 allocs/op   (1.96x faster, -53% bytes)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Optimizes the free AndNot(a, b) implementation to reduce allocation churn and memory footprint by precomputing exact result sizing and compacting sparse bitmap-container results into array containers while preserving arena alignment and semantics.

Changes:

  • Introduces a two-pass andNotContainers walk: pass 1 sizes keys/container arena space; pass 2 materializes results in key order (avoids realloc churn and out-of-order insert memmoves).
  • Adds sparse-result compaction: bitmap-container results with cardinality < 1024 are emitted as compact array containers with padding to preserve 8-byte alignment for unsafe uint64 views.
  • Adds targeted tests/benchmarks for compaction correctness, alignment invariants, corrupt-cardinality robustness, and bounded allocations.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
container.go Adds allocation-free bitmap-to-array enumeration helper used for compaction writes.
container_opt.go Adds allocation-free (ac &^ bc) cardinality computation and shared dense-path predicate used by both sizing and materialization passes.
bitmap_opt.go Reworks andNotContainers into a two-pass implementation, adds compaction sizing/layout helpers, and adds in-place dense subtraction path.
andnot_compact_test.go Adds new tests/benchmarks covering compaction behavior, alignment, corrupt-cardinality handling, and allocation bounds.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread bitmap_opt.go Outdated
Comment thread bitmap_opt.go
Comment thread bitmap_opt.go Outdated
Comment thread container.go Outdated
Comment thread bitmap_opt.go Outdated
Comment thread bitmap_opt.go Outdated
amourao added 3 commits July 3, 2026 16:29
… compacting

A bitmap container whose cardinality header understated its real popcount
drove andNotResultCard's (bitmap, array) branch negative; the negative size
made expandConditionally's capacity check pass spuriously and slice past
capacity (panic). The same header trust let writeCompactedArray truncate
membership to the header count, while the bitmap/bitmap path preserved it.

- andNotResultCard counts bitmap containers via a clamped popcount
  (bitmapCardClamped); counts are structurally non-negative
- pass 2 recounts real bits before compacting any bitmap-typed result,
  since the container ops can seed result headers from a corrupt input
  header (andNotArrayAlt) or return ac verbatim (early-outs)
- TestAndNotCorruptCardinalityNoPanic never reached the path it guarded:
  its subtrahend was header-empty, so AndNot short-circuited to a.Clone().
  Replaced by TestAndNotCorruptCardinalityHeaders, whose subtests pin the
  negative-count shape, matched-empty, unmatched dense/sparse, and the
  overstated-header dense route
main gained a package-level sink in contains_cursor_test.go, so the merge
build of this branch failed on the redeclaration.
…esults, share container ops

- andNotWalk skips containers that are empty by their cardinality header,
  removing the per-pass zero checks on unmatched containers
- pass 1 stashes each pair's result count; pass 2 consumes them instead of
  recomputing, so empty results are never materialized in scratch, the
  corrupt-header recount is unnecessary, and both passes take identical
  size/type decisions by construction (heavy-shrink benchmark -11%)
- every bitmap-typed result builds directly inside the reserved arena slot
  via containerAndNotAlt(ac, bc, dst, 0); bitmapAndNotInPlace is deleted
- array-typed results (matched and unmatched) are emitted at their exact
  compacted size instead of bufAsArray's roundSize or a verbatim copy with
  the source's doubling slack; compactedArraySize caps at maxContainerSize
  so a full 4096-value array fits without the spare slot
- new bitmap.toArrayContainer mirrors array.toBitmapContainer and backs
  both writeCompactedArray and bitmap.all()
amourao and others added 4 commits July 3, 2026 17:32
containerAndNotAlt gains a non-inline mode that fills a caller-sized
slot in place (bitmap or compact array, selected by len(dst)); pass 2
reserves the slot and drops the separate repacking helpers. Enumeration
is split into typed *IntoArray methods and array framing shares
setArrayHeader. No behavior change; ~6% faster HeavyShrink, ~3% Dense,
allocations unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Trust the container cardinality header in andNotResultCard instead of
popcounting bitmap sources: the bc==nil/empty and bitmap-array cases now
read the header, with a floor-based early exit for the latter. Inline the
dense check, fold nil/empty into one guard, and drop the corrupt-header
test that pinned the old count-by-bits behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AndNot: materialize results directly into arena slots

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

@aliszka aliszka merged commit 49dc89a into main Jul 7, 2026
7 checks passed
@aliszka aliszka deleted the perf/andnot-exact-size branch July 7, 2026 11:48
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.

3 participants