AndNot: exact two-pass sizing and sparse-result compaction#45
Merged
Conversation
There was a problem hiding this comment.
Orca Security Scan Summary
| Status | Check | Issues by priority | |
|---|---|---|---|
| Infrastructure as Code | View in Orca | ||
| SAST | View in Orca | ||
| Secrets | View in Orca | ||
| Vulnerabilities | 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)
bcccd5a to
7ea70b3
Compare
There was a problem hiding this comment.
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
andNotContainerswalk: 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
< 1024are emitted as compact array containers with padding to preserve 8-byte alignment for unsafeuint64views. - 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.
aliszka
reviewed
Jul 3, 2026
… 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()
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
aliszka
approved these changes
Jul 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
andNotContainersbuilt its result incrementally, which cost three ways:res.datagrew container by container throughfastExpand'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.map[uint64][]uint16, and its random iteration order madesetKeyinsert keys out of order, memmoving existing keys (O(k·m)).What this PR does
andNotResultCard(sorted-merge count via the existingintersection2by2Cardinality, bit tests, or popcount per container combination — with an O(1) cardinality-header shortcut and an early exit once a result is provably dense), soresis grown exactly once viaexpandConditionally. Allocations per call are constant (TestAndNotAllocsBounded), and total bytes equal the retained result.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).uint16To64SliceUnsafe's*uint64views rely on (TestAndNotResultAlignmentchecks every offset, including in-place ops over compacted results).TestAndNotCorruptCardinalityNoPanic).andNotWalk) and one density predicate (andNotDenseByHeaders), so sizing and materialization cannot disagree; the temp map is gone andsetKeyalways 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):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:
Profile attribution confirms the mechanism: the AndNot chain's allocations went from 779.6 MB of
fastExpanddoubling churn (whole benchmark run) to 297 MB in a singleexpandConditionally— exactly the retained results, zero waste.