Skip to content

refactor(xor): remove item counts and simplify construction - #329

Merged
tisonkun merged 14 commits into
apache:mainfrom
tisonkun:codex/xor-count-metadata
Nov 6, 2026
Merged

tisonkun merged 14 commits into
apache:mainfrom
tisonkun:codex/xor-count-metadata

Conversation

@tisonkun

@tisonkun tisonkun commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

Remove item-count metadata and the count-dependent APIs from XorFilter. Membership queries need only the seed, layout, and fingerprints; imported filters can now use standard serialization without supplying a count. Segment length stays cached internally and has no public accessor. Remove redundant sizing accessors while retaining estimated_size.

Use XorFilter::from_hashes for distinct hashes or from_fingerprints(seed, fingerprints) for imports. XorFilterBuilder replaces from_values and accepts duplicate inputs. Add values or precomputed hashes individually, or use update_values and update_hashes for batches. The initial seed selects the layouts to try; imports take the seed returned by the built filter.

Reduce the serialization header from 24 to 16 bytes, matching the Java proposal. XOR has not been released in either library, so serialization stays at version 1. Reference fixtures are generated from the Java change.

Validation: cargo x check, cargo x test (810 tests including 147 doctests), and cargo x lint. Release and ThinLTO results compare 45 XOR workloads at 12dbf18 and c294258. At 1dd2c25, building 2,000 Xor8 filters from 16,384 values each (4,096 distinct) took a median of 340 ms with individual update calls and 324 ms with update_values over five release runs.

@tisonkun

tisonkun commented Nov 5, 2026 •

Copy link
Copy Markdown
Member Author

Revision note: the compact count representation measured below has been removed. The current PR retains Option<usize> and the 56-byte object layout; this table records an earlier experiment, not the current head.

This reduces the 64-bit filter header from 56 to 48 bytes. It is a memory tradeoff, not a lookup speedup: the imported-filter query benchmark is mostly 2–5% slower than main. The unknown-count path now loads and compares one count instead of branching around the count check based on the Option tag.

Before: main 4ba130e; after: 6b449a3. Apple M4 Max, macOS ARM64, nightly-2026-08-28, optimized bench profile with ThinLTO. Three repetitions per revision, interleaved B/A/A/B/B/A; fixed 20,000 rounds, each querying 32 filters 256 times. Same fixtures, rounds, and timing boundaries. Values are median milliseconds [min–max]; positive change means more elapsed time.

Scenario Main PR Change
8_present/128 273 [272–277] 286 [280–304] +4.8%
8_absent/128 276 [273–280] 282 [281–285] +2.2%
8_present/16384 353 [340–353] 354 [353–368] +0.3%
8_absent/16384 348 [337–362] 357 [354–361] +2.6%
16_present/128 280 [275–283] 286 [286–292] +2.1%
16_absent/128 281 [278–301] 288 [286–288] +2.5%
16_present/16384 362 [357–367] 369 [361–369] +1.9%
16_absent/16384 353 [350–360] 366 [361–366] +3.7%
32_present/128 275 [273–277] 282 [279–284] +2.5%
32_absent/128 276 [275–281] 282 [281–285] +2.2%
32_present/16384 359 [352–375] 368 [360–373] +2.5%
32_absent/16384 358 [354–365] 366 [363–367] +2.2%

Reproduce each revision with CARGO_PROFILE_BENCH_LTO=thin cargo +nightly-2026-08-28 bench -p benchmarks --bench benchmarks -- xor::query_shards --baseline-only --repeat 3 --raw.

The empty-set check remains necessary: a known-empty filter must reject every hash, while an imported filter with unknown cardinality must preserve the fingerprint comparison. No additional query special cases or packed metadata types were added.

@tisonkun tisonkun changed the title perf(xor): store optional item counts in one word refactor(xor): simplify metadata storage and public api Nov 5, 2026
@tisonkun tisonkun changed the title refactor(xor): simplify metadata storage and public api refactor(xor): simplify metadata accessors and serialization Nov 5, 2026
@tisonkun tisonkun changed the title refactor(xor): simplify metadata accessors and serialization refactor(xor): simplify metadata and membership queries Nov 5, 2026
@tisonkun

tisonkun commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

Retain the cached segment length: deriving fingerprints.len() / 3 on every lookup adds work to the query loop. The final change derives it once in from_parts(seed, fingerprints, num_items), removing the redundant import argument.

Before: cb079f874dd346bb0ecd1042dde0c9cb54f1f29b. Final: 6faf16148b10fab8363d5b6bb7a78f11acb68e70. Both use optimized Cargo bench profiles. The per-query-derived variant was measured but is not included in the PR.

For xor::query_shards::8_present/128 (32 filters, 256 lookups per filter, 20,000 rounds):

Variant Profile Before ms After ms Time change
Compute on each query Ordinary release 263.7 278.3 +5.6%
Compute on each query ThinLTO 263.3 277.3 +5.3%
Final: cache on import Ordinary release 267.7 270.7 +1.1%
Final: cache on import ThinLTO 276.0 280.3 +1.6%

The final multi-filter query loops match the baseline instructions apart from addresses. Measurements still include host noise: ordinary-release 8_absent/16384 has a 591 ms sample, and 8_absent/50000 has large samples on both revisions. These rows are inconclusive; no samples were dropped. This comparison measures this library change, not end-to-end application performance.

All 45 final workloads: arithmetic means and all three samples, in milliseconds

Apple M4 Max, macOS 26.6.2, aarch64, rustc 1.100.0-nightly (e457a7b0d, nightly-2026-08-28). Ordinary bench profile and the same profile with CARGO_PROFILE_BENCH_LTO=thin. No concurrent builds or tests during measurement. Fixed inputs and registered round counts are identical across revisions. Each row ran in before/after/after/before/before/after order, with setup and cleanup included. Each cell is mean [samples in measurement order]; positive time change means slower.

Build each revision with:

cargo +nightly-2026-08-28 bench -p benchmarks --bench benchmarks --no-run
CARGO_PROFILE_BENCH_LTO=thin cargo +nightly-2026-08-28 bench -p benchmarks --bench benchmarks --no-run

Invoke each saved binary for each registered XOR scenario with --exact --baseline-only --repeat 1 --raw, interleaving revisions as above.

Workload Release before Release final Time change ThinLTO before ThinLTO final Time change
build::8_distinct/1000 307.3 [323, 303, 296] 305.7 [307, 308, 302] -0.5% 299.0 [299, 297, 301] 300.3 [301, 301, 299] +0.4%
build::8_deduplicate/1000 346.3 [349, 345, 345] 354.3 [358, 352, 353] +2.3% 350.3 [346, 351, 354] 352.0 [348, 353, 355] +0.5%
build::16_distinct/1000 308.7 [313, 305, 308] 306.0 [307, 305, 306] -0.9% 302.3 [303, 302, 302] 301.3 [303, 299, 302] -0.3%
build::16_deduplicate/1000 356.0 [359, 353, 356] 358.3 [359, 355, 361] +0.7% 356.7 [362, 357, 351] 358.0 [360, 361, 353] +0.4%
build::32_distinct/1000 308.7 [310, 310, 306] 315.3 [316, 318, 312] +2.2% 302.0 [302, 304, 300] 313.3 [314, 313, 313] +3.8%
build::32_deduplicate/1000 356.0 [356, 352, 360] 353.0 [355, 350, 354] -0.8% 356.0 [355, 362, 351] 356.3 [358, 353, 358] +0.1%
build::8_distinct/16384 358.7 [359, 360, 357] 358.0 [356, 359, 359] -0.2% 358.7 [357, 356, 363] 360.7 [355, 358, 369] +0.6%
build::8_deduplicate/16384 249.0 [243, 251, 253] 247.7 [250, 244, 249] -0.5% 247.7 [255, 243, 245] 257.0 [273, 245, 253] +3.8%
build::16_distinct/16384 355.7 [358, 355, 354] 361.7 [368, 360, 357] +1.7% 357.0 [362, 355, 354] 355.3 [356, 353, 357] -0.5%
build::16_deduplicate/16384 242.7 [242, 241, 245] 245.0 [245, 242, 248] +1.0% 244.0 [244, 245, 243] 244.3 [244, 245, 244] +0.1%
build::32_distinct/16384 358.3 [360, 358, 357] 362.3 [361, 370, 356] +1.1% 368.0 [370, 367, 367] 371.3 [372, 376, 366] +0.9%
build::32_deduplicate/16384 244.7 [244, 244, 246] 243.7 [242, 244, 245] -0.4% 251.3 [254, 252, 248] 250.3 [250, 252, 249] -0.4%
build::8_distinct/1000000 153.3 [154, 153, 153] 152.0 [152, 152, 152] -0.9% 153.7 [156, 151, 154] 154.7 [153, 151, 160] +0.7%
build::8_deduplicate/1000000 149.3 [148, 150, 150] 147.0 [147, 147, 147] -1.6% 153.7 [150, 156, 155] 151.0 [154, 148, 151] -1.7%
build::16_distinct/1000000 150.0 [150, 149, 151] 154.0 [151, 151, 160] +2.7% 155.7 [152, 157, 158] 167.3 [173, 163, 166] +7.5%
build::16_deduplicate/1000000 148.7 [149, 149, 148] 149.7 [150, 150, 149] +0.7% 156.0 [154, 154, 160] 154.7 [153, 152, 159] -0.9%
build::32_distinct/1000000 155.0 [151, 156, 158] 159.7 [157, 158, 164] +3.0% 159.0 [163, 158, 156] 158.0 [162, 157, 155] -0.6%
build::32_deduplicate/1000000 152.7 [153, 153, 152] 150.7 [150, 153, 149] -1.3% 152.3 [151, 150, 156] 153.3 [150, 150, 160] +0.7%
build_batches::8/128 264.7 [264, 263, 267] 260.3 [262, 257, 262] -1.6% 262.3 [255, 269, 263] 266.0 [262, 264, 272] +1.4%
build_batches::8/16384 138.7 [139, 139, 138] 139.7 [139, 140, 140] +0.7% 141.0 [142, 141, 140] 143.0 [145, 143, 141] +1.4%
build_batches::8/50000 164.3 [164, 165, 164] 164.7 [165, 165, 164] +0.2% 169.7 [166, 175, 168] 174.7 [167, 188, 169] +2.9%
query::8_present/16384 253.0 [255, 252, 252] 255.0 [256, 255, 254] +0.8% 262.3 [260, 259, 268] 260.3 [259, 258, 264] -0.8%
query::8_present/50000 236.7 [228, 254, 228] 231.3 [230, 232, 232] -2.3% 236.0 [234, 237, 237] 238.0 [244, 231, 239] +0.8%
query::8_absent/16384 270.3 [257, 247, 307] 363.3 [252, 247, 591] +34.4% 254.7 [256, 253, 255] 254.7 [255, 256, 253] +0.0%
query::8_absent/50000 306.0 [461, 231, 226] 393.7 [470, 485, 226] +28.6% 230.0 [236, 229, 225] 230.3 [234, 230, 227] +0.1%
query::16_present/16384 260.0 [257, 260, 263] 261.7 [259, 263, 263] +0.6% 274.3 [261, 259, 303] 262.0 [260, 259, 267] -4.5%
query::16_present/50000 267.3 [301, 250, 251] 251.3 [255, 248, 251] -6.0% 251.3 [248, 252, 254] 251.3 [259, 248, 247] +0.0%
query::16_absent/16384 251.3 [254, 250, 250] 250.3 [251, 250, 250] -0.4% 256.0 [252, 256, 260] 258.3 [262, 253, 260] +0.9%
query::16_absent/50000 240.7 [240, 242, 240] 244.0 [241, 241, 250] +1.4% 246.7 [243, 249, 248] 247.0 [249, 247, 245] +0.1%
query::32_present/16384 251.0 [252, 252, 249] 250.0 [254, 250, 246] -0.4% 250.0 [253, 248, 249] 250.7 [252, 253, 247] +0.3%
query::32_present/50000 260.7 [260, 262, 260] 265.0 [270, 268, 257] +1.7% 262.3 [260, 262, 265] 261.3 [261, 262, 261] -0.4%
query::32_absent/16384 249.3 [247, 249, 252] 254.0 [257, 253, 252] +1.9% 249.7 [250, 249, 250] 247.0 [246, 246, 249] -1.1%
query::32_absent/50000 271.0 [265, 261, 287] 262.3 [262, 260, 265] -3.2% 262.7 [263, 262, 263] 267.0 [269, 265, 267] +1.6%
query_shards::8_present/128 267.7 [267, 267, 269] 270.7 [266, 272, 274] +1.1% 276.0 [274, 276, 278] 280.3 [280, 280, 281] +1.6%
query_shards::8_absent/128 268.0 [269, 269, 266] 268.0 [266, 266, 272] +0.0% 284.0 [276, 301, 275] 276.7 [281, 279, 270] -2.6%
query_shards::8_present/16384 362.3 [383, 320, 384] 350.3 [387, 344, 320] -3.3% 358.0 [400, 341, 333] 338.3 [346, 332, 337] -5.5%
query_shards::8_absent/16384 361.0 [384, 322, 377] 340.3 [378, 321, 322] -5.7% 373.0 [398, 394, 327] 346.3 [328, 381, 330] -7.1%
query_shards::16_present/128 267.0 [271, 265, 265] 266.0 [266, 265, 267] -0.4% 278.0 [282, 275, 277] 276.3 [274, 275, 280] -0.6%
query_shards::16_absent/128 266.3 [266, 266, 267] 267.3 [267, 269, 266] +0.4% 274.3 [279, 272, 272] 273.3 [276, 273, 271] -0.4%
query_shards::16_present/16384 330.3 [331, 329, 331] 331.0 [333, 330, 330] +0.2% 340.0 [337, 339, 344] 338.7 [335, 340, 341] -0.4%
query_shards::16_absent/16384 335.7 [335, 336, 336] 334.0 [331, 334, 337] -0.5% 356.0 [362, 341, 365] 342.3 [343, 345, 339] -3.8%
query_shards::32_present/128 272.7 [271, 275, 272] 265.7 [264, 262, 271] -2.6% 282.0 [284, 281, 281] 299.0 [285, 283, 329] +6.0%
query_shards::32_absent/128 265.7 [271, 262, 264] 269.0 [261, 271, 275] +1.3% 276.7 [280, 271, 279] 278.0 [278, 281, 275] +0.5%
query_shards::32_present/16384 337.0 [338, 333, 340] 347.7 [351, 345, 347] +3.2% 349.7 [357, 347, 345] 346.7 [343, 350, 347] -0.9%
query_shards::32_absent/16384 338.0 [336, 340, 338] 334.7 [337, 332, 335] -1.0% 352.0 [347, 354, 355] 360.0 [361, 363, 356] +2.3%

Validation: cargo x check, cargo x test (813 tests including doctests), and cargo x lint. Existing Java and application-format serialization fixtures remain unchanged.

@tisonkun tisonkun changed the title refactor(xor): simplify metadata and membership queries refactor(xor): remove item counts and simplify construction Nov 5, 2026
@tisonkun

tisonkun commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

Before: 12dbf18, the previous PR head. After: c294258, with the builder and item-count removal.

All 45 workloads were run in optimized release and ThinLTO builds, with three samples each. Median changes range from -3.4% to +2.6% in release and -9.5% to +1.7% with ThinLTO. No workload shows a median slowdown above 3%.

Apple M4 Max, 36 GiB, macOS 26.6.2; rustc 1.101.0-nightly (8006091eb). Commands: cargo bench -p benchmarks --bench benchmarks -- xor:: --repeat 3 --raw --baseline-only, repeated with CARGO_PROFILE_BENCH_LTO=thin. Input sets, fixed round counts, and timing boundaries are unchanged. Deduplication workloads use caller sorting before the change and the builder batch API after it. Benchmark binaries ran sequentially.

All measurements (milliseconds; three samples in measurement order)
Workload Release before Release after ThinLTO before ThinLTO after
build::8_distinct/1000 313, 290, 294 316, 294, 297 318, 295, 294 324, 297, 297
build::8_deduplicate/1000 335, 338, 334 344, 342, 341 343, 349, 345 347, 343, 347
build::16_distinct/1000 295, 294, 295 292, 291, 290 296, 297, 296 301, 301, 300
build::16_deduplicate/1000 335, 333, 339 341, 343, 342 344, 343, 344 348, 348, 347
build::32_distinct/1000 298, 296, 296 296, 296, 295 294, 293, 294 297, 300, 294
build::32_deduplicate/1000 342, 338, 338 342, 344, 341 344, 344, 343 349, 348, 347
build::8_distinct/16384 341, 343, 339 343, 345, 344 346, 347, 347 349, 347, 348
build::8_deduplicate/16384 240, 244, 239 240, 238, 239 242, 244, 244 241, 242, 244
build::16_distinct/16384 371, 344, 344 345, 345, 346 349, 349, 348 347, 348, 346
build::16_deduplicate/16384 234, 236, 233 240, 240, 239 243, 242, 243 241, 242, 240
build::32_distinct/16384 346, 346, 348 348, 348, 349 351, 353, 349 350, 355, 350
build::32_deduplicate/16384 235, 235, 235 240, 241, 239 243, 243, 242 242, 242, 241
build::8_distinct/1000000 141, 141, 141 142, 142, 143 144, 142, 142 145, 140, 140
build::8_deduplicate/1000000 142, 148, 144 142, 142, 142 143, 147, 143 143, 143, 144
build::16_distinct/1000000 146, 142, 143 141, 143, 143 142, 142, 142 146, 143, 141
build::16_deduplicate/1000000 142, 141, 140 143, 143, 142 143, 144, 144 144, 144, 145
build::32_distinct/1000000 143, 142, 143 144, 142, 143 143, 142, 144 143, 143, 143
build::32_deduplicate/1000000 146, 147, 145 143, 143, 144 144, 144, 145 145, 145, 145
build_batches::8/128 260, 252, 256 260, 261, 265 251, 249, 250 253, 254, 252
build_batches::8/16384 134, 134, 132 135, 132, 133 134, 133, 134 135, 135, 135
build_batches::8/50000 159, 158, 157 159, 159, 158 160, 159, 158 160, 158, 158
query::8_present/16384 250, 253, 253 252, 250, 251 250, 250, 245 248, 249, 249
query::8_present/50000 226, 226, 226 226, 230, 225 226, 225, 220 227, 226, 226
query::8_absent/16384 242, 241, 241 243, 246, 247 242, 244, 241 241, 242, 241
query::8_absent/50000 219, 219, 219 221, 222, 221 219, 219, 218 219, 219, 219
query::16_present/16384 249, 251, 252 255, 254, 255 252, 253, 253 253, 252, 254
query::16_present/50000 241, 241, 240 242, 240, 242 240, 241, 241 243, 240, 239
query::16_absent/16384 247, 249, 247 247, 247, 248 247, 246, 245 246, 248, 246
query::16_absent/50000 237, 237, 237 239, 239, 239 238, 237, 238 237, 238, 237
query::32_present/16384 252, 242, 240 243, 243, 243 243, 243, 243 242, 242, 241
query::32_present/50000 251, 253, 253 251, 251, 252 254, 253, 253 251, 251, 250
query::32_absent/16384 243, 241, 242 241, 242, 242 243, 242, 242 248, 242, 241
query::32_absent/50000 258, 258, 259 255, 256, 255 255, 258, 255 255, 255, 255
query_shards::8_present/128 265, 267, 261 262, 262, 264 261, 261, 261 263, 261, 262
query_shards::8_absent/128 262, 263, 262 261, 264, 262 262, 262, 262 262, 261, 262
query_shards::8_present/16384 329, 331, 329 323, 319, 323 332, 329, 332 326, 322, 326
query_shards::8_absent/16384 324, 320, 326 324, 325, 325 328, 329, 330 324, 322, 324
query_shards::16_present/128 269, 268, 268 266, 266, 265 267, 265, 266 267, 267, 267
query_shards::16_absent/128 267, 266, 267 266, 266, 265 266, 266, 266 267, 267, 271
query_shards::16_present/16384 341, 340, 339 333, 330, 332 341, 339, 339 336, 334, 336
query_shards::16_absent/16384 337, 333, 337 342, 343, 341 338, 339, 337 334, 332, 334
query_shards::32_present/128 264, 268, 268 260, 259, 259 263, 266, 265 263, 261, 260
query_shards::32_absent/128 264, 264, 262 259, 260, 259 263, 262, 263 260, 261, 260
query_shards::32_present/16384 343, 342, 344 335, 335, 336 362, 373, 368 339, 333, 333
query_shards::32_absent/16384 336, 335, 339 336, 336, 339 349, 341, 342 337, 334, 337

Comment thread datasketches/src/xor/mod.rs Outdated
@@ -22,16 +22,15 @@
//! performs exactly three fingerprint reads per query. Construction needs temporary space linear
//! in the number of distinct hashes.

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.

Could we clarify which part of construction this space bound applies to? XorFilterBuilder stores one u64 per input until build() deduplicates the hashes. Passing one million copies of the same hash to update_hashes allocated 8,000,000 bytes just to collect them. I’d suggest saying that the builder buffers one hash per input and that, after deduplication, constructing the layout needs additional space proportional to the number of distinct hashes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Updated.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We may also use a hashset to collect hashes. I'd leave it as an internal improvement.

Perhaps we should hide this details from user documents.

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.

Agreed, users should not know the details.

@tisonkun
tisonkun merged commit 87ee04b into apache:main Nov 6, 2026
10 checks passed
@tisonkun
tisonkun deleted the codex/xor-count-metadata branch November 6, 2026 08:36
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.

2 participants