Skip to content

Let a band posting list outgrow a single MongoDB document - #196

Open
r0ny123 wants to merge 3 commits into
danielplohmann:mainfrom
r0ny123:pr3/band-bucketing
Open

r0ny123 wants to merge 3 commits into
danielplohmann:mainfrom
r0ny123:pr3/band-bucketing

Conversation

@r0ny123

@r0ny123 r0ny123 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Stacks on #195; only the commits after it are this PR's, and the diff will collapse to them once that one merges.

fork PR with CI history: r0ny123#45

A band posting list lives as a function_ids array inside one document, and MongoDB caps a document at 16MB, which puts a hard ceiling on corpus size that I don't think anyone has hit yet but is closer than it looks. On a 7,244-sample corpus the largest band_0 document held 18,968 postings in 197,606 bytes — about 10.4 bytes each, so roughly 1.6M fit, which is 84.9x that corpus and puts the wall somewhere near 615,000 samples. I checked rather than extrapolating: pushing 100,000 ids at a time into one document succeeded ten times and failed on the eleventh with BSONObj size: 17588958 is invalid. It fails, it doesn't slow down, so indexing stops outright for any sample holding a function whose band hash is already at the cap. Sharding doesn't help either, since a document can't span shards.

STORAGE_BAND_BUCKET_SIZE splits a hash across (band_hash, bucket) documents once it would exceed the cap, and defaults to 0, which keeps the current single-document shape byte for byte. Bucket 0 carries the bookkeeping for the whole hash — df as the total across every bucket, plus tail/tail_n for placement — which is what leaves the cutoff filter and its (band_hash, df) index untouched: a hash under the cutoff is nowhere near a bucket's worth so it never spills, and one that did spill has a df that rejects it anyway. Buckets fill in order rather than by hashing the function id, deliberately, since modulo placement would scatter a 200-entry posting list across 200 documents and make the common cheap case expensive. One thing to watch on an existing database: enabling this needs rebuild_band_df_index run first, because documents written earlier have no bucket field and the upsert filter would miss them and insert a second document per hash, splitting the list with nothing reporting an error — the rebuild stamps bucket: 0 and is what makes them addressable. The estimate above is also on the optimistic side, since Malpedia is curated and deduplicated; a corpus full of near-duplicate packed variants would concentrate df faster and get there sooner. Stacked on #195.

LogBucket.getLogBucketRange raised KeyError for any value at or above SHINGLER_LOGBUCKETS,
which aborts the whole indexing job. The table covers 0..max_value-1 and FuzzyStatPairShingler
buckets max_block_size, num_ins_C, num_ins_S and num_calls through it without bounding any of
them - only stack_size is clamped, at its own call site. A single basic block of 108,837 bytes
was enough to make a corpus unindexable, and the odds of containing one rise with corpus size.
Values outside the table are clamped to its bounds now; nothing inside it moves, so no MinHash
that could be computed before changes value.

Worker.updateMinHashes raised UnboundLocalError when there was nothing left to hash, because
minhashes was only ever bound inside the batch loop. Finishing with an empty backlog therefore
failed exactly like a crash, and that is the normal state of a resumed index. The same statement
also returned the size of the last batch rather than the total, so any run longer than one
workpack under-reported - a 238,991-function backlog across 24 batches reported whatever the
final batch held. Every caller reads it as a total, so it accumulates now.

Both come with tests that fail against the unfixed code.
Every stage of a 1-vs-N query grows with corpus size, and so does the answer - a result naming
thousands of matched samples is not something anybody reads, and bounding the answer turns out to
be the only thing that bounds the work. MINHASH_MATCHING_SHORTLIST_SIZE ranks candidate samples
cheaply first (one vote per distinct query function, plus weighted PicHash evidence, ranked by
vote count and by coverage so a small sample matching entirely is not buried under a large one
matching partly) and then matches only those exactly. STORAGE_BAND_DF_CUTOFF skips band hashes
whose posting list is longer than the cutoff, decided from a stored df and a (band_hash, df)
index so an over-long list is never read rather than read and discarded. MINHASH_PICHASH_MAX_MATCHES
bounds the PicHash path the same way, and pairwise scoring now compares each distinct MinHash
signature once instead of once per function holding it, which is exact rather than approximate.

Measured on four real Malpedia corpus sizes - 2,016 / 2,997 / 5,236 / 7,244 samples, same three
query samples throughout, three repeats each - the baseline's median grows as corpus^1.40 while
two-stage is corpus^-0.07. At the largest size that is 10.20s down to 1.58s on the median, 25.39s
down to 1.93s on the tail, and 766MB down to 253MB peak RSS, with top-10 sample recall still 1.000
and 0.985 of surviving function matches keeping a bit-identical score. All three knobs default to
0, so an instance behaves exactly as before until it opts in.
A posting list lives as a function_ids array inside one document, and MongoDB caps a document at
16 MB. On a 7,244-sample corpus the largest band document held 18,968 postings in 197,606 bytes,
about 10.4 bytes each, so roughly 1.6M fit - 84.9x that corpus, which puts the wall somewhere near
615,000 samples. Past it the $push raises "BSONObj size ... is invalid" and indexing stops for any
sample holding a function whose band hash is already at the cap. It fails rather than slowing down,
and sharding does not help because a document cannot span shards.

STORAGE_BAND_BUCKET_SIZE splits a hash across (band_hash, bucket) documents once it would exceed
the cap, and defaults to 0, which keeps the current single-document shape byte for byte. Bucket 0
carries the bookkeeping for the whole hash - df as the total across every bucket, plus tail and
tail_n for placement - which is what leaves the cutoff filter and its (band_hash, df) index
untouched: a hash under the cutoff is nowhere near a bucket's worth so it never spills, and one
that did spill has a df that rejects it anyway. Buckets fill in order rather than by hashing the
function id, so a short posting list stays in one document instead of scattering across many.
Enabling it on an existing database needs rebuild_band_df_index first - documents written earlier
have no bucket field, so the upsert would miss them and insert a second document per hash, which
nothing would report as an error.
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.

1 participant