Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
folds into the confidence score (CV ≥2% → Medium, ≥10% → Low), implementing the
spec §12 "sample variance" factor. The deterministic recorded backend ignores
`samples` and never fabricates a spread (`ExecutionBackend::is_deterministic`).
- **`cu-profiler bench` (turnkey real-CU).** A declarative bench-plan schema
(`cu_profiler_core::bench::BenchPlan`: instructions, program id, hex data, accounts)
with base58/hex validation, and a `bench` subcommand that validates the plan,
optionally builds the program (`--build` via `cargo build-sbf`), and — with
`--program-name` — measures real compute units by delegating to the Linux
`cu-profiler-bench` executor over `PATH`. The executor links the Solana stack and is
a runtime sibling, not a build dependency, so the main CLI stays Solana-free; when
it is absent, `bench` validates the plan and fails with the exact command to run.

## [0.1.2] - 2026-06-20

Expand Down
26 changes: 26 additions & 0 deletions crates/cu-profiler-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ pub enum Command {
Import(ImportArgs),
/// Post the Markdown report as a sticky pull-request comment.
Comment(CommentArgs),
/// Turnkey real-CU path: validate a bench plan and measure via cu-profiler-bench.
Bench(BenchArgs),
}

/// Inputs shared by `run`, `ci` and `compare`.
Expand Down Expand Up @@ -250,6 +252,30 @@ pub struct CommentArgs {
pub dry_run: bool,
}

/// `cu-profiler bench` — turnkey real-CU path.
///
/// Validates a declarative bench plan and, with `--program-name`, measures real
/// compute units via the Linux `cu-profiler-bench` executor.
#[derive(Debug, Args)]
pub struct BenchArgs {
/// Bench fixture file (`[[instruction]]` declarations with accounts/data).
#[arg(long, default_value = "bench.toml")]
pub fixtures: PathBuf,

/// Program name (the `.so` stem, loaded from `$SBF_OUT_DIR`). With it, `bench`
/// measures via the `cu-profiler-bench` executor; without it, validate only.
#[arg(long)]
pub program_name: Option<String>,

/// Build the program with `cargo build-sbf` before benchmarking.
#[arg(long)]
pub build: bool,

/// Directory to run `cargo build-sbf` in.
#[arg(long, default_value = ".")]
pub manifest_path: PathBuf,
}

/// `cu-profiler init`.
#[derive(Debug, Args)]
pub struct InitArgs {
Expand Down
113 changes: 113 additions & 0 deletions crates/cu-profiler-cli/src/commands/bench.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//! `cu-profiler bench` — turnkey real-CU path.
//!
//! `bench` validates a declarative [`BenchPlan`](cu_profiler_core::bench::BenchPlan),
//! optionally builds the program with `cargo build-sbf`, then **delegates the real
//! Mollusk measurement** to the Linux-only `cu-profiler-bench` executor, found over
//! `PATH` (a runtime sibling, never a build dependency — so the main CLI keeps the
//! Solana/Mollusk stack out and stays Windows-buildable).
//!
//! - With `--program-name`: run the executor and forward its result; if the executor
//! is not installed, fail with the exact command to run (no silent half-measure).
//! - Without `--program-name`: validate the plan and summarise it (a lint/prepare run).

use std::path::Path;
use std::process::Command;

use cu_profiler_core::bench::BenchPlan;
use cu_profiler_core::{Error, Result};

use crate::args::BenchArgs;
use crate::commands::{MAX_LOG_BYTES, read_to_string_capped};
use crate::exit::ExitCode;

/// The Linux-only sibling binary that performs the real Mollusk measurement.
const EXECUTOR: &str = "cu-profiler-bench";

/// Execute the `bench` command.
pub fn run(args: &BenchArgs, quiet: bool) -> Result<ExitCode> {
let text = read_to_string_capped(&args.fixtures, MAX_LOG_BYTES)?;
let plan = BenchPlan::from_toml(&text)?;

if args.build {
build_sbf(&args.manifest_path, quiet)?;
}

// With a program, measure for real via the executor; without one, validate only.
let Some(program_name) = &args.program_name else {
if !quiet {
summarise(&plan);
}
return Ok(ExitCode::Success);
};

match delegate(&args.fixtures, program_name) {
Some(code) => Ok(code),
None => Err(Error::Simulation(format!(
"plan is valid, but the `{EXECUTOR}` executor was not found on PATH, so no compute \
units were measured. It is Linux-only (built from the cu-profiler-mollusk crate, \
which links the Solana stack). Install it, then run:\n \
{EXECUTOR} --fixtures {} --program-name {program_name}",
args.fixtures.display()
))),
}
}

/// Run `cargo build-sbf` in `dir` to compile the program to an `.so`.
fn build_sbf(dir: &Path, quiet: bool) -> Result<()> {
if !quiet {
eprintln!(
"building program with `cargo build-sbf` in {}…",
dir.display()
);
}
let status = Command::new("cargo")
.arg("build-sbf")
.current_dir(dir)
.status()
.map_err(|e| {
Error::Simulation(format!(
"could not run `cargo build-sbf` (is the Solana SBF toolchain installed?): {e}"
))
})?;
if !status.success() {
return Err(Error::Simulation(
"`cargo build-sbf` failed — see its output above".to_string(),
));
}
Ok(())
}

/// Run the `cu-profiler-bench` executor, inheriting its stdout/stderr and returning a
/// mapped exit code — or `None` when the executor is not on `PATH`.
fn delegate(fixtures: &Path, program_name: &str) -> Option<ExitCode> {
let status = Command::new(EXECUTOR)
.arg("--fixtures")
.arg(fixtures)
.arg("--program-name")
.arg(program_name)
.status()
.ok()?;
Some(if status.success() {
ExitCode::Success
} else {
ExitCode::Simulation
})
}

/// Validate-only output: print the parsed plan and how to measure it.
fn summarise(plan: &BenchPlan) {
println!("bench plan OK: {} instruction(s)", plan.instructions.len());
for ix in &plan.instructions {
println!(
" - {} → program {} ({} account(s), {} data byte(s))",
ix.scenario,
ix.program_id,
ix.accounts.len(),
ix.data.len() / 2,
);
}
eprintln!(
"note: plan validated. Pass --program-name and have the `{EXECUTOR}` executor on PATH \
(Linux; from the cu-profiler-mollusk crate) to measure real compute units."
);
}
2 changes: 2 additions & 0 deletions crates/cu-profiler-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//! `cu-profiler-core`, render with `cu-profiler-report`, choose an exit code.

mod baseline;
mod bench;
mod ci;
mod comment;
mod compare;
Expand All @@ -12,6 +13,7 @@ mod inspect;
mod run;

pub use baseline::{approve as baseline_approve, save as baseline_save};
pub use bench::run as bench;
pub use ci::run as ci;
pub use comment::run as comment;
pub use compare::run as compare;
Expand Down
1 change: 1 addition & 0 deletions crates/cu-profiler-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ fn dispatch(cli: &Cli) -> Result<ExitCode> {
Command::Inspect(args) => commands::inspect(args, quiet),
Command::Import(args) => commands::import(args, quiet),
Command::Comment(args) => commands::comment(args, quiet),
Command::Bench(args) => commands::bench(args, quiet),
Command::Baseline(args) => match &args.command {
BaselineCommand::Save(a) => commands::baseline_save(a, quiet),
BaselineCommand::Approve(a) => commands::baseline_approve(a, quiet),
Expand Down
65 changes: 65 additions & 0 deletions crates/cu-profiler-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,71 @@ fn import_file_without_logs_reports_error() {
assert!(!dir.join(".cu/logs/empty.log").exists());
}

#[test]
fn bench_validates_a_plan_and_summarises() {
let dir = scratch_dir("bench-ok");
let fixtures = dir.join("bench.toml");
std::fs::write(
&fixtures,
"[[instruction]]\nscenario=\"swap\"\nprogram_id=\"11111111111111111111111111111111\"\ndata=\"01ab\"\n",
)
.unwrap();

let out = run(&dir, &["bench", "--fixtures", fixtures.to_str().unwrap()]);
assert!(out.status.success(), "bench failed: {out:?}");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("bench plan OK: 1 instruction"),
"summary: {stdout}"
);
assert!(stdout.contains("swap"), "scenario: {stdout}");
}

#[test]
fn bench_rejects_an_invalid_plan() {
let dir = scratch_dir("bench-bad");
let fixtures = dir.join("bench.toml");
// Non-base58 program id must be rejected with a non-zero exit.
std::fs::write(
&fixtures,
"[[instruction]]\nscenario=\"s\"\nprogram_id=\"not-valid-0OIl\"\n",
)
.unwrap();

let out = run(&dir, &["bench", "--fixtures", fixtures.to_str().unwrap()]);
assert!(!out.status.success(), "expected invalid plan to fail");
assert!(String::from_utf8_lossy(&out.stderr).contains("base58"));
}

#[test]
fn bench_with_program_name_errors_clearly_without_executor() {
let dir = scratch_dir("bench-noexec");
let fixtures = dir.join("bench.toml");
std::fs::write(
&fixtures,
"[[instruction]]\nscenario=\"swap\"\nprogram_id=\"11111111111111111111111111111111\"\n",
)
.unwrap();

// Asking to measure (--program-name) when the Linux `cu-profiler-bench` executor
// is not on PATH must fail clearly with the exact command to run — never silently
// pretend to have measured.
let out = run(
&dir,
&[
"bench",
"--fixtures",
fixtures.to_str().unwrap(),
"--program-name",
"demo",
],
);
assert!(!out.status.success(), "should fail without the executor");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("cu-profiler-bench"), "stderr: {stderr}");
assert!(stderr.contains("not found"), "stderr: {stderr}");
}

#[test]
fn comment_dry_run_renders_sticky_body_from_config() {
let dir = scratch_dir("comment-dry");
Expand Down
Loading