Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ per-sample data (CI regression / reproducible runs).
| Slot freshness / lag (slots behind the leading endpoint) | ✅ now | tick-aligned, fair same-moment comparison |
| Transaction landing rate + slots-to-land | ✅ `--features send` | actual on-chain inclusion (not `sendTransaction`-returned-success) |
| Yellowstone gRPC first-seen delta (concurrent two-endpoint race) | ✅ `--features grpc` | the metric that reflects co-located infra; never `blockTime` |
| Per-method matrix (`getAccountInfo`, `getMultipleAccounts`, …) | ⏳ roadmap | |
| Per-method matrix (`getSlot`/`getVersion`/`getLatestBlockhash`/`getAccountInfo`/`getMultipleAccounts`) | ✅ now | `solbench methods`; network-inclusive per-method round-trip |

**Honesty first (it's the whole point):** `getSlot` round-trip is *read latency from the host
running solbench*, dominated by network distance to the client. A globally-CDN'd public RPC will
Expand Down Expand Up @@ -66,6 +66,7 @@ solbench probe # read latency + jitter + slot-lag, one table
solbench probe --samples 50 # more samples for tighter percentiles
solbench probe --interval-ms 150 # open-loop tick cadence (>= typical RTT)
solbench probe --json # raw per-endpoint results as JSON
solbench methods # per-method read-latency matrix (add --json)
solbench serve # live dashboard at http://127.0.0.1:8787
solbench report --region "…" # leaderboard-shaped JSON for a hosted board
solbench demo # measurement pipeline over synthetic data
Expand Down Expand Up @@ -138,7 +139,8 @@ Ordered by how much they close the gap to "what traders actually trade on":
4. **Landing-rate `send`** (on-chain inclusion) — ✅ done (`--features send`).
5. **Yellowstone gRPC first-seen** (concurrent two-endpoint race; never `blockTime`) — ✅ done
(`--features grpc`).
6. **Per-method latency matrix**; HDR histograms; crates.io publish + prebuilt binaries (cargo-dist).
6. **Per-method latency matrix** (`solbench methods`) — ✅ done.
7. **HDR histograms**; crates.io publish + prebuilt binaries (cargo-dist).

## How it's built

Expand Down
73 changes: 72 additions & 1 deletion crates/solbench-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
//! `solbench` CLI.
//!
//! `probe` measures live RPC read-latency + slot-lag (open-loop, tick-aligned);
//! `serve` renders it as a local dashboard; `demo` exercises the measurement core.
//! `methods` measures a per-method read-latency matrix; `serve` renders a local
//! dashboard; `demo` exercises the measurement core.
//! `grpc` (feature `grpc`) races Yellowstone first-seen and `send` (feature `send`)
//! measures transaction landing — the metrics that reflect co-located infra.

mod grpc;
mod methods;
mod probe;
mod report;
mod send;
mod server;

use clap::{Parser, Subcommand};
use methods::{method_specs, probe_methods, CLOCK};
use probe::{endpoints_from_env, probe_all};
use solbench_core::LatencyRecorder;

Expand Down Expand Up @@ -40,6 +43,22 @@ enum Command {
#[arg(long)]
json: bool,
},
/// Per-method read-latency matrix across endpoints (getSlot, getVersion,
/// getLatestBlockhash, getAccountInfo, getMultipleAccounts).
Methods {
/// Samples per method per endpoint.
#[arg(long, default_value_t = 20)]
samples: usize,
/// Milliseconds between sample ticks (open-loop schedule).
#[arg(long, default_value_t = 100)]
interval_ms: u64,
/// getAccountInfo target account (default: Clock sysvar).
#[arg(long)]
account: Option<String>,
/// Emit the matrix as JSON.
#[arg(long)]
json: bool,
},
/// Serve a live latency dashboard on localhost.
Serve {
#[arg(long, default_value_t = 8787)]
Expand Down Expand Up @@ -153,6 +172,58 @@ fn main() {
`solbench grpc` / `solbench send` (or run co-located) for the infra story."
);
}
Command::Methods {
samples,
interval_ms,
account,
json,
} => {
let endpoints = endpoints_from_env();
if !json && endpoints.iter().all(|e| e.label != "rpc edge") {
eprintln!("note: set SOLBENCH_RPCEDGE_URL to include rpc edge in the comparison.");
}
let account = account.unwrap_or_else(|| CLOCK.to_string());
let specs = method_specs(&account);
let reports = probe_methods(&endpoints, &specs, samples, interval_ms);

if json {
println!(
"{}",
serde_json::to_string_pretty(&reports).expect("reports serialize")
);
return;
}

let ms = |ns: u64| format!("{:.2}", ns as f64 / 1e6);
println!(
"{:<20} {:<16} {:<26} {:>8} {:>8} {:>8} {:>8}",
"method", "endpoint", "host", "p50ms", "p99ms", "jitter", "ok"
);
for report in &reports {
for r in &report.results {
let (p50, p99, jitter) = match &r.latency {
Some(l) => (ms(l.p50_ns), ms(l.p99_ns), ms(l.stddev_ns)),
None => ("-".into(), "-".into(), "-".into()),
};
println!(
"{:<20} {:<16} {:<26} {:>8} {:>8} {:>8} {:>8}",
report.method,
r.label,
r.host,
p50,
p99,
jitter,
format!("{}/{}", r.ok, samples),
);
}
}
eprintln!(
"\nper-method latency is a network-inclusive round-trip from THIS host. Compare\n\
endpoints WITHIN a method; read cross-method gaps as relative method cost, not\n\
infra. Methods run in sequence, so a run may span changing network conditions.\n\
getAccountInfo / getMultipleAccounts hit fixed Solana sysvar accounts."
);
}
Command::Serve {
port,
samples,
Expand Down
227 changes: 227 additions & 0 deletions crates/solbench-cli/src/methods.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
//! Per-method read-latency matrix: measure a set of read RPC methods across all
//! endpoints, reusing the fair open-loop sampler from `probe`.
//!
//! Latency here is a network-inclusive round-trip from THIS host. Methods differ
//! in server-side cost, so compare endpoints *within* a method and read
//! cross-method gaps as relative method cost, not infrastructure.

use crate::probe::{redact_host, sample_endpoint, Endpoint};
use serde::Serialize;
use serde_json::json;
use solbench_core::LatencySummary;
use std::thread;
use std::time::{Duration, Instant};

/// Canonical Solana sysvar accounts — present on any cluster, so the account-reading
/// methods stay provider-neutral and reproducible.
pub const CLOCK: &str = "SysvarC1ock11111111111111111111111111111111";
pub const RENT: &str = "SysvarRent111111111111111111111111111111111";
pub const RECENT_BLOCKHASHES: &str = "SysvarRecentB1ockHashes11111111111111111111";

/// A read method to probe: a display name and the exact JSON-RPC request body.
pub struct MethodSpec {
pub name: &'static str,
pub body: String,
}

/// The default matrix. `account` is the `getAccountInfo` target (default: Clock sysvar).
pub fn method_specs(account: &str) -> Vec<MethodSpec> {
let mk = |name: &'static str, v: serde_json::Value| MethodSpec {
name,
body: v.to_string(),
};
vec![
mk(
"getSlot",
json!({"jsonrpc":"2.0","id":1,"method":"getSlot"}),
),
mk(
"getVersion",
json!({"jsonrpc":"2.0","id":1,"method":"getVersion"}),
),
mk(
"getLatestBlockhash",
json!({"jsonrpc":"2.0","id":1,"method":"getLatestBlockhash"}),
),
mk(
"getAccountInfo",
json!({"jsonrpc":"2.0","id":1,"method":"getAccountInfo",
"params":[account, {"encoding":"base64"}]}),
),
mk(
"getMultipleAccounts",
json!({"jsonrpc":"2.0","id":1,"method":"getMultipleAccounts",
"params":[[CLOCK, RENT, RECENT_BLOCKHASHES], {"encoding":"base64"}]}),
),
]
}

/// Success = a JSON-RPC `result` is present and there is no `error`. The numeric
/// payload is unused for these methods, so return 0 on success.
fn methods_extract(v: &serde_json::Value) -> Option<u64> {
if v.get("error").is_some() {
None
} else {
v.get("result").is_some().then_some(0u64)
}
}

#[derive(Serialize)]
pub struct MethodEndpointResult {
pub label: String,
pub host: String,
pub ok: usize,
pub errors: usize,
pub latency: Option<LatencySummary>,
}

#[derive(Serialize)]
pub struct MethodReport {
pub method: String,
pub results: Vec<MethodEndpointResult>,
}

/// Probe each method across every endpoint. Methods run in sequence; within a
/// method, all endpoints share one fixed tick schedule (fair same-moment compare).
pub fn probe_methods(
endpoints: &[Endpoint],
specs: &[MethodSpec],
samples: usize,
interval_ms: u64,
) -> Vec<MethodReport> {
let interval = Duration::from_millis(interval_ms.max(1));
specs
.iter()
.map(|spec| {
let t0 = Instant::now() + Duration::from_millis(50);
let results: Vec<MethodEndpointResult> = thread::scope(|scope| {
let handles: Vec<_> = endpoints
.iter()
.map(|ep| {
scope.spawn(move || {
let out = sample_endpoint(
&ep.url,
&spec.body,
samples,
interval,
t0,
methods_extract,
);
MethodEndpointResult {
label: ep.label.clone(),
host: redact_host(&ep.url),
ok: out.rec.len(),
errors: out.errors,
latency: out.rec.summary(),
}
})
})
.collect();
handles
.into_iter()
.map(|h| h.join().expect("methods probe thread"))
.collect()
});
MethodReport {
method: spec.name.to_string(),
results,
}
})
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
use tiny_http::{Response, Server};

fn serve_canned(body: &'static str, count: usize) -> (String, thread::JoinHandle<()>) {
let server = Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
let url = format!("http://127.0.0.1:{port}/");
let handle = thread::spawn(move || {
for _ in 0..count {
match server.recv() {
Ok(req) => {
let _ = req.respond(Response::from_string(body));
}
Err(_) => break,
}
}
});
(url, handle)
}

#[test]
fn method_specs_are_well_formed() {
let specs = method_specs(CLOCK);
let names: Vec<_> = specs.iter().map(|s| s.name).collect();
assert_eq!(
names,
[
"getSlot",
"getVersion",
"getLatestBlockhash",
"getAccountInfo",
"getMultipleAccounts"
]
);
for s in &specs {
let v: serde_json::Value = serde_json::from_str(&s.body).unwrap();
assert_eq!(v["method"], s.name);
}
let gai: serde_json::Value = serde_json::from_str(
&method_specs("ACCT")
.into_iter()
.find(|s| s.name == "getAccountInfo")
.unwrap()
.body,
)
.unwrap();
assert_eq!(gai["params"][0], "ACCT");
let gma: serde_json::Value = serde_json::from_str(
&specs
.iter()
.find(|s| s.name == "getMultipleAccounts")
.unwrap()
.body,
)
.unwrap();
assert_eq!(gma["params"][0].as_array().unwrap().len(), 3);
}

#[test]
fn methods_extract_distinguishes_success_and_error() {
assert_eq!(methods_extract(&json!({"result":{"x":1}})), Some(0));
assert_eq!(methods_extract(&json!({"result":123})), Some(0));
assert_eq!(
methods_extract(&json!({"error":{"code":-1,"message":"x"}})),
None
);
assert_eq!(methods_extract(&json!({})), None);
}

#[test]
fn probe_methods_reports_latency_per_method() {
let samples = 3;
let (url, handle) =
serve_canned(r#"{"jsonrpc":"2.0","id":1,"result":{"ok":true}}"#, samples);
let endpoints = vec![Endpoint {
label: "local".into(),
url,
}];
let specs = vec![MethodSpec {
name: "getSlot",
body: r#"{"jsonrpc":"2.0","id":1,"method":"getSlot"}"#.to_string(),
}];
let reports = probe_methods(&endpoints, &specs, samples, 10);
assert_eq!(reports.len(), 1);
assert_eq!(reports[0].method, "getSlot");
assert_eq!(reports[0].results.len(), 1);
let r = &reports[0].results[0];
assert_eq!(r.ok, samples);
assert_eq!(r.errors, 0);
assert!(r.latency.is_some());
handle.join().unwrap();
}
}
Loading