Speed up the Markdown parser#1755
Open
tompng wants to merge 3 commits into
Open
Conversation
The kpeg-generated runtime addresses the input string by character index, so on non-ASCII input every position lookup scans the string from the start and parsing becomes quadratic in the input size: 300 paragraphs of Japanese text took 2.9s, and doubling a non-ASCII document quadruples its parse time. Generated rule bodies only save and restore `pos` without inspecting it, and every generated regexp is anchored with `\G`, so overriding the position helpers (set_string, scan, match_string, get_byte, get_text) with byte-offset versions backed by StringScanner is enough to switch the whole parser. get_byte (the grammar's ".") consumes one character and returns its codepoint exactly as before -- for non-ASCII lead bytes it scans /./m, which interprets the character in the string's own encoding, so an ASCII-compatible encoding is assumed but not UTF-8 specifically. The only observable change is the position representation itself. Parse time on non-ASCII input becomes linear; the Japanese example above drops to 40ms. The error-reporting helpers (current_line, current_column, ...) still interpret positions as character indices. They are unreachable -- markdown is deliberately designed to parse any input somehow rather than fail, so the root rule cannot fail and nothing invokes raise_error or show_error -- and a note in byte_runtime.rb requires making them byte-aware before any future use. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HtmlBlock is tried at every block position and immediately fans out into one memoized rule application per known HTML tag (~50 rules), so plain paragraphs paid the full alternation cost before falling through to Para. Requiring the next character to be "<" before entering the alternation makes a paragraph-heavy document parse 1.7x faster (1000 one-word paragraphs: 160ms to 94ms). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Markdown#parse always runs up to three full passes over the document: one collecting reference definitions, one collecting note definitions, then the real parse. A note definition must contain "[^", which is rare in real markdown (11 of 594 sampled files), so a cheap String#include? check saves a full pass for typical documents. A similar guard for the References pass is deliberately not added: "[" appears in almost every real markdown file (507 of the same 594 sample; links, code spans, plain brackets), so it would only ever fire for unusual documents. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Collaborator
|
🚀 Preview deployment available at: https://93195844.rdoc-6cd.pages.dev (commit: b9fb012) |
Contributor
There was a problem hiding this comment.
Pull request overview
This PR improves RDoc’s kpeg-generated Markdown parser performance on non‑ASCII input by moving the parser runtime from character-indexed positions (which cause repeated O(n) index conversions) to byte-offset positions, and by reducing two always-paid parsing costs in common documents.
Changes:
- Add a
ByteRuntimeoverride (backed byStringScanner) to makeposbyte-based while preserving character-wise consumption for.(get_byte). - Optimize HTML block parsing by adding a
&"<"lookahead to avoid attempting many HTML-tag rules when the input doesn’t start with<. - Skip the Notes collection pass when the document does not contain the
[^marker.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
lib/rdoc/markdown/byte_runtime.rb |
Introduces a byte-offset runtime override for the kpeg parser helpers (string access, scanning, and substring extraction). |
lib/rdoc/markdown.rb |
Wires in the byte runtime (via prepend), adds the Notes-pass guard, and includes the HtmlBlock lookahead in the generated parser output. |
lib/rdoc/markdown.kpeg |
Updates the grammar source to require/prepend the byte runtime, add the Notes-pass guard, and add the HtmlBlock lookahead so regeneration stays consistent. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+74
to
+77
| def get_text(start) | ||
| @string.byteslice(start, @pos - start) | ||
| end | ||
| end |
Contributor
|
Is there a source code for these benchmarks? Curious to see. |
Member
Author
|
Benchmark script require 'rdoc'
CASES = {
'300 Japanese paragraphs' =>
"これはテストです。日本語の**強調**と`コード`を含む段落。\n\n" * 300,
'1,000 one-word ASCII paragraphs' =>
"word\n\n" * 1000,
}
CASES.each do |name, code|
RDoc::Markdown.parse(code) # warmup
times = 5.times.map do
GC.start
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
RDoc::Markdown.parse(code)
Process.clock_gettime(Process::CLOCK_MONOTONIC) - t
end
puts format('%-34s %8.1f ms (min of 5)', name, times.min * 1000)
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The kpeg-generated Markdown parser addresses the input string by character index, so on non-ASCII input every position lookup scans the string from the start and parsing becomes quadratic in the input size — real changelogs (contributor names etc.) always contain some non-ASCII, so this is the default path for large
.mdfiles. There were also two smaller costs paid by every document.Three changes, one per commit:
poswithout inspecting it, and all generated regexps are\G-anchored, so overriding the five position helpers (set_string,scan,match_string,get_byte,get_text) with byte-offset versions backed by StringScanner is enough.get_byte(the grammar's.) still consumes one character and returns its codepoint, so the only observable change is the position representation. The override lives in a hand-written file (lib/rdoc/markdown/byte_runtime.rb) prepended from the grammar, so regeneration is unaffected.HtmlBlockwith a&"<"lookahead. Previously every block position tried ~50 memoized per-tag HTML rules before falling through toPara."[^".parsealways ran up to three full passes; a note definition must contain[^, which is rare (11 of 594 sampled markdown files). The References pass is left unconditional:[appears in almost every real document.Benchmarks (M-series, Ruby 4.0):
bundle exec rdoc bundler/CHANGELOG.md(5,526 lines)