Skip to content

Speed up the Markdown parser#1755

Open
tompng wants to merge 3 commits into
ruby:masterfrom
tompng:markdown-kpeg-perf
Open

Speed up the Markdown parser#1755
tompng wants to merge 3 commits into
ruby:masterfrom
tompng:markdown-kpeg-perf

Conversation

@tompng

@tompng tompng commented Jul 23, 2026

Copy link
Copy Markdown
Member

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 .md files. There were also two smaller costs paid by every document.

Three changes, one per commit:

  • Address positions by byte offset. Generated rule bodies only save and restore pos without 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.
  • Guard HtmlBlock with a &"<" lookahead. Previously every block position tried ~50 memoized per-tag HTML rules before falling through to Para.
  • Skip the Notes pass when the document contains no "[^". parse always 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):

input before after
bundle exec rdoc bundler/CHANGELOG.md (5,526 lines) 14.5s 1.5s
300 Japanese paragraphs 2,911ms 40ms
1,000 one-word ASCII paragraphs 160ms 77ms

tompng and others added 3 commits July 24, 2026 03:51
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>
Copilot AI review requested due to automatic review settings July 23, 2026 19:08
@tompng
tompng deployed to fork-preview-protection July 23, 2026 19:08 — with GitHub Actions Active
@matzbot

matzbot commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

🚀 Preview deployment available at: https://93195844.rdoc-6cd.pages.dev (commit: b9fb012)

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

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 ByteRuntime override (backed by StringScanner) to make pos byte-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
@skatkov

skatkov commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Is there a source code for these benchmarks? Curious to see.

@tompng

tompng commented Jul 24, 2026

Copy link
Copy Markdown
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

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.

4 participants