Benchmark methodology¶
Every number published on the Performance page comes from one run of one harness, and the file it comes from is committed to the repository. This page describes how those numbers are produced and what they do and do not claim.
Reproducing a run¶
pip install -e ".[bench,all]"
python benchmarks/download_corpora.py # ~257 MB, five corpora
python benchmarks/06_mega.py --legacy-timeout 5400 --publish
# then regenerate the published tables from the measurements
python tools/gen_benchmark_tables.py --write
--publish writes benchmarks/results/06_mega.<platform>.json and refuses
a file that mixes runs: every corpus must have been measured by that same
invocation, on a clean tree, and the result must satisfy the schema-v3 evidence
contract. It never writes benchmarks/results/06_mega.json — that name holds
the preserved macOS artifact, whose bytes are pinned by three separate digests.
tools/gen_benchmark_tables.py --check verifies that every published table
still matches the committed measurements, and is asserted by
tests/test_release_hygiene.py — so a table cannot drift from the numbers
behind it without a test failing.
What is measured¶
Metric |
How |
|---|---|
Ingest wall |
|
Peak RSS |
parent polls the child’s RSS (plus its children) every 50 ms |
On-disk size |
recursive size of the finished database |
Spatial qps |
5 000 regions sampled from the corpus’s own per-seqid spans |
Batched extraction |
|
Region sampling uses a fixed seed (20260501), recorded in the results file,
so two runs sample the same regions.
Corpora¶
The five canonical human-genome annotations, downloaded from their primary
sources by benchmarks/download_corpora.py: GENCODE v49 basic (both the
GTF and the GFF3 release of the same biology), RefSeq GRCh38.p14, MANE v1.5
(Ensembl IDs) and CHESS 3.1.3.
Fairness¶
Both engines get
merge_strategy="create_unique". This is not cosmetic: under the default"error"both refuse RefSeq and MANE outright, and timing gffbase under one duplicate-ID policy against gffutils under another would compare two different workloads on exactly the axis that decides whether the run completes.The primary GENCODE GTF comparison disables parent inference for both engines, which makes the authored records directly comparable. Separate non-headline controls exercise default inference and a parent-stripped GTF; their results are never mixed into the primary namespace.
Each engine runs in its own subprocess, so peak RSS is attributable and neither inherits the other’s warm caches.
Both write to the same filesystem.
What is not comparable¶
Spatial and batched columns are gffbase-only.
gffutilshas no spatial index and no batched API, so there is nothing to put in the other column. Theregion()throughput comparison in the narrative sections is like-for-like; the per-corpus qps figures are not a head-to-head.Query benchmarks run against a warm page cache, immediately after the ingest that built the database. They measure steady-state query throughput, not cold-start.
Capped runs, and why there are no extrapolated numbers¶
The default-inference GENCODE v49 GTF control can exceed a practical run
window: GTF has no explicit parents, so every gene and transcript row must be
invented through the legacy Python/SQLite path. Every comparator arm therefore
has a --legacy-timeout safety valve (default 90 minutes), including arms that
normally finish well below it.
A capped run is censored, not measured. In a current result its state is
timed_out, cap_seconds records the safety valve, and wall_seconds is
null. It produces neither a speedup nor a speedup floor. Tables render only
censored at 90 min in the comparator column.
The preserved 2026-08-15 Mac artifact predates this contract and uses schema
v2 names such as wall_seconds_lower_bound. Its bytes remain historical
evidence, but the renderer treats those fields only as censoring metadata and
never repeats the old > N× claim.
This replaced a hardcoded wall_seconds = timeout × 2.0. That factor had no
measurement behind it, and it was the sole source of the previously published
“≥ 2 hr 30 min” legacy wall and “≥ 32×” headline. A number produced by
multiplying a timeout is not a result, and printing one beside real
measurements invites a reader to distrust all of them.
The table generator refuses to render a row whose legacy run timed out but still carries a wall time, so the defect cannot come back quietly.
Uncertainty¶
The headline sweep is n = 1 per cell: legacy ingest of the large corpora takes hours, and repeating the whole sweep three times is not a good use of a day. Run-to-run spread is instead measured on the two cheap corpora, which share the same code paths:
python benchmarks/06_mega.py --repeats 5 --only mane --only chess
--repeats applies to the two cheap in-process measurements — the spatial
sweep and the batched extraction — and not to the ingest walls, where one
GENCODE job already runs over an hour: 71 minutes for GENCODE GFF3, of which the
two ingests are about 21 and signature verification the rest. (The legacy
ingest itself is 7–10 minutes on the published arms; it is the unpublished
default-inference GTF arm that does not finish inside 90.) When it is greater than
1, the first pass is discarded as a warm-up: a smoke test measured a 6.7×
max/min ratio that was entirely cold page cache, and a spread that is really a
cold-start artifact is worse than no spread, because it gets published as
measurement uncertainty. After the warm-up the same measurement spreads under
5%.
Treat that spread as the measurement uncertainty for every cell. Where a
result file reports n = 1 it carries a value key and deliberately no
median, so a renderer cannot present a single sample as a central tendency.
Equal work, or no ratio¶
A speedup is recorded only when both completed databases have the same strict
database-signature-v3. The signature covers every logical segment,
canonicalized per-segment attributes (including empty flags and value order),
direct relationships, minimum-depth closure, feature counts, and the
feature-type histogram. Feature counts remain a useful diagnostic but are not
accepted as a correctness proof by themselves.
Attribute key order is deliberately canonicalized lexically because GFF/GTF key order is non-semantic and engines may expose inferred attributes differently. Value order is retained within each key, so reordering values changes the signature while reordering keys does not.
Two further normalizations exist because the comparison must not mistake a difference in representation for a difference in content. Both were found by whole-genome runs, and each had cost a corpus its published speedup.
The closure is derived, not read. gffutils stores relationships in a
relations table, but what it records at level = 2 is not a transitive
closure: it inserts, for each feature, the children of its children — one hop,
at a fixed level, with no iteration to a fixed point. On a four-deep chain it
records five ancestor–descendant pairs and omits the sixth. gffbase stores the
real closure, so comparing the two reported RefSeq as differing by 3,218 pairs
and GENCODE GFF3 by 108 — on hierarchies whose direct edges agreed exactly.
The closure is a function of those edges, so the signature now computes it from
them for the comparator rather than trusting the stored cache.
A comma followed by a space is not a separator. GFF3 says an unescaped
comma separates values and that a literal comma must be percent-encoded.
gffbase follows that; gffutils deliberately does not, keeping , `` inside the
value so an unescaped ``description=kinase, subunit 1 survives as one value.
Ten CHESS genes record two names as gene_name=ADAM6, RPS8P1, which reported
that corpus as a 20-row divergence. The signature therefore applies one rule to
both engines, and it is the comparator’s coarser one: re-splitting on every
comma would shatter 2,294 correctly escaped CHESS descriptions in order to line
up twenty gene names. The parse-policy difference itself is a declared API
deviation, recorded in tests/parity/deviations.toml and pinned by a
differential test — it belongs there, not hidden inside a benchmark digest.
What the ingest numbers do and do not say¶
Two properties of the ingest measurement bound how much weight a single figure carries, and both were measured rather than assumed.
Ingest is attribute-bound, and essentially serial. Cost tracks the number of attributes far better than the number of features. Dividing each corpus’s attribute values by its published ingest wall:
corpus |
attribute values |
per feature |
gffbase attr/s |
legacy attr/s |
|---|---|---|---|---|
MANE v1.5 |
6,711,851 |
12.8 |
167,700 |
146,900 |
GENCODE v49 (GFF3) |
108,289,882 |
17.9 |
162,000 |
180,900 |
GENCODE v49 (GTF) |
96,291,901 |
15.9 |
156,800 |
228,800 |
RefSeq GRCh38.p14 |
55,104,379 |
11.2 |
130,600 |
139,900 |
CHESS 3.1.3 |
7,063,651 |
2.6 |
62,400 |
51,600 |
Across the three attribute-dense corpora – 12.8 to 17.9 attributes per feature – gffbase holds 157,000 to 168,000 attributes per second, a seven percent band over a sixteen-fold range of corpus size. The rate falls off as attributes per feature falls, to 130,600 on RefSeq and 62,400 on CHESS, which is what an attribute-bound cost with a fixed per-feature floor looks like: where there are few attributes to move, the per-feature work is what is left to pay. That is also why gffbase wins on CHESS and not on GENCODE.
Attribute values are counted from the corpus files themselves – semicolon fields in column nine, with comma-separated GFF3 values expanded, which reproduces the per-feature ratios above – rather than from either database, so the figure does not depend on how either engine chose to store them.
Raising the DuckDB thread count barely helps. From the 25-job thread sweep of one cluster campaign run, over five corpora at 1, 2, 4, 8 and 10 threads – all twenty-five figures from that single run, since thread scaling is only meaningful within one:
corpus |
1 thread |
10 threads |
speedup |
|---|---|---|---|
MANE v1.5 |
41.6 s |
36.0 s |
1.16x |
CHESS 3.1.3 |
99.1 s |
94.6 s |
1.05x |
RefSeq GRCh38.p14 |
425.4 s |
340.9 s |
1.25x |
GENCODE v49 (GTF) |
614.4 s |
498.3 s |
1.23x |
GENCODE v49 (GFF3) |
657.2 s |
534.8 s |
1.23x |
Ten times the cores buys at most a quarter more throughput. The comparator is single-threaded, so the published comparison is largely one serial ingest against another, and neither engine’s figure should be read as a parallel result.
A second, partial sweep measured the same corpora 12-15 percent slower at every thread count – 47.4 s rather than 41.6 s for MANE at one thread, 506.8 s rather than 425.4 s for RefSeq – while reproducing the same shape. Absolute figures from a thread sweep are therefore run-specific; the conclusion that ingest does not parallelise is not.
Ingest uncertainty is asymmetric, and it falls with corpus size. Repeating the same corpus with a byte-identical binary on ten pinned physical cores of an otherwise idle machine:
corpus |
repeated gffbase ingest |
spread |
|---|---|---|
CHESS 3.1.3 |
93.9 s – 113.1 s |
20% |
RefSeq GRCh38.p14 |
419.5 s – 424.8 s |
1.3% |
GENCODE v49 (GTF) |
606.1 s – 622.2 s |
2.7% |
The large corpora are reproducible to within a few percent; the small, fast one
is not, because fixed startup and page-cache effects are a large fraction of a
ninety-second run. gffutils on CHESS spanned 134.2 s to 137.0 s over the
same period – 2 percent – so the sensitivity is specific to the parallel,
allocation-heavy engine rather than to the machine. Hyperthreading,
temporary-directory placement and machine load were each ruled out as the cause.
Read the published ratios accordingly: the whole-genome figures are solid, and the CHESS ratio is the one carrying real uncertainty.
The harness measures each ingest once, while the query phases run five times and report their spread. Closing that asymmetry is future work; until it is closed, a published ingest ratio near 1.0 should be read as “comparable”, not as a ranking.
Publishing a run¶
A sweep writes to benchmarks/out/ (gitignored). The committed file the
published tables are generated from is benchmarks/results/06_mega.json, and
copying between them used to be an undocumented manual step — so a fresh run
could sit on disk while the docs kept rendering the previous measurement.
python benchmarks/06_mega.py --legacy-timeout 5400 --publish
python tools/gen_benchmark_tables.py --write
--publish copies the finished run into place; the generator then rewrites
every table from it. tests/test_release_hygiene.py fails if a table stops
matching the numbers behind it.
Provenance¶
Every results file embeds the environment it was produced in — CPU model, core
count, RAM, OS, Python, DuckDB, PyArrow, gffutils and gffbase versions,
rustc, the git commit and whether the working tree was dirty:
import json
env = json.load(open("benchmarks/results/06_mega.json"))["environment"]
None of this was recorded before 0.2.0. The published numbers carried their hardware and versions only as hand-typed prose, which said “gffbase 0.1.0” throughout the 0.2.0 development cycle — so nothing in the repository could have detected a regression.
Disk¶
A full sweep is disk-bound before it is CPU-bound: the five corpus pairs total
roughly 38 GiB. Each pair is purged as soon as its numbers are recorded, which
holds the peak to about 16 GiB. --keep-db KEY retains one for later stages;
--no-purge keeps everything and needs the full 38 GiB. Set
GFFBASE_BENCH_OUT to run against another volume. The harness checks free
space before each corpus and refuses to start one it cannot finish.