Store minorant flag in family mask to avoid costly k-mer reconstruction

Transition the minorant flag from a derived value to a stored field within the family mask, resolving a performance regression where on-the-fly reconstruction consumed significant query time. This change introduces O(1) k-mer reconstruction APIs, shifts minorant computation to the index build phase, and enables direct annex-based statistics. Supporting updates include adopting shared ownership for partition caches and refactoring batch processing pipelines.
This commit is contained in:
Eric Coissac
2026-08-16 13:50:44 +02:00
parent c7679fac90
commit 0de078fdf1
15 changed files with 519 additions and 145 deletions
+30 -3
View File
@@ -1624,10 +1624,33 @@ a fixed reference. Storing instead a **4-bit mask** — one bit per base
family's fixed canonical form, i.e. the member with `A` at the centre — see
above) is observed anywhere in the index — fixes both:
- **Sibling count is derived, not stored**: `siblings = popcount(mask) - 1`.
- **Minorant is derived, not stored**: regenerate the family's 4 canonical
- ~~**Minorant is derived, not stored**: regenerate the family's 4 canonical
forms from the slot's own k-mer (cheap, no lookup — see above), compare
the raw encodings of whichever bits are set in the mask, take the
smallest.
smallest.~~ **Erratum (2026-08-14) — this was wrong, kept struck through
rather than deleted.** "Cheap, no lookup" only accounts for the bit
algebra (regenerate 4 forms, compare raw encodings) — true in isolation,
but it silently assumed "the slot's own k-mer" is a free fact. It isn't:
getting from an MPHF slot index back to the actual k-mer sequence means
reconstructing `slot_kmer` for the whole layer — scan `unitigs.bin`,
`mphf.find()` every k-mer to place it — an O(distinct k-mers) pass
through the MPHF, not a per-slot O(1) lookup. That reconstruction is free
*only* when the caller already needs k-mer identity for something else in
the same traversal (e.g. the SNP sweep below, which needs it anyway to
generate `central_canonical_neighbors()`). A caller that wants *only* the
minorant flag pays the full reconstruction for nothing: measured on a real
run, a bare family-size histogram (four buckets, otherwise near-instant)
spent 71% of wall-clock in `MphfLayer::find`, all of it solely to answer
"is this slot the minorant". **Current design: minorant *is* stored**
after all — a 5th mask bit (4 presence bits + 1 minorant bit, still fits
one byte alongside the presence mask below), written once in the
construction pass where the k-mer is already in hand for other reasons
(`obikindex::siblings::build_layer_sibling_annex`), read back for free by
every later consumer (`FamilyMask::is_minorant`). This is genuinely a
return to the superseded 3-bit design's core idea (store minorant
alongside the count) — the "which variant" blindness that motivated
moving away from it is fixed by keeping the full 4-bit presence mask
too, not by dropping the stored minorant bit again.
- **A future consumer knows exactly which variants to (re-)query** —
`popcount(mask) - 1` lookups instead of always 3, and it knows *which*
3 (or fewer) to issue, not just how many hits to expect.
@@ -1644,7 +1667,11 @@ above) is observed anywhere in the index — fixes both:
per-slot packed array (the presence mask above), one per partition — same
on-disk shape family as `PersistentBitMatrix`'s `Packed` variant, but
simpler (no per-genome columns, a single derived read-only value per
slot).
slot). As actually implemented (`obicompactvec::siblingannex`): **not**
truly bit-packed — 1 byte/slot, 4 presence bits + the minorant bit (see
the erratum above) in the low 5 bits, 3 unused. Deliberately simpler for
a first implementation; packing to 5 bits/slot is a pure storage-density
follow-up, not a behavioural change, still not done as of this note.
<details><summary>Superseded 3-bit design (historical)</summary>
3 bits, storing minorant status alongside sibling count directly, since
it came for free from the same lookups (point 3 below) — 5 real states