Improve parse performance, and fix quadratic runtime on deeply nested elements - #88
Open
alexleighton wants to merge 7 commits into
Open
Improve parse performance, and fix quadratic runtime on deeply nested elements#88alexleighton wants to merge 7 commits into
alexleighton wants to merge 7 commits into
Conversation
Extend the performance comparison with four synthetic HTML documents, each exercising one part of the parser: multi-byte UTF-8 decoding, the adoption agency algorithm, character references, and deep element nesting. Also make the shared measure function warm up and report the median of its runs, and parse from an in-memory string so file I/O is excluded. This applies equally to all three libraries under comparison.
The decoder fed Uutf one byte per pull, allocating a fresh one-byte Bytes for every byte of input. Uutf's manual-source protocol consumes each supplied byte before returning `Await (copying cross-buffer sequences into its own scratch buffer via t_fill), so a single one-byte buffer can be reused for the whole stream. This is safe by both the documented contract and the implementation, and produces byte-identical output on multi-byte-heavy input.
Feeding Uutf one byte per pull means a Manual.src call and an `Await round-trip for every byte, and every multi-byte sequence is forced down Uutf's slow cross-buffer path. Buffer up to 4096 bytes and let Uutf decode the whole chunk before refilling. ASCII runs then decode in a tight loop with no `Await between bytes, and multi-byte characters take Uutf's fast in-buffer path. The chunk buffer is reused; Uutf consumes each chunk fully (copying any boundary-straddling sequence into its own scratch) before returning `Await, so overwriting it is safe. Output is byte-identical on multi-byte input, including a character straddling the buffer boundary.
The string and buffer byte sources were built on a state_fold helper that threaded the read position through the stream by allocating a (char, position) tuple on every pull -- one allocation per input byte, on the hot path in the pipeline. Position is calculated with a plain mutable ref instead, avoiding the per-byte allocation.
Preprocessing calls is_valid_html_char on every code point. The check is two function calls (is_control_character, is_non_character) with several range comparisons. Printable ASCII (0x20-0x7E) is the vast majority of input and is always valid, so check that range first and short-circuit.
The HTML tree construction reconstructs the active formatting elements before each character insertion in body mode -- i.e. once per text character. In the common case there is nothing to reconstruct: the list is empty, or its most recent entry is a marker or an already-open element. The code still walked the list, allocated a (to_reopen, remainder) tuple, and wrote the active_formatting_elements ref back on every character. That ref write is the real cost, on every character. Return early instead. The gain scales with how much text sits inside formatting elements.
Parsing deeply nested block elements was quadratic in nesting depth. Every block-level start tag (div, section, ul, h1..h6, and ~25 others) runs "close a p element if one is in button scope", and the button-scope check scans the stack of open elements until it finds a p or a scope boundary. div (and most block elements) are not button-scope boundaries, so with N nested divs each open scans the whole growing stack -- O(N^2) overall. A 176 KB document of ~16000 nested divs took 4.6 s to parse; a small input is enough to make this a denial-of-service vector for a parser handling untrusted HTML. in_button_scope "p" can only be true when at least one p element is open, so maintain a count of open p elements and skip the scan when it is zero (the case for all block nesting that contains no open paragraph). The count is exact: a p enters the stack only via push_and_emit and leaves only through pop; the adoption agency can also remove p elements, so the count is recomputed after it runs (rare, and already O(stack)).
alexleighton
force-pushed
the
optimize-parsing
branch
from
July 14, 2026 05:50
93adfe2 to
d6ab278
Compare
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.
Performance work on the parse pipeline: the UTF-8 decoder, the input source, and HTML tree construction. And one algorithmic fix. The existing
make performance-testharness is extended to cover some stress test cases, and to report a more stable metric.There are a number of changes stuffed into this PR, but the commits are self-contained, I recommend reading each commit on its own.
Changes
synthetic HTML inputs (multi-byte UTF-8, adoption agency, character
references, deep nesting), generated in code;
measurenow warms up, reportsthe median, and times an in-memory parse (I/O excluded), equally for all three
libraries compared.
decode in 4096-byte chunks so Uutf isn't fed one byte per call.
sources, dropping a per-byte tuple allocation.
is_valid_html_char: short-circuit on printable ASCII.reconstruct_active_formatting_elements: return early when there isnothing to reconstruct (avoids a per-character ref write in body text).
<p>elements soclose_current_p_elementcan skip a full open-elements scan. Opening N nestedblock elements was O(N²) (each open scans the growing stack for a
ptoclose); a ~176 KB doc of deeply nested
<div>s took ~4.6 s. Now it's linear.Results
markup.ml median parse time, measuring the difference between
masterand this branch. Run on my Macbook Air, using OCaml 4.13.1.Verification
make test, 354 tests) on OCaml 4.13.1.