Export Performance Envelope
Export Performance Envelope
Section titled “Export Performance Envelope”What a large export actually costs, measured — not estimated. The numbers below come from two benchmark tiers that are reported separately on purpose, because they answer different questions and conflating them would overstate what has been verified.
This page is the reference for capacity planning and the precondition for any future chapter-streaming work: you cannot argue that streaming is needed without a measured baseline to compare against.
On this page
Section titled “On this page”- The two tiers
- How the numbers are measured
- Measured envelope: engine tier
- Measured envelope: end-to-end tier
- Browser code-highlighting trace
- Browser host-versus-WASM attribution
- What the numbers mean
- Reproducing a measurement
- Trend tracking in CI
- Troubleshooting
- Related topics
The two tiers
Section titled “The two tiers”| Engine tier | End-to-end tier | |
|---|---|---|
| Script | scripts/bench/run-bench.ts |
scripts/bench/run-e2e-bench.ts |
| Input | Pre-parsed ExportBlock[] |
Confluence storage XHTML |
| Exercises | composeChapters, DOCX serialize + zip, PDF serialize + real Typst WASM compile, image embedding |
everything in the engine tier plus the tree walk (fetchExportTree), per-page storageToBlocks, and the full MacroRendererRegistry resolver pass (live Jira renderer, draw.io renderer, scroll-* macros) |
| Does NOT exercise | storage parsing, macro resolution, tree traversal, asset fetch over the network | real HTTP (auth, pagination, rate limits, retry), real attachment downloads, a browser host (heap limits, module-worker transfer cost) |
Chromium-hosted memory is covered by its own instrument since issue #118
Phase 0: the host-versus-WASM attribution harness
measures the real extension byte path (store → worker → VFS → compile) on
the mixed fixture and the ≥100 MiB image-heavy corpus, and
bench:runtime-lane compares Typst runtime candidates on the same corpora.
The focused code-highlighting trace below remains its own narrower
measurement; do not present any single lane as the full export envelope.
How the numbers are measured
Section titled “How the numbers are measured”Wall clock
Section titled “Wall clock”Each phase is timed in-process around the phase work only. Setup (fixture generation, wasm
and font loading, template loading) sits outside every reported ms. Reported values are
the median of 3 runs, each in a fresh process.
The end-to-end tier additionally reports cold and warm: the same work run twice inside one process.
Peak RSS
Section titled “Peak RSS”/usr/bin/time reports the peak resident set size of one whole process. There is no
way to ask it for “the peak RSS of phase 2”, and an in-process process.memoryUsage()
sample is not a substitute — it measures the heap at an instant, is contaminated by the
previous phase’s uncollected garbage, and misses the wasm linear memory the Typst compiler
owns.
So the runners do this instead:
wholeProcessPeakRssBytes— one child process runs every phase; its peak is the run-level number.peakRssBytesper phase — each phase also runs in its own child process, so its peak is still a whole-process number, phase-scoped only because the process was.
A per-phase number therefore includes the runtime baseline, the fixture, and (for the
PDF phase) the wasm and font bytes. The baseline phase — load everything, do no work —
is measured so that floor is visible rather than guessed at. No number on this page is
derived from in-process heap sampling.
/usr/bin/time comes in two incompatible flavours: GNU (-v, kbytes) on Linux/CI and BSD
(-l, bytes) on macOS. Every record carries which one produced it as rssMethod, and the
trend comparison refuses to compare across them.
Measured envelope: engine tier
Section titled “Measured envelope: engine tier”Fixture: 500 pages, 3,590 blocks, seed 0x9e3779b9 — per page ~3 headings, prose, one
list; every 10th page a 200-row table; every 25th page a code block and an embedded PNG.
Machine: Apple M5 Max (18 cores), macOS darwin 25.4.0 arm64, Bun 1.3.8,
typst.ts 0.7.0 / Typst 0.14.2, RSS via bsd-time-l. Median of 3.
| Phase | Wall clock | Peak RSS (whole process) | Output |
|---|---|---|---|
baseline (load only, no work) |
— | 183 MB | — |
compose |
9 ms | 152 MB | 5.0 MB (block JSON) |
docx (serialize + zip) |
222 ms | 336 MB | 523 KB |
pdf (serialize + Typst compile) |
7,952 ms | 3,035 MB | 16.2 MB |
| whole run (all phases, one process) | 8,142 ms | 3,114 MB | — |
Measured envelope: end-to-end tier
Section titled “Measured envelope: end-to-end tier”Fixture: the same 500 pages generated as storage XHTML, yielding 4,779 composed blocks.
Adds scroll-title/scroll-pagebreak macros (every 8th page), a resolvable Jira JQL macro
(every 12th), and a draw.io macro that settles on the placeholder floor (every 20th).
Machine: identical to above. Median of 3.
Read the Cold column as the export cost. Warm† is a cache-hit repeat of identical input, not throughput — see the warning under Wall clock.
| Phase | Cold | Warm† | Peak RSS (whole process) | Output |
|---|---|---|---|---|
baseline (load only, no work) |
— | — | 187 MB | — |
fetch (tree walk + storageToBlocks) |
33 ms | 26 ms | 197 MB | 5.1 MB |
resolve (macro registry pass) |
6 ms | 3 ms | 200 MB | 5.1 MB |
compose |
13 ms | 7 ms | 221 MB | 5.1 MB |
docx (serialize + zip) |
236 ms | 126 ms | 441 MB | 546 KB |
pdf (serialize + Typst compile) |
7,677 ms | 577 ms | 3,351 MB | 17.1 MB |
| whole run (all phases, one process) | 8,127 ms | — | 3,400 MB | — |
† Warm re-runs byte-identical input, so Typst’s incremental cache absorbs most of the work. It is a lower bound on one-time setup cost, never a steady-state export time.
Browser code-highlighting trace
Section titled “Browser code-highlighting trace”The browser export harness records Shiki’s active request path separately from the large
export envelope. A fresh Chromium context first proves that no core, RegExp engine, theme,
grammar, or code-font resource is requested before explicit preparation/export intent. A
one-language typescript / github-light preparation then records request names, transfer
and decoded bytes, and cold/warm initialization time. The warm repeat must issue no new
JavaScript request.
The generated registry contains a literal loader for every supported canonical language and theme, but one selected loader is not necessarily one HTTP request: Shiki registrations can reference another grammar and a bundler can factor a small wrapper or shared runtime chunk. The Chromium trace therefore asserts the complete selected request set and rejects unrelated language/theme or aggregate catalogue chunks instead of assuming a universal chunk count.
bun run build:browser-export-harnessbun run --cwd apps/browser-export-harness test:e2e --grep "one-language preparation"The run writes apps/browser-export-harness/test-results/highlight-performance/one-language-cold.json.
The broader 22-language case in the same suite proves concurrent grammar/core/engine/theme
loading, cold/warm DOCX byte parity, and deterministic repeat output.
Browser host-versus-WASM attribution
Section titled “Browser host-versus-WASM attribution”The Chrome/V8 memory harness splits the PDF compiler worker’s footprint per
phase into Typst WASM linear memory versus host bytes (V8 heap plus
backing storage outside the WASM instance). This is the Phase 0 gate input of
specs/issue-118-adaptive-browser-pdf-memory/PLAN.md: host-side transport
work can only ever reduce the host share, so the split decides whether that
work is justified.
bun run bench:memory-chromeThe report’s workerAttribution section carries the split. Measured on the
deterministic 8.33 MiB mixed fixture (6 chapters, two 1200 x 1200 PNGs,
8.30 MiB PDF), Chromium 140.0.7339.16 / V8 14.0.365.1, reproduced
byte-identically across two consecutive runs:
| Phase | WASM linear (MiB) | Host outside WASM (MiB) | Total (MiB) | WASM share |
|---|---|---|---|---|
| warm | 15.56 | 33.67 | 49.23 | 31.6% |
| bundle-received | 15.56 | 42.04 | 57.59 | 27.0% |
| vfs-ready | 32.31 | 42.05 | 74.36 | 43.5% |
| compiled-held (peak) | 87.31 | 50.62 | 137.93 | 63.3% |
| complete | 87.31 | 33.98 | 121.29 | 72.0% |
The same run also measures the image-heavy corpus (100.29 MiB assets,
105.23 MiB source bundle, scale 1) through the identical store → worker →
VFS → compile path, unlocked by the benchmark-only cap seams. Result
(ATLCLI_CHROME_MEMORY_IMAGE_HEAVY_RESULT, same pinned runtime):
| Phase | WASM linear (MiB) | Host outside WASM (MiB) | Total (MiB) | WASM share |
|---|---|---|---|---|
| warm | 15.56 | 33.67 | 49.23 | 31.6% |
| bundle-received | 15.56 | 134.12 | 149.67 | 10.4% (host holds the 100 MiB bundle) |
| vfs-ready | 120.88 | 134.13 | 255.00 | 47.4% |
| compiled-held (peak) | 1326.56 | 231.76 | 1558.32 | 85.1% |
| complete | 1326.56 | 33.99 | 1360.55 | 97.5% (warm-runtime retention) |
The compiled PDF is 97.36 MiB (JPEG passthrough) and Typst compiles the whole corpus without diagnostics.
How to read this:
- The WASM linear memory is read through a benchmark-only
Symbol.forhook thatBrowserPdfCompilerinvokes during initialization; production hosts never install it. Linear memory only grows, so the post-compile value is the high-water mark, and the harness asserts monotonic growth. - Whether CDP
backingStorageSizeincludes the WASM memory is detected from the samples and reported asbasis(backing-includes-wasmon this runtime), never assumed — a runtime change alters the report instead of silently double-counting. - Gate input (issue #118 attribution gate): on the image-heavy corpus, the host-side share at peak is 14.9% — below the plan’s 25% working threshold. 85.1% of the peak lives inside the WASM instance (decoded rasters, layout, PDF assembly), out of reach of host-side descriptor/lease transport: even a zero-copy host transport could reduce this peak by at most ~15%. The levers that reach the dominant share are the explicit image profiles (smaller decoded rasters shrink the 1.3 GiB in-WASM footprint directly) and the Typst/runtime evaluation lanes.
- Second gate reading from the same run: whole-PDF result handoff is 97.36 MiB of a 1558.32 MiB peak = 6.2%, below the plan’s 10% bar for pursuing an owned/chunked output handle.
- The small mixed fixture (63.3% WASM / 36.7% host at its 137.93 MiB peak) shows the host share shrinks as inputs grow image-heavy — the workloads this issue targets are exactly the ones where host-side transport matters least.
Explicit image profiles (Phase 1 result)
Section titled “Explicit image profiles (Phase 1 result)”The standard profile (180 PPI candidate pin, @atlcli/export-media
deterministic codec) measured against original on the same full-scale
image-heavy corpus, one complete store → worker → VFS → compile cycle per
profile (ATLCLI_CHROME_MEMORY_IMAGE_PROFILE_RESULT):
original |
standard (180 PPI) |
delta | |
|---|---|---|---|
| source bundle | 100.36 MiB | 16.65 MiB | −83.4% |
| compiled PDF | 97.36 MiB | 15.79 MiB | −83.8% |
| WASM linear high-water | 1326.56 MiB | 325.63 MiB | −75.5% |
| worker peak | 1558.32 MiB | 392.10 MiB | −74.84% |
The plan’s 40% bar for recommending standard on large image-heavy trees
is met with a 74.84% measured peak reduction — asserted in the harness at
scale 1. Profiles are always explicit (original stays byte-identical and
is the default); imagePpi (72–1200) tunes the same pipeline between the
presets. Normalization never upscales, keeps JPEG as JPEG and transparency
lossless, and keeps original bytes for anything it cannot decode faithfully
(reported in one aggregate image-profile-applied note).
The attribution math is pure and unit-tested
(apps/extension/tests/pdf/memory/attribution.ts); the harness README
documents the probe protocol.
Image-heavy corpus
Section titled “Image-heavy corpus”The plan’s image-heavy acceptance corpus is generated deterministically at bench time — no blob is committed:
bun run bench:image-heavy-corpus # stats + manifest hash, scale 1bun run bench:image-heavy-corpus -- --scale 0.25 --out /tmp/corpus@atlcli/export-fixtures (image-heavy-corpus.ts) produces ≥ 100 MiB of
unique compressed media at scale 1 from (seed, scale) using pinned pure-TS
encoders (baseline JPEG with literal cosine constants; PNG with Paeth
filtering and an in-repo fixed-Huffman DEFLATE) — no canvas, host zlib, or
CompressionStream, whose outputs are not engine-pinned. Measured recipe
(seed 0x1837c0de, scale 1, Bun 1.3.14/arm64): 76 unique assets,
100.29 MiB in ~26 s — 57 photographic JPEGs (0.36 bytes/pixel), 12
screenshot PNGs with textured hero regions (0.44 bytes/pixel), 6 transparent
diagram PNGs plus a logo repeated across all 67 chapters; 224 total
placements, inline and full-width. Manifest SHA-256
95b46f8904d8788099a674a37ca7c3212f7fe5c23d8cc4989bd8f66e769710b3; the
scale-0.06 recipe hash is pinned in the unit suite, and a real-compiler test
proves Typst decodes every generated asset with zero diagnostics.
Because the corpus exceeds the product asset budgets by design, the
preparation pipeline gains a benchmark-only Symbol.for override
(atlcli.pdf.benchmark-asset-budget) for the per-file and total caps —
release configuration has no path to it, and the covering tests prove the
product caps unchanged when the hook is absent.
What the numbers mean
Section titled “What the numbers mean”PDF compilation is the entire cost. It is ~94% of wall clock in both tiers. Optimising anything else — the parser, the resolver, compose — cannot move the total meaningfully at this scale. Work aimed at export speed should target the Typst compile or the document size handed to it.
The parsing/resolver gap is real but small — at this fixture. The three phases the
engine tier cannot see (fetch, resolve, compose) total ~52 ms of ~8.1 s: well under
1%. That is an empirical result for this corpus, not a licence to assume the engine tier
is always a good proxy. It notably does not hold for memory — the end-to-end tier peaks
~9% higher (3,400 MB vs 3,114 MB) because the parsed tree is resident alongside the
compile — and it says nothing about a real tenant, where network latency and attachment
downloads dominate everything measured here.
Peak RSS exceeds the planning placeholder. Spec 011’s provisional budget was
“500-page PDF compile < 2 GB RSS”. The measured figure is 3.0–3.4 GB. The placeholder
was a guess made before measurement; this page is the measurement. Any absolute budget
frozen into scripts/bench/budgets.json must start from these numbers, and a
memory-constrained host (a browser tab, a small CI runner, a container with a 2 GB limit)
should be assumed to fail a 500-page PDF export until proven otherwise.
The warm PDF number is a lower bound, not a second-export cost. Cold 7,677 ms vs warm 577 ms is a ~13× gap, most of which is Typst’s incremental compilation cache hitting on byte-identical input. Exporting a genuinely different document will not be 577 ms. Read the warm column as “how much of the cold number is one-time setup”, not as “steady-state throughput”.
Reproducing a measurement
Section titled “Reproducing a measurement”# Engine tier: 500 pages, median of 3 runs per phasebun run bench:engine -- --pages 500 --repeat 3
# End-to-end tierbun run bench:e2e -- --pages 500 --repeat 3Records land in scripts/bench/out/bench-engine.json and bench-e2e.json (both
gitignored). Each record carries the fixture digest, the Typst wasm digest, the font-set
digest, OS/arch/runner, Bun version, and rssMethod, so a number is always attributable to
an environment.
Useful flags:
| Flag | Default | Meaning |
|---|---|---|
--pages <n> |
500 |
Fixture size |
--seed <n> |
0x9e3779b9 |
PRNG seed; same seed → byte-identical fixture |
--repeat <n> |
1 |
Runs per phase; the median is reported |
--phase <name> |
— | Run one phase in this process (used internally by the parent) |
--out <path> |
scripts/bench/out/… |
Where to write the record |
A quick smoke run:
bun run bench:engine -- --pages 30 --repeat 1Trend tracking in CI
Section titled “Trend tracking in CI”.github/workflows/bench.yml runs both tiers nightly (and on manual dispatch). It is
continue-on-error: true and never runs per PR — CI wall clock is too noisy to gate
merges on, and a gate people re-run until it passes is not a gate.
The workflow restores the previous records via actions/cache, compares each phase against
the rolling median of comparable prior records, and emits a GitHub ::warning:: when
wall clock regresses >20% or peak RSS >15%.
“Comparable” is strict: same tier, page count, fixture digest, Typst wasm digest, font-set
digest, OS, arch, runner, and rssMethod. A deliberate compiler or font bump therefore
resets the baseline instead of firing a false alarm — and a warning names the phase, the
tier, and the machine, so a regression localizes rather than showing up as an unexplained
total.
The runner field identifies a runner class, never an instance. In CI it is built from
RUNNER_ENVIRONMENT, RUNNER_OS and RUNNER_ARCH (e.g. ci:github-hosted:Linux:X64);
locally it is the hostname. This matters more than it looks: GitHub-hosted runners set
RUNNER_NAME to a per-instance value (GitHub Actions 2, GitHub Actions 14, …) that
changes between runs, so keying on it would make every nightly non-comparable with every
other nightly — the trend would report “no comparable history yet” forever and never warn,
silently, behind continue-on-error: true. Set ATLCLI_BENCH_RUNNER to declare your own
class label on a self-hosted bench rig.
Absolute budgets stay unfrozen until roughly two weeks of trend data exist. Only then
should scripts/bench/budgets.json be written and the workflow flipped to failing.
Troubleshooting
Section titled “Troubleshooting”Peak RSS is null in the record.
Neither /usr/bin/time -v (GNU) nor /usr/bin/time -l (BSD) was available. The runners
report rssMethod: "unavailable" and record null rather than inventing a number. Timing
is unaffected. On Debian/Ubuntu, install time (the shell builtin is not enough).
A phase child fails with “Cannot find module ‘@atlcli/…’”.
Phase children need in-repo workspace resolution. Run through bun run bench:engine /
bench:e2e, which pass --conditions=development; invoking bun scripts/bench/run-bench.ts
directly without that flag resolves the packages’ unbuilt dist barrels.
Numbers swing between runs on a laptop.
Thermal throttling and background load both move wall clock by tens of percent. Raise
--repeat, and treat cross-machine comparisons as invalid — that is exactly what the
environment fingerprint on each record exists to prevent.
The nightly warns after a Typst or font bump.
It should not: a changed compilerWasmDigest or fontSetDigest makes prior records
non-comparable, so the trend restarts. If a warning does appear across such a bump, the
digest was not recorded — check the environment block of the record.
The nightly always says “no comparable history yet”.
Every run is being treated as a new environment. Compare the environment blocks of two
consecutive records and find the field that moved — most likely runner, if something
reintroduced an instance-specific value there (see above), or datasetDigest, if the
fixture generator stopped being deterministic.
Related topics
Section titled “Related topics”- PDF Export Engine — the compile path these numbers measure
- DOCX Export Engine — the serialize + zip path
- Export Asset Contract — how wasm and fonts are loaded
- Public Export API (v1) — the seams the benchmarks drive