From 7ea70b354adf0daa225066fdfb8407d8d734e6a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mour=C3=A3o?= Date: Thu, 2 Jul 2026 08:39:16 +0100 Subject: [PATCH 1/7] AndNot: exact two-pass sizing and sparse-result compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- andnot_compact_test.go | 324 +++++++++++++++++++++++++++++++++++++++++ bitmap_opt.go | 192 ++++++++++++++++++------ container.go | 27 ++++ container_opt.go | 59 ++++++++ 4 files changed, 557 insertions(+), 45 deletions(-) create mode 100644 andnot_compact_test.go diff --git a/andnot_compact_test.go b/andnot_compact_test.go new file mode 100644 index 0000000..fd2417f --- /dev/null +++ b/andnot_compact_test.go @@ -0,0 +1,324 @@ +package sroar + +import ( + "math/rand" + "testing" +) + +// AndNot results whose bitmap containers drop below the array threshold must be +// down-converted: semantically identical, materially smaller. +func TestAndNotCompactsSparseResults(t *testing.T) { + // dense a: every container becomes a bitmap container + a := NewBitmap() + for x := uint64(0); x < 1_000_000; x++ { + a.Set(x) + } + // b removes all but ~500 scattered values per container + b := NewBitmap() + rng := rand.New(rand.NewSource(3)) + survivors := map[uint64]struct{}{} + for i := 0; i < 8000; i++ { + survivors[uint64(rng.Int63n(1_000_000))] = struct{}{} + } + for x := uint64(0); x < 1_000_000; x++ { + if _, ok := survivors[x]; !ok { + b.Set(x) + } + } + + res := AndNot(a, b) + + // semantic equality against per-element reference + for x := uint64(0); x < 1_000_000; x++ { + _, want := survivors[x] + if got := res.Contains(x); got != want { + t.Fatalf("mismatch at %d: got %v want %v", x, got, want) + } + } + if res.GetCardinality() != len(survivors) { + t.Fatalf("cardinality %d want %d", res.GetCardinality(), len(survivors)) + } + + // size: ~8k survivors as array containers should be a few bytes/value, far + // below the ~16 bitmap containers (8KB each) the uncompacted result kept. + gotBytes := len(res.ToBuffer()) + uncompacted := 16 * 8192 + if gotBytes >= uncompacted/4 { + t.Fatalf("result not compacted: %d bytes (uncompacted would be ~%d)", gotBytes, uncompacted) + } +} + +// The boundary: results at/above the threshold must stay bitmap containers and +// results just below must convert, both with exact semantics. +func TestAndNotCompactThreshold(t *testing.T) { + for _, keep := range []int{andNotCompactThreshold - 1, andNotCompactThreshold, 4200} { + a := NewBitmap() + for x := uint64(0); x < 65536; x++ { + a.Set(x) // one full bitmap container + } + b := NewBitmap() + for x := uint64(keep); x < 65536; x++ { + b.Set(x) // remove all but the first `keep` values + } + res := AndNot(a, b) + if got := res.GetCardinality(); got != keep { + t.Fatalf("keep=%d: cardinality %d", keep, got) + } + for x := uint64(0); x < 65536; x++ { + want := x < uint64(keep) + if res.Contains(x) != want { + t.Fatalf("keep=%d: mismatch at %d", keep, x) + } + } + } +} + +// Mixed container shapes through the two-pass sizing: matched array/bitmap pairs +// in all four combinations, unmatched containers, and containers emptied by the +// subtraction — all against a brute-force reference. +func TestAndNotTwoPassMixedShapes(t *testing.T) { + rng := rand.New(rand.NewSource(17)) + a, b := NewBitmap(), NewBitmap() + // container 0: a bitmap, b bitmap (partial overlap) + for x := uint64(0); x < 65536; x++ { + a.Set(x) + if x%3 != 0 { + b.Set(x) + } + } + // container 1: a array, b array + for i := 0; i < 900; i++ { + x := uint64(1<<16) + uint64(rng.Int63n(65536)) + a.Set(x) + if i%2 != 0 { + b.Set(x) + } + } + // container 2: a array unmatched in b + for i := 0; i < 500; i++ { + a.Set(uint64(2<<16) + uint64(rng.Int63n(65536))) + } + // container 3: a bitmap fully erased by b + for x := uint64(3 << 16); x < 4<<16; x++ { + a.Set(x) + b.Set(x) + } + // container 4: b-only (must not appear) + for i := 0; i < 100; i++ { + b.Set(uint64(4<<16) + uint64(rng.Int63n(65536))) + } + + want := NewBitmap() + for _, x := range a.ToArray() { + if !b.Contains(x) { + want.Set(x) + } + } + + res := AndNot(a, b) + if res.GetCardinality() != want.GetCardinality() { + t.Fatalf("cardinality %d want %d", res.GetCardinality(), want.GetCardinality()) + } + for _, x := range want.ToArray() { + if !res.Contains(x) { + t.Fatalf("missing %d", x) + } + } + for x := uint64(0); x < 5<<16; x += 7 { + if res.Contains(x) != want.Contains(x) { + t.Fatalf("mismatch at %d", x) + } + } +} + +// Every container offset in an AndNot result must stay a multiple of 4 uint16s: +// uint16To64SliceUnsafe builds *uint64 views over container payloads, so a +// misaligned compacted container would be UB (and a SIGBUS on strict-alignment +// targets) for every operation that later touches res. +func TestAndNotResultAlignment(t *testing.T) { + checkAligned := func(t *testing.T, res *Bitmap) { + t.Helper() + for i := 0; i < res.keys.numKeys(); i++ { + off := res.keys.val(i) + if off%4 != 0 { + t.Fatalf("container %d (key %d) at misaligned offset %d", i, res.keys.key(i), off) + } + c := res.getContainer(off) + if len(c)%4 != 0 { + t.Fatalf("container %d has unpadded size %d", i, len(c)) + } + } + } + + // the shape that put a compacted container ahead of a bitmap container + a, b := NewBitmap(), NewBitmap() + for x := uint64(0); x < 131072; x++ { + a.Set(x) + } + for x := uint64(100); x < 65536; x++ { + b.Set(x) + } + res := AndNot(a, b) + checkAligned(t, res) + // exercise the unsafe uint64 views over the result + if got := res.Maximum(); got != 131071 { + t.Fatalf("Maximum on compacted result: %d", got) + } + if !res.Intersects(a) { + t.Fatal("Intersects on compacted result") + } + res.AndNot(b) // in-place op over the compacted arena + + // fuzz: random container mixes, all results must stay aligned + rng := rand.New(rand.NewSource(7)) + for trial := 0; trial < 30; trial++ { + a, b := NewBitmap(), NewBitmap() + for c := 0; c < 6; c++ { + base := uint64(c) << 16 + an := rng.Intn(60000) + 1 + for i := 0; i < an; i++ { + a.Set(base + uint64(rng.Intn(65536))) + } + if rng.Intn(3) > 0 { + bn := rng.Intn(65000) + 1 + for i := 0; i < bn; i++ { + b.Set(base + uint64(rng.Intn(65536))) + } + } + } + res := AndNot(a, b) + checkAligned(t, res) + } +} + +// A bitmap container whose cardinality header understates its popcount (corrupt +// serialized input) must not panic: the compaction write clamps to the sized +// slots and degrades deterministically. +func TestAndNotCorruptCardinalityNoPanic(t *testing.T) { + a := NewBitmap() + for x := uint64(0); x < 3000; x++ { + a.Set(x) // one bitmap container, real popcount 3000 + } + off, found := a.keys.getValue(0) + if !found { + t.Fatal("missing container") + } + setCardinality(a.getContainer(off), 100) // corrupt: header says 100 + + b := NewBitmap() + b.Set(1) + b.Remove(1) // matched key with an empty container: AndNot keeps a's verbatim + + res := AndNot(a, b) // must not panic + if got := res.GetCardinality(); got != 100 { + t.Fatalf("clamped cardinality: got %d want 100", got) + } +} + +// Sparse bitmap containers of a with no matching key in b must be compacted +// exactly like matched ones — the space win must not depend on b's key set. +func TestAndNotCompactsUnmatchedContainers(t *testing.T) { + a := NewBitmap() + for x := uint64(0); x < 3000; x++ { + a.Set(x) // key-0 container converts to bitmap + } + for x := uint64(500); x < 3000; x++ { + a.Remove(x) // 500 remain; containers never down-convert, stays bitmap + } + for x := uint64(1 << 16); x < 1<<16+5000; x++ { + a.Set(x) // second container so b can match something else + } + if off, ok := a.keys.getValue(0); !ok || a.getContainer(off)[indexType] != typeBitmap { + t.Fatal("fixture: key-0 container must be a sparse bitmap container") + } + b := NewBitmap() + for x := uint64(1 << 16); x < 1<<16+2500; x++ { + b.Set(x) // matches only container 1; container 0 is unmatched + } + res := AndNot(a, b) + off, found := res.keys.getValue(0) + if !found { + t.Fatal("unmatched container missing from result") + } + if c := res.getContainer(off); c[indexType] != typeArray { + t.Fatalf("unmatched sparse bitmap container not compacted (type %d, size %d)", c[indexType], len(c)) + } + for x := uint64(0); x < 500; x++ { + if !res.Contains(x) { + t.Fatalf("missing %d", x) + } + } + if res.Contains(600) { + t.Fatal("false positive") + } +} + +// Two-pass sizing must grow res exactly once: allocations per AndNot call stay +// constant regardless of container count (res struct + initial data + one +// reservation + scratch), instead of one doubling per ~container. +func TestAndNotAllocsBounded(t *testing.T) { + a := NewBitmap() + for x := uint64(0); x < 2_000_000; x++ { + a.Set(x) // ~30 full bitmap containers + } + b := NewBitmap() + for x := uint64(0); x < 2_000_000; x += 3 { + b.Set(x) // remove a third: results stay bitmap containers + } + allocs := testing.AllocsPerRun(10, func() { + res := AndNot(a, b) + if res.GetCardinality() == 0 { + t.Fatal("unexpected empty result") + } + }) + // NewBitmap (struct+data), one exact reservation, one 8KB scratch, plus + // small constant slack. + if allocs > 8 { + t.Fatalf("AndNot allocations not bounded: %.0f per call", allocs) + } +} + +// Heavy-shrink AndNot: dense minuend, subtrahend that removes almost everything — +// the regime where exact sizing and compaction matter most. +func BenchmarkAndNotHeavyShrink(b *testing.B) { + a := NewBitmap() + for x := uint64(0); x < 2_000_000; x++ { + a.Set(x) + } + sub := NewBitmap() + rng := rand.New(rand.NewSource(9)) + for x := uint64(0); x < 2_000_000; x++ { + if rng.Intn(100) != 0 { // keep ~1% + sub.Set(x) + } + } + b.ReportAllocs() + b.ResetTimer() + var s int + for i := 0; i < b.N; i++ { + s += AndNot(a, sub).GetCardinality() + } + sink = s +} + +var sink int + +// Dense AndNot: results stay bitmap containers — the regime where the sizing +// pass short-circuits and pass 2 builds containers directly inside res. +func BenchmarkAndNotDense(b *testing.B) { + a := NewBitmap() + for x := uint64(0); x < 2_000_000; x++ { + a.Set(x) + } + sub := NewBitmap() + for x := uint64(0); x < 2_000_000; x += 3 { + sub.Set(x) + } + b.ReportAllocs() + b.ResetTimer() + var s int + for i := 0; i < b.N; i++ { + s += AndNot(a, sub).GetCardinality() + } + sink = s +} diff --git a/bitmap_opt.go b/bitmap_opt.go index 450336b..32c96f4 100644 --- a/bitmap_opt.go +++ b/bitmap_opt.go @@ -3,6 +3,7 @@ package sroar import ( "fmt" "math" + "math/bits" "slices" "sync" ) @@ -210,6 +211,13 @@ func andContainersInRange(a, b *Bitmap, ai, aj int, optBuf []uint16, useGallopB } } +// andNotCompactThreshold is the result cardinality below which AndNot writes a +// surviving bitmap container out as an array container. Set below the 4096 +// encoding break-even on purpose: the conversion is only worth losing the O(1) +// bit-test probe when the array is small enough to stay cache-resident and the +// space win is large (8KB -> at most ~2KB). +const andNotCompactThreshold = 1024 + func AndNot(a, b *Bitmap) *Bitmap { res := NewBitmap() if a.IsEmpty() { @@ -224,64 +232,158 @@ func AndNot(a, b *Bitmap) *Bitmap { return res } -func andNotContainers(a, b, res *Bitmap, optBuf []uint16) { - ai, an := 0, a.keys.numKeys() - bi, bn := 0, b.keys.numKeys() - - akToAc := map[uint64][]uint16{} - sizeContainers := 0 - newKeys := 0 - - for ai < an && bi < bn { +// andNotWalk drives one ordered pass over a's keys, pairing each with b's +// matching container (bc == nil for unmatched keys). Both andNotContainers +// passes use it, so the sizing and materialization walks cannot disagree. +func andNotWalk(a, b *Bitmap, visit func(ak uint64, ac, bc []uint16)) { + an := a.keys.numKeys() + bn := b.keys.numKeys() + for ai, bi := 0, 0; ai < an; ai++ { ak := a.keys.key(ai) - bk := b.keys.key(bi) - if ak == bk { - off := a.keys.val(ai) - ac := a.getContainer(off) - off = b.keys.val(bi) - bc := b.getContainer(off) - if c := containerAndNotAlt(ac, bc, optBuf, 0); len(c) > 0 && getCardinality(c) > 0 { - // create a new container and update the key offset to this container. - offset := res.newContainerNoClr(uint16(len(c))) - copy(res.data[offset:], c) - res.setKey(ak, offset) - } - ai++ + for bi < bn && b.keys.key(bi) < ak { bi++ - } else if ak < bk { - off := a.keys.val(ai) - ac := a.getContainer(off) - if getCardinality(ac) > 0 { - akToAc[ak] = ac - sizeContainers += len(ac) - newKeys++ - } - ai++ - } else { + } + ac := a.getContainer(a.keys.val(ai)) + var bc []uint16 + if bi < bn && b.keys.key(bi) == ak { + bc = b.getContainer(b.keys.val(bi)) bi++ } + visit(ak, ac, bc) } - for ; ai < an; ai++ { - offset := a.keys.val(ai) - ac := a.getContainer(offset) - if getCardinality(ac) > 0 { - ak := a.keys.key(ai) - akToAc[ak] = ac - sizeContainers += len(ac) - newKeys++ +} + +// compactedArraySize is the arena footprint of a compacted array container of +// cardinality n: header + values + one spare slot, padded to a multiple of 4 +// uint16s so every later container keeps the arena's 8-byte alignment (the +// uint64 views built by uint16To64SliceUnsafe rely on it). +func compactedArraySize(n int) int { + return (int(startIdx) + n + 1 + 3) &^ 3 +} + +// andNotResultSize returns the exact number of uint16s the result container for +// (ac &^ bc) occupies in res, given its cardinality n > 0 (n may be clamped to +// andNotCompactThreshold when at least that large; the size only depends on +// exactness below the threshold). +func andNotResultSize(ac, bc []uint16, n int) int { + if ac[indexType] == typeBitmap { + if n < andNotCompactThreshold { + return compactedArraySize(n) } + return maxContainerSize } + if bc == nil { + return len(ac) // copied verbatim + } + return int(roundSize(startIdx + uint16(n))) // bufAsArray's output size +} - if sizeContainers > 0 { - res.expandConditionally(newKeys, sizeContainers) - - for ak, ac := range akToAc { - // create a new container and update the key offset to this container. +func andNotContainers(a, b, res *Bitmap, optBuf []uint16) { + // pass 1: exact sizing — count result keys and container space without + // materializing anything, so res is grown exactly once instead of doubling + // container by container (the dominant allocation churn). + newKeys, sizeContainers := 0, 0 + andNotWalk(a, b, func(ak uint64, ac, bc []uint16) { + n := andNotResultCard(ac, bc) + if n == 0 { + return + } + newKeys++ + sizeContainers += andNotResultSize(ac, bc, n) + }) + if newKeys == 0 { + return + } + res.expandConditionally(newKeys, sizeContainers) + + // pass 2: materialize. Keys arrive in ascending order, so setKey always + // appends and never memmoves existing keys. The size/compaction decisions + // must land where pass 1 reserved: the header shortcut is shared via + // andNotDenseByHeaders, and a materialized result's cardinality header + // equals andNotResultCard's count (both compute |ac &^ bc|). + andNotWalk(a, b, func(ak uint64, ac, bc []uint16) { + if bc == nil { + n := getCardinality(ac) + if n == 0 { + return + } + if ac[indexType] == typeBitmap && n < andNotCompactThreshold { + writeCompactedArray(res, ak, ac, n) + return + } offset := res.newContainerNoClr(uint16(len(ac))) copy(res.data[offset:], ac) res.setKey(ak, offset) + return + } + if ac[indexType] == typeBitmap && andNotDenseByHeaders(ac, bc) { + // provably dense result: build directly inside res — copy ac and + // subtract bc in place, skipping the scratch round-trip. + offset := res.newContainerNoClr(uint16(maxContainerSize)) + dst := res.data[offset : offset+uint64(maxContainerSize)] + copy(dst, ac) + setCardinality(dst, bitmapAndNotInPlace(dst, bc)) + res.setKey(ak, offset) + return + } + c := containerAndNotAlt(ac, bc, optBuf, 0) + if len(c) == 0 { + return + } + n := getCardinality(c) + if n == 0 { + return + } + if c[indexType] == typeBitmap && n < andNotCompactThreshold { + writeCompactedArray(res, ak, c, n) + return + } + offset := res.newContainerNoClr(uint16(len(c))) + copy(res.data[offset:], c) + res.setKey(ak, offset) + }) +} + +// writeCompactedArray appends bitmap container c (result cardinality n, with +// n < andNotCompactThreshold) to res as an array container under key ak. +func writeCompactedArray(res *Bitmap, ak uint64, c []uint16, n int) { + sz := compactedArraySize(n) + offset := res.newContainerNoClr(uint16(sz)) + dst := res.data[offset : offset+uint64(sz)] + dst[indexSize] = uint16(sz) + dst[indexType] = typeArray + // clamp enumeration to n slots: a corrupt container whose header + // understates its popcount must degrade deterministically, not overrun. + written := bitmapToArrayValues(c, dst[startIdx:int(startIdx)+n]) + setCardinality(dst, written) + for i := int(startIdx) + written; i < sz; i++ { + dst[i] = 0 // NoClr: zero the padding and spare slots + } + res.setKey(ak, offset) +} + +// bitmapAndNotInPlace subtracts container bc from the bitmap container dst in +// place and returns the resulting cardinality. +func bitmapAndNotInPlace(dst, bc []uint16) int { + if bc[indexType] == typeBitmap { + dst64 := uint16To64SliceUnsafe(dst[startIdx:]) + src64 := uint16To64SliceUnsafe(bc[startIdx:]) + num := 0 + for i := range dst64 { + dst64[i] &^= src64[i] + num += bits.OnesCount64(dst64[i]) + } + return num + } + num := getCardinality(dst) + for _, x := range bc[startIdx : int(startIdx)+getCardinality(bc)] { + idx, pos := x>>4, x&0xF + if dst[int(startIdx)+int(idx)]&bitmapMask[pos] != 0 { + dst[int(startIdx)+int(idx)] &^= bitmapMask[pos] + num-- } } + return num } // AndNot performs in-place AND-NOT of ra and bm (ra &^= bm). diff --git a/container.go b/container.go index 86e0321..fa854d5 100644 --- a/container.go +++ b/container.go @@ -602,6 +602,33 @@ func (b bitmap) all() []uint16 { return res } +// bitmapToArrayValues writes the set values of bitmap container b into out in +// ascending order, without allocating, stopping when out is full (a corrupt +// container whose cardinality header understates its popcount must degrade +// deterministically rather than overrun). Returns the number written. +func bitmapToArrayValues(b []uint16, out []uint16) int { + idx := 0 + data := b[startIdx:] + for w, word := range data { + if word == 0 { + continue + } + base := uint16(w) << 4 + // bitmapMask[pos] is 1<<(15-pos), so the smallest pos is the highest bit: + // LeadingZeros16 yields values in ascending order. + for word != 0 { + if idx == len(out) { + return idx + } + pos := uint16(bits.LeadingZeros16(word)) + out[idx] = base | pos + idx++ + word &^= 1 << (15 - pos) + } + } + return idx +} + // TODO: It can be optimized. func (b bitmap) selectAt(idx int) uint16 { data := b[startIdx:] diff --git a/container_opt.go b/container_opt.go index 4de0e8e..67a8b48 100644 --- a/container_opt.go +++ b/container_opt.go @@ -14,6 +14,65 @@ func init() { setCardinality(emptyArrayContainer, 0) } +// andNotDenseByHeaders reports, from cardinality headers alone, that (ac &^ bc) +// keeps at least andNotCompactThreshold values: bc can remove at most its own +// cardinality. Shared by the sizing pass and the materialization pass so both +// take the same dense-path decision. +func andNotDenseByHeaders(ac, bc []uint16) bool { + return getCardinality(ac)-getCardinality(bc) >= andNotCompactThreshold +} + +// andNotResultCard returns the cardinality of (ac &^ bc) without materializing +// the result and without allocating. bc == nil means no matching container. +// For bitmap sources the value is exact only below andNotCompactThreshold; +// results at least that large may be clamped to the threshold (their size in +// the arena does not depend on the exact count). +func andNotResultCard(ac, bc []uint16) int { + if bc == nil { + return getCardinality(ac) + } + at, bt := ac[indexType], bc[indexType] + switch { + case at == typeArray && bt == typeArray: + av := ac[startIdx : int(startIdx)+getCardinality(ac)] + bv := bc[startIdx : int(startIdx)+getCardinality(bc)] + return len(av) - intersection2by2Cardinality(av, bv) + case at == typeArray && bt == typeBitmap: + n := 0 + for _, x := range ac[startIdx : int(startIdx)+getCardinality(ac)] { + if !bitmap(bc).has(x) { + n++ + } + } + return n + case at == typeBitmap && bt == typeArray: + if andNotDenseByHeaders(ac, bc) { + return andNotCompactThreshold + } + n := getCardinality(ac) + for _, x := range bc[startIdx : int(startIdx)+getCardinality(bc)] { + if bitmap(ac).has(x) { + n-- + } + } + return n + default: // bitmap, bitmap + if andNotDenseByHeaders(ac, bc) { + return andNotCompactThreshold + } + a64 := uint16To64SliceUnsafe(ac[startIdx:]) + b64 := uint16To64SliceUnsafe(bc[startIdx:]) + n := 0 + for i := range a64 { + n += bits.OnesCount64(a64[i] &^ b64[i]) + if n >= andNotCompactThreshold { + return andNotCompactThreshold // dense: exact count not needed + } + } + return n + } +} + func containerAndAlt(ac, bc []uint16, optBuf []uint16, runMode int) []uint16 { at := ac[indexType] bt := bc[indexType] From a4826dbefb228ddff46ceb83374a6c802e6450ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mour=C3=A3o?= Date: Fri, 3 Jul 2026 16:29:15 +0100 Subject: [PATCH 2/7] AndNot: count bitmap containers by bits, not headers, when sizing and 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 --- andnot_compact_test.go | 133 +++++++++++++++++++++++++++++++++++------ bitmap_opt.go | 23 ++++--- container_opt.go | 33 ++++++++-- 3 files changed, 158 insertions(+), 31 deletions(-) diff --git a/andnot_compact_test.go b/andnot_compact_test.go index fd2417f..d9191c8 100644 --- a/andnot_compact_test.go +++ b/andnot_compact_test.go @@ -191,28 +191,123 @@ func TestAndNotResultAlignment(t *testing.T) { } } -// A bitmap container whose cardinality header understates its popcount (corrupt -// serialized input) must not panic: the compaction write clamps to the sized -// slots and degrades deterministically. -func TestAndNotCorruptCardinalityNoPanic(t *testing.T) { - a := NewBitmap() - for x := uint64(0); x < 3000; x++ { - a.Set(x) // one bitmap container, real popcount 3000 - } - off, found := a.keys.getValue(0) - if !found { - t.Fatal("missing container") +// A bitmap container whose cardinality header misstates its real popcount +// (corrupt serialized input) must neither panic nor lose membership. The +// container lives under key 1 because NewBitmap pre-creates a key-0 +// container, which would turn the unmatched cases into matched-empty ones. +func TestAndNotCorruptCardinalityHeaders(t *testing.T) { + const base = uint64(1) << 16 + // newCorrupt returns a bitmap holding [base, base+real) in one bitmap + // container whose cardinality header is overwritten with header. + newCorrupt := func(t *testing.T, real uint64, header int) *Bitmap { + t.Helper() + a := NewBitmap() + for x := base; x < base+3000; x++ { + a.Set(x) // force a bitmap container + } + for x := base + real; x < base+3000; x++ { + a.Remove(x) // containers never down-convert: stays bitmap + } + off, found := a.keys.getValue(base) + if !found { + t.Fatal("missing container") + } + c := a.getContainer(off) + if c[indexType] != typeBitmap { + t.Fatal("fixture: container must be a bitmap container") + } + setCardinality(c, header) + return a } - setCardinality(a.getContainer(off), 100) // corrupt: header says 100 - b := NewBitmap() - b.Set(1) - b.Remove(1) // matched key with an empty container: AndNot keeps a's verbatim + t.Run("matched array overlapping more than the header claims", func(t *testing.T) { + // the shape that drives a header-seeded count negative: pass-1 sizing + // must not panic and the result must keep the real survivors + a := newCorrupt(t, 3000, 100) + b := NewBitmap() + for x := base; x < base+500; x++ { + b.Set(x) // matched array container, 500 hits > header's 100 + } + res := AndNot(a, b) + for _, x := range []uint64{0, 499, 500, 1234, 2999} { + if got, want := res.Contains(base+x), x >= 500; got != want { + t.Fatalf("Contains(base+%d) = %v, want %v", x, got, want) + } + } + }) - res := AndNot(a, b) // must not panic - if got := res.GetCardinality(); got != 100 { - t.Fatalf("clamped cardinality: got %d want 100", got) - } + t.Run("matched empty array container", func(t *testing.T) { + a := newCorrupt(t, 3000, 100) + b := NewBitmap() + b.Set(base + 1) + b.Remove(base + 1) // matched key with an empty container + b.Set(5 << 16) // keep b non-empty so AndNot doesn't Clone + res := AndNot(a, b) + for _, x := range []uint64{0, 100, 2999} { + if !res.Contains(base + x) { + t.Fatalf("membership lost at base+%d", x) + } + } + }) + + t.Run("unmatched container, dense real popcount", func(t *testing.T) { + a := newCorrupt(t, 3000, 100) + b := NewBitmap() + b.Set(5 << 16) // no key overlap with a's corrupt container + res := AndNot(a, b) + for _, x := range []uint64{0, 100, 2999} { + if !res.Contains(base + x) { + t.Fatalf("membership lost at base+%d", x) + } + } + }) + + t.Run("unmatched container, sparse real popcount", func(t *testing.T) { + a := newCorrupt(t, 500, 50) + b := NewBitmap() + b.Set(5 << 16) + res := AndNot(a, b) + off, found := res.keys.getValue(base) + if !found { + t.Fatal("container missing from result") + } + if c := res.getContainer(off); c[indexType] != typeArray { + t.Fatalf("sparse corrupt container not compacted (type %d)", c[indexType]) + } + if got := res.GetCardinality(); got != 500 { + t.Fatalf("cardinality %d, want 500 (compaction repairs the header)", got) + } + for x := uint64(0); x < 500; x++ { + if !res.Contains(base + x) { + t.Fatalf("missing base+%d", x) + } + } + if res.Contains(base + 600) { + t.Fatal("false positive") + } + }) + + t.Run("overstated header routes to dense path", func(t *testing.T) { + a := newCorrupt(t, 100, 3000) + b := NewBitmap() + for x := base; x < base+5000; x++ { + b.Set(x) // >4096 values: bitmap container + } + for x := base + 50; x < base+5000; x++ { + b.Remove(x) // card 50 keeps a's header-dense check true + } + // header-dense (3000-50): the in-place build's bitmap-bc branch + // popcounts for real, repairing the header + res := AndNot(a, b) + if got := res.GetCardinality(); got != 50 { + t.Fatalf("cardinality %d, want 50", got) + } + for _, x := range []uint64{0, 49, 50, 99, 100} { + if got, want := res.Contains(base+x), x >= 50 && x < 100; got != want { + t.Fatalf("Contains(base+%d) = %v, want %v", x, got, want) + } + } + }) } // Sparse bitmap containers of a with no matching key in b must be compacted diff --git a/bitmap_opt.go b/bitmap_opt.go index 32c96f4..1e186c9 100644 --- a/bitmap_opt.go +++ b/bitmap_opt.go @@ -285,7 +285,7 @@ func andNotContainers(a, b, res *Bitmap, optBuf []uint16) { newKeys, sizeContainers := 0, 0 andNotWalk(a, b, func(ak uint64, ac, bc []uint16) { n := andNotResultCard(ac, bc) - if n == 0 { + if n <= 0 { return } newKeys++ @@ -297,13 +297,14 @@ func andNotContainers(a, b, res *Bitmap, optBuf []uint16) { res.expandConditionally(newKeys, sizeContainers) // pass 2: materialize. Keys arrive in ascending order, so setKey always - // appends and never memmoves existing keys. The size/compaction decisions - // must land where pass 1 reserved: the header shortcut is shared via - // andNotDenseByHeaders, and a materialized result's cardinality header - // equals andNotResultCard's count (both compute |ac &^ bc|). + // appends and never memmoves existing keys. Both passes count bitmap + // containers by their bits and share the dense shortcut, so on well-formed + // input every size/compaction decision lands where pass 1 reserved; a + // corrupt cardinality header can at worst make pass 2 outgrow the + // reservation, which res absorbs by growing. andNotWalk(a, b, func(ak uint64, ac, bc []uint16) { if bc == nil { - n := getCardinality(ac) + n := andNotResultCard(ac, nil) if n == 0 { return } @@ -331,6 +332,12 @@ func andNotContainers(a, b, res *Bitmap, optBuf []uint16) { return } n := getCardinality(c) + if c[indexType] == typeBitmap && n < andNotCompactThreshold { + // c's header can derive from ac's (andNotArrayAlt seeds it, the + // ops' early-outs return ac verbatim), so count real bits before + // compaction sizes an array by it. + n = bitmapCardClamped(c, andNotCompactThreshold) + } if n == 0 { return } @@ -352,8 +359,8 @@ func writeCompactedArray(res *Bitmap, ak uint64, c []uint16, n int) { dst := res.data[offset : offset+uint64(sz)] dst[indexSize] = uint16(sz) dst[indexType] = typeArray - // clamp enumeration to n slots: a corrupt container whose header - // understates its popcount must degrade deterministically, not overrun. + // clamp enumeration to n slots: if n and the container's bits ever + // disagree, degrade deterministically instead of overrunning. written := bitmapToArrayValues(c, dst[startIdx:int(startIdx)+n]) setCardinality(dst, written) for i := int(startIdx) + written; i < sz; i++ { diff --git a/container_opt.go b/container_opt.go index 67a8b48..eaa841a 100644 --- a/container_opt.go +++ b/container_opt.go @@ -17,18 +17,39 @@ func init() { // andNotDenseByHeaders reports, from cardinality headers alone, that (ac &^ bc) // keeps at least andNotCompactThreshold values: bc can remove at most its own // cardinality. Shared by the sizing pass and the materialization pass so both -// take the same dense-path decision. +// take the same dense-path decision. A corrupt header can only misroute to the +// dense path, which is sized maxContainerSize and preserves membership either +// way. func andNotDenseByHeaders(ac, bc []uint16) bool { return getCardinality(ac)-getCardinality(bc) >= andNotCompactThreshold } +// bitmapCardClamped counts the set bits of bitmap container c, returning bound +// as soon as the count reaches it. +func bitmapCardClamped(c []uint16, bound int) int { + c64 := uint16To64SliceUnsafe(c[startIdx:]) + n := 0 + for i := range c64 { + n += bits.OnesCount64(c64[i]) + if n >= bound { + return bound + } + } + return n +} + // andNotResultCard returns the cardinality of (ac &^ bc) without materializing // the result and without allocating. bc == nil means no matching container. // For bitmap sources the value is exact only below andNotCompactThreshold; // results at least that large may be clamped to the threshold (their size in -// the arena does not depend on the exact count). +// the arena does not depend on the exact count). Bitmap containers are counted +// by their bits, never their cardinality header, so a corrupt header cannot +// yield a negative or under-real count. func andNotResultCard(ac, bc []uint16) int { if bc == nil { + if ac[indexType] == typeBitmap { + return bitmapCardClamped(ac, andNotCompactThreshold) + } return getCardinality(ac) } at, bt := ac[indexType], bc[indexType] @@ -49,8 +70,12 @@ func andNotResultCard(ac, bc []uint16) int { if andNotDenseByHeaders(ac, bc) { return andNotCompactThreshold } - n := getCardinality(ac) - for _, x := range bc[startIdx : int(startIdx)+getCardinality(bc)] { + bn := getCardinality(bc) + n := bitmapCardClamped(ac, andNotCompactThreshold+bn) + if n >= andNotCompactThreshold+bn { + return andNotCompactThreshold // dense regardless of overlap + } + for _, x := range bc[startIdx : int(startIdx)+bn] { if bitmap(ac).has(x) { n-- } From 4d0d6c126a4cd6369588d5946c72f472ae11836c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mour=C3=A3o?= Date: Fri, 3 Jul 2026 16:30:45 +0100 Subject: [PATCH 3/7] Rename benchmark sink to andNotSink main gained a package-level sink in contains_cursor_test.go, so the merge build of this branch failed on the redeclaration. --- andnot_compact_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/andnot_compact_test.go b/andnot_compact_test.go index d9191c8..93996c8 100644 --- a/andnot_compact_test.go +++ b/andnot_compact_test.go @@ -393,10 +393,10 @@ func BenchmarkAndNotHeavyShrink(b *testing.B) { for i := 0; i < b.N; i++ { s += AndNot(a, sub).GetCardinality() } - sink = s + andNotSink = s } -var sink int +var andNotSink int // Dense AndNot: results stay bitmap containers — the regime where the sizing // pass short-circuits and pass 2 builds containers directly inside res. @@ -415,5 +415,5 @@ func BenchmarkAndNotDense(b *testing.B) { for i := 0; i < b.N; i++ { s += AndNot(a, sub).GetCardinality() } - sink = s + andNotSink = s } From 851a24b5b7c557f4fbfc49dabe5b3ad0d06c7ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mour=C3=A3o?= Date: Fri, 3 Jul 2026 17:07:41 +0100 Subject: [PATCH 4/7] =?UTF-8?q?AndNot:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20reuse=20pass-1=20counts,=20shrink=20array=20results?= =?UTF-8?q?,=20share=20container=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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() --- andnot_compact_test.go | 42 +++++++++++- bitmap_opt.go | 147 ++++++++++++++++------------------------- container.go | 28 ++++---- container_opt.go | 10 +-- 4 files changed, 117 insertions(+), 110 deletions(-) diff --git a/andnot_compact_test.go b/andnot_compact_test.go index 93996c8..ad03a9c 100644 --- a/andnot_compact_test.go +++ b/andnot_compact_test.go @@ -350,7 +350,8 @@ func TestAndNotCompactsUnmatchedContainers(t *testing.T) { // Two-pass sizing must grow res exactly once: allocations per AndNot call stay // constant regardless of container count (res struct + initial data + one -// reservation + scratch), instead of one doubling per ~container. +// reservation + scratch + pass-1 counts), instead of one doubling per +// ~container. func TestAndNotAllocsBounded(t *testing.T) { a := NewBitmap() for x := uint64(0); x < 2_000_000; x++ { @@ -366,13 +367,48 @@ func TestAndNotAllocsBounded(t *testing.T) { t.Fatal("unexpected empty result") } }) - // NewBitmap (struct+data), one exact reservation, one 8KB scratch, plus - // small constant slack. + // NewBitmap (struct+data), one exact reservation, one 8KB scratch, the + // pass-1 counts, plus small constant slack. if allocs > 8 { t.Fatalf("AndNot allocations not bounded: %.0f per call", allocs) } } +// Array-container results, matched and unmatched, are emitted at their exact +// compacted size: capacity slack from the source container's doubling growth +// must not survive into the result arena. +func TestAndNotShrinksArrayContainers(t *testing.T) { + const base = uint64(1) << 16 + for _, matched := range []bool{false, true} { + a := NewBitmap() + for x := base; x < base+2000; x++ { + a.Set(x) // array container grown by doubling + } + for x := base + 10; x < base+2000; x++ { + a.Remove(x) // 10 values remain in an oversized container + } + b := NewBitmap() + b.Set(5 << 16) + want := 10 + if matched { + b.Set(base + 5) + want = 9 + } + res := AndNot(a, b) + off, found := res.keys.getValue(base) + if !found { + t.Fatal("container missing") + } + c := res.getContainer(off) + if c[indexType] != typeArray || getCardinality(c) != want { + t.Fatalf("matched=%v: type %d cardinality %d", matched, c[indexType], getCardinality(c)) + } + if len(c) != compactedArraySize(want) { + t.Fatalf("matched=%v: container size %d, want %d", matched, len(c), compactedArraySize(want)) + } + } +} + // Heavy-shrink AndNot: dense minuend, subtrahend that removes almost everything — // the regime where exact sizing and compaction matter most. func BenchmarkAndNotHeavyShrink(b *testing.B) { diff --git a/bitmap_opt.go b/bitmap_opt.go index 1e186c9..a2ddcf8 100644 --- a/bitmap_opt.go +++ b/bitmap_opt.go @@ -3,7 +3,6 @@ package sroar import ( "fmt" "math" - "math/bits" "slices" "sync" ) @@ -233,8 +232,9 @@ func AndNot(a, b *Bitmap) *Bitmap { } // andNotWalk drives one ordered pass over a's keys, pairing each with b's -// matching container (bc == nil for unmatched keys). Both andNotContainers -// passes use it, so the sizing and materialization walks cannot disagree. +// matching container (bc == nil for unmatched keys) and skipping containers +// that are empty by their cardinality header. Both andNotContainers passes use +// it, so the sizing and materialization walks cannot disagree. func andNotWalk(a, b *Bitmap, visit func(ak uint64, ac, bc []uint16)) { an := a.keys.numKeys() bn := b.keys.numKeys() @@ -244,6 +244,9 @@ func andNotWalk(a, b *Bitmap, visit func(ak uint64, ac, bc []uint16)) { bi++ } ac := a.getContainer(a.keys.val(ai)) + if getCardinality(ac) == 0 { + continue + } var bc []uint16 if bi < bn && b.keys.key(bi) == ak { bc = b.getContainer(b.keys.val(bi)) @@ -256,98 +259,80 @@ func andNotWalk(a, b *Bitmap, visit func(ak uint64, ac, bc []uint16)) { // compactedArraySize is the arena footprint of a compacted array container of // cardinality n: header + values + one spare slot, padded to a multiple of 4 // uint16s so every later container keeps the arena's 8-byte alignment (the -// uint64 views built by uint16To64SliceUnsafe rely on it). +// uint64 views built by uint16To64SliceUnsafe rely on it). Capped at +// maxContainerSize: a full 4096-value array fits exactly, without the spare. func compactedArraySize(n int) int { - return (int(startIdx) + n + 1 + 3) &^ 3 + return min((int(startIdx)+n+1+3)&^3, maxContainerSize) } -// andNotResultSize returns the exact number of uint16s the result container for -// (ac &^ bc) occupies in res, given its cardinality n > 0 (n may be clamped to -// andNotCompactThreshold when at least that large; the size only depends on -// exactness below the threshold). -func andNotResultSize(ac, bc []uint16, n int) int { - if ac[indexType] == typeBitmap { - if n < andNotCompactThreshold { - return compactedArraySize(n) - } +// andNotResultSize returns the exact number of uint16s the result container +// for source container ac occupies in res, given the result cardinality n > 0 +// from andNotResultCard (bitmap-source counts clamp at andNotCompactThreshold, +// exactly where the size stops depending on them). +func andNotResultSize(ac []uint16, n int) int { + if ac[indexType] == typeBitmap && n >= andNotCompactThreshold { return maxContainerSize } - if bc == nil { - return len(ac) // copied verbatim - } - return int(roundSize(startIdx + uint16(n))) // bufAsArray's output size + return compactedArraySize(n) } func andNotContainers(a, b, res *Bitmap, optBuf []uint16) { - // pass 1: exact sizing — count result keys and container space without + // pass 1: exact sizing — count each pair's result cardinality without // materializing anything, so res is grown exactly once instead of doubling - // container by container (the dominant allocation churn). + // container by container (the dominant allocation churn). The counts are + // kept for pass 2; bitmap-source counts clamp at andNotCompactThreshold + // (where the size stops depending on them), all others are exact. + counts := make([]uint16, 0, a.keys.numKeys()) newKeys, sizeContainers := 0, 0 andNotWalk(a, b, func(ak uint64, ac, bc []uint16) { n := andNotResultCard(ac, bc) + counts = append(counts, uint16(n)) if n <= 0 { return } newKeys++ - sizeContainers += andNotResultSize(ac, bc, n) + sizeContainers += andNotResultSize(ac, n) }) if newKeys == 0 { return } res.expandConditionally(newKeys, sizeContainers) - // pass 2: materialize. Keys arrive in ascending order, so setKey always - // appends and never memmoves existing keys. Both passes count bitmap - // containers by their bits and share the dense shortcut, so on well-formed - // input every size/compaction decision lands where pass 1 reserved; a + // pass 2: materialize, driven by pass 1's counts so both passes take + // identical size and type decisions. Keys arrive in ascending + // order, so setKey always appends and never memmoves existing keys. A // corrupt cardinality header can at worst make pass 2 outgrow the // reservation, which res absorbs by growing. + idx := 0 andNotWalk(a, b, func(ak uint64, ac, bc []uint16) { - if bc == nil { - n := andNotResultCard(ac, nil) - if n == 0 { - return - } - if ac[indexType] == typeBitmap && n < andNotCompactThreshold { - writeCompactedArray(res, ak, ac, n) - return - } - offset := res.newContainerNoClr(uint16(len(ac))) - copy(res.data[offset:], ac) - res.setKey(ak, offset) + n := int(counts[idx]) + idx++ + if n == 0 { return } - if ac[indexType] == typeBitmap && andNotDenseByHeaders(ac, bc) { - // provably dense result: build directly inside res — copy ac and - // subtract bc in place, skipping the scratch round-trip. + if ac[indexType] == typeBitmap && n >= andNotCompactThreshold { + // the result stays a bitmap container: build it directly inside + // res, skipping the scratch round-trip offset := res.newContainerNoClr(uint16(maxContainerSize)) dst := res.data[offset : offset+uint64(maxContainerSize)] - copy(dst, ac) - setCardinality(dst, bitmapAndNotInPlace(dst, bc)) + if bc == nil { + copy(dst, ac) + } else if c := containerAndNotAlt(ac, bc, dst, 0); &c[0] != &dst[0] { + copy(dst, c) // the ops' early-outs return their input, not dst + } res.setKey(ak, offset) return } - c := containerAndNotAlt(ac, bc, optBuf, 0) - if len(c) == 0 { - return - } - n := getCardinality(c) - if c[indexType] == typeBitmap && n < andNotCompactThreshold { - // c's header can derive from ac's (andNotArrayAlt seeds it, the - // ops' early-outs return ac verbatim), so count real bits before - // compaction sizes an array by it. - n = bitmapCardClamped(c, andNotCompactThreshold) + // the result is an array container of n values + c := ac + if bc != nil { + c = containerAndNotAlt(ac, bc, optBuf, 0) } - if n == 0 { - return - } - if c[indexType] == typeBitmap && n < andNotCompactThreshold { + if c[indexType] == typeBitmap { writeCompactedArray(res, ak, c, n) return } - offset := res.newContainerNoClr(uint16(len(c))) - copy(res.data[offset:], c) - res.setKey(ak, offset) + writeArrayValues(res, ak, c[startIdx:int(startIdx)+n]) }) } @@ -356,43 +341,25 @@ func andNotContainers(a, b, res *Bitmap, optBuf []uint16) { func writeCompactedArray(res *Bitmap, ak uint64, c []uint16, n int) { sz := compactedArraySize(n) offset := res.newContainerNoClr(uint16(sz)) + bitmap(c).toArrayContainer(res.data[offset : offset+uint64(sz)]) + res.setKey(ak, offset) +} + +// writeArrayValues appends vals (the sorted values of an array-container +// result) to res as an array container under key ak, dropping any capacity +// slack the source container carried. +func writeArrayValues(res *Bitmap, ak uint64, vals []uint16) { + sz := compactedArraySize(len(vals)) + offset := res.newContainerNoClr(uint16(sz)) dst := res.data[offset : offset+uint64(sz)] dst[indexSize] = uint16(sz) dst[indexType] = typeArray - // clamp enumeration to n slots: if n and the container's bits ever - // disagree, degrade deterministically instead of overrunning. - written := bitmapToArrayValues(c, dst[startIdx:int(startIdx)+n]) - setCardinality(dst, written) - for i := int(startIdx) + written; i < sz; i++ { - dst[i] = 0 // NoClr: zero the padding and spare slots - } + n := copy(dst[startIdx:], vals) + setCardinality(dst, n) + clear(dst[int(startIdx)+n:]) res.setKey(ak, offset) } -// bitmapAndNotInPlace subtracts container bc from the bitmap container dst in -// place and returns the resulting cardinality. -func bitmapAndNotInPlace(dst, bc []uint16) int { - if bc[indexType] == typeBitmap { - dst64 := uint16To64SliceUnsafe(dst[startIdx:]) - src64 := uint16To64SliceUnsafe(bc[startIdx:]) - num := 0 - for i := range dst64 { - dst64[i] &^= src64[i] - num += bits.OnesCount64(dst64[i]) - } - return num - } - num := getCardinality(dst) - for _, x := range bc[startIdx : int(startIdx)+getCardinality(bc)] { - idx, pos := x>>4, x&0xF - if dst[int(startIdx)+int(idx)]&bitmapMask[pos] != 0 { - dst[int(startIdx)+int(idx)] &^= bitmapMask[pos] - num-- - } - } - return num -} - // AndNot performs in-place AND-NOT of ra and bm (ra &^= bm). // Neither bitmap has unmatched containers requiring mandatory action: // unmatched ra containers are kept, unmatched bm containers are irrelevant. diff --git a/container.go b/container.go index fa854d5..5ee38d2 100644 --- a/container.go +++ b/container.go @@ -367,6 +367,20 @@ func (c array) toBitmapContainer(buf []uint16) []uint16 { return b } +// toArrayContainer writes b's set values into buf (sized by the caller; a +// NoClr region is fine, the tail is zeroed) as an array container: the +// counterpart of array.toBitmapContainer. Enumeration clamps to buf's value +// space, so a count/bits disagreement degrades deterministically instead of +// overrunning. +func (b bitmap) toArrayContainer(buf []uint16) []uint16 { + buf[indexSize] = uint16(len(buf)) + buf[indexType] = typeArray + written := bitmapToArrayValues(b, buf[startIdx:]) + setCardinality(buf, written) + clear(buf[int(startIdx)+written:]) + return buf +} + func (c array) String() string { var b strings.Builder b.WriteString(fmt.Sprintf("Size: %d\n", c[0])) @@ -588,18 +602,8 @@ func (b bitmap) orArray(other array, buf []uint16, runMode int) []uint16 { } func (b bitmap) all() []uint16 { - var res []uint16 - data := b[startIdx:] - for idx := uint16(0); idx < uint16(len(data)); idx++ { - x := data[idx] - // TODO: This could potentially be optimized. - for pos := uint16(0); pos < 16; pos++ { - if x&bitmapMask[pos] > 0 { - res = append(res, (idx<<4)|pos) - } - } - } - return res + res := make([]uint16, getCardinality(b)) + return res[:bitmapToArrayValues(b, res)] } // bitmapToArrayValues writes the set values of bitmap container b into out in diff --git a/container_opt.go b/container_opt.go index eaa841a..837e1d9 100644 --- a/container_opt.go +++ b/container_opt.go @@ -16,10 +16,8 @@ func init() { // andNotDenseByHeaders reports, from cardinality headers alone, that (ac &^ bc) // keeps at least andNotCompactThreshold values: bc can remove at most its own -// cardinality. Shared by the sizing pass and the materialization pass so both -// take the same dense-path decision. A corrupt header can only misroute to the -// dense path, which is sized maxContainerSize and preserves membership either -// way. +// cardinality. Lets andNotResultCard skip exact counting for provably dense +// results. func andNotDenseByHeaders(ac, bc []uint16) bool { return getCardinality(ac)-getCardinality(bc) >= andNotCompactThreshold } @@ -50,7 +48,9 @@ func andNotResultCard(ac, bc []uint16) int { if ac[indexType] == typeBitmap { return bitmapCardClamped(ac, andNotCompactThreshold) } - return getCardinality(ac) + // the header defines an array's content, but cap it to the container + // so a corrupt size/cardinality pair cannot slice past it + return min(getCardinality(ac), len(ac)-int(startIdx)) } at, bt := ac[indexType], bc[indexType] switch { From 69b75cf444414742c59c5c46dee22f058da6610e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mour=C3=A3o?= Date: Fri, 3 Jul 2026 17:32:07 +0100 Subject: [PATCH 5/7] Trim AndNot comment slop --- bitmap_opt.go | 37 +++++++++++++++---------------------- container_opt.go | 8 +++----- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/bitmap_opt.go b/bitmap_opt.go index a2ddcf8..84279b6 100644 --- a/bitmap_opt.go +++ b/bitmap_opt.go @@ -256,19 +256,18 @@ func andNotWalk(a, b *Bitmap, visit func(ak uint64, ac, bc []uint16)) { } } -// compactedArraySize is the arena footprint of a compacted array container of -// cardinality n: header + values + one spare slot, padded to a multiple of 4 -// uint16s so every later container keeps the arena's 8-byte alignment (the -// uint64 views built by uint16To64SliceUnsafe rely on it). Capped at -// maxContainerSize: a full 4096-value array fits exactly, without the spare. +// compactedArraySize is the arena footprint of an array container of +// cardinality n: header, values, one spare slot, padded to a multiple of 4 +// uint16s so every later container keeps the 8-byte alignment the +// uint16To64SliceUnsafe views rely on. A full 4096-value array drops the spare +// rather than exceed maxContainerSize. func compactedArraySize(n int) int { return min((int(startIdx)+n+1+3)&^3, maxContainerSize) } -// andNotResultSize returns the exact number of uint16s the result container -// for source container ac occupies in res, given the result cardinality n > 0 -// from andNotResultCard (bitmap-source counts clamp at andNotCompactThreshold, -// exactly where the size stops depending on them). +// andNotResultSize returns the uint16s the result for source ac occupies in +// res at cardinality n. A bitmap source at/above the threshold stays a +// maxContainerSize bitmap, so andNotResultCard's clamp there is harmless. func andNotResultSize(ac []uint16, n int) int { if ac[indexType] == typeBitmap && n >= andNotCompactThreshold { return maxContainerSize @@ -277,11 +276,9 @@ func andNotResultSize(ac []uint16, n int) int { } func andNotContainers(a, b, res *Bitmap, optBuf []uint16) { - // pass 1: exact sizing — count each pair's result cardinality without - // materializing anything, so res is grown exactly once instead of doubling - // container by container (the dominant allocation churn). The counts are - // kept for pass 2; bitmap-source counts clamp at andNotCompactThreshold - // (where the size stops depending on them), all others are exact. + // pass 1: size res by counting each pair's result cardinality, so it grows + // once here instead of doubling container by container (the dominant + // allocation churn). counts is reused by pass 2. counts := make([]uint16, 0, a.keys.numKeys()) newKeys, sizeContainers := 0, 0 andNotWalk(a, b, func(ak uint64, ac, bc []uint16) { @@ -298,11 +295,9 @@ func andNotContainers(a, b, res *Bitmap, optBuf []uint16) { } res.expandConditionally(newKeys, sizeContainers) - // pass 2: materialize, driven by pass 1's counts so both passes take - // identical size and type decisions. Keys arrive in ascending - // order, so setKey always appends and never memmoves existing keys. A - // corrupt cardinality header can at worst make pass 2 outgrow the - // reservation, which res absorbs by growing. + // pass 2: materialize. Reusing pass 1's counts keeps both passes' size and + // type decisions identical. a-keys arrive ascending, so setKey always + // appends and never memmoves existing keys. idx := 0 andNotWalk(a, b, func(ak uint64, ac, bc []uint16) { n := int(counts[idx]) @@ -311,8 +306,7 @@ func andNotContainers(a, b, res *Bitmap, optBuf []uint16) { return } if ac[indexType] == typeBitmap && n >= andNotCompactThreshold { - // the result stays a bitmap container: build it directly inside - // res, skipping the scratch round-trip + // subtract straight into res, no scratch round-trip offset := res.newContainerNoClr(uint16(maxContainerSize)) dst := res.data[offset : offset+uint64(maxContainerSize)] if bc == nil { @@ -323,7 +317,6 @@ func andNotContainers(a, b, res *Bitmap, optBuf []uint16) { res.setKey(ak, offset) return } - // the result is an array container of n values c := ac if bc != nil { c = containerAndNotAlt(ac, bc, optBuf, 0) diff --git a/container_opt.go b/container_opt.go index 837e1d9..f6b0277 100644 --- a/container_opt.go +++ b/container_opt.go @@ -38,11 +38,9 @@ func bitmapCardClamped(c []uint16, bound int) int { // andNotResultCard returns the cardinality of (ac &^ bc) without materializing // the result and without allocating. bc == nil means no matching container. -// For bitmap sources the value is exact only below andNotCompactThreshold; -// results at least that large may be clamped to the threshold (their size in -// the arena does not depend on the exact count). Bitmap containers are counted -// by their bits, never their cardinality header, so a corrupt header cannot -// yield a negative or under-real count. +// Bitmap sources are counted by their bits — never the cardinality header, so +// a corrupt header can't produce a negative or under-real count — and clamped +// at andNotCompactThreshold, above which the exact count is unused. func andNotResultCard(ac, bc []uint16) int { if bc == nil { if ac[indexType] == typeBitmap { From 2ead5b4865c4defaad463ad0213ea750652ae653 Mon Sep 17 00:00:00 2001 From: Andrzej Liszka Date: Tue, 7 Jul 2026 10:16:36 +0200 Subject: [PATCH 6/7] AndNot: materialize results directly into arena slots 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) --- bitmap_opt.go | 66 +++--------- container.go | 106 +++++++++++++------ container_opt.go | 261 +++++++++++++++++++---------------------------- 3 files changed, 187 insertions(+), 246 deletions(-) diff --git a/bitmap_opt.go b/bitmap_opt.go index 84279b6..879c9e1 100644 --- a/bitmap_opt.go +++ b/bitmap_opt.go @@ -226,8 +226,7 @@ func AndNot(a, b *Bitmap) *Bitmap { return a.Clone() } - buf := make([]uint16, maxContainerSize) - andNotContainers(a, b, res, buf) + andNotContainers(a, b, res) return res } @@ -275,10 +274,10 @@ func andNotResultSize(ac []uint16, n int) int { return compactedArraySize(n) } -func andNotContainers(a, b, res *Bitmap, optBuf []uint16) { - // pass 1: size res by counting each pair's result cardinality, so it grows - // once here instead of doubling container by container (the dominant - // allocation churn). counts is reused by pass 2. +func andNotContainers(a, b, res *Bitmap) { + // Count each pair's result cardinality and total the container sizes, then + // grow res once. The counts are kept so the materialization below does not + // recompute them. counts := make([]uint16, 0, a.keys.numKeys()) newKeys, sizeContainers := 0, 0 andNotWalk(a, b, func(ak uint64, ac, bc []uint16) { @@ -295,9 +294,9 @@ func andNotContainers(a, b, res *Bitmap, optBuf []uint16) { } res.expandConditionally(newKeys, sizeContainers) - // pass 2: materialize. Reusing pass 1's counts keeps both passes' size and - // type decisions identical. a-keys arrive ascending, so setKey always - // appends and never memmoves existing keys. + // Materialize each result: reserve a slot of the counted size and let + // containerAndNotAlt fill it. Sizing and filling both go through + // andNotResultSize, so the slot always fits exactly. idx := 0 andNotWalk(a, b, func(ak uint64, ac, bc []uint16) { n := int(counts[idx]) @@ -305,54 +304,13 @@ func andNotContainers(a, b, res *Bitmap, optBuf []uint16) { if n == 0 { return } - if ac[indexType] == typeBitmap && n >= andNotCompactThreshold { - // subtract straight into res, no scratch round-trip - offset := res.newContainerNoClr(uint16(maxContainerSize)) - dst := res.data[offset : offset+uint64(maxContainerSize)] - if bc == nil { - copy(dst, ac) - } else if c := containerAndNotAlt(ac, bc, dst, 0); &c[0] != &dst[0] { - copy(dst, c) // the ops' early-outs return their input, not dst - } - res.setKey(ak, offset) - return - } - c := ac - if bc != nil { - c = containerAndNotAlt(ac, bc, optBuf, 0) - } - if c[indexType] == typeBitmap { - writeCompactedArray(res, ak, c, n) - return - } - writeArrayValues(res, ak, c[startIdx:int(startIdx)+n]) + sz := andNotResultSize(ac, n) + offset := res.newContainerNoClr(uint16(sz)) + containerAndNotAlt(ac, bc, res.data[offset:offset+uint64(sz)], 0) + res.setKey(ak, offset) }) } -// writeCompactedArray appends bitmap container c (result cardinality n, with -// n < andNotCompactThreshold) to res as an array container under key ak. -func writeCompactedArray(res *Bitmap, ak uint64, c []uint16, n int) { - sz := compactedArraySize(n) - offset := res.newContainerNoClr(uint16(sz)) - bitmap(c).toArrayContainer(res.data[offset : offset+uint64(sz)]) - res.setKey(ak, offset) -} - -// writeArrayValues appends vals (the sorted values of an array-container -// result) to res as an array container under key ak, dropping any capacity -// slack the source container carried. -func writeArrayValues(res *Bitmap, ak uint64, vals []uint16) { - sz := compactedArraySize(len(vals)) - offset := res.newContainerNoClr(uint16(sz)) - dst := res.data[offset : offset+uint64(sz)] - dst[indexSize] = uint16(sz) - dst[indexType] = typeArray - n := copy(dst[startIdx:], vals) - setCardinality(dst, n) - clear(dst[int(startIdx)+n:]) - res.setKey(ak, offset) -} - // AndNot performs in-place AND-NOT of ra and bm (ra &^= bm). // Neither bitmap has unmatched containers requiring mandatory action: // unmatched ra containers are kept, unmatched bm containers are irrelevant. diff --git a/container.go b/container.go index 5ee38d2..98cb2a1 100644 --- a/container.go +++ b/container.go @@ -80,6 +80,14 @@ func setCardinality(data []uint16, c int) { } } +// setArrayHeader stamps c's header as an array container of the given +// cardinality, sized to c's full length. +func setArrayHeader(c []uint16, card int) { + c[indexSize] = uint16(len(c)) + c[indexType] = typeArray + setCardinality(c, card) +} + func zeroOutContainer(c []uint16) { c[indexCardinality] = 0 c[indexCardinality+1] = 0 @@ -237,9 +245,7 @@ func (c array) andArray(other array) []uint16 { // Truncate out to how many values were found. out = out[:startIdx+num+1] - out[indexType] = typeArray - out[indexSize] = uint16(len(out)) - setCardinality(out, int(num)) + setArrayHeader(out, int(num)) return out } @@ -254,9 +260,7 @@ func (c array) andNotArray(other array, buf []uint16) []uint16 { // Truncate out to how many values were found. out = out[:startIdx+num+1] - out[indexType] = typeArray - out[indexSize] = uint16(len(out)) - setCardinality(out, int(num)) + setArrayHeader(out, int(num)) return out } @@ -278,9 +282,7 @@ func (c array) orArray(other array, buf []uint16, runMode int) []uint16 { // The output would be of typeArray. out := buf[:int(startIdx)+max] num := union2by2(c.all(), other.all(), out[startIdx:]) - out[indexType] = typeArray - out[indexSize] = uint16(len(out)) - setCardinality(out, num) + setArrayHeader(out, num) return out } @@ -288,7 +290,6 @@ var tmp = make([]uint16, 8192) func (c array) andBitmap(other bitmap) []uint16 { out := make([]uint16, int(startIdx)+getCardinality(c)+2) // some extra space. - out[indexType] = typeArray pos := startIdx for _, x := range c.all() { @@ -298,8 +299,7 @@ func (c array) andBitmap(other bitmap) []uint16 { // Ensure we have at least one empty slot at the end. res := out[:pos+1] - res[indexSize] = uint16(len(res)) - setCardinality(res, int(pos-startIdx)) + setArrayHeader(res, int(pos-startIdx)) return res } @@ -367,20 +367,6 @@ func (c array) toBitmapContainer(buf []uint16) []uint16 { return b } -// toArrayContainer writes b's set values into buf (sized by the caller; a -// NoClr region is fine, the tail is zeroed) as an array container: the -// counterpart of array.toBitmapContainer. Enumeration clamps to buf's value -// space, so a count/bits disagreement degrades deterministically instead of -// overrunning. -func (b bitmap) toArrayContainer(buf []uint16) []uint16 { - buf[indexSize] = uint16(len(buf)) - buf[indexType] = typeArray - written := bitmapToArrayValues(b, buf[startIdx:]) - setCardinality(buf, written) - clear(buf[int(startIdx)+written:]) - return buf -} - func (c array) String() string { var b strings.Builder b.WriteString(fmt.Sprintf("Size: %d\n", c[0])) @@ -603,17 +589,15 @@ func (b bitmap) orArray(other array, buf []uint16, runMode int) []uint16 { func (b bitmap) all() []uint16 { res := make([]uint16, getCardinality(b)) - return res[:bitmapToArrayValues(b, res)] + return res[:b.intoArray(res)] } -// bitmapToArrayValues writes the set values of bitmap container b into out in -// ascending order, without allocating, stopping when out is full (a corrupt -// container whose cardinality header understates its popcount must degrade -// deterministically rather than overrun). Returns the number written. -func bitmapToArrayValues(b []uint16, out []uint16) int { +// intoArray writes b's set values into out in ascending order and returns the +// number written. Stops when out is full, so a cardinality header that +// understates the popcount degrades deterministically rather than overrunning. +func (b bitmap) intoArray(out []uint16) int { idx := 0 - data := b[startIdx:] - for w, word := range data { + for w, word := range b[startIdx:] { if word == 0 { continue } @@ -633,6 +617,60 @@ func bitmapToArrayValues(b []uint16, out []uint16) int { return idx } +// andNotBitmapIntoArray writes the ascending survivors of (b &^ other) into out +// and returns the count, subtracting word by word with no intermediate bitmap. +func (b bitmap) andNotBitmapIntoArray(other bitmap, out []uint16) int { + odata := other[startIdx:] + idx := 0 + for w, word := range b[startIdx:] { + if word &^= odata[w]; word == 0 { + continue + } + base := uint16(w) << 4 + for word != 0 { + if idx == len(out) { + return idx + } + pos := uint16(bits.LeadingZeros16(word)) + out[idx] = base | pos + idx++ + word &^= 1 << (15 - pos) + } + } + return idx +} + +// andNotArrayIntoArray writes the ascending survivors of (b &^ other) into out +// and returns the count, enumerating b's bits while a two-pointer walks other's +// sorted values to skip the removed ones. +func (b bitmap) andNotArrayIntoArray(other array, out []uint16) int { + ovals := other.all() + idx, j := 0, 0 + for w, word := range b[startIdx:] { + if word == 0 { + continue + } + base := uint16(w) << 4 + for word != 0 { + if idx == len(out) { + return idx + } + pos := uint16(bits.LeadingZeros16(word)) + word &^= 1 << (15 - pos) + val := base | pos + for j < len(ovals) && ovals[j] < val { + j++ + } + if j < len(ovals) && ovals[j] == val { + continue // removed by the array subtrahend + } + out[idx] = val + idx++ + } + } + return idx +} + // TODO: It can be optimized. func (b bitmap) selectAt(idx int) uint16 { data := b[startIdx:] diff --git a/container_opt.go b/container_opt.go index f6b0277..9541a43 100644 --- a/container_opt.go +++ b/container_opt.go @@ -422,195 +422,144 @@ func (b bitmap) intersectsBitmap(other bitmap) bool { return false } -func containerAndNotAlt(ac, bc []uint16, optBuf []uint16, runMode int) []uint16 { - at := ac[indexType] - bt := bc[indexType] - - if at == typeArray && bt == typeArray { - left := array(ac) - right := array(bc) - return left.andNotArrayAlt(right, optBuf, runMode) - } - if at == typeArray && bt == typeBitmap { - left := array(ac) - right := bitmap(bc) - return left.andNotBitmapAlt(right, optBuf, runMode) - } - if at == typeBitmap && bt == typeArray { - left := bitmap(ac) - right := array(bc) - return left.andNotArrayAlt(right, optBuf, runMode) - } - if at == typeBitmap && bt == typeBitmap { - left := bitmap(ac) - right := bitmap(bc) - return left.andNotBitmapAlt(right, optBuf, runMode) - } - panic("containerAnd: We should not reach here") -} - -func (c array) andNotArrayAlt(other array, optBuf []uint16, runMode int) []uint16 { - cnum := getCardinality(c) - onum := getCardinality(other) - - if cnum == 0 { - if runMode&runInline == 0 { - return emptyArrayContainer - } - // do nothing, array already empty - return nil - } - if onum == 0 { - if runMode&runInline == 0 { - return resizeArray(c, optBuf) - } - // do nothing, nothing to remove - return nil - } - - // merge - out := c +// containerAndNotAlt computes ac &^ bc. A nil bc means nothing to subtract. +// +// Inline (runMode&runInline): subtracts bc from ac in place. +// +// Non-inline: writes the result into dst, whose length selects the output form — +// a full bitmap when len(dst) == maxContainerSize, otherwise a compact array. +// The result is produced straight into dst with no workspace: an array source +// subtracts into the slot; a dense bitmap is copied in and subtracted in place; +// a sparse bitmap has its survivors enumerated into the slot. A compact array's +// spare tail is left uninitialized — array reads never look past the cardinality. +func containerAndNotAlt(ac, bc, dst []uint16, runMode int) []uint16 { if runMode&runInline == 0 { - out = optBuf - if out == nil { - out = make([]uint16, roundSize(startIdx+uint16(cnum))) + out := dst[startIdx:] + var w int + switch ac[indexType] { + case typeArray: + a := array(ac) + switch { + case bc == nil: + w = copy(out, a.all()) + case bc[indexType] == typeArray: + w = a.andNotArrayIntoArray(array(bc), out) + case bc[indexType] == typeBitmap: + w = a.andNotBitmapIntoArray(bitmap(bc), out) + default: + panic("containerAndNot: unknown bc container type") + } + case typeBitmap: + if len(dst) == maxContainerSize { + // dense result stays a bitmap: subtract in place inside the slot + copy(dst, ac) + containerAndNotAlt(dst, bc, nil, runInline) + return dst + } + // sparse result: enumerate the survivors into the slot as an array + b := bitmap(ac) + switch { + case bc == nil: + w = b.intoArray(out) + case bc[indexType] == typeArray: + w = b.andNotArrayIntoArray(array(bc), out) + case bc[indexType] == typeBitmap: + w = b.andNotBitmapIntoArray(bitmap(bc), out) + default: + panic("containerAndNot: unknown bc container type") + } + default: + panic("containerAndNot: unknown ac container type") } + setArrayHeader(dst, w) + return dst } - setc := c.all() - seto := other.all() - num := difference(setc, seto, out[startIdx:]) - lastIdx := startIdx + uint16(num) - if runMode&runInline == 0 { - return bufAsArray(out, lastIdx) + if bc == nil { + return nil // subtracting nothing leaves ac unchanged + } + at := ac[indexType] + bt := bc[indexType] + switch { + case at == typeArray && bt == typeArray: + array(ac).andNotArrayAlt(array(bc)) + case at == typeArray && bt == typeBitmap: + array(ac).andNotBitmapAlt(bitmap(bc)) + case at == typeBitmap && bt == typeArray: + bitmap(ac).andNotArrayAlt(array(bc)) + case at == typeBitmap && bt == typeBitmap: + bitmap(ac).andNotBitmapAlt(bitmap(bc)) + default: + panic("containerAndNot: We should not reach here") } - setCardinality(c, num) return nil } -func (c array) andNotBitmapAlt(other bitmap, optBuf []uint16, runMode int) []uint16 { - cnum := getCardinality(c) - onum := getCardinality(other) +// The four andNot*Alt methods subtract other from the receiver in place. An +// empty receiver or an empty other leaves the receiver untouched. - if cnum == 0 { - if runMode&runInline == 0 { - return emptyArrayContainer - } - // do nothing, array already empty - return nil - } - if onum == 0 { - if runMode&runInline == 0 { - return resizeArray(c, optBuf) - } - // do nothing, nothing to remove - return nil +func (c array) andNotArrayAlt(other array) { + if getCardinality(c) == 0 || getCardinality(other) == 0 { + return } + setCardinality(c, c.andNotArrayIntoArray(other, c[startIdx:])) +} - // merge - out := c - if runMode&runInline == 0 { - out = optBuf - if out == nil { - out = make([]uint16, roundSize(startIdx+uint16(cnum))) - } +func (c array) andNotBitmapAlt(other bitmap) { + if getCardinality(c) == 0 || getCardinality(other) == 0 { + return } + setCardinality(c, c.andNotBitmapIntoArray(other, c[startIdx:])) +} - lastIdx := startIdx +// andNotArrayIntoArray writes the ascending values of (c &^ other) into out, +// returning the count. Array minus array is exactly a set difference. +func (c array) andNotArrayIntoArray(other array, out []uint16) int { + return difference(c.all(), other.all(), out) +} + +// andNotBitmapIntoArray writes the ascending values of (c &^ other) into out +// and returns the count, keeping the values not present in other. +func (c array) andNotBitmapIntoArray(other bitmap, out []uint16) int { + w := 0 for _, x := range c.all() { if !other.has(x) { - out[lastIdx] = x - lastIdx++ + out[w] = x + w++ } } - - if runMode&runInline == 0 { - return bufAsArray(out, lastIdx) - } - setCardinality(c, int(lastIdx-startIdx)) - return nil + return w } -func (b bitmap) andNotArrayAlt(other array, optBuf []uint16, runMode int) []uint16 { +func (b bitmap) andNotArrayAlt(other array) { bnum := getCardinality(b) - onum := getCardinality(other) - - if bnum == 0 { - if runMode&runInline == 0 { - return emptyArrayContainer - } - // do nothing, array already empty - return nil - } - if onum == 0 { - if runMode&runInline == 0 { - return b - } - // do nothing, nothing to remove - return nil - } - - // merge - out := b - if runMode&runInline == 0 { - out = copyBitmap(b, optBuf) + if bnum == 0 || getCardinality(other) == 0 { + return } - delnum := 0 for _, x := range other.all() { idx := x >> 4 pos := x & 0xF - if has := out[startIdx+idx]&bitmapMask[pos] > 0; has { - out[startIdx+idx] ^= bitmapMask[pos] + if b[startIdx+idx]&bitmapMask[pos] > 0 { + b[startIdx+idx] ^= bitmapMask[pos] delnum++ } } - setCardinality(out, bnum-delnum) - - if runMode&runInline == 0 { - return out - } - return nil + setCardinality(b, bnum-delnum) } -func (b bitmap) andNotBitmapAlt(other bitmap, optBuf []uint16, runMode int) []uint16 { - bnum := getCardinality(b) - onum := getCardinality(other) - - if bnum == 0 { - if runMode&runInline == 0 { - return emptyArrayContainer - } - // do nothing, array already empty - return nil +func (b bitmap) andNotBitmapAlt(other bitmap) { + if getCardinality(b) == 0 || getCardinality(other) == 0 { + return } - if onum == 0 { - if runMode&runInline == 0 { - return b - } - // do nothing, nothing to remove - return nil - } - - // merge - out := b - if runMode&runInline == 0 { - out = copyBitmap(b, optBuf) - } - - dst64 := uint16To64SliceUnsafe(out[startIdx:]) + dst64 := uint16To64SliceUnsafe(b[startIdx:]) src64 := uint16To64SliceUnsafe(other[startIdx:]) var num int for i := range dst64 { dst64[i] &^= src64[i] num += bits.OnesCount64(dst64[i]) } - setCardinality(out, num) - - if runMode&runInline == 0 { - return out - } - return nil + setCardinality(b, num) } func containerOrAlt(ac, bc []uint16, buf []uint16, runMode int) []uint16 { @@ -886,9 +835,7 @@ func resizeArray(c array, out []uint16) []uint16 { } else { out = out[:size] } - out[indexType] = typeArray - out[indexSize] = uint16(len(out)) - setCardinality(out, cnum) + setArrayHeader(out, cnum) copy(out[startIdx:], c[startIdx:lastIdx]) return out } @@ -903,9 +850,7 @@ func copyBitmap(b bitmap, out []uint16) []uint16 { func bufAsArray(buf []uint16, lastIdx uint16) []uint16 { out := buf[:roundSize(lastIdx)] - out[indexType] = typeArray - out[indexSize] = uint16(len(out)) - setCardinality(out, int(lastIdx-startIdx)) + setArrayHeader(out, int(lastIdx-startIdx)) return out } From 9924606eb83435c3c840eb0325d7af14aad1ccff Mon Sep 17 00:00:00 2001 From: Andrzej Liszka Date: Tue, 7 Jul 2026 12:52:34 +0200 Subject: [PATCH 7/7] AndNot: seed result cardinality from headers, not bit counts 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) --- andnot_compact_test.go | 119 ----------------------------------------- container_opt.go | 93 +++++++++++++++----------------- 2 files changed, 43 insertions(+), 169 deletions(-) diff --git a/andnot_compact_test.go b/andnot_compact_test.go index ad03a9c..409f2fb 100644 --- a/andnot_compact_test.go +++ b/andnot_compact_test.go @@ -191,125 +191,6 @@ func TestAndNotResultAlignment(t *testing.T) { } } -// A bitmap container whose cardinality header misstates its real popcount -// (corrupt serialized input) must neither panic nor lose membership. The -// container lives under key 1 because NewBitmap pre-creates a key-0 -// container, which would turn the unmatched cases into matched-empty ones. -func TestAndNotCorruptCardinalityHeaders(t *testing.T) { - const base = uint64(1) << 16 - // newCorrupt returns a bitmap holding [base, base+real) in one bitmap - // container whose cardinality header is overwritten with header. - newCorrupt := func(t *testing.T, real uint64, header int) *Bitmap { - t.Helper() - a := NewBitmap() - for x := base; x < base+3000; x++ { - a.Set(x) // force a bitmap container - } - for x := base + real; x < base+3000; x++ { - a.Remove(x) // containers never down-convert: stays bitmap - } - off, found := a.keys.getValue(base) - if !found { - t.Fatal("missing container") - } - c := a.getContainer(off) - if c[indexType] != typeBitmap { - t.Fatal("fixture: container must be a bitmap container") - } - setCardinality(c, header) - return a - } - - t.Run("matched array overlapping more than the header claims", func(t *testing.T) { - // the shape that drives a header-seeded count negative: pass-1 sizing - // must not panic and the result must keep the real survivors - a := newCorrupt(t, 3000, 100) - b := NewBitmap() - for x := base; x < base+500; x++ { - b.Set(x) // matched array container, 500 hits > header's 100 - } - res := AndNot(a, b) - for _, x := range []uint64{0, 499, 500, 1234, 2999} { - if got, want := res.Contains(base+x), x >= 500; got != want { - t.Fatalf("Contains(base+%d) = %v, want %v", x, got, want) - } - } - }) - - t.Run("matched empty array container", func(t *testing.T) { - a := newCorrupt(t, 3000, 100) - b := NewBitmap() - b.Set(base + 1) - b.Remove(base + 1) // matched key with an empty container - b.Set(5 << 16) // keep b non-empty so AndNot doesn't Clone - res := AndNot(a, b) - for _, x := range []uint64{0, 100, 2999} { - if !res.Contains(base + x) { - t.Fatalf("membership lost at base+%d", x) - } - } - }) - - t.Run("unmatched container, dense real popcount", func(t *testing.T) { - a := newCorrupt(t, 3000, 100) - b := NewBitmap() - b.Set(5 << 16) // no key overlap with a's corrupt container - res := AndNot(a, b) - for _, x := range []uint64{0, 100, 2999} { - if !res.Contains(base + x) { - t.Fatalf("membership lost at base+%d", x) - } - } - }) - - t.Run("unmatched container, sparse real popcount", func(t *testing.T) { - a := newCorrupt(t, 500, 50) - b := NewBitmap() - b.Set(5 << 16) - res := AndNot(a, b) - off, found := res.keys.getValue(base) - if !found { - t.Fatal("container missing from result") - } - if c := res.getContainer(off); c[indexType] != typeArray { - t.Fatalf("sparse corrupt container not compacted (type %d)", c[indexType]) - } - if got := res.GetCardinality(); got != 500 { - t.Fatalf("cardinality %d, want 500 (compaction repairs the header)", got) - } - for x := uint64(0); x < 500; x++ { - if !res.Contains(base + x) { - t.Fatalf("missing base+%d", x) - } - } - if res.Contains(base + 600) { - t.Fatal("false positive") - } - }) - - t.Run("overstated header routes to dense path", func(t *testing.T) { - a := newCorrupt(t, 100, 3000) - b := NewBitmap() - for x := base; x < base+5000; x++ { - b.Set(x) // >4096 values: bitmap container - } - for x := base + 50; x < base+5000; x++ { - b.Remove(x) // card 50 keeps a's header-dense check true - } - // header-dense (3000-50): the in-place build's bitmap-bc branch - // popcounts for real, repairing the header - res := AndNot(a, b) - if got := res.GetCardinality(); got != 50 { - t.Fatalf("cardinality %d, want 50", got) - } - for _, x := range []uint64{0, 49, 50, 99, 100} { - if got, want := res.Contains(base+x), x >= 50 && x < 100; got != want { - t.Fatalf("Contains(base+%d) = %v, want %v", x, got, want) - } - } - }) -} - // Sparse bitmap containers of a with no matching key in b must be compacted // exactly like matched ones — the space win must not depend on b's key set. func TestAndNotCompactsUnmatchedContainers(t *testing.T) { diff --git a/container_opt.go b/container_opt.go index 9541a43..9d74e57 100644 --- a/container_opt.go +++ b/container_opt.go @@ -14,74 +14,65 @@ func init() { setCardinality(emptyArrayContainer, 0) } -// andNotDenseByHeaders reports, from cardinality headers alone, that (ac &^ bc) -// keeps at least andNotCompactThreshold values: bc can remove at most its own -// cardinality. Lets andNotResultCard skip exact counting for provably dense -// results. -func andNotDenseByHeaders(ac, bc []uint16) bool { - return getCardinality(ac)-getCardinality(bc) >= andNotCompactThreshold -} - -// bitmapCardClamped counts the set bits of bitmap container c, returning bound -// as soon as the count reaches it. -func bitmapCardClamped(c []uint16, bound int) int { - c64 := uint16To64SliceUnsafe(c[startIdx:]) - n := 0 - for i := range c64 { - n += bits.OnesCount64(c64[i]) - if n >= bound { - return bound - } - } - return n -} - // andNotResultCard returns the cardinality of (ac &^ bc) without materializing -// the result and without allocating. bc == nil means no matching container. -// Bitmap sources are counted by their bits — never the cardinality header, so -// a corrupt header can't produce a negative or under-real count — and clamped -// at andNotCompactThreshold, above which the exact count is unused. +// or allocating, clamped at andNotCompactThreshold (above it the exact count is +// unused, as the source stays a maxContainerSize bitmap). A nil or empty bc +// leaves ac unchanged. +// +// Bitmap sources are counted from the cardinality header, not by recounting +// bits: bc removes at most bcCard values, so acCard-bcCard >= andNotCompactThreshold +// proves a dense result without any counting. func andNotResultCard(ac, bc []uint16) int { - if bc == nil { + acCard := getCardinality(ac) + bcCard := 0 + if bc != nil { + bcCard = getCardinality(bc) + } + if bcCard == 0 { + // nothing to subtract: result is ac unchanged if ac[indexType] == typeBitmap { - return bitmapCardClamped(ac, andNotCompactThreshold) + // clamp: a full bitmap's cardinality overflows the caller's uint16, + // and the exact value above the threshold is unused + return min(acCard, andNotCompactThreshold) } - // the header defines an array's content, but cap it to the container - // so a corrupt size/cardinality pair cannot slice past it - return min(getCardinality(ac), len(ac)-int(startIdx)) + // an array result stays an array sized to this exact count, so no clamp + return acCard } at, bt := ac[indexType], bc[indexType] switch { case at == typeArray && bt == typeArray: - av := ac[startIdx : int(startIdx)+getCardinality(ac)] - bv := bc[startIdx : int(startIdx)+getCardinality(bc)] - return len(av) - intersection2by2Cardinality(av, bv) + return acCard - intersection2by2Cardinality(array(ac).all(), array(bc).all()) case at == typeArray && bt == typeBitmap: - n := 0 - for _, x := range ac[startIdx : int(startIdx)+getCardinality(ac)] { - if !bitmap(bc).has(x) { - n++ + n := acCard + for _, x := range array(ac).all() { + if bitmap(bc).has(x) { + n-- } } return n case at == typeBitmap && bt == typeArray: - if andNotDenseByHeaders(ac, bc) { - return andNotCompactThreshold - } - bn := getCardinality(bc) - n := bitmapCardClamped(ac, andNotCompactThreshold+bn) - if n >= andNotCompactThreshold+bn { - return andNotCompactThreshold // dense regardless of overlap - } - for _, x := range bc[startIdx : int(startIdx)+bn] { + if acCard-bcCard >= andNotCompactThreshold { + return andNotCompactThreshold // dense by headers alone + } + // Start from acCard and drop each array element present in ac. n only + // falls, so n-remaining is its floor; once that reaches the threshold + // the result is dense no matter the rest. + n := acCard + remaining := bcCard + for _, x := range array(bc).all() { + remaining-- if bitmap(ac).has(x) { n-- } + if n-remaining >= andNotCompactThreshold { + return andNotCompactThreshold + } } + // the final iteration's floor check was n >= threshold, so here n < threshold return n - default: // bitmap, bitmap - if andNotDenseByHeaders(ac, bc) { - return andNotCompactThreshold + case at == typeBitmap && bt == typeBitmap: + if acCard-bcCard >= andNotCompactThreshold { + return andNotCompactThreshold // dense by headers alone } a64 := uint16To64SliceUnsafe(ac[startIdx:]) b64 := uint16To64SliceUnsafe(bc[startIdx:]) @@ -93,6 +84,8 @@ func andNotResultCard(ac, bc []uint16) int { } } return n + default: + panic("andNotResultCard: We should not reach here") } }