Skip to main content

Performance and Benchmark Reports

LoraDB benchmarks are meant to answer two different questions:

QuestionSourceUse it for
Is this build obviously slower?perf-smoke CI workflowPull request review and regression triage
What does this release look like overall?Manual benchmarks workflowRelease notes, dashboards, and capacity planning experiments

The shared output is benchmark-summary.json: a compact JSON file generated from Criterion's bencher output. It gives you a table-friendly view of the current database state without scraping terminal logs.

note

CI runners are noisy. Treat these numbers as a trend signal for the same benchmark on the same workflow, not as a hardware-independent promise.

Current Snapshot​

Representative numbers below come from the Criterion benchmark suite used for LoraDB performance tracking. Throughput units are benchmark specific: scans count nodes, traversals count edges or paths, writes count entities or full write operations.

AreaBenchmarkDatasetMean timeThroughputUnitWhat it tells you
Scanmatch/all_nodes/10001,000 nodes285.13 µs3,507,211nodes/secFull label scan plus projection
Countaggregation/count_star/10001,000 rows59.52 µs16,801,625rows/secFast aggregate path without row projection
Traversaltraversal/single_hop_chain/10001,000-node chain527.42 µs1,894,126edges/secBasic relationship iteration
Traversaltraversal/tree_depth3_branch5_traverse155 descendants55.63 µs2,786,462paths/secBounded tree expansion
Orderingordering/order_by_single/10001,000 rows853.55 µs1,171,582rows/secSort cost for a single key
Writewrite/create_single_nodefresh DB8.60 µs116,331ops/secSingle-node write overhead
Writewrite/batch_create_unwind/500500 creates554.79 µs901,238nodes/secBatched UNWIND insertion
Workloadrealistic/social_friend_of_friend_500500 persons399.46 µs2,503queries/secRepresentative two-hop social query

Live Smoke Report​

The PR-facing perf-smoke workflow produces a smaller table with a baseline comparison. The table below is loaded from /benchmarks/perf-smoke-summary.json on the live website.

Loading benchmark summary from /benchmarks/perf-smoke-summary.json…

The smoke gate fails only when a benchmark is slower than its configured threshold. The default threshold is 3.0x, because shared CI machines vary enough that a tight gate would be noisy instead of useful.

JSON Shape​

The summary artifact is designed to be boring to consume. The useful top-level fields are:

FieldTypePurpose
schema_versionnumberIncremented only when the artifact contract changes
generated_atstringISO timestamp for the summary generation time
suitestringWorkflow or explicit suite name, such as perf-smoke
source.githubobjectRepository, SHA, ref, workflow, run id, and actor when produced in GitHub Actions
source.runnerobjectOS, CPU architecture, and CPU count
summaryobjectBenchmark count, group count, fastest and slowest benchmarks
baselineobjectCompared, ok, regressed, new, and missing benchmark counts
groupsarrayPer-group rollups: count, fastest, median, p95, slowest, average
benchmarksarrayOne row per benchmark, ready for table rendering

Each benchmarks[] row contains:

FieldMeaning
nameFull Criterion name, for example perf_smoke/scan_1k
groupFirst path segment, for example perf_smoke
caseRemaining path segments
ns_per_iterCurrent mean time in nanoseconds per iteration
error_nsCriterion reported error in nanoseconds
relative_error_pcterror_ns / ns_per_iter * 100
iterations_per_secondDerived 1_000_000_000 / ns_per_iter
baseline.statusok, regressed, or new when a baseline is supplied
baseline.ratiocurrent / baseline
baseline.thresholdMaximum allowed ratio for the smoke gate

Render A Table​

After downloading a workflow artifact, render the benchmark rows into a developer-readable table with jq:

jq -r '
["benchmark","current_ns","baseline_ns","ratio","status"],
(.benchmarks[] | [
.name,
.ns_per_iter,
(.baseline.ns_per_iter // ""),
(.baseline.ratio // ""),
(.baseline.status // "")
])
| @tsv
' benchmark-summary.json

For Markdown:

jq -r '
"| Benchmark | Current ns | Baseline ns | Ratio | Status |",
"|---|---:|---:|---:|---|",
(.benchmarks[] |
"| `\(.name)` | \(.ns_per_iter) | \(.baseline.ns_per_iter // "") | \(.baseline.ratio // "") | \(.baseline.status // "") |"
)
' benchmark-summary.json

Reproduce Locally​

Run the smoke suite and produce the same JSON shape:

cargo bench -p lora-database --bench perf_smoke \
-- --output-format bencher > bencher.log

node scripts/summarize-benchmarks.mjs \
--input bencher.log \
--output benchmark-summary.json \
--baseline crates/lora-database/benches/perf_smoke_baseline.json \
--suite perf-smoke-local

node scripts/check-perf-smoke.mjs --input bencher.log

Run the full suite when you want a release-level snapshot:

cargo bench --locked -p lora-database --benches \
-- --output-format bencher > benchmarks.log

node scripts/summarize-benchmarks.mjs \
--input benchmarks.log \
--output benchmark-summary.json \
--suite release-benchmarks

Reading Regressions​

SignalMeaningNext step
baseline.regressed_count > 0One or more current timings exceeded thresholdInspect the named rows in baseline.regressions
baseline.new_count > 0A benchmark exists in the run but not the baselineAdd it deliberately with a baseline refresh
baseline.missing_count > 0A baseline entry was not producedCheck for renamed or deleted benchmark cases
High relative_error_pctCriterion saw noisy measurementsRe-run before drawing conclusions
One slow outlier in CIShared runner noise is possibleRe-run the job, then compare trend across runs

Query Shape Notes​

Lookup by id​

A WHERE conjunct id(n) = $id or id(n) IN $ids fetches the node directly (NodeByIdSeek in EXPLAIN) instead of scanning, with or without a label on n, and also for writes such as MATCH (n) WHERE id(n) = $id SET n.x = 1. The same holds for a relationship: MATCH (a)-[r]->(b) WHERE id(r) = $id plans as RelByIdSeek. The value must not read n itself, and the conjunct must be joined to the rest of the WHERE with AND: under OR, or as id(n) + 0 = $id, the query scans.

Unbound start nodes in pattern subqueries​

A pattern comprehension, EXISTS { } or COUNT { } runs once per outer row. When its start node is not bound by the outer row, write the lookup key as an inline property map. LoraDB then seeks that property through an index, the same way MATCH does, instead of scanning the whole label once per outer row:

// Seeks :Person(subject) once per row
MATCH (n:Post)
WHERE size([(v:Person {subject: $s}) WHERE v.verified | 1]) > 0
RETURN count(n) AS c

The seek applies to the inline map only. Moving the same key into the inner WHERE ((v:Person) WHERE v.subject = $s) keeps the per-row label scan, so its cost grows with the size of the label times the number of outer rows. Other predicates, such as v.verified above, still belong in WHERE.

Constant values in a filter​

A temporal or spatial value written with constant arguments, such as datetime('2024-01-01T00:00:00Z'), date('2024-01-01'), duration('PT1H') or point({x: 0, y: 0}), is built once when the query is planned, not again for every row. Casts and math.* functions on constant arguments are folded the same way. A value built from a parameter (datetime($since)) is still built per row, since a plan is reused across parameter values. The clock (datetime(), timestamp()), random values and a TIME are never folded, and a call that fails on its arguments, such as datetime('not a date'), still fails only when a row reaches it.

See Also​