Skip to content

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

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

👌 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

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.
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