Benchmark · rendered from 2025.11.5.json
167,636 conformant PDF/A-3b invoices per hour
On Linode Dedicated 8 GB (4 dedicated vCPU), eu-central (Frankfurt). Median of 5 timed runs over 10,000 distinct invoices; range 164,738–172,490. Licensed mode (no watermark), veraPDF-3b PASS.
Every figure on this page is interpolated at build time from the machine-readable result artifact — no number is hand-typed. If a value here disagrees with the linked JSON, the artifact wins.
The honest numbers
Throughput, read three ways.
| Aggregate (4 workers) | 167,636 | docs/hour |
| Per core, sustained at full width | 41,909 | docs/hour/core |
| Single worker, uncontended | 77,568 | docs/hour |
Latency a distinct quantity from throughput
| p50 | p95 | p99 |
|---|---|---|
| 61 ms | 215 ms | 349 ms |
Per-document render time. The p99 tail is dominated by the largest invoices — a big-document number, not the typical one. Dividing throughput by latency is meaningless: concurrency is exactly the gap between them.
Scaling — the number, read honestly
Four cores do not do four cores' work. Here is exactly why.
| Workers | 1 | 2 | 4 | 8 |
|---|---|---|---|---|
| Speedup vs. one | 1.00× | 1.70× | 2.16× | 1.93× |
A full 1 / 2 / 4 / 8-worker sweep on the reference box — one physical host, so the headline, the per-core figure, and this curve all share a die. Throughput peaks at 4 workers (the box's 4 vCPUs); the 8-worker run oversubscribes the cores and regresses — more threads than cores is contention, not speedup.
Four cores buy ~2.2×, not 4× — a serial fraction of ~28% at full utilization, and past the core count more workers only contend (8 workers trail 4). That residual isn't a mystery we hand-wave: it is not our factory lock (removing it left throughput unchanged) and not GC (ZGC nil, ParallelGC ~10% within noise) — it sits inside the Saxon/FOP render internals, a named, bounded post-launch target.
The deployment conclusion: scale out with replicas, not up with cores. Several 2–4 vCPU replicas behind a load balancer beat one fat many-core node — which is how stateless containers get deployed anyway. Size against the per-core number as measured on your own hardware — it moved 33% between two boxes of the same plan, so ours is not a constant we can hand you; it is what holds as you add replicas of your instance.
This section shows the page updates against evidence — including evidence against ourselves. That is the point of publishing a benchmark from an artifact.
The benchmark corpus
Distinct invoices, not one fixture 10,000 times.
| Dimension | Distribution |
|---|---|
| Lines | 70% small (1–5) · 25% medium (10–40) · 5% large (100–150) |
| Tax scenario | 60% standard @19% · 20% reduced @7% · 15% reverse-charge · 5% zero-rated |
| Text | diacritics, guillemets, µ/Ω/×, Cyrillic — the real glyph path |
10,000 distinct documents from published seed
42. Same seed → byte-identical corpus → identical
hash. The mix is conservative against us: an all-minimal corpus would render
faster and inflate the number.
This is the throughput corpus, and it is deliberately narrow: one jurisdiction, one currency, sized to stress the render path rather than the spec surface. It is not the conformance corpus. Coverage of other currencies and national profiles — JPY with no minor unit, AUD under PINT A-NZ, GST rather than VAT — lives in the fixture corpus, which is what the Schematron and pixel gates run on every commit. Throughput and coverage are measured by different corpora on purpose.
sha256 88e6c2d1b39fca2452d0ba99a25c4e62da31abcef2256bc1345a30f1a5991b7e
Reproduce it yourself
A seed, an image digest, and about €1 of cloud time.
| Image | ghcr.io/faktorei/render:2025.11.5 |
| Digest | sha256:815b89a9c8647bee81c84a6e12cf7218635016990fdb9c1a15885b43a7036f14 |
| Box | Linode Dedicated 8 GB (4 dedicated vCPU), eu-central (Frankfurt) |
Image note: measured on 2025.11.5, the current render path. Throughput is 43% BELOW the 2025.11.1 row (167,636 vs 292,826) despite the same Linode plan, the same region and a byte-identical corpus. That gap is the HOST, not the software: an A/B of the 2025.11.1, .3 and .5 images on one identical machine put all three within ~3% of each other (licensed 3,000-doc runs at workers=4, and unlicensed 480-doc runs to reach 2025.11.1, which predates the ci-2026-2 trust anchor and cannot be run licensed). So the 2025.11.2 render-path change costs nothing measurable. This is the third instance of 'Dedicated 4 vCPU' we have measured and the third distinct number (194,347 / 292,826 / 167,636) -- the plan name does not pin the silicon, which is exactly why the methodology, corpus hash and digest are the invariants rather than the absolute figure. Published without a worker sweep, so the site's scaling curve stops at the core count.
# regenerate the exact corpus, then run the harness
python3 tools/gen_benchmark_corpus.py --count 10000 --seed 42 --out ./bench-corpus
python3 tools/benchmark.py \
--corpus ./bench-corpus \
--engine ./bench_container.sh \
--workers 4 \
--image "ghcr.io/faktorei/render:2025.11.5" \
--image-digest "sha256:815b89a9c8647bee81c84a6e12cf7218635016990fdb9c1a15885b43a7036f14" \
--box "Linode Dedicated 8 GB (4 dedicated vCPU), eu-central (Frankfurt)" \
--out result.json Reproducing the published run needs a license. It renders 60,500 documents — 10,000 × 5 timed passes, plus 500 warm-up and a 10,000-document single-worker pass — against an evaluation allowance of 500/day, so evaluation mode cannot complete it, and the harness marks any non-licensed result do-not-publish because the watermark changes what is being measured. Want to verify it? Ask for a time-boxed license and we will issue one.
What you can do unlicensed is the thing this page argues actually
matters: the harness takes --runs and --warmup, so a
small corpus fits inside a single evaluation day and gives you a per-core
figure on your own hardware. The methodology, the corpus sha256
and the image digest are the parts we ask you to hold us to — those are
checkable without us.
The full methodology (warm-up, median-of-N, per-core pass, veraPDF-on-output) is docs/benchmark.md, alongside every published result artifact.
Hardware variance — read the absolute number honestly
The plan name is not the silicon.
Two instances of the same "Dedicated 4 vCPU" plan, measured days apart, differed by +51% aggregate and +33% single-core (the two rows in the History table below). The render path between them was byte-identical — the delta is the physical die. Cloud dedicated-vCPU plans span CPU generations, and the host you land on dominates the absolute number more than anything in our software.
So the invariants of this benchmark are the things you can hold constant and verify: the methodology, the corpus sha256, the image digest, and the veraPDF result. The absolute throughput is hardware-bound. Size your deployment against per-core measurements on your own hardware — the evaluation container and the open harness let you produce them in an evening, on the exact silicon you will run in production.
Conformance
Throughput of broken PDFs is worthless.
veraPDF profile 3b was run over the benchmark's own output: 200 / 200 sampled documents passed, 0 failed — PASS. The claim is conformant PDF/A-3b, so the run proves its own output conforms.
History — the page's own audit trail
Every published run.
| Release | Median docs/hour | Box | Image |
|---|---|---|---|
| 2025.11.5latest | 167,636 | Linode Dedicated 8 GB (4 dedicated vCPU), eu-central (Frankfurt) | ghcr.io/faktorei/render:2025.11.5 |
| 2025.11.1 | 292,826 | Linode Dedicated 8 GB (4 dedicated vCPU), eu-central (Frankfurt) | ghcr.io/faktorei/render:2025.11.1 |
| 2025.11.0 | 194,347 | Linode Dedicated 8 GB (4 dedicated vCPU), eu-central (Frankfurt) | ghcr.io/jdmac26/kontor-render:2025.11.0 |
The 2025.11.0 row has an identical render path
to 2025.11.1 — same code, a different physical host on the same
"Dedicated 4 vCPU" plan. The ~51% aggregate / ~33% single-core gap is
hardware, not software (see Hardware
variance below) — which is exactly why every row carries a box descriptor.
Its image path (ghcr.io/jdmac26/kontor-render) predates the
faktorei rename and is kept as provenance: what was measured, not what is current.