Skip to content

perf: speed up _unescape_special_chars for large documents - #748

Open
SwastikTripathi wants to merge 3 commits into
trentm:masterfrom
SwastikTripathi:perf/unescape-special-chars
Open

SwastikTripathi wants to merge 3 commits into
trentm:masterfrom
SwastikTripathi:perf/unescape-special-chars

Conversation

@SwastikTripathi

@SwastikTripathi SwastikTripathi commented Oct 25, 2026 •

Copy link
Copy Markdown

Fixes #635.

Problem

_unescape_special_chars has two hidden O(n) costs that compound on large markdown documents:

  1. Full-string comparison for loop termination. Each pass through the while True loop stores orig_text = text and then compares text == orig_text to detect whether anything changed. On a large document this is an O(n) string comparison even when the very first regex scan found nothing to replace.

  2. Unnecessary intermediate allocations on every call. The replacements dict was built from three tuple(...) calls, converting _escape_table.items(), _code_table.items(), and a generator expression over html_blocks.items(), before iterating them into the dict. That's three extra list/tuple objects per call.

Fix

  • Early exit: if 'md5-' is not present anywhere in text, return immediately. This is the common case for plain paragraphs that contain no inline code, links, backslash escapes, or raw HTML, skipping both the dict build and the regex engine entirely.
  • Direct dict construction: iterate each source dict once, without allocating intermediate tuples.
  • Flag-based termination: a replaced flag (a one-element list so the inner function can mutate it) is set inside the re.sub callback whenever a match is found in the lookup table. The loop breaks when a full pass sets no flag, no full-string comparison needed.

All existing tests pass (test/test.py, test/api.doctests).

🤖 Generated with Claude Code

Two O(n) costs were paid per pass through the while loop:

1. The termination check compared the full text string (`text ==
   orig_text`), which is O(n) even when nothing changed.
2. The replacements dict was built from three intermediate tuples before
   every call (escape_table + code_table + reversed html_blocks).

Fix both:
- Skip immediately if 'md5-' is not present in the text (the common
  case for plain paragraphs with no inline code, links, or escapes).
- Build the replacements dict by iterating the source dicts directly,
  without allocating intermediate tuples.
- Replace the string comparison with a boolean flag set inside the
  re.sub callback; the loop breaks as soon as a full pass produces no
  replacements.

Fixes trentm#635

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@Crozzers Crozzers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do you have any perf data that demonstrates an improvement? Any tests or profiling you've done, maybe a before and after?

Comment thread lib/markdown2.py Outdated
for ch, hash in hashmap:
if 'md5-' not in text:
return text
# Build hash→original mapping directly without intermediate tuple.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nitpick but I would remove the → char in the comment in favour of plain text like ->.
I don't think it will cause any issues (python source is default utf-8) but I just think it can't be typed from a keyboard so should it be in a comment?

It also displays oddly in VSCode, really small and almost looks like an underline:
Image
As opposed to this which is bigger and more legible:
Image

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 4bf3ddb, it's plain -> now.

Comment thread lib/markdown2.py Outdated
text = re.sub(r'md5-[0-9a-f]{32}',
lambda match: replacements.get(match[0], match[0]), text)
if text == orig_text:
replaced = [False]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Interesting but odd pattern. Would nonlocal work here? Bit cleaner?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, switched to nonlocal in 4bf3ddb.

Comment thread lib/markdown2.py Outdated
lambda match: replacements.get(match[0], match[0]), text)
if text == orig_text:
replaced = [False]
def _sub(match: re.Match, _rep: dict = replacements) -> str:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Does _rep need defining as a kwarg with a default value? Can the function not just use replacements directly?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also _sub is being re-defined every loop?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

No, it doesn't. It uses replacements directly now (4bf3ddb).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. It's defined once before the loop now, and only the flag is reset each pass (4bf3ddb).

Use a nonlocal flag instead of a one-element list, define the re.sub
callback once outside the loop, use replacements directly instead of a
default kwarg, and replace the arrow character with plain ASCII.
Add a CHANGES entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@SwastikTripathi

Copy link
Copy Markdown
Author

Thanks for the review. I hadn't measured it before, so I did now.

Per-call cost, after converting a doc with 2000 code spans and 2000 HTML blocks (so _code_table and html_blocks have 2000 entries each), calling _unescape_special_chars on a short string like a header or link text:

input before after
short text, no hash 346 us/call 0.1 us/call
short text, one hash 344 us/call 159 us/call

Most of the old cost was rebuilding the replacements dict on every call, which grows with the size of the tables, so on big documents the per-header and per-link calls get expensive. The early return skips that entirely when there's nothing to replace.

End to end the gain is smaller than that suggests. On a synthetic 420 KB doc (2000 sections, each with a header, inline code, a link, an escape and inline HTML, extras toc, header-ids, fenced-code-blocks, tables) cProfile shows the function's cumulative time going from 0.101s to 0.026s over 6 conversions, but total wall time is about the same (1.04s vs 1.02s median), because the function is only a few percent of runtime on that doc. So this mainly helps documents with many hashed blocks and many headers or links, which is the shape in #635. I don't have the reporter's file to confirm their case.

Output is byte-identical between before and after on the benchmark docs (compared by sha1 of html + toc_html), and test/test.py gives the same result as master (300 tests, only the 14 knownfailure ones fail).

The inline nits are done in 4bf3ddb: nonlocal flag, _sub defined once outside the loop and using replacements directly, ASCII -> in the comment. I also added a CHANGES entry.

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.

Bad performance in _unescape_special_chars

2 participants