diff --git a/.cargo/mutants.linux.toml b/.cargo/mutants.linux.toml
index 444b1b2fb..d069f44f7 100644
--- a/.cargo/mutants.linux.toml
+++ b/.cargo/mutants.linux.toml
@@ -45,6 +45,7 @@ profile = "mutants"
# Only run unit tests and integration tests, not doctests, benchmarks or examples.
# Retain fatal-path tests needed to detect mutations in termination guards;
# skip the remaining intentional-abort tests only during mutation runs.
+# Exact metabench deadlines use FakeClock/FakeProcess; keep real-time stress outside mutation runs.
additional_cargo_test_args = [
"--locked", "--tests", "--",
"--skip", "abort::tests::subprocess_oracle_rejects_normal_return",
@@ -55,9 +56,17 @@ additional_cargo_test_args = [
"--skip", "observation::tests::poisoned_publisher_aborts_instead_of_overwriting_failed_state",
"--skip", "observation::tests::generation_overflow_aborts_instead_of_reusing_a_lease_identity",
"--skip", "thread::tests::poisoned_pool_aborts_before_lending_an_owner",
+ "--skip", "runner::tests::captured_logs_bound_sustained_output_and_timeout_cleanup",
]
exclude_re = [
+ # Windows and unsupported-platform pipe adapters are compiled out on Linux.
+ 'crates/metabench/src/worker_log\.rs:\d+:\d+: (?:.* in (?:read_available_windows|prepare_reader_unsupported|read_available_unsupported)$|replace (?:read_available_windows|prepare_reader_unsupported|read_available_unsupported) ->)',
+ # Windows metadata admission is compiled out on Linux.
+ '\bin reject_windows_reparse_point$',
+ '\breplace reject_windows_reparse_point ->',
+ # O_NOFOLLOW and O_NONBLOCK have disjoint bits, so XOR is equivalent to OR.
+ 'crates/metabench/src/artifact.rs:\d+:\d+: replace \| with \^ in open_regular_file$',
# Shared exclusions copied from mutants.toml.
# There are no API contracts for what these must return because they
# are for human-readable stringification. No purpose mutating them.
diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml
index 2c098d48a..cd99edc30 100644
--- a/.cargo/mutants.toml
+++ b/.cargo/mutants.toml
@@ -34,6 +34,7 @@ profile = "mutants"
# Only run unit tests and integration tests, not doctests, benchmarks or examples.
# Retain fatal-path tests needed to detect mutations in termination guards;
# skip the remaining intentional-abort tests only during mutation runs.
+# Exact metabench deadlines use FakeClock/FakeProcess; keep real-time stress outside mutation runs.
additional_cargo_test_args = [
"--locked", "--tests", "--",
"--skip", "abort::tests::subprocess_oracle_rejects_normal_return",
@@ -44,9 +45,12 @@ additional_cargo_test_args = [
"--skip", "observation::tests::poisoned_publisher_aborts_instead_of_overwriting_failed_state",
"--skip", "observation::tests::generation_overflow_aborts_instead_of_reusing_a_lease_identity",
"--skip", "thread::tests::poisoned_pool_aborts_before_lending_an_owner",
+ "--skip", "runner::tests::captured_logs_bound_sustained_output_and_timeout_cleanup",
]
exclude_re = [
+ # O_NOFOLLOW and O_NONBLOCK have disjoint bits, so XOR is equivalent to OR.
+ 'crates/metabench/src/artifact.rs:\d+:\d+: replace \| with \^ in open_regular_file$',
# There are no API contracts for what these must return because they
# are for human-readable stringification. No purpose mutating them.
"impl Debug",
diff --git a/.cargo/mutants.windows.toml b/.cargo/mutants.windows.toml
index 316fa451f..0dde463b6 100644
--- a/.cargo/mutants.windows.toml
+++ b/.cargo/mutants.windows.toml
@@ -40,6 +40,7 @@ profile = "mutants"
# Only run unit tests and integration tests, not doctests, benchmarks or examples.
# Retain fatal-path tests needed to detect mutations in termination guards;
# skip the remaining intentional-abort tests only during mutation runs.
+# Exact metabench deadlines use FakeClock/FakeProcess; keep real-time stress outside mutation runs.
additional_cargo_test_args = [
"--locked", "--tests", "--",
"--skip", "abort::tests::subprocess_oracle_rejects_normal_return",
@@ -50,9 +51,14 @@ additional_cargo_test_args = [
"--skip", "observation::tests::poisoned_publisher_aborts_instead_of_overwriting_failed_state",
"--skip", "observation::tests::generation_overflow_aborts_instead_of_reusing_a_lease_identity",
"--skip", "thread::tests::poisoned_pool_aborts_before_lending_an_owner",
+ "--skip", "runner::tests::captured_logs_bound_sustained_output_and_timeout_cleanup",
]
exclude_re = [
+ # Unix and unsupported-platform pipe adapters are compiled out on Windows.
+ 'crates/metabench/src/worker_log\.rs:\d+:\d+: (?:.* in (?:prepare_reader_unix|prepare_reader_with|read_available_unix|prepare_reader_unsupported|read_available_unsupported)$|replace (?:prepare_reader_unix|prepare_reader_with|read_available_unix|prepare_reader_unsupported|read_available_unsupported) ->)',
+ # Unix artifact-open flags are compiled out on Windows.
+ 'crates/metabench/src/artifact.rs:\d+:\d+: replace \| with (\^|&) in open_regular_file$',
# Shared exclusions copied from mutants.toml.
# There are no API contracts for what these must return because they
# are for human-readable stringification. No purpose mutating them.
diff --git a/.github/workflows/repository-checks.yml b/.github/workflows/repository-checks.yml
index b8f8ade5f..ce47f688a 100644
--- a/.github/workflows/repository-checks.yml
+++ b/.github/workflows/repository-checks.yml
@@ -103,10 +103,41 @@ jobs:
- name: Test no_std with serde
run: cargo +1.95 test --package internity --no-default-features --features serde --test no_std_api --locked
+ metabench-native-contracts:
+ name: Metabench native contracts (Linux)
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ env:
+ CARGO_BUILD_JOBS: "2"
+ METABENCH_REQUIRE_NATIVE_CONTRACTS: "1"
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: ./.github/actions/anvil-setup
+ with:
+ group: none
+ free-disk-space: "true"
+ - name: Install Rust
+ run: just anvil-toolchain-stable-install
+ - name: Install native prerequisites
+ run: |
+ mkdir -p "$GITHUB_WORKSPACE/target/metabench-contract-scratch"
+ export TMPDIR="$GITHUB_WORKSPACE/target/metabench-contract-scratch"
+ sudo apt-get update --quiet
+ sudo apt-get install --yes --quiet valgrind
+ cargo +1.95 install --locked --version 0.19.4 gungraun-runner
+ gungraun-runner --version
+ valgrind --version
+ - name: Run native listing and smoke contracts
+ run: |
+ mkdir -p "$GITHUB_WORKSPACE/target/metabench-contract-scratch"
+ export TMPDIR="$GITHUB_WORKSPACE/target/metabench-contract-scratch"
+ cargo +1.95 test --package metabench --test spawned_benchmark --locked gungraun_listing_is_forwarded -- --exact --nocapture
+ cargo +1.95 test --package metabench --test spawned_benchmark --locked default_engine_smoke_checks_do_not_publish_measurements -- --exact --nocapture
+
required-repository-checks:
name: Required repository checks
if: always()
- needs: [release-script-tests, simd-no-std-arm, simd-no-std-x86, internity-32-bit-handles, internity-no-std-serde]
+ needs: [release-script-tests, simd-no-std-arm, simd-no-std-x86, internity-32-bit-handles, internity-no-std-serde, metabench-native-contracts]
runs-on: ubuntu-latest
steps:
- name: Verify repository checks
@@ -116,6 +147,7 @@ jobs:
SIMD_NO_STD_X86: ${{ needs.simd-no-std-x86.result }}
INTERNITY_32_BIT_HANDLES: ${{ needs.internity-32-bit-handles.result }}
INTERNITY_NO_STD_SERDE: ${{ needs.internity-no-std-serde.result }}
+ METABENCH_NATIVE_CONTRACTS: ${{ needs.metabench-native-contracts.result }}
run: |
if [[ "$RELEASE_SCRIPT_TESTS" != "success" ]]; then
echo "::error::release-script-tests concluded $RELEASE_SCRIPT_TESTS"
@@ -137,3 +169,7 @@ jobs:
echo "::error::internity-no-std-serde concluded $INTERNITY_NO_STD_SERDE"
exit 1
fi
+ if [[ "$METABENCH_NATIVE_CONTRACTS" != "success" ]]; then
+ echo "::error::metabench-native-contracts concluded $METABENCH_NATIVE_CONTRACTS"
+ exit 1
+ fi
diff --git a/Cargo.lock b/Cargo.lock
index 50e41255a..4eeaf3cea 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2459,20 +2459,6 @@ dependencies = [
"serde",
]
-[[package]]
-name = "gungraun-summary"
-version = "6.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "89f967da1beab0153f3f1e52408a71d3bd0fbea3c9e0bf0431f9a3a3c9e96130"
-dependencies = [
- "either-or-both",
- "gungraun-runner",
- "indexmap",
- "serde",
- "serde_json",
- "thiserror",
-]
-
[[package]]
name = "h2"
version = "0.4.19"
@@ -3517,17 +3503,22 @@ dependencies = [
"bolero",
"command-group",
"criterion",
+ "either-or-both",
"gungraun",
- "gungraun-summary",
+ "gungraun-runner",
"jiff",
+ "libc",
"metabench_macros",
"mutants",
"nix 0.31.3",
+ "ohno",
+ "regex",
"serde",
"serde_json",
"tempfile",
"terminal_size",
"unicode-width 0.2.2",
+ "windows-sys 0.61.2",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index e228c685a..5a9525290 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -124,6 +124,7 @@ deadpool = { version = "0.13.0", default-features = false }
derive_more = { version = "2.0.1", default-features = false }
duct = { version = "1.1.1", default-features = false }
dynosaur = { version = "0.3.0", default-features = false }
+either-or-both = { version = "0.3", default-features = false }
events_once = { version = "0.5.32", default-features = false }
fakeable = { path = "crates/fakeable", default-features = false, version = "0.2.0" }
fakeable_impl = { path = "crates/fakeable_impl", default-features = false, version = "=0.2.0" }
@@ -148,7 +149,7 @@ futures-util = { version = "0.3.31", default-features = false }
getrandom = { version = "0.4.3", default-features = false }
# Keep the library and summary parser aligned with gungraun-runner's schema.
gungraun = { version = "=0.19.4", default-features = false }
-gungraun-summary = { version = "=6.0.0", default-features = false }
+gungraun-runner = { version = "=0.19.4", default-features = false }
h3 = { version = "0.0.8", default-features = false }
h3-quinn = { version = "0.0.10", default-features = false }
hashbrown = { version = "0.17.0", default-features = false }
diff --git a/crates/arty/benches/arty_contention.rs b/crates/arty/benches/arty_contention.rs
index aebb64db0..9ca8c303b 100644
--- a/crates/arty/benches/arty_contention.rs
+++ b/crates/arty/benches/arty_contention.rs
@@ -228,16 +228,16 @@ fn criterion_benchmarks(criterion: &mut Criterion) {
}
#[benchmark(ARTY, "arty_contention/relocated_cache", "Arty")]
-#[bench::concurrency_w1(&mut ArtyCase::new(1), 10)]
-#[bench::concurrency_w8(&mut ArtyCase::new(8), 10)]
-fn arty_workload(case: &mut ArtyCase, concurrency: u64) -> Duration {
+#[bench::concurrency_w1(&ArtyCase::new(1), 10)]
+#[bench::concurrency_w8(&ArtyCase::new(8), 10)]
+fn arty_workload(case: &ArtyCase, concurrency: u64) -> Duration {
case.run(usize::try_from(concurrency).expect("benchmark concurrency fits in usize"))
}
#[benchmark(TOKIO, "arty_contention/relocated_cache", "Tokio")]
-#[bench::concurrency_w1(&mut TokioCase::new(1), 10)]
-#[bench::concurrency_w8(&mut TokioCase::new(8), 10)]
-fn tokio_workload(case: &mut TokioCase, concurrency: u64) -> Duration {
+#[bench::concurrency_w1(&TokioCase::new(1), 10)]
+#[bench::concurrency_w8(&TokioCase::new(8), 10)]
+fn tokio_workload(case: &TokioCase, concurrency: u64) -> Duration {
case.run(usize::try_from(concurrency).expect("benchmark concurrency fits in usize"))
}
diff --git a/crates/http_headers/benches/http_headers_micro.rs b/crates/http_headers/benches/http_headers_micro.rs
index c9af3f563..bd443c3f9 100644
--- a/crates/http_headers/benches/http_headers_micro.rs
+++ b/crates/http_headers/benches/http_headers_micro.rs
@@ -2141,6 +2141,7 @@ fn downstream_agent() -> (HeaderMap, DownstreamAgent) {
(empty_map(), DownstreamAgent(field_value(USER_AGENT_VALUE)))
}
+#[derive(Clone, Copy)]
struct RawUserAgentSource;
impl FieldSource for RawUserAgentSource {
diff --git a/crates/http_headers/docs/PERF.md b/crates/http_headers/docs/PERF.md
index 36f356ac4..250d40ce6 100644
--- a/crates/http_headers/docs/PERF.md
+++ b/crates/http_headers/docs/PERF.md
@@ -5,7 +5,7 @@ is not a promise of current timing on different hardware or dependency
versions; use the repository's metabench targets to measure the current
checkout.
-Each cell reports metabench's median wall-clock time, Callgrind instruction
+Each cell reports the historical Criterion median wall-clock time, Callgrind instruction
count, allocation count, and total allocated bytes for one typed decode and
read. Benchmarks consume the decoded value and force comparable semantic work
when `headers 0.4.1` defers parsing. Setup uses a prebuilt `HeaderMap` outside
@@ -14,6 +14,11 @@ and 60 samples per arm. Each cell is formatted as time, instructions, then
allocation count / allocated bytes. `n/a` means `headers 0.4.1` does not
provide that typed header.
+These measurements have not been rerun for the metabench schema 9 migration.
+Current `scripts/perf_report.rs` output reads Criterion's primary estimate from
+`criterion/time`, not the historical median metric. A median across independent
+runs, where used, is a separate aggregation of per-run estimates.
+
| Header | `headers 0.4.1` | `http_headers` (owned) | `http_headers` (borrowed) |
|---|---:|---:|---:|
| Accept | n/a | 399.0 ns
1,387 instr
1 allocs / 24 B | 343.5 ns
1,055 instr
0 allocs / 0 B |
diff --git a/crates/http_headers/scripts/perf_report.rs b/crates/http_headers/scripts/perf_report.rs
index ca77f2f74..6decf2ba0 100755
--- a/crates/http_headers/scripts/perf_report.rs
+++ b/crates/http_headers/scripts/perf_report.rs
@@ -21,7 +21,7 @@ use std::{env, fs};
use serde::Deserialize;
-const SCHEMA_VERSION: u32 = 8;
+const SCHEMA_VERSION: u32 = 9;
const DEFAULT_ARTIFACT: &str = "target/metabench/http_headers_per_header/report.json";
const DEFAULT_REPORT: &str = "crates/http_headers/docs/PERF.md";
@@ -369,7 +369,7 @@ fn render(report: &Report) -> Result {
.map_err(|error| format!("cannot render report disclaimer: {error}"))?;
writeln!(
output,
- "Each cell reports metabench's median wall-clock time, Callgrind instruction\n\
+ "Each cell reports Criterion's primary wall-clock estimate, Callgrind instruction\n\
count, allocation count, and total allocated bytes for one typed decode and\n\
read. Benchmarks consume the decoded value and force comparable semantic work\n\
when `headers 0.4.1` defers parsing. Setup uses a prebuilt `HeaderMap` outside\n\
@@ -421,16 +421,16 @@ fn required_entry<'a>(
}
fn measurements(entry: &Entry) -> Result {
- let time = metric(entry, "criterion", "median")?;
+ let time = metric(entry, "criterion", "time")?;
if time.unit.as_deref() != Some("ns") {
return Err(format!(
- "`{}` criterion median has unit {}, expected ns",
+ "`{}` criterion primary estimate (`time`) has unit {}, expected ns",
entry.identity,
time.unit.as_deref().unwrap_or("")
));
}
Ok(Measurements {
- time_ns: finite_nonnegative(entry, "criterion/median", float_value(time.value))?,
+ time_ns: finite_nonnegative(entry, "criterion/time", float_value(time.value))?,
instructions: integer_metric(entry, "gungraun.callgrind", "Ir")?,
allocations: integer_metric(entry, "alloc_tracker", "Allocations")?,
allocated_bytes: integer_metric(entry, "alloc_tracker", "Allocated bytes")?,
@@ -503,3 +503,46 @@ fn grouped(value: u64) -> String {
fn bytes(value: u64) -> String {
format!("{} B", grouped(value))
}
+
+#[cfg(test)]
+mod tests {
+ use super::{Report, SCHEMA_VERSION, measurements};
+
+ #[test]
+ fn schema_nine_primary_estimate_preserves_allocation_metrics() {
+ let mut report: Report = serde_json::from_str(
+ r#"{
+ "schema_version": 9,
+ "entries": [{
+ "identity": "example",
+ "results": {
+ "criterion": {"metrics": {"time": {"value": 12.5, "unit": "ns"}}},
+ "gungraun.callgrind": {"metrics": {"Ir": {"value": 123}}},
+ "alloc_tracker": {"metrics": {
+ "Allocations": {"value": 2},
+ "Allocated bytes": {"value": 64}
+ }}
+ }
+ }]
+ }"#,
+ )
+ .unwrap();
+ assert_eq!(report.schema_version, SCHEMA_VERSION);
+ let entry = &mut report.entries[0];
+ let result = measurements(entry).unwrap();
+ assert_eq!(result.time_ns, 12.5);
+ assert_eq!(result.instructions, 123);
+ assert_eq!(result.allocations, 2);
+ assert_eq!(result.allocated_bytes, 64);
+
+ let metrics = &mut entry.results.get_mut("criterion").unwrap().metrics;
+ let time = metrics.remove("time").unwrap();
+ metrics.insert("median".to_string(), time);
+ assert!(
+ measurements(entry)
+ .err()
+ .unwrap()
+ .contains("criterion/time")
+ );
+ }
+}
diff --git a/crates/internity/docs/COMPARISON.md b/crates/internity/docs/COMPARISON.md
index 33c332501..de3204fde 100644
--- a/crates/internity/docs/COMPARISON.md
+++ b/crates/internity/docs/COMPARISON.md
@@ -17,6 +17,12 @@ treated as a distinct row.
> predate cached hashes and reusable shared snapshots; do not treat them as
> measurements of the current implementation.
+The historical timing data has not been rerun for the metabench schema 9
+migration. Current report scripts read Criterion's primary estimates from
+`criterion/time`; those estimates are distinct from these historical medians.
+A median across independent runs, where used, is a separate aggregation of
+per-run estimates.
+
---
## 1. Master comparison table
diff --git a/crates/internity/docs/PERF.md b/crates/internity/docs/PERF.md
index aa50e64bc..7814dc59d 100644
--- a/crates/internity/docs/PERF.md
+++ b/crates/internity/docs/PERF.md
@@ -157,6 +157,12 @@ The established comparison timing figures below are wall-clock medians measured
machine-dependent: the ratios between rows are the durable signal, not the
absolute values.
+These historical timing figures have not been rerun for the metabench schema 9
+migration. Current `scripts/perf_report.rs` output reads Criterion's primary
+estimate from `criterion/time`, not the historical median metric. A median
+across independent runs, where used, is a separate aggregation of per-run
+estimates.
+
This report is a curated set of customer-facing scenarios. The crate also carries
internal Callgrind instruction-count benches in the consolidated metabench target
(`benches/internity.rs`); the matched cases used above are part of that target.
@@ -207,7 +213,7 @@ One table per operation, comparing internity against every other interner measur
## Concurrent scaling
-One table per operation, with a column per thread count. Each cell is the wall-clock median of the whole parallel phase at that thread count, so the total work grows with the thread count and the numbers show how each interner holds up under contention rather than a per-thread speedup. The trailing column is the delta against internity at the highest thread count, which is where the designs separate most; per-thread-count deltas are omitted to keep the tables readable.
+One table per operation, with a column per thread count. Each historical cell is the wall-clock median of the whole parallel phase at that thread count, so the total work grows with the thread count and the numbers show how each interner holds up under contention rather than a per-thread speedup. The trailing column is the delta against internity at the highest thread count, which is where the designs separate most; per-thread-count deltas are omitted to keep the tables readable.
### `insert` — concurrent
diff --git a/crates/internity/scripts/perf_report.rs b/crates/internity/scripts/perf_report.rs
index 5cacd2199..17168e8ee 100755
--- a/crates/internity/scripts/perf_report.rs
+++ b/crates/internity/scripts/perf_report.rs
@@ -24,14 +24,14 @@ serde_json = "1"
//! live-heap measurement remains the dedicated `internity_mem` target because it
//! reports steady-state retained bytes, not a metabench engine metric.
-use std::collections::{BTreeMap, btree_map::Entry};
-use std::env;
+use std::collections::BTreeMap;
+use std::collections::btree_map::Entry;
use std::error::Error;
use std::fmt::Write as _;
-use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
+use std::{env, fs};
use clap::Parser;
use serde::Deserialize;
@@ -129,10 +129,7 @@ const THREAD_COUNTS: &[&str] = &["1", "2", "4", "8"];
/// Rows that have no repeatable single-threaded criterion timing in this suite
/// because they are backed by a process-global, persistent table.
-const CRITERION_OMITTED: &[&str] = &[
- "internity_compare/insert/ustr",
- "internity_compare/insert/string_cache",
-];
+const CRITERION_OMITTED: &[&str] = &["internity_compare/insert/ustr", "internity_compare/insert/string_cache"];
#[derive(Deserialize)]
struct JsonReport {
@@ -190,7 +187,7 @@ fn index_report(report: JsonReport) -> Result {
}
fn criterion_time_ns(report: &ReportIndex, identity: &str) -> Option {
- let metric = report.get(identity)?.results.get("criterion")?.metrics.get("median")?;
+ let metric = report.get(identity)?.results.get("criterion")?.metrics.get("time")?;
let value = metric.value.as_f64();
match metric.unit.as_deref()? {
"ps" => Some(value * 1e-3),
@@ -206,6 +203,40 @@ fn lookup_time(report: &ReportIndex, key: &str) -> Option {
criterion_time_ns(report, key)
}
+#[cfg(test)]
+mod tests {
+ use super::{JsonReport, criterion_time_ns, index_report};
+
+ #[test]
+ fn schema_nine_primary_estimate_and_units() {
+ for (value, unit, expected) in [
+ (serde_json::json!(1250), "ps", 1.25),
+ (serde_json::json!(1.25), "ns", 1.25),
+ (serde_json::json!(1.25), "µs", 1250.0),
+ (serde_json::json!(1.25), "us", 1250.0),
+ (serde_json::json!(1.25), "ms", 1_250_000.0),
+ (serde_json::json!(1.25), "s", 1_250_000_000.0),
+ ] {
+ let fixture = serde_json::json!({
+ "schema_version": 9,
+ "entries": [{
+ "identity": "example",
+ "results": {"criterion": {"metrics": {
+ "time": {"value": value, "unit": unit}
+ }}}
+ }]
+ });
+ let report: JsonReport = serde_json::from_value(fixture).unwrap();
+ let mut report = index_report(report).unwrap();
+ assert_eq!(criterion_time_ns(&report, "example"), Some(expected));
+ let metrics = &mut report.get_mut("example").unwrap().results.get_mut("criterion").unwrap().metrics;
+ let time = metrics.remove("time").unwrap();
+ metrics.insert("median".to_string(), time);
+ assert_eq!(criterion_time_ns(&report, "example"), None);
+ }
+ }
+}
+
fn validate_criterion(report: &ReportIndex) -> Result<(), BoxErr> {
let mut missing = Vec::new();
for (_, group, rows) in SINGLE_GROUPS {
@@ -262,7 +293,7 @@ fn header() -> String {
refresh these numbers.\n\n",
);
out.push_str(
- "All timing figures are wall-clock medians measured by Criterion. They are\n\
+ "All timing figures are primary wall-clock estimates measured by Criterion. They are\n\
machine-dependent: the ratios between rows are the durable signal, not the\n\
absolute values.\n\n",
);
@@ -331,14 +362,12 @@ fn render_single(out: &mut String, report: &ReportIndex) {
/// Render each concurrent operation as a single table: one row per interner, one
/// column per thread count, and a trailing delta at the highest thread count.
fn render_concurrent(out: &mut String, report: &ReportIndex) {
- let top = THREAD_COUNTS
- .last()
- .expect("THREAD_COUNTS is a non-empty compile-time constant");
+ let top = THREAD_COUNTS.last().expect("THREAD_COUNTS is a non-empty compile-time constant");
out.push_str("## Concurrent scaling\n\n");
out.push_str(
"One table per operation, with a column per thread count. Each cell is the \
- wall-clock median of the whole parallel phase at that thread count, so the total \
+ primary wall-clock estimate of the whole parallel phase at that thread count, so the total \
work grows with the thread count and the numbers show how each interner holds up \
under contention rather than a per-thread speedup. The trailing column is the \
delta against internity at the highest thread count, which is where the designs \
@@ -366,11 +395,7 @@ fn render_concurrent(out: &mut String, report: &ReportIndex) {
let _ = write!(out, " {} |", fmt_ns(time));
}
let top_time = lookup_time(report, &format!("{group}/{row}/{top}"));
- let _ = writeln!(
- out,
- " {} |",
- fmt_delta(top_time, reference, *row == "internity")
- );
+ let _ = writeln!(out, " {} |", fmt_delta(top_time, reference, *row == "internity"));
}
out.push('\n');
}
@@ -450,14 +475,7 @@ fn run_bench(cwd: &Path, bench: &str, extra: &[&str], label: &str) -> Result Result<(), BoxErr> {
+fn run_metabench(cwd: &Path, json_path: &Path, markdown_path: &Path, samples: &str, measurement: &str, warmup: &str) -> Result<(), BoxErr> {
println!("==> Running criterion metabench suite");
let mut cmd = Command::new(env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
cmd.current_dir(cwd).arg("bench").arg("--bench").arg("internity").arg("--");
@@ -506,8 +524,7 @@ fn run(args: &Args) -> Result<(), BoxErr> {
let warmup = args.warm_up_time.unwrap_or(1).to_string();
let metabench_dir = crate_dir.join("target").join("metabench").join("internity");
- fs::create_dir_all(&metabench_dir)
- .map_err(|e| format!("creating {}: {e}", metabench_dir.display()))?;
+ fs::create_dir_all(&metabench_dir).map_err(|e| format!("creating {}: {e}", metabench_dir.display()))?;
let json_path = metabench_dir.join("report.json");
let markdown_path = metabench_dir.join("report.md");
@@ -530,11 +547,7 @@ fn run(args: &Args) -> Result<(), BoxErr> {
let out_path = docs_dir.join("PERF.md");
fs::write(&out_path, &report).map_err(|e| format!("writing {}: {e}", out_path.display()))?;
- println!(
- "Wrote {} ({} metabench identities)",
- out_path.display(),
- entry_count,
- );
+ println!("Wrote {} ({} metabench identities)", out_path.display(), entry_count,);
println!("==> Done. Report written to docs/PERF.md");
Ok(())
}
diff --git a/crates/metabench/Cargo.toml b/crates/metabench/Cargo.toml
index 2279618f3..534b5544a 100644
--- a/crates/metabench/Cargo.toml
+++ b/crates/metabench/Cargo.toml
@@ -24,12 +24,7 @@ exclude = true
min-lines-percent = 1
[package.metadata.cargo_check_external_types]
-allowed_external_types = [
- "alloc_tracker::allocator::Allocator",
- "criterion",
- "gungraun",
- "metabench_macros::*",
-]
+allowed_external_types = ["criterion", "gungraun", "metabench_macros::*"]
[package.metadata.docs.rs]
all-features = true
@@ -41,10 +36,14 @@ bench = false
alloc_tracker = { workspace = true }
command-group = { workspace = true }
criterion = { workspace = true }
+either-or-both = { workspace = true }
gungraun = { workspace = true, features = ["default"] }
-gungraun-summary = { workspace = true }
+# Parse summaries with the matching runner, avoiding a separately resolved wrapper.
+gungraun-runner = { workspace = true, features = ["summary"] }
jiff = { workspace = true, features = ["std"] }
metabench_macros = { workspace = true }
+ohno = { workspace = true }
+regex = { workspace = true, features = ["std", "perf", "unicode"] }
serde = { workspace = true, features = ["derive", "std"] }
serde_json = { workspace = true, features = ["std"] }
tempfile = { workspace = true, features = ["getrandom"] }
@@ -54,8 +53,16 @@ unicode-width = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
nix = { workspace = true, features = ["fs", "poll"] }
+[target.'cfg(unix)'.dependencies]
+libc = { workspace = true }
+
+[target.'cfg(windows)'.dependencies]
+windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_Pipes"] }
+
[dev-dependencies]
bolero = { workspace = true, features = ["std"] }
+
+[target.'cfg(not(target_os = "linux"))'.dev-dependencies]
mutants = { workspace = true }
# >>> anvil-managed: anvil-lints
diff --git a/crates/metabench/README.md b/crates/metabench/README.md
index 9e272b5e2..ffe484316 100644
--- a/crates/metabench/README.md
+++ b/crates/metabench/README.md
@@ -83,6 +83,11 @@ markdown reports in your `target` directory.
## Measurement engines
+A **native case** is a case supplied by a benchmark’s Gungraun declarations,
+including its implicit single case when no case attribute is present.
+**Probes** are the separate allocation, perf, and `VTune` workers that execute
+these cases; they do not use Criterion registrations.
+
As mentioned, metabench supports five distinct benchmark tools: Criterion, Gungraun, allocation
tracking, Linux `perf`, and Intel `VTune`:
@@ -94,15 +99,19 @@ tracking, Linux `perf`, and Intel `VTune`:
enabled, system calls, and heap activity.
* **Allocation tracking** reports allocation count and allocated bytes for
- one invocation of every discovered Criterion case.
+ one invocation of every declared native case. Counts
+ are process-wide, so concurrent threads can contribute allocations.
* **Linux `perf`** reports supported hardware and software counters for one
- invocation of every discovered Criterion case. Counters cover only the
- annotated workload. Use repeated `--perf-arg` options for additional
+ invocation of every declared native case. Collection starts and stops in
+ a private caller around the workload, excluding input preparation and
+ returned-value teardown. The collection window can include caller and
+ control overhead; it is not a claim of exact function-only counters.
+ Use repeated `--perf-arg` options for additional
`perf stat` arguments.
* **Intel `VTune`** reports hardware event counts for one invocation of every
- discovered Criterion case, using the same annotated-workload
+ declared native case, using the same private caller
measurement as Linux `perf`. Because `VTune`’s hardware-event collection
is a statistical sampler rather than an exact counter, reported counts
are sampling estimates that can read zero for a short workload, and the
@@ -123,9 +132,6 @@ cargo bench --bench parser -- --criterion
Some limitations around engines:
-* Benchmark functions declared with `const fn` can be measured by Criterion and Gungraun, but not
- by allocation tracking, `perf`, or `VTune`.
-
* Gungraun and Linux `perf` are only available on Linux platforms. `VTune` is
available on Linux and Windows, provided a `vtune` installation is
reachable on `PATH`. `VTune`’s integration test exercises the control
@@ -133,6 +139,34 @@ Some limitations around engines:
`vtune` fixture (see `tests/spawned_benchmark.rs`), rather than a real
`VTune` install.
+## Workload boundaries
+
+The attribute preserves a non-inlined workload symbol without injecting
+runtime collector checks into the function. Criterion callbacks call it
+directly, and Gungraun uses its normal adapter. Neither executes the private
+allocation, perf, or `VTune` collection controls.
+
+Allocation and hardware workers reuse the existing `#[bench::...]` and
+`#[benches::...]` declarations through separate generated adapters. Native
+suite and group hooks run outside collection. Case argument evaluation and
+setup precede collection; teardown and returned-value destruction follow it.
+Calls to annotated functions during setup cannot consume the measurement.
+Collection guards stop on unwinding.
+
+No new registration language or public measurement helper is required. Probes
+do not run Criterion registration or infer its arbitrary callbacks. A
+Criterion-only case without a corresponding native declaration has no probe
+result. Matching case names do not prove matching Criterion inputs or cleanup;
+those remain author responsibilities.
+
+Probe execution reuses native case arguments and lifecycle hooks, not
+Valgrind configuration such as sandboxes or environment overrides. Configure
+hardware tools with their own forwarded arguments.
+
+Input destruction inside the benchmark function is included; return owned
+inputs with the output if their destruction should happen outside collection.
+Functions declared with `const fn` can also have runtime probes.
+
## Configure Criterion
Provide a Criterion factory to customize measurements or other Criterion
@@ -152,6 +186,9 @@ metabench::main!(
## Configure Gungraun
+The installed `gungraun-runner` must match the Gungraun library version
+(currently 0.19.4).
+
Benchmark-level `gungraun_config`, `gungraun_setup`, and
`gungraun_teardown` options work with native `#[bench::...]` and
`#[benches::...]` cases:
@@ -214,12 +251,51 @@ under `target/metabench//`. Reports include benchmark
metadata, environment information, measured engines, and their metrics.
JSON retains every available metric and includes human-readable display
names.
+Criterion’s `Time` is its slope estimate when available, otherwise its mean,
+with the statistic and matching confidence level and bounds retained in JSON
+and Markdown. Custom measurement units remain unchanged. `Input bytes` denotes
+the declared work size, not allocated memory or a throughput rate.
+Confidence levels must be strictly between zero and one, with finite, ordered
+bounds. Bootstrap intervals need not contain the point estimate. Markdown
+encodes report text as literal content, and terminal cells visibly escape
+controls and untrusted line breaks; JSON preserves the original strings.
When the JSON output path already exists, it is used as the baseline before
being replaced. `--baseline` selects another report, while `--no-baseline`
-disables comparison. Metrics with compatible names, units, and directions
+disables comparison. Report schema 9 distinguishes these estimates from old
+median-only reports. Old automatic baselines are warned about and skipped;
+an explicitly selected unsupported schema is an error. Different target,
+compiler, CPU, profile, or compiler flags also warn and skip comparison.
+Compiler identity is captured when the executable is built, not from the
+runtime `RUSTC` override or the compiler currently on `PATH`.
+These checks do not prove identical hardware, features, inputs, or boundaries.
+Metrics with compatible names, units, directions, and timing statistics
are classified as improved, stable, or regressed. The default regression
threshold is five percent; change it with `--regression-threshold`.
+These percentage labels are not statistical significance or equivalence tests.
+
+Smoke runs (`--test`, including a forwarded native `--test`) run Criterion or
+Gungraun cases briefly without collecting probe metrics or publishing reports.
+Allocation, perf, and `VTune` smoke selections fall back to Criterion registration,
+which must be provided even when the target declares native probe cases.
+An invocation
+without arguments is also smoke mode, matching Cargo’s harness-free test
+invocation; pass an engine selector to request measurements directly.
+A measurement selection matching no cases fails and preserves existing reports.
+Failed multi-engine runs can write separate `.partial` reports but never
+replace the complete rolling baseline.
+
+Default native artifacts and combined reports follow the Cargo output root
+of the running executable, including `--target-dir`, target triples, and recognized
+custom profiles. `CARGO_TARGET_DIR` identifies a target root whose name matches
+the target triple without overriding a different executable root selected by Cargo.
+A relocated executable in an unknown layout needs an explicit
+`CARGO_TARGET_DIR`; metabench does not guess a workspace root.
+Hidden worker stdout and stderr share a pipe. Each run log retains at most
+1 MiB of raw output, keeping the tail with an explicit truncation marker.
+Failures and timeouts show the final 8 KiB and the log path. Capture is bounded
+while workers run, and inherited pipe writers cannot delay cleanup indefinitely.
+`--show-engine-output` instead inherits the terminal without capturing a log.
## Command-line options
@@ -258,12 +334,33 @@ selected. The `BENCH_ENGINE` environment variable may select `criterion`,
`gungraun`, `perf`, `vtune`, or `allocations` when no engine selector is present.
`CRITERION_HOME` and `GUNGRAUN_HOME` select the corresponding engine data
directories.
+Filters remain engine-specific: use Criterion arguments for Criterion and
+probes, and Gungraun arguments for Gungraun. Probes apply the Criterion name
+filter (and `--exact`) to declared native case identities, then run a private
+indexed worker. Criterion timing, profiling, and baseline options do not
+affect probe execution. A common cross-engine filter is not supported.
+Native Gungraun symbols include readable registration-identity suffixes and
+collision-safe hashes; run manifests retain native-to-canonical mappings.
+Renamed native symbols do not automatically reconnect older native histories.
## Custom memory allocator
The system allocator
is used by default; `main!(..., allocator = MyAllocator)` selects another
underlying allocator.
+Put `allocator` after `benchmarks` or `groups` in [`main!`][__link3]. The executable
+may define only one global allocator. Any existing `alloc_tracker`
+instrumentation must resolve to the same Cargo package source as the metabench
+tracker; a registry dependency and a separate path dependency have independent
+tracking state even if their names and versions match.
+The generated allocator checks one process-wide atomic flag on allocation,
+reallocation, and deallocation. Tracking is enabled only in allocation workers; Criterion,
+Gungraun, perf, and `VTune` bypass the tracking counters and thread-local state.
+The flag check still adds overhead to allocating workloads; it is not a
+zero-overhead claim.
+Both paths use the same underlying allocator instance, including custom
+allocators. Allocation workers track process-wide totals, with snapshots
+excluding setup and teardown from reported counts.
@@ -274,3 +371,4 @@ This crate was developed as part of u64 {
+ black_box(value).wrapping_add(1)
+}
+
+#[inline(never)]
+fn plain(value: u64) -> u64 {
+ black_box(value).wrapping_add(1)
+}
+
+#[library_benchmark]
+fn annotated_control() -> u64 {
+ black_box(annotated(black_box(42)))
+}
+
+#[library_benchmark]
+fn plain_control() -> u64 {
+ black_box(plain(black_box(42)))
+}
+
+library_benchmark_group!(
+ name = controls;
+ benchmarks = annotated_control, plain_control
+);
+main!(library_benchmark_groups = controls);
diff --git a/crates/metabench/examples/measurement_boundaries.rs b/crates/metabench/examples/measurement_boundaries.rs
new file mode 100644
index 000000000..ada03fdc4
--- /dev/null
+++ b/crates/metabench/examples/measurement_boundaries.rs
@@ -0,0 +1,98 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Prepared cases with unmeasured calls to annotated functions during setup.
+
+use std::hint::black_box;
+
+use criterion::{BatchSize, BenchmarkId, Criterion};
+
+// Allocating destruction must follow collection; return this value to defer it.
+struct Output(Vec);
+
+impl Drop for Output {
+ fn drop(&mut self) {
+ black_box(self.0.len());
+ drop(black_box(vec![42; 64]));
+ }
+}
+
+// Preparation and allocating destruction stay outside collection by returning ownership.
+struct Input {
+ size: usize,
+ prepared: Vec,
+}
+
+impl Drop for Input {
+ fn drop(&mut self) {
+ black_box(self.prepared.len());
+ drop(black_box(vec![42; 64]));
+ }
+}
+
+fn input(size: usize) -> Input {
+ Input {
+ size,
+ prepared: vec![42; 11],
+ }
+}
+
+fn prepare(size: usize) -> Input {
+ if std::env::var_os("METABENCH_TEST_TIMEOUT").is_some() {
+ eprintln!("selected workload is waiting");
+ std::thread::sleep(std::time::Duration::from_mins(1));
+ }
+ assert!(std::env::var_os("METABENCH_TEST_FAIL_SETUP").is_none(), "native setup failed");
+ drop(other());
+ drop(allocate(input(5)));
+ drop(allocate(input(size)));
+ input(size)
+}
+
+#[metabench::benchmark(ALLOCATE, "boundaries", "allocate", gungraun_setup = prepare)]
+#[bench::small(32)]
+#[bench::large(128)]
+fn allocate(input: Input) -> (Input, Output) {
+ let output = Output(black_box(vec![42; black_box(input.size)]));
+ (input, output)
+}
+
+#[metabench::benchmark(OTHER, "boundaries", "other")]
+fn other() -> Vec {
+ black_box(vec![42; 7])
+}
+
+#[metabench::benchmark(CONST, "boundaries", "const")]
+const fn constant() -> u64 {
+ 42
+}
+
+fn criterion_benchmarks(criterion: &mut Criterion) {
+ assert!(
+ std::env::var_os("METABENCH_TEST_NO_CRITERION").is_none(),
+ "Criterion must not run for native probes"
+ );
+ if std::env::var_os("METABENCH_TEST_REQUIRE_UNTRACKED").is_some() {
+ let session = alloc_tracker::Session::new().no_stdout().no_file();
+ let span = session.operation("untracked").measure_process().iterations(1);
+ drop(black_box(vec![42; 128]));
+ drop(span);
+ let report = session.to_report();
+ for (_, operation) in report.operations() {
+ assert_eq!(operation.total_allocations_count(), 0, "Criterion must bypass allocation tracking");
+ assert_eq!(operation.total_bytes_allocated(), 0, "Criterion must bypass allocation tracking");
+ }
+ }
+ drop(other());
+ drop(allocate(input(3)));
+ let mut group = criterion.benchmark_group(ALLOCATE.group_name());
+ for (case, size) in [("small", 32), ("large", 128)] {
+ group.bench_function(BenchmarkId::new(ALLOCATE.benchmark_name(), case), |bencher| {
+ bencher.iter_batched(|| prepare(size), allocate, BatchSize::SmallInput);
+ });
+ }
+ group.bench_function(CONST.benchmark_name(), |bencher| bencher.iter(constant));
+ group.finish();
+}
+
+metabench::main!(criterion = criterion_benchmarks, benchmarks = [ALLOCATE, OTHER, CONST],);
diff --git a/crates/metabench/examples/native_probe_contracts.rs b/crates/metabench/examples/native_probe_contracts.rs
new file mode 100644
index 000000000..560d15594
--- /dev/null
+++ b/crates/metabench/examples/native_probe_contracts.rs
@@ -0,0 +1,113 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Native iterator cases and lifecycle hooks without Criterion registration.
+
+use std::hint::black_box;
+use std::io::Write as _;
+use std::sync::OnceLock;
+
+static TEST_OPTIONS: OnceLock = OnceLock::new();
+
+struct TestOptions {
+ require_untracked: bool,
+ ordering: bool,
+ panic_workload: bool,
+}
+
+fn event(value: &str) {
+ if let Some(path) = std::env::var_os("METABENCH_TEST_LIFECYCLE") {
+ let mut file = std::fs::OpenOptions::new()
+ .append(true)
+ .create(true)
+ .open(path)
+ .expect("open lifecycle log");
+ writeln!(file, "{value}").expect("write lifecycle log");
+ }
+}
+
+fn suite_setup() {
+ TEST_OPTIONS.get_or_init(|| TestOptions {
+ require_untracked: std::env::var_os("METABENCH_TEST_REQUIRE_UNTRACKED").is_some(),
+ ordering: std::env::var_os("METABENCH_TEST_ORDERING").is_some(),
+ panic_workload: std::env::var_os("METABENCH_TEST_PANIC_WORKLOAD").is_some(),
+ });
+ println!("native suite setup");
+ event("suite setup");
+}
+fn suite_teardown() {
+ event("suite teardown");
+}
+fn group_setup() {
+ event("group setup");
+}
+fn group_teardown() {
+ event("group teardown");
+}
+
+fn prepare(size: usize) -> Vec {
+ event("case setup");
+ black_box(vec![1; size])
+}
+
+fn cleanup(output: (Vec, Vec)) {
+ event("case teardown");
+ assert_eq!(output.0.len(), output.1.len());
+ drop(output);
+ drop(black_box(vec![1; 256]));
+}
+
+#[metabench::benchmark(ITER, "contracts", "iterator", gungraun_setup = prepare, gungraun_teardown = cleanup)]
+#[benches::sizes(iter = [8, 16])]
+#[cfg_attr(all(), bench::conditional(args = (24), setup = prepare, teardown = cleanup))]
+#[cfg_attr(any(), bench::disabled(32))]
+fn allocate(input: Vec) -> (Vec, Vec) {
+ // Windows environment lookups allocate even when the variable is absent.
+ let options = TEST_OPTIONS
+ .get()
+ .expect("suite setup initializes test options before workloads run");
+ if options.require_untracked {
+ let session = alloc_tracker::Session::new().no_stdout().no_file();
+ let span = session.operation("untracked").measure_process().iterations(1);
+ drop(black_box(vec![42; 128]));
+ drop(span);
+ for (_, operation) in session.to_report().operations() {
+ assert_eq!(
+ operation.total_allocations_count(),
+ 0,
+ "hardware workers must bypass allocation tracking"
+ );
+ assert_eq!(
+ operation.total_bytes_allocated(),
+ 0,
+ "hardware workers must bypass allocation tracking"
+ );
+ }
+ }
+ if options.ordering {
+ event("workload entry");
+ assert!(!options.panic_workload, "native workload panic");
+ }
+ let output = black_box(vec![42; black_box(input.len())]);
+ if options.ordering {
+ event("workload exit");
+ }
+ (input, output)
+}
+
+#[expect(clippy::panic, reason = "process fixture fails if a probe executes Criterion registration")]
+fn criterion_benchmarks(_: &mut criterion::Criterion) {
+ panic!("native probes must not execute Criterion registration");
+}
+
+metabench::main!(
+ criterion = criterion_benchmarks,
+ gungraun = { setup = suite_setup(); teardown = suite_teardown(); },
+ groups = {
+ CONTRACTS {
+ benchmarks = [ITER],
+ gungraun_setup = group_setup(),
+ gungraun_teardown = group_teardown(),
+ },
+ },
+);
diff --git a/crates/metabench/examples/threaded_allocations.rs b/crates/metabench/examples/threaded_allocations.rs
new file mode 100644
index 000000000..5c731ad76
--- /dev/null
+++ b/crates/metabench/examples/threaded_allocations.rs
@@ -0,0 +1,72 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Deterministic process-wide allocation accounting with prestarted threads.
+
+#![expect(clippy::unwrap_used, reason = "process fixture failures provide sufficient context")]
+
+use std::hint::black_box;
+use std::sync::{Arc, Barrier};
+use std::thread::{self, JoinHandle};
+
+// Return ownership so final thread allocations and joins happen after collection.
+struct Workers {
+ start: Arc,
+ complete: Arc,
+ finish: Arc,
+ threads: [Option>; 2],
+}
+
+impl Drop for Workers {
+ fn drop(&mut self) {
+ self.finish.wait();
+ for thread in &mut self.threads {
+ thread.take().unwrap().join().unwrap();
+ }
+ }
+}
+
+fn prepare() -> Workers {
+ let ready = Arc::new(Barrier::new(3));
+ let start = Arc::new(Barrier::new(3));
+ let complete = Arc::new(Barrier::new(3));
+ let finish = Arc::new(Barrier::new(3));
+ let threads = [37, 83].map(|size| {
+ let ready = Arc::clone(&ready);
+ let start = Arc::clone(&start);
+ let complete = Arc::clone(&complete);
+ let finish = Arc::clone(&finish);
+ Some(thread::spawn(move || {
+ // Initialize each thread's tracker and finish startup before collection.
+ drop(black_box(vec![1_u8; 2048]));
+ ready.wait();
+ start.wait();
+ drop(black_box(vec![2_u8; black_box(size)]));
+ drop(black_box(vec![3_u8; black_box(19)]));
+ complete.wait();
+ finish.wait();
+ drop(black_box(vec![4_u8; 4096]));
+ }))
+ });
+ ready.wait();
+ Workers {
+ start,
+ complete,
+ finish,
+ threads,
+ }
+}
+
+#[metabench::benchmark(THREADED, "threads", "allocations", gungraun_setup = prepare)]
+fn allocate_on_threads(workers: Workers) -> Workers {
+ workers.start.wait();
+ workers.complete.wait();
+ workers
+}
+
+#[expect(clippy::panic, reason = "process fixture fails if a native probe executes Criterion registration")]
+fn criterion_benchmarks(_: &mut criterion::Criterion) {
+ panic!("the process fixture only runs native allocation probes");
+}
+
+metabench::main!(criterion = criterion_benchmarks, benchmarks = [THREADED],);
diff --git a/crates/metabench/src/__private.rs b/crates/metabench/src/__private.rs
index 4f31803b2..c2927d342 100644
--- a/crates/metabench/src/__private.rs
+++ b/crates/metabench/src/__private.rs
@@ -3,12 +3,13 @@
//! Dependencies and implementation details used by macro expansions.
-pub use alloc_tracker::Allocator;
pub use criterion;
pub use gungraun;
pub use crate::allocation::begin as begin_allocation_measurement;
+pub use crate::allocator::Allocator;
pub use crate::perf::begin as begin_perf_measurement;
+pub use crate::probe::{ProbeBenchmark, ProbeGroup, run_probe_groups};
pub use crate::runner::{EngineSet, run};
pub use crate::vtune::begin as begin_vtune_measurement;
diff --git a/crates/metabench/src/allocation.rs b/crates/metabench/src/allocation.rs
index 82042f83d..5c9e3daef 100644
--- a/crates/metabench/src/allocation.rs
+++ b/crates/metabench/src/allocation.rs
@@ -15,7 +15,7 @@ use crate::report::{Metric, MetricDirection, NativeResult};
pub(crate) const ARTIFACT_ENV: &str = "METABENCH_INTERNAL_ALLOCATION_ARTIFACT";
pub(crate) const IDENTITY_ENV: &str = "METABENCH_INTERNAL_ALLOCATION_IDENTITY";
-static ACTIVE: LazyLock = LazyLock::new(|| env::var_os(ARTIFACT_ENV).is_some());
+static ACTIVE: AtomicBool = AtomicBool::new(false);
static MEASURED: AtomicBool = AtomicBool::new(false);
static SESSION: LazyLock = LazyLock::new(|| Session::new().no_stdout().no_file());
@@ -27,25 +27,25 @@ struct AllocationArtifact {
}
#[inline]
-/// Begins measuring the first workload invocation in an allocation worker.
+/// Begins measuring the first explicit workload boundary in an allocation worker.
#[doc(hidden)]
pub fn begin() -> Option {
- (*ACTIVE && !MEASURED.swap(true, Ordering::Relaxed)).then(|| {
+ (ACTIVE.load(Ordering::Relaxed) && !MEASURED.swap(true, Ordering::Relaxed)).then(|| {
let operation = SESSION.operation("workload");
operation.measure_process().iterations(1)
})
}
-/// Resolves the lazy state behind [`begin`] ahead of any measurement.
+/// Enables collection after validating an allocation worker's token and mode.
///
-/// [`begin`] runs inside the region a profiler collects, so leaving these
-/// `LazyLock`s cold would charge the first workload for a `getenv` call, the
-/// surrounding one-time synchronization, and the tracking session setup.
-#[cfg_attr(coverage_nightly, coverage(off))] // not exercised by the instrumented test binary; see prime()'s doc comment
-#[cfg_attr(test, mutants::skip)] // warm-up only affects timing, not any observable behavior
+/// Enable tracking and initialize the session before the workload boundary so
+/// session setup cannot contribute allocations to the selected workload.
+#[cfg_attr(coverage_nightly, coverage(off))] // exercised in generated allocation-worker executables
pub(crate) fn prime() {
- if *ACTIVE {
+ if env::var_os(ARTIFACT_ENV).is_some() {
+ crate::allocator::enable_tracking();
LazyLock::force(&SESSION);
+ ACTIVE.store(true, Ordering::Relaxed);
}
}
@@ -63,7 +63,7 @@ pub(crate) fn write_worker_artifact() -> Result<(), Error> {
.find_map(|(name, operation)| (name == "workload").then_some(operation))
.ok_or_else(|| Error::ArtifactFormat {
path: path.clone(),
- message: format!("benchmark '{identity}' did not execute its instrumented workload"),
+ message: format!("benchmark '{identity}' did not execute its generated allocation probe"),
})?;
if operation.total_iterations() != 1 {
return Err(Error::ArtifactFormat {
@@ -85,7 +85,7 @@ pub(crate) fn write_worker_artifact() -> Result<(), Error> {
}
pub(crate) fn read_artifact(path: &Path, native_identity: String, relative_path: String) -> Result {
- let artifact: AllocationArtifact = read_json(path)?;
+ let artifact: AllocationArtifact = crate::artifact::read_json(path)?;
Ok(NativeResult {
identity: artifact.identity,
native_identity,
@@ -132,16 +132,15 @@ fn write_json(path: &Path, value: &impl Serialize) -> Result<(), Error> {
})
}
-fn read_json(path: &Path) -> Result
-where
- T: for<'de> Deserialize<'de>,
-{
- let file = fs::File::open(path).map_err(|source| Error::ArtifactIo {
- path: path.to_owned(),
- source,
- })?;
- serde_json::from_reader(file).map_err(|source| Error::ArtifactJson {
- path: path.to_owned(),
- source,
- })
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn inactive_collection_does_not_consume_the_first_workload() {
+ assert!(begin().is_none());
+ assert!(begin().is_none());
+ assert!(!MEASURED.load(Ordering::Relaxed));
+ }
}
diff --git a/crates/metabench/src/allocator.rs b/crates/metabench/src/allocator.rs
new file mode 100644
index 000000000..ff13251df
--- /dev/null
+++ b/crates/metabench/src/allocator.rs
@@ -0,0 +1,212 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use std::alloc::{GlobalAlloc, Layout, System};
+use std::sync::atomic::{AtomicBool, Ordering};
+
+static TRACKING_ENABLED: AtomicBool = AtomicBool::new(false);
+
+pub(crate) fn enable_tracking() {
+ TRACKING_ENABLED.store(true, Ordering::Relaxed);
+}
+
+/// Allocator used by generated entry points; tracking is enabled only in allocation workers.
+#[doc(hidden)]
+#[derive(Debug)]
+pub struct Allocator {
+ inner: A,
+}
+
+impl Allocator {
+ /// Uses the system allocator.
+ #[must_use]
+ pub const fn system() -> Self {
+ Self::new(System)
+ }
+}
+
+impl Allocator {
+ /// Wraps the allocator shared by tracked and untracked operations.
+ #[must_use]
+ pub const fn new(inner: A) -> Self {
+ Self { inner }
+ }
+
+ fn tracker(&self) -> alloc_tracker::Allocator> {
+ // Borrow the same allocator in both paths; custom allocator state must not be duplicated.
+ alloc_tracker::Allocator::new(AllocatorRef(&self.inner))
+ }
+}
+
+// SAFETY: Both paths forward the caller's allocation contract to the same underlying allocator.
+unsafe impl GlobalAlloc for Allocator {
+ unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
+ if TRACKING_ENABLED.load(Ordering::Relaxed) {
+ // SAFETY: The caller supplies a valid allocation layout.
+ unsafe { self.tracker().alloc(layout) }
+ } else {
+ // SAFETY: The caller supplies a valid allocation layout.
+ unsafe { self.inner.alloc(layout) }
+ }
+ }
+
+ unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
+ if TRACKING_ENABLED.load(Ordering::Relaxed) {
+ // SAFETY: The caller supplies a valid allocation layout.
+ unsafe { self.tracker().alloc_zeroed(layout) }
+ } else {
+ // SAFETY: The caller supplies a valid allocation layout.
+ unsafe { self.inner.alloc_zeroed(layout) }
+ }
+ }
+
+ unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
+ if TRACKING_ENABLED.load(Ordering::Relaxed) {
+ // SAFETY: The caller supplies the live allocation, original layout, and valid new size.
+ unsafe { self.tracker().realloc(ptr, layout, new_size) }
+ } else {
+ // SAFETY: The caller supplies the live allocation, original layout, and valid new size.
+ unsafe { self.inner.realloc(ptr, layout, new_size) }
+ }
+ }
+
+ unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
+ if TRACKING_ENABLED.load(Ordering::Relaxed) {
+ // SAFETY: Both paths allocate through self.inner; the caller supplies its live allocation and layout.
+ unsafe { self.tracker().dealloc(ptr, layout) }
+ } else {
+ // SAFETY: Both paths allocate through self.inner; the caller supplies its live allocation and layout.
+ unsafe { self.inner.dealloc(ptr, layout) }
+ }
+ }
+}
+
+struct AllocatorRef<'a, A>(&'a A);
+
+// SAFETY: This adapter forwards all operations unchanged to the borrowed GlobalAlloc implementation.
+unsafe impl GlobalAlloc for AllocatorRef<'_, A> {
+ unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
+ // SAFETY: The caller supplies a valid allocation layout.
+ unsafe { self.0.alloc(layout) }
+ }
+
+ unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
+ // SAFETY: The caller supplies a valid allocation layout.
+ unsafe { self.0.alloc_zeroed(layout) }
+ }
+
+ unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
+ // SAFETY: The caller supplies the live allocation, original layout, and valid new size.
+ unsafe { self.0.realloc(ptr, layout, new_size) }
+ }
+
+ unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
+ // SAFETY: The caller supplies a live allocation from this allocator and its original layout.
+ unsafe { self.0.dealloc(ptr, layout) }
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[cfg(test)]
+mod tests {
+ use std::ptr::NonNull;
+ use std::sync::atomic::AtomicUsize;
+
+ use alloc_tracker::Session;
+
+ use super::*;
+
+ #[derive(Default)]
+ struct CountingAllocator {
+ allocations: AtomicUsize,
+ zeroed: AtomicUsize,
+ reallocations: AtomicUsize,
+ frees: AtomicUsize,
+ }
+
+ // SAFETY: All operations preserve the GlobalAlloc contract by forwarding unchanged to System.
+ unsafe impl GlobalAlloc for CountingAllocator {
+ unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
+ self.allocations.fetch_add(1, Ordering::Relaxed);
+ // SAFETY: The caller supplies a valid allocation layout.
+ unsafe { System.alloc(layout) }
+ }
+
+ unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
+ self.zeroed.fetch_add(1, Ordering::Relaxed);
+ // SAFETY: The caller supplies a valid allocation layout.
+ unsafe { System.alloc_zeroed(layout) }
+ }
+
+ unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
+ self.reallocations.fetch_add(1, Ordering::Relaxed);
+ // SAFETY: The caller supplies a live System allocation and valid layouts.
+ unsafe { System.realloc(ptr, layout, new_size) }
+ }
+
+ unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
+ self.frees.fetch_add(1, Ordering::Relaxed);
+ // SAFETY: The caller supplies a live System allocation and its original layout.
+ unsafe { System.dealloc(ptr, layout) }
+ }
+ }
+
+ struct DisableOnDrop;
+
+ impl Drop for DisableOnDrop {
+ fn drop(&mut self) {
+ TRACKING_ENABLED.store(false, Ordering::Relaxed);
+ }
+ }
+
+ #[test]
+ fn only_enabled_operations_are_tracked_and_both_paths_share_allocator_state() {
+ let _reset = DisableOnDrop;
+ let allocator = Allocator::new(CountingAllocator::default());
+ let session = Session::new().no_stdout().no_file();
+ let layout = Layout::from_size_align(16, 8).unwrap();
+ let resized = Layout::from_size_align(32, 8).unwrap();
+
+ for (name, enabled) in [("disabled", false), ("enabled", true)] {
+ TRACKING_ENABLED.store(enabled, Ordering::Relaxed);
+ let span = session.operation(name).measure_process().iterations(1);
+ // SAFETY: The layout has non-zero size and valid alignment.
+ let ptr = NonNull::new(unsafe { allocator.alloc(layout) }).unwrap();
+ // SAFETY: The allocation contains at least one writable byte.
+ unsafe { ptr.as_ptr().write(42) };
+ // SAFETY: The pointer is live, its original layout matches, and the new size is valid.
+ let ptr = NonNull::new(unsafe { allocator.realloc(ptr.as_ptr(), layout, resized.size()) }).unwrap();
+ // SAFETY: Successful reallocation preserves the initialized first byte.
+ assert_eq!(unsafe { ptr.as_ptr().read() }, 42);
+ // SAFETY: The resized layout matches this live allocation.
+ unsafe { allocator.dealloc(ptr.as_ptr(), resized) };
+ // SAFETY: The layout has non-zero size and valid alignment.
+ let zeroed = NonNull::new(unsafe { allocator.alloc_zeroed(layout) }).unwrap();
+ // SAFETY: The allocation contains layout.size() initialized bytes.
+ let bytes = unsafe { std::slice::from_raw_parts(zeroed.as_ptr(), layout.size()) };
+ assert!(bytes.iter().all(|byte| *byte == 0));
+ // SAFETY: The layout matches this live allocation.
+ unsafe { allocator.dealloc(zeroed.as_ptr(), layout) };
+ drop(span);
+ }
+
+ let report = session.to_report();
+ for (name, operation) in report.operations() {
+ let (count, bytes) = if name == "enabled" { (3, 64) } else { (0, 0) };
+ assert_eq!(operation.total_allocations_count(), count, "{name}");
+ assert_eq!(operation.total_bytes_allocated(), bytes, "{name}");
+ }
+ assert_eq!(allocator.inner.allocations.load(Ordering::Relaxed), 2);
+ assert_eq!(allocator.inner.zeroed.load(Ordering::Relaxed), 2);
+ assert_eq!(allocator.inner.reallocations.load(Ordering::Relaxed), 2);
+ assert_eq!(allocator.inner.frees.load(Ordering::Relaxed), 4);
+
+ TRACKING_ENABLED.store(false, Ordering::Relaxed);
+ // SAFETY: A valid layout is supplied; the allocation is freed with the same allocator and layout.
+ let ptr = NonNull::new(unsafe { allocator.alloc(layout) }).unwrap();
+ enable_tracking();
+ // SAFETY: Enabling tracking does not change ownership of this live allocation.
+ unsafe { allocator.dealloc(ptr.as_ptr(), layout) };
+ assert_eq!(allocator.inner.frees.load(Ordering::Relaxed), 5);
+ }
+}
diff --git a/crates/metabench/src/arguments.rs b/crates/metabench/src/arguments.rs
index ba3cd3af6..9d2fac2dc 100644
--- a/crates/metabench/src/arguments.rs
+++ b/crates/metabench/src/arguments.rs
@@ -69,6 +69,12 @@ pub(crate) enum FailureMode {
}
impl Arguments {
+ pub(crate) fn smoke(&self) -> bool {
+ self.cargo_test
+ || self.native_args.criterion.iter().any(|argument| argument == "--test")
+ || self.native_args.gungraun.iter().any(|argument| argument == "--test")
+ }
+
pub(crate) fn parse() -> Result {
Self::parse_from(env::args_os().skip(1), env::var_os("BENCH_ENGINE"))
}
@@ -110,10 +116,10 @@ impl Arguments {
|| modes.contains(&Mode::Perf)
|| modes.contains(&Mode::Vtune)
{
- if self.cargo_bench || !self.cargo_test {
+ if !self.smoke() {
push_argument_unique(&mut self.native_args.criterion, "--bench");
}
- if self.cargo_test {
+ if self.smoke() {
push_argument_unique(&mut self.native_args.criterion, "--test");
}
if self.list {
@@ -121,10 +127,10 @@ impl Arguments {
}
}
if modes.contains(&Mode::Gungraun) {
- if self.cargo_bench {
+ if self.cargo_bench && !self.smoke() {
push_argument_unique(&mut self.native_args.gungraun, "--bench");
}
- if self.cargo_test {
+ if self.smoke() {
push_argument_unique(&mut self.native_args.gungraun, "--test");
}
if self.list {
@@ -154,7 +160,11 @@ impl Arguments {
let mut trailing = Vec::new();
let mut cargo_bench = false;
let mut cargo_test = false;
- let mut arguments = arguments.into_iter();
+ let mut arguments = arguments.into_iter().peekable();
+ if arguments.peek().is_none() {
+ // Cargo invokes harness-free `cargo test --bench` binaries without --bench.
+ cargo_test = true;
+ }
while let Some(argument) = arguments.next() {
if argument == OsStr::new("--") {
@@ -460,6 +470,20 @@ mod tests {
assert_eq!(arguments.args_for(Mode::Gungraun), ["--baseline"]);
}
+ #[test]
+ fn gungraun_bench_flag_requires_cargo_bench_and_non_smoke_mode() {
+ for (options, expected) in [
+ (&["--gungraun"][..], &[][..]),
+ (&["--gungraun", "--bench"][..], &["--bench"][..]),
+ (&["--gungraun", "--test"][..], &["--test"][..]),
+ (&["--gungraun", "--bench", "--test"][..], &["--test"][..]),
+ ] {
+ let mut arguments = parse(options, None).unwrap();
+ arguments.finalize_native_args(&[Mode::Gungraun]).unwrap();
+ assert_eq!(arguments.args_for(Mode::Gungraun), expected);
+ }
+ }
+
#[test]
fn parses_perf_selection_and_arguments() {
let mut arguments = Arguments::parse_from(["--perf".into(), "--perf-arg".into(), "--event=cpu-clock".into()], None).unwrap();
@@ -505,6 +529,28 @@ mod tests {
));
}
+ #[test]
+ fn smoke_intent_covers_cargo_and_forwarded_native_arguments() {
+ for options in [
+ &[][..],
+ &["--test"][..],
+ &["--criterion", "--criterion-arg=--test"][..],
+ &["--allocations", "--", "--test"][..],
+ ] {
+ let mut arguments = Arguments::parse_from(options.iter().map(OsString::from), None).unwrap();
+ arguments.finalize_native_args(&arguments.modes.clone()).unwrap();
+ assert!(arguments.smoke());
+ assert!(arguments.criterion_args().contains(&"--test".into()));
+ assert!(!arguments.criterion_args().contains(&"--bench".into()));
+ }
+ for options in [&["--bench"][..], &["--criterion"][..], &["--allocations"][..]] {
+ let mut arguments = Arguments::parse_from(options.iter().map(OsString::from), None).unwrap();
+ arguments.finalize_native_args(&arguments.modes.clone()).unwrap();
+ assert!(!arguments.smoke());
+ assert!(arguments.criterion_args().contains(&"--bench".into()));
+ }
+ }
+
#[test]
fn defaults_exclude_opt_in_perf_and_keep_going() {
let arguments = parse(&[], None).unwrap();
diff --git a/crates/metabench/src/artifact.rs b/crates/metabench/src/artifact.rs
index 55020c4d3..202cc1f3a 100644
--- a/crates/metabench/src/artifact.rs
+++ b/crates/metabench/src/artifact.rs
@@ -6,15 +6,21 @@ use std::ffi::OsString;
use std::fmt::Debug;
use std::fs::{File, OpenOptions};
use std::hash::Hash;
-use std::io::{self, BufReader, BufWriter, Write as _};
+use std::io::{self, BufWriter, Read as _, Write as _};
+#[cfg(unix)]
+use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _};
+#[cfg(windows)]
+use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _};
use std::path::{Path, PathBuf};
use std::process::{self, ExitStatus};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use std::{env, fs};
-use gungraun_summary::either_or_both::EitherOrBoth;
-use gungraun_summary::v6::{BenchmarkSummary, Metric as GungraunMetric, MetricsSummary, ToolMetricSummary, ValgrindTool};
+use either_or_both::EitherOrBoth;
+use gungraun_runner::api::ValgrindTool;
+use gungraun_runner::metrics::model::{Metric as GungraunMetric, MetricsSummary};
+use gungraun_runner::summary::model::{BenchmarkSummary, ToolMetricSummary};
use serde::{Deserialize, Serialize};
use crate::error::Error;
@@ -48,6 +54,8 @@ struct EngineRunManifest {
success: bool,
exit_code: Option,
discovered_native_identities: Vec,
+ #[serde(default)]
+ native_to_canonical: BTreeMap,
}
impl ArtifactDirectory {
@@ -72,7 +80,7 @@ impl ArtifactDirectory {
let gungraun = gungraun_runs
.as_ref()
.map_or_else(|| run.join("gungraun"), |runs| runs.join(&run_name));
- fs::create_dir_all(&run).map_err(|source| Error::ArtifactIo { path: run.clone(), source })?;
+ Self::create_private_run(&run)?;
let mut marked_runs = BTreeSet::from([run.clone()]);
let mut active_locks = vec![mark_run_active(&run)?];
Self::prune_owned_runs(&runs, &run)?;
@@ -84,6 +92,7 @@ impl ArtifactDirectory {
.flatten()
{
if marked_runs.insert(current.to_owned()) {
+ Self::create_private_run(current)?;
active_locks.push(mark_run_active(current)?);
}
Self::prune_owned_runs(owned_runs, current)?;
@@ -97,6 +106,26 @@ impl ArtifactDirectory {
})
}
+ fn create_private_run(path: &Path) -> Result<(), Error> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).map_err(|source| Error::ArtifactIo {
+ path: parent.to_owned(),
+ source,
+ })?;
+ }
+ let directory = fs::DirBuilder::new();
+ #[cfg(unix)]
+ let directory = {
+ let mut directory = directory;
+ directory.mode(0o700);
+ directory
+ };
+ directory.create(path).map_err(|source| Error::ArtifactIo {
+ path: path.to_owned(),
+ source,
+ })
+ }
+
fn prune_owned_runs(runs: &Path, current: &Path) -> Result<(), Error> {
let mut directories = fs::read_dir(runs)
.map_err(|source| Error::ArtifactIo {
@@ -175,6 +204,7 @@ pub(crate) fn write_run_manifest(
success: status.is_some_and(|status| status.success()),
exit_code: status.and_then(|status| status.code()),
discovered_native_identities: Vec::new(),
+ native_to_canonical: BTreeMap::new(),
};
write_json(&manifest_root.join("manifests").join(format!("{engine}.json")), &manifest)
}
@@ -188,6 +218,10 @@ pub(crate) fn record_discovered_identities(manifest_root: &Path, engine: &str, r
.collect::>()
.into_iter()
.collect();
+ manifest.native_to_canonical = results
+ .iter()
+ .map(|result| (result.native_identity.clone(), result.identity.clone()))
+ .collect();
write_json(&path, &manifest)
}
@@ -201,7 +235,8 @@ struct CriterionMetadata {
#[derive(Debug, Deserialize)]
struct CriterionEstimates {
- median: CriterionEstimate,
+ slope: Option,
+ mean: CriterionEstimate,
}
#[derive(Debug, Deserialize)]
@@ -212,6 +247,7 @@ struct CriterionEstimate {
#[derive(Debug, Deserialize)]
struct CriterionConfidenceInterval {
+ confidence_level: f64,
lower_bound: f64,
upper_bound: f64,
}
@@ -234,13 +270,18 @@ pub(crate) fn parse_criterion(
let identity = identities.resolve_criterion(&native_identity);
let estimates_path = path.with_file_name("estimates.json");
let estimates = read_json::(&estimates_path)?;
- let median = estimates.median;
- validate_criterion_estimate(&estimates_path, &median)?;
- let mut metric = Metric::float("criterion", "median", median.point_estimate, MetricDirection::LowerIsBetter);
+ let (statistic, estimate) = match estimates.slope {
+ Some(slope) => ("slope", slope),
+ None => ("mean", estimates.mean),
+ };
+ validate_criterion_estimate(&estimates_path, &estimate)?;
+ let mut metric = Metric::float("criterion", "time", estimate.point_estimate, MetricDirection::LowerIsBetter);
metric.unit = unit.map(str::to_owned);
- metric.lower_bound = Some(median.confidence_interval.lower_bound);
- metric.upper_bound = Some(median.confidence_interval.upper_bound);
- let mut metrics = BTreeMap::from([("median".to_owned(), metric)]);
+ metric.statistic = Some(statistic.to_owned());
+ metric.confidence_level = Some(estimate.confidence_interval.confidence_level);
+ metric.lower_bound = Some(estimate.confidence_interval.lower_bound);
+ metric.upper_bound = Some(estimate.confidence_interval.upper_bound);
+ let mut metrics = BTreeMap::from([("time".to_owned(), metric)]);
if let Some((name, value)) = metadata.throughput.as_ref().and_then(parse_criterion_throughput) {
metrics.insert(name.clone(), Metric::integer("criterion", &name, value, MetricDirection::Unknown));
}
@@ -263,10 +304,12 @@ pub(crate) fn parse_gungraun(root: &Path, artifact_root: &Path, identities: &[Be
let identities = CanonicalIdentityIndex::new(identities);
let mut results = Vec::new();
for path in paths {
- validate_json_size(&path, MAX_ARTIFACT_JSON_BYTES)?;
- let summary = gungraun_summary::v6::parse(&path).map_err(|error| Error::ArtifactFormat {
- path: path.clone(),
- message: error.to_string(),
+ let summary = read_json::(&path).map_err(|error| match error {
+ Error::ArtifactJson { path, source } => Error::ArtifactFormat {
+ path,
+ message: source.to_string(),
+ },
+ error => error,
})?;
if summary.version != "6" {
return Err(Error::ArtifactFormat {
@@ -311,11 +354,7 @@ struct PerfStatRecord {
}
pub(crate) fn parse_perf(path: &Path, artifact_root: &Path, identity: String, native_identity: String) -> Result {
- validate_json_size(path, MAX_ARTIFACT_JSON_BYTES)?;
- let contents = fs::read_to_string(path).map_err(|source| Error::ArtifactIo {
- path: path.to_owned(),
- source,
- })?;
+ let contents = read_artifact_text(path, "JSON")?;
let mut metrics = BTreeMap::new();
for (index, line) in contents.lines().enumerate() {
if line.trim().is_empty() {
@@ -431,11 +470,7 @@ const VTUNE_EVENT_COUNT_COLUMN: &str = "Hardware Event Count:Self";
/// against a live install) parses correctly as long as it is quoted per CSV
/// convention, e.g. `INST_RETIRED.ANY,"1,234,567"`.
pub(crate) fn parse_vtune(path: &Path, artifact_root: &Path, identity: String, native_identity: String) -> Result {
- validate_artifact_size(path, MAX_ARTIFACT_JSON_BYTES, "CSV")?;
- let contents = fs::read_to_string(path).map_err(|source| Error::ArtifactIo {
- path: path.to_owned(),
- source,
- })?;
+ let contents = read_artifact_text(path, "CSV")?;
let mut lines = contents.lines().enumerate();
let (name_column, count_column, expected_field_count) = match lines.next() {
Some((_, header)) => {
@@ -547,10 +582,6 @@ fn criterion_identity(metadata: &CriterionMetadata) -> String {
.join("/")
}
-pub(crate) fn allocation_criterion_identity(native_identity: &str, identities: &[BenchmarkIdentity]) -> Option {
- CanonicalIdentityIndex::new(identities).resolve_allocation_criterion(native_identity)
-}
-
fn parse_criterion_throughput(value: &serde_json::Value) -> Option<(String, u64)> {
let object = value.as_object()?;
let (kind, value) = object.iter().next()?;
@@ -573,14 +604,13 @@ fn gungraun_canonical_identity(summary: &BenchmarkSummary) -> String {
}
let group = parts.last().copied().unwrap_or("gungraun");
match &summary.id {
- Some(id) if id != "default" => format!("{group}/{}/{id}", summary.function_name),
+ Some(id) => format!("{group}/{}/{id}", summary.function_name),
_ => format!("{group}/{}", summary.function_name),
}
}
struct CanonicalIdentityIndex {
criterion: BTreeMap,
- allocation_criterion: BTreeMap,
gungraun: BTreeMap,
}
@@ -590,20 +620,11 @@ impl CanonicalIdentityIndex {
.iter()
.map(|identity| (identity.criterion_identity(), identity.canonical_identity()))
.collect();
- let allocation_criterion = identities
- .iter()
- .filter(|identity| identity.allocation_tracking())
- .map(|identity| (identity.criterion_identity(), identity.canonical_identity()))
- .collect();
let gungraun = identities
.iter()
.map(|identity| (identity.gungraun_identity(), identity.canonical_identity()))
.collect();
- Self {
- criterion,
- allocation_criterion,
- gungraun,
- }
+ Self { criterion, gungraun }
}
fn resolve_criterion(&self, native: &str) -> String {
@@ -614,10 +635,6 @@ impl CanonicalIdentityIndex {
Self::resolve(&self.criterion, native)
}
- fn resolve_allocation_criterion(&self, native: &str) -> Option {
- Self::resolve(&self.allocation_criterion, native)
- }
-
fn resolve_gungraun(&self, native: &str, default_identity: String) -> String {
Self::resolve(&self.gungraun, native).unwrap_or(default_identity)
}
@@ -700,7 +717,13 @@ fn validate_criterion_estimate(path: &Path, estimate: &CriterionEstimate) -> Res
let point = estimate.point_estimate;
let lower = estimate.confidence_interval.lower_bound;
let upper = estimate.confidence_interval.upper_bound;
- if !point.is_finite() || !lower.is_finite() || !upper.is_finite() {
+ let confidence = estimate.confidence_interval.confidence_level;
+ if !confidence.is_finite() || confidence <= 0.0 || confidence >= 1.0 {
+ Err(Error::ArtifactFormat {
+ path: path.to_owned(),
+ message: format!("Criterion confidence level must be between zero and one; found {confidence}"),
+ })
+ } else if !point.is_finite() || !lower.is_finite() || !upper.is_finite() {
Err(Error::ArtifactFormat {
path: path.to_owned(),
message: "contains a non-finite Criterion estimate".to_owned(),
@@ -710,10 +733,10 @@ fn validate_criterion_estimate(path: &Path, estimate: &CriterionEstimate) -> Res
path: path.to_owned(),
message: format!("Criterion estimates must be non-negative; found {lower}..{point}..{upper}"),
})
- } else if lower > point || point > upper {
+ } else if lower > upper {
Err(Error::ArtifactFormat {
path: path.to_owned(),
- message: format!("Criterion confidence interval must contain its point estimate; found {lower}..{point}..{upper}"),
+ message: format!("Criterion confidence interval bounds must be ordered; found {lower}..{upper}"),
})
} else {
Ok(())
@@ -847,43 +870,112 @@ fn mark_run_active(path: &Path) -> Result {
Ok(file)
}
-fn read_json(path: &Path) -> Result {
- validate_json_size(path, MAX_ARTIFACT_JSON_BYTES)?;
- let file = fs::File::open(path).map_err(|source| Error::ArtifactIo {
+pub(crate) fn read_json(path: &Path) -> Result {
+ let bytes = read_regular_file(path, MAX_ARTIFACT_JSON_BYTES, "JSON")?;
+ serde_json::from_slice(&bytes).map_err(|source| Error::ArtifactJson {
+ path: path.to_owned(),
+ source,
+ })
+}
+
+fn read_artifact_text(path: &Path, format: &str) -> Result {
+ let bytes = read_regular_file(path, MAX_ARTIFACT_JSON_BYTES, format)?;
+ String::from_utf8(bytes).map_err(|source| Error::ArtifactIo {
+ path: path.to_owned(),
+ source: io::Error::new(io::ErrorKind::InvalidData, source),
+ })
+}
+
+pub(crate) fn read_regular_file(path: &Path, limit: u64, format: &str) -> Result, Error> {
+ let file = open_regular_file(path)?;
+ read_bounded_file(file, path, limit, format)
+}
+
+/// Admits the same regular-file handle later consumed by the bounded reader.
+fn open_regular_file(path: &Path) -> Result {
+ let mut options = OpenOptions::new();
+ options.read(true);
+ #[cfg(unix)]
+ {
+ // Reject final links and avoid waiting for a FIFO writer before checking the file type.
+ options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
+ }
+ #[cfg(windows)]
+ {
+ // Inspect rather than follow reparse points, denying concurrent writes and path replacement.
+ const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
+ const FILE_SHARE_READ: u32 = 1;
+ options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT).share_mode(FILE_SHARE_READ);
+ }
+ let file = options.open(path).map_err(|source| Error::ArtifactIo {
path: path.to_owned(),
source,
})?;
- serde_json::from_reader(BufReader::new(file)).map_err(|source| Error::ArtifactJson {
+ let metadata = file.metadata().map_err(|source| Error::ArtifactIo {
path: path.to_owned(),
source,
- })
+ })?;
+ #[cfg(windows)]
+ {
+ reject_windows_reparse_point(path, metadata.file_attributes())?;
+ }
+ if !metadata.is_file() {
+ return Err(Error::ArtifactFormat {
+ path: path.to_owned(),
+ message: "artifact must be a regular file".to_owned(),
+ });
+ }
+ Ok(file)
}
-fn validate_json_size(path: &Path, limit: u64) -> Result<(), Error> {
- validate_artifact_size(path, limit, "JSON")
+#[cfg(windows)]
+fn reject_windows_reparse_point(path: &Path, attributes: u32) -> Result<(), Error> {
+ const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
+ if attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
+ return Err(Error::ArtifactFormat {
+ path: path.to_owned(),
+ message: "artifact must not be a reparse point".to_owned(),
+ });
+ }
+ Ok(())
}
-/// Rejects an artifact larger than `limit`, naming its `format` (e.g.
-/// `"JSON"` or `"CSV"`) in the error so the diagnostic matches the actual
-/// file being validated rather than always describing it as JSON.
-fn validate_artifact_size(path: &Path, limit: u64, format: &str) -> Result<(), Error> {
- let length = fs::metadata(path)
+fn read_bounded_file(file: File, path: &Path, limit: u64, format: &str) -> Result, Error> {
+ let length = file
+ .metadata()
.map_err(|source| Error::ArtifactIo {
path: path.to_owned(),
source,
})?
.len();
if length > limit {
- Err(Error::ArtifactFormat {
+ return Err(Error::ArtifactFormat {
path: path.to_owned(),
message: format!("{format} file is {length} bytes; limit is {limit} bytes"),
- })
- } else {
- Ok(())
+ });
+ }
+ read_bounded_stream(file, path, limit, format)
+}
+
+fn read_bounded_stream(reader: impl io::Read, path: &Path, limit: u64, format: &str) -> Result, Error> {
+ let mut bytes = Vec::new();
+ reader
+ .take(limit.saturating_add(1))
+ .read_to_end(&mut bytes)
+ .map_err(|source| Error::ArtifactIo {
+ path: path.to_owned(),
+ source,
+ })?;
+ if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > limit {
+ return Err(Error::ArtifactFormat {
+ path: path.to_owned(),
+ message: format!("{format} stream exceeds the {limit}-byte limit"),
+ });
}
+ Ok(bytes)
}
-fn write_json(path: &Path, value: &impl Serialize) -> Result<(), Error> {
+pub(crate) fn write_json(path: &Path, value: &impl Serialize) -> Result<(), Error> {
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
@@ -924,9 +1016,238 @@ fn write_json(path: &Path, value: &impl Serialize) -> Result<(), Error> {
#[cfg(test)]
mod tests {
use std::fmt::Write as _;
+ #[cfg(unix)]
+ use std::os::unix::fs::{PermissionsExt as _, symlink};
use super::*;
+ #[test]
+ fn private_run_creation_creates_parents_and_rejects_existing_paths() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("runs").join("new-run");
+ ArtifactDirectory::create_private_run(&path).unwrap();
+ assert!(path.is_dir());
+ assert!(matches!(
+ ArtifactDirectory::create_private_run(&path),
+ Err(Error::ArtifactIo { path: failed, source })
+ if failed == path && source.kind() == io::ErrorKind::AlreadyExists
+ ));
+ let file = directory.path().join("file");
+ fs::write(&file, b"preserve me").unwrap();
+ assert!(matches!(
+ ArtifactDirectory::create_private_run(&file.join("run")),
+ Err(Error::ArtifactIo { path: failed, .. }) if failed == file
+ ));
+ assert_eq!(fs::read(file).unwrap(), b"preserve me");
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn json_admission_rejects_reparse_attributes_without_symlink_privileges() {
+ let path = Path::new("artifact.json");
+ for attributes in [0, 0x80, 0x1000] {
+ reject_windows_reparse_point(path, attributes).unwrap();
+ }
+ for attributes in [0x400, 0x480, 0x1400] {
+ assert!(matches!(
+ reject_windows_reparse_point(path, attributes),
+ Err(Error::ArtifactFormat { path: failed, message })
+ if failed == path && message == "artifact must not be a reparse point"
+ ));
+ }
+ }
+
+ #[test]
+ fn json_admission_limits_the_opened_stream_and_ignores_path_replacement() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("artifact.json");
+ fs::write(&path, b"null").unwrap();
+ let file = open_regular_file(&path).unwrap();
+ assert_eq!(file.metadata().unwrap().len(), 4);
+ #[cfg(unix)]
+ {
+ fs::write(&path, b"null ").unwrap();
+ assert!(
+ read_bounded_stream(file, &path, 4, "JSON")
+ .unwrap_err()
+ .to_string()
+ .contains("stream exceeds")
+ );
+ }
+ #[cfg(windows)]
+ {
+ // Admission denies write sharing while the artifact handle is open.
+ fs::write(&path, b"null ").unwrap_err();
+ assert_eq!(read_bounded_file(file, &path, 4, "JSON").unwrap(), b"null");
+ fs::write(&path, b"null ").unwrap();
+ }
+ assert!(
+ read_bounded_stream(io::Cursor::new(b"null "), &path, 4, "JSON")
+ .unwrap_err()
+ .to_string()
+ .contains("stream exceeds")
+ );
+ assert!(matches!(
+ read_bounded_file(open_regular_file(&path).unwrap(), &path, 4, "JSON"),
+ Err(Error::ArtifactFormat { .. })
+ ));
+ fs::write(&path, b"null").unwrap();
+ assert_eq!(
+ read_bounded_file(open_regular_file(&path).unwrap(), &path, 4, "JSON").unwrap(),
+ b"null"
+ );
+ #[cfg(unix)]
+ {
+ let file = open_regular_file(&path).unwrap();
+ fs::rename(&path, directory.path().join("original.json")).unwrap();
+ fs::write(&path, b"not the opened artifact").unwrap();
+ assert_eq!(read_bounded_file(file, &path, 4, "JSON").unwrap(), b"null");
+ }
+ }
+
+ #[test]
+ fn json_admission_rejects_oversized_allocation_identities() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("allocation.json");
+ let mut file = File::create(&path).unwrap();
+ file.write_all(b"{\"identity\":\"").unwrap();
+ for _ in 0..(MAX_ARTIFACT_JSON_BYTES / 8_192) {
+ file.write_all(&[b'x'; 8_192]).unwrap();
+ }
+ file.write_all(b"\",\"allocated_bytes\":1,\"allocation_count\":1}").unwrap();
+ drop(file);
+ assert!(matches!(
+ crate::allocation::read_artifact(&path, "native".to_owned(), "allocation.json".to_owned()),
+ Err(Error::ArtifactFormat { .. })
+ ));
+ read_json::(directory.path()).unwrap_err();
+ }
+
+ #[test]
+ fn hardware_artifact_admission_rejects_invalid_files_before_parsing() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("artifact");
+ for csv in [false, true] {
+ let parse = if csv { parse_vtune } else { parse_perf };
+ let parse = |path: &Path| parse(path, directory.path(), "group/case".into(), "native/case".into());
+ parse(directory.path()).unwrap_err();
+ fs::write(&path, [0xff]).unwrap();
+ assert!(matches!(
+ parse(&path),
+ Err(Error::ArtifactIo { source, .. }) if source.kind() == io::ErrorKind::InvalidData
+ ));
+ File::create(&path).unwrap().set_len(MAX_ARTIFACT_JSON_BYTES + 1).unwrap();
+ assert!(matches!(
+ parse(&path),
+ Err(Error::ArtifactFormat { message, .. })
+ if message.starts_with(if csv { "CSV file" } else { "JSON file" })
+ ));
+ #[cfg(unix)]
+ {
+ let link = directory.path().join(if csv { "csv-link" } else { "json-link" });
+ symlink(&path, &link).unwrap();
+ assert!(matches!(parse(&link), Err(Error::ArtifactIo { .. })));
+ }
+ #[cfg(target_os = "linux")]
+ {
+ let fifo = directory.path().join(if csv { "csv-fifo" } else { "json-fifo" });
+ nix::unistd::mkfifo(&fifo, nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR).unwrap();
+ assert!(matches!(
+ parse(&fifo),
+ Err(Error::ArtifactFormat { message, .. }) if message.contains("regular file")
+ ));
+ }
+ }
+ }
+
+ #[test]
+ fn bounded_hardware_streams_check_growth_and_retain_the_output_format() {
+ let path = Path::new("hardware-output");
+ for format in ["JSON", "CSV"] {
+ assert_eq!(read_bounded_stream(io::Cursor::new(b"data"), path, 4, format).unwrap(), b"data");
+ assert!(matches!(
+ read_bounded_stream(io::Cursor::new(b"data grows"), path, 4, format),
+ Err(Error::ArtifactFormat { message, .. })
+ if message == format!("{format} stream exceeds the 4-byte limit")
+ ));
+ }
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn json_admission_rejects_links_and_fifos_without_waiting_for_a_writer() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("artifact.json");
+ let link = directory.path().join("link.json");
+ fs::write(&path, b"null").unwrap();
+ symlink(&path, &link).unwrap();
+ read_json::(&link).unwrap_err();
+ #[cfg(target_os = "linux")]
+ {
+ let fifo = directory.path().join("fifo.json");
+ nix::unistd::mkfifo(&fifo, nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR).unwrap();
+ assert!(
+ read_json::(&fifo)
+ .unwrap_err()
+ .to_string()
+ .contains("regular file")
+ );
+ }
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn artifact_run_directories_are_private() {
+ let directory = tempfile::tempdir().unwrap();
+ let artifacts = ArtifactDirectory::create_with_homes(
+ &directory.path().join("target"),
+ Some(directory.path().join("criterion").into_os_string()),
+ Some(directory.path().join("gungraun").into_os_string()),
+ )
+ .unwrap();
+ for path in [artifacts.path().to_owned(), artifacts.criterion_path(), artifacts.gungraun_path()] {
+ assert_eq!(fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o700);
+ }
+ }
+
+ #[test]
+ fn explicit_default_case_is_distinct_and_joins_matching_engine_results() {
+ let source: serde_json::Value = read_json(&fixture("gungraun/minimal/summary.json")).unwrap();
+ let directory = tempfile::tempdir().unwrap();
+ let mut all = Vec::new();
+ for id in [None, Some("default")] {
+ let path = directory.path().join(if id.is_some() { "named" } else { "unnamed" });
+ let mut summary = source.clone();
+ summary["id"] = id.into();
+ write_json(&path.join("summary.json"), &summary).unwrap();
+ let results = parse_gungraun(&path, directory.path(), &[]).unwrap();
+ let expected = if id.is_some() {
+ "adapter/metabench_adapter/default"
+ } else {
+ "adapter/metabench_adapter"
+ };
+ assert_eq!(results[0].native_identity, expected);
+ assert_eq!(results[0].identity, expected);
+ all.extend(results);
+ }
+ let mut time = Metric::float("criterion", "ns", 1.0, MetricDirection::LowerIsBetter);
+ time.statistic = Some("mean".to_owned());
+ time.lower_bound = Some(1.0);
+ time.upper_bound = Some(1.0);
+ time.confidence_level = Some(0.95);
+ all.push(NativeResult {
+ identity: "adapter/metabench_adapter/default".to_owned(),
+ native_identity: "adapter/metabench_adapter/default".to_owned(),
+ source: "criterion".to_owned(),
+ metrics: BTreeMap::from([("time".to_owned(), time)]),
+ raw_artifacts: Vec::new(),
+ });
+ let report = crate::report::BenchmarkReport::from_results(all, vec![], String::new()).unwrap();
+ assert_eq!(report.entries.len(), 2);
+ let named = report.entries.iter().find(|entry| entry.identity.ends_with("/default")).unwrap();
+ assert_eq!(named.results.len(), 2);
+ }
+
#[test]
fn accepts_only_performance_measurement_tools() {
for tool in [ValgrindTool::Callgrind, ValgrindTool::Cachegrind, ValgrindTool::DHAT] {
@@ -953,7 +1274,7 @@ mod tests {
fs::write(new.join("benchmark.json"), r#"{"group_id":"group","function_id":"bench"}"#).unwrap();
fs::write(
new.join("estimates.json"),
- format!(r#"{{"median":{{"confidence_interval":{{"lower_bound":{lower},"upper_bound":{upper}}},"point_estimate":{point}}}}}"#),
+ format!(r#"{{"mean":{{"confidence_interval":{{"confidence_level":0.95,"lower_bound":{lower},"upper_bound":{upper}}},"point_estimate":{point}}}}}"#),
)
.unwrap();
}
@@ -970,12 +1291,14 @@ mod tests {
assert_eq!(result.native_identity, "group/bench");
assert_eq!(result.source, "criterion");
assert_eq!(result.raw_artifacts, ["new/benchmark.json", "new/estimates.json"]);
- let median = &result.metrics["median"];
+ let median = &result.metrics["time"];
assert_eq!(median.value, crate::report::MetricValue::Float(20.0));
assert_eq!(median.direction, MetricDirection::LowerIsBetter);
assert_eq!(median.unit.as_deref(), Some("ns"));
assert_eq!(median.lower_bound, Some(10.0));
assert_eq!(median.upper_bound, Some(30.0));
+ assert_eq!(median.statistic.as_deref(), Some("mean"));
+ assert_eq!(median.confidence_level, Some(0.95));
assert_eq!(
result.metrics["declared_throughput.bytes"].value,
crate::report::MetricValue::Integer(10)
@@ -985,6 +1308,63 @@ mod tests {
assert_eq!(index.resolve_criterion("other/bench/case_0"), "other/bench/case_0");
}
+ #[test]
+ fn criterion_preserves_primary_estimate_and_its_confidence_interval() {
+ let directory = tempfile::tempdir().unwrap();
+ write_criterion(directory.path(), 20.0, 10.0, 30.0);
+ let path = directory.path().join("new/estimates.json");
+ let mut estimates: serde_json::Value = read_json(&path).unwrap();
+ estimates["slope"] = serde_json::json!({
+ "point_estimate": 100.0,
+ "confidence_interval": {"lower_bound": 90.0, "upper_bound": 110.0, "confidence_level": 0.99}
+ });
+ estimates["median"] = serde_json::json!({
+ "point_estimate": 2.0,
+ "confidence_interval": {"lower_bound": 1.0, "upper_bound": 3.0, "confidence_level": 0.90}
+ });
+ write_json(&path, &estimates).unwrap();
+ let results = parse_criterion(directory.path(), directory.path(), &[], Some("cycles")).unwrap();
+ let time = &results[0].metrics["time"];
+ assert_eq!(time.value, crate::report::MetricValue::Float(100.0));
+ assert_eq!(time.lower_bound, Some(90.0));
+ assert_eq!(time.upper_bound, Some(110.0));
+ assert_eq!(time.confidence_level, Some(0.99));
+ assert_eq!(time.statistic.as_deref(), Some("slope"));
+ assert_eq!(time.unit.as_deref(), Some("cycles"));
+ estimates["slope"] = serde_json::Value::Null;
+ write_json(&path, &estimates).unwrap();
+ let results = parse_criterion(directory.path(), directory.path(), &[], None).unwrap();
+ assert_eq!(results[0].metrics["time"].value, crate::report::MetricValue::Float(20.0));
+ assert_eq!(results[0].metrics["time"].statistic.as_deref(), Some("mean"));
+ assert_eq!(results[0].metrics["time"].lower_bound, Some(10.0));
+ assert_eq!(results[0].metrics["time"].upper_bound, Some(30.0));
+ assert_eq!(results[0].metrics["time"].confidence_level, Some(0.95));
+ assert_eq!(results[0].metrics["time"].unit, None);
+ for confidence in [0.0, 1.0, -0.1] {
+ estimates["mean"]["confidence_interval"]["confidence_level"] = serde_json::json!(confidence);
+ write_json(&path, &estimates).unwrap();
+ assert!(matches!(
+ parse_criterion(directory.path(), directory.path(), &[], None),
+ Err(Error::ArtifactFormat { .. })
+ ));
+ }
+ }
+
+ #[test]
+ fn criterion_accepts_zero_and_equal_interval_bounds() {
+ for (point, lower, upper) in [(0.0, 0.0, 0.0), (1.0, 0.0, 2.0), (1.0, 1.0, 1.0)] {
+ let estimate = CriterionEstimate {
+ point_estimate: point,
+ confidence_interval: CriterionConfidenceInterval {
+ lower_bound: lower,
+ upper_bound: upper,
+ confidence_level: 0.95,
+ },
+ };
+ validate_criterion_estimate(Path::new("estimates.json"), &estimate).unwrap();
+ }
+ }
+
#[test]
fn generated_native_identities_resolve_only_on_segment_boundaries() {
let identities = [BenchmarkIdentity::__new("group", "bench", "native_group", "native_bench", true)];
@@ -1038,7 +1418,7 @@ mod tests {
Err(Error::ArtifactFormat { .. })
));
- for (point, lower, upper) in [(-1.0, 0.0, 1.0), (1.0, -1.0, 2.0), (1.0, 2.0, 3.0), (3.0, 1.0, 2.0)] {
+ for (point, lower, upper) in [(-1.0, 0.0, 1.0), (1.0, -1.0, 2.0), (1.0, 3.0, 2.0), (1.0, 0.0, -1.0)] {
let directory = tempfile::tempdir().unwrap();
write_criterion(directory.path(), point, lower, upper);
assert!(matches!(
@@ -1048,6 +1428,55 @@ mod tests {
}
}
+ #[test]
+ fn criterion_accepts_bootstrap_intervals_excluding_the_point() {
+ let directory = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).unwrap();
+ for point in [1.0, 4.0] {
+ write_criterion(directory.path(), point, 2.0, 3.0);
+ let path = directory.path().join("new/estimates.json");
+ let mut estimates: serde_json::Value = read_json(&path).unwrap();
+ for statistic in ["mean", "slope"] {
+ if statistic == "slope" {
+ estimates["slope"] = estimates["mean"].clone();
+ write_json(&path, &estimates).unwrap();
+ }
+ let results = parse_criterion(directory.path(), directory.path(), &[], None).unwrap();
+ let time = &results[0].metrics["time"];
+ assert_eq!(time.value, crate::report::MetricValue::Float(point));
+ assert_eq!(time.lower_bound, Some(2.0));
+ assert_eq!(time.upper_bound, Some(3.0));
+ assert_eq!(time.statistic.as_deref(), Some(statistic));
+ assert_eq!(time.confidence_level, Some(0.95));
+ }
+ }
+ }
+
+ #[test]
+ fn criterion_rejects_non_finite_estimate_components() {
+ for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
+ for component in 0..4 {
+ let mut estimate = CriterionEstimate {
+ point_estimate: 2.0,
+ confidence_interval: CriterionConfidenceInterval {
+ lower_bound: 1.0,
+ upper_bound: 3.0,
+ confidence_level: 0.95,
+ },
+ };
+ match component {
+ 0 => estimate.point_estimate = invalid,
+ 1 => estimate.confidence_interval.lower_bound = invalid,
+ 2 => estimate.confidence_interval.upper_bound = invalid,
+ _ => estimate.confidence_interval.confidence_level = invalid,
+ }
+ assert!(matches!(
+ validate_criterion_estimate(Path::new("estimates.json"), &estimate),
+ Err(Error::ArtifactFormat { .. })
+ ));
+ }
+ }
+ }
+
#[test]
fn parses_gungraun_schema_and_strips_native_diffs() {
let source = fs::read_to_string(fixture("gungraun/minimal/summary.json")).unwrap();
@@ -1390,7 +1819,18 @@ mod tests {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("large.json");
fs::File::create(&path).unwrap().set_len(11).unwrap();
- assert!(matches!(validate_json_size(&path, 10), Err(Error::ArtifactFormat { .. })));
+ assert!(matches!(read_regular_file(&path, 10, "JSON"), Err(Error::ArtifactFormat { .. })));
+ }
+
+ #[test]
+ fn run_manifest_retains_native_to_canonical_identity_mapping() {
+ let directory = tempfile::tempdir().unwrap();
+ write_run_manifest(directory.path(), directory.path(), "target", "criterion", &[], None).unwrap();
+ let results = parse_criterion(&fixture("criterion/minimal"), &fixture("criterion"), &[], None).unwrap();
+ record_discovered_identities(directory.path(), "criterion", &results).unwrap();
+ let manifest: EngineRunManifest = read_json(&directory.path().join("manifests/criterion.json")).unwrap();
+ assert_eq!(manifest.discovered_native_identities, [results[0].native_identity.clone()]);
+ assert_eq!(manifest.native_to_canonical[&results[0].native_identity], results[0].identity);
}
#[test]
diff --git a/crates/metabench/src/entrypoint.rs b/crates/metabench/src/entrypoint.rs
index ab66e30c5..ccb7d929b 100644
--- a/crates/metabench/src/entrypoint.rs
+++ b/crates/metabench/src/entrypoint.rs
@@ -9,6 +9,7 @@ macro_rules! __metabench_main {
criterion = $criterion_benchmarks:path,
gungraun = { $($gungraun:tt)* },
identities = $identities:expr
+ , probes = $probes:expr
$(, allocator = $allocator:path)?
$(,)?
) => {
@@ -21,6 +22,7 @@ macro_rules! __metabench_main {
criterion_unit = ::core::option::Option::Some("ns");
gungraun = { $($gungraun)* };
identities = $identities;
+ probes = $probes;
allocator = [$($allocator)?];
);
};
@@ -32,6 +34,7 @@ macro_rules! __metabench_main {
},
gungraun = { $($gungraun:tt)* },
identities = $identities:expr
+ , probes = $probes:expr
$(, allocator = $allocator:path)?
$(,)?
) => {
@@ -44,6 +47,7 @@ macro_rules! __metabench_main {
criterion_unit = $crate::__metabench_main!(@unit $( $unit)?);
gungraun = { $($gungraun)* };
identities = $identities;
+ probes = $probes;
allocator = [$($allocator)?];
);
};
@@ -59,6 +63,7 @@ macro_rules! __metabench_main {
criterion_unit = $criterion_unit:expr;
gungraun = { $($gungraun:tt)* };
identities = $identities:expr;
+ probes = $probes:expr;
allocator = [$($allocator:path)?];
) => {
$crate::__metabench_main!(@allocator $($allocator)?);
@@ -82,6 +87,10 @@ macro_rules! __metabench_main {
pub(super) fn run() {
main();
}
+
+ pub(super) fn run_probe() -> Result<(), String> {
+ $crate::__private::run_probe_groups($probes, __run_setup, __run_teardown)
+ }
}
fn main() {
@@ -90,6 +99,7 @@ macro_rules! __metabench_main {
__metabench_criterion::run,
__metabench_gungraun::run,
$criterion_unit,
+ __metabench_gungraun::run_probe,
),
$identities,
env!("CARGO_CRATE_NAME"),
diff --git a/crates/metabench/src/error.rs b/crates/metabench/src/error.rs
index f53d4f9a6..bddf4bee8 100644
--- a/crates/metabench/src/error.rs
+++ b/crates/metabench/src/error.rs
@@ -6,9 +6,12 @@ use std::path::PathBuf;
use std::time::Duration;
use std::{error, fmt, io};
+use crate::runner::terminal_text;
+
#[derive(Debug)]
pub(crate) enum Error {
InvalidMode(String),
+ Probe(String),
UnsupportedMode {
mode: &'static str,
reason: &'static str,
@@ -25,7 +28,6 @@ pub(crate) enum Error {
ConflictingOutputOptions,
ConflictingBaselineOptions,
CurrentExecutable(io::Error),
- CurrentDirectory(io::Error),
HelpOutput(io::Error),
ConsoleOutput(io::Error),
Spawn {
@@ -91,6 +93,7 @@ impl fmt::Display for Error {
#[expect(clippy::renamed_function_params, reason = "the descriptive name improves readability")]
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
+ Self::Probe(message) => write!(formatter, "native benchmark probe failed: {}", terminal_text(message)),
Self::InvalidMode(mode) => {
write!(
formatter,
@@ -130,7 +133,6 @@ impl fmt::Display for Error {
Self::ConflictingOutputOptions => formatter.write_str("--no-output, --output, --export-json, and --export-md cannot conflict"),
Self::ConflictingBaselineOptions => formatter.write_str("--no-baseline cannot be combined with --baseline"),
Self::CurrentExecutable(error) => write!(formatter, "failed to locate the benchmark executable: {error}"),
- Self::CurrentDirectory(error) => write!(formatter, "failed to locate the benchmark working directory: {error}"),
Self::HelpOutput(error) => write!(formatter, "failed to write help text: {error}"),
Self::ConsoleOutput(error) => write!(formatter, "failed to write console output: {error}"),
Self::Spawn { mode, source } => write!(formatter, "failed to launch the {mode} worker: {source}"),
@@ -144,9 +146,9 @@ impl fmt::Display for Error {
Self::CreateWorkerToken(error) => write!(formatter, "failed to create an internal worker token: {error}"),
Self::ConsumeWorkerToken(error) => write!(formatter, "failed to consume an internal worker token: {error}"),
Self::PerfControl(error) => write!(formatter, "failed to control Linux perf counters: {error}"),
- Self::PerfWorkloadNotMeasured => formatter.write_str("the perf worker did not execute an instrumented workload"),
+ Self::PerfWorkloadNotMeasured => formatter.write_str("the perf worker did not execute its generated probe"),
Self::VtuneControl(error) => write!(formatter, "failed to control the VTune collection: {error}"),
- Self::VtuneWorkloadNotMeasured => formatter.write_str("the vtune worker did not execute an instrumented workload"),
+ Self::VtuneWorkloadNotMeasured => formatter.write_str("the vtune worker did not execute its generated probe"),
Self::MultipleModeFailures(failures) => {
formatter.write_str("multiple benchmark failures occurred")?;
for failure in failures {
@@ -162,7 +164,9 @@ impl fmt::Display for Error {
"unsupported report schema {found} in {}; expected schema {expected}",
path.display()
),
- Self::DuplicateReportBenchmark(identity) => write!(formatter, "report contains duplicate benchmark '{identity}'"),
+ Self::DuplicateReportBenchmark(identity) => {
+ write!(formatter, "report contains duplicate benchmark '{}'", terminal_text(identity))
+ }
Self::Metadata(message) => write!(formatter, "failed to collect environment metadata: {message}"),
Self::InvalidThreshold(value) => write!(
formatter,
@@ -183,7 +187,6 @@ impl error::Error for Error {
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
match self {
Self::CurrentExecutable(source)
- | Self::CurrentDirectory(source)
| Self::HelpOutput(source)
| Self::ConsoleOutput(source)
| Self::CreateWorkerToken(source)
@@ -205,6 +208,23 @@ impl error::Error for Error {
mod tests {
use super::*;
+ #[test]
+ fn probe_diagnostics_escape_untrusted_messages() {
+ let message = "unsupported option --bad\n\u{1b}[31m\r\t\u{061c}\u{200e}\u{200f}\u{202e}\u{2066}";
+ let error = Error::Probe(message.to_owned());
+ assert_eq!(
+ error.to_string(),
+ r"native benchmark probe failed: unsupported option --bad\n\u{1b}[31m\r\t\u{61c}\u{200e}\u{200f}\u{202e}\u{2066}"
+ );
+ assert!(matches!(error, Error::Probe(raw) if raw == message));
+ }
+
+ #[test]
+ fn duplicate_report_diagnostics_escape_disk_supplied_identities() {
+ let error = Error::DuplicateReportBenchmark("case\n\u{1b}[31m\u{202e}".into());
+ assert_eq!(error.to_string(), r"report contains duplicate benchmark 'case\n\u{1b}[31m\u{202e}'");
+ }
+
#[test]
fn source_returns_the_wrapped_io_error_for_control_protocol_variants() {
let error = Error::VtuneControl(io::Error::other("control pipe closed"));
diff --git a/crates/metabench/src/identity.rs b/crates/metabench/src/identity.rs
index 1779e0b9e..27d0ab643 100644
--- a/crates/metabench/src/identity.rs
+++ b/crates/metabench/src/identity.rs
@@ -72,10 +72,6 @@ impl BenchmarkIdentity {
pub(crate) fn gungraun_identity(self) -> String {
join_identity(self.gungraun_group, self.gungraun_benchmark)
}
-
- pub(crate) const fn allocation_tracking(self) -> bool {
- self.allocation_tracking
- }
}
fn join_identity(group: &str, benchmark: &str) -> String {
diff --git a/crates/metabench/src/lib.rs b/crates/metabench/src/lib.rs
index d9a0a0446..0c4683ae3 100644
--- a/crates/metabench/src/lib.rs
+++ b/crates/metabench/src/lib.rs
@@ -78,6 +78,11 @@
//!
//! # Measurement engines
//!
+//! A **native case** is a case supplied by a benchmark's Gungraun declarations,
+//! including its implicit single case when no case attribute is present.
+//! **Probes** are the separate allocation, perf, and `VTune` workers that execute
+//! these cases; they do not use Criterion registrations.
+//!
//! As mentioned, metabench supports five distinct benchmark tools: Criterion, Gungraun, allocation
//! tracking, Linux `perf`, and Intel `VTune`:
//!
@@ -89,15 +94,19 @@
//! enabled, system calls, and heap activity.
//!
//! - **Allocation tracking** reports allocation count and allocated bytes for
-//! one invocation of every discovered Criterion case.
+//! one invocation of every declared native case. Counts
+//! are process-wide, so concurrent threads can contribute allocations.
//!
//! - **Linux `perf`** reports supported hardware and software counters for one
-//! invocation of every discovered Criterion case. Counters cover only the
-//! annotated workload. Use repeated `--perf-arg` options for additional
+//! invocation of every declared native case. Collection starts and stops in
+//! a private caller around the workload, excluding input preparation and
+//! returned-value teardown. The collection window can include caller and
+//! control overhead; it is not a claim of exact function-only counters.
+//! Use repeated `--perf-arg` options for additional
//! `perf stat` arguments.
//!
//! - **Intel `VTune`** reports hardware event counts for one invocation of every
-//! discovered Criterion case, using the same annotated-workload
+//! declared native case, using the same private caller
//! measurement as Linux `perf`. Because `VTune`'s hardware-event collection
//! is a statistical sampler rather than an exact counter, reported counts
//! are sampling estimates that can read zero for a short workload, and the
@@ -118,9 +127,6 @@
//!
//! Some limitations around engines:
//!
-//! - Benchmark functions declared with `const fn` can be measured by Criterion and Gungraun, but not
-//! by allocation tracking, `perf`, or `VTune`.
-//!
//! - Gungraun and Linux `perf` are only available on Linux platforms. `VTune` is
//! available on Linux and Windows, provided a `vtune` installation is
//! reachable on `PATH`. `VTune`'s integration test exercises the control
@@ -128,6 +134,34 @@
//! `vtune` fixture (see `tests/spawned_benchmark.rs`), rather than a real
//! `VTune` install.
//!
+//! # Workload boundaries
+//!
+//! The attribute preserves a non-inlined workload symbol without injecting
+//! runtime collector checks into the function. Criterion callbacks call it
+//! directly, and Gungraun uses its normal adapter. Neither executes the private
+//! allocation, perf, or `VTune` collection controls.
+//!
+//! Allocation and hardware workers reuse the existing `#[bench::...]` and
+//! `#[benches::...]` declarations through separate generated adapters. Native
+//! suite and group hooks run outside collection. Case argument evaluation and
+//! setup precede collection; teardown and returned-value destruction follow it.
+//! Calls to annotated functions during setup cannot consume the measurement.
+//! Collection guards stop on unwinding.
+//!
+//! No new registration language or public measurement helper is required. Probes
+//! do not run Criterion registration or infer its arbitrary callbacks. A
+//! Criterion-only case without a corresponding native declaration has no probe
+//! result. Matching case names do not prove matching Criterion inputs or cleanup;
+//! those remain author responsibilities.
+//!
+//! Probe execution reuses native case arguments and lifecycle hooks, not
+//! Valgrind configuration such as sandboxes or environment overrides. Configure
+//! hardware tools with their own forwarded arguments.
+//!
+//! Input destruction inside the benchmark function is included; return owned
+//! inputs with the output if their destruction should happen outside collection.
+//! Functions declared with `const fn` can also have runtime probes.
+//!
//! # Configure Criterion
//!
//! Provide a Criterion factory to customize measurements or other Criterion
@@ -147,6 +181,9 @@
//!
//! # Configure Gungraun
//!
+//! The installed `gungraun-runner` must match the Gungraun library version
+//! (currently 0.19.4).
+//!
//! Benchmark-level `gungraun_config`, `gungraun_setup`, and
//! `gungraun_teardown` options work with native `#[bench::...]` and
//! `#[benches::...]` cases:
@@ -209,12 +246,51 @@
//! metadata, environment information, measured engines, and their metrics.
//! JSON retains every available metric and includes human-readable display
//! names.
+//! Criterion's `Time` is its slope estimate when available, otherwise its mean,
+//! with the statistic and matching confidence level and bounds retained in JSON
+//! and Markdown. Custom measurement units remain unchanged. `Input bytes` denotes
+//! the declared work size, not allocated memory or a throughput rate.
+//! Confidence levels must be strictly between zero and one, with finite, ordered
+//! bounds. Bootstrap intervals need not contain the point estimate. Markdown
+//! encodes report text as literal content, and terminal cells visibly escape
+//! controls and untrusted line breaks; JSON preserves the original strings.
//!
//! When the JSON output path already exists, it is used as the baseline before
//! being replaced. `--baseline` selects another report, while `--no-baseline`
-//! disables comparison. Metrics with compatible names, units, and directions
+//! disables comparison. Report schema 9 distinguishes these estimates from old
+//! median-only reports. Old automatic baselines are warned about and skipped;
+//! an explicitly selected unsupported schema is an error. Different target,
+//! compiler, CPU, profile, or compiler flags also warn and skip comparison.
+//! Compiler identity is captured when the executable is built, not from the
+//! runtime `RUSTC` override or the compiler currently on `PATH`.
+//! These checks do not prove identical hardware, features, inputs, or boundaries.
+//! Metrics with compatible names, units, directions, and timing statistics
//! are classified as improved, stable, or regressed. The default regression
//! threshold is five percent; change it with `--regression-threshold`.
+//! These percentage labels are not statistical significance or equivalence tests.
+//!
+//! Smoke runs (`--test`, including a forwarded native `--test`) run Criterion or
+//! Gungraun cases briefly without collecting probe metrics or publishing reports.
+//! Allocation, perf, and `VTune` smoke selections fall back to Criterion registration,
+//! which must be provided even when the target declares native probe cases.
+//! An invocation
+//! without arguments is also smoke mode, matching Cargo's harness-free test
+//! invocation; pass an engine selector to request measurements directly.
+//! A measurement selection matching no cases fails and preserves existing reports.
+//! Failed multi-engine runs can write separate `.partial` reports but never
+//! replace the complete rolling baseline.
+//!
+//! Default native artifacts and combined reports follow the Cargo output root
+//! of the running executable, including `--target-dir`, target triples, and recognized
+//! custom profiles. `CARGO_TARGET_DIR` identifies a target root whose name matches
+//! the target triple without overriding a different executable root selected by Cargo.
+//! A relocated executable in an unknown layout needs an explicit
+//! `CARGO_TARGET_DIR`; metabench does not guess a workspace root.
+//! Hidden worker stdout and stderr share a pipe. Each run log retains at most
+//! 1 MiB of raw output, keeping the tail with an explicit truncation marker.
+//! Failures and timeouts show the final 8 KiB and the log path. Capture is bounded
+//! while workers run, and inherited pipe writers cannot delay cleanup indefinitely.
+//! `--show-engine-output` instead inherits the terminal without capturing a log.
//!
//! # Command-line options
//!
@@ -253,17 +329,39 @@
//! `gungraun`, `perf`, `vtune`, or `allocations` when no engine selector is present.
//! `CRITERION_HOME` and `GUNGRAUN_HOME` select the corresponding engine data
//! directories.
+//! Filters remain engine-specific: use Criterion arguments for Criterion and
+//! probes, and Gungraun arguments for Gungraun. Probes apply the Criterion name
+//! filter (and `--exact`) to declared native case identities, then run a private
+//! indexed worker. Criterion timing, profiling, and baseline options do not
+//! affect probe execution. A common cross-engine filter is not supported.
+//! Native Gungraun symbols include readable registration-identity suffixes and
+//! collision-safe hashes; run manifests retain native-to-canonical mappings.
+//! Renamed native symbols do not automatically reconnect older native histories.
//!
//! # Custom memory allocator
//!
//! The system allocator
//! is used by default; `main!(..., allocator = MyAllocator)` selects another
//! underlying allocator.
+//! Put `allocator` after `benchmarks` or `groups` in [`main!`]. The executable
+//! may define only one global allocator. Any existing `alloc_tracker`
+//! instrumentation must resolve to the same Cargo package source as the metabench
+//! tracker; a registry dependency and a separate path dependency have independent
+//! tracking state even if their names and versions match.
+//! The generated allocator checks one process-wide atomic flag on allocation,
+//! reallocation, and deallocation. Tracking is enabled only in allocation workers; Criterion,
+//! Gungraun, perf, and `VTune` bypass the tracking counters and thread-local state.
+//! The flag check still adds overhead to allocating workloads; it is not a
+//! zero-overhead claim.
+//! Both paths use the same underlying allocator instance, including custom
+//! allocators. Allocation workers track process-wide totals, with snapshots
+//! excluding setup and teardown from reported counts.
#[doc(hidden)]
pub mod __private;
mod allocation;
+mod allocator;
mod arguments;
mod artifact;
mod entrypoint;
@@ -272,9 +370,12 @@ mod identity;
mod metric_display;
mod mode;
mod perf;
+mod probe;
+mod probe_error;
mod report;
mod runner;
mod vtune;
+mod worker_log;
#[doc(inline)]
pub use identity::BenchmarkIdentity;
diff --git a/crates/metabench/src/metric_display.rs b/crates/metabench/src/metric_display.rs
index f161c486e..d4a75b535 100644
--- a/crates/metabench/src/metric_display.rs
+++ b/crates/metabench/src/metric_display.rs
@@ -31,17 +31,18 @@ impl TableMetric {
fn descriptor(key: &str) -> (Cow<'static, str>, u8, u8) {
let known = match key {
- "criterion/median" => ("Time", 1, 1),
+ "criterion/time" => ("Time", 1, 1),
+ "criterion/median" => ("Median time", 1, 1),
"alloc_tracker/Allocations" => ("Allocs", 1, 12),
- "alloc_tracker/Allocated bytes" => ("Bytes", 1, 13),
+ "alloc_tracker/Allocated bytes" => ("Allocated bytes", 1, 13),
"gungraun.callgrind/Ir" => ("Instr", 1, 2),
"gungraun.cachegrind/Ir" => ("Instr", 1, 3),
- "gungraun.callgrind/EstimatedCycles" => ("Cycles", 1, 4),
+ "gungraun.callgrind/EstimatedCycles" => ("Est cycles", 1, 4),
"perf/instructions" => ("HW Instr", 1, 5),
"perf/cycles" => ("HW Cycles", 1, 6),
"criterion/declared_throughput.bits" => ("Bits", 1, 7),
- "criterion/declared_throughput.bytes" => ("Bytes", 1, 8),
- "criterion/declared_throughput.bytes_decimal" => ("Decimal Bytes", 1, 9),
+ "criterion/declared_throughput.bytes" => ("Input bytes", 1, 8),
+ "criterion/declared_throughput.bytes_decimal" => ("Input bytes (decimal)", 1, 9),
"criterion/declared_throughput.elements" => ("Elements", 1, 10),
"criterion/declared_throughput.elements_and_bytes" => ("Elements And Bytes", 1, 11),
@@ -201,4 +202,23 @@ mod tests {
assert_eq!(metric.label, "INST RETIRED ANY");
assert_eq!(metric.priority, 10);
}
+
+ #[test]
+ fn primary_metric_descriptors_preserve_labels_and_order() {
+ for (engine, key, label, rank) in [
+ ("criterion", "time", "Time", 1),
+ ("criterion", "median", "Median time", 1),
+ ("gungraun.callgrind", "Ir", "Instr", 2),
+ ("gungraun.cachegrind", "Ir", "Instr", 3),
+ ("gungraun.callgrind", "EstimatedCycles", "Est cycles", 4),
+ ("criterion", "declared_throughput.bits", "Bits", 7),
+ ("criterion", "declared_throughput.bytes", "Input bytes", 8),
+ ("criterion", "declared_throughput.bytes_decimal", "Input bytes (decimal)", 9),
+ ("alloc_tracker", "Allocated bytes", "Allocated bytes", 13),
+ ] {
+ let metric = TableMetric::new(engine.to_owned(), key.to_owned());
+ assert_eq!(metric.label, label);
+ assert_eq!(metric.sort_key(), (1, rank, engine, key));
+ }
+ }
}
diff --git a/crates/metabench/src/perf.rs b/crates/metabench/src/perf.rs
index f94aba354..9013e70d4 100644
--- a/crates/metabench/src/perf.rs
+++ b/crates/metabench/src/perf.rs
@@ -7,8 +7,8 @@ use std::io::{self, Read as _, Write as _};
#[cfg(target_os = "linux")]
use std::os::fd::AsFd as _;
use std::path::PathBuf;
+use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::{LazyLock, Mutex};
#[cfg(target_os = "linux")]
use nix::poll::{PollFd, PollFlags, poll};
@@ -18,15 +18,7 @@ use crate::error::Error;
pub(crate) const ACK_ENV: &str = "METABENCH_INTERNAL_PERF_ACK";
pub(crate) const CONTROL_ENV: &str = "METABENCH_INTERNAL_PERF_CONTROL";
-// `perf` control is only ever driven by our own Linux-only worker process
-// (see `runner::run_perf`), which is the only thing that sets `CONTROL_ENV`.
-// Still, force this to `false` off Linux regardless of the environment:
-// `Control::command` only guards its FIFO read with a poll timeout under
-// `#[cfg(target_os = "linux")]`, so contaminated environment variables on
-// another platform (for example inherited across a `cargo bench` re-exec)
-// would otherwise attempt an unbounded blocking read on a FIFO that is
-// never written to.
-static ACTIVE: LazyLock = LazyLock::new(|| cfg!(target_os = "linux") && env::var_os(CONTROL_ENV).is_some());
+static ACTIVE: AtomicBool = AtomicBool::new(false);
static ERROR: Mutex