feat(obicompactvec): introduce sparse bit matrix with supporting primitives
Implements a compact, row-major sparse bit matrix backed by memory-mapped components, introducing EliasFano, PersistentFixedIntVec, and PersistentRankSelectBitVec primitives for efficient storage and decoding. Adds a BinaryMatrix trait to unify row-level operations across dense and sparse implementations. Corrects edge-case behaviors for zero-width bit storage and cardinality-0 rows. Delivers reduced on-disk size and faster random row access, with column reads remaining dense-only. Test suites and benchmarks are included but currently marked as ignored.
This commit is contained in:
@@ -535,3 +535,259 @@ fn diag_plant_index_cardinality_distribution() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_sparse_matrix_inmemory_construction_ram() {
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
let idx = KmerIndex::open("/Users/coissac/travail/obiskim/data/phyloalps/phyloskims_sal_vac")
|
||||
.expect("open real plant index");
|
||||
let layer_dirs = super::family_scan::sibling_layer_dirs(&idx).expect("layer dirs");
|
||||
// A `layer_1` (the larger, merged layer) — pick the one already
|
||||
// profiled: part_00018/index/layer_1, ~30.2M rows.
|
||||
let layer_dir = layer_dirs.iter()
|
||||
.find(|p| p.to_string_lossy().contains("part_00018") && p.to_string_lossy().contains("layer_1"))
|
||||
.expect("expected layer not found — layer_dirs order may have changed");
|
||||
|
||||
let mat = obicompactvec::PersistentBitMatrix::open(layer_dir).expect("open presence matrix");
|
||||
let n = mat.n();
|
||||
let n_cols = mat.n_cols();
|
||||
println!("layer={} n_slots={n} n_cols={n_cols}", layer_dir.display());
|
||||
|
||||
let rss_before = obisys::peak_rss_bytes();
|
||||
let t0 = Instant::now();
|
||||
|
||||
// Plan design: is_multi flag (1 bit/row) + two SEPARATE arrays
|
||||
// (singleton-only, multi-only), each sized to its own value range —
|
||||
// not one array covering the whole dict_id space. In-memory prototype
|
||||
// (u32 per entry, not yet bit-packed — the fixed-width primitive
|
||||
// isn't built yet); this benchmark measures RAM + the split's real
|
||||
// final size, not the construction-time representation's size.
|
||||
let mut is_multi: Vec<bool> = Vec::with_capacity(n);
|
||||
let mut singleton_array: Vec<u32> = Vec::new();
|
||||
let mut multi_array: Vec<u32> = Vec::new();
|
||||
let mut dedup: HashMap<Vec<u32>, u32> = HashMap::new();
|
||||
let mut next_dict_id: u32 = 0;
|
||||
let mut dict_values_bytes: u64 = 0; // running varint-encoded size estimate
|
||||
let mut buf = vec![0u32; n_cols];
|
||||
|
||||
for slot in 0..n {
|
||||
mat.fill_row(slot, &mut buf);
|
||||
let set: Vec<u32> = (0..n_cols).filter(|&c| buf[c] != 0).map(|c| c as u32).collect();
|
||||
match set.len() {
|
||||
0 => { is_multi.push(false); singleton_array.push(0); } // shouldn't happen on a real built index; placeholder
|
||||
1 => {
|
||||
is_multi.push(false);
|
||||
singleton_array.push(set[0]);
|
||||
}
|
||||
_ => {
|
||||
is_multi.push(true);
|
||||
let id = if let Some(&id) = dedup.get(&set) {
|
||||
id
|
||||
} else {
|
||||
let id = next_dict_id;
|
||||
next_dict_id += 1;
|
||||
// varint size estimate: 1 byte per index < 128 (always
|
||||
// true here, n_cols=91), matching the plan's encoding.
|
||||
dict_values_bytes += set.iter().map(|&v| if v < 128 { 1 } else { 2 }).sum::<u64>();
|
||||
dedup.insert(set, id);
|
||||
id
|
||||
};
|
||||
multi_array.push(id);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let elapsed = t0.elapsed();
|
||||
let rss_after = obisys::peak_rss_bytes();
|
||||
|
||||
let n_distinct_multi = dedup.len() as u64;
|
||||
let n_singleton = singleton_array.len() as u64;
|
||||
let n_multi = multi_array.len() as u64;
|
||||
|
||||
let is_multi_bytes = (n as u64).div_ceil(8);
|
||||
let singleton_width_bits = (32 - (n_cols as u32).max(1).leading_zeros()).max(1);
|
||||
let multi_width_bits = (32 - (n_distinct_multi as u32).max(1).leading_zeros()).max(1);
|
||||
let singleton_array_bytes = (n_singleton * singleton_width_bits as u64).div_ceil(8);
|
||||
let multi_array_bytes = (n_multi * multi_width_bits as u64).div_ceil(8);
|
||||
|
||||
let split_total = is_multi_bytes + singleton_array_bytes + multi_array_bytes + dict_values_bytes;
|
||||
let dense_bytes = (n as u64 * n_cols as u64).div_ceil(8);
|
||||
|
||||
println!(
|
||||
"elapsed={:?} rss_before={} rss_after={} rss_delta={}",
|
||||
elapsed, fmt_mb(rss_before), fmt_mb(rss_after), fmt_mb(rss_after.saturating_sub(rss_before)),
|
||||
);
|
||||
println!(
|
||||
"n_singleton={n_singleton} n_multi={n_multi} n_distinct_multi={n_distinct_multi} \
|
||||
singleton_width_bits={singleton_width_bits} multi_width_bits={multi_width_bits}",
|
||||
);
|
||||
println!(
|
||||
"is_multi_bytes={} singleton_array_bytes={} multi_array_bytes={} dict_values_bytes={} \
|
||||
split_total_bytes={} ({:.1}x vs dense) dense_bytes={}",
|
||||
fmt_mb(is_multi_bytes), fmt_mb(singleton_array_bytes), fmt_mb(multi_array_bytes),
|
||||
fmt_mb(dict_values_bytes), fmt_mb(split_total),
|
||||
dense_bytes as f64 / split_total as f64,
|
||||
fmt_mb(dense_bytes),
|
||||
);
|
||||
}
|
||||
|
||||
fn fmt_mb(bytes: u64) -> String {
|
||||
format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_real_persistent_sparse_bit_matrix_on_disk_size() {
|
||||
use std::time::Instant;
|
||||
|
||||
let idx = KmerIndex::open("/Users/coissac/travail/obiskim/data/phyloalps/phyloskims_sal_vac")
|
||||
.expect("open real plant index");
|
||||
let layer_dirs = super::family_scan::sibling_layer_dirs(&idx).expect("layer dirs");
|
||||
let layer_dir = layer_dirs.iter()
|
||||
.find(|p| p.to_string_lossy().contains("part_00018") && p.to_string_lossy().contains("layer_1"))
|
||||
.expect("expected layer not found — layer_dirs order may have changed");
|
||||
|
||||
let dense = obicompactvec::PersistentBitMatrix::open(layer_dir).expect("open presence matrix");
|
||||
let n = dense.n();
|
||||
let n_cols = dense.n_cols();
|
||||
println!("layer={} n_slots={n} n_cols={n_cols}", layer_dir.display());
|
||||
|
||||
let out_dir = std::env::temp_dir().join(format!("sparse_bench_{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&out_dir);
|
||||
|
||||
let rss_before = obisys::peak_rss_bytes();
|
||||
let t0 = Instant::now();
|
||||
|
||||
let sparse = obicompactvec::PersistentSparseBitMatrixBuilder::build_from_dense(&dense, &out_dir)
|
||||
.expect("build_from_dense")
|
||||
.finish()
|
||||
.expect("finish");
|
||||
|
||||
let elapsed = t0.elapsed();
|
||||
let rss_after = obisys::peak_rss_bytes();
|
||||
|
||||
// Real on-disk size: sum of every file actually written under out_dir.
|
||||
let mut sparse_bytes: u64 = 0;
|
||||
for entry in std::fs::read_dir(&out_dir).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let size = entry.metadata().unwrap().len();
|
||||
println!(" {}: {}", entry.file_name().to_string_lossy(), fmt_mb(size));
|
||||
sparse_bytes += size;
|
||||
}
|
||||
|
||||
let dense_bytes = (n as u64 * n_cols as u64).div_ceil(8);
|
||||
|
||||
println!(
|
||||
"elapsed={:?} rss_before={} rss_after={} rss_delta={}",
|
||||
elapsed, fmt_mb(rss_before), fmt_mb(rss_after), fmt_mb(rss_after.saturating_sub(rss_before)),
|
||||
);
|
||||
println!(
|
||||
"REAL on-disk: sparse_total={} dense={} ({:.1}x)",
|
||||
fmt_mb(sparse_bytes), fmt_mb(dense_bytes), dense_bytes as f64 / sparse_bytes as f64,
|
||||
);
|
||||
|
||||
// Sanity: reopen from disk (fresh mmap, not the just-built handle) and
|
||||
// spot-check a handful of rows against the dense original.
|
||||
drop(sparse);
|
||||
let reopened = obicompactvec::PersistentSparseBitMatrix::open(&out_dir).expect("reopen");
|
||||
let mut dense_buf = vec![0u32; n_cols];
|
||||
for &slot in &[0usize, 1, 1000, n / 2, n - 1] {
|
||||
dense.fill_row(slot, &mut dense_buf);
|
||||
let sparse_row = reopened.row(slot);
|
||||
for c in 0..n_cols {
|
||||
assert_eq!(sparse_row[c], dense_buf[c] != 0, "slot {slot}, col {c}");
|
||||
}
|
||||
}
|
||||
println!("spot-check rows: OK");
|
||||
|
||||
// ── Access-time comparison ──────────────────────────────────────────
|
||||
// Both patterns matter in practice: sequential (a full-layer scan, the
|
||||
// existing `scan_layer_families` shape) and random (a single-family
|
||||
// cross-partition lookup, the entropy/`--shannon` shape). Same slot
|
||||
// sequence used against both structures for a fair comparison.
|
||||
const N_ACCESS: usize = 2_000_000;
|
||||
|
||||
// Deterministic xorshift, no extra dependency — fine for a benchmark's
|
||||
// access pattern, not for anything security- or correctness-sensitive.
|
||||
let mut rng_state: u64 = 0x9E3779B97F4A7C15;
|
||||
let mut next_rand = move || {
|
||||
rng_state ^= rng_state << 13;
|
||||
rng_state ^= rng_state >> 7;
|
||||
rng_state ^= rng_state << 17;
|
||||
rng_state
|
||||
};
|
||||
let random_slots: Vec<usize> = (0..N_ACCESS).map(|_| (next_rand() as usize) % n).collect();
|
||||
let sequential_slots: Vec<usize> = (0..N_ACCESS).map(|i| i % n).collect();
|
||||
|
||||
let mut buf = vec![0u32; n_cols];
|
||||
|
||||
let t = Instant::now();
|
||||
for &slot in &sequential_slots {
|
||||
dense.fill_row(slot, &mut buf);
|
||||
}
|
||||
let dense_seq = t.elapsed();
|
||||
|
||||
let t = Instant::now();
|
||||
for &slot in &sequential_slots {
|
||||
reopened.fill_row(slot, &mut buf);
|
||||
}
|
||||
let sparse_seq = t.elapsed();
|
||||
|
||||
let t = Instant::now();
|
||||
for &slot in &random_slots {
|
||||
dense.fill_row(slot, &mut buf);
|
||||
}
|
||||
let dense_rand = t.elapsed();
|
||||
|
||||
let t = Instant::now();
|
||||
for &slot in &random_slots {
|
||||
reopened.fill_row(slot, &mut buf);
|
||||
}
|
||||
let sparse_rand = t.elapsed();
|
||||
|
||||
println!(
|
||||
"ACCESS ({N_ACCESS} rows) sequential: dense={:?} ({:.0}ns/row) sparse={:?} ({:.0}ns/row) — {:.2}x",
|
||||
dense_seq, dense_seq.as_nanos() as f64 / N_ACCESS as f64,
|
||||
sparse_seq, sparse_seq.as_nanos() as f64 / N_ACCESS as f64,
|
||||
sparse_seq.as_secs_f64() / dense_seq.as_secs_f64(),
|
||||
);
|
||||
println!(
|
||||
"ACCESS ({N_ACCESS} rows) random: dense={:?} ({:.0}ns/row) sparse={:?} ({:.0}ns/row) — {:.2}x",
|
||||
dense_rand, dense_rand.as_nanos() as f64 / N_ACCESS as f64,
|
||||
sparse_rand, sparse_rand.as_nanos() as f64 / N_ACCESS as f64,
|
||||
sparse_rand.as_secs_f64() / dense_rand.as_secs_f64(),
|
||||
);
|
||||
|
||||
// ── Column-major access, "just for fun" ─────────────────────────────
|
||||
// The whole point of `docmd/architecture/siblings.md`'s "Explicitly
|
||||
// deferred" section: dense is genome-major (native, contiguous column
|
||||
// access), sparse is k-mer-major (no column method at all — reading
|
||||
// one column means decoding every row and keeping one bit each time).
|
||||
// Extract one full column (all n rows) both ways.
|
||||
let col = n_cols / 2;
|
||||
|
||||
let t = Instant::now();
|
||||
let dense_col_view = dense.col_view(col);
|
||||
let dense_col_ones: u64 = (0..n).filter(|&s| dense_col_view.get(s)).count() as u64;
|
||||
let dense_col_time = t.elapsed();
|
||||
|
||||
let t = Instant::now();
|
||||
let mut buf2 = vec![0u32; n_cols];
|
||||
let sparse_col_ones: u64 = (0..n)
|
||||
.filter(|&s| { reopened.fill_row(s, &mut buf2); buf2[col] != 0 })
|
||||
.count() as u64;
|
||||
let sparse_col_time = t.elapsed();
|
||||
|
||||
assert_eq!(dense_col_ones, sparse_col_ones, "column {col} popcount disagrees");
|
||||
println!(
|
||||
"COLUMN-MAJOR (col {col}, {n} rows) dense={:?} ({:.0}ns/row) sparse={:?} ({:.0}ns/row) — {:.1}x SLOWER on sparse",
|
||||
dense_col_time, dense_col_time.as_nanos() as f64 / n as f64,
|
||||
sparse_col_time, sparse_col_time.as_nanos() as f64 / n as f64,
|
||||
sparse_col_time.as_secs_f64() / dense_col_time.as_secs_f64(),
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&out_dir);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user