Files
helexa/crates/helexa-bench/src/main.rs
rob thijssen f50f5531cf
Some checks are pending
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 31s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m21s
build-prerelease / Build cortex binary (push) Successful in 2m27s
build-prerelease / Build helexa-bench binary (push) Successful in 2m44s
build-prerelease / Test (push) Successful in 4m32s
build-prerelease / Build neuron-ampere (push) Successful in 2m7s
build-prerelease / Build neuron-ada (push) Successful in 2m28s
build-prerelease / Build neuron-blackwell (push) Successful in 2m59s
build-prerelease / Package cortex RPM (push) Successful in 1m20s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m19s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m39s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m39s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m42s
feat(bench): read-only JSON API on bob + bench/ React visualisation app
Part A — helexa-bench read API:
- [api] config (enabled, listen :13132); WAL on the store so API reads
  never block the sweep writer.
- store read methods: summary, series (chronological per-build medians),
  runs (filtered), dimensions, run_count.
- api.rs: axum /api/health|dimensions|summary|series|runs, permissive
  CORS (UI is a separate origin). The `run` daemon binds the API
  alongside the sweep; new `serve` subcommand serves API-only.
- listener plumbing (bench gains a port): data/helexa-bench-firewalld.xml,
  spec install, deploy-bench /api/health probe + firewalld step, sudoers
  firewall-cmd grants, [api] in example + bob.toml.
- 5 API tests + serve smoke.

Part B — bench/ Vite + React-SWC-TS app (router, react-bootstrap,
recharts): Overview (summary table), Trends (decode tok/s & TTFT across
build SHAs), Runs (filterable explorer). Typed API client with
VITE_API_BASE + dev proxy to bob. npm build/typecheck clean. Hosted
separately from the API (per design); .gitignore excludes node_modules/dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:26:55 +03:00

154 lines
5.1 KiB
Rust

//! helexa-bench CLI.
//!
//! - `run` — continuous daemon (systemd default): sweep, sleep, repeat.
//! - `once` — a single sweep, then exit (manual / CI).
//! - `report` — render the SQLite store as a results table.
//!
//! Runs on a single-threaded runtime: the workload is batch-1 sequential
//! (one request at a time, the regime we measure), and it lets the
//! SQLite connection live across awaits without `Sync` gymnastics.
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use helexa_bench::api;
use helexa_bench::config::BenchConfig;
use helexa_bench::report;
use helexa_bench::store::Store;
use helexa_bench::sweep::Sweeper;
use tracing_subscriber::EnvFilter;
#[derive(Parser)]
#[command(name = "helexa-bench")]
#[command(about = "Continuous version-aware benchmark harness for the neuron fleet")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Run sweeps continuously, pausing `sweep_interval_secs` between them.
Run {
#[arg(short, long, default_value = "helexa-bench.toml")]
config: String,
},
/// Run a single sweep over all targets, then exit.
Once {
#[arg(short, long, default_value = "helexa-bench.toml")]
config: String,
},
/// Serve the read-only JSON API only (no sweeping).
Serve {
#[arg(short, long, default_value = "helexa-bench.toml")]
config: String,
},
/// Render recorded results. Uses `--db` if given, else the db_path
/// from `--config`.
Report {
#[arg(short, long, default_value = "helexa-bench.toml")]
config: String,
/// Override the SQLite path (skips reading the config file).
#[arg(long)]
db: Option<String>,
/// Output format.
#[arg(long, default_value = "md")]
format: Format,
},
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum Format {
Md,
Json,
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("building tokio runtime")?;
rt.block_on(run(cli))
}
async fn run(cli: Cli) -> Result<()> {
match cli.command {
Command::Run { config } => {
let cfg = load_config(&config)?;
require_targets(&cfg)?;
// Bind the read API alongside the sweep loop (one bob service
// does both). Its own store connection; WAL keeps the sweep
// writer and the API readers from blocking each other.
if cfg.api.enabled {
let state = api::open_state(&cfg.bench.db_path)?;
let listen = cfg.api.listen.clone();
tokio::spawn(async move {
if let Err(e) = api::serve(&listen, state).await {
tracing::error!(error = %format!("{e:#}"), "bench API server exited");
}
});
}
let sweeper = Sweeper::new(cfg)?;
tracing::info!("helexa-bench started; entering continuous sweep loop");
sweeper.run_forever().await
}
Command::Serve { config } => {
let cfg = load_config(&config)?;
if !cfg.api.enabled {
anyhow::bail!("[api] enabled = false — nothing to serve");
}
let state = api::open_state(&cfg.bench.db_path)?;
tracing::info!("helexa-bench serving API only");
api::serve(&cfg.api.listen, state).await
}
Command::Once { config } => {
let cfg = load_config(&config)?;
require_targets(&cfg)?;
let sweeper = Sweeper::new(cfg)?;
let summary = sweeper.run_once().await?;
tracing::info!(
measured = summary.measured,
skipped = summary.skipped,
failed = summary.failed,
unreachable = summary.targets_unreachable,
"single sweep complete"
);
Ok(())
}
Command::Report { config, db, format } => {
let db_path = match db {
Some(p) => p,
None => load_config(&config)?.bench.db_path,
};
let store = Store::open(&db_path)?;
let rows = store.report_rows()?;
let rendered = match format {
Format::Md => report::render_markdown(&rows),
Format::Json => report::render_json(&rows)?,
};
println!("{rendered}");
Ok(())
}
}
}
fn load_config(path: &str) -> Result<BenchConfig> {
BenchConfig::load(path)
.map_err(|e| anyhow::anyhow!("{e}"))
.with_context(|| format!("loading config {path}"))
}
fn require_targets(cfg: &BenchConfig) -> Result<()> {
if cfg.targets.is_empty() {
anyhow::bail!("no targets configured — add at least one [[targets]] entry");
}
Ok(())
}