Extract dereplication logic into obikderep crate and centralize paths
Move partition dereplication logic into a dedicated `obikderep` crate that implements a two-phase hash-split/merge strategy with Rayon for parallel processing. Centralize superkmer file path construction in `obilayeredmap` and update dependent crates to use the new pipeline and shared path helpers. Adjust test suites to explicitly invoke the dereplication step.
This commit is contained in:
@@ -8,8 +8,18 @@ state, added (panics on every read method). (2a) done — the
|
|||||||
onto it — **not started**, deliberately deferred. (3) done — the
|
onto it — **not started**, deliberately deferred. (3) done — the
|
||||||
`obikindex ↔ obikpartitionner` dependency inverted: `PartitionRouter` now
|
`obikindex ↔ obikpartitionner` dependency inverted: `PartitionRouter` now
|
||||||
takes `&mut KmerIndex` and produces `Layer::Empty` shells directly, closing
|
takes `&mut KmerIndex` and produces `Layer::Empty` shells directly, closing
|
||||||
the gap `Layer::Empty` was built for in (1b) — see "(3) done" below.
|
the gap `Layer::Empty` was built for in (1b) — see "(3) done" below. (4)
|
||||||
Earlier mix-up, for
|
done — dereplication split out into its own crate, `obikderep`, first step
|
||||||
|
of an incremental "one algorithm at a time" split of `obikpartitionner`'s
|
||||||
|
remaining bundle (`count_kmer`/`build_layers` not yet moved) — see "(4)
|
||||||
|
done" below. **(5) — full design agreed, not yet implemented** —
|
||||||
|
`KmerPartition` was found to be wired into nothing (`KmerIndex` never
|
||||||
|
calls it; every path is still computed via free functions), and the fix
|
||||||
|
turned out to be bigger than `KmerPartition` alone: `Layer`'s own
|
||||||
|
constructors don't self-name either. Full redesign of both, agreed in
|
||||||
|
detail, session ended (budget) before implementation — see "(5) design
|
||||||
|
agreed" below; **read it before touching `KmerPartition`/`Layer`
|
||||||
|
signatures**, the shape is fully specified. Earlier mix-up, for
|
||||||
context: an earlier
|
context: an earlier
|
||||||
version of this doc used the name `KmerPartition` (singular) for what was
|
version of this doc used the name `KmerPartition` (singular) for what was
|
||||||
actually the *collection* type (later renamed `KmerPartitions`, later
|
actually the *collection* type (later renamed `KmerPartitions`, later
|
||||||
@@ -447,6 +457,289 @@ Full workspace suite green (`cargo check --workspace --all-targets` +
|
|||||||
`index_layer.rs` fix above — the smoke test is what actually caught the
|
`index_layer.rs` fix above — the smoke test is what actually caught the
|
||||||
regression the suite missed.
|
regression the suite missed.
|
||||||
|
|
||||||
|
## (4) done (2026-08-20): `obikderep` — dereplication split out of `obikpartitionner`, one algorithm at a time
|
||||||
|
|
||||||
|
Follow-on question after (3): the indexing pipeline has 4 stages (scatter,
|
||||||
|
dereplicate, count_kmer, index-build — see the CLI's own `Reporter` output,
|
||||||
|
one line per stage), but `obikpartitionner` — a name that says
|
||||||
|
*partitioning* — owned three of them (routing, dereplication, counting).
|
||||||
|
Challenged directly, same as (3)'s dependency-direction question: a crate
|
||||||
|
should hold what its name says, not accumulate unrelated stages just
|
||||||
|
because they happened to land there first. Two ways to fix it — one crate
|
||||||
|
renamed to hold all remaining stages, or one crate per stage — decided in
|
||||||
|
favour of the latter, explicitly **incremental**: build the *second* algo
|
||||||
|
crate first (`obikderep`, dereplication only), only then look at what it
|
||||||
|
and `PartitionRouter` actually have in common, and factor a shared
|
||||||
|
`Algorithm` trait (future `obikalgorithm` crate) from that real overlap —
|
||||||
|
not guessed at from a single example. `count_kmer` and `build_layers`
|
||||||
|
(currently `KmerIndex` inherent methods — itself flagged as inconsistent
|
||||||
|
with "`KmerIndex` is a data structure, not a compute structure") are left
|
||||||
|
alone this round, on purpose — one stage moves at a time.
|
||||||
|
|
||||||
|
**`obikderep`** (new crate): `Dereplicator<'a> { index: &'a KmerIndex, n_partitions, level }`
|
||||||
|
— `new(index: &KmerIndex)` (shared borrow, not `&mut`: dereplication never
|
||||||
|
writes index metadata), no setters yet (nothing to configure), `run(on_progress)`
|
||||||
|
does the two-phase split+merge dereplication in parallel across partitions,
|
||||||
|
ported unchanged from `PartitionRouter::dereplicate` (moved wholesale:
|
||||||
|
`optimal_buckets`/`dereplicate_partition`/`load_bucket`/`flush_map`/
|
||||||
|
`remove_skmer_file`, now private to this crate in `dereplicate.rs`).
|
||||||
|
`obikpartitionner::PartitionRouter::dereplicate` is gone; `count_kmer`
|
||||||
|
stays.
|
||||||
|
|
||||||
|
**A real signature difference from `PartitionRouter::run`, not an
|
||||||
|
inconsistency**: `Dereplicator::run` takes `Option<impl Fn(Progress) +
|
||||||
|
Sync>`, not `FnMut`. `PartitionRouter::run`'s callback is invoked from one
|
||||||
|
sequential loop (`FnMut` is fine); `Dereplicator::run`'s work is
|
||||||
|
`rayon::par_iter`, so the callback can be invoked concurrently from
|
||||||
|
multiple worker threads — same reason `obisys::TracedBar`'s own methods
|
||||||
|
take `&self`, not `&mut self`. Progress position is tracked with an
|
||||||
|
`AtomicU64`, incremented from inside the parallel closure so each
|
||||||
|
completed partition reports immediately — collecting all results first and
|
||||||
|
reporting after (the first draft of this) would have delivered every tick
|
||||||
|
in one burst at the very end, defeating the point of a live progress bar.
|
||||||
|
`total: Some(n_partitions)` (known up front, unlike scatter's bases count)
|
||||||
|
— `cmd/index/mod.rs` renders a real `progress_bar`, not a spinner, driven
|
||||||
|
by the callback exactly like scatter's spinner is.
|
||||||
|
|
||||||
|
**A second, pre-existing instance of the exact bug (3) fixed, caught
|
||||||
|
before it shipped**: `dereplicated.skmer.zst` was hand-built as a string
|
||||||
|
literal independently in three places — `obikpartitionner`'s
|
||||||
|
`dereplicate.rs`/`count.rs` *and* `obikindex`'s `index_layer.rs` (a literal
|
||||||
|
that already predated this session, never caught until now). Splitting
|
||||||
|
dereplication into its own crate turns this from "two places, still
|
||||||
|
matching by luck" into "three independent crates that must agree on a
|
||||||
|
filename with no shared dependency forcing them to" — no longer
|
||||||
|
deferrable. Fixed by adding `obilayeredmap::{raw_superkmers_path,
|
||||||
|
dereplicated_superkmers_path}` (free functions, `layer_dir: &Path ->
|
||||||
|
PathBuf`, mirroring `layer_dir` itself) — the filename lives in one place,
|
||||||
|
in the Layer-tier crate every consumer here already depends on
|
||||||
|
(`obikpartitionner`, `obikderep`, `obikindex` all reach it without a new
|
||||||
|
edge), and no external crate ever sees the literal `"skmer.zst"` again.
|
||||||
|
This reverses (3)'s own earlier call to keep `SK_EXT` private to
|
||||||
|
`obikpartitionner` — that call assumed a single owner; a second owner
|
||||||
|
appearing (`obikderep`) removed the assumption it rested on, so the
|
||||||
|
decision changed with it, not out of inconsistency.
|
||||||
|
|
||||||
|
Every `count_kmer` call site that used to run after `router.dereplicate()`
|
||||||
|
on the same `PartitionRouter` now runs after a separate
|
||||||
|
`Dereplicator::new(&idx).run(...)` call, on a freshly-constructed
|
||||||
|
`PartitionRouter` — `PartitionRouter` no longer offers a combined
|
||||||
|
"dereplicate then count" path. Updated at all three call sites that had
|
||||||
|
one: `cmd/index/mod.rs`, `obikphylo`'s test harness, `obikpartitionner`'s
|
||||||
|
own tests.
|
||||||
|
|
||||||
|
Full workspace suite green (`cargo check --workspace --all-targets` +
|
||||||
|
`cargo test --workspace`, exit code 0), plus an end-to-end CLI smoke test
|
||||||
|
against real FASTA data (scatter → dereplicate → count → index-build →
|
||||||
|
query, same numbers as (3)'s smoke test: 870 kmers) — required this time
|
||||||
|
too, per (3)'s own lesson: no test in the suite exercises `obikmer index`'s
|
||||||
|
real file-driven path.
|
||||||
|
|
||||||
|
Still not done: `count_kmer`/`build_layers` staying where they are, the
|
||||||
|
`obikalgorithm` shared-trait extraction (deliberately deferred until a
|
||||||
|
third data point exists), and everything already listed under (2b).
|
||||||
|
|
||||||
|
## (5) design agreed, not yet implemented (2026-08-20): `KmerPartition` rewritten, `Layer` gains self-naming, a future cache crate over `KmerIndex`
|
||||||
|
|
||||||
|
Session ended (out of budget) before any of this was coded. Everything
|
||||||
|
below is a **fully specified plan**, agreed sentence by sentence with the
|
||||||
|
user — not a sketch to re-derive, not a proposal to re-litigate. Implement
|
||||||
|
it as written; if something here turns out to be wrong once coded, fix it
|
||||||
|
and update this section, don't restart the design conversation.
|
||||||
|
|
||||||
|
### How this was found
|
||||||
|
|
||||||
|
Direct question from the user: "tu as bien créé une structure
|
||||||
|
`KmerPartition` ?" — yes (2a), but investigating exposed that it is
|
||||||
|
**wired into nothing**. `KmerIndex` has no `partition(i)` method at all;
|
||||||
|
`partition_dir`/`index_dir`/`layer_dir` still call `obikpartition::
|
||||||
|
partition_dir`/`index_dir` and `obilayeredmap::layer_dir` as bare free
|
||||||
|
functions directly, never touching a `KmerPartition`/`Layer` object to get
|
||||||
|
there. The end result on disk is identical (same paths), which is exactly
|
||||||
|
why no test caught it — but the *responsibility* is in the wrong place:
|
||||||
|
one function (on `KmerIndex`) knows the whole three-tier naming
|
||||||
|
convention, instead of each tier asking the one below it for its own
|
||||||
|
path. User's framing, verbatim, now saved as [[feedback_no_spaghetti_petits_pois]]:
|
||||||
|
"spaghetti" (logic untraceable, split across too many unrelated crates)
|
||||||
|
and "petits pois" (small bits of naming logic dispersed with no owning
|
||||||
|
object) are **strictly forbidden** — this was a live example of both.
|
||||||
|
|
||||||
|
Pushed further, twice:
|
||||||
|
1. First correction: `Layer::create(&obilayeredmap::layer_dir(&dir, 0))` —
|
||||||
|
still a free-function call from *outside* `Layer` to compute where it
|
||||||
|
should live. "Le layer n'est pas con, c'est lui qui dit où est-ce qu'il
|
||||||
|
doit être sauvé" (the layer isn't stupid, it says itself where it
|
||||||
|
should be saved).
|
||||||
|
2. Second correction, the general principle: **"une partition est juste
|
||||||
|
identifiée par un numéro, tout se calcule à partir du numéro, et un
|
||||||
|
layer est identifié à partir d'un numéro et tout se calcule à partir de
|
||||||
|
ce numéro."** Concretely: each object stores its own local identifying
|
||||||
|
number *plus* its immediate parent's path (captured once, at
|
||||||
|
construction) — never a path handed in again later by a caller, and
|
||||||
|
never a free function outside the object that can compute that path
|
||||||
|
independently. Explicitly rejected along the way: making users pass
|
||||||
|
"the partition's path that contains the layer" to open a layer — the
|
||||||
|
parent path is captured once, at the child's construction, not
|
||||||
|
re-supplied at every call.
|
||||||
|
|
||||||
|
### The agreed shape
|
||||||
|
|
||||||
|
**`Layer`** (`obilayeredmap`) — identified by `l` + its parent partition's
|
||||||
|
directory, both captured at construction, never received again:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum Layer {
|
||||||
|
Empty { partition_dir: PathBuf, l: usize }, // pure identification, no disk I/O
|
||||||
|
Count(TypedLayer<PersistentCompactIntMatrix>),
|
||||||
|
Presence(TypedLayer<PersistentBitMatrix>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Layer {
|
||||||
|
pub fn at(partition_dir: &Path, l: usize) -> Self; // identify only
|
||||||
|
fn dir(&self) -> PathBuf; // private — layer_dir() no longer a public free function, folded in here
|
||||||
|
pub fn create(self) -> io::Result<Self>; // creates the directory if needed; no path parameter anymore
|
||||||
|
pub fn open(self, mode: &IndexMode, with_counts: bool) -> OLMResult<Self>; // no path parameter anymore
|
||||||
|
// mphf_path()/unitigs_path()/evidence_path()/fingerprint_path()/counts_dir()/presence_dir()
|
||||||
|
// unchanged in spirit, implemented via self.dir() instead of a stored `dir` field read directly
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note this **replaces** `Layer::Empty { dir: PathBuf }` from (1b) — `dir`
|
||||||
|
becomes a computed value (`partition_dir.join(format!("layer_{l}"))`), not
|
||||||
|
a stored field. `obilayeredmap::layer_dir`/`raw_superkmers_path`/
|
||||||
|
`dereplicated_superkmers_path` (currently public free functions,
|
||||||
|
introduced in (3)/(4)) stop being called from outside `obilayeredmap`
|
||||||
|
entirely once this lands — they were the right fix for their moment (a
|
||||||
|
second crate, `obikderep`, needed to agree on a filename with no owner),
|
||||||
|
but the *real* fix, now visible with a third data point, is that `Layer`
|
||||||
|
itself should be the only thing anyone asks.
|
||||||
|
|
||||||
|
**`KmerPartition`** (`obikpartition`) — same principle, one tier up:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct KmerPartition {
|
||||||
|
index_root: PathBuf, // the parent KmerIndex's root, captured once
|
||||||
|
i: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KmerPartition {
|
||||||
|
pub fn new(index_root: PathBuf, i: usize) -> Self; // identify only, no disk I/O
|
||||||
|
pub fn create(index_root: PathBuf, i: usize) -> io::Result<Self>; // creates this partition's directory + an empty layer 0 (a partition is never born without one — that knowledge lives here, not in whoever calls create)
|
||||||
|
pub fn partition_dir(&self) -> PathBuf; // part_{i:05}
|
||||||
|
pub fn index_dir(&self) -> PathBuf; // part_{i:05}/index
|
||||||
|
pub fn layer(&self, l: usize) -> Layer; // Layer::at(&self.index_dir(), l) — caller never touches a path
|
||||||
|
pub fn meta(&self) -> SKResult<PartitionMeta>; // n_layers + mode; must absorb the recovery-on-missing-file logic
|
||||||
|
// currently private in obikindex::common::load_meta (obikpartition
|
||||||
|
// can't depend on obikindex to reuse it — this logic moves down)
|
||||||
|
pub fn n_layers(&self) -> SKResult<usize>; // meta()?.n_layers
|
||||||
|
pub fn mode(&self) -> SKResult<IndexMode>; // meta()?.mode — "exact/approximatif"
|
||||||
|
pub fn is_filled(&self) -> bool; // does this partition's directory exist at all
|
||||||
|
pub fn n_kmers(&self) -> io::Result<usize>; // LayerMeta::load(&self.layer(0).dir()).n — reads layer 0's count as
|
||||||
|
// a representative figure, same "read the first one" trick
|
||||||
|
// n_layers_per_partition() already uses at the KmerIndex level
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This **replaces** (2a)'s `KmerPartition { layers: Vec<Layer> }` entirely
|
||||||
|
— no eagerly-opened `Vec<Layer>`, no `find()` (both belong to the future
|
||||||
|
cache, see below, which is the thing that actually holds opened layers
|
||||||
|
alive across many lookups). (2a)'s version is safe to delete outright: it
|
||||||
|
was never wired into anything (confirmed above), so nothing depends on
|
||||||
|
its current shape. New dependencies needed: `obikpartition` gains
|
||||||
|
`obiskio` (for `SKResult`) and `obicompactvec` (for `LayerMeta`).
|
||||||
|
|
||||||
|
Deliberately **not built this round**: cross-level consistency checks
|
||||||
|
("verify everything below me is in the same state") — a real idea, raised
|
||||||
|
by the user, but nothing concrete needs it yet; building it speculatively
|
||||||
|
would be exactly the premature-abstraction pattern this project avoids.
|
||||||
|
|
||||||
|
**`KmerIndex`** (`obikindex`) — becomes the sole entry point:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn partition(&self, i: usize) -> KmerPartition; // KmerPartition::new(self.root_path.clone(), i)
|
||||||
|
```
|
||||||
|
|
||||||
|
`partition_dir(i)`/`index_dir(i)`/`layer_dir(i, l)` **stay** as public
|
||||||
|
methods (≈30 existing call sites across `obikindex`/`obikphylo` — see (3)'s
|
||||||
|
option A, applied identically here) but become pure delegations:
|
||||||
|
`self.partition(i).partition_dir()`, `self.partition(i).index_dir()`,
|
||||||
|
`self.partition(i).layer(l).dir()` (needs `Layer::dir()` to be visible
|
||||||
|
enough for this — likely `pub(crate)` in `obilayeredmap` plus a thin
|
||||||
|
public wrapper, or a public accessor on `Layer` itself; not fully nailed
|
||||||
|
down, decide while implementing). No caller outside `obikindex` changes.
|
||||||
|
|
||||||
|
### Known blast radius (why this wasn't done in the same session)
|
||||||
|
|
||||||
|
- **14 files** call `Layer::open`/`Layer::create` directly today
|
||||||
|
(`obikphylo/siblings/{cache,build,family_scan,tests}.rs`,
|
||||||
|
`obikpartitionner/partition/router.rs`, `obikpartition/src/lib.rs`,
|
||||||
|
`obikindex/{rebuild_layer,dump_layer,index,query_layer}.rs`,
|
||||||
|
`obilayeredmap/{mphf_layer,layer,map,content_layer}.rs`) — every one
|
||||||
|
loses its path parameter and gains a `(partition_dir, l)` or an
|
||||||
|
already-identified `Layer` to call `.create()`/`.open()` on instead.
|
||||||
|
- **≈30 files** call `KmerIndex::partition_dir`/`index_dir`/`layer_dir` —
|
||||||
|
unaffected in their own code (same public signatures), but worth
|
||||||
|
re-checking once (5) lands that none of them were relying on the old
|
||||||
|
free-function-based implementation in a way the new delegation breaks.
|
||||||
|
- `obikpartitionner::PartitionRouter::ensure_writer` and `obikderep`'s
|
||||||
|
`run` both currently call `obilayeredmap::{layer_dir, raw_superkmers_path,
|
||||||
|
dereplicated_superkmers_path}` directly (from (3)/(4)) — both need to
|
||||||
|
switch to going through `index.partition(i).layer(0)` instead.
|
||||||
|
|
||||||
|
### Also agreed, separately: `PartitionRouter::new` never needed `&mut KmerIndex`
|
||||||
|
|
||||||
|
Verified by reading the code: every call `PartitionRouter` makes on
|
||||||
|
`index` is `&self` (`index.kmer_size()`, `index.index_dir(i)`). The `&mut`
|
||||||
|
in its current signature (from (3)) was inherited from the original
|
||||||
|
"the router writes to the partitions" reasoning, never actually required
|
||||||
|
by any method call. This is *exactly* what caused every `drop(router)`
|
||||||
|
workaround needed throughout (3)/(4) (`cmd/index/mod.rs` ×2, `obikphylo`'s
|
||||||
|
test harness, `obikpartitionner`'s own tests) — `PartitionRouter` holds a
|
||||||
|
`Drop` impl, which extends a `&mut` borrow to the end of its scope even
|
||||||
|
past its last real use. **Fix alongside (5)**: change
|
||||||
|
`PartitionRouter::new(index: &'a mut KmerIndex)` to `&'a KmerIndex`, and
|
||||||
|
remove the now-unnecessary `drop(router)` calls at all four sites.
|
||||||
|
|
||||||
|
### Also discussed: a future cache crate, not part of (5), not `obikalgorithm` either
|
||||||
|
|
||||||
|
Separate idea, explicitly **not** part of this design and **not** started:
|
||||||
|
a new crate whose only job is to cache open `KmerPartition`s (and their
|
||||||
|
opened `Layer`s) across one run — replacing both `obikphylo::siblings::
|
||||||
|
cache::PartitionCache` (today, sibling-specific, holds `Vec<Vec<Layer>>`)
|
||||||
|
and `obikindex::query_layer::QueryLayer` (today, uncached, bypasses
|
||||||
|
`Layer` entirely) — the two consumers (2b) already identified as each
|
||||||
|
reinventing a fragment of the same thing.
|
||||||
|
|
||||||
|
User's framing: this is **not** a third `obikalgorithm` data point — an
|
||||||
|
algorithm has a `new → run → done` shape; a cache has a fundamentally
|
||||||
|
different one (open, stay alive for a whole run, serve lookups, maybe
|
||||||
|
evict) — "on crée un cache sur un index, ça consomme un index." Two
|
||||||
|
distinct crate *roles* in this ecosystem (data crates: `obikpartition`/
|
||||||
|
`obilayeredmap`; algorithm crates: `obikpartitionner`/`obikderep`/future
|
||||||
|
`obikalgorithm` implementors; and now a cache/service crate), not one
|
||||||
|
unified shape to force everything into.
|
||||||
|
|
||||||
|
Depends on (5) being done first: the cache crate's whole job is holding
|
||||||
|
`Vec<KmerPartition>`/opened `Layer`s alive, built via `KmerIndex::
|
||||||
|
partition(i)` as its factory — nothing to build it on top of until (5)
|
||||||
|
lands. Still open once (5) is done: eviction policy vs. holding everything
|
||||||
|
open for the process lifetime (the never-measured mmap/VM-mapping-count
|
||||||
|
question from earlier in this doc), and whether it lives in `obikpartition`
|
||||||
|
itself or a new crate.
|
||||||
|
|
||||||
|
### Order of remaining work, as currently understood
|
||||||
|
|
||||||
|
1. **(5)** — `Layer`/`KmerPartition`/`KmerIndex` rewrite described above,
|
||||||
|
plus the `PartitionRouter` `&mut` → `&` fix (same root cause, same
|
||||||
|
session, do together).
|
||||||
|
2. The future cache crate (name not chosen), consuming `KmerIndex::
|
||||||
|
partition(i)` — unblocks migrating `PartitionCache`/`QueryLayer` (2b).
|
||||||
|
3. `obikalgorithm` — still deliberately waiting for a third `run()`-shaped
|
||||||
|
data point (`count_kmer` or `build_layers` migrating out of
|
||||||
|
`KmerIndex`/`PartitionRouter`) before extracting a shared trait; two
|
||||||
|
examples were judged not enough to be sure of the shape (`Fn+Sync` vs
|
||||||
|
`FnMut` callback bound already diverged between the two that exist).
|
||||||
|
|
||||||
## The problem
|
## The problem
|
||||||
|
|
||||||
Reading a layer's data (MPHF + matrix) is not free: `MphfLayer::open` mmaps
|
Reading a layer's data (MPHF + matrix) is not free: `MphfLayer::open` mmaps
|
||||||
|
|||||||
Generated
+21
@@ -1515,6 +1515,24 @@ dependencies = [
|
|||||||
"xxhash-rust",
|
"xxhash-rust",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "obikderep"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"niffler",
|
||||||
|
"obikindex",
|
||||||
|
"obikrope",
|
||||||
|
"obikseq",
|
||||||
|
"obilayeredmap",
|
||||||
|
"obiskbuilder",
|
||||||
|
"obiskio",
|
||||||
|
"obisys",
|
||||||
|
"rayon",
|
||||||
|
"sysinfo",
|
||||||
|
"tempfile",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obikentropy"
|
name = "obikentropy"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1565,6 +1583,7 @@ dependencies = [
|
|||||||
"kodama",
|
"kodama",
|
||||||
"obidebruinj",
|
"obidebruinj",
|
||||||
"obifastwrite",
|
"obifastwrite",
|
||||||
|
"obikderep",
|
||||||
"obikindex",
|
"obikindex",
|
||||||
"obikpartitionner",
|
"obikpartitionner",
|
||||||
"obikphylo",
|
"obikphylo",
|
||||||
@@ -1607,6 +1626,7 @@ dependencies = [
|
|||||||
"memmap2",
|
"memmap2",
|
||||||
"niffler",
|
"niffler",
|
||||||
"obicompactvec",
|
"obicompactvec",
|
||||||
|
"obikderep",
|
||||||
"obikindex",
|
"obikindex",
|
||||||
"obikrope",
|
"obikrope",
|
||||||
"obikseq",
|
"obikseq",
|
||||||
@@ -1632,6 +1652,7 @@ dependencies = [
|
|||||||
"memmap2",
|
"memmap2",
|
||||||
"ndarray",
|
"ndarray",
|
||||||
"obicompactvec",
|
"obicompactvec",
|
||||||
|
"obikderep",
|
||||||
"obikindex",
|
"obikindex",
|
||||||
"obikpartitionner",
|
"obikpartitionner",
|
||||||
"obikseq",
|
"obikseq",
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["obikseq", "obiread", "obiskbuilder", "obifastwrite", "obikmer","obikrope","obipipeline", "obikpartitionner","obiskio","obidebruinj","obilayeredmap", "obicompactvec", "obisys", "obikindex", "obitaxonomy", "obikentropy", "obikphylo", "obikpartition"]
|
members = ["obikseq", "obiread", "obiskbuilder", "obifastwrite", "obikmer","obikrope","obipipeline", "obikpartitionner","obiskio","obidebruinj","obilayeredmap", "obicompactvec", "obisys", "obikindex", "obitaxonomy", "obikentropy", "obikphylo", "obikpartition", "obikderep"]
|
||||||
[profile.release]
|
[profile.release]
|
||||||
debug = 1
|
debug = 1
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "obikderep"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
niffler = "3.0.0"
|
||||||
|
obikseq = { path = "../obikseq" }
|
||||||
|
obikindex = { path = "../obikindex" }
|
||||||
|
obilayeredmap = { path = "../obilayeredmap" }
|
||||||
|
obiskio = { path = "../obiskio" }
|
||||||
|
obisys = { path = "../obisys" }
|
||||||
|
rayon = "1"
|
||||||
|
sysinfo = "0.39"
|
||||||
|
tracing = "0.1.44"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
obikseq = { path = "../obikseq", features = ["test-utils"] }
|
||||||
|
obikrope = { path = "../obikrope" }
|
||||||
|
obiskbuilder = { path = "../obiskbuilder" }
|
||||||
+21
-11
@@ -1,3 +1,7 @@
|
|||||||
|
//! Per-partition dereplication mechanics — private to this crate.
|
||||||
|
//! [`crate::Dereplicator`] is the public entry point; this module is the
|
||||||
|
//! two-phase split+merge algorithm it runs once per partition.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
@@ -5,16 +9,21 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use niffler::Level;
|
|
||||||
use niffler::send::compression::Format;
|
use niffler::send::compression::Format;
|
||||||
use obikseq::Sequence;
|
use niffler::Level;
|
||||||
use obikseq::superkmer::SuperKmer;
|
use obikseq::superkmer::SuperKmer;
|
||||||
|
use obikseq::Sequence;
|
||||||
|
use obilayeredmap::{dereplicated_superkmers_path, raw_superkmers_path};
|
||||||
use obiskio::{SKFileMeta, SKFileReader, SKFileWriter, SKResult};
|
use obiskio::{SKFileMeta, SKFileReader, SKFileWriter, SKResult};
|
||||||
|
|
||||||
use super::SK_EXT;
|
/// Scratch-file extension for this algorithm's own intermediate split
|
||||||
|
/// buckets — never read by anything outside [`dereplicate_partition`],
|
||||||
|
/// unlike `raw`/`dereplicated` (see `obilayeredmap::{raw_superkmers_path,
|
||||||
|
/// dereplicated_superkmers_path}`, the actual cross-crate contract).
|
||||||
|
const TEMP_EXT: &str = "skmer.zst";
|
||||||
|
|
||||||
/// Estimate the number of in-memory buckets needed to deduplicate the partition
|
/// Estimate the number of in-memory buckets needed to deduplicate the
|
||||||
/// file at `raw_path` given `available_bytes` of free RAM.
|
/// partition file at `raw_path` given `available_bytes` of free RAM.
|
||||||
///
|
///
|
||||||
/// Memory per HashMap entry:
|
/// Memory per HashMap entry:
|
||||||
/// key Box (1 + avg_seq_bytes) + SuperKmer header (4 B) + avg seq bytes + u64 count (8 B),
|
/// key Box (1 + avg_seq_bytes) + SuperKmer header (4 B) + avg seq bytes + u64 count (8 B),
|
||||||
@@ -22,7 +31,7 @@ use super::SK_EXT;
|
|||||||
///
|
///
|
||||||
/// Returns 1 if the partition fits comfortably in memory (no split needed).
|
/// Returns 1 if the partition fits comfortably in memory (no split needed).
|
||||||
/// Always returns a power of two.
|
/// Always returns a power of two.
|
||||||
pub(super) fn optimal_buckets(raw_path: &Path, available_bytes: u64) -> usize {
|
pub(crate) fn optimal_buckets(raw_path: &Path, available_bytes: u64) -> usize {
|
||||||
// Use 60 % of available RAM to leave headroom for the rest of the process.
|
// Use 60 % of available RAM to leave headroom for the rest of the process.
|
||||||
let budget = (available_bytes as f64 * 0.60) as u64;
|
let budget = (available_bytes as f64 * 0.60) as u64;
|
||||||
|
|
||||||
@@ -62,14 +71,15 @@ fn remove_skmer_file(path: &Path) -> SKResult<()> {
|
|||||||
/// Maximum value that fits in the 24-bit COUNT field of a SuperKmer header.
|
/// Maximum value that fits in the 24-bit COUNT field of a SuperKmer header.
|
||||||
const MAX_SK_COUNT: u64 = (1 << 24) - 1;
|
const MAX_SK_COUNT: u64 = (1 << 24) - 1;
|
||||||
|
|
||||||
/// Deduplicate one partition directory in place (two-phase split + merge).
|
/// Deduplicate one partition's layer-0 directory in place (two-phase split
|
||||||
pub(super) fn dereplicate_partition(dir: &Path, level: Level, n_temp: usize) -> SKResult<()> {
|
/// + merge): `raw_superkmers_path(dir)` -> `dereplicated_superkmers_path(dir)`.
|
||||||
let raw_path = dir.join(format!("raw.{SK_EXT}"));
|
pub(crate) fn dereplicate_partition(dir: &Path, level: Level, n_temp: usize) -> SKResult<()> {
|
||||||
|
let raw_path = raw_superkmers_path(dir);
|
||||||
if !raw_path.exists() {
|
if !raw_path.exists() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let out_path = dir.join(format!("dereplicated.{SK_EXT}"));
|
let out_path = dereplicated_superkmers_path(dir);
|
||||||
let mut writer = SKFileWriter::create_with(&out_path, Format::Zstd, level)?;
|
let mut writer = SKFileWriter::create_with(&out_path, Format::Zstd, level)?;
|
||||||
|
|
||||||
if n_temp == 1 {
|
if n_temp == 1 {
|
||||||
@@ -81,7 +91,7 @@ pub(super) fn dereplicate_partition(dir: &Path, level: Level, n_temp: usize) ->
|
|||||||
// ── Phase 1: split raw file into temp buckets ─────────────────────────
|
// ── Phase 1: split raw file into temp buckets ─────────────────────────
|
||||||
let temp_mask = (n_temp as u64) - 1;
|
let temp_mask = (n_temp as u64) - 1;
|
||||||
let temp_paths: Vec<PathBuf> = (0..n_temp)
|
let temp_paths: Vec<PathBuf> = (0..n_temp)
|
||||||
.map(|j| dir.join(format!("temp_{j:04}.{SK_EXT}")))
|
.map(|j| dir.join(format!("temp_{j:04}.{TEMP_EXT}")))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
//! Superkmer dereplication — the second stage of the indexing pipeline,
|
||||||
|
//! after `obikpartitionner::PartitionRouter::run` (scatter) has written
|
||||||
|
//! each partition's raw superkmer file, before
|
||||||
|
//! `obikpartitionner::PartitionRouter::count_kmer` (counting) reads the
|
||||||
|
//! result. One algorithm, one crate — see
|
||||||
|
//! `DevDocMD/implementation/partition_layer_cache.md`'s "on avance pas à
|
||||||
|
//! pas" note: `obikpartitionner` used to also own dereplication and
|
||||||
|
//! counting; this crate is step one of splitting that bundle apart,
|
||||||
|
//! deliberately one algorithm at a time rather than all at once, so a
|
||||||
|
//! shared `Algorithm` pattern can be factored out later from real
|
||||||
|
//! examples instead of guessed at up front.
|
||||||
|
|
||||||
|
mod dereplicate;
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use niffler::Level;
|
||||||
|
use obikindex::KmerIndex;
|
||||||
|
use obiskio::SKResult;
|
||||||
|
use obisys::Progress;
|
||||||
|
use rayon::prelude::*;
|
||||||
|
use sysinfo::System;
|
||||||
|
|
||||||
|
use dereplicate::{dereplicate_partition, optimal_buckets};
|
||||||
|
|
||||||
|
/// Deduplicates every partition's raw superkmer file in place, replacing
|
||||||
|
/// each with a dereplicated one where identical canonical sequences are
|
||||||
|
/// merged and their counts summed.
|
||||||
|
///
|
||||||
|
/// Two-phase construction, same shape as `PartitionRouter`: `new` (no disk
|
||||||
|
/// access), then `run`. Nothing to configure yet — no setters, unlike
|
||||||
|
/// `PartitionRouter`'s `level_max`/`theta`/etc. — added if a real need
|
||||||
|
/// shows up, not speculatively.
|
||||||
|
pub struct Dereplicator<'a> {
|
||||||
|
index: &'a KmerIndex,
|
||||||
|
n_partitions: usize,
|
||||||
|
level: Level,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Dereplicator<'a> {
|
||||||
|
pub fn new(index: &'a KmerIndex) -> Self {
|
||||||
|
Self {
|
||||||
|
index,
|
||||||
|
n_partitions: index.n_partitions(),
|
||||||
|
level: Level::One,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dereplicate every partition in parallel.
|
||||||
|
///
|
||||||
|
/// Each partition file is processed in two phases to bound memory use:
|
||||||
|
///
|
||||||
|
/// 1. **Split** — the raw file is scattered into `2^temp_bits` temporary
|
||||||
|
/// files routed by `hash(canonical_seq) & temp_mask`. Because duplicates
|
||||||
|
/// always share the same hash, they always land in the same temp file.
|
||||||
|
/// 2. **Merge** — each temp file is loaded fully into a `HashMap`, counts
|
||||||
|
/// are accumulated in `u64` (no 24-bit overflow risk), and the result is
|
||||||
|
/// appended to the partition's dereplicated file.
|
||||||
|
///
|
||||||
|
/// If a merged count exceeds the 24-bit header limit, the sequence is
|
||||||
|
/// emitted as multiple records whose counts sum to the true total.
|
||||||
|
///
|
||||||
|
/// `on_progress`, when set, is called once per completed partition, from
|
||||||
|
/// whichever rayon worker thread finished it — `Fn(...) + Sync`, not
|
||||||
|
/// `FnMut`, unlike `PartitionRouter::run`'s callback: that one is driven
|
||||||
|
/// from a single sequential loop, this one from a parallel `par_iter`,
|
||||||
|
/// so the callback itself must tolerate concurrent calls (same reason
|
||||||
|
/// `obisys::TracedBar`'s own methods take `&self`, not `&mut self`).
|
||||||
|
/// `total: Some(n_partitions)` — known up front here, unlike
|
||||||
|
/// `PartitionRouter::run`'s bases-processed count, so the caller can
|
||||||
|
/// render an actual progress bar rather than a spinner. This crate
|
||||||
|
/// never renders anything itself — see the module docs.
|
||||||
|
pub fn run(&self, on_progress: Option<impl Fn(Progress) + Sync>) -> SKResult<()> {
|
||||||
|
let level = self.level;
|
||||||
|
let sys = System::new_all();
|
||||||
|
// available_memory() can return 0 on macOS when the compressor page count exceeds
|
||||||
|
// free+inactive+purgeable pages (sysinfo saturating_sub). Fall back to half of total.
|
||||||
|
let available = match sys.available_memory() {
|
||||||
|
0 => sys.total_memory() / 2,
|
||||||
|
n => n,
|
||||||
|
};
|
||||||
|
let n_threads = rayon::current_num_threads().max(1) as u64;
|
||||||
|
let available_per_thread = available / n_threads;
|
||||||
|
let done = AtomicU64::new(0);
|
||||||
|
|
||||||
|
let results: Vec<SKResult<()>> = (0..self.n_partitions)
|
||||||
|
.into_par_iter()
|
||||||
|
.map(|i| {
|
||||||
|
let dir = self.index.layer_dir(i, 0);
|
||||||
|
let result = if dir.exists() {
|
||||||
|
let raw_path = obilayeredmap::raw_superkmers_path(&dir);
|
||||||
|
let n_buckets = optimal_buckets(&raw_path, available_per_thread);
|
||||||
|
dereplicate_partition(&dir, level, n_buckets)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
if let Some(cb) = &on_progress {
|
||||||
|
let pos = done.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
cb(Progress { position: pos, total: Some(self.n_partitions as u64) });
|
||||||
|
}
|
||||||
|
result
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for r in results {
|
||||||
|
r?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ impl KmerIndex {
|
|||||||
block_bits: u8,
|
block_bits: u8,
|
||||||
) -> Result<usize, SKError> {
|
) -> Result<usize, SKError> {
|
||||||
let layer0_dir = self.layer_dir(i, 0);
|
let layer0_dir = self.layer_dir(i, 0);
|
||||||
let dedup_path = layer0_dir.join("dereplicated.skmer.zst");
|
let dedup_path = obilayeredmap::dereplicated_superkmers_path(&layer0_dir);
|
||||||
if !dedup_path.exists() {
|
if !dedup_path.exists() {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ impl KmerIndex {
|
|||||||
/// Deletes `dereplicated.skmer.zst` (+ sidecar), `mphf1.bin`, `counts1.bin`.
|
/// Deletes `dereplicated.skmer.zst` (+ sidecar), `mphf1.bin`, `counts1.bin`.
|
||||||
pub fn remove_build_artifacts(&self, i: usize) {
|
pub fn remove_build_artifacts(&self, i: usize) {
|
||||||
let layer0_dir = self.layer_dir(i, 0);
|
let layer0_dir = self.layer_dir(i, 0);
|
||||||
let dedup = layer0_dir.join("dereplicated.skmer.zst");
|
let dedup = obilayeredmap::dereplicated_superkmers_path(&layer0_dir);
|
||||||
remove_if_exists(&SKFileMeta::sidecar_path(&dedup));
|
remove_if_exists(&SKFileMeta::sidecar_path(&dedup));
|
||||||
remove_if_exists(&dedup);
|
remove_if_exists(&dedup);
|
||||||
remove_if_exists(&layer0_dir.join("mphf1.bin"));
|
remove_if_exists(&layer0_dir.join("mphf1.bin"));
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ obidebruinj = { path = "../obidebruinj" }
|
|||||||
obipipeline = { path = "../obipipeline" }
|
obipipeline = { path = "../obipipeline" }
|
||||||
obikrope = { path = "../obikrope" }
|
obikrope = { path = "../obikrope" }
|
||||||
obikpartitionner = { path = "../obikpartitionner" }
|
obikpartitionner = { path = "../obikpartitionner" }
|
||||||
|
obikderep = { path = "../obikderep" }
|
||||||
obisys = { path = "../obisys" }
|
obisys = { path = "../obisys" }
|
||||||
obiskio = { path = "../obiskio" }
|
obiskio = { path = "../obiskio" }
|
||||||
obikindex = { path = "../obikindex", default-features = false }
|
obikindex = { path = "../obikindex", default-features = false }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::path::PathBuf;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use clap::Args;
|
use clap::Args;
|
||||||
|
use obikderep::Dereplicator;
|
||||||
use obikindex::{validate_label, GenomeInfo, IndexConfig, IndexState, KmerIndex};
|
use obikindex::{validate_label, GenomeInfo, IndexConfig, IndexState, KmerIndex};
|
||||||
use obikpartitionner::PartitionRouter;
|
use obikpartitionner::PartitionRouter;
|
||||||
use obilayeredmap::IndexMode;
|
use obilayeredmap::IndexMode;
|
||||||
@@ -10,7 +11,7 @@ fn parse_key_value(s: &str) -> Result<(String, String), String> {
|
|||||||
let pos = s.find('=').ok_or_else(|| format!("invalid key=value: no '=' in '{s}'"))?;
|
let pos = s.find('=').ok_or_else(|| format!("invalid key=value: no '=' in '{s}'"))?;
|
||||||
Ok((s[..pos].to_string(), s[pos + 1..].to_string()))
|
Ok((s[..pos].to_string(), s[pos + 1..].to_string()))
|
||||||
}
|
}
|
||||||
use obisys::{spinner, Progress, Reporter, Stage};
|
use obisys::{progress_bar, spinner, Progress, Reporter, Stage};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::cli::{CommonArgs, block_size_to_bits, partitions_to_bits};
|
use crate::cli::{CommonArgs, block_size_to_bits, partitions_to_bits};
|
||||||
@@ -290,15 +291,19 @@ pub fn run(args: IndexArgs) {
|
|||||||
|
|
||||||
// ── Stage 2: dereplicate + count ─────────────────────────────────────────
|
// ── Stage 2: dereplicate + count ─────────────────────────────────────────
|
||||||
if idx.state() < IndexState::Counted {
|
if idx.state() < IndexState::Counted {
|
||||||
let router = PartitionRouter::new(&mut idx);
|
|
||||||
|
|
||||||
let t = Stage::start("dereplicate");
|
let t = Stage::start("dereplicate");
|
||||||
router.dereplicate().unwrap_or_else(|e| {
|
let pb = progress_bar("dereplication", idx.n_partitions() as u64, "partitions");
|
||||||
|
Dereplicator::new(&idx)
|
||||||
|
.run(Some(|_: Progress| pb.inc(1)))
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
eprintln!("error: {e}");
|
eprintln!("error: {e}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
});
|
});
|
||||||
|
pb.finish_and_clear();
|
||||||
rep.push(t.stop());
|
rep.push(t.stop());
|
||||||
|
|
||||||
|
let router = PartitionRouter::new(&mut idx);
|
||||||
|
|
||||||
let t = Stage::start("count_kmer");
|
let t = Stage::start("count_kmer");
|
||||||
let spectrum = router.count_kmer(args.keep_intermediate).unwrap_or_else(|e| {
|
let spectrum = router.count_kmer(args.keep_intermediate).unwrap_or_else(|e| {
|
||||||
eprintln!("error: {e}");
|
eprintln!("error: {e}");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ edition = "2024"
|
|||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
obikseq = { path = "../obikseq", features = ["test-utils"] }
|
obikseq = { path = "../obikseq", features = ["test-utils"] }
|
||||||
obikrope = { path = "../obikrope" }
|
obikrope = { path = "../obikrope" }
|
||||||
|
obikderep = { path = "../obikderep" }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
niffler = "3.0.0"
|
niffler = "3.0.0"
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
//! K-mer partitioning: routing super-kmers into per-partition, layer-0
|
//! K-mer partitioning: routing super-kmers into per-partition, layer-0
|
||||||
//! files, deduplicating them, and counting unique canonical k-mers.
|
//! files, and counting unique canonical k-mers. Dereplication itself moved
|
||||||
|
//! to `obikderep` (2026-08-20) — see
|
||||||
|
//! `DevDocMD/implementation/partition_layer_cache.md`: one algorithm, one
|
||||||
|
//! crate, split off one at a time rather than all at once.
|
||||||
//!
|
//!
|
||||||
//! Submodules: [`router`] (`PartitionRouter`, `KmerSpectrum`, the
|
//! Submodules: [`router`] (`PartitionRouter`, `KmerSpectrum`, the
|
||||||
//! routing/lifecycle API — partition/layer path naming itself lives on
|
//! routing/counting lifecycle API — partition/layer path naming itself
|
||||||
//! `obikindex::KmerIndex`, not here, see
|
//! lives on `obikindex::KmerIndex`, not here), [`count`] (unique-kmer
|
||||||
//! `DevDocMD/implementation/partition_layer_cache.md`), [`dereplicate`]
|
|
||||||
//! (two-phase split+merge deduplication), [`count`] (unique-kmer
|
|
||||||
//! enumeration, MPHF, abundance counting).
|
//! enumeration, MPHF, abundance counting).
|
||||||
|
|
||||||
mod count;
|
mod count;
|
||||||
mod dereplicate;
|
|
||||||
mod router;
|
mod router;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
pub use router::{KmerSpectrum, PartitionRouter};
|
pub use router::{KmerSpectrum, PartitionRouter};
|
||||||
|
|
||||||
const SK_EXT: &str = "skmer.zst";
|
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ use obiread::NucPage;
|
|||||||
use crate::kmer_sort::chunk_size_from_ram;
|
use crate::kmer_sort::chunk_size_from_ram;
|
||||||
|
|
||||||
use super::count::count_partition;
|
use super::count::count_partition;
|
||||||
use super::dereplicate::{dereplicate_partition, optimal_buckets};
|
|
||||||
use super::SK_EXT;
|
|
||||||
|
|
||||||
pub struct KmerSpectrum {
|
pub struct KmerSpectrum {
|
||||||
pub f0: u64,
|
pub f0: u64,
|
||||||
@@ -269,60 +267,6 @@ impl<'a> PartitionRouter<'a> {
|
|||||||
self.close()
|
self.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deduplicate all `raw.{ext}` files in parallel, replacing each with a
|
|
||||||
/// `dereplicated.{ext}` file where identical canonical sequences are merged
|
|
||||||
/// and their counts summed.
|
|
||||||
///
|
|
||||||
/// Each partition file is processed in two phases to bound memory use:
|
|
||||||
///
|
|
||||||
/// 1. **Split** — the raw file is scattered into `2^temp_bits` temporary
|
|
||||||
/// files routed by `hash(canonical_seq) & temp_mask`. Because duplicates
|
|
||||||
/// always share the same hash, they always land in the same temp file.
|
|
||||||
/// 2. **Merge** — each temp file is loaded fully into a `HashMap`, counts
|
|
||||||
/// are accumulated in `u64` (no 24-bit overflow risk), and the result is
|
|
||||||
/// appended to `dereplicated.{ext}`.
|
|
||||||
///
|
|
||||||
/// If a merged count exceeds the 24-bit header limit, the sequence is
|
|
||||||
/// emitted as multiple records whose counts sum to the true total.
|
|
||||||
pub fn dereplicate(&self) -> SKResult<()> {
|
|
||||||
let level = self.level;
|
|
||||||
let sys = System::new_all();
|
|
||||||
// available_memory() can return 0 on macOS when the compressor page count exceeds
|
|
||||||
// free+inactive+purgeable pages (sysinfo saturating_sub). Fall back to half of total.
|
|
||||||
let available = match sys.available_memory() {
|
|
||||||
0 => sys.total_memory() / 2,
|
|
||||||
n => n,
|
|
||||||
};
|
|
||||||
let n_threads = rayon::current_num_threads().max(1) as u64;
|
|
||||||
let available_per_thread = available / n_threads;
|
|
||||||
|
|
||||||
let pb = progress_bar("dereplication", self.n_partitions as u64, "partitions");
|
|
||||||
|
|
||||||
let results: Vec<SKResult<()>> = (0..self.n_partitions)
|
|
||||||
.into_par_iter()
|
|
||||||
.map(|i| {
|
|
||||||
let dir = self.layer0_dir(i);
|
|
||||||
if !dir.exists() {
|
|
||||||
pb.inc(1);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let raw_path = dir.join(format!("raw.{SK_EXT}"));
|
|
||||||
let t = Instant::now();
|
|
||||||
let n_buckets = optimal_buckets(&raw_path, available_per_thread);
|
|
||||||
let result = dereplicate_partition(&dir, level, n_buckets);
|
|
||||||
pb.set_message(format!("last {:.0}ms", t.elapsed().as_millis()));
|
|
||||||
pb.inc(1);
|
|
||||||
result
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
pb.finish_and_clear();
|
|
||||||
for r in results {
|
|
||||||
r?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// For each partition that has a `dereplicated.{ext}` file:
|
/// For each partition that has a `dereplicated.{ext}` file:
|
||||||
/// 1. Enumerates all unique canonical kmers (two passes over the file).
|
/// 1. Enumerates all unique canonical kmers (two passes over the file).
|
||||||
/// 2. Builds a provisional MPHF (FMPHGO) over those kmers.
|
/// 2. Builds a provisional MPHF (FMPHGO) over those kmers.
|
||||||
@@ -347,7 +291,7 @@ impl<'a> PartitionRouter<'a> {
|
|||||||
.into_par_iter()
|
.into_par_iter()
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
let dir = self.layer0_dir(i);
|
let dir = self.layer0_dir(i);
|
||||||
let dedup_path = dir.join(format!("dereplicated.{SK_EXT}"));
|
let dedup_path = obilayeredmap::dereplicated_superkmers_path(&dir);
|
||||||
if !dedup_path.exists() {
|
if !dedup_path.exists() {
|
||||||
pb.inc(1);
|
pb.inc(1);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -416,7 +360,7 @@ impl<'a> PartitionRouter<'a> {
|
|||||||
if self.writers[partition].is_none() {
|
if self.writers[partition].is_none() {
|
||||||
let dir = self.layer0_dir(partition);
|
let dir = self.layer0_dir(partition);
|
||||||
Layer::create(&dir).map_err(|e| io::Error::other(e.to_string()))?;
|
Layer::create(&dir).map_err(|e| io::Error::other(e.to_string()))?;
|
||||||
let file_path = dir.join(format!("raw.{SK_EXT}"));
|
let file_path = obilayeredmap::raw_superkmers_path(&dir);
|
||||||
let writer = SKFileWriter::create_with(file_path, Format::Zstd, self.level)?;
|
let writer = SKFileWriter::create_with(file_path, Format::Zstd, self.level)?;
|
||||||
self.writers[partition] = Some(writer);
|
self.writers[partition] = Some(writer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
use obikderep::Dereplicator;
|
||||||
use obikindex::{IndexConfig, KmerIndex};
|
use obikindex::{IndexConfig, KmerIndex};
|
||||||
use obikrope::Rope;
|
use obikrope::Rope;
|
||||||
use obikseq::SuperKmer;
|
use obikseq::SuperKmer;
|
||||||
@@ -64,11 +65,11 @@ fn pipeline_counts(seqs: &[&[u8]]) -> (u64, u64) {
|
|||||||
let mut kp = PartitionRouter::new(&mut index);
|
let mut kp = PartitionRouter::new(&mut index);
|
||||||
kp.write_batch(superkmers).unwrap();
|
kp.write_batch(superkmers).unwrap();
|
||||||
kp.close().unwrap();
|
kp.close().unwrap();
|
||||||
kp.dereplicate().unwrap();
|
|
||||||
drop(kp); // ends the borrow of `index` early — `PartitionRouter`'s `Drop` impl would otherwise extend it to the end of scope
|
drop(kp); // ends the borrow of `index` early — `PartitionRouter`'s `Drop` impl would otherwise extend it to the end of scope
|
||||||
|
Dereplicator::new(&index).run(None::<fn(obisys::Progress)>).unwrap();
|
||||||
|
|
||||||
let part_dir = index.layer_dir(0, 0);
|
let part_dir = index.layer_dir(0, 0);
|
||||||
let dedup_path = part_dir.join("dereplicated.skmer.zst");
|
let dedup_path = obilayeredmap::dereplicated_superkmers_path(&part_dir);
|
||||||
if !dedup_path.exists() {
|
if !dedup_path.exists() {
|
||||||
return (0, 0);
|
return (0, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ tracing = "0.1.44"
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
obiread = { path = "../obiread" }
|
obiread = { path = "../obiread" }
|
||||||
|
obikderep = { path = "../obikderep" }
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use obilayeredmap::MphfLayer;
|
|||||||
use obisys::Reporter;
|
use obisys::Reporter;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
use obikderep::Dereplicator;
|
||||||
use obikindex::{GenomeInfo, IndexConfig, KmerIndex, MergeMode};
|
use obikindex::{GenomeInfo, IndexConfig, KmerIndex, MergeMode};
|
||||||
use obikpartitionner::PartitionRouter;
|
use obikpartitionner::PartitionRouter;
|
||||||
|
|
||||||
@@ -76,7 +77,10 @@ fn build_single_genome_index(dir: &Path, label: &str, seq: &[u8]) -> KmerIndex {
|
|||||||
router.write_batch(batch).expect("write_batch");
|
router.write_batch(batch).expect("write_batch");
|
||||||
}
|
}
|
||||||
router.close().expect("close partition writers");
|
router.close().expect("close partition writers");
|
||||||
router.dereplicate().expect("dereplicate");
|
drop(router); // ends the borrow of `idx` early — `PartitionRouter`'s `Drop` impl would otherwise extend it to the end of scope
|
||||||
|
|
||||||
|
Dereplicator::new(&idx).run(None::<fn(obisys::Progress)>).expect("dereplicate");
|
||||||
|
let router = PartitionRouter::new(&mut idx);
|
||||||
let spectrum = router.count_kmer(false).expect("count_kmer");
|
let spectrum = router.count_kmer(false).expect("count_kmer");
|
||||||
drop(router); // ends the borrow of `idx` early — `PartitionRouter`'s `Drop` impl would otherwise extend it to the end of scope
|
drop(router); // ends the borrow of `idx` early — `PartitionRouter`'s `Drop` impl would otherwise extend it to the end of scope
|
||||||
|
|
||||||
|
|||||||
@@ -33,13 +33,37 @@ pub trait LayerData: Sized {
|
|||||||
///
|
///
|
||||||
/// `obilayeredmap` operates within a single partition's index root; it has
|
/// `obilayeredmap` operates within a single partition's index root; it has
|
||||||
/// no notion of "partition" at all. Turning a partition number into that
|
/// no notion of "partition" at all. Turning a partition number into that
|
||||||
/// root is `obikpartitionner::KmerPartition::part_dir`'s job, one layer up
|
/// root is `obikindex::KmerIndex::index_dir`'s job, one layer up — callers
|
||||||
/// — callers here only ever name a layer *number*, never build the path
|
/// here only ever name a layer *number*, never build the path themselves.
|
||||||
/// themselves.
|
|
||||||
pub fn layer_dir(root: &Path, i: usize) -> PathBuf {
|
pub fn layer_dir(root: &Path, i: usize) -> PathBuf {
|
||||||
root.join(format!("layer_{i}"))
|
root.join(format!("layer_{i}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Superkmer-file extension used by every pre-layer-construction artifact
|
||||||
|
/// below (`raw`/`dereplicated`) — an implementation detail of the SK file
|
||||||
|
/// format, never meant to leak as a literal string past this module.
|
||||||
|
const SK_EXT: &str = "skmer.zst";
|
||||||
|
|
||||||
|
/// Path of a layer's raw, not-yet-dereplicated superkmer file — written by
|
||||||
|
/// whichever algorithm routes superkmers into this layer (today:
|
||||||
|
/// `obikpartitionner::PartitionRouter`), read by whichever algorithm
|
||||||
|
/// dereplicates it (today: `obikderep::Dereplicator`). Naming this once
|
||||||
|
/// here, rather than in either algorithm crate, is what lets two
|
||||||
|
/// independent crates agree on the filename without depending on each
|
||||||
|
/// other — see `DevDocMD/implementation/partition_layer_cache.md`.
|
||||||
|
pub fn raw_superkmers_path(layer_dir: &Path) -> PathBuf {
|
||||||
|
layer_dir.join(format!("raw.{SK_EXT}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path of a layer's dereplicated superkmer file — written by
|
||||||
|
/// `obikderep::Dereplicator`, read by whichever algorithm counts kmer
|
||||||
|
/// abundances from it (today: `obikpartitionner::PartitionRouter::
|
||||||
|
/// count_kmer`) and, later, by `obikindex::build_index_layer` to build the
|
||||||
|
/// real layer.
|
||||||
|
pub fn dereplicated_superkmers_path(layer_dir: &Path) -> PathBuf {
|
||||||
|
layer_dir.join(format!("dereplicated.{SK_EXT}"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens layer `i`'s data only, skipping the MPHF — for callers that only
|
/// Opens layer `i`'s data only, skipping the MPHF — for callers that only
|
||||||
/// need matrix-level operations (distance traits, column weights, group
|
/// need matrix-level operations (distance traits, column weights, group
|
||||||
/// filters, sub-matrix extraction) and never look up a kmer for this layer.
|
/// filters, sub-matrix extraction) and never look up a kmer for this layer.
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ pub(crate) mod mphf_layer;
|
|||||||
|
|
||||||
pub use content_layer::Layer;
|
pub use content_layer::Layer;
|
||||||
pub use error::{OLMError, OLMResult};
|
pub use error::{OLMError, OLMResult};
|
||||||
pub use layer::{layer_dir, open_data, HasLayerContent, HasStorageKind, Hit, LayerContent, LayerData, TypedLayer};
|
pub use layer::{
|
||||||
|
dereplicated_superkmers_path, layer_dir, open_data, raw_superkmers_path, HasLayerContent,
|
||||||
|
HasStorageKind, Hit, LayerContent, LayerData, TypedLayer,
|
||||||
|
};
|
||||||
pub use layered_store::LayeredStore;
|
pub use layered_store::LayeredStore;
|
||||||
pub use map::LayeredMap;
|
pub use map::LayeredMap;
|
||||||
pub use meta::{IndexMode, PartitionMeta};
|
pub use meta::{IndexMode, PartitionMeta};
|
||||||
|
|||||||
Reference in New Issue
Block a user