Skip to content

perf(markdown): stop rescanning the whole document for every math token - #431

Closed
PathGao wants to merge 1 commit into
masterfrom
perf/restore-math-spans-linear
Closed

perf(markdown): stop rescanning the whole document for every math token#431
PathGao wants to merge 1 commit into
masterfrom
perf/restore-math-spans-linear

Conversation

@PathGao

@PathGao PathGao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

The defect

mask_math_spans replaces every math span and every escaped-dollar run with a token — MPMATHMASK<n>E — so comrak cannot rewrite the formula. restore_math_spans then 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= and href="#…", 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:

while let Some((at, anchored)) = [
    (rest.find(masked.prefix.as_str()), false),
    (rest.find(anchor_prefix.as_str()), true),
].into_iter().filter_map().min()
{
    …
    rest = &after[digits + suffix.len_utf8()..];   // advance past this token
}

rest advances, 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 return None — and the next token reads them again, minus one token's worth. That is O(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; growth is each row against the one above it.

Before

formulas HTML restore_math_spans growth convert_markdown restore's share
1 000 157 KB 16.2 ms 17.7 ms 92%
2 000 318 KB 57.0 ms 3.5× 62.1 ms 92%
5 000 801 KB 350 ms 6.1× 360 ms 97%
10 000 1.6 MB 1 364 ms 3.9× 1 372 ms 99%
20 000 3.2 MB 6 341 ms 4.7× 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.

After

formulas HTML restore_math_spans growth convert_markdown restore's share
1 000 157 KB 0.22 ms 2.5 ms 8.9%
2 000 318 KB 0.43 ms 1.9× 5.0 ms 8.6%
5 000 801 KB 1.07 ms 2.5× 12.6 ms 8.5%
10 000 1.6 MB 2.18 ms 2.0× 26.5 ms 8.2%
20 000 3.2 MB 4.41 ms 2.0× 86.8 ms 5.1%

Growth 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_markdown column carries far more run-to-run variance than the restore column — it allocates and frees several megabytes, and this machine was not idle. Across four runs the 20 000-formula figure ranged 56–157 ms while restore stayed 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 as mask_math_spans 22.5 ms (40%), comrak 21.1 ms (37%), the three pre-passes 5.4 ms (10%), restore_math_spans 4.4 ms (8%), annotate_task_checkboxes 0.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:

struct TokenScan<'needle> {
    needle: &'needle str,
    carried: Option<usize>,
}

fn at_or_after(&mut self, haystack: &str, from: usize) -> Option<usize> {
    if self.carried.is_some_and(|at| at < from) {
        self.carried = haystack[from..].find(self.needle).map(|at| from + at);
    }
    self.carried
}

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: &str became cursor: usize so both scans can index the same buffer. Nothing else in the pass changed — the tie-break still favours the plain spelling, in_tag is 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_markdown pipeline over 40 082 documents, asserting byte equality of the HTML:

  • 40 000 generated by a deterministic fragment shuffler over 40 fragments chosen to stress this seam — both delimiter forms, escapes, code spans and fences, headings, link destinations, alt text, 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.
  • 29 cases of scripts/mathDelimiterCorpus.json.
  • 8 .md files in the repo.
  • 42 hand-written adversarial documents: no math; exactly one span; a span at the first byte and at the last; adjacent spans with nothing between them; non-ASCII and emoji immediately either side; a span inside a fenced block, an inline code span, a heading, an href, an alt, 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_spans establishes uniqueness by construction: it grows the prefix with X until the source no longer contains it, case-insensitively. So a user who types MPMATHMASK0E gets tokens spelled MPMATHMASKX…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: &#77;PMATHMASK0E reaches restore_math_spans as MPMATHMASK0E and is replaced by the first masked span. &#77;PMATHMASK alone, 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 TokenScan mutated 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! idiom every_convert_markdown_preprocessing_step_is_registered already uses:

  • a_carried_search_reuses_its_answer_instead_of_searching_again hands 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 sticky None, and that the answers agree with a fresh str::find at every cursor position.
  • restore_math_spans_scans_only_through_a_carried_search extracts the function body from include_str!("lib.rs") and asserts it contains TokenScan::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 as masterEXE_NAME unused, one collapsible if, one single-character push_str; zero delta), npm test (562 pass), npm run check (0 errors). cargo fmt --check reports the same 53 pre-existing hunks before and after; the new code adds none.

Not covered

  • mask_math_spans is 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_spans checks each escape against every math span (O(escapes × spans)), and find_display_close checks 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.
  • No behaviour change, so no new corpus case. scripts/mathDelimiterCorpus.json is untouched; the_math_contract_corpus_is_a_live_capture passes unchanged, which is itself a byte-equality check over 29 documents.
  • The line-number contract is not affected. restore_math_spans runs on rendered HTML, after sourcepos numbers exist, and is already listed in not_a_transform for exactly that reason. It emits what it emitted before, so no source line can have moved.
  • Not measured in a release build. All numbers are opt-level = 2; the release profile is opt-level = "s" with LTO, so absolute times will differ. The growth shape will not.
  • The frontend was not profiled. This measures convert_markdown only. Whether the preview is now bounded by KaTeX or by DOM work on a math-heavy document is unknown.

🤖 Generated with Claude Code

`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>
@PathGao

PathGao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

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 convert_markdown, release build, min-of-30:

TOTAL                        6.88 ms
  comrak markdown_to_html    3.885 ms   56.5%
  restore_math_spans         1.498 ms   21.8%   ← what this PR removes
  mask_math_spans            0.711 ms   10.3%
  process_parenthesized_…    0.230 ms    3.4%
  process_internal_embeds    0.230 ms    3.3%
  annotate_task_checkboxes   0.180 ms    2.6%
  process_wikilinks          0.022 ms    0.3%

So the pass really is 21.8 % of the pipeline on a document containing almost no maths — which is exactly the O(tokens × bytes) shape this PR diagnosed: seven maths spans, each scanning all 119 KB. The diagnosis holds. 6.88 ms → ~5.4 ms is the whole prize.

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 {@html} subtree replacement, KaTeX, Mermaid and highlight.js. Whether the Rust side takes 7 ms or 5 ms is not what the editor's responsiveness turns on.

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 TokenScan a haystack the needle does not occur in — all of it is sound work. It just turns out to be optimising something no user can perceive.

Two findings from it worth keeping on the record:

  • code_region_ranges is rebuilt seven times per render, not four — process_wikilinks alone calls it four times. Measured at 22.7 % of the stress document and 37–39 % of smaller ones. Also not worth acting on: 39 % of 1.46 ms is 0.57 ms, and 37.6 % of a README is 0.06 ms.
  • comrak is 56–70 % of the pipeline everywhere. Any future work on render latency has to start there, and there is no obvious lever.

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.

@PathGao PathGao closed this Aug 3, 2026
@PathGao
PathGao deleted the perf/restore-math-spans-linear branch August 3, 2026 09:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant