Skip to content

Commit bc239a7

Browse files
committed
Updated with benchmarks
1 parent f08edd3 commit bc239a7

3 files changed

Lines changed: 346 additions & 14 deletions

File tree

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,50 @@ Sample local result (`ops=500`, same machine, debug build):
300300
These numbers are workload and hardware dependent, but show the expected pattern:
301301
`batch`/`async` improve write throughput compared with `strict`.
302302

303+
## gRPC Throughput Benchmark (DB-Style)
304+
305+
For p50/p95/p99 and throughput under concurrent load:
306+
307+
```bash
308+
cargo run --bin bench_grpc -- \
309+
--addr 127.0.0.1:50051 \
310+
--namespace bench \
311+
--vector-dim 3 \
312+
--insert-ops 5000 \
313+
--query-ops 5000 \
314+
--concurrency 32 \
315+
--top-k 10
316+
```
317+
318+
Output includes:
319+
320+
- insert/query throughput (ops/s),
321+
- average latency,
322+
- p50/p95/p99/max latency.
323+
324+
For observability during benchmark, scrape:
325+
326+
```bash
327+
curl -s http://127.0.0.1:50052/metrics
328+
```
329+
330+
### Comparison Protocol (Mnemos vs Qdrant vs Pinecone)
331+
332+
To compare fairly, keep identical workload shape across all systems:
333+
334+
1. same embedding model + vector dimension,
335+
2. same dataset and namespace cardinality,
336+
3. same write count / query count / concurrency,
337+
4. same top-k and filter conditions,
338+
5. same warmup period and measurement window.
339+
340+
Use `bench_grpc` numbers from Mnemos as baseline, then run equivalent load for Qdrant/Pinecone and compare:
341+
342+
- ingest ops/s,
343+
- query ops/s,
344+
- p95 and p99 query latency,
345+
- resource usage/cost for same workload.
346+
303347
## Test
304348

305349
Run all tests:

src/bin/bench_grpc.rs

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
use std::sync::Arc;
2+
use std::sync::atomic::{AtomicU64, Ordering};
3+
use std::time::Instant;
4+
5+
use tokio::sync::Mutex;
6+
use tonic::transport::Channel;
7+
8+
pub mod proto {
9+
tonic::include_proto!("mnemos");
10+
}
11+
12+
use proto::mnemos_service_client::MnemosServiceClient;
13+
14+
#[derive(Debug, Clone)]
15+
struct BenchConfig {
16+
addr: String,
17+
namespace: String,
18+
vector_dim: usize,
19+
insert_ops: u64,
20+
query_ops: u64,
21+
concurrency: usize,
22+
top_k: u32,
23+
}
24+
25+
#[derive(Debug, Clone)]
26+
struct PhaseSummary {
27+
name: &'static str,
28+
ops: u64,
29+
concurrency: usize,
30+
elapsed_ms: u128,
31+
throughput_ops_s: f64,
32+
avg_ms: f64,
33+
p50_ms: f64,
34+
p95_ms: f64,
35+
p99_ms: f64,
36+
max_ms: f64,
37+
}
38+
39+
#[tokio::main]
40+
async fn main() -> Result<(), Box<dyn std::error::Error>> {
41+
let cfg = parse_args();
42+
let endpoint = format!("http://{}", cfg.addr);
43+
let channel = Channel::from_shared(endpoint)?.connect().await?;
44+
45+
println!("Running Mnemos gRPC benchmark with config: {:?}", cfg);
46+
47+
let insert = run_insert_phase(&cfg, channel.clone()).await?;
48+
let query = run_query_phase(&cfg, channel).await?;
49+
50+
print_markdown_summary(&cfg, &insert, &query);
51+
Ok(())
52+
}
53+
54+
async fn run_insert_phase(
55+
cfg: &BenchConfig,
56+
channel: Channel,
57+
) -> Result<PhaseSummary, Box<dyn std::error::Error>> {
58+
let counter = Arc::new(AtomicU64::new(0));
59+
let latencies = Arc::new(Mutex::new(Vec::<u128>::with_capacity(
60+
cfg.insert_ops as usize,
61+
)));
62+
let started = Instant::now();
63+
64+
let mut handles = Vec::with_capacity(cfg.concurrency);
65+
for worker in 0..cfg.concurrency {
66+
let counter = Arc::clone(&counter);
67+
let latencies = Arc::clone(&latencies);
68+
let namespace = cfg.namespace.clone();
69+
let dim = cfg.vector_dim;
70+
let ops = cfg.insert_ops;
71+
let mut client = MnemosServiceClient::new(channel.clone());
72+
73+
handles.push(tokio::spawn(async move {
74+
loop {
75+
let i = counter.fetch_add(1, Ordering::Relaxed);
76+
if i >= ops {
77+
break;
78+
}
79+
let memory_id = 10_000_000 + i;
80+
let embedding = make_embedding(memory_id, dim);
81+
let req = proto::InsertMemoryRequest {
82+
memory: Some(proto::Memory {
83+
id: memory_id,
84+
namespace: namespace.clone(),
85+
content: format!("bench-memory-{}-w{}", i, worker).into_bytes(),
86+
embedding,
87+
created_at: 1_700_000_000 + i,
88+
importance: 0.5,
89+
metadata: vec![],
90+
}),
91+
};
92+
93+
let t0 = Instant::now();
94+
client.insert_memory(req).await?;
95+
let elapsed = t0.elapsed().as_micros();
96+
latencies.lock().await.push(elapsed);
97+
}
98+
Ok::<(), tonic::Status>(())
99+
}));
100+
}
101+
102+
for h in handles {
103+
h.await??;
104+
}
105+
let elapsed = started.elapsed();
106+
let lats = latencies.lock().await.clone();
107+
Ok(summarize_phase("insert", cfg.insert_ops, cfg.concurrency, elapsed, lats))
108+
}
109+
110+
async fn run_query_phase(
111+
cfg: &BenchConfig,
112+
channel: Channel,
113+
) -> Result<PhaseSummary, Box<dyn std::error::Error>> {
114+
let counter = Arc::new(AtomicU64::new(0));
115+
let latencies = Arc::new(Mutex::new(Vec::<u128>::with_capacity(
116+
cfg.query_ops as usize,
117+
)));
118+
let started = Instant::now();
119+
120+
let mut handles = Vec::with_capacity(cfg.concurrency);
121+
for _ in 0..cfg.concurrency {
122+
let counter = Arc::clone(&counter);
123+
let latencies = Arc::clone(&latencies);
124+
let namespace = cfg.namespace.clone();
125+
let dim = cfg.vector_dim;
126+
let ops = cfg.query_ops;
127+
let top_k = cfg.top_k;
128+
let mut client = MnemosServiceClient::new(channel.clone());
129+
130+
handles.push(tokio::spawn(async move {
131+
loop {
132+
let i = counter.fetch_add(1, Ordering::Relaxed);
133+
if i >= ops {
134+
break;
135+
}
136+
let query_vec = make_embedding(10_000_000 + (i % 1024), dim);
137+
let req = proto::QueryRequest {
138+
query_embedding: query_vec,
139+
top_k,
140+
namespace: Some(namespace.clone()),
141+
time_start: None,
142+
time_end: None,
143+
graph_hops: None,
144+
candidate_multiplier: 0,
145+
similarity_pct: 0,
146+
importance_pct: 0,
147+
recency_pct: 0,
148+
};
149+
let t0 = Instant::now();
150+
client.query(req).await?;
151+
let elapsed = t0.elapsed().as_micros();
152+
latencies.lock().await.push(elapsed);
153+
}
154+
Ok::<(), tonic::Status>(())
155+
}));
156+
}
157+
158+
for h in handles {
159+
h.await??;
160+
}
161+
let elapsed = started.elapsed();
162+
let lats = latencies.lock().await.clone();
163+
Ok(summarize_phase("query", cfg.query_ops, cfg.concurrency, elapsed, lats))
164+
}
165+
166+
fn summarize_phase(
167+
name: &'static str,
168+
ops: u64,
169+
concurrency: usize,
170+
elapsed: std::time::Duration,
171+
mut lats_us: Vec<u128>,
172+
) -> PhaseSummary {
173+
lats_us.sort_unstable();
174+
let elapsed_ms = elapsed.as_millis();
175+
let throughput_ops_s = if elapsed.as_secs_f64() > 0.0 {
176+
ops as f64 / elapsed.as_secs_f64()
177+
} else {
178+
0.0
179+
};
180+
181+
let avg_us = if lats_us.is_empty() {
182+
0.0
183+
} else {
184+
lats_us.iter().sum::<u128>() as f64 / lats_us.len() as f64
185+
};
186+
let p50 = percentile_us(&lats_us, 50.0);
187+
let p95 = percentile_us(&lats_us, 95.0);
188+
let p99 = percentile_us(&lats_us, 99.0);
189+
let max = lats_us.last().copied().unwrap_or(0) as f64;
190+
191+
PhaseSummary {
192+
name,
193+
ops,
194+
concurrency,
195+
elapsed_ms,
196+
throughput_ops_s,
197+
avg_ms: avg_us / 1000.0,
198+
p50_ms: p50 / 1000.0,
199+
p95_ms: p95 / 1000.0,
200+
p99_ms: p99 / 1000.0,
201+
max_ms: max / 1000.0,
202+
}
203+
}
204+
205+
fn percentile_us(sorted: &[u128], p: f64) -> f64 {
206+
if sorted.is_empty() {
207+
return 0.0;
208+
}
209+
let rank = (p / 100.0) * (sorted.len().saturating_sub(1) as f64);
210+
let lo = rank.floor() as usize;
211+
let hi = rank.ceil() as usize;
212+
if lo == hi {
213+
return sorted[lo] as f64;
214+
}
215+
let w = rank - lo as f64;
216+
(1.0 - w) * (sorted[lo] as f64) + w * (sorted[hi] as f64)
217+
}
218+
219+
fn make_embedding(seed: u64, dim: usize) -> Vec<f32> {
220+
(0..dim)
221+
.map(|i| {
222+
let x = seed.wrapping_mul(6364136223846793005).wrapping_add(i as u64);
223+
((x % 10_000) as f32) / 10_000.0
224+
})
225+
.collect()
226+
}
227+
228+
fn print_markdown_summary(cfg: &BenchConfig, insert: &PhaseSummary, query: &PhaseSummary) {
229+
println!();
230+
println!("Benchmark summary");
231+
println!(
232+
"Workload: namespace={}, dim={}, insert_ops={}, query_ops={}, concurrency={}, top_k={}",
233+
cfg.namespace, cfg.vector_dim, cfg.insert_ops, cfg.query_ops, cfg.concurrency, cfg.top_k
234+
);
235+
println!();
236+
println!("| Phase | Ops | Conc | Total ms | Ops/s | Avg ms | P50 ms | P95 ms | P99 ms | Max ms |");
237+
println!("|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|");
238+
for p in [insert, query] {
239+
println!(
240+
"| {} | {} | {} | {} | {:.2} | {:.3} | {:.3} | {:.3} | {:.3} | {:.3} |",
241+
p.name,
242+
p.ops,
243+
p.concurrency,
244+
p.elapsed_ms,
245+
p.throughput_ops_s,
246+
p.avg_ms,
247+
p.p50_ms,
248+
p.p95_ms,
249+
p.p99_ms,
250+
p.max_ms
251+
);
252+
}
253+
}
254+
255+
fn parse_args() -> BenchConfig {
256+
BenchConfig {
257+
addr: arg("--addr").unwrap_or_else(|| "127.0.0.1:50051".to_string()),
258+
namespace: arg("--namespace").unwrap_or_else(|| "bench".to_string()),
259+
vector_dim: arg("--vector-dim")
260+
.and_then(|v| v.parse().ok())
261+
.unwrap_or(3),
262+
insert_ops: arg("--insert-ops")
263+
.and_then(|v| v.parse().ok())
264+
.unwrap_or(5000),
265+
query_ops: arg("--query-ops")
266+
.and_then(|v| v.parse().ok())
267+
.unwrap_or(5000),
268+
concurrency: arg("--concurrency")
269+
.and_then(|v| v.parse().ok())
270+
.unwrap_or(32),
271+
top_k: arg("--top-k").and_then(|v| v.parse().ok()).unwrap_or(10),
272+
}
273+
}
274+
275+
fn arg(flag: &str) -> Option<String> {
276+
let args = std::env::args().collect::<Vec<_>>();
277+
args.windows(2).find_map(|w| {
278+
if w[0] == flag {
279+
Some(w[1].clone())
280+
} else {
281+
None
282+
}
283+
})
284+
}

src/service/grpc.rs

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -170,38 +170,42 @@ impl ServiceMetrics {
170170

171171
out.push_str("# HELP mnemos_rpc_method_calls_total Calls per method\n");
172172
out.push_str("# TYPE mnemos_rpc_method_calls_total counter\n");
173-
for (method, value) in calls {
173+
for (method, value) in &calls {
174174
out.push_str(&format!(
175175
"mnemos_rpc_method_calls_total{{method=\"{}\"}} {}\n",
176176
method, value
177177
));
178178
}
179179
out.push_str("# HELP mnemos_rpc_method_errors_total Errors per method\n");
180180
out.push_str("# TYPE mnemos_rpc_method_errors_total counter\n");
181-
for (method, value) in errors {
181+
for (method, value) in &errors {
182182
out.push_str(&format!(
183183
"mnemos_rpc_method_errors_total{{method=\"{}\"}} {}\n",
184184
method, value
185185
));
186186
}
187-
out.push_str("# HELP mnemos_rpc_method_latency_ms_sum Summed method latencies in ms\n");
188-
out.push_str("# TYPE mnemos_rpc_method_latency_ms_sum counter\n");
189-
for (method, value) in sums {
190-
out.push_str(&format!(
191-
"mnemos_rpc_method_latency_ms_sum{{method=\"{}\"}} {}\n",
192-
method, value
193-
));
194-
}
195-
out.push_str("# HELP mnemos_rpc_method_latency_bucket Latency bucket counts\n");
196-
out.push_str("# TYPE mnemos_rpc_method_latency_bucket counter\n");
187+
out.push_str("# HELP mnemos_rpc_method_latency_bucket RPC latency histogram buckets (ms)\n");
188+
out.push_str("# TYPE mnemos_rpc_method_latency_bucket histogram\n");
197189
let bounds = ["5", "10", "25", "50", "100", "+Inf"];
198-
for (method, vals) in buckets {
190+
for (method, vals) in &buckets {
191+
let total_calls = calls.get(method).copied().unwrap_or(0);
192+
let mut cumulative = 0usize;
199193
for (i, bound) in bounds.iter().enumerate() {
194+
cumulative += vals[i];
200195
out.push_str(&format!(
201196
"mnemos_rpc_method_latency_bucket{{method=\"{}\",le=\"{}\"}} {}\n",
202-
method, bound, vals[i]
197+
method, bound, cumulative
203198
));
204199
}
200+
out.push_str(&format!(
201+
"mnemos_rpc_method_latency_ms_count{{method=\"{}\"}} {}\n",
202+
method, total_calls
203+
));
204+
out.push_str(&format!(
205+
"mnemos_rpc_method_latency_ms_sum{{method=\"{}\"}} {}\n",
206+
method,
207+
sums.get(method).copied().unwrap_or(0)
208+
));
205209
}
206210

207211
out.push_str("# HELP mnemos_query_vector_candidates_sum Sum of query vector candidates\n");

0 commit comments

Comments
 (0)