mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-07-12 03:12:11 +02:00
bench(engine): scenario benchmark harness — cold subprocess runs, wait4 peak-RSS, JSON lines
The dedicated cost/perf instrument testing.md's write_cost_s3 note has been promising: one cold, stateful macro-run per scenario in a fresh subprocess (self-respawn via current_exe), reaped with libc::wait4 so ru_maxrss gives kernel-exact peak RSS with no sampling; results are JSON lines and there are deliberately no assertions — a decision instrument, never a CI gate. Criterion is deliberately not used: statistics over warm in-process iterations is the wrong model for multi-second stateful scenarios, it measures no memory, and an OOM under --memory-cap-mb (setrlimit; enforced on Linux, best-effort on macOS) is a data point that needs crash isolation. Two scenarios ship with the skeleton: - merge-all-changed: an embedding table whose branch changed EVERY row's vector, merged three-way into a diverged main — the changed-delta concat + hash-join cost of branch_merge. --baseline re-runs the identical workload minus the merge so the peak-RSS delta isolates it (smoke, 20k rows x 256 dims: ~72 MB merge contribution on a 20.5 MB raw delta, ~3.5x). - nearest-prefilter: selectivity-s filtered nearest() where matching rows sit far from the query point — quantifies the post-filter ANN recall deficit (smoke, 20k rows, s=0.05, k=10: 1000 matching rows exist, 0 returned); becomes the prefilter latency comparison unchanged once the fix lands.
This commit is contained in:
parent
db96e7000d
commit
d426904ab6
3 changed files with 511 additions and 0 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -4946,6 +4946,7 @@ dependencies = [
|
|||
"lance-namespace",
|
||||
"lance-namespace-impls",
|
||||
"lance-table",
|
||||
"libc",
|
||||
"object_store",
|
||||
"omnigraph-compiler",
|
||||
"omnigraph-policy",
|
||||
|
|
|
|||
|
|
@ -61,3 +61,12 @@ lance-namespace-impls = { workspace = true }
|
|||
lance-io = { version = "7.0.0", features = ["test-util"] }
|
||||
serial_test = "3"
|
||||
proptest = "1"
|
||||
# benches/scenarios.rs only: wait4/rusage peak-RSS + setrlimit memory caps.
|
||||
libc = "0.2"
|
||||
|
||||
[[bench]]
|
||||
# Scenario benchmark harness — a decision instrument, not a CI gate. One cold
|
||||
# instrumented subprocess per scenario, JSON lines out. See docs/dev/testing.md
|
||||
# "Examples & benches". `harness = false`: the file provides its own main.
|
||||
name = "scenarios"
|
||||
harness = false
|
||||
|
|
|
|||
501
crates/omnigraph/benches/scenarios.rs
Normal file
501
crates/omnigraph/benches/scenarios.rs
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
//! Scenario benchmark harness — a decision instrument, not a CI gate.
|
||||
//!
|
||||
//! Each scenario is ONE cold, stateful, multi-second macro-run (a branch
|
||||
//! merge, a filtered vector search) executed in a fresh subprocess and
|
||||
//! instrumented for wall-clock, peak RSS, and scenario-specific metrics.
|
||||
//! Results are JSON lines on stdout; there are no assertions and this target
|
||||
//! is never part of `cargo test --workspace` or any CI gate. Criterion is
|
||||
//! deliberately not used: statistics over many warm in-process iterations is
|
||||
//! the wrong model for these workloads (cold-vs-warm is the whole game, the
|
||||
//! primary metric is memory, and an OOM under a cap is a *data point* that
|
||||
//! needs crash isolation, not a bench failure).
|
||||
//!
|
||||
//! Run:
|
||||
//! cargo bench -p omnigraph-engine --bench scenarios -- \
|
||||
//! --scenario merge-all-changed --rows 20000 --dims 256
|
||||
//! cargo bench -p omnigraph-engine --bench scenarios -- \
|
||||
//! --scenario nearest-prefilter --rows 100000 --dims 64 --selectivity 0.05
|
||||
//!
|
||||
//! Mechanism: the parent re-invokes `current_exe()` with `--child` per run,
|
||||
//! reaps it with `libc::wait4`, and reads `rusage.ru_maxrss` — the kernel's
|
||||
//! exact per-child peak RSS, no sampling. `--memory-cap-mb` applies
|
||||
//! `setrlimit(RLIMIT_AS)` in the child (reliable on Linux; macOS often
|
||||
//! ignores RLIMIT_AS — the cap variant is primarily a Linux tool, while
|
||||
//! peak-RSS reporting works everywhere).
|
||||
|
||||
#[path = "../tests/helpers/mod.rs"]
|
||||
mod helpers;
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Read as _;
|
||||
use std::time::Instant;
|
||||
|
||||
use omnigraph::db::{Omnigraph, ReadTarget};
|
||||
use omnigraph::loader::LoadMode;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Args
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Args {
|
||||
scenario: String,
|
||||
rows: usize,
|
||||
dims: usize,
|
||||
seed: u64,
|
||||
runs: usize,
|
||||
/// Selectivity for nearest-prefilter: fraction of rows matching the filter.
|
||||
selectivity: f64,
|
||||
/// ANN k (the query's `limit`) for nearest-prefilter.
|
||||
k: usize,
|
||||
memory_cap_mb: Option<u64>,
|
||||
/// Run the identical workload but SKIP the measured operation; the
|
||||
/// peak-RSS delta between a normal run and a baseline run isolates the
|
||||
/// measured op's own memory contribution (ru_maxrss spans the whole
|
||||
/// child, seeding included).
|
||||
baseline: bool,
|
||||
child: bool,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse() -> Self {
|
||||
let mut args = Args {
|
||||
scenario: String::new(),
|
||||
rows: 20_000,
|
||||
dims: 256,
|
||||
seed: 42,
|
||||
runs: 1,
|
||||
selectivity: 0.05,
|
||||
k: 10,
|
||||
memory_cap_mb: None,
|
||||
baseline: false,
|
||||
child: false,
|
||||
};
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(arg) = it.next() {
|
||||
let mut take = |name: &str| {
|
||||
it.next()
|
||||
.unwrap_or_else(|| panic!("missing value for {name}"))
|
||||
};
|
||||
match arg.as_str() {
|
||||
"--scenario" => args.scenario = take("--scenario"),
|
||||
"--rows" => args.rows = take("--rows").parse().expect("--rows"),
|
||||
"--dims" => args.dims = take("--dims").parse().expect("--dims"),
|
||||
"--seed" => args.seed = take("--seed").parse().expect("--seed"),
|
||||
"--runs" => args.runs = take("--runs").parse().expect("--runs"),
|
||||
"--selectivity" => {
|
||||
args.selectivity = take("--selectivity").parse().expect("--selectivity")
|
||||
}
|
||||
"--k" => args.k = take("--k").parse().expect("--k"),
|
||||
"--memory-cap-mb" => {
|
||||
args.memory_cap_mb = Some(take("--memory-cap-mb").parse().expect("cap"))
|
||||
}
|
||||
"--baseline" => args.baseline = true,
|
||||
"--child" => args.child = true,
|
||||
// `cargo bench` appends `--bench`; tolerate any unknown flag so
|
||||
// the harness composes with cargo's own argument plumbing.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
fn to_child_argv(&self) -> Vec<String> {
|
||||
let mut v = vec![
|
||||
"--scenario".into(),
|
||||
self.scenario.clone(),
|
||||
"--rows".into(),
|
||||
self.rows.to_string(),
|
||||
"--dims".into(),
|
||||
self.dims.to_string(),
|
||||
"--seed".into(),
|
||||
self.seed.to_string(),
|
||||
"--selectivity".into(),
|
||||
self.selectivity.to_string(),
|
||||
"--k".into(),
|
||||
self.k.to_string(),
|
||||
"--child".into(),
|
||||
];
|
||||
if self.baseline {
|
||||
v.push("--baseline".into());
|
||||
}
|
||||
if let Some(cap) = self.memory_cap_mb {
|
||||
v.push("--memory-cap-mb".into());
|
||||
v.push(cap.to_string());
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parent: spawn child, reap with wait4, merge rusage into the JSON record
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
if args.scenario.is_empty() {
|
||||
eprintln!(
|
||||
"usage: --scenario <merge-all-changed|nearest-prefilter> [--rows N] [--dims D] \
|
||||
[--seed S] [--runs K] [--selectivity F] [--k K] [--memory-cap-mb M]"
|
||||
);
|
||||
// `cargo bench` with no args must exit 0 so the target stays inert in
|
||||
// any blanket `cargo bench` invocation.
|
||||
return;
|
||||
}
|
||||
if args.child {
|
||||
run_child(&args);
|
||||
return;
|
||||
}
|
||||
for run in 0..args.runs {
|
||||
let record = run_once(&args, run);
|
||||
println!("{record}");
|
||||
}
|
||||
}
|
||||
|
||||
fn run_once(args: &Args, run: usize) -> serde_json::Value {
|
||||
let exe = std::env::current_exe().expect("current_exe");
|
||||
let mut child = std::process::Command::new(exe)
|
||||
.args(args.to_child_argv())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.spawn()
|
||||
.expect("spawn child");
|
||||
let pid = child.id() as i32;
|
||||
|
||||
// Read stdout to EOF BEFORE reaping — the pipe closes when the child
|
||||
// exits, and reading first avoids any pipe-full deadlock.
|
||||
let mut child_stdout = String::new();
|
||||
child
|
||||
.stdout
|
||||
.take()
|
||||
.expect("child stdout piped")
|
||||
.read_to_string(&mut child_stdout)
|
||||
.expect("read child stdout");
|
||||
|
||||
let (exit_status, peak_rss_bytes) = wait4_rusage(pid);
|
||||
|
||||
// The child prints exactly one JSON metrics line on success; on a crash
|
||||
// (e.g. OOM under --memory-cap-mb) stdout may be empty — record that as
|
||||
// the result rather than failing the harness.
|
||||
let scenario_metrics: serde_json::Value = child_stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.find_map(|l| serde_json::from_str(l).ok())
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
|
||||
serde_json::json!({
|
||||
"scenario": args.scenario,
|
||||
"run": run,
|
||||
"params": {
|
||||
"rows": args.rows,
|
||||
"dims": args.dims,
|
||||
"seed": args.seed,
|
||||
"selectivity": args.selectivity,
|
||||
"k": args.k,
|
||||
"memory_cap_mb": args.memory_cap_mb,
|
||||
"baseline": args.baseline,
|
||||
},
|
||||
"exit_status": exit_status,
|
||||
"peak_rss_bytes": peak_rss_bytes,
|
||||
"metrics": scenario_metrics,
|
||||
"host": {
|
||||
"os": std::env::consts::OS,
|
||||
"arch": std::env::consts::ARCH,
|
||||
"cores": std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Reap `pid` with `wait4` and return (exit code or -signal, peak RSS bytes).
|
||||
/// `ru_maxrss` is bytes on macOS and KiB on Linux.
|
||||
fn wait4_rusage(pid: i32) -> (i64, u64) {
|
||||
let mut status: libc::c_int = 0;
|
||||
let mut rusage: libc::rusage = unsafe { std::mem::zeroed() };
|
||||
let reaped = unsafe { libc::wait4(pid, &mut status, 0, &mut rusage) };
|
||||
assert_eq!(reaped, pid, "wait4 reaped unexpected pid");
|
||||
let exit: i64 = if libc::WIFEXITED(status) {
|
||||
libc::WEXITSTATUS(status) as i64
|
||||
} else if libc::WIFSIGNALED(status) {
|
||||
-(libc::WTERMSIG(status) as i64)
|
||||
} else {
|
||||
i64::MIN
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let peak = rusage.ru_maxrss as u64;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let peak = (rusage.ru_maxrss as u64) * 1024;
|
||||
(exit, peak)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Child: apply the cap, build a runtime, run the scenario, print metrics JSON
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn run_child(args: &Args) {
|
||||
if let Some(cap_mb) = args.memory_cap_mb {
|
||||
let cap = libc::rlimit {
|
||||
rlim_cur: cap_mb * 1024 * 1024,
|
||||
rlim_max: cap_mb * 1024 * 1024,
|
||||
};
|
||||
// RLIMIT_AS is enforced on Linux; macOS frequently ignores it. Applied
|
||||
// best-effort everywhere so the same command line works on both.
|
||||
unsafe { libc::setrlimit(libc::RLIMIT_AS, &cap) };
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio runtime");
|
||||
let metrics = runtime.block_on(async {
|
||||
match args.scenario.as_str() {
|
||||
"merge-all-changed" => merge_all_changed(args).await,
|
||||
"nearest-prefilter" => nearest_prefilter(args).await,
|
||||
other => panic!("unknown scenario '{other}'"),
|
||||
}
|
||||
});
|
||||
println!("{metrics}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic vectors (the tests/search.rs mock_embedding pattern, local
|
||||
// copy — those fns are private to that test binary)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn fnv1a64(input: &str) -> u64 {
|
||||
let mut hash: u64 = 0xcbf29ce484222325;
|
||||
for byte in input.as_bytes() {
|
||||
hash ^= *byte as u64;
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn xorshift64(state: &mut u64) -> u64 {
|
||||
let mut x = *state;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
*state = x;
|
||||
x
|
||||
}
|
||||
|
||||
/// Unit-norm D-dim vector seeded by (seed, slug). `pole` biases the first
|
||||
/// component: +1.0 clusters vectors near e1, -1.0 near -e1, 0.0 uniform-ish —
|
||||
/// the lever the prefilter scenario uses to place matching rows far from the
|
||||
/// query point.
|
||||
fn seeded_vector(seed: u64, slug: &str, dims: usize, pole: f32) -> Vec<f32> {
|
||||
let mut state = seed ^ fnv1a64(slug);
|
||||
if state == 0 {
|
||||
state = 0x9e3779b97f4a7c15;
|
||||
}
|
||||
let mut v: Vec<f32> = (0..dims)
|
||||
.map(|_| ((xorshift64(&mut state) >> 11) as f32 / (1u64 << 53) as f32) * 2.0 - 1.0)
|
||||
.collect();
|
||||
if pole != 0.0 {
|
||||
// Dominate the direction with the pole while keeping per-row jitter.
|
||||
v[0] = pole * 10.0;
|
||||
}
|
||||
let norm = v.iter().map(|x| (*x as f64) * (*x as f64)).sum::<f64>().sqrt() as f32;
|
||||
if norm > f32::EPSILON {
|
||||
for x in &mut v {
|
||||
*x /= norm;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn push_vector_json(out: &mut String, v: &[f32]) {
|
||||
out.push('[');
|
||||
for (i, x) in v.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
let _ = write!(out, "{x:.8}");
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario: merge-all-changed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The merge-memory scenario: an embedding-bearing table where a branch
|
||||
/// changed EVERY row's vector (the re-embed-the-corpus workflow), merged back
|
||||
/// into main. Measures the changed-delta materialization cost of
|
||||
/// `branch_merge` (exec/merge.rs concat + hash-join path — the part the
|
||||
/// fast-forward streaming fix does not cover).
|
||||
async fn merge_all_changed(args: &Args) -> serde_json::Value {
|
||||
const BATCH_ROWS: usize = 500;
|
||||
let schema = format!(
|
||||
"node Doc {{\n slug: String @key\n embedding: Vector({})\n}}\n",
|
||||
args.dims
|
||||
);
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let uri = dir.path().to_str().unwrap();
|
||||
let db = Omnigraph::init(uri, &schema).await.expect("init");
|
||||
|
||||
// Seed N rows on main in batches (merge-written fragments, matching the
|
||||
// embed workflow's write shape). JSONL strings are per-batch transients.
|
||||
let seed_start = Instant::now();
|
||||
load_vector_rows(&db, "main", args, BATCH_ROWS, args.seed, 0.0).await;
|
||||
let seed_ms = seed_start.elapsed().as_millis() as u64;
|
||||
|
||||
db.branch_create("bench").await.expect("branch_create");
|
||||
|
||||
// Diverge main with one non-conflicting insert so the merge takes the
|
||||
// three-way path (publish_rewritten_merge_table) rather than the
|
||||
// fast-forward adopt; the measured cost is the changed-delta concat +
|
||||
// hash join that path performs.
|
||||
{
|
||||
let mut jsonl = String::new();
|
||||
let slug = "doc-main-diverge";
|
||||
let _ = write!(jsonl, r#"{{"type":"Doc","data":{{"slug":"{slug}","embedding":"#);
|
||||
push_vector_json(&mut jsonl, &seeded_vector(args.seed ^ 0x5eed, slug, args.dims, 0.0));
|
||||
jsonl.push_str("}}
|
||||
");
|
||||
db.load("main", &jsonl, LoadMode::Merge).await.expect("diverge main");
|
||||
}
|
||||
|
||||
// Rewrite every row's vector on the branch (same keys, new seed).
|
||||
let branch_start = Instant::now();
|
||||
load_vector_rows(&db, "bench", args, BATCH_ROWS, args.seed ^ 0xdead_beef, 0.0).await;
|
||||
let branch_load_ms = branch_start.elapsed().as_millis() as u64;
|
||||
|
||||
if args.baseline {
|
||||
// Identical workload minus the measured op — see Args::baseline.
|
||||
return serde_json::json!({
|
||||
"seed_ms": seed_ms,
|
||||
"branch_load_ms": branch_load_ms,
|
||||
"baseline": true,
|
||||
});
|
||||
}
|
||||
|
||||
// The measured window: the merge alone.
|
||||
let merge_start = Instant::now();
|
||||
let outcome = db.branch_merge("bench", "main").await.expect("branch_merge");
|
||||
let merge_ms = merge_start.elapsed().as_millis() as u64;
|
||||
|
||||
serde_json::json!({
|
||||
"seed_ms": seed_ms,
|
||||
"branch_load_ms": branch_load_ms,
|
||||
"merge_ms": merge_ms,
|
||||
"merge_outcome": format!("{outcome:?}"),
|
||||
"raw_delta_bytes": (args.rows * args.dims * 4) as u64,
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_vector_rows(
|
||||
db: &Omnigraph,
|
||||
branch: &str,
|
||||
args: &Args,
|
||||
batch_rows: usize,
|
||||
seed: u64,
|
||||
pole: f32,
|
||||
) {
|
||||
let mut row = 0;
|
||||
while row < args.rows {
|
||||
let end = (row + batch_rows).min(args.rows);
|
||||
let mut jsonl = String::with_capacity(batch_rows * (args.dims * 12 + 64));
|
||||
for i in row..end {
|
||||
let slug = format!("doc-{i:08}");
|
||||
let _ = write!(jsonl, r#"{{"type":"Doc","data":{{"slug":"{slug}","embedding":"#);
|
||||
push_vector_json(&mut jsonl, &seeded_vector(seed, &slug, args.dims, pole));
|
||||
jsonl.push_str("}}\n");
|
||||
}
|
||||
db.load(branch, &jsonl, LoadMode::Merge).await.expect("load batch");
|
||||
row = end;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario: nearest-prefilter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The filtered-ANN scenario: `selectivity` fraction of rows match
|
||||
/// `status = "hit"` but sit FAR from the query vector, while all non-matching
|
||||
/// rows cluster AROUND it — so a post-filtered ANN top-k (the current Lance
|
||||
/// default; no `prefilter(true)` on the scanner) returns ~0 of the k requested
|
||||
/// rows even though `rows * selectivity` matches exist. `rows_returned` is the
|
||||
/// headline metric pre-fix; the same scenario becomes the prefilter latency
|
||||
/// comparison once the fix lands.
|
||||
async fn nearest_prefilter(args: &Args) -> serde_json::Value {
|
||||
const BATCH_ROWS: usize = 1000;
|
||||
const QUERY_ITERS: usize = 20;
|
||||
let schema = format!(
|
||||
"node Doc {{\n slug: String @key\n status: String @index\n embedding: Vector({}) @index\n}}\n",
|
||||
args.dims
|
||||
);
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let uri = dir.path().to_str().unwrap();
|
||||
let db = Omnigraph::init(uri, &schema).await.expect("init");
|
||||
|
||||
// Every ~1/selectivity-th row is a far-from-query "hit"; the rest cluster
|
||||
// near the query point (+e1 pole).
|
||||
let stride = (1.0 / args.selectivity).round().max(1.0) as usize;
|
||||
let seed_start = Instant::now();
|
||||
let mut row = 0;
|
||||
let mut hit_rows = 0usize;
|
||||
while row < args.rows {
|
||||
let end = (row + BATCH_ROWS).min(args.rows);
|
||||
let mut jsonl = String::with_capacity(BATCH_ROWS * (args.dims * 12 + 96));
|
||||
for i in row..end {
|
||||
let slug = format!("doc-{i:08}");
|
||||
let hit = i % stride == 0;
|
||||
if hit {
|
||||
hit_rows += 1;
|
||||
}
|
||||
let (status, pole) = if hit { ("hit", -1.0) } else { ("miss", 1.0) };
|
||||
let _ = write!(
|
||||
jsonl,
|
||||
r#"{{"type":"Doc","data":{{"slug":"{slug}","status":"{status}","embedding":"#
|
||||
);
|
||||
push_vector_json(&mut jsonl, &seeded_vector(args.seed, &slug, args.dims, pole));
|
||||
jsonl.push_str("}}\n");
|
||||
}
|
||||
db.load("main", &jsonl, LoadMode::Merge).await.expect("load batch");
|
||||
row = end;
|
||||
}
|
||||
let seed_ms = seed_start.elapsed().as_millis() as u64;
|
||||
|
||||
// Fold coverage / materialize any deferred index work.
|
||||
let optimize_start = Instant::now();
|
||||
db.optimize().await.expect("optimize");
|
||||
let optimize_ms = optimize_start.elapsed().as_millis() as u64;
|
||||
|
||||
// Query vector = +e1 (the "miss" cluster's pole): the global ANN top-k is
|
||||
// dominated by non-matching rows by construction.
|
||||
let mut query_vec = vec![0.0f32; args.dims];
|
||||
query_vec[0] = 1.0;
|
||||
let query_src = format!(
|
||||
"query filtered_nearest($q: Vector({dims})) {{\n match {{ $d: Doc {{ status: \"hit\" }} }}\n return {{ $d.slug }}\n order {{ nearest($d.embedding, $q) }}\n limit {k}\n}}\n",
|
||||
dims = args.dims,
|
||||
k = args.k
|
||||
);
|
||||
let params = helpers::vector_param("q", &query_vec);
|
||||
|
||||
let mut rows_returned = 0usize;
|
||||
let mut total_ms = 0u64;
|
||||
for i in 0..QUERY_ITERS {
|
||||
let q_start = Instant::now();
|
||||
let result = db
|
||||
.query(ReadTarget::branch("main"), &query_src, "filtered_nearest", ¶ms)
|
||||
.await
|
||||
.expect("filtered nearest query");
|
||||
total_ms += q_start.elapsed().as_millis() as u64;
|
||||
let n: usize = result.batches().iter().map(|b| b.num_rows()).sum();
|
||||
if i == 0 {
|
||||
rows_returned = n;
|
||||
}
|
||||
std::hint::black_box(n);
|
||||
}
|
||||
|
||||
serde_json::json!({
|
||||
"seed_ms": seed_ms,
|
||||
"optimize_ms": optimize_ms,
|
||||
"hit_rows": hit_rows,
|
||||
"k": args.k,
|
||||
"rows_returned": rows_returned,
|
||||
"recall_vs_k": rows_returned as f64 / args.k as f64,
|
||||
"query_iters": QUERY_ITERS,
|
||||
"mean_query_ms": total_ms as f64 / QUERY_ITERS as f64,
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue