2026-08-14 12:06:35 +02:00
|
|
|
|
# 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
|
2026-08-14 17:23:59 +02:00
|
|
|
|
from `obikphylo/siblings/build.rs` and `family_scan.rs` (since removed — see
|
|
|
|
|
|
"Pending work" status below).
|
2026-08-14 12:06:35 +02:00
|
|
|
|
|
|
|
|
|
|
## 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
|
2026-08-14 17:23:59 +02:00
|
|
|
|
consumers (`obikphylo/siblings/stats.rs`, `family_scan.rs`) enumerate it
|
2026-08-14 12:06:35 +02:00
|
|
|
|
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()`.
|
|
|
|
|
|
|
2026-08-14 17:23:59 +02:00
|
|
|
|
## Pending work — done
|
2026-08-14 12:06:35 +02:00
|
|
|
|
|
2026-08-14 17:23:59 +02:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-08-14 22:45:08 +02:00
|
|
|
|
## 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).
|
|
|
|
|
|
|
2026-08-14 17:23:59 +02:00
|
|
|
|
**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.
|