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 cost tracks attributes. Cost follows the number of attribute values 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 |
429,800 |
148,700 |
GENCODE v49 (GFF3) |
108,289,882 |
17.9 |
560,900 |
185,200 |
GENCODE v49 (GTF) |
96,291,901 |
15.9 |
446,700 |
232,400 |
RefSeq GRCh38.p14 |
55,104,379 |
11.2 |
441,200 |
140,900 |
CHESS 3.1.3 |
7,063,651 |
2.6 |
190,800 |
52,700 |
On the four attribute-dense corpora – 11.2 to 17.9 attributes per feature –
gffbase moves 430,000 to 561,000 attributes per second, against 141,000 to
232,000 for gffutils. The rate falls to 190,800 on CHESS, where 2.6
attributes per feature leave the fixed per-feature work to dominate; the
comparator’s per-feature cost is higher still there, which is why CHESS has
the largest ratio. GENCODE GTF has the smallest because gffutils runs at
its fastest on it: with parent inference off, its GTF path is a plain bulk
insert. Before 0.3.0 gffbase held 131,000 to 168,000 attributes per second on
those four, level with the comparator; the rebuilt ingest removed the Python
object it built per record.
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.
More threads buy little. Measured on 0.3.0, MANE ingest took 15.8 to 16.1 s at 8, 32 and 128 DuckDB threads alike, while peak RSS rose from 1.0 GiB to 1.2 and 1.6 GiB – which is why gffbase uses at most 8 threads by default. The parse and per-record work runs on one core; the rest, DuckDB’s append and index builds, gained nothing past 8 threads here.
Before the rebuild the picture was the same. One cluster campaign run swept gffbase 0.2.0rc1 over the 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 (0.2.0rc1) |
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 |
The comparator is single-threaded, so neither engine’s figure should be read as a parallel result.
Ingest uncertainty falls with corpus size. Repeating the ingest alone with the same wheel, on the same eight pinned cores of an otherwise idle socket:
corpus |
runs |
repeated gffbase ingest |
spread |
published figure |
|---|---|---|---|---|
MANE v1.5 |
5 |
14.9 s – 15.2 s |
2.3% |
15.6 s |
CHESS 3.1.3 |
5 |
36.0 s – 36.5 s |
1.4% |
37.0 s |
RefSeq GRCh38.p14 |
3 |
118.8 s – 119.3 s |
0.5% |
124.9 s |
GENCODE v49 (GTF) |
3 |
208.2 s – 208.7 s |
0.3% |
215.6 s |
GENCODE v49 (GFF3) |
3 |
191.1 s – 192.5 s |
0.7% |
193.1 s |
Every corpus repeats to within a few percent, the whole-genome ones to under one. The published single figures sit 0.7 to 4.9 percent above the probe’s medians. The canonical run shared the machine with test suites running on the other socket for its first four corpora; the fifth, GENCODE GFF3, measured alone, sits closest. Either way the published ratios understate gffbase slightly rather than flatter it. (0.2.0rc1, probed on ten pinned cores, spread 20 percent on CHESS.)
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.