Files
clawhdf5/crates/clawhdf5-bench/src/bin/footprint_bench.rs
T
osobhandClaude Opus 5.5 dce5559ff2
CI / test-arm64 (pull_request) Successful in 54s
CI / test (pull_request) Successful in 5m6s
bench: re-run every stale BENCHMARKS.md section, dated and traced
Every undated or pre-September section re-run on one machine on one day
(tank, AMD Ryzen 7 7800X3D, 2026-09-24, commit 5c8323c), 24 commands run
serially with the load average checked before each, with the command
recorded for each section. A separate check traced every changed number
back to the raw output; its corrections are applied (e.g. the on-disk
~820 B/record is float16 plus always-deflated text on a synthetic corpus
of 40 distinct texts, not float16 alone).

Two apparent regressions were isolated rather than published:
- knowledge-graph traversal: a real bug, fixed in the previous commit;
- the write path: v2.3.0 built and run on the same machine measures the
  same as today, so the old 18 us / 6.17 ms figures (undated, other
  hardware) are not reproducible; float16 adds ~2 us per save and the
  int8 index nothing (both isolated by switching the bench's config).

Also:
- new multimodal_bench: cross-modal search at 1K/10K records, which the
  README claimed but nothing measured;
- footprint_bench reports whether it built float16 or f32 stores and
  takes --f32 (it kept printing "f32" after the default changed);
- README: performance tables, the "Why" table figures and the SQLite
  migration section (from the previous migrate commit);
- CHANGELOG for this branch.

Not re-run: consolidation_efficiency's 100K row and its memory-reduction
part (stopped for time), and cross_platform.sh.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-24 23:45:15 -05:00

375 lines
11 KiB
Rust

//! Memory Footprint Benchmark (Track 8.4)
//!
//! Measures HDF5 file size at various record counts:
//! 100, 1K, 10K, 50K, 100K records
//!
//! Reports:
//! - File size on disk (bytes / KB / MB)
//! - Bytes per record
//! - Compression ratio (compressed vs uncompressed)
//! - Ingestion throughput (records/second)
//!
//! Configuration matrix:
//! - Text lengths: short (50 chars), medium (200 chars), long (1000 chars)
//! - Embedding: 384-dim, stored as float16 (the default for new stores) or
//! f32 with `--f32`; "raw" bytes are counted as f32 input either way
//! - WAL: enabled and disabled
//!
//! # Usage
//! ```
//! cargo run --release --bin footprint_bench # float16 stores
//! cargo run --release --bin footprint_bench -- --f32 # f32 stores
//! ```
use std::time::Instant;
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
use tempfile::TempDir;
/// `--f32`: build f32 stores instead of the library's float16 default.
static F32: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
const EMBEDDING_DIM: usize = 384;
// Raw bytes per record: 384 f32 embeddings + median text + overhead
const RAW_BYTES_PER_FLOAT: usize = 4;
// ---------------------------------------------------------------------------
// Deterministic text generator (no randomness)
// ---------------------------------------------------------------------------
const WORD_BANK: &[&str] = &[
"system",
"architecture",
"distributed",
"memory",
"vector",
"embedding",
"agent",
"knowledge",
"search",
"retrieval",
"temporal",
"semantic",
"episodic",
"working",
"consolidation",
"importance",
"activation",
"cosine",
"similarity",
"hybrid",
"keyword",
"BM25",
"index",
"session",
"context",
"token",
"chunk",
"overlap",
"inference",
"pipeline",
"latency",
"throughput",
"benchmark",
"performance",
"Rust",
"async",
"parallel",
"concurrent",
"thread",
"atomic",
];
fn make_text(record_idx: usize, target_chars: usize) -> String {
let mut result = String::with_capacity(target_chars + 50);
let mut word_idx = record_idx % WORD_BANK.len();
while result.len() < target_chars {
if !result.is_empty() {
result.push(' ');
}
result.push_str(WORD_BANK[word_idx]);
word_idx = (word_idx + 7) % WORD_BANK.len(); // stride 7 for variety
}
result.truncate(target_chars);
result
}
fn make_embedding(record_idx: usize) -> Vec<f32> {
// Deterministic non-zero embeddings to stress compression
(0..EMBEDDING_DIM)
.map(|i| ((record_idx * 31 + i * 17) % 1000) as f32 / 1000.0 - 0.5)
.collect()
}
fn make_entries(n: usize, text_len: usize) -> Vec<MemoryEntry> {
(0..n)
.map(|i| MemoryEntry {
chunk: make_text(i, text_len),
embedding: make_embedding(i),
source_channel: "footprint-bench".to_string(),
timestamp: 1_000_000.0 + i as f64,
session_id: format!("sess_{}", i / 50),
tags: String::new(),
})
.collect()
}
// ---------------------------------------------------------------------------
// Single footprint measurement
// ---------------------------------------------------------------------------
#[allow(dead_code)]
struct FootprintResult {
n: usize,
text_len: usize,
wal_enabled: bool,
compression: bool,
file_bytes: u64,
wal_bytes: u64,
ingest_ms: f64,
raw_bytes: u64,
}
impl FootprintResult {
fn bytes_per_record(&self) -> u64 {
self.file_bytes / self.n.max(1) as u64
}
fn compression_ratio(&self) -> f64 {
self.raw_bytes as f64 / self.file_bytes.max(1) as f64
}
fn records_per_sec(&self) -> f64 {
self.n as f64 / (self.ingest_ms / 1000.0).max(0.001)
}
}
fn measure_footprint(
n: usize,
text_len: usize,
wal_enabled: bool,
compression: bool,
) -> FootprintResult {
let dir = TempDir::new().expect("TempDir failed");
let h5_path = dir.path().join("footprint.h5");
let mut config = MemoryConfig::new(h5_path.clone(), "footprint-bench", EMBEDDING_DIM);
config.wal_enabled = wal_enabled;
config.compression = compression;
config.compression_level = if compression { 6 } else { 0 };
config.compact_threshold = 0.0;
if F32.load(std::sync::atomic::Ordering::Relaxed) {
config.float16 = false;
}
let mut memory = HDF5Memory::create(config).expect("HDF5Memory::create failed");
let entries = make_entries(n, text_len);
let raw_bytes = entries
.iter()
.map(|e| e.chunk.len() + e.embedding.len() * RAW_BYTES_PER_FLOAT)
.sum::<usize>() as u64;
// Batch ingest, measure time
let t0 = Instant::now();
memory.save_batch(entries).expect("save_batch failed");
let ingest_ms = t0.elapsed().as_secs_f64() * 1000.0;
let file_bytes = std::fs::metadata(&h5_path).map(|m| m.len()).unwrap_or(0);
let wal_path = h5_path.with_extension("h5.wal");
let wal_bytes = std::fs::metadata(&wal_path).map(|m| m.len()).unwrap_or(0);
FootprintResult {
n,
text_len,
wal_enabled,
compression,
file_bytes,
wal_bytes,
ingest_ms,
raw_bytes,
}
}
// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------
fn fmt_bytes(b: u64) -> String {
if b >= 1_048_576 {
format!("{:.1} MB", b as f64 / 1_048_576.0)
} else if b >= 1024 {
format!("{:.1} KB", b as f64 / 1024.0)
} else {
format!("{} B", b)
}
}
fn print_table(results: &[FootprintResult], label: &str) {
println!("### {label}");
println!();
println!(
"{:>8} {:>12} {:>12} {:>10} {:>8} {:>14}",
"Records", "File Size", "Raw Data", "Bytes/Rec", "Ratio", "Throughput"
);
println!("{}", "-".repeat(76));
for r in results {
let wal_note = if r.wal_enabled && r.wal_bytes > 0 {
format!(" (+{} WAL)", fmt_bytes(r.wal_bytes))
} else {
String::new()
};
println!(
"{:>8} {:>12} {:>12} {:>10} {:>7.2}x {:>11.0} rec/s{}",
fmt_n(r.n),
fmt_bytes(r.file_bytes),
fmt_bytes(r.raw_bytes),
fmt_bytes(r.bytes_per_record()),
r.compression_ratio(),
r.records_per_sec(),
wal_note
);
}
println!();
}
fn fmt_n(n: usize) -> String {
match n {
100 => "100".to_owned(),
1_000 => "1K".to_owned(),
10_000 => "10K".to_owned(),
50_000 => "50K".to_owned(),
100_000 => "100K".to_owned(),
_ => n.to_string(),
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
fn main() {
if std::env::args().skip(1).any(|a| a == "--f32") {
F32.store(true, std::sync::atomic::Ordering::Relaxed);
}
let stored = if F32.load(std::sync::atomic::Ordering::Relaxed) {
"f32 (1,536 bytes per record)"
} else {
"float16 (768 bytes per record; the default for new stores)"
};
println!("=================================================================");
println!(" ClawhDF5 Memory Footprint Benchmark");
println!("=================================================================");
println!();
println!("Embedding: 384-dim, stored as {stored}; raw input counted as f32");
println!("Text lengths: short=50 chars, medium=200 chars, long=1000 chars");
println!();
// Test scales
let scales = [100usize, 1_000, 10_000, 50_000, 100_000];
// --- Medium text, no compression, no WAL ---
let mut results = Vec::new();
for &n in &scales {
eprint!("\r medium text, no compression, no WAL: {n} records...");
results.push(measure_footprint(n, 200, false, false));
}
eprintln!();
print_table(&results, "Medium Text (200 chars), No Compression, No WAL");
// --- Medium text, with compression, no WAL ---
let mut results = Vec::new();
for &n in &scales {
eprint!("\r medium text, compression, no WAL: {n} records...");
results.push(measure_footprint(n, 200, false, true));
}
eprintln!();
print_table(
&results,
"Medium Text (200 chars), Gzip Compression (level 6), No WAL",
);
// --- Text length comparison at 10K records ---
println!("### Text Length Comparison at 10K Records (no compression, no WAL)");
println!();
println!(
"{:>12} {:>12} {:>12} {:>10} {:>14}",
"Text Length", "File Size", "Raw Data", "Bytes/Rec", "Throughput"
);
println!("{}", "-".repeat(65));
for &text_len in &[50usize, 200, 1000] {
let r = measure_footprint(10_000, text_len, false, false);
let label = match text_len {
50 => "short (50)",
200 => "medium (200)",
_ => "long (1000)",
};
println!(
"{:>12} {:>12} {:>12} {:>10} {:>11.0} rec/s",
label,
fmt_bytes(r.file_bytes),
fmt_bytes(r.raw_bytes),
fmt_bytes(r.bytes_per_record()),
r.records_per_sec()
);
}
println!();
// --- WAL overhead at 1K records ---
println!("### WAL Overhead at 1K Records (medium text, no compression)");
println!();
let no_wal = measure_footprint(1_000, 200, false, false);
let with_wal = measure_footprint(1_000, 200, true, false);
println!(
" No WAL: file={:>10} ingest={:.1}ms",
fmt_bytes(no_wal.file_bytes),
no_wal.ingest_ms
);
println!(
" With WAL: file={:>10} WAL={:>8} ingest={:.1}ms (+{:.0}% latency)",
fmt_bytes(with_wal.file_bytes),
fmt_bytes(with_wal.wal_bytes),
with_wal.ingest_ms,
(with_wal.ingest_ms / no_wal.ingest_ms.max(0.001) - 1.0) * 100.0
);
println!();
println!("=================================================================");
println!(" Summary");
println!("=================================================================");
println!();
println!("At 10K records (typical agent memory), 384-dim embeddings + 200-char text:");
let r10k = measure_footprint(10_000, 200, false, false);
let r10k_comp = measure_footprint(10_000, 200, false, true);
println!(" Uncompressed: {}", fmt_bytes(r10k.file_bytes));
println!(
" Compressed: {} ({:.1}x ratio)",
fmt_bytes(r10k_comp.file_bytes),
r10k_comp.compression_ratio()
);
println!(
" Per record: {} (uncompressed)",
fmt_bytes(r10k.bytes_per_record())
);
println!(" Throughput: {:.0} records/sec", r10k.records_per_sec());
println!();
println!("At 100K records:");
let r100k = measure_footprint(100_000, 200, false, false);
let r100k_comp = measure_footprint(100_000, 200, false, true);
println!(" Uncompressed: {}", fmt_bytes(r100k.file_bytes));
println!(
" Compressed: {} ({:.1}x ratio)",
fmt_bytes(r100k_comp.file_bytes),
r100k_comp.compression_ratio()
);
}
// ---------------------------------------------------------------------------
// Ephemeral tier microbenchmark
// ---------------------------------------------------------------------------
#[cfg(test)]
mod ephemeral_perf {
// placeholder — actual perf measured in main below
}