use std::hint::black_box;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use plugmem_host::{
Config, Database, FsyncPolicy, LinkInput, MaintenanceOptions, RecallQuery, RememberInput,
Stats, UnlinkInput,
};
const DEFAULT_TRIPLES: usize = 200;
const DEFAULT_ROUNDS: usize = 500;
const RECALL_WARMUP: usize = 3;
const RECALL_SAMPLES: usize = 20;
const ANCHOR: &str = "hub";
const REL: &str = "assigned_to";
const EPOCH: u64 = 1_000_000;
const ROUND_STEP: u64 = 1_000;
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new() -> std::io::Result<Self> {
let path = std::env::temp_dir().join(format!(
"plugmem-churn-bench-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock is before the Unix epoch")
.as_nanos()
));
std::fs::create_dir_all(&path)?;
Ok(Self { path })
}
fn db(&self) -> PathBuf {
self.path.join("churn.plugmem")
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
struct Options {
triples: usize,
rounds: usize,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
run(parse_options()?)
}
fn parse_options() -> Result<Options, String> {
let mut positional = Vec::new();
for arg in std::env::args().skip(1) {
match arg.as_str() {
"-h" | "--help" => {
println!(
"usage: bench_edge_churn [TRIPLES] [ROUNDS]\n\n\
TRIPLES defaults to 200, ROUNDS to 500. The benchmark \
links and unlinks each triple ROUNDS times, retaining \
TRIPLES * ROUNDS edge versions, then measures `as_of` \
graph recall at both ends of that history."
);
std::process::exit(0);
}
value if value.starts_with('-') => {
return Err(format!("unrecognized argument `{value}`"));
}
value => positional.push(
value
.parse::<usize>()
.map_err(|_| format!("expected a positive integer, got `{value}`"))?,
),
}
}
let triples = positional.first().copied().unwrap_or(DEFAULT_TRIPLES);
let rounds = positional.get(1).copied().unwrap_or(DEFAULT_ROUNDS);
if positional.len() > 2 {
return Err("expected at most TRIPLES and ROUNDS".into());
}
if triples == 0 || rounds == 0 {
return Err("triples and rounds must be greater than zero".into());
}
Ok(Options { triples, rounds })
}
fn run(options: Options) -> Result<(), Box<dyn std::error::Error>> {
let temp = TempDir::new()?;
let versions = options.triples * options.rounds;
let corpus = format!("churn-{}x{}", options.triples, options.rounds);
let (db, _) = Database::builder(Config::default())
.fsync(FsyncPolicy::OnSnapshot)
.snapshot_every_ops(0)
.snapshot_journal_bytes(0)
.open(temp.db())?;
println!(
"# plugmem edge churn benchmark: triples={} rounds={} versions={versions}",
options.triples, options.rounds
);
println!("# persistence: OnSnapshot, no auto-snapshot");
println!("# metric format: #DB<TAB>corpus<TAB>runtime<TAB>phase<TAB>metric<TAB>value");
db.remember(RememberInput {
now: 1,
text: "edge churn hub anchor",
entity: Some(ANCHOR),
tags: &[],
links: &[],
vector: None,
valid_from: None,
metadata: None,
})?;
let names: Vec<String> = (0..options.triples)
.map(|i| format!("target-{i}"))
.collect();
for name in &names {
db.remember(RememberInput {
now: 2,
text: "edge churn target",
entity: Some(name),
tags: &[],
links: &[],
vector: None,
valid_from: None,
metadata: None,
})?;
}
let churn_start = Instant::now();
for round in 0..options.rounds {
let opened = EPOCH + round as u64 * ROUND_STEP;
for name in &names {
db.link(LinkInput {
now: opened,
src: ANCHOR,
rel: REL,
dst: name,
provenance: None,
})?;
}
if round + 1 < options.rounds {
for name in &names {
black_box(db.unlink(UnlinkInput {
now: opened + ROUND_STEP / 2,
src: ANCHOR,
rel: REL,
dst: name,
})?);
}
}
}
let churn_elapsed = churn_start.elapsed();
emit_ms(&corpus, "churn", "elapsed_ms", churn_elapsed);
emit_f64(
&corpus,
"churn",
"latency_us_per_version",
churn_elapsed.as_secs_f64() * 1_000_000.0 / versions as f64,
);
emit_stats(&corpus, "after_churn", db.stats());
let last_round = EPOCH + (options.rounds - 1) as u64 * ROUND_STEP;
let now = last_round + ROUND_STEP;
let anchors = [ANCHOR];
measure_query(&corpus, "current_graph_recall", || {
db.recall(graph_query(now, None, &anchors))
})?;
measure_query(&corpus, "as_of_recent", || {
db.recall(graph_query(now, Some(last_round - ROUND_STEP), &anchors))
})?;
measure_query(&corpus, "as_of_oldest", || {
db.recall(graph_query(now, Some(EPOCH), &anchors))
})?;
measure_query(&corpus, "as_of_gap_early", || {
db.recall(graph_query(now, Some(EPOCH + ROUND_STEP / 2 + 1), &anchors))
})?;
measure_query(&corpus, "as_of_gap_late", || {
db.recall(graph_query(
now,
Some(last_round - ROUND_STEP + ROUND_STEP / 2 + 1),
&anchors,
))
})?;
let maintain_start = Instant::now();
let report = db.maintain_with_options(now + 1, MaintenanceOptions::full())?;
let maintain_elapsed = maintain_start.elapsed();
emit_ms(&corpus, "full_maintain", "elapsed_ms", maintain_elapsed);
emit_f64(
&corpus,
"full_maintain",
"latency_us_per_version",
maintain_elapsed.as_secs_f64() * 1_000_000.0 / versions as f64,
);
emit_usize(
&corpus,
"full_maintain",
"edges_compacted",
usize::from(report.edges_compacted),
);
emit_stats(&corpus, "after_full_maintain", db.stats());
measure_query(&corpus, "as_of_recent_after_maintain", || {
db.recall(graph_query(now, Some(last_round - ROUND_STEP), &anchors))
})?;
Ok(())
}
fn graph_query<'a>(now: u64, as_of: Option<u64>, entities: &'a [&'a str]) -> RecallQuery<'a> {
let mut query = RecallQuery::text(now, "");
query.text = None;
query.entities = entities;
query.as_of = as_of;
query.k = 64;
query.token_budget = Some(4096);
query
}
fn measure_query(
corpus: &str,
phase: &str,
mut call: impl FnMut() -> Result<plugmem_host::RecallResult, plugmem_host::HostError>,
) -> Result<(), plugmem_host::HostError> {
for _ in 0..RECALL_WARMUP {
black_box(call()?.edges.len());
}
let mut samples = Vec::with_capacity(RECALL_SAMPLES);
let mut last_edges = 0usize;
for _ in 0..RECALL_SAMPLES {
let started = Instant::now();
let result = call()?;
last_edges = result.edges.len();
black_box(last_edges);
samples.push(started.elapsed());
}
samples.sort_unstable();
emit_us_duration(corpus, phase, "p50_us", samples[samples.len() / 2]);
emit_us_duration(corpus, phase, "p95_us", samples[samples.len() * 95 / 100]);
emit_usize(corpus, phase, "edges", last_edges);
Ok(())
}
fn emit_stats(corpus: &str, phase: &str, stats: Stats) {
emit_usize(corpus, phase, "edges", stats.edges);
emit_usize(corpus, phase, "edge_versions", stats.edge_versions);
emit_usize(corpus, phase, "pool_bytes", stats.pool_bytes);
}
fn emit_ms(corpus: &str, phase: &str, metric: &str, duration: Duration) {
emit_f64(corpus, phase, metric, duration.as_secs_f64() * 1_000.0);
}
fn emit_us_duration(corpus: &str, phase: &str, metric: &str, duration: Duration) {
emit_f64(corpus, phase, metric, duration.as_secs_f64() * 1_000_000.0);
}
fn emit_usize(corpus: &str, phase: &str, metric: &str, value: usize) {
emit_f64(corpus, phase, metric, value as f64);
}
fn emit_f64(corpus: &str, phase: &str, metric: &str, value: f64) {
println!("#DB\t{corpus}\tnative\t{phase}\t{metric}\t{value:.3}");
}