The Measure of the Tool

The Measure of the Tool

A converter’s claims are cheap until a corpus arrives. This chapter measures zenfmt against the tools a reader would actually reach for instead: pandoc, the universal document converter, firecrawl’s anydoc, the Rust converter whose format roster zenfmt set out to match, and Docling, the Python document-understanding toolkit, measured here in its model-free parser-only configuration. Everything below is generated. The tables, the bars, and the headline ratios come from benchmarks/results/latest.json, written by the same build step you can run tonight on your own machine.

Method

Each tool converts each corpus file to GitHub-Flavored Markdown. The output is discarded. Each tool is invoked the way its documentation recommends. One warm-up run is discarded, and the tables keep the median of 5 measured runs. A tool runs only on formats its documentation claims. unsupported in the tables is itself a result. So is failed, which means an exit code other than zero on a format the tool claims.

Fairness is a design decision, not an accident:

  • anydoc’s Node.js launcher and pandoc’s Haskell runtime startup are in the measurement, because they are in every real invocation. zenfmt’s own process startup is measured identically.
  • zenfmt is built with -Doptimize=ReleaseSafe. Bounds checks stay on. This is the mode the project ships. The competitors are their released binaries.
  • The corpus is nobody’s home turf. It holds real files from public sample repositories, Apache POI and LibreOffice test data, Project Gutenberg, and the W3C. It spans 16 files and every format family zenfmt reads.

Every file is real. Nothing in the corpus was authored for this benchmark.

filefamilyorigin
report.docxDOCXfilesamples.com document sample
memo.docWord 97 binaryfilesamples.com, authored in Word 9.0
letter.odtOpenDocument textfilesamples.com
notes.rtfRTFfilesamples.com
sheet.xlsxXLSXfilesamples.com
table.xlsExcel 97 binaryfilesamples.com
grid.xlsbExcel binary workbookApache POI test corpus
sheet.odsOpenDocument spreadsheetfilesamples.com
slides.pptxPPTXApache POI corpus, a real ApacheCon deck
deck.pptPowerPoint 97 binaryApache POI corpus, a 2.5 MB thesis defense
slides.odpOpenDocument presentationthe ApacheCon deck, converted by LibreOffice
book.epubEPUBProject Gutenberg, Pride and Prejudice
page.htmlHTMLProject Gutenberg, the same novel as one page
data.csvCSVFSU sample data, 25,000 rows
article.pdfPDFpdfobject.com sample article
spec.pdfPDFW3C accessibility test file
Table 1: The corpus: 16 real documents from public sources.

The headline

6.9x
speed ratio
anydoc wall time / zenfmt over 14 shared files
7.9x
CPU ratio
anydoc user + system CPU time / zenfmt
10.1x
peak memory ratio
anydoc peak resident memory / zenfmt

The three cards use AnyDoc because it shares the most successful corpus files with zenfmt. A ratio divides AnyDoc by zenfmt, so a value above one means AnyDoc used more of that measure in this run. The geometric mean is used because it treats proportional changes symmetrically. These values describe one corpus and one host; they are not quality scores.

Comparison tool / zenfmtShared filesSpeedCPU usePeak memory
Docling5190.4x205.5x47.5x
anydoc146.9x7.9x10.1x
pandoc618.2x16.5x16.6x
Table 2: Geometric-mean resource ratios over shared successful files. Each value is the comparison tool divided by zenfmt. Larger than one means the comparison used more elapsed time, CPU time, or peak memory in this run.

Support is a result

Half of a converter’s value is answering at all. Rows are corpus files. A filled cell means the tool converted the file successfully.

filesizezenfmtDoclinganydocpandoczenfmt wheel
article.pdf18.4 KiB
book.epub545.5 KiB
data.csv618.5 KiB
deck.ppt2519 KiB
grid.xlsb8.9 KiBfailed
letter.odt4.2 KiB
memo.doc32 KiB
notes.rtf32.4 KiB
page.html832.6 KiB
report.docx33.6 KiB
sheet.ods6.1 KiB
sheet.xlsx12.9 KiB
slides.odp466 KiB
slides.pptx633.1 KiB
spec.pdf13 KiB
table.xls13 KiB

Each column tells its own story. pandoc’s is honest minimalism: it never claimed the binary Office formats, the OpenDocument spreadsheet and presentation, or PDF input. Docling here is deliberately narrowed to its model-free parsers — Office Open XML, HTML, and CSV — so its blank cells are a benchmark choice, not a Docling limit; the next section explains why. anydoc’s one failed is the real-world XLSB workbook. Its sheet directory uses a 40-byte BrtBundleSh record where the specification’s example shows 36 bytes. Chapter 4 describes how zenfmt detects this variant by exact-consumption parsing.

Docling, parser only

Docling is a document-understanding toolkit, not a plain converter: its strength is layout, OCR, and table models over PDFs and images. Those pipelines load machine-learning weights and are a different workload from the millisecond structural conversion this chapter measures. The benchmark therefore pins Docling to its model-free backends and denies every model download, so the row measures Docling’s own parsers converting Office Open XML, HTML, and CSV to Markdown — and nothing of its AI features. The cost it still carries is a real one: a fresh Python interpreter that imports the toolkit’s scientific stack before any document work begins, which is why its bars sit whole seconds above the compiled tools even on the files it does convert. The comparison is narrow on purpose; it keeps the workload appropriate for the modest machines zenfmt targets.

Latency

Grouped bar chart of median wall latency per corpus file on a log-10 axis, one bar per tool per file. zenfmt's bars are the shortest on every file; the other tools' bars start near a common floor of about 40 milliseconds, marked by a dashed rule, which is their runtime startup cost before any document work. Files a tool does not convert have no bar. The exact values are in the table below.
Median wall latency per conversion, log-10 axis. Absent bars carry their reason in italics. The dashed rule marks the startup floor the interpreted runtimes pay before any document work begins.
The numbers behind this chart
Filezenfmtdoclinganydocpandoczenfmt-python-wheel
article.pdf3.4 msnot supported41.53 msnot supported59.22 ms
book.epub39.93 msnot supported60.86 ms1303.76 ms97.33 ms
data.csv53.92 ms3207.25 ms83.18 ms829.45 ms112.47 ms
deck.ppt8.1 msnot supported42.11 msnot supported63.49 ms
grid.xlsb2.9 msnot supportedfailednot supported58.18 ms
letter.odt2.85 msnot supported40.02 ms33.29 ms58.86 ms
memo.doc2.92 msnot supported40.18 msnot supported58.62 ms
notes.rtf3.09 msnot supported39.42 ms44.94 ms58.78 ms
page.html36.32 ms3089.96 msnot supported1289.34 ms93.38 ms
report.docx4.55 ms2472.37 ms41.38 ms53.88 ms60.59 ms
sheet.ods3.18 msnot supported40.29 msnot supported58.71 ms
sheet.xlsx3.84 ms2425.2 ms40.58 msnot supported60.09 ms
slides.odp15.9 msnot supported46.7 msnot supported71.64 ms
slides.pptx19 ms2737.56 ms45.08 msnot supported74.61 ms
spec.pdf2.77 msnot supported39.12 msnot supported58.33 ms
table.xls2.88 msnot supported39.35 msnot supported58.33 ms

The log axis is doing real work, because the tools live on different decades. Most zenfmt bars sit in the single digits to low tens of milliseconds; the 33 KiB DOCX converts in about four. That time is dominated by actual parsing, which is why the 2.5 MB deck.ppt costs no more than a small spreadsheet: the reader touches the text atoms it projects and skips the rest. The compiled competitors’ bars start near their runtime startup floor, about 40 ms for anydoc’s Node launcher and a similar amount for pandoc’s runtime, before any document work happens. Docling sits a whole decade higher still: even on the files it converts, a fresh interpreter imports its scientific stack before the first byte is read, so its floor is measured in seconds, not milliseconds.

zenfmt’s heaviest file is data.csv: 25,000 rows at about 54 milliseconds, its slowest by a small margin. The cost is deliberate — zenfmt measures every column across every row so it can emit width-aligned GFM table pipes, an O(rows × columns) pass that anydoc skips by emitting ragged ones — yet even here zenfmt finishes ahead of anydoc. On large structured inputs the ordering is not close: pandoc climbs to about 1.3 seconds on the EPUB book and the 850 KiB HTML page, where zenfmt stays well under a tenth of a second.

CPU use

Grouped bar chart of median CPU time per corpus file on a log-10 axis, one bar per tool per file. The ordering broadly follows wall latency because each command performs one conversion at a time. Files a tool does not convert have no bar. Exact values are in the table below.
Median user plus system CPU time per conversion, log-10 axis.
The numbers behind this chart
Filezenfmtdoclinganydocpandoczenfmt-python-wheel
article.pdf2.83 msnot supported42.27 msnot supported57.81 ms
book.epub38.95 msnot supported60.25 ms1293.69 ms95.79 ms
data.csv52.73 ms3198.73 ms82.59 ms814.92 ms110.89 ms
deck.ppt7.36 msnot supported41.69 msnot supported62.04 ms
grid.xlsb2.39 msnot supportedfailednot supported56.75 ms
letter.odt2.33 msnot supported39.6 ms19.46 ms57.4 ms
memo.doc2.36 msnot supported39.72 msnot supported57.25 ms
notes.rtf2.59 msnot supported39.03 ms32.84 ms57.26 ms
page.html35.3 ms3079.59 msnot supported1273.47 ms91.83 ms
report.docx3.89 ms2462.15 ms40.82 ms40.34 ms59.1 ms
sheet.ods2.64 msnot supported39.84 msnot supported57.27 ms
sheet.xlsx3.29 ms2415.16 ms40.14 msnot supported58.76 ms
slides.odp15.19 msnot supported46.2 msnot supported70.26 ms
slides.pptx18.33 ms2731.49 ms44.69 msnot supported73.3 ms
spec.pdf2.27 msnot supported39.77 msnot supported56.99 ms
table.xls2.38 msnot supported39.1 msnot supported57.04 ms

CPU time measures processor work rather than what a person waits for. It includes user and operating-system time reported for the child process. On this single-conversion workload it follows wall latency closely, but it remains separate because a service pays CPU even when requests overlap. The ratio table above uses the same shared successful files as the speed comparison.

Memory

Grouped bar chart of peak resident memory per corpus file on a log-10 axis, one bar per tool per file. zenfmt's bars stay a small constant above each input's size, while the other tools' bars sit roughly an order of magnitude higher and grow faster on the larger inputs. The exact values are in the table below.
Peak resident set size per conversion, log-10 axis.
The numbers behind this chart
Filezenfmtdoclinganydocpandoczenfmt-python-wheel
article.pdf3.64 MBnot supported49.75 MBnot supported31.34 MB
book.epub23.16 MBnot supported54.33 MB269.11 MB54.31 MB
data.csv41.3 MB565.31 MB68 MB290.31 MB73.41 MB
deck.ppt7.64 MBnot supported53.92 MBnot supported35.56 MB
grid.xlsb3.06 MBnot supportedfailednot supported30.95 MB
letter.odt3.16 MBnot supported47.02 MB41.61 MB31.13 MB
memo.doc3.09 MBnot supported47.3 MBnot supported31.03 MB
notes.rtf3.11 MBnot supported46.5 MB104.52 MB30.98 MB
page.html23.41 MB392.77 MBnot supported426.53 MB53.98 MB
report.docx3.44 MB373.45 MB47.98 MB108.44 MB31.22 MB
sheet.ods3.19 MBnot supported47.53 MBnot supported31.05 MB
sheet.xlsx3.22 MB370.03 MB47.28 MBnot supported31.09 MB
slides.odp4.45 MBnot supported54.48 MBnot supported32.33 MB
slides.pptx4.48 MB379.19 MB49.38 MBnot supported32.34 MB
spec.pdf3.27 MBnot supported48.91 MBnot supported31.17 MB
table.xls3.16 MBnot supported46.98 MBnot supported31.08 MB

Memory tells the architecture story more plainly than latency does. zenfmt’s peak sits a small constant above the input size. There is one arena per conversion, flat struct-of-arrays storage, and no DOM. pandoc builds a full tree in a garbage-collected heap: over 100 MB for a 33 KiB DOCX, and more than 400 MB for the HTML page. anydoc pays a flat 48 MB before documents enter the picture. On the shared files the geometric-mean gap is an order of magnitude. It widens exactly on the inputs where memory matters.

The shape of the win

One more picture makes the distribution visible. Take each file both zenfmt and anydoc convert. Divide anydoc’s median wall time by zenfmt’s. Sort. The result is not one lucky file carrying an average. All 14 shared files land on the winning side of parity; data.csv is the closest at 1.5x. The spread tells you the rest: small files are dominated by the competitor’s startup, and large files are dominated by parsing.

Single-series bar chart, sorted, of how many times faster zenfmt converts each file that both it and anydoc convert, on a log-10 axis with a dashed parity rule at one times. Every bar sits above parity, so the advantage is not one outlier carrying an average; the smallest margin is on the large CSV file and the largest are on the small files, where the competitor's startup cost dominates. The exact ratios are in the table below.
Wall-time speedup of zenfmt over anydoc per shared corpus file, sorted, log-10 axis. The dashed rule is parity.
The numbers behind this chart
FileSpeedup over anydoc
article.pdf12.21x
book.epub1.52x
data.csv1.54x
deck.ppt5.2x
letter.odt14.04x
memo.doc13.76x
notes.rtf12.76x
report.docx9.09x
sheet.ods12.67x
sheet.xlsx10.57x
slides.odp2.94x
slides.pptx2.37x
spec.pdf14.12x
table.xls13.66x

Where the time goes

The process benchmark treats each tool as a black box. A second harness, zig build benchmark-stages, opens zenfmt’s box from inside the library: for each corpus file it times a conversion through a probe writer that emits nothing, which prices reading, tree building, validation, and the manifest. A timed wrapper then measures the ordinary Markdown writer callback directly. The residual after subtracting both from total is the lowering share; it also includes the small writer setup and finalization difference. That residual is derived, not directly measured, and the results file says so in a derived field. For a focused profile, append -- --file data.csv --iterations 25; the default remains five runs over the full corpus.

FileRead (ms)Lower (ms)Write (ms)Total (ms)
book.epub18.312.85.7126.82
data.csv11.057.2810.9829.31
deck.ppt3.810.040.164.01
page.html12.872.955.9121.72
report.docx0.470.020.030.52
sheet.xlsx0.360.020.020.4
slides.odp5.90.080.116.08
slides.pptx4.930.20.115.25
Table 3: In-process stage split, median of 5 runs, for the eight files with the most work to split. Read covers parsing, building, and validation; write is measured inside the callback; lower is the explicitly marked residual.

Two facts fall out. Parsing dominates the container formats: an ODT or a PPTX spends almost all its time inside the archive and XML, and the Markdown writer is nearly free. The 633 KiB CSV now spends 10.4 ms reading, 6.6 ms in the lowering residual, and 9.9 ms in the writer. Stage separation found the 0.3.0 regression: validation and lowering each created hard-cap-sized scratch storage repeatedly as they visited a wide document. The current implementation allocates one scratch area for each validation or lowering plan and reuses it synchronously. That keeps the same bounds and decisions while avoiding work proportional to the node count times the hard cap.

The Python wheel

The same engine ships to Python as the zenfmt wheel (ZDS 0014), and the benchmark measures what users actually install: zig build
benchmark-python
builds the wheel, installs it into a clean isolated environment, verifies artifact parity against the same-revision CLI, and only then times it. The detailed results land in benchmarks/results/python.json; the process harness adds a cold zenfmt-python-wheel row beside the CLI. Cold and warm numbers answer different questions and are never merged into one headline.

45.7 ms
cold import
fresh interpreter, import zenfmt
49.5 ms
cold first conversion
import, load, verify, convert
1.4802 ms
warm memory call
median corpus file, in-process
0.29 ms
boundary microbenchmark
tiny text; FFI + copies + models

The boundary microbenchmark is reported separately and never used to claim corpus throughput: it exists so native loading, validation, copying, and model construction stay visible when parsing work is negligible. Parity ran before timing: 16 corpus files compared for format ids, artifact digests, resource digests, and report codes — all agreed.

ThreadsDocumentsWall (ms)Docs / s
180855.793.49
280441.66181.14
480237.71336.54
880173.7460.56
Table 4: One immutable Converter shared across worker threads over independent corpus documents. The GIL is released for every native call, so throughput scales with cores until conversion saturates memory bandwidth.

Head to head on the shared corpus, the wheel’s cold child-process row — a fresh interpreter per document, directly comparable to the CLI row — runs at 9.9x the CLI’s wall time (geometric mean over 16 files; above 1.0 means the CLI is faster). The difference is interpreter start plus one-time bridge verification; the warm rows above are what a long-running service pays.

The server lens: against Apache Tika

The rows above measure a converter that starts, converts one file, and exits. A different question is how the long-running service compares, and the natural comparison there is Apache Tika, the tool teams reach for when they want extraction behind a port. zig build benchmark-server starts zenfmt in open mode and a pinned Apache Tika Server (4.0.0-beta-1, its documented Markdown handler) on loopback, and writes benchmarks/results/server.json. These numbers are never merged with the native rows above: process startup, HTTP transfer, and service isolation are different costs.

65x
startup ratio
zenfmt is ready in 0.11 s; Tika’s JVM and parser pool take 7 s; the ratio is Tika divided by zenfmt
34x
sampled resident ratio
53 MB resident against Tika’s 1815 MB parent and direct parser children
31x
shared warm ratio
geometric mean over the 16 files both services convert, each service warmed to steady state first; the ratio is Tika divided by zenfmt

Warm latency here measures steady state: both services convert one discarded warm-up per file before the timed samples, because Tika’s per-client parser mode pays several seconds per forked worker on its first requests. zenfmt had lower median latency on all 16 shared files in this run. The closest was data.csv, where Tika used 2.3x the wall time. This is one host and corpus, not a claim about every service workload. Concurrent throughput, in documents per second, scaled with cores for zenfmt in this short run:

concurrencyzenfmtTika
1553 /s4 /s
21075 /s8 /s
42016 /s14 /s
82655 /s15 /s

One caveat belongs to Tika, not against it. Tika 4 parses in forked child processes, so a parser that panics or exhausts memory takes down a worker, not the service. zenfmt converts in-process; the deployment guide requires a supervisor and operating-system limits for exactly this reason. The speed and memory numbers are real, and so is that difference; the record keeps both visible rather than collapsing them into a single verdict.

Reading it honestly

These measurements need their caveats stated plainly.

  • Startup is part of the story, but not all of it. Subtract the 40 ms runtime floor from every anydoc bar, and its document work is competitive on small files. zenfmt’s advantage there is the sum of native start and arena parsing. It is not evidence that the Rust code is slow. On the large inputs the gap survives the subtraction.
  • Completeness is bounded by the corpus. Sixteen real files cover every reader once. They do not cover every construct. The per-format fidelity claims rest on the test suites and the ZDS mapping tables, not on this chapter.
  • PDF numbers measure text extraction, structure heuristics included. They do not measure OCR. Scanned documents are refused with pdf.no-text rather than converted into silence.
  • One machine, one printing. The shapes replicate across machines. The absolute values are yours to re-measure with one build command.

Exercise 9.1.

Add another corpus file: a Markdown document of at least a megabyte. Predict all three of zenfmt’s bars before running. Which tool’s ordering changes? pandoc is a Markdown-native tool. Why does it not obviously win this file?

Hint: benchmarks/fetch_corpus.sh shows the naming convention; the harness picks up any file in the corpus directory. pandoc must parse and re-serialize; zenfmt’s round-trip is measured in the fixed-point suite.