Skip to content

perf: speed up _unescape_special_chars for large documents - #748

Merged
nicholasserra merged 6 commits into
trentm:masterfrom
SwastikTripathi:perf/unescape-special-chars
Nov 9, 2026
Merged

nicholasserra merged 6 commits into
trentm:masterfrom
SwastikTripathi:perf/unescape-special-chars

Conversation

@SwastikTripathi

@SwastikTripathi SwastikTripathi commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

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

@nicholasserra

Copy link
Copy Markdown
Collaborator

@Crozzers ill let you have final say

Comment thread lib/markdown2.py Outdated
for hash, item in self.html_blocks.items():
replacements.setdefault(hash, item)

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.

replaced defined twice? Gets defined in the loop before sub is called so this is unneeded?

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.

Or, this could be done:

replaced=True
while replaced:
    replaced=False
    text = re.sub(...)
return text

Removes need for if/break branch

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in be9bb4e, it uses while replaced: now, which also drops the extra replaced = False before _sub. I also merged master to clear the CHANGES.md conflict with #762. I could not run the suite locally this time, so CI is the check on this push.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, it was only there so the name existed before _sub closed over it. Gone in be9bb4e.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Follow-up: I got the suite running locally after all. On be9bb4e, python test.py runs 316 tests with the same 14 known failures as master and nothing new.

Comment thread lib/markdown2.py
replacements = {}
for ch, hash in hashmap:
replacements: dict[str, str] = {}
for ch, hash in self._escape_table.items():

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.

Would it be perhaps faster and simpler if instead of building a replacements dict, you just did this in the _sub function:

val = (
    self._escape_table.get(match[0])
    or self._code_table.get(match[0])
    or self.html_blocks.get(match[0])
)

Maybe more memory efficient?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That works for html_blocks, which is keyed by hash, but _escape_table and _code_table go the other way ({original: hash}, e.g. self._code_table[text] = hashed), so .get(match[0]) on them would always miss. To skip the dict build we would need reverse tables kept in sync at every place those two are written (the escape table is also written per URL in _protect_url). The early md5- check already skips the build for most calls, so I kept the dict here. Happy to add the reverse tables if you prefer that.

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.

Ah yes, forgot about that. In that case building the map is probably the best way

@Crozzers

Crozzers commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

LGTM

@nicholasserra
nicholasserra merged commit 7975553 into trentm:master Nov 9, 2026
15 checks passed
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

3 participants