Skip to content

👌 Fix quadratic inline tokenization on runs of special characters - #411

Closed
hdimer wants to merge 1 commit into
executablebooks:masterfrom
hdimer:fix/quadratic-inline-special-chars
Closed

👌 Fix quadratic inline tokenization on runs of special characters#411
hdimer wants to merge 1 commit into
executablebooks:masterfrom
hdimer:fix/quadratic-inline-special-chars

Conversation

@hdimer

@hdimer hdimer commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Long runs of characters that begin an inline rule but do not form a construct — bare & / <, incomplete entities like &am&am…, etc. — were tokenised in O(n²) time. A single such character is fine; it is the count that blows up. For example, md.render("&" * 128_000) took over a second and grew quadratically.

This is the same class as the previously fixed #367 and #389.

Root causes

Two independent quadratic factors, both hit once per character in these runs:

  1. state.pending accumulation. The inline tokenizer's fallback path does state.pending += state.src[state.pos] one character at a time. Because pending is an attribute, str += ch cannot use CPython's in-place concatenation optimisation (the attribute keeps a second reference), so each append rebuilds the whole string — O(len) per character.
  2. src[pos:] slicing in entity / html_inline. These rules matched ^-anchored regexes against state.src[pos:], copying the remaining source on every & / <.

In JavaScript markdown-it neither is quadratic (rope-backed string concat, and the equivalent regexes are applied with a sticky/lastIndex match), so this is Python-port-specific.

Fix

  • StateInline.pending now accumulates through a lazily-materialised list buffer (append_pending), giving amortised O(1) appends. It still presents as a plain str to every reader (the getter joins the buffer on demand and caches).
  • entity and html_inline anchor their regexes with .match(state.src, pos) instead of slicing (the leading ^ is dropped, since .match already anchors at pos). HTML_TAG_RE is only used by html_inline, so this is contained.

Rendering is now linear in all these cases (measured slope ≈ 1.0, including the html=True html_inline path); "&" * 500_000 drops from seconds to well under one. Output is unchanged — the full test suite passes.

Tests

Added test_long_special_char_runs_stay_linear in tests/test_api/test_main.py: it asserts correct rendering for the affected constructs and renders a large input whose previous O(n²) behaviour would exceed the global 10s test timeout. Full pytest tests/ passes (982). ruff check / ruff format (0.15.12) and mypy (1.20.2, strict) are clean.


Disclosure: this fix was developed with AI assistance; I reviewed it and stand behind it.

Long runs of characters that begin an inline rule but do not form a
construct (bare `&`/`<`, incomplete entities, ...) were tokenised in
O(n^2) time, from two causes:

- the inline tokenizer's fallback appended to `state.pending` one
  character at a time; `str += ch` on an attribute cannot reuse the
  buffer in place, so each append is O(len).
- the `entity` and `html_inline` rules matched their `^`-anchored
  regexes against `src[pos:]`, slicing the remaining source on every
  character.

Accumulate `pending` through a list buffer materialised lazily, and
anchor the entity/html regexes with `.match(src, pos)` instead of
slicing. Rendering is now linear (e.g. `'&' * 500_000` drops from
seconds to well under one); output is unchanged and the full suite passes.

@chrisjsewell chrisjsewell left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks!

@chrisjsewell chrisjsewell reopened this Sep 8, 2026
chrisjsewell pushed a commit that referenced this pull request Sep 8, 2026
…cters

Long runs of characters that begin an inline rule but never complete a
construct -- bare `&`, `<`, `~`, `{`, incomplete entities -- were tokenised
in O(n^2) time, so a few hundred KB of such input stalls `render()` for
many seconds. Output was always correct; the cost is pure CPU.

Two independent quadratic factors, each hit once per character in a run:

1. `state.pending` accumulation. The inline tokenizer's fallback path did
   `state.pending += state.src[state.pos]` one character at a time. Because
   `pending` is an *attribute*, `str += ch` cannot use CPython's in-place
   concatenation optimisation (the attribute holds a second reference), so
   every append copies the whole accumulated string.

2. `src[pos:]` slicing in `entity` / `html_inline`. Both matched
   `^`-anchored regexes against `state.src[pos:]`, copying the remainder of
   the source on every `&` / `<`. On Python 3.10 this carried a second
   quadratic factor inside the regex engine itself: before 3.11, `search()`
   on a `^`-anchored pattern retries at every offset.

Neither is quadratic in the JavaScript markdown-it (rope-backed string
concatenation, and sticky/lastIndex regex matching), so this is specific to
the Python port.

Fix:

- `StateInline.pending` now accumulates through a lazily-materialised list
  buffer via `append_pending()`, giving amortised O(1) appends. It is still
  exposed as a plain `str` property, so existing readers and third-party
  plugins doing `state.pending += x` keep working unchanged.
- `entity` and `html_inline` anchor with `.match(state.src, pos)` instead of
  slicing; the leading `^` is dropped since `.match` already anchors at
  `pos`. Neither pattern uses `\b` or lookbehind, so this is exactly
  equivalent. `HTML_TAG_RE` has a single consumer; the separate
  `HTML_OPEN_CLOSE_TAG_RE` is untouched.

All affected inputs now grow ~2.0x per doubling (linear) out to 640k
characters. `"&" * 400_000` drops from 6.8s to 1.3s on CPython 3.11, from
653s to 1.8s on CPython 3.10, and from 145s to 0.2s on PyPy. Runs of `[`
were also reported as superlinear, but once the `pending` quadratic is
removed they measure a flat 2.0x per doubling: that path is linear already
(bounded by the existing `skipToken` cache), just with a large constant.

Output is unchanged: a 47,936-case differential run over the repo fixtures,
the CommonMark spec, targeted constructs and fuzzed inputs across seven
presets compares rendered HTML, `renderInline`, and full token streams
byte-for-byte against the previous behaviour with zero differences. The
test suites of mdit-py-plugins, myst-parser, mdformat and rich are
unaffected.

Adds `test_long_special_char_runs_are_linear`, which asserts correct output
for the affected constructs and renders inputs that took ~16s before this
change, so a regression trips the global 10s test timeout.

Same class as the previously fixed #367 and #389. The root causes and this
fix were independently identified by Haim Dimer in #411, which this
supersedes.

Co-authored-by: Haim Dimer <haim@dimer.org>
chrisjsewell added a commit that referenced this pull request Sep 8, 2026
## Summary

Long runs of characters that begin an inline rule but never complete a
construct (bare `&`, `<`, `~`, `{`, incomplete entities, …) were
tokenised in **O(n²)** time, so a few hundred KB of such input stalls
`render()` for many seconds. Output was always correct; the cost is pure
CPU. This is the same class as #367 and #389.

Supersedes #411. The root causes and the core fix were independently
identified by @hdimer there, and the first commit here is code-identical
to that PR (credited as co-author, thank you!). The second commit closes
a gap found while auditing it that made the fix ineffective for a common
plugin configuration, and adds a few hardening changes.

## Root causes

Two independent quadratic factors, each hit once per character in a run:

1. **`state.pending` accumulation.** The inline tokenizer's fallback
path did `state.pending += state.src[state.pos]` one character at a
time. Because `pending` is an *attribute*, `str += ch` can't use
CPython's in-place concatenation optimisation (the attribute holds a
second reference), so every append copies the whole accumulated string.
2. **`src[pos:]` slicing in `entity` / `html_inline`.** Both matched
`^`-anchored regexes against `state.src[pos:]`, copying the remainder of
the source on every `&` / `<`. On Python 3.10 this carried a *second*
quadratic factor inside the regex engine: before 3.11, `search()` on a
`^`-anchored pattern retries at every offset.

Neither is quadratic in the JavaScript markdown-it (rope-backed concat,
sticky regex matching), so this is Python-port-specific.

## Fix

**Commit 1** (as in #411):
- `StateInline.pending` accumulates through a lazily-materialised list
buffer via a new `append_pending()` method, giving amortised O(1)
appends. It's still exposed as a plain `str` property, so existing
readers and plugins doing `state.pending += x` keep working.
- `entity` and `html_inline` anchor with `.match(state.src, pos)`
instead of slicing; the leading `^` is dropped since `.match` anchors at
`pos`. Neither pattern uses `\b` or lookbehind, so this is exactly
equivalent.

**Commit 2** (new):
- The buffered getter materialised with `self._pending += joined`,
itself an attribute concat. Any rule that reads `state.pending` before
its cheap character check therefore re-introduced the quadratic on every
character. The mdit-py-plugins `attrs` rule does exactly that, and
myst-parser's `attrs_inline` extension enables it: with it, `"~a~" *
400_000` still took **34 s** (5.7× per doubling) after commit 1. The
getter now moves the string into a local and drops the instance's
reference before concatenating, so CPython resizes it in place: **3.0 s,
2.0× per doubling**, verified on CPython 3.10–3.13.
- The setter no longer depends on `__init__` having run (a subclass
assigning `pending` before `super().__init__()` raised
`AttributeError`).
- `copy.copy(state)` no longer shares the mutable buffer with the
original.

## Results

`"&" * 400_000`, before → after:

| interpreter | before | after |
|---|---|---|
| CPython 3.10 | 653 s | 1.8 s |
| CPython 3.11 | 6.8 s | 1.3 s |
| CPython 3.13 | 6.6 s | 1.1 s |
| PyPy 7.3 | 145 s | 0.2 s |

All affected inputs now grow ~2.0× per doubling out to 640k characters.
Runs of `[` were also reported as superlinear, but once the `pending`
quadratic is removed they measure a flat 2.0× per doubling: that path
was already linear (bounded by the `skipToken` cache), just with a large
constant.

**Trade-off:** the property indirection costs ~2% on ordinary Markdown
(up to ~4.7% on inline-dense files) — measured over 55 inputs × 3
presets, 8 interleaved rounds. The regex half is a pure win at every
size. Memory is unchanged on realistic input. If wanted, most of the 2%
can be recovered by having `push()`/`pushPending()` read the private
fields directly; happy to do that as a follow-up once benchmarked
properly.

## Verification

- **Output unchanged:** 47,936-case differential (repo fixtures,
CommonMark spec, targeted constructs, 6k fuzzed inputs × 7 presets)
comparing HTML, `renderInline` and full token streams byte-for-byte: 0
differences.
- **Downstream:** mdit-py-plugins (511), myst-parser (1245), mdformat
(4282), rich and mdformat-gfm test suites give identical results against
this branch and `master`; a 61-document differential through a full
plugin stack shows 0 differing renders.
- Full suite passes on CPython 3.10, 3.11, 3.12, 3.13 and PyPy. Pinned
`ruff` and strict `mypy` clean.
- New tests: `test_long_special_char_runs_are_linear`,
`test_state_inline_pending_buffer_semantics`,
`test_pending_reader_rule_stays_linear`. Each linearity test exceeds the
global 10 s timeout on the pre-fix code.

## Notes for reviewers

- `HTML_TAG_RE`, `DIGITAL_RE` and `NAMED_RE` lose their `^` anchor
(they're now applied with `.match(src, pos)`). No external consumer was
found in any scanned package or via code search, but any third-party
`.search()` on them would now be unanchored. Probably worth a changelog
line. `HTML_OPEN_CLOSE_TAG_RE` is untouched.
- mdformat-gfm ships its own replacement `text` rule that still does
`state.pending +=`; it keeps its quadratic and gains nothing from this
fix.
- **Not covered here, follow-up PR:** with `html=True` (the `commonmark`
and `gfm-like` presets), runs of `<![CDATA[`, `<!--`, `<?` and `<!a` in
inline context are still quadratic because the regex sub-patterns rescan
to end-of-input on every attempt (`<![CDATA[` × 40k takes ~245 s). This
is inherited from upstream (it's quadratic in markdown-it JS too),
pre-existing, and a different code path, so it's kept separate. A
validated fix (terminator quick-reject, zero output change) is ready.
- The changelog is left for the release, following prior PRs.

---------

Co-authored-by: Haim Dimer <haim@dimer.org>

chrisjsewell commented Sep 8, 2026

Copy link
Copy Markdown
Member

Thanks @hdimer, and apologies this sat for a while. You identified both root causes and the right fix here, and #423 (now merged) carries your change verbatim as its first commit, with you credited as co-author.

While auditing it before merging we found one gap that #423 adds on top: the buffered pending getter materialised with self._pending += joined, which is itself an attribute concatenation, so any inline rule that reads state.pending on every character (the mdit-py-plugins attrs rule does, and myst-parser enables it) re-introduced the quadratic. The getter now moves the string into a local before concatenating so CPython can resize it in place. #423 also hardens the setter and copy.copy, and replaces the timeout-based tests with deterministic ones, since CI runs everything under coverage.

Closing this in favour of #423. It will be in the next release. Much appreciated!

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.

2 participants