Files
obikmer/docmd/architecture/siblings.md
T
Eric Coissac f5e4bbfc6b Document architecture redesign and add partition layer accessor
Documents a proposed redesign for cross-partition batch resolution, shifting trigger logic to per-destination accumulator thresholds and introducing entropy-based pruning criteria. Adds an `n_layers_per_partition` method to the index, exposing partition metadata with consistent error handling and clarified documentation regarding build-time structural properties.
2026-08-16 21:20:55 +02:00

16 KiB
Raw Blame History

Sibling annex — architecture (discussion)

Status: architecture decided (2026-08-14). Implementation not yet mandated.

Two index spaces, uncorrelated

Every kmer stored in a Layer lives in two independent index spaces:

  • Iteration order: its position when enumerating unitigs.bin (the superkmer file), deterministic but arbitrary with respect to slot.
  • MPHF slot: MphfLayer::index(kmer), the number the MPHF assigns.

The two are not correlated by any formula. Converting from one to the other requires either recomputing the MPHF (kmer → slot) or scanning the iteration stream (kmer → order). There is no slot → kmer operation: the MPHF is a one-way function, not an invertible bijection with a stored inverse. Any method that reconstructs a kmer from a bare slot number is wrong by construction, regardless of the mechanism used (MPHF re-hash, or evidence decode + direct unitig read). See MphfLayer::kmer_at (obilayeredmap/src/mphf_layer.rs) — flagged for removal, currently called from obikphylo/siblings/build.rs and family_scan.rs (since removed — see "Pending work" status below).

Two pipelines, never mixed

origin of the kmer membership known? correct mapping
query pipeline external (caller-supplied) no query/find/find_strict — MPHF + evidence check
iteration pipeline enumerated from this layer's own unitigs.bin yes, by construction index/index_batch — MPHF only, no evidence

Evidence exists solely to answer "is this external kmer a member of the layer" for the query pipeline. Using it (or the MPHF) to go the other way — recover a kmer from a slot, or re-verify a kmer that was just produced by iterating the layer — is a conceptual error: evidence can be probabilistic (Approx mode), so any slot→kmer attempt is unsound in general, and pointless even in Exact/Hybrid mode since the kmer was already known.

Sibling annex: an iteration-pipeline artifact only

The sibling annex (FamilyMask/SiblingAnnex, .psib, obicompactvec/src/siblingannex.rs) records, per kmer, whether it is a family minorant and which family members are present in the index. Its only consumers (obikphylo/siblings/stats.rs, family_scan.rs) enumerate it exhaustively (0..annex.len()); no query-pipeline code path touches it.

Decision: the annex must be persisted in iteration order, not slot order. This lets readers zip-iterate Layer::iter_kmers() and the annex file directly — one linear, cache-friendly pass, no MPHF/slot indirection, no kmer_at. It also enables specialized iterators building on this zip: minorants-only iteration, batch-of-kmers → batch-of-family-members, etc.

Today the annex is built and stored in slot order (build_layer_sibling_annex, siblings/build.rs): slot_kmer is populated via (0..n_slots).map(|slot| mphf.kmer_at(slot)), and the origin slot is threaded through the whole cross-partition reconciliation pipeline (variant generation, query_partition_with, final mask[slot].fetch_or(...)). This must change to iterating iter_kmers()/enumerate_kmers() and threading the iteration index instead of the slot end to end — eliminating kmer_at from the build path entirely, not just the read path. No slot-indexed intermediate is needed even during construction; the iteration-order id is sufficient throughout.

The cross-partition side of the same pipeline is unaffected: checking whether a generated family-variant kmer exists in another partition is a genuine query-pipeline operation (the variant's membership in the target partition is unknown) and must keep going through KmerPartition::query_partition_with (MPHF + evidence), never a raw index().

Pending work — done

The plan above shipped: obikphylo (a new crate — phylo-domain extension traits over obikindex::KmerIndex/obilayeredmap::Layer<D>, replacing the old obikindex::siblings module) builds and reads the annex purely in iteration order (SiblingLayerExt::iter_siblings/iter_minorants, both with batch variants, mirroring Layer<D>'s own KmerIter/KmerBatchIter shape). MphfLayer::kmer_at has no remaining callers.

A separate, unrelated bug surfaced during this work and was fixed (2026-08-14): MphfLayer::enumerate_kmers_batch computed its batch_start_index via the stdlib .enumerate() adapter, which counts batches (0, 1, 2…), not the cumulative k-mer offset the annex is actually keyed on — every batch past the first wrote its mask/annex entries at the wrong iteration-order position. Fixed by tracking a running offset instead; regression tests added (sibling_annex_no_empty_masks_after_build, sibling_histogram_does_not_panic_on_partial_last_batch).

Performance: build_sibling_annex parallelism (2026-08-14)

Investigated on a real multi-genome run (phyloskims_sal_vac, k=31/m=11). Baseline: mostly one active core, with short multi-core bursts — average ~3 cores.

Fixes that helped, kept:

  • CanonicalKmerOf::minimizer() (obikseq/src/kmer.rs) — a direct O(k) bit-arithmetic minimiser for a single isolated k-mer, replacing a RollingStat instance fed byte-by-byte through an ASCII round-trip (used by helpers::partition_of, called for every generated family variant). ~3x wall-clock improvement on its own, confirmed by sampling (obiskbuilder::rolling_stat/obikentropy frames disappeared from the hot path). CanonicalKmerOf::partition() added alongside it (wraps minimizer().seq_hash() & mask, the same routing rule KmerPartition/RoutableSuperKmer use).
  • Cross-partition resolution (outgoing.par_iter() in build_layer_sibling_annex) parallelised at the partition level — one Rayon task per non-empty outgoing[dest] bucket. For k=31/m=11, a central-base substitution changes the winning minimiser (and thus the destination partition) only when that window overlaps the central base: ~11 of the 21 possible windows do, so ~10/21 (≈48%) of generated variants route right back to the partition already being built. That self bucket ends up far larger than any other, so the per-partition split pinned one thread to it alone while the rest of the pool finished instantly — confirmed by sampling: one thread solid in MphfLayer::find, everyone else idle. Fixed by splitting each non-empty bucket into total_queries / n_workers (capped 4096) chunks before par_iter(), preserving per-partition mmap locality (each chunk stays contiguous within one partition) while letting Rayon spread an oversized bucket across several threads. Net effect of both fixes together: ~3 cores average → ~10-13 cores average on the same run, and a projected total build time of ~1h15 down to ~30min on the real phyloskims_sal_vac run this was measured against.
  • TracedBar's ETA (obisys/src/progress.rs) was silently starved: the custom progress message and the self-computed ETA text used to share one pb.set_message() slot, with the ETA holding off for 2s after any custom message — fine when custom messages are rare, broken once build_sibling_annex's per-partition callback fires more often than that. Fixed by keeping the two texts in separate fields, composed together on every render instead of one overwriting the other.

Tried and reverted — do not repeat blindly:

  • Parallelising the outer partition loop in build_sibling_annex with obikindex::PartitionRunner (already used by merge/build_layers), splitting a fixed core budget between outer (partition) and inner (pipeline + resolution) concurrency so their product wouldn't exceed the budget. Measured worse: throughput dropped over time (26 partitions/5min → 38/11-12min) and peak resolution concurrency fell from ~11-12 cores to ~7-8. Cause: this capped the resolution burst — which scales very well on its own — to make room for outer concurrency, and running several partitions' resolution at once scatters access across multiple partitions' mmap regions at once, working against the locality outgoing's per-partition grouping exists for. PartitionRunner stayed exported from obikindex (new_capped too) since it's general-purpose, but nothing in obikphylo calls it.
  • Splitting resolution chunks even finer (/(n_workers*8), cap 1024, instead of /n_workers, cap 4096) to smooth the residual sawtooth. Measured ~10% slower, wider dips, not narrower. Reverted to the original chunk sizing.

Cross-partition batch resolution — current state vs. the batched-accumulator design (discussion, 2026-08-14)

family_scan.rs::scan_layer_families (shared by snp_pseudo_alignment, sibling_annex_stats, cardinality_tally, scan_family_pairs) already implements most of a dispatch/accumulate/resolve pipeline: generation (cheap, CPU-only — builds outgoing[dest_partition] from FamilyMask and buckets cross-partition queries) runs on an obipipeline::throttle + make_pipe! stage, decoupled from resolution (I/O-bound, rayon::par_iter across partitions, one generated batch resolved at a time, never several concurrently — this ordering is deliberate, see the module's own docs on a reverted concurrent-batch-resolution attempt that scattered mmap access). The fast/slow mode gate (PartitionCache::fast_mode, cache.rs:162-163) already exists: n_layers <= 7 (checked once from the first non-empty partition's PartitionMeta::n_layers, documented as identical across every partition of an index — a structural, build-time property, never a per-partition state) decides whether FamilyMask's recorded layer_value can be trusted to skip straight to the right layer (find_presence_batch_fast) or must fall back to scanning every layer of the destination partition (find_presence_batch).

Real gap, confirmed not implemented: resolution is triggered by the source batch finishing (FAMILY_BATCH = 65536 minorants read from the scanned layer), not by an output accumulator filling up. Since most central-base variants of a family route back to the same partition being scanned (~48% per the k=31/m=11 measurement above), a FAMILY_BATCH's outgoing[dest] is large for the local/self partition and thin for the other ~255 (or however many) destination partitions — each of those gets resolved at low query density every batch instead of being accumulated across several source batches until resolving it is worthwhile. This is distinct from, and not fixed by, the fast/slow layer gate above.

Redesign sketched (not built): per-destination accumulators decoupled from FAMILY_BATCH, flushed on reaching a size threshold instead of on source-batch completion — a "hot" accumulator for the partition being scanned (sharded one-per-generation-worker, no lock, since all n_workers pipeline workers write to it concurrently — this differs from an earlier, simpler mental model of "one thread owns one layer's local collector," which doesn't hold here since n_workers threads cooperate on scanning one layer at a time, not one thread per layer) and "cold" mutex-per-partition accumulators for the rest, low contention expected since traffic to any single cold destination is a small fraction of total.

This breaks the current strict-iteration-order delivery of on_family (today: a reorder buffer keyed by batch, since a whole FAMILY_BATCH resolves atomically). With cross-batch accumulation, a family only becomes complete once every accumulator holding one of its outgoing queries has flushed, at unpredictable, independent times — no longer streamable strictly in order without a large, unbounded pending buffer. Resolution sketched: replace order-dependent consumers with coordinate-addressed writes instead of order-dependent appends (see PseudoAlignment idea below) wherever possible, since sibling_annex_stats's reduction (plain counts) is already order-independent and needs nothing here.

Pseudo-alignment at scale — pruning is unavoidable (discussion, 2026-08-14)

The reference run (phyloskims_sal_vac-scale bacterial test set, iqtree.fasta) produced a dense alignment for 13 genomes × 383,965 sites (4.8 MB) — trivially small. The in-progress plant index is expected to carry on the order of 9 billion minorant families; a dense byte-per-cell alignment at that column count is unbuildable regardless of genome count (hundreds of GB even at a handful of genomes). Long-term ambition is 6,000 8,000 genomes on a large machine, which makes the per-cell cost dominant in the other dimension too. Pruning the retained family set before materializing anything is mandatory, not an optimization.

Already free: family_size() < 2 (no sibling variant registered at all) is a zero-cost structural filter, read directly off FamilyMask bits, already applied in snp_pseudo_alignment. Insufficient alone — per the ~80-85% mono-family estimate from earlier discussion, this only brings 9 billion down to roughly 1.3-1.8 billion, still unusable.

Criterion under discussion: fix a global cell budget (n_genomes × n_columns_retained ≤ threshold) and retain the highest-entropy families first until the budget is spent — column count self-adjusts to genome count automatically. Entropy of a family: Shannon entropy over the observed base distribution across genomes carrying that family, decided 2026-08-14: genomes where the family is absent are excluded from the calculation (denominator = genomes where present, not all genomes) — measures signal purity where it exists, independent of coverage rate.

Key consequence for storage: entropy needs per-variant genome counts, which cannot be derived from FamilyMask's bits alone — it requires the same cross-partition resolution work the alignment/stats pipeline already pays for. This favors computing it once at annex-build time and persisting it in an auxiliary vector alongside the annex (one value per retained minorant) over encoding a fixed keep/discard decision as one of FamilyMask's 3 remaining free bits (bits 13-15, siblingannex.rs:77): the bit approach bakes a single threshold in permanently (any different budget needs a full annex rebuild), the vector approach pays the expensive resolution once and lets budget/threshold be chosen freely per analysis afterward.

Open, explicitly deferred: scope of the top-K selection — global across the whole index (needs a first pass computing/storing every family's entropy, then a global threshold from the full distribution, e.g. a quantile, before building the final alignment: two full passes, but the size budget is honored precisely) vs. local per layer/partition (stays within the current single-pass streaming model, but the global budget is no longer exactly guaranteed — depends on how unevenly entropy is distributed across layers).

Known remaining limitation, not yet worth fixing: within one layer, the four stages (sequential unitigs.bin read → parallel generation → parallel resolution → sequential annex write) never overlap — confirmed by 1s-interval sampling: generation alone occupies ~17 threads evenly, but the next layer's read/generation never starts until the current layer's resolution and write are both done. This produces a real, periodic (~layer duration) alternation between "many cores" and "few cores" that neither of the fixes above touches, since both operate within one layer's resolution step. The only remaining lever is overlapping consecutive layers (e.g. a depth-2 pipeline: start layer N+1's read/generation while layer N's resolution/write is still running) — a real restructuring, not a parameter tweak, and explicitly not to be combined with the reverted budget-capping idea above (let each phase use however many cores it naturally wants; only the scheduling needs to overlap). Deferred, not started.