Skip to content

Avoid quadratic container insertions in unions - #376

Open
fjmaco wants to merge 2 commits into
RoaringBitmap:mainfrom
fjmaco:linear-bitor-assign
Open

fjmaco wants to merge 2 commits into
RoaringBitmap:mainfrom
fjmaco:linear-bitor-assign

Conversation

@fjmaco

@fjmaco fjmaco commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

a |= b inserts every container of b that is missing from a with Vec::insert, and so do the MultiOps union and symmetric difference. Each insert shifts all the containers after it. When the right-hand side brings many containers that fall between the left-hand ones, the operation is quadratic in the number of containers:

use roaring::RoaringBitmap;

// 32768 containers on each side, at even keys on the left and odd keys on the right.
let even: RoaringBitmap = (0..32768u32).map(|key| key << 17).collect();
let odd: RoaringBitmap = (0..32768u32).map(|key| (key << 17) | (1 << 16)).collect();

let mut union = even.clone();
union |= &odd; // ~199 ms on main, ~1.4 ms with this PR

For comparison, &even | &odd already builds a new vector in one pass, and takes about as long as |= does with this PR.

What this touches:

  • BitOrAssign<RoaringBitmap> and BitOrAssign<&RoaringBitmap>;
  • the BitOr impls that call them: a | b, a | &b and &a | b;
  • union() and symmetric_difference() on iterators of owned or borrowed bitmaps, through merge_container_owned and merge_container_ref.

BitXorAssign already merges with Pairs, and &a | &b builds a new vector, so neither needs this. RoaringTreemap keeps its bitmaps in a BTreeMap, so it doesn't have the problem at its own level. Its unions call these bitmap operations for each high key, so it gets the fix there.

The new benchmark group goes right after successive_or in benchmarks/benches/lib.rs, where #361 and #343 also add benchmarks, so whichever lands second needs a trivial rebase there.

Approach

The binary search for each right-hand container stays as it is. The insertions now go through a small SortedInserts helper in bitmap/util.rs:

  • A container is still inserted with Vec::insert when that shifts fewer than 64 containers. That covers appending, small bitmaps and insertions near the end.
  • The first 8 insertions that shift more are also done directly, as before.
  • Every other container is deferred. finish then merges the deferred ones into the vector in a single pass. The pass only moves the containers after the first deferred position, so its cost is in proportion to what Vec::insert would have shifted for that one container.

The containers and the operations applied to them are the same as before, and the inserted containers end up at the same positions. Up to 8 long shifts, the code does the same Vec::insert calls as main.

Tests

  • tests/union_with.rs and tests/symmetric_difference_with.rs:
    • property tests comparing every form of |, |=, ^ and ^=, and MultiOps union and symmetric difference over 2 and 3 bitmaps, against a BTreeSet. The inputs are spread over up to 512 containers, so they can be interleaved, overlapping, disjoint or empty;
    • deterministic cases for 4096 + 4096 interleaved containers, and for containers added both before and after the existing ones.
  • A unit test for SortedInserts that mixes direct and deferred insertions.
  • Outside the PR, I compared this branch with main on 500 random cases that mix array, bitmap and run containers over up to 65536 keys. All 7000 results matched: same serialize_into bytes and the same statistics().

Benchmarks

AMD Ryzen 9 7900X, rustc 1.99.0, pinned to two cores with taskset -c 10-11, RUSTFLAGS="-C target-cpu=native -C opt-level=3". Each table shows the median, with min–max in parentheses. Other jobs were running on the machine, sometimes on the SMT siblings of these two cores, so differences under ~10% are within noise.

The first commit adds an interleaved_containers criterion group: one bitmap has containers at even keys and the other at odd keys. 3 runs per side, alternating main and this branch:

benchmark size main this PR change
or_assign_own 8192 11.6 ms (11.5 ms–11.8 ms) 228 µs (227 µs–231 µs) 51× faster
or_assign_own 16384 46.0 ms (45.9 ms–46.8 ms) 462 µs (461 µs–483 µs) 100× faster
or_assign_own 32768 195.4 ms (194.5 ms–199.6 ms) 996 µs (974 µs–1.0 ms) 196× faster
or_assign_ref 8192 11.5 ms (11.4 ms–11.6 ms) 323 µs (318 µs–325 µs) 35× faster
or_assign_ref 16384 46.7 ms (46.0 ms–46.7 ms) 676 µs (660 µs–676 µs) 69× faster
or_assign_ref 32768 199.2 ms (197.8 ms–201.6 ms) 1.4 ms (1.4 ms–1.4 ms) 146× faster
multi_or_own 8192 11.6 ms (11.5 ms–11.6 ms) 245 µs (244 µs–251 µs) 47× faster
multi_or_own 16384 46.6 ms (46.2 ms–46.7 ms) 508 µs (504 µs–523 µs) 92× faster
multi_or_own 32768 196.7 ms (194.5 ms–198.0 ms) 1.1 ms (1.1 ms–1.1 ms) 180× faster
multi_or_ref 8192 12.4 ms (12.3 ms–12.7 ms) 722 µs (716 µs–725 µs) 17× faster
multi_or_ref 16384 48.6 ms (47.8 ms–48.8 ms) 1.5 ms (1.5 ms–1.5 ms) 33× faster
multi_or_ref 32768 206.9 ms (206.3 ms–209.8 ms) 3.0 ms (3.0 ms–3.0 ms) 68× faster
multi_xor_own 8192 11.6 ms (11.5 ms–11.6 ms) 247 µs (243 µs–254 µs) 47× faster
multi_xor_own 16384 46.1 ms (46.1 ms–46.8 ms) 510 µs (506 µs–524 µs) 90× faster
multi_xor_own 32768 197.6 ms (195.0 ms–199.8 ms) 1.1 ms (1.1 ms–1.2 ms) 186× faster
multi_xor_ref 8192 12.4 ms (12.4 ms–12.5 ms) 731 µs (725 µs–736 µs) 17× faster
multi_xor_ref 16384 48.7 ms (47.9 ms–49.5 ms) 1.5 ms (1.5 ms–1.5 ms) 32× faster
multi_xor_ref 32768 207.2 ms (205.5 ms–211.5 ms) 3.1 ms (3.0 ms–3.1 ms) 67× faster

The existing pairwise_or and Successive Or groups on the real datasets, 3 runs per side, alternating, with --warm-up-time 1 --measurement-time 3:

  • Successive Or: between −2.5% and +3.8% per benchmark, and every sub-group's geomean is within +2% (within ±1% with functions aligned on both builds).
  • pairwise_or: in a default build the changed operations came out +3% to +6% (geomean), but so did ref_ref (&a | &b, +4%), whose code this PR doesn't change. With -C llvm-args=-align-all-functions=6 on both builds, every pairwise_or variant is within ±1.5% (geomean over the 8 datasets), and an in-process A/B on the same data agrees (0.998–1.012). So that difference is code placement, not this change.

Default-layout runs from a busier machine; see the note on code placement above for pairwise_or.

All dataset results
benchmark main this PR change
Successive Or/Multi Or Owned/census-income 381 µs (380 µs–384 µs) 386 µs (382 µs–405 µs) +1.4%
Successive Or/Multi Or Owned/census-income_srt 386 µs (381 µs–391 µs) 386 µs (385 µs–388 µs) +0.1%
Successive Or/Multi Or Owned/census1881 754 µs (747 µs–761 µs) 749 µs (738 µs–777 µs) -0.6%
Successive Or/Multi Or Owned/census1881_srt 563 µs (558 µs–564 µs) 562 µs (552 µs–585 µs) -0.2%
Successive Or/Multi Or Owned/weather_sept_85 2.0 ms (1.9 ms–2.0 ms) 1.9 ms (1.8 ms–1.9 ms) -5.8%
Successive Or/Multi Or Owned/weather_sept_85_srt 884 µs (883 µs–887 µs) 872 µs (863 µs–893 µs) -1.3%
Successive Or/Multi Or Owned/wikileaks-noquotes 365 µs (364 µs–371 µs) 365 µs (356 µs–368 µs) -0.0%
Successive Or/Multi Or Owned/wikileaks-noquotes_srt 188 µs (188 µs–189 µs) 186 µs (185 µs–188 µs) -1.2%
Successive Or/Multi Or Ref/census-income 353 µs (352 µs–355 µs) 378 µs (354 µs–395 µs) +7.0%
Successive Or/Multi Or Ref/census-income_srt 360 µs (359 µs–364 µs) 359 µs (358 µs–362 µs) -0.3%
Successive Or/Multi Or Ref/census1881 709 µs (706 µs–720 µs) 709 µs (705 µs–737 µs) +0.0%
Successive Or/Multi Or Ref/census1881_srt 478 µs (472 µs–479 µs) 475 µs (468 µs–489 µs) -0.6%
Successive Or/Multi Or Ref/weather_sept_85 1.8 ms (1.8 ms–1.9 ms) 1.8 ms (1.7 ms–2.1 ms) -2.9%
Successive Or/Multi Or Ref/weather_sept_85_srt 810 µs (802 µs–823 µs) 800 µs (791 µs–808 µs) -1.2%
Successive Or/Multi Or Ref/wikileaks-noquotes 318 µs (317 µs–320 µs) 310 µs (308 µs–312 µs) -2.4%
Successive Or/Multi Or Ref/wikileaks-noquotes_srt 148 µs (147 µs–148 µs) 144 µs (144 µs–145 µs) -2.5%
Successive Or/Successive Or Assign Owned/census-income 1.1 ms (1.1 ms–1.1 ms) 1.1 ms (1.1 ms–1.1 ms) -0.6%
Successive Or/Successive Or Assign Owned/census-income_srt 682 µs (680 µs–696 µs) 705 µs (696 µs–709 µs) +3.3%
Successive Or/Successive Or Assign Owned/census1881 3.3 ms (3.3 ms–3.3 ms) 3.4 ms (3.3 ms–3.6 ms) +1.6%
Successive Or/Successive Or Assign Owned/census1881_srt 2.2 ms (2.2 ms–2.2 ms) 2.2 ms (2.2 ms–2.2 ms) +1.9%
Successive Or/Successive Or Assign Owned/weather_sept_85 1.9 ms (1.9 ms–2.0 ms) 1.9 ms (1.9 ms–2.6 ms) -2.9%
Successive Or/Successive Or Assign Owned/weather_sept_85_srt 935 µs (904 µs–939 µs) 901 µs (901 µs–922 µs) -3.6%
Successive Or/Successive Or Assign Owned/wikileaks-noquotes 2.1 ms (2.1 ms–2.1 ms) 2.1 ms (2.1 ms–2.1 ms) -0.6%
Successive Or/Successive Or Assign Owned/wikileaks-noquotes_srt 1.6 ms (1.6 ms–1.6 ms) 1.6 ms (1.6 ms–1.6 ms) +0.8%
Successive Or/Successive Or Assign Ref/census-income 1.0 ms (999 µs–1.0 ms) 1.0 ms (1.0 ms–1.1 ms) +1.8%
Successive Or/Successive Or Assign Ref/census-income_srt 654 µs (654 µs–665 µs) 671 µs (665 µs–722 µs) +2.6%
Successive Or/Successive Or Assign Ref/census1881 3.2 ms (3.2 ms–3.3 ms) 3.3 ms (3.3 ms–3.7 ms) +2.7%
Successive Or/Successive Or Assign Ref/census1881_srt 2.1 ms (2.1 ms–2.1 ms) 2.1 ms (2.1 ms–2.2 ms) +2.0%
Successive Or/Successive Or Assign Ref/weather_sept_85 1.8 ms (1.8 ms–1.9 ms) 1.8 ms (1.7 ms–2.6 ms) -3.3%
Successive Or/Successive Or Assign Ref/weather_sept_85_srt 847 µs (839 µs–853 µs) 829 µs (819 µs–862 µs) -2.0%
Successive Or/Successive Or Assign Ref/wikileaks-noquotes 2.0 ms (2.0 ms–2.1 ms) 2.0 ms (2.0 ms–2.0 ms) -0.0%
Successive Or/Successive Or Assign Ref/wikileaks-noquotes_srt 1.5 ms (1.5 ms–1.6 ms) 1.5 ms (1.5 ms–1.6 ms) -0.1%
Successive Or/Successive Or Ref Ref/census-income 1.1 ms (1.1 ms–1.1 ms) 1.1 ms (1.1 ms–1.1 ms) +1.1%
Successive Or/Successive Or Ref Ref/census-income_srt 758 µs (755 µs–760 µs) 768 µs (766 µs–789 µs) +1.4%
Successive Or/Successive Or Ref Ref/census1881 5.4 ms (5.4 ms–5.9 ms) 5.6 ms (5.5 ms–6.6 ms) +3.4%
Successive Or/Successive Or Ref Ref/census1881_srt 3.3 ms (3.3 ms–3.3 ms) 3.3 ms (3.3 ms–3.5 ms) -0.9%
Successive Or/Successive Or Ref Ref/weather_sept_85 2.5 ms (2.4 ms–2.7 ms) 2.3 ms (2.3 ms–3.3 ms) -4.8%
Successive Or/Successive Or Ref Ref/weather_sept_85_srt 1.4 ms (1.3 ms–1.4 ms) 1.3 ms (1.3 ms–1.3 ms) -2.0%
Successive Or/Successive Or Ref Ref/wikileaks-noquotes 2.6 ms (2.6 ms–2.6 ms) 2.6 ms (2.5 ms–2.6 ms) -0.2%
Successive Or/Successive Or Ref Ref/wikileaks-noquotes_srt 2.0 ms (2.0 ms–2.1 ms) 2.0 ms (2.0 ms–2.0 ms) -1.2%
pairwise_or/assign_own/census-income 1.7 ms (1.6 ms–1.8 ms) 1.7 ms (1.7 ms–1.7 ms) +3.4%
pairwise_or/assign_own/census-income_srt 797 µs (786 µs–835 µs) 855 µs (843 µs–917 µs) +7.3%
pairwise_or/assign_own/census1881 240 µs (235 µs–258 µs) 262 µs (233 µs–279 µs) +9.2%
pairwise_or/assign_own/census1881_srt 148 µs (147 µs–152 µs) 162 µs (160 µs–171 µs) +9.0%
pairwise_or/assign_own/weather_sept_85 8.5 ms (8.3 ms–8.6 ms) 8.5 ms (8.4 ms–8.9 ms) -0.5%
pairwise_or/assign_own/weather_sept_85_srt 1.6 ms (1.5 ms–2.2 ms) 1.7 ms (1.6 ms–1.8 ms) +5.3%
pairwise_or/assign_own/wikileaks-noquotes 445 µs (440 µs–445 µs) 463 µs (457 µs–473 µs) +4.2%
pairwise_or/assign_own/wikileaks-noquotes_srt 218 µs (210 µs–222 µs) 224 µs (223 µs–263 µs) +2.7%
pairwise_or/assign_ref/census-income 1.8 ms (1.8 ms–1.8 ms) 1.9 ms (1.9 ms–1.9 ms) +6.4%
pairwise_or/assign_ref/census-income_srt 864 µs (847 µs–915 µs) 942 µs (934 µs–1.0 ms) +9.1%
pairwise_or/assign_ref/census1881 340 µs (332 µs–387 µs) 341 µs (340 µs–425 µs) +0.3%
pairwise_or/assign_ref/census1881_srt 237 µs (233 µs–248 µs) 245 µs (244 µs–263 µs) +3.4%
pairwise_or/assign_ref/weather_sept_85 8.8 ms (8.4 ms–8.8 ms) 9.2 ms (8.8 ms–9.2 ms) +4.7%
pairwise_or/assign_ref/weather_sept_85_srt 1.8 ms (1.7 ms–1.9 ms) 1.9 ms (1.8 ms–2.8 ms) +7.6%
pairwise_or/assign_ref/wikileaks-noquotes 494 µs (482 µs–500 µs) 525 µs (520 µs–553 µs) +6.2%
pairwise_or/assign_ref/wikileaks-noquotes_srt 257 µs (245 µs–266 µs) 279 µs (265 µs–306 µs) +8.3%
pairwise_or/len/census-income 1.1 ms (1.1 ms–1.2 ms) 1.1 ms (1.1 ms–1.2 ms) +0.6%
pairwise_or/len/census-income_srt 391 µs (365 µs–404 µs) 401 µs (399 µs–413 µs) +2.6%
pairwise_or/len/census1881 105 µs (105 µs–107 µs) 107 µs (106 µs–107 µs) +1.3%
pairwise_or/len/census1881_srt 14.2 µs (14.2 µs–14.5 µs) 14.4 µs (14.4 µs–14.6 µs) +1.3%
pairwise_or/len/weather_sept_85 6.0 ms (5.3 ms–6.4 ms) 5.9 ms (5.8 ms–6.1 ms) -0.4%
pairwise_or/len/weather_sept_85_srt 727 µs (707 µs–743 µs) 721 µs (698 µs–862 µs) -0.8%
pairwise_or/len/wikileaks-noquotes 194 µs (192 µs–194 µs) 194 µs (194 µs–196 µs) +0.3%
pairwise_or/len/wikileaks-noquotes_srt 53.6 µs (52.9 µs–55.3 µs) 53.8 µs (53.7 µs–56.0 µs) +0.4%
pairwise_or/own_own/census-income 1.6 ms (1.6 ms–1.8 ms) 1.7 ms (1.7 ms–1.8 ms) +4.5%
pairwise_or/own_own/census-income_srt 777 µs (773 µs–827 µs) 832 µs (829 µs–856 µs) +7.1%
pairwise_or/own_own/census1881 264 µs (225 µs–336 µs) 278 µs (257 µs–369 µs) +5.3%
pairwise_or/own_own/census1881_srt 135 µs (130 µs–183 µs) 143 µs (139 µs–143 µs) +5.6%
pairwise_or/own_own/weather_sept_85 7.9 ms (7.9 ms–8.8 ms) 8.5 ms (8.5 ms–8.9 ms) +7.8%
pairwise_or/own_own/weather_sept_85_srt 1.7 ms (1.5 ms–1.8 ms) 1.7 ms (1.7 ms–1.9 ms) +4.1%
pairwise_or/own_own/wikileaks-noquotes 434 µs (419 µs–443 µs) 453 µs (450 µs–517 µs) +4.4%
pairwise_or/own_own/wikileaks-noquotes_srt 206 µs (199 µs–211 µs) 208 µs (207 µs–239 µs) +1.1%
pairwise_or/own_ref/census-income 1.8 ms (1.7 ms–1.9 ms) 1.9 ms (1.9 ms–2.0 ms) +4.9%
pairwise_or/own_ref/census-income_srt 828 µs (823 µs–857 µs) 922 µs (882 µs–935 µs) +11.3%
pairwise_or/own_ref/census1881 406 µs (362 µs–409 µs) 400 µs (356 µs–460 µs) -1.4%
pairwise_or/own_ref/census1881_srt 225 µs (222 µs–229 µs) 242 µs (238 µs–244 µs) +7.5%
pairwise_or/own_ref/weather_sept_85 8.3 ms (8.2 ms–9.0 ms) 9.1 ms (8.9 ms–9.3 ms) +9.2%
pairwise_or/own_ref/weather_sept_85_srt 1.8 ms (1.8 ms–2.0 ms) 2.0 ms (1.9 ms–2.0 ms) +8.2%
pairwise_or/own_ref/wikileaks-noquotes 472 µs (464 µs–483 µs) 518 µs (511 µs–534 µs) +9.7%
pairwise_or/own_ref/wikileaks-noquotes_srt 248 µs (227 µs–251 µs) 251 µs (250 µs–257 µs) +1.2%
pairwise_or/ref_own/census-income 1.7 ms (1.7 ms–1.8 ms) 1.9 ms (1.8 ms–1.9 ms) +8.0%
pairwise_or/ref_own/census-income_srt 842 µs (834 µs–844 µs) 922 µs (912 µs–923 µs) +9.5%
pairwise_or/ref_own/census1881 435 µs (387 µs–555 µs) 436 µs (366 µs–462 µs) +0.2%
pairwise_or/ref_own/census1881_srt 222 µs (220 µs–238 µs) 232 µs (231 µs–236 µs) +4.4%
pairwise_or/ref_own/weather_sept_85 8.6 ms (8.3 ms–9.1 ms) 9.2 ms (9.1 ms–9.3 ms) +7.3%
pairwise_or/ref_own/weather_sept_85_srt 1.8 ms (1.7 ms–2.3 ms) 2.0 ms (1.9 ms–2.0 ms) +10.9%
pairwise_or/ref_own/wikileaks-noquotes 479 µs (467 µs–504 µs) 514 µs (499 µs–533 µs) +7.2%
pairwise_or/ref_own/wikileaks-noquotes_srt 239 µs (229 µs–249 µs) 253 µs (250 µs–349 µs) +5.6%
pairwise_or/ref_ref/census-income 1.8 ms (1.8 ms–1.9 ms) 1.9 ms (1.9 ms–2.0 ms) +6.1%
pairwise_or/ref_ref/census-income_srt 866 µs (864 µs–951 µs) 939 µs (932 µs–956 µs) +8.4%
pairwise_or/ref_ref/census1881 451 µs (395 µs–502 µs) 524 µs (469 µs–526 µs) +16.1%
pairwise_or/ref_ref/census1881_srt 281 µs (278 µs–286 µs) 293 µs (284 µs–293 µs) +4.0%
pairwise_or/ref_ref/weather_sept_85 8.7 ms (8.7 ms–9.1 ms) 9.2 ms (9.1 ms–9.4 ms) +6.2%
pairwise_or/ref_ref/weather_sept_85_srt 2.0 ms (1.8 ms–2.5 ms) 2.2 ms (2.1 ms–2.2 ms) +11.7%
pairwise_or/ref_ref/wikileaks-noquotes 494 µs (475 µs–497 µs) 527 µs (523 µs–547 µs) +6.8%
pairwise_or/ref_ref/wikileaks-noquotes_srt 248 µs (244 µs–274 µs) 279 µs (267 µs–324 µs) +12.7%

Small inputs are where the extra bookkeeping would show. I measured them with main, an identical copy of main and this branch all linked into one binary. The binary alternates between them in every round (9 rounds, median of per-round medians, timer resolution about 10 ns). The copy of main shows how much two builds of the same code differ:

Small and same-key cases
case main identical copy of main this PR PR vs main
same keys 16+16/assign ref 331 ns 331 ns 331 ns +0%
same keys 16+16/assign own 441 ns 441 ns 451 ns +2%
same keys 16+16/multi or ref 5.2 µs 5.2 µs 5.1 µs -1%
same keys 16+16/multi or own 5.7 µs 5.6 µs 5.6 µs -1%
same keys 1024+1024/assign ref 31 µs 31 µs 31 µs -0%
same keys 1024+1024/assign own 45 µs 45 µs 45 µs -1%
same keys 1024+1024/multi or ref 1.7 ms 1.7 ms 1.6 ms -1%
same keys 1024+1024/multi or own 1.7 ms 1.7 ms 1.7 ms -1%
same keys 32768+32768/assign ref 1.3 ms 1.3 ms 1.3 ms +1%
same keys 32768+32768/assign own 1.8 ms 1.8 ms 1.8 ms -0%
same keys 32768+32768/multi or ref 135.9 ms 137.0 ms 139.7 ms +3%
same keys 32768+32768/multi or own 141.3 ms 138.8 ms 142.7 ms +1%
1+1 same key/assign ref 131 ns 120 ns 131 ns +0%
1+1 same key/assign own 160 ns 150 ns 160 ns +0%
1+1 new key/assign ref 40 ns 40 ns 50 ns +25%
1+1 new key/assign own 50 ns 50 ns 50 ns +0%
4+4 interleaved/assign ref 91 ns 91 ns 101 ns +11%
4+4 interleaved/assign own 100 ns 100 ns 100 ns +0%
4+4 interleaved/multi or ref 200 ns 200 ns 201 ns +0%
4+4 interleaved/multi or own 141 ns 140 ns 140 ns -1%
8+8 interleaved/assign ref 160 ns 160 ns 171 ns +7%
8+8 interleaved/assign own 160 ns 160 ns 170 ns +6%
8+8 interleaved/multi or ref 351 ns 360 ns 370 ns +5%
8+8 interleaved/multi or own 221 ns 221 ns 211 ns -5%
16+16 interleaved/assign ref 411 ns 420 ns 421 ns +2%
16+16 interleaved/assign own 331 ns 340 ns 360 ns +9%
16+16 interleaved/multi or ref 1.0 µs 1.0 µs 982 ns -2%
16+16 interleaved/multi or own 411 ns 420 ns 441 ns +7%
32+32 interleaved/assign ref 792 ns 782 ns 842 ns +6%
32+32 interleaved/assign own 761 ns 761 ns 772 ns +1%
32+32 interleaved/multi or ref 2.1 µs 2.2 µs 2.1 µs -1%
32+32 interleaved/multi or own 902 ns 922 ns 1.0 µs +14%
64+64 interleaved/assign ref 2.0 µs 1.8 µs 2.0 µs +1%
64+64 interleaved/assign own 1.8 µs 1.8 µs 1.9 µs +5%
64+64 interleaved/multi or ref 4.8 µs 4.8 µs 4.6 µs -3%
64+64 interleaved/multi or own 2.0 µs 2.1 µs 2.1 µs +3%
128+128 interleaved/assign ref 5.0 µs 5.0 µs 4.2 µs -15%
128+128 interleaved/assign own 4.8 µs 4.8 µs 3.6 µs -25%
128+128 interleaved/multi or ref 11 µs 11 µs 9.1 µs -15%
128+128 interleaved/multi or own 5.3 µs 5.4 µs 3.9 µs -27%
sparse40+40 interleaved/assign ref 882 ns 872 ns 962 ns +9%
sparse40+40 interleaved/assign own 962 ns 952 ns 1.0 µs +7%
sparse40+40 interleaved/multi or ref 3.7 µs 3.7 µs 3.6 µs -2%
sparse40+40 interleaved/multi or own 2.3 µs 2.4 µs 2.4 µs +3%

Choosing the thresholds

I picked 8 with a sweep: insert k new containers into a bitmap of n containers with |= (borrowed, vector with spare capacity), placed between the existing ones, in front of them, or after them. I compared main with the count set to 0 (always defer), 4, 8 and 16. Times are in µs, medians of 7 alternating rounds:

Sweep
n new containers k main 0 4 8 (this PR) 16
1024 spread 1 0.51 1.34 0.52 0.37 0.38
1024 spread 4 1.30 1.70 1.32 1.32 1.32
1024 spread 5 1.10 1.47 1.26 1.13 1.13
1024 spread 8 1.62 1.56 1.90 1.67 1.66
1024 spread 9 1.80 1.91 2.02 1.92 1.84
1024 spread 12 2.32 1.69 2.30 2.49 2.37
1024 spread 16 3.01 1.81 2.63 3.04 3.08
1024 spread 32 5.76 2.31 3.32 4.22 5.34
1024 spread 256 45 8.13 9.68 11 13
1024 in front 1 0.51 1.26 0.52 0.52 0.52
1024 in front 4 2.02 1.27 1.46 1.46 1.47
1024 in front 5 1.79 1.32 2.56 1.82 1.82
1024 in front 8 2.86 1.35 2.60 2.90 2.91
1024 in front 9 3.22 1.45 2.71 4.01 3.27
1024 in front 12 4.28 1.50 2.75 4.05 4.34
1024 in front 16 5.70 1.60 2.90 4.18 5.78
1024 in front 32 11 2.07 3.39 4.70 7.32
1024 in front 256 92 8.12 9.72 11 14
1024 after 1 0.06 0.06 0.06 0.06 0.06
1024 after 4 0.11 0.11 0.12 0.12 0.12
1024 after 5 0.13 0.14 0.14 0.14 0.17
1024 after 8 0.21 0.21 0.22 0.22 0.23
1024 after 9 0.23 0.24 0.24 0.25 0.26
1024 after 12 0.29 0.30 0.30 0.31 0.30
1024 after 16 0.36 0.38 0.39 0.39 0.39
1024 after 32 0.66 0.69 0.70 0.70 0.70
1024 after 256 4.98 5.09 5.19 5.20 5.19
32768 spread 1 11 40 11 11 11
32768 spread 4 29 41 29 29 29
32768 spread 5 35 41 41 35 35
32768 spread 8 53 41 60 52 52
32768 spread 9 58 41 63 61 58
32768 spread 12 75 41 70 80 75
32768 spread 16 98 42 76 95 98
32768 spread 32 191 43 83 118 166
32768 spread 256 1504 58 102 145 233
32768 in front 1 31 59 31 31 31
32768 in front 4 67 59 67 67 67
32768 in front 5 79 59 106 79 79
32768 in front 8 114 59 106 114 114
32768 in front 9 126 59 107 154 127
32768 in front 12 161 59 107 153 161
32768 in front 16 209 59 106 154 207
32768 in front 32 396 60 108 155 252
32768 in front 256 3075 68 116 164 259
32768 after 1 0.08 0.08 0.09 0.09 0.09
32768 after 4 0.20 0.20 0.20 0.21 0.21
32768 after 5 0.24 0.23 0.24 0.25 0.24
32768 after 8 0.36 0.35 0.36 0.36 0.36
32768 after 9 0.39 0.39 0.40 0.39 0.40
32768 after 12 0.49 0.49 0.50 0.50 0.50
32768 after 16 0.61 0.62 0.64 0.63 0.65
32768 after 32 1.05 1.11 1.10 1.11 1.13
32768 after 256 6.89 7.05 7.11 7.08 7.19
  • Always deferring is 2–4x slower than main for a single new container. Inserting one at a time falls behind somewhere between 4 and 12 containers, depending on where they go.
  • With 8, a handful of new containers takes the same path as main.
  • Right above the cutoff, the 8 direct inserts plus the merge cost more than main. The worst case in the sweep is 9 containers in front of 1024: 3.2 µs → 4.0 µs. With 4, that bump is bigger: 1.8 µs → 2.6 µs at 5 containers in front of 1024.
  • A higher count gives up a lot when many containers are new: 256 in front of 32768 takes 164 µs with 8 and 259 µs with 16, against 3.1 ms on main.

The shift threshold keeps small vectors away from the merge, whose allocations cost more than shifting a few dozen containers. Measured with borrowed |= (9 alternating rounds), with the shift threshold at 0 / 64 / 256:

  • 16 + 16 interleaved containers: 401 ns on main, then 511 / 421 / 411 ns;
  • 128 + 128: 4.9 µs on main, then 3.7 / 4.2 / 5.2 µs.

Limitations

  • The binary search per right-hand container is still there, so the operation is O(m log n), with m and n the right- and left-hand container counts. Only the repeated shifting, which was the quadratic part, is gone.
  • Just above 8 long shifts it can be slower than main: up to ~30% in the sweep, at 9 new containers.
  • Small interleaved inputs measured up to ~10–15% slower than main. For example, borrowed |= of 8 + 8 went from 160 ns to 171 ns, and owned union() of 32 + 32 from 902 ns to 1.0 µs.

let mut tail = vec.split_off(start).into_iter();
let mut prev = start;
for (index, value) in self.deferred {
vec.extend(tail.by_ref().take(index - prev));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This seems to depend on deferred being in sorted order: otherwise, index could be less than prev (and would panic in debug mode, do weird stuff in release mode).

@Kerollmops Kerollmops Oct 27, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Indeed, @Dr-Emann is right here. In the insert method, you push a tuple of index and value that are arbitrary, and the first index could be larger than the second one pushed. Then, in the finish method, you iterate over all the deferred entries and seem to try to implement some sort of merge join, but the deferred values are always pushed back (never inserted, obviously). To implement a merge join, deferred values must be in sorted order.

I ran the fuzzer for ten minutes but didn't find anything, yet...

@emschwartz emschwartz mentioned this pull request Oct 10, 2026
@Kerollmops

Copy link
Copy Markdown
Member

Hey @fjmaco 👋

Thank you very much for this impressive PR. However, would you mind answering @Dr-Emann, please? I made the CI run and it seems to pass the tests which is a first good step 👍

Have a nice week 🌵

One bitmap has containers at even keys and the other one at odd keys,
so every container of one side is missing from the other side.
BitOrAssign and the MultiOps union and symmetric difference insert each
right-hand container missing on the left with Vec::insert. Every insert
shifts the containers after it, so when the right side brings many
containers interleaved with the left ones, the operation is quadratic
in the number of containers.

Containers are still inserted directly when that shifts fewer than 64
containers, and for the first eight insertions that shift more. The
other ones are deferred and merged into the vector in a single pass,
which only moves the containers that follow the first deferred one.
Comment on lines 279 to +280
match lhs.binary_search_by_key(&rhs.key, |c| c.key) {
Err(loc) => lhs.insert(loc, rhs),
Err(loc) => inserts.insert(lhs, loc, rhs),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not even sure to understand how it can work because a container that is not found during the first loop run must be inserted and the next loop will be able to find it and will therefore enter the Ok branch hand this time, triggering the op function to merge both containers.

This currently works because you use the FEW_INSERTS and SHORT_SHIFT parameters to still insert in the target containers (and find the freshly inserted container) but it will no longer work if you inserted too many containers already or if you happen to insert too far on the left. I thought these container mergings could have been implement in the SortedInserts::finish method but as far as I read it I don't see anything like that.

I find the idea to avoid shifts really good but the implementation doesn't seems correct to me.

@Kerollmops

Kerollmops commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Hey @fjmaco 👋

Please read my last comment. It seems to me that the implementation is wrong. That's not a big deal and I'm happy that @Dr-Emann and I found out that there was something odd in the code.

However, I'm concerned about the fact that our CI, benchmarks, nor fuzzer found those bugs. It seems to me that the bug described in my last comment can only be triggered when merging multiple bitmaps simultaneously and that happen when using the MultiOps::union/symmetric_difference trait method.

Do you think you could find some time to create a test to trigger the bug in a dedicated commit and once you got the right test I can merge it on main, please?

Have a nice day 🌵

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants