Adding a benchmark
July 9, 2026 · View on GitHub
When you add or modify a hot path in Fluree, instrument it with a bench so regressions surface in CI rather than in production. This guide explains the six-step workflow, the chassis helpers, the conventions, and the gotchas.
For running existing benches, see BENCHMARKING.md.
For the chassis API, see
fluree-bench-support/README.md.
For tracing conventions inside the database itself, see
tracing-guide.md.
When to add a bench
Add one when:
- you touch a hot path the existing benches don't already exercise (commit,
index build, reindex, incremental index, bulk import, novelty replay,
reload — see
Current categoriesbelow for the canonical list), - you add a new feature whose perf characteristics matter,
- you fix a regression and want a regression test that catches it next time.
You don't need a bench for purely correctness-driven changes. Most type refactors don't move numbers and shouldn't add a bench just because they touch hot files.
The six-step workflow
1. Choose a category and a name
A bench file lives at <crate>/benches/<category>_<name>.rs. Pick a
category from the table below or add a new one (see Adding a new category). The name should be specific: transact_commit_single_flake,
not transact_commit.
2. Copy the template
cp fluree-bench-support/templates/BENCH_TEMPLATE.rs \
<crate>/benches/<category>_<name>.rs
The template is a working bench against synthetic data with // TODO
markers at every spot you need to edit.
3. Fill in the scenario
Edit the bench_main function. Keep:
init_tracing_for_bench()at the top,let _rt = bench_runtime();for any async work,current_scale()andcurrent_profile()for env-driven sizing.
Replace synthetic_work(n) with the operation you actually want to measure.
Wrap inputs in black_box so the optimizer doesn't elide the work.
If your bench needs realistic data, reach for
fluree_bench_support::gen::* first:
| Need | Use |
|---|---|
| Linked-data person/company graph | gen::people::generate_txn_data(...) + gen::people::txn_data_to_jsonld(...) or txn_data_to_turtle(...) |
Random f64 vectors | gen::vectors::rng_one(rng, dim) (RNG-driven) or gen::vectors::hashed_pair(dim) (deterministic, no RNG) |
| Paragraph documents | gen::corpora::random_paragraph(rng) |
If your domain is genuinely new (e.g., spatial geometries, Turtle-import
edge cases), keep the generator co-located with the bench file. Lift it
into gen:: only when a second bench wants to reuse it.
4. Add the [[bench]] entry
Append to your crate's Cargo.toml:
[[bench]]
name = "<category>_<name>" # matches the file stem
harness = false # use criterion's harness, not libtest
If the crate doesn't already depend on fluree-bench-support and
criterion, add them to [dev-dependencies]:
[dev-dependencies]
criterion = "0.5"
fluree-bench-support = { path = "../fluree-bench-support" }
5. Register a regression budget
Append to regression-budget.json at the workspace root:
{
"crates": {
"<crate-name>": {
"<category>_<name>": {
"tiny": 10.0,
"small": 5.0,
"medium": 5.0
}
}
}
}
Numbers are percent regression allowed vs. the committed baseline. Omit a
scale to fall back to default_budget_pct (5%). The CI gate fails if an
observed run exceeds the budget for any listed scale.
If you don't yet have a baseline, leave the entries empty — default_budget_pct
applies. After your first nightly run lands a baseline, tighten the budget.
6. Document if you added a new category
If you introduced a category that's not in the Current categories table
below, add a row. One sentence per category is enough. Reviewers will ask
for this if you forget.
The chassis helpers
init_tracing_for_bench()
Idempotent. Call at the top of every bench_* entry point. Off by default
(zero overhead). Set FLUREE_BENCH_TRACING=1 to install a stderr
subscriber filtered by RUST_LOG.
next_ledger_alias(prefix)
Returns bench/{prefix}-{n}:main with an atomic counter that's unique
within the process. Use this when each criterion iteration creates a
fresh ledger:
b.iter(|| {
let alias = next_ledger_alias("commit");
rt.block_on(async {
let ledger = fluree.create_ledger(&alias).await.unwrap();
// ...
});
});
Don't hand-roll an AtomicU64 counter or interpolate a per-iteration index
into a format! literal — those patterns cause bench-vs-bench alias
collisions when criterion runs groups concurrently.
bench_runtime()
Single-threaded tokio runtime. Set FLUREE_BENCH_RUNTIME=multi for
multi-thread; only use multi when measuring code that intrinsically depends
on parallel scheduling (e.g., parallel bulk-import).
current_scale() and current_profile()
let scale = current_scale(); // BenchScale::{Tiny|Small|Medium|Large}
let profile = current_profile(); // BenchProfile::{Quick|Full}
let n = scale.elements_default(); // 1k / 10k / 100k / 1M
group.sample_size(profile.sample_size()); // 10 / 30
Benches with non-element metrics (bytes/sec, txns/sec, articles) should
override elements_default() with their own scale-aware mapping.
gen::*
Deterministic generators. Output is byte-identical across runs given the same parameters; the chassis tests pin this contract. Determinism matters because regression budgets compare against a stored baseline — a non-deterministic input would invalidate the baseline.
report::print_summary
Optional. Use when you want a human-readable cross-scenario table at the end of the run beyond what criterion's HTML report shows:
use fluree_bench_support::report::{print_summary, SummaryRow};
print_summary("insert_formats", &[
SummaryRow::new("jsonld 100x10").add("ms", jld_ms).add("flakes/s", jld_fps),
SummaryRow::new("turtle 100x10").add("ms", ttl_ms).add("flakes/s", ttl_fps),
]);
Current categories
A bench category is just a string in the file-name prefix and the budget
JSON. Adding a new category is one row here and one section in
regression-budget.json. CI accepts any category as long as it's
documented.
Where benches actually live today: every shipped bench (existing
and new) is currently under one of three crates' benches/ directories
— fluree-db-api/benches/, fluree-db-query/benches/,
fluree-db-spatial/benches/. The index, novelty, and core
categories below are end-to-end-realised under fluree-db-api/benches/
because their user-facing entry points (Fluree::reindex,
Fluree::trigger_index, fluree.graph(...).load()) live there. If a
future micro-bench wants to exercise fluree-db-indexer,
fluree-db-novelty, or fluree-db-core directly, it can add a
benches/ directory under that crate; the chassis already supports it.
| Category | Hot path | Where it lives today |
|---|---|---|
import | bulk Turtle / N-Quads / JSON-LD ingest | fluree-db-api/benches/import_bulk.rs |
transact | stage + commit | fluree-db-api/benches/transact_commit.rs |
reindex | full reindex; incremental | fluree-db-api/benches/reindex_full.rs, fluree-db-api/benches/reindex_incremental.rs |
query_hot | warm-cache query latency | fluree-db-api/benches/query_hot_bsbm.rs (BSBM Explore Q3/Q5/Q9), fluree-db-api/benches/query_hot_bsbm_bi.rs (BI-F2 bowtie / seed tie-break), fluree-db-api/benches/query_hot_whole_graph_agg.rs (Cypher metadata-lane aggregate folds vs. pipeline baseline) |
query_cold | reload + first-query latency | fluree-db-api/benches/query_cold_reload.rs |
novelty | replay, catch-up, bulk-apply | fluree-db-api/benches/novelty_replay.rs |
vector_math | SIMD vs scalar math micro-benches | fluree-db-query/benches/vector_math.rs |
spatial | S2 covering / build / query | fluree-db-spatial/benches/spatial_bench.rs |
insert_formats | JSON-LD vs Turtle insert format comparison | fluree-db-api/benches/insert_formats.rs |
vector_query | end-to-end vector similarity through the query engine | fluree-db-api/benches/vector_query.rs |
fulltext_query | full-text scoring through novelty + index | fluree-db-api/benches/fulltext_query.rs |
Reserved categories (not yet in use; add a row here when you ship
the first bench under that prefix): core (foundational ops —
e.g., fluree-db-core/benches/core_*.rs once added), query
(scan/join/aggregate micro-benches under fluree-db-query/benches/).
Common patterns
Setup that shouldn't be measured
Criterion's iter_batched accepts a setup closure that's not counted in
the timing. Use it when bench setup is heavy (large dataset generation,
ledger creation):
group.bench_with_input(BenchmarkId::new("commit", scale.as_str()), &n, |b, &n| {
b.iter_batched(
|| rt.block_on(setup_fresh_ledger(n)), // setup, not measured
|ledger| rt.block_on(commit_one_txn(ledger)), // measured
criterion::BatchSize::SmallInput,
);
});
One ledger per scenario, multiple iterations
When the bench reads from a populated ledger and doesn't mutate it, create the ledger once per scenario (not per iteration):
for &n in DATASET_SIZES {
let (fluree, ledger, query, ...) = rt.block_on(setup_dataset(n));
// ledger is reused across all b.iter() calls below
group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| {
b.iter(|| rt.block_on(async {
black_box(fluree.query(&ledger, &query).await.unwrap())
}));
});
}
One ledger per iteration
When the bench mutates state (commit, transact, index), each iteration needs a fresh ledger:
b.iter(|| {
let alias = next_ledger_alias("commit");
rt.block_on(async {
let ledger = fluree.create_ledger(&alias).await.unwrap();
// commit one txn ...
});
});
Throughput annotations
Choose units that make criterion's thrpt line meaningful:
Throughput::Elements(n)→elem/s(rows, flakes, articles)Throughput::Bytes(n)→B/s- omit → ns/op only
insert_formats.rs uses Throughput::Elements(total_flakes) so its output
is in flakes/s, the unit users care about for ingest performance.
Gotchas
Determinism is not optional
If a bench uses an RNG, seed it with StdRng::seed_from_u64(42) (or a
similar fixed seed). Benches that draw from rand::thread_rng() produce
non-comparable runs — every iteration sees different inputs and the
regression budget loses meaning.
The chassis generators are seeded internally and don't need an external RNG; if you generate something the chassis doesn't cover, follow the same discipline.
Don't hold span.enter() across .await
This is a generic tracing rule (see
tracing-guide.md) but worth re-emphasizing because
benches under FLUREE_BENCH_TRACING=1 will surface the cross-task
contamination as nonsense traces. Use .instrument(span) for async work.
Cold-vs-warm cache effects
The first iteration after a fresh build is always slower (CPU cache cold,
allocator warming up). Criterion's default warmup handles this, but if
your bench is short and warmup is skipped, drop the first sample manually
or use iter_batched with BatchSize::PerIteration.
Setup dominating the measurement
If your "measured" work is a 1-µs operation but setup is a 10-ms ledger
load, criterion will measure mostly setup. Move setup into iter_batched
or amortize it over a larger batch:
b.iter_batched(
|| setup_one_input(),
|inputs| inputs.into_iter().map(|x| measured_op(x)).collect::<Vec<_>>(),
criterion::BatchSize::SmallInput,
);
Runtime configuration leakage
Don't read FLUREE_BENCH_* env vars in your bench's hot loop —
current_scale() and current_profile() cache via OnceLock, but a
hand-rolled std::env::var call inside b.iter is a system call per
iteration. Read once, reuse.
iter_batched setup needs a tokio reactor to build Fluree
criterion::iter_batched's setup closure runs synchronously, outside
any block_on. If setup calls anything that requires a running tokio
reactor — including every FlureeBuilder::build* method — you'll get:
thread 'main' panicked: there is no reactor running, must be called from
the context of a Tokio 1.x runtime
The fix is to wrap setup work that touches the runtime in rt.block_on:
let rt = bench_runtime();
b.iter_batched(
// setup — wrap in block_on so the reactor is alive while
// FlureeBuilder::file(...).build() runs.
|| rt.block_on(async {
let dir = tempfile::tempdir().unwrap();
let fluree = FlureeBuilder::file(dir.path().to_string_lossy().to_string())
.build()
.unwrap();
(dir, fluree)
}),
|(_dir, fluree)| rt.block_on(async {
// measured op
}),
criterion::BatchSize::PerIteration,
);
FlureeBuilder::memory().build_memory() has the same constraint: even
though it is synchronous, it spawns the ledger-cache event listener task
whenever ledger caching is enabled (the default), so it too must run
inside rt.block_on. Use the memory builder when the bench's hot path
doesn't actually need disk I/O; reach for the file builder only when you
need to exercise persistence/load paths.
Workspace clippy lints apply to bench code
The workspace Cargo.toml denies several clippy lints
(see [workspace.lints.clippy]). Two that matter for benches:
needless_raw_string_hashes = "deny": writer"..."notr#"..."#unless the string actually contains". This usually surfaces in embedded SPARQL/Turtle string literals.uninlined_format_args = "deny": writeformat!("{x}")notformat!("{}", x)whenever the variable name is in scope.
Running cargo clippy --benches locally before pushing catches these.
Debugging a flaky bench
A bench is "flaky" when CI runs sometimes pass and sometimes fail with no code change. Diagnostic steps:
-
Run the same bench multiple times locally:
for i in 1 2 3 4 5; do cargo bench -p <crate> --bench <name> -- --quick doneIf results vary by more than the budget, the bench has high variance.
-
Increase sample size and warmup:
FLUREE_BENCH_PROFILE=full cargo bench -p <crate> --bench <name>Fullwidens the sample distribution; if variance is real, this exposes it; if variance was a sample-size artifact, it disappears. -
Capture a trace under tracing:
FLUREE_BENCH_TRACING=1 RUST_LOG=info,fluree_db_api=debug \ cargo bench -p <crate> --bench <name> -- --test 2> trace.logInspect the spans for setup/measurement separation issues.
-
If genuinely flaky on CI hardware: raise the budget for that bench in
regression-budget.jsonand document the reasoning in the PR. The gate exists to catch real regressions, not to chase shared-runner noise.
Capturing a span trace
The eventual JSON-emitting tracing layer
(FLUREE_BENCH_TRACING=file:./out.json) is reserved but not yet
implemented. Today it falls back to stderr.
Until the file mode ships, use the stderr mode plus shell redirection:
FLUREE_BENCH_TRACING=1 RUST_LOG=info,fluree_db_api=debug,fluree_db_query=debug \
cargo bench -p fluree-db-api --bench insert_formats -- --test 2> trace.log
Then grep for the spans you care about:
grep -E 'transact_commit|txn_stage|index_build' trace.log | head -50
Reviewing a bench PR
When reviewing someone else's bench, check:
- File name matches
<category>_<name>.rsand the category appears in this guide'sCurrent categoriestable. -
[[bench]]entry is present in the crate'sCargo.toml. -
regression-budget.jsonhas a matching entry (or default applies). - Bench uses
init_tracing_for_bench(),bench_runtime(),next_ledger_alias()(where applicable) — not hand-rolled equivalents. - Determinism: any RNG is seeded with a fixed seed; any non-chassis generator is byte-stable across runs.
-
black_boxwraps the measured operation's inputs or outputs so LLVM doesn't elide it. - Throughput annotation matches the units in the bench's docstring.
- Bench compiles with
cargo bench --no-run -p <crate> --bench <name>and runs with-- --test.
Future work
BenchSpanLayerfile-mode tracing (FLUREE_BENCH_TRACING=file:...) is reserved but unimplemented; today it falls back to stderr.fixtures::load_or_generateis a stub today; vendored fixture loading and remote fetch are tracked in thebench-nightlyfollow-up.- The bench/budget reconciler runs as the
workspace_reconcileintegration test (fluree-bench-support/tests/workspace_reconcile.rs) and is invoked by thebench-gateCI job — there is no library function for it.