perf(markdown): stop rescanning the whole document for every math token - #431
perf(markdown): stop rescanning the whole document for every math token#431PathGao wants to merge 1 commit into
Conversation
`restore_math_spans` walks the rendered HTML looking for the mask tokens
`mask_math_spans` left behind. It looked for two spellings at once — the
token as emitted, and the lowercase one comrak produces when it anchorizes
a heading into `id=`/`href="#…"` — and it asked `str::find` for both, over
the remaining HTML, once per token.
The anchorized spelling only ever occurs in a heading that contains a
formula. In a document without one, every token paid a full scan of
everything still to come to be told "not found", then the next token paid
it again. That is O(tokens x bytes): a document is not slow because it is
long, it is slow because it is long *and* full of formulas.
Measured on documents of one inline formula per line; one run, minimum of
several reps per size, `opt-level = 2`:
formulas HTML restore growth convert_markdown share
1 000 157 KB 16.2 ms — 17.7 ms 92%
2 000 318 KB 57.0 ms 3.5x 62.1 ms 92%
5 000 801 KB 349.9 ms 6.1x 359.8 ms 97%
10 000 1.6 MB 1 364 ms 3.9x 1 372 ms 99%
20 000 3.2 MB 6 341 ms 4.7x 6 359 ms 99%
Four times the time for twice the input, six for two and a half. That is
quadratic, and it is effectively all of `convert_markdown` — which runs on
every keystroke in reading mode.
Each spelling now carries its last answer in a `TokenScan`. An offset that
is the first occurrence at or after some cursor is still the first
occurrence at or after any later cursor it survives, so a needle is only
searched for again once the cursor has passed its answer, and "no
occurrence from here on" stays true forever. Consecutive searches cover
disjoint stretches of the HTML.
formulas HTML restore growth convert_markdown share
1 000 157 KB 0.22 ms — 2.5 ms 8.9%
2 000 318 KB 0.43 ms 1.9x 5.0 ms 8.6%
5 000 801 KB 1.07 ms 2.5x 12.6 ms 8.5%
10 000 1.6 MB 2.18 ms 2.0x 26.5 ms 8.2%
20 000 3.2 MB 4.41 ms 2.0x 86.8 ms 5.1%
Growth now tracks the input exactly — 2.5x the formulas costs 2.49x the
time, and a 40 000-formula document extends the line at 13.9 ms.
`convert_markdown` at 10 000 formulas goes from 1 372 ms to 26 ms. What is
left is `mask_math_spans` (40%), comrak (37%), the three pre-passes (10%)
and this pass (8%), all of them linear on this document shape.
Nothing about the output changes, and that is checked rather than claimed:
old and new were diffed byte for byte over 40 082 documents — 40 000
generated by shuffling fragments chosen to stress this seam, every case in
`scripts/mathDelimiterCorpus.json`, every `.md` in the repo, and 42
hand-written adversarial ones (no tokens, one token, tokens at either
edge, adjacent tokens, non-ASCII and emoji either side, a token in a code
block, a heading, an `href`, an `alt`, a table cell, and the mask sentinel
typed by the user or synthesised by an HTML entity). Not one differed.
Because removing the carrying would change no output, no output test could
catch the regression — verified, by mutating `TokenScan` to search every
time and watching all 40 082 differential documents stay identical — and a
wall-clock assertion would measure the CI runner rather than the code. The
two tests added pin the mechanism instead: that a carried answer comes
back without the haystack being consulted, and that the pass does not
search the HTML by itself. Both fail against a reverted implementation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Closing this. The quadratic behaviour is real and the fix is correct — but profiled against a real document rather than a synthetic one, it buys 1.5 ms, and that does not justify 163 lines and a new abstraction. The measurement that decided it. A 119 KB / 3 165-line stress document with 146 code fences and 5 Mermaid diagrams — the largest realistic document available here — profiled through the whole of So the pass really is 21.8 % of the pipeline on a document containing almost no maths — which is exactly the For scale, the same profile on smaller documents: 40 KB → 1.46 ms total; a 4 KB README → 0.16 ms. And downstream of this sits the IPC round trip, a full The earlier numbers that motivated this work — 1 364 ms at 10 000 formulas — are real, but a 10 000-formula Markdown document is not a document anyone writes in this editor. Nothing here was wrong. The growth curve, the isolating control (48.9 ms vs 6 341 ms once a maths-bearing heading exists — same code, same token count, 130× apart), the 40 082-document byte-equality proof, and the structural regression test that hands Two findings from it worth keeping on the record:
Same reasoning that closed #430, though that one was worse: it made ordinary documents 1.2–1.6× slower. This one has no regression at all — it is simply too small to buy. |
The defect
mask_math_spansreplaces every math span and every escaped-dollar run with a token —MPMATHMASK<n>E— so comrak cannot rewrite the formula.restore_math_spansthen walks the rendered HTML and puts the source back.It has to look for two spellings of that token. Everywhere else it lands verbatim, but comrak anchorizes a heading's rendered text into
id=andhref="#…", which lowercases it, and there the anchorized source must go back instead so[[#A heading with $x_1$]]still resolves. So the loop asked for both:restadvances, so this looks linear. It is not. The lowercase spelling only occurs in a heading that contains a formula. In a document without one,rest.find(anchor_prefix)reads every remaining byte to returnNone— and the next token reads them again, minus one token's worth. That isO(tokens × bytes): a document is not slow because it is long, it is slow because it is long and full of formulas.The uppercase search hides the shape rather than causing it. It terminates quickly precisely because tokens are dense — which is the same condition that makes the failing lowercase search run so many times.
Measured, not assumed
Documents of one inline formula per line. One run, minimum of several reps at each size,
opt-level = 2;growthis each row against the one above it.Before
restore_math_spansconvert_markdownFour times the time for twice the input, six for two and a half. That is quadratic, and it is effectively all of
convert_markdown— which runs on every keystroke in reading mode.After
restore_math_spansconvert_markdownGrowth now tracks the input exactly: 2× the formulas costs 2.0×, 2.5× costs 2.49×. A separate run extended the line to 40 000 formulas (6.6 MB of HTML) at 13.9 ms, still 2.1× per doubling.
The
convert_markdowncolumn carries far more run-to-run variance than therestorecolumn — it allocates and frees several megabytes, and this machine was not idle. Across four runs the 20 000-formula figure ranged 56–157 ms whilerestorestayed at 4.4–7.8 ms and the 10 000-formula "before" figure reproduced at 1 364 ms three times out of four. The growth columns, which are what the claim rests on, were stable in every run.What the bottleneck is now. At 20 000 formulas, on a quiet machine,
convert_markdown(56.4 ms) breaks down asmask_math_spans22.5 ms (40%), comrak 21.1 ms (37%), the three pre-passes 5.4 ms (10%),restore_math_spans4.4 ms (8%),annotate_task_checkboxes0.9 ms (2%). Halving to 10 000 halves each of them, so no second quadratic is hiding behind this one on this document shape.A control that isolates the cause. The same document with a math-bearing heading every 100 lines — identical token count, but now the anchorized spelling really occurs — ran the old code at 2.5 / 4.5 / 10.5 / 22.2 / 48.9 ms across the same five sizes. Near-linear, because the failing search terminates at the next heading instead of at end of document. Same code, same tokens, 130× apart at 20 000. That difference is the bug.
The fix
Each spelling carries its last answer:
The correctness argument is that the cursor only ever moves forward. An offset that is the first occurrence at or after some cursor is still the first occurrence at or after any later cursor it survives — anything earlier would already have been found by the earlier search. And
None, "no occurrence from here on", stays true forever. So a needle is only searched for again once the cursor has passed its answer, and consecutive searches begin where the previous one stopped: disjoint stretches, linear in total, whatever the token count.rest: &strbecamecursor: usizeso both scans can index the same buffer. Nothing else in the pass changed — the tie-break still favours the plain spelling,in_tagis still decided by the last<or>in the gap since the previous token, and every branch emits exactly what it emitted before.Byte-equality evidence
The old implementation was kept alongside the new one during development and both were run through the full
convert_markdownpipeline over 40 082 documents, asserting byte equality of the HTML:alttext, raw HTML, CRLF, CJK, emoji, lone$and\, wikilinks, and mask-sentinel lookalikes. Branch coverage over that corpus: 39 365 documents produced masked spans, 4 153 reached the anchorized branch, 3 438 left an unparsed token behind, 30 341 needed a grown prefix.scripts/mathDelimiterCorpus.json..mdfiles in the repo.href, analt, a table cell, a task item, a blockquote, a raw-HTML attribute; an unclosed<before a span; a span containing all four of&<>"; a truncated$$opener; CRLF; no trailing newline.Not one differed. The differential harness is not in this PR — shipping it would mean shipping the old implementation.
The mask-sentinel collision, unchanged. Worth stating because it is unusual and this PR deliberately preserves it.
mask_math_spansestablishes uniqueness by construction: it grows the prefix withXuntil the source no longer contains it, case-insensitively. So a user who typesMPMATHMASK0Egets tokens spelledMPMATHMASKX…E, and their text is left alone — the four adversarial cases covering that are byte-identical. The gap is that uniqueness is checked against the source while restoration runs on comrak's output, so an HTML entity can synthesise a token the check never saw:MPMATHMASK0Ereachesrestore_math_spansasMPMATHMASK0Eand is replaced by the first masked span.MPMATHMASKalone, with nothing token-shaped after it, is emitted verbatim. Both behaviours are identical before and after this change. Whether the first one should be fixed is a separate decision.Regression test
A wall-clock assertion here would be measuring how busy the CI runner is, and there is no timing test anywhere in this repo. But the property is also invisible to an output test: deleting the carrying changes no byte of any document. That was verified rather than assumed — with
TokenScanmutated to search every time, all 40 082 differential documents and every other test in the suite still passed, and only the new mechanism test failed.So the two tests added pin the mechanism instead, in the same
include_str!idiomevery_convert_markdown_preprocessing_step_is_registeredalready uses:a_carried_search_reuses_its_answer_instead_of_searching_againhands one scan a haystack the needle does not occur in at all and requires the previous answer back. No implementation that searched again could answer that correctly. It also asserts the stickyNone, and that the answers agree with a freshstr::findat every cursor position.restore_math_spans_scans_only_through_a_carried_searchextracts the function body frominclude_str!("lib.rs")and asserts it containsTokenScan::new(and no.find(— the shape it had when it was quadratic. Line endings are normalised first, because git hands the file to Windows CI with CRLF.Both were confirmed to fail against a deliberately reverted implementation, and the second one's message says what to do if a future bounded search legitimately needs to relax it.
Checks
cargo test(146 pass),cargo clippy(three warnings, the same three asmaster—EXE_NAMEunused, one collapsibleif, one single-characterpush_str; zero delta),npm test(562 pass),npm run check(0 errors).cargo fmt --checkreports the same 53 pre-existing hunks before and after; the new code adds none.Not covered
mask_math_spansis now the largest single cost (40% at 20 000 formulas) and was not touched. It is linear on the documents measured here, but it has two nested scans that could bite on other shapes:find_escaped_dollar_spanschecks each escape against every math span (O(escapes × spans)), andfind_display_closechecks each line against every code region. Both need a document that is simultaneously escape-heavy and math-heavy, or code-region-heavy, to matter. Separate work.scripts/mathDelimiterCorpus.jsonis untouched;the_math_contract_corpus_is_a_live_capturepasses unchanged, which is itself a byte-equality check over 29 documents.restore_math_spansruns on rendered HTML, aftersourceposnumbers exist, and is already listed innot_a_transformfor exactly that reason. It emits what it emitted before, so no source line can have moved.opt-level = 2; the release profile isopt-level = "s"with LTO, so absolute times will differ. The growth shape will not.convert_markdownonly. Whether the preview is now bounded by KaTeX or by DOM work on a math-heavy document is unknown.🤖 Generated with Claude Code