//! Cold random-read harness for QMDB: seeds a large unordered fixed-value `any` DB on the tokio
//! runtime, then times batches of random keys, reporting device statistics from /proc/diskstats
//! alongside each batch (Linux only, zeros elsewhere). Pages a batch reads stay in the page
//! cache, so until the cache fills a later batch of the same run finds about
//! `batch * iteration / log pages` of its keys resident. Size `keys` so this stays small.
//!
//! Seed a directory once with `seed`, drop the OS page cache (`sudo purge` on macOS), then run
//! `read` or `blob` variants against the same directory:
//!
//! ```text
//! cargo bench -p commonware-storage --bench cold_read -- seed
[seed_batch] [page] [logical] [blob] [cache] [threads] [workers] [blocking]
//! cargo bench -p commonware-storage --bench cold_read -- read [batch] [iters] [depth] [rseed] [disk] [page] [logical] [blob] [cache] [threads] [workers] [blocking]
//! cargo bench -p commonware-storage --bench cold_read -- blob [batch] [iters] [rseed] [disk] [workers] [blocking]
//! ```
//!
//! Without a subcommand the harness no-ops, so blanket `cargo bench` invocations (with no
//! arguments or with libtest or Criterion flags) skip it. Always pass `read` the `keys`, `page`,
//! `logical`, and `blob` the DB was seeded with: reopening it with another page size truncates
//! it. The stage and pipeline modes commit updates to the sampled keys, moving their later read
//! locations, so compare those modes on a fresh copy of the seeded DB or with a different `rseed`
//! per run.
//!
//! - dir: storage directory (required)
//! - keys: seeded keys (required). `read` samples keys from this range and first checks that the
//! last one is present
//! - mode (required): for `read`, one of get_many, chunked (up to 8 concurrent get_many),
//! shared (`depth` concurrent get_many of the same batch, whose readers miss the same pages),
//! get_concurrent (one `get` per key, joined), get_serial (one `get` at a time), stage (stage,
//! merkleize, apply, commit), pipeline (prefetch the next batch with get_many while staging
//! this one, then commit), or sustained (keep `depth` independent get_many batches in flight
//! for 2 x iters batches and report the whole run: the steady-state device throughput a
//! pipelined caller sees). For `blob` (raw reads of random 4 KiB pages of the first log blob
//! through the runtime, no QMDB or page cache), one of blob_read_at or blob_read_many
//! - seed_batch: keys per seeding batch (default 1,000,000)
//! - batch: random keys (or blob pages) per timed batch (default 1500)
//! - iters: timed batches (default 20)
//! - depth: batches in flight for the sustained mode, or concurrent readers of each batch for the
//! shared mode (default 2, must be positive)
//! - rseed: key (or blob page) sampling seed (default 1234)
//! - disk: /proc/diskstats device name (default nvme1n1)
//! - page: physical page size, a power of two (default 4096)
//! - logical: logical page size override for unaligned layouts (default 0, no override)
//! - blob: items per blob (default 10,000,000)
//! - cache: page cache capacity in pages (default 65,536)
//! - threads: strategy pool threads (default 8)
//! - workers: tokio worker threads (default 8)
//! - blocking: tokio blocking threads (default 512)
use commonware_allocator::mimalloc;
use commonware_cryptography::{DigestOf, Hasher as _, Sha256};
use commonware_parallel::Rayon;
use commonware_runtime::{
Blob as _, ReadOptions, Runner as _, Spawner, Strategizer,
buffer::paged::{self, CacheRef},
tokio::{Config as RConfig, Runner},
};
use commonware_storage::{
Context as Ctx,
journal::contiguous::fixed::Config as FConfig,
merkle::{full, mmb},
qmdb::{
any::{FixedConfig, batch::Slot},
floor::Proportional,
},
translator::EightCap,
};
use commonware_utils::{NZU64, NZUsize, TestRng, futures::try_join_all};
use futures::{StreamExt as _, join, stream::FuturesUnordered};
use rand::{Rng as _, RngExt as _};
use std::{
hint::black_box,
num::{NonZeroU16, NonZeroUsize},
slice::Iter,
str::FromStr,
time::Instant,
};
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc::new(mimalloc::Config::DEFAULT);
type Digest = DigestOf;
type AnyDb = commonware_storage::qmdb::any::unordered::fixed::Db<
mmb::Family,
E,
Digest,
Digest,
Sha256,
EightCap,
Rayon,
>;
const WRITE_BUFFER: NonZeroUsize = NZUsize!(2 * 1024 * 1024);
const REPLAY_BUFFER: NonZeroUsize = NZUsize!(2 * 1024 * 1024);
const READ_MODES: [&str; 8] = [
"get_many",
"chunked",
"shared",
"get_concurrent",
"get_serial",
"stage",
"pipeline",
"sustained",
];
const BLOB_MODES: [&str; 2] = ["blob_read_at", "blob_read_many"];
/// The subcommand: what the harness does with the directory.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Phase {
Seed,
Read,
Blob,
}
impl Phase {
fn parse(name: &str) -> Option {
match name {
"seed" => Some(Self::Seed),
"read" => Some(Self::Read),
"blob" => Some(Self::Blob),
_ => None,
}
}
}
struct Args {
phase: Phase,
dir: String,
keys: u64,
batch: usize,
iters: usize,
page: u32,
cache: usize,
blob: u64,
workers: usize,
blocking: usize,
mode: String,
disk: String,
seed: u64,
threads: usize,
rseed: u64,
logical: Option,
depth: usize,
}
/// Positional arguments after the subcommand and directory, consumed in usage order.
struct Positional<'a>(Iter<'a, String>);
impl Positional<'_> {
fn required(&mut self) -> Option {
self.0.next()?.parse().ok()
}
/// The next argument, or `default` once the arguments run out. `None` is a parse failure.
fn optional(&mut self, default: T) -> Option {
self.0.next().map_or(Some(default), |a| a.parse().ok())
}
}
impl Args {
/// Parse ` ...` as listed in the module docs. `None` is a parse failure.
fn parse(argv: &[String]) -> Option {
let mut args = Self {
phase: Phase::parse(argv.first()?)?,
dir: argv.get(1)?.clone(),
keys: 0,
batch: 1500,
iters: 20,
page: 4096,
cache: 65_536,
blob: 10_000_000,
workers: 8,
blocking: 512,
mode: String::new(),
disk: "nvme1n1".into(),
seed: 1_000_000,
threads: 8,
rseed: 1234,
logical: None,
depth: 2,
};
let mut p = Positional(argv[2..].iter());
match args.phase {
Phase::Seed => {
args.keys = p.required()?;
args.seed = p.optional(args.seed)?;
args.parse_layout(&mut p)?;
}
Phase::Read => {
args.keys = p.required()?;
args.mode = p.required()?;
if !READ_MODES.contains(&args.mode.as_str()) {
return None;
}
args.batch = p.optional(args.batch)?;
args.iters = p.optional(args.iters)?;
args.depth = p.optional(args.depth).filter(|depth| *depth > 0)?;
args.rseed = p.optional(args.rseed)?;
args.disk = p.optional(args.disk)?;
args.parse_layout(&mut p)?;
}
Phase::Blob => {
args.mode = p.required()?;
if !BLOB_MODES.contains(&args.mode.as_str()) {
return None;
}
args.batch = p.optional(args.batch)?;
args.iters = p.optional(args.iters)?;
args.rseed = p.optional(args.rseed)?;
args.disk = p.optional(args.disk)?;
}
}
args.workers = p.optional(args.workers)?;
args.blocking = p.optional(args.blocking)?;
p.0.next().is_none().then_some(args)
}
/// Parse the DB layout and page cache options shared by `seed` and `read`.
fn parse_layout(&mut self, p: &mut Positional<'_>) -> Option<()> {
self.page = p.optional(self.page)?;
self.logical = NonZeroU16::new(p.optional(0)?);
self.blob = p.optional(self.blob)?;
self.cache = p.optional(self.cache)?;
self.threads = p.optional(self.threads)?;
Some(())
}
}
fn usage() {
eprintln!(
"usage:\n seed [seed_batch] [page] [logical] [blob] [cache] [threads] [workers] [blocking] seed a fresh directory\n read [batch] [iters] [depth] [rseed] [disk] [page] [logical] [blob] [cache] [threads] [workers] [blocking] time random key reads (mode: {})\n blob [batch] [iters] [rseed] [disk] [workers] [blocking] time raw first-log-blob page reads (mode: {})",
READ_MODES.join("|"),
BLOB_MODES.join("|")
);
}
fn key(i: u64) -> Digest {
Sha256::hash(&[&i.to_be_bytes()])
}
#[derive(Clone, Copy, Default, Debug)]
struct DiskStats {
reads: u64,
sectors: u64,
read_ms: u64,
io_ms: u64,
weighted_ms: u64,
}
fn diskstats(disk: &str) -> DiskStats {
let Ok(s) = std::fs::read_to_string("/proc/diskstats") else {
return DiskStats::default();
};
for line in s.lines() {
let f: Vec<&str> = line.split_whitespace().collect();
if f.len() > 13 && f[2] == disk {
return DiskStats {
reads: f[3].parse().unwrap_or(0),
sectors: f[5].parse().unwrap_or(0),
read_ms: f[6].parse().unwrap_or(0),
io_ms: f[12].parse().unwrap_or(0),
weighted_ms: f[13].parse().unwrap_or(0),
};
}
}
DiskStats::default()
}
fn disk_delta(label: &str, before: DiskStats, after: DiskStats, elapsed_ms: f64) -> String {
let reads = after.reads - before.reads;
let sectors = after.sectors - before.sectors;
let read_ms = after.read_ms - before.read_ms;
let io_ms = after.io_ms - before.io_ms;
let weighted = after.weighted_ms - before.weighted_ms;
format!(
"{label}: dev_reads={reads} kb={} avg_lat_ms={:.3} util={:.0}% avg_qd={:.1} iops={:.0}",
sectors / 2,
if reads > 0 {
read_ms as f64 / reads as f64
} else {
0.0
},
if elapsed_ms > 0.0 {
100.0 * io_ms as f64 / elapsed_ms
} else {
0.0
},
if elapsed_ms > 0.0 {
weighted as f64 / elapsed_ms
} else {
0.0
},
if elapsed_ms > 0.0 {
reads as f64 * 1000.0 / elapsed_ms
} else {
0.0
},
)
}
fn config(ctx: &E, args: &Args) -> FixedConfig {
let page_size = args.logical.unwrap_or(paged::page_size(args.page));
let page_cache = CacheRef::new(page_size, NZUsize!(args.cache));
FixedConfig {
merkle_config: full::Config {
journal_partition: "cold-merkle".into(),
metadata_partition: "cold-merkle-meta".into(),
items_per_blob: NZU64!(args.blob),
write_buffer: WRITE_BUFFER,
replay_buffer: REPLAY_BUFFER,
strategy: ctx.strategy(NZUsize!(args.threads)),
page_cache: page_cache.clone(),
},
journal_config: FConfig {
partition: "cold-log".into(),
items_per_blob: NZU64!(args.blob),
page_cache,
write_buffer: WRITE_BUFFER,
replay_buffer: REPLAY_BUFFER,
},
translator: EightCap,
init_cache: Some(NZUsize!(1 << 20)),
init_buffer: NZUsize!(1 << 22),
init_concurrency: (),
}
}
async fn seed(mut db: AnyDb, args: &Args) -> AnyDb {
let start = Instant::now();
let mut rng = TestRng::new(42);
if *db.bounds().end > 1 {
eprintln!(
"db already seeded (bounds={:?}); skipping seed",
db.bounds()
);
return db;
}
let mut i = 0u64;
while i < args.keys {
let end = (i + args.seed).min(args.keys);
let mut batch = db.new_batch();
for k in i..end {
batch = batch.write(key(k), Some(Sha256::hash(&[&rng.next_u32().to_be_bytes()])));
}
let merkleized = batch.merkleize(&db, None, &mut Proportional).await.unwrap();
db = db.apply_batch(merkleized).await.unwrap();
db = db.commit().await.unwrap();
i = end;
if (i / args.seed).is_multiple_of(10) {
eprintln!("seeded {i} keys in {:?}", start.elapsed());
}
}
let db = db.sync().await.unwrap();
eprintln!("seed done: {} keys in {:?}", args.keys, start.elapsed());
db
}
fn percentile(v: &mut [f64], q: f64) -> f64 {
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
v[((v.len() - 1) as f64 * q) as usize]
}
async fn read_phase(mut db: AnyDb, args: &Args) -> AnyDb {
let mut rng = TestRng::new(args.rseed);
let mut times = Vec::new();
// Pre-generate every batch's keys so a pipeline iteration can prefetch the next batch.
let batches: Vec> = (0..args.iters + 1)
.map(|_| {
let mut keys: Vec = (0..args.batch)
.map(|_| key(rng.random_range(0..args.keys)))
.collect();
keys.sort();
keys.dedup();
keys
})
.collect();
for iter in 0..args.iters {
let keys = &batches[iter];
let refs: Vec<&Digest> = keys.iter().collect();
let before = diskstats(&args.disk);
let start = Instant::now();
let mut detail = String::new();
match args.mode.as_str() {
"get_many" => {
let values = db.get_many(&refs).await.unwrap();
assert!(values.iter().all(|v| v.is_some()));
black_box(values);
}
"get_serial" => {
for k in &refs {
let value = db.get(k).await.unwrap();
assert!(value.is_some());
black_box(value);
}
}
"get_concurrent" => {
let values = try_join_all(refs.iter().map(|k| db.get(k))).await.unwrap();
assert!(values.iter().all(|value| value.is_some()));
black_box(values);
}
"chunked" => {
let chunk = refs.len().div_ceil(8).max(1);
let values = try_join_all(refs.chunks(chunk).map(|c| db.get_many(c)))
.await
.unwrap();
assert!(values.iter().flatten().all(|value| value.is_some()));
black_box(values);
}
"shared" => {
// Concurrent readers of one batch miss the same pages at the same time.
let values = try_join_all((0..args.depth).map(|_| db.get_many(&refs)))
.await
.unwrap();
assert!(values.iter().flatten().all(|value| value.is_some()));
black_box(values);
detail = format!(" depth={}", args.depth);
}
"stage" => {
let (values, staged) = db.new_batch().stage(&refs, &db).await.unwrap();
assert!(values.iter().all(|value| value.is_some()));
black_box(&values);
let t_stage = start.elapsed();
let slots: Vec<_> = (0..refs.len())
.map(|i| {
let v = (iter as u64) * 1_000_000 + i as u64;
Slot::Put(Sha256::hash(&[&v.to_be_bytes()]))
})
.collect();
let merkleized = staged
.merkleize(slots, Vec::new(), None, &db, &mut Proportional)
.await
.unwrap();
let t_merk = start.elapsed();
db = db.apply_batch(merkleized).await.unwrap();
db = db.commit().await.unwrap();
let t_commit = start.elapsed();
detail = format!(
" stage={:.2} merkleize={:.2} apply+commit={:.2}",
t_stage.as_secs_f64() * 1000.0,
(t_merk - t_stage).as_secs_f64() * 1000.0,
(t_commit - t_merk).as_secs_f64() * 1000.0
);
}
"pipeline" => {
// Prefetch the next batch's keys (as an app would, given the key list ahead of
// execution) while staging this batch, which was prefetched one iteration ago.
let next_refs: Vec<&Digest> = batches[iter + 1].iter().collect();
let prefetch = async {
if iter + 1 < args.iters {
let t = Instant::now();
let v = db.get_many(&next_refs).await.unwrap();
assert!(v.iter().all(|value| value.is_some()));
black_box(v);
t.elapsed()
} else {
std::time::Duration::ZERO
}
};
let stage = async {
let t = Instant::now();
let out = db.new_batch().stage(&refs, &db).await.unwrap();
(out, t.elapsed())
};
let (t_prefetch, ((values, staged), t_stage)) = join!(prefetch, stage);
assert!(values.iter().all(|value| value.is_some()));
black_box(&values);
let slots: Vec<_> = (0..refs.len())
.map(|i| {
let v = (iter as u64) * 1_000_000 + i as u64;
Slot::Put(Sha256::hash(&[&v.to_be_bytes()]))
})
.collect();
let t0 = Instant::now();
let merkleized = staged
.merkleize(slots, Vec::new(), None, &db, &mut Proportional)
.await
.unwrap();
let t_merk = t0.elapsed();
db = db.apply_batch(merkleized).await.unwrap();
db = db.commit().await.unwrap();
let t_commit = t0.elapsed();
detail = format!(
" prefetch_next={:.2} stage={:.2} merkleize={:.2} apply+commit={:.2}",
t_prefetch.as_secs_f64() * 1000.0,
t_stage.as_secs_f64() * 1000.0,
t_merk.as_secs_f64() * 1000.0,
(t_commit - t_merk).as_secs_f64() * 1000.0
);
}
"sustained" => {
// Keep `depth` independent batches in flight for the whole run, refilling as each
// completes, as a pipelined application does. One iteration covers all batches.
let depth = args.depth;
let total = args.iters * 2;
let mut rng2 = TestRng::new(args.rseed.wrapping_add(99));
let pool: Vec> = (0..total)
.map(|_| {
let mut keys: Vec = (0..args.batch)
.map(|_| key(rng2.random_range(0..args.keys)))
.collect();
keys.sort();
keys.dedup();
keys
})
.collect();
let pool = &pool;
let db_ref = &db;
let read_batch = move |i: usize| async move {
let refs: Vec<&Digest> = pool[i].iter().collect();
let values = db_ref.get_many(&refs).await.unwrap();
assert!(values.iter().all(|value| value.is_some()));
values.len()
};
let mut next = 0;
let mut inflight = FuturesUnordered::new();
while inflight.len() < depth && next < total {
inflight.push(read_batch(next));
next += 1;
}
// Time the run from its first read. Generating the key pool above is setup.
let before = diskstats(&args.disk);
let start = Instant::now();
let mut done = 0usize;
while let Some(n) = inflight.next().await {
black_box(n);
done += 1;
if next < total {
inflight.push(read_batch(next));
next += 1;
}
}
detail = format!(
" depth={depth} batches={done} ms_per_batch={:.2}",
start.elapsed().as_secs_f64() * 1000.0 / done as f64
);
drop(inflight);
// One sustained run is the whole measurement.
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
let after = diskstats(&args.disk);
println!(
"sustained: {detail} | total_ms={elapsed:.1} | {}",
disk_delta("disk", before, after, elapsed)
);
return db;
}
other => panic!("unknown mode {other}"),
}
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
let after = diskstats(&args.disk);
times.push(elapsed);
println!(
"iter={iter} keys={} ms={elapsed:.2}{detail} | {}",
refs.len(),
disk_delta("disk", before, after, elapsed)
);
}
let mut t = times.clone();
println!(
"RESULT mode={} keys={} batch={} page={} cache={} blob={} workers={} blocking={} p50={:.2} mean={:.2} min={:.2} max={:.2}",
args.mode,
args.keys,
args.batch,
args.page,
args.cache,
args.blob,
args.workers,
args.blocking,
percentile(&mut t, 0.5),
times.iter().sum::() / times.len() as f64,
percentile(&mut t, 0.0),
percentile(&mut t, 1.0),
);
db
}
fn main() {
// `cargo bench` appends a trailing `--bench` arg even for harness=false binaries. Drop it so
// trailing optional args parse.
let raw: Vec = std::env::args()
.skip(1)
.filter(|a| a != "--bench")
.collect();
// Run only when explicitly given a subcommand. Blanket harness invocations (no args, or
// libtest or Criterion flags like `--list` or `--output-format bencher` from the benchmark
// CI) must no-op so `cargo bench --benches` does not seed millions of keys.
let Some(first) = raw.first() else {
return;
};
if first.starts_with("--") {
return;
}
let Some(args) = Args::parse(&raw) else {
usage();
return;
};
let logical = args.logical.map_or(0, NonZeroU16::get);
match args.phase {
Phase::Seed => eprintln!(
"cold_read seed dir={} keys={} seed_batch={} page={} logical={logical} blob={} cache={} threads={} workers={} blocking={}",
args.dir,
args.keys,
args.seed,
args.page,
args.blob,
args.cache,
args.threads,
args.workers,
args.blocking
),
Phase::Read => eprintln!(
"cold_read read dir={} keys={} mode={} batch={} iters={} depth={} rseed={} disk={} page={} logical={logical} blob={} cache={} threads={} workers={} blocking={}",
args.dir,
args.keys,
args.mode,
args.batch,
args.iters,
args.depth,
args.rseed,
args.disk,
args.page,
args.blob,
args.cache,
args.threads,
args.workers,
args.blocking
),
Phase::Blob => eprintln!(
"cold_read blob dir={} mode={} batch={} iters={} rseed={} disk={} workers={} blocking={}",
args.dir,
args.mode,
args.batch,
args.iters,
args.rseed,
args.disk,
args.workers,
args.blocking
),
}
let cfg = RConfig::default()
.with_worker_threads(args.workers)
.with_max_blocking_threads(args.blocking)
.with_storage_directory(args.dir.clone());
Runner::new(cfg).start(|ctx| run(ctx, args));
}
/// Raw blob reads through the runtime's storage, bypassing QMDB and the page cache: `batch`
/// random 4 KiB physical pages of the log's first data blob per iteration, issued either as
/// concurrent `read_at` calls (one blocking task per read on tokio) or as one `read_many`.
async fn blob_phase(ctx: &E, args: &Args) {
let (blob, size) = ctx
.open("cold-log-blobs", &0u64.to_be_bytes())
.await
.expect("open first log blob");
let pages = size / 4096;
eprintln!("blob phase: blob size {size} ({pages} pages)");
let blob = std::sync::Arc::new(blob);
let mut rng = TestRng::new(args.rseed);
let mut times = Vec::new();
for iter in 0..args.iters {
let mut offsets: Vec = (0..args.batch)
.map(|_| rng.random_range(0..pages) * 4096)
.collect();
offsets.sort_unstable();
offsets.dedup();
let before = diskstats(&args.disk);
let start = Instant::now();
match args.mode.as_str() {
"blob_read_at" => {
let mut reads: FuturesUnordered<_> = offsets
.iter()
.map(|&offset| blob.read_at(offset, 4096, ReadOptions::DONT_CACHE))
.collect();
while let Some(read) = reads.next().await {
black_box(read.unwrap());
}
}
"blob_read_many" => {
let ranges: Vec<(u64, usize)> = offsets.iter().map(|&o| (o, 4096)).collect();
let mut count = 0;
let mut reads = std::pin::pin!(blob.read_many(&ranges, ReadOptions::DONT_CACHE));
while let Some(read) = reads.next().await {
black_box(read.unwrap());
count += 1;
}
assert_eq!(count, ranges.len());
}
other => panic!("unknown blob mode {other}"),
}
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
let after = diskstats(&args.disk);
times.push(elapsed);
println!(
"iter={iter} reads={} ms={elapsed:.2} | {}",
offsets.len(),
disk_delta("disk", before, after, elapsed)
);
}
let mut t = times.clone();
println!(
"RESULT mode={} batch={} p50={:.2} mean={:.2} min={:.2} max={:.2}",
args.mode,
args.batch,
percentile(&mut t, 0.5),
times.iter().sum::() / times.len() as f64,
percentile(&mut t, 0.0),
percentile(&mut t, 1.0),
);
}
async fn run(ctx: E, args: Args) {
if args.phase == Phase::Blob {
blob_phase(&ctx, &args).await;
return;
}
let start = Instant::now();
let db = AnyDb::::init(ctx.child("db"), config(&ctx, &args), None)
.await
.unwrap();
eprintln!("init: {:?} bounds={:?}", start.elapsed(), db.bounds());
if *db.bounds().end > 1 || args.phase == Phase::Read {
// Sequential seeding commits a prefix of the keyspace before any timed updates.
let last = args.keys.checked_sub(1).expect("keys must be positive");
assert!(
db.get(&key(last)).await.unwrap().is_some(),
"database seed is incomplete; seed a fresh directory"
);
}
if args.phase == Phase::Seed {
drop(seed(db, &args).await);
} else {
drop(read_phase(db, &args).await);
}
}