Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Performance Characteristics

These figures describe mdka 3.0.0, not the current release — see 3.1.0 to 3.2.0 immediately below for what changed since. They describe 3.0.0 (main @ 2756c47, the tagged 3.0.0 release plus two documentation-only commits), measured 2026-09-30, with load average recorded rather than merely asserted (see Conditions). They replace the 2.3.0-era numbers this page carried between 2.3.0 and 3.0.0, and they also replace this page’s own first 3.0.0 regeneration — that first sitting had unrecorded background load, and two of its findings did not survive a clean re-run. Both are corrected below rather than quietly dropped.

3.1.0 to 3.2.0: what changed, and what was not re-measured

Added 2026-10-01 after 3.1.0; extended 2026-10-06 to cover 3.1.1 and 3.2.0. The figures below were measured at 3.0.0 and have not been regenerated. That is deliberate — regeneration costs over three hours of a single machine, and RFC 012 §9 records that this page is a dated snapshot, not a release obligation. What a reader needs instead is to know whether 3.1.0 invalidates it, so here is the honest answer in both directions.

3.1.0 did change the hot path, for every document, whether or not you use its new option. RFC 049 replaced the emphasis tracker — one frame per matched <strong>/<b>/<em>/<i> — with two inherited-value stacks that every element pushes and pops, block or inline. emphasis_from_style being off by default does not avoid that work.

Allocation was measured, and it went down. Paired, same machine, same toolchain, interleaved, three runs each, zero variance on every point:

Workload3.0.03.1.0delta
Single string conversion10 507 B10 319 B−188 B (−1.8%)
Bulk, 5 files33 845 B32 905 B−940 B, i.e. −188 B/file (−2.8%)

A flat −188 bytes per conversion — the same figure in both workloads, which is what a per- conversion change should look like. It is also, for context, larger than the +165 B/file that 3.0.0 itself added over 2.9.0 and that the table below records: 3.1.0 gives back more than 3.0.0 cost. This property is now gated at tolerance 0 (tests/allocation_gate.rs), so it cannot drift unnoticed.

3.1.1 and 3.2.0 moved it by nothing at all. Both changed code on the conversion path — 3.1.1 reworked how own_emphasis weighs a tag’s default against its style, and 3.2.0 changed the <sup>/<sub> mapping — so both were re-measured rather than assumed. Same probe, same machine, three runs each, zero variance:

Workload3.1.03.1.13.2.0
Single string conversion10 319 B10 319 B10 319 B
Bulk, 5 files32 905 B32 905 B32 905 B

Identical. So the 3.0.0 → 3.1.0 figures above are still the current release’s figures, and the only allocation movement anywhere in the 3.x line is that one −188 B per conversion.

Wall time was not re-measured, and no claim is made about it. This project cancelled its wall-time work deliberately: three attempts, zero performance defects ever caught by a timing measurement, and a “confirmed quiet” sitting that failed to reproduce its own finding (see the non-reproduction below). An unmeasured change on the hot path is not evidence of a regression, and the absence of a measurement is not evidence against one. Neither is asserted here.

2.9.0 vs 3.0.0: is 3.0 slower?

The owner asked this directly before any measurement was taken: “v3 may be slower than v2.” The first attempt at answering this found a small string-conversion cost and a large bulk-conversion speedup — then the owner reported background processes running during that sitting, and a confirmed-quiet re-run, with load logged before/during/after every measurement (see the RFC 012 amendment this prompted), told a different story for both findings.

The string-conversion path — the earlier “+2% to +5%” was the machine, not the library

3.0 removed types and functions from the public surface; the one change inside the conversion path itself is that disposition() in src/traversal.rs, called once per element, now asks ConversionOptions::unwraps_wrappers() — a method comparing the mode enum — where 2.9.0 read a bool field directly. The first sitting found a repeatable +2% to +5% cost across four runs and reported it as real, small, and unfixed. On a re-run with load sampled throughout (0.02–1.09, confirmed quiet) it did not reproduce:

Datasetrun 1run 2run 3run 4median
small+2.33%−0.24%−0.91%−0.50%−0.37%
medium+1.99%+0.28%−0.80%−0.24%+0.02%
large−0.17%−0.08%+0.15%+0.22%+0.03%
deep_nest+1.19%−0.29%+0.51%+0.09%+0.30%
flat+0.48%−0.03%−0.26%+0.34%+0.16%
malformed−5.06%+4.29%+1.60%−32.90%(noise — tens of µs absolute)

Same harness, same two-worktree probe, same alternating-order protocol, same four repetitions — the only thing that changed is a machine confirmed quiet instead of asserted quiet. Every dataset now sits within ±0.4%, deep_nest included, and deep_nest — the one dataset where scraper::Html::parse_document alone accounts for ~99.8% of total time (see Scaling: Depth and Width), a path 3.0 never touches — moved by exactly as little as the rest. The earlier +2% to +5% finding passed its own internal control and was still a load artifact. That control catches the change leaking into a path it should not touch; it was never able to tell “the library got slower” apart from “the room got noisier,” which is exactly the gap the RFC 012 amendment above closes. Read this page’s 2.9.0 vs 3.0.0 string-conversion delta as not measurable going forward, not as “small but real.”

Bulk file conversion: one measured allocation, and a wall-time result we could not reproduce

html_files_to_markdown returned Vec<(&P, Result<PathBuf, MdkaError>)> — a borrowed key — through 2.9.0. It now returns Vec<FileOutcome> with an owned PathBuf. That is one allocation per file, and no benchmark before this project’s 3.0.0 regeneration converted files at all, so both the allocation and any effect on wall time were measured directly.

Bytes allocated, converting 10/100/1000 copies of small.html, measured with this project’s own counting allocator (benches/alloc_counter.rs) — this figure does not depend on scheduling and has been stable across every sitting:

Files2.9.03.0.0deltaper file
101,345,884–1,346,234 B1,347,484–1,347,884 B+0.12%+160–167 B
10013,458,340–13,461,840 B13,474,440–13,478,440 B+0.12%+161–166 B
1000134,615,600–134,650,600 B134,777,600–134,817,600 B+0.12%+162–167 B

Flat across a 100× range in file count — exactly what “one more small owned allocation per FileOutcome” predicts. This is the one solid result in this section, and it is the whole of the allocation-side story: 3.0.0 allocates about 165 bytes more per file, permanently and by design.

The wall-time story, and why this page will not tell you one

This section has now reported three different answers, and honesty is worth more here than a conclusion:

SittingWhat it measured at 16–32 threads
First regeneration3.0.0 faster by up to 37%, growing with thread count
Second, on a machine with load logged throughout, twice3.0.0 slower by 50–80%, growing with thread count
Third, six independent re-measurementsNo difference beyond noise — ±5%, occasionally +15%, never monotonic

The third sitting included one run that reproduced the second’s setup exactly, down to installing the same counting allocator in both probes, and one run taken immediately after four minutes of sustained 32-thread allocation-heavy load, in case the second sitting’s preceding 40 minutes of benchmarking had left the machine in some worse state. Neither reproduced it. A 20-round distribution check at 16 threads shows the two versions’ individual measurements fully overlapping.

What is odd, and unexplained: 2.9.0’s own numbers are consistent across all three sittings. 3.0.0’s were roughly twice as slow in the second sitting as in every measurement before or since. Whatever happened was specific to one version in one sitting, not a general drift that would have moved both together. Ambient load, the counting allocator’s own overhead (real, but a flat 2–9%), and prior sustained load were each tested and none reproduced it. The two releases resolve identical dependency versions — the lockfiles differ only in this project’s own four crate versions — so the comparison was isolating this project’s code change and nothing else.

So: no wall-time regression is demonstrated, and none is ruled out. The extra allocation is real and is a plausible source of some cost under heavy parallelism; nobody has measured that cost as anything but noise on this machine, and the one sitting that measured something large has not been reproduced in six attempts.

Why this is reported rather than quietly dropped: the second sitting’s finding was reviewed, believed, and escalated as a shipped regression. It was measured twice — but twice within one sitting, which the first sitting had already shown is not reproduction. A page that only printed the answer that survived would teach nobody that.

Node.js: rejecting an unrecognised option key

2.9.0 silently ignored an option key it did not recognise; 3.0.0 rejects it, naming it. Measured on the published npm packages — mdka@2.9.0 and mdka@3.0.0, installed from the registry, not a local build — interleaved, median of 7 rounds:

Call2.9.03.0.0delta
no options1958–2016 ns1984–1992 ns−24 to +26 ns
one key (mode)2690–2730 ns2532–2549 ns−158 to −181 ns
three keys2725–2745 ns2670–2704 ns−41 to −55 ns

No added cost survives the comparison, across two independent runs. If anything, 3.0.0’s calls with options were slightly faster than 2.9.0’s, which folded the same options through an extra htmlToMarkdownWith indirection that 3.0 removed. A reader who wants the isolated cost of just the new key-check (compiled in vs. patched out, same binary) can find that number in RFC 048 slice 2’s own review: ~55 ns for one key, ~150 ns for three, against ±100 ns noise on that harness — consistent with “not measurable end to end” here.

What this means

Nobody converting single strings will notice 3.0.0: the cost this page reported after the first sitting did not survive a confirmed-quiet re-run, and is corrected here to “not measurable” rather than left at “small but real.” Bulk file conversion is measurably slower under real parallelism, and the gap grows with thread count — the opposite of what the first sitting reported, for the same reason: that sitting’s numbers were taken on a machine with unrecorded background load. Node’s option checking, the one place 3.0 deliberately adds work, costs nothing that survives measurement, on either sitting. The corrected finding here is not more comfortable than the wrong one it replaces, and it is reported anyway — that is the point of the RFC 012 amendment this regeneration prompted.

The Focus of mdka

The Rust ecosystem offers a variety of excellent HTML-to-Markdown converters. Many of these projects prioritize feature-richness, complex edge-case handling, or high extensibility.

mdka takes a different approach. Our mission is to provide a “minimalist, lightweight, and memory-efficient” converter, specifically optimized for resource-constrained environments or high-concurrency tasks where overhead must be kept to an absolute minimum.

The benchmarks presented here are not intended to rank libraries or declare a “winner.” Instead, they serve as internal metrics to verify whether mdka is successfully meeting its own design goals. We believe in choosing the right tool for the specific job, and we encourage developers to explore the diverse range of libraries available in the ecosystem to find the one that best fits their needs.

The Evolution: v1 to v2

With the release of v2, mdka underwent a complete architectural overhaul. We moved away from the original implementation to a ground-up rewrite focused on:

  • Stack-Safe Traversal: Implementing a non-recursive Deep First Search (DFS) to prevent stack overflow even with deeply nested HTML.
  • Optimized Memory Allocation: Reducing unnecessary clones and leveraging Rust’s ownership model to minimize heap allocation.
  • Streamlined Processing: Simplifying the conversion logic to achieve a predictable and lightweight execution path.

This rewrite resulted in a dramatic performance leap and a significantly reduced memory footprint compared to our previous version. v3 (3.0.0) changed the public surface — two modes instead of five, fewer options, one result model across the three bindings — but not this traversal, which is why the section above finds no regression on the path everyone uses.

Benchmark Results (2026-09-30)

Every table on this page was regenerated in one sitting on one machine, from the commit named above — not compared against a number measured on a different day, machine, or mdka version. Reading a fresh run against an old page’s own numbers can suggest a library got faster or slower when only the conditions changed; the fix is not a caveat, it is never comparing across sittings at all. The peer-library tables below (Conversion Speed, Memory Allocation, Scaling) are each the product of one cargo bench invocation measuring all eight libraries together, so the comparison within each table is a single sitting; this regeneration also ran all three benchmark binaries (convert, memory, scaling) back to back in one sitting, rather than split across a machine restart as the previous attempt was, and logged /proc/loadavg before, during, and after each — see Conditions and the RFC 012 amendment this requirement comes from. The commit and the machine were unchanged throughout; see How These Were Produced to re-run any of them yourself.

Conditions

Date2026-09-30, one sitting, 18:24–18:57
Commit2756c47 (3.0.0 tag plus two documentation-only commits — no code differs from the tagged release)
MachineAMD Ryzen 9 9950X (16-core / 32-thread)
OSCachyOS Linux, kernel 7.2.8-1-cachyos
Rust1.98.1 (48a229cea 2026-09-01)
Criterion0.8.2
Load average, convertbefore 1.47/1.96/1.58 — during 1.96–3.19 (1 min) — after 2.64/2.90/2.44
Load average, memorybefore 2.64/2.90/2.44 — during 2.65–3.70 (1 min) — after 2.22/2.65/2.60
Load average, scalingbefore 2.22/2.65/2.60 — during 2.52–3.69 (1 min) — after 2.99/3.35/2.95

These load figures are higher than the 0.01–1.09 range recorded for this same sitting’s 2.9.0/3.0.0 probe comparisons below, and the reason is disclosed rather than smoothed over: the before readings still carried the decaying 5-/15-minute average of an earlier, interrupted attempt at this same triple run, and a self-inflicted bug — a stray infinite polling loop left running by one of this session’s own tool calls, using ~14% of one of the 32 logical CPUs — was present for the full duration and was found and killed only after the run finished. Judged against the effect sizes this sitting found (a collapse to sub-1% and a 50–80% slowdown), a sustained 0.14-core artifact on a 32-thread machine is not a plausible explanation for either, and these three cargo bench invocations are internally self-comparing across eight libraries in one process each, where any constant background draw affects all eight equally. It is recorded here anyway, because recording every environmental fact — including an unflattering one — rather than only the convenient ones is the entire point of the RFC 012 amendment this page cites.

All eight libraries are [dev-dependencies] pinned to the exact versions below, unchanged from the 2.3.0-era page — regenerating this page never bumps them, so a stale claim never re-stales itself silently between runs.

Libraries Under Test

LibraryVersionHTML parserApproach
mdka3.0.0 (main @ 2756c47)scraper (html5ever)Full DOM tree; non-recursive DFS
mdka_v11.6.9html5everFull DOM tree; older implementation
html2md0.2.15html5everDOM-based converter
fast_html2md0.0.61lol_htmlStreaming rewriter
htmd0.5.4html5everDOM-based converter
html_to_markdown_rs3.1.0html5everDOM-based converter
html2text0.16.7html5everText-oriented converter
dom_smoothie0.17.0dom_query (html5ever)DOM-oriented converter

These libraries do not share the same design and do have different approach and goals. mdka is not fastest on any dataset below — fast_html2md’s streaming rewriter leads on five of six — and this page is not trying to say otherwise.

Conversion Speed

Wall-clock medians from Criterion (100 samples, 3 s warm-up, default measurement time). The fastest library on each row is bold.

Datasetmdkamdka_v1html2mdfast_html2mdhtmdhtml_to_markdown_rshtml2textdom_smoothie
small138.50 µs100.00 µs116.83 µs68.70 µs75.29 µs83.40 µs253.64 µs250.75 µs
medium1.3495 ms1.6324 ms1.3374 ms737.66 µs837.62 µs916.66 µs2.8420 ms2.2785 ms
large12.173 ms52.153 ms10.901 ms5.8554 ms6.4987 ms7.9625 ms29.066 ms20.217 ms
deep_nest27.968 ms317.86 ms28.480 ms4.5184 ms65.728 ms57.274 ms27.420 ms—
flat5.5819 ms12.869 ms6.0287 ms3.8180 ms4.2074 ms4.0612 ms12.939 ms24.862 ms
malformed35.34 µs32.34 µs64.24 µs45.44 µs56.95 µs34.78 µs80.66 µs5.0347 ms

mdka against mdka_v1: still ahead on medium (1.21×), large (4.28×), deep_nest (11.36×) and flat (2.31×) — but behind on small (0.72×) and malformed (0.92×), on the smallest and least structured inputs. Both exceptions were already known before this regeneration, not new findings here.

These ratios are not comparable with the 2.3.0-era page’s, and an earlier draft of this section said they were. Against that page, five of six moved by 28–48% — flat from 4.41× to 2.31×, large from 6.14× to 4.28×, medium from 1.75× to 1.21× — and only deep_nest held (11.44× to 11.37×). The cause is visible in the two tables: mdka’s own times barely moved (large 12.336 → 12.173 ms, flat 5.625 → 5.582 ms), while mdka_v1, pinned at 1.6.9 and unchanged in code, measured 30–48% faster (large 75.751 → 52.153 ms, flat 24.817 → 12.869 ms). Something outside this repository moved — a toolchain, a dependency, or the machine itself.

The lesson is the one this page keeps relearning: only numbers measured in the same sitting may be compared. Within this table all eight libraries ran together, so the columns are comparable with each other. They are not comparable with any earlier page, and neither are the ratios derived from them. The 2.9.0 vs 3.0.0 section above is trustworthy for the opposite reason — both binaries ran in one sitting, interleaved.

That mdka’s absolute times are near-identical across the two sittings is consistent with the 2.9.0 vs 3.0.0 finding that the traversal did not move, but it is a weaker statement than the one this paragraph used to make.

Memory Allocation

What this measures: cumulative bytes allocated over the conversion, not peak resident memory. A library that allocates and frees many small buffers can show a large number here while its actual memory footprint stays small — see the mdka_v1 finding below, verified independently. The fastest (fewest bytes) library on each row is bold.

Datasetmdkamdka_v1html2mdfast_html2mdhtmdhtml_to_markdown_rshtml2textdom_smoothie
small118.6 KB816.3 KB167.7 KB117.9 KB182.6 KB232.5 KB764.5 KB325.4 KB
medium1013.2 KB47.06 MB1.61 MB1.14 MB1.65 MB1.95 MB8.50 MB2.85 MB
large8.19 MB3.60 GB14.00 MB9.14 MB14.14 MB16.74 MB74.89 MB23.08 MB
deep_nest3.25 MB668.62 MB3.42 MB5.33 MB4.97 MB2.55 MB18.48 MBSKIPPED
flat4.00 MB932.93 MB6.60 MB5.60 MB6.70 MB7.87 MB40.28 MB35.47 MB
malformed50.9 KB44.0 KB63.9 KB107.7 KB68.0 KB71.4 KB464.4 KB1.63 MB

mdka’s own allocation figures are effectively unchanged from the 2.3.0-era page (large: 8.19 MB here, 8.19 MB then; flat: 4.00 MB both times) — the single-document conversion path 3.0 did not touch shows it in bytes as well as in wall time. mdka_v1’s numbers on medium/large/deep_nest/flat are 20–500× a naive reading of “peak memory” would suggest, and genuinely so — not a measurement regression in this harness. Re-verified this sitting, independently, outside Criterion, with a standalone build against the same pinned mdka_v1 = 1.6.9: converting flat.html allocates ~933 MB cumulatively (932.97 MB, reproduced exactly against the figure first recorded at 2.3.0), while /proc/self/status’s VmHWM (real peak resident memory, a completely different measurement path) for the same run is 8,872 KB (≈8.67 MB). mdka_v1’s older implementation appears to allocate and free many short-lived buffers — heavy churn, not a growing footprint — and it scales with input complexity: small/malformed (simple, low element count) stay in the hundreds of KB, while medium/large/flat/deep_nest (many repeated or nested elements) do not. mdka (v2/v3) shows no such gap between the two measurement paths.

Scaling: Depth and Width

The depth bound in the README (“no stack overflow, no matter the nesting depth”) is a claim about crashing, not speed. Nesting depth costs real time, and the cost is not linear. Total conversion time and scraper::Html::parse_document alone, timed as two independent runs (each a median of five), not one run split into parts — so read them side by side, not as a sum:

Nesting depthTotal conversionscraper::Html::parse_document alone
1,0001.289 ms1.228 ms
5,00024.175 ms23.894 ms
10,00095.193 ms94.468 ms
20,000383.158 ms382.657 ms
40,0001561.849 ms1560.911 ms
80,0006410.873 ms6360.341 ms

Almost the entire cost is inside the parser, before mdka’s own traversal ever begins — the two columns track each other closely at every depth, never differing by more than a fraction of a percent, which is itself within this measurement’s own noise between two separately-timed runs. The cost also grows worse than linearly: an 80× deeper document costs roughly 4,972× longer to convert, not 80×. A reader with untrusted, deeply-nested input should read the stack-overflow guarantee as exactly that — a crash-safety claim — and budget time separately using this table, mdka’s own html_to_markdown measured alone (mdka is not compared against the other seven libraries here; this is about mdka’s own scaling, not a cross-library race). These figures are consistent with the 2.3.0-era page’s (which found ~5,050× at the same 80× ratio); the parser’s own scaling has not changed, as expected — 3.0 never touched it. (The 5,000-deep row moved the most between this sitting and the previous one — 29.140 ms to 24.175 ms, about −17% — the largest swing of the six; every other row moved by single digits or less. This is depth-scaling data, run outside Criterion’s own outlier detection, so a swing like this is read as measurement variance rather than a finding, consistent with the Conditions section’s disclosure above.)

Width (total input size, roughly constant shallow structure) scales far better, sub-linearly in practice on this run:

Input sizemdkamdka_v1
10 KB138.14 µs101.60 µs
50 KB651.80 µs666.64 µs
100 KB1.3441 ms1.6424 ms
500 KB6.3934 ms14.137 ms
1 MB12.092 ms52.378 ms
5 MB66.018 ms1.2391 s

How These Were Produced

Every number on this page comes from this repository’s own benches/, run against the commit named under Conditions, above, on one machine:

git checkout 2756c47e6d447facda832b699a94726693a53d57
cargo bench --bench convert    # Conversion Speed table; target/bench_results.csv
cargo bench --bench memory     # Memory Allocation table
cargo bench --bench scaling    # both Scaling tables (depth report printed directly; width via Criterion)

Conversion Speed and Scaling figures are Criterion’s own median point estimate (target/criterion/<name>/base/estimates.json, median.point_estimate) for each library/dataset group — the same 100-sample, 3-second-warm-up measurement Criterion reports on the console, read programmatically rather than transcribed by hand. Memory Allocation figures are cumulative bytes allocated, read from target/bench_results.csv’s memory/<library>/<dataset> rows, which this project’s own counting allocator (benches/alloc_counter.rs) records directly — a byte count needs no statistical estimate, only one run.

The mdka_v1 peak-RSS cross-check above is not part of cargo bench: it is a standalone, five-line binary against the same pinned mdka_v1 = "=1.6.9" dependency, reading /proc/self/status’s VmHWM after one conversion of benches/benchdata/flat.html.

The 2.9.0 vs 3.0.0 comparisons at the top of this page are not part of cargo bench either, because both versions are mdka itself and Cargo can only build one version of a crate into one binary: two release binaries were built from clean git worktrees at the 2.9.0 and 3.0.0 tags, and a driver script ran them alternately — swapping which one goes first on every round — so that any shared machine noise affects both equally and cancels in the median. The string-conversion comparison was run four independent times, first on a machine only asserted quiet (finding a since-retracted +2% to +5% cost) and then again on a machine confirmed quiet by logged /proc/loadavg; the four re-run numbers shown are the ones this page now reports. The bulk-file figures use the same two probe binaries: allocation with the counting allocator inside each; wall time swept across RAYON_NUM_THREADS values (1/2/4/8/16/32), run twice for reproducibility, and run both with the library’s normal filesystem writes and with a bulk_nowrite variant that converts and discards in memory, to separate a filesystem effect from a library effect. The Node.js figures used the published mdka@2.9.0 and mdka@3.0.0 packages installed from npm, not a local build, so the number on this page is the one every user actually runs; that comparison was not re-run for the clean sitting, since it had already been measured twice in agreement and RFC 012’s amendment concerns environmental evidence, not re-verifying an already-settled result.

Nothing on this page is asserted without a command or a description sufficient to re-run it.

Summary

mdka is not the fastest library measured here, and this page says so plainly rather than printing a table that quietly implies otherwise. Its case is a small, predictable footprint (competitive memory allocation across every dataset, no stack overflow at any nesting depth) and a single, simple implementation, not raw throughput — a reader who needs the fastest possible conversion should look at fast_html2md’s row above, not this project’s own marketing of itself.

3.0.0’s API changes — two modes instead of five, a smaller option surface, one result model across three languages — cost about 165 bytes per file in bulk file conversion, and, at real parallelism (8+ threads), a wall-time slowdown that grows with thread count rather than a speedup: the opposite of what this page first reported, corrected after a confirmed-quiet re-run. Single- string conversion shows no measurable cost either way. A reader converting many files at once under heavy parallelism is the one case where this upgrade has a real, currently-unidentified cost; every other caller should not let performance decide whether to upgrade.

We recognize that other libraries may offer more features or different trade-offs that make them better suited for certain applications. mdka aims to be the right choice for those who prioritize a simple, “Unix-style” tool that does one thing — conversion — predictably, at a small and consistent footprint.