Skip to content

Improve parse performance, and fix quadratic runtime on deeply nested elements - #88

Open
alexleighton wants to merge 7 commits into
aantron:masterfrom
alexleighton:optimize-parsing
Open

Improve parse performance, and fix quadratic runtime on deeply nested elements#88
alexleighton wants to merge 7 commits into
aantron:masterfrom
alexleighton:optimize-parsing

Conversation

@alexleighton

@alexleighton alexleighton commented Jul 14, 2026

Copy link
Copy Markdown

Performance work on the parse pipeline: the UTF-8 decoder, the input source, and HTML tree construction. And one algorithmic fix. The existingmake performance-test harness 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

  • Add stress documents and in-memory median timing to the perf test. Four
    synthetic HTML inputs (multi-byte UTF-8, adoption agency, character
    references, deep nesting), generated in code; measure now warms up, reports
    the median, and times an in-memory parse (I/O excluded), equally for all three
    libraries compared.
  • Decoder: reuse a single byte buffer instead of allocating per byte; then
    decode in 4096-byte chunks so Uutf isn't fed one byte per call.
  • Source: track the read position with a mutable index in the string/buffer
    sources, dropping a per-byte tuple allocation.
  • is_valid_html_char: short-circuit on printable ASCII.
  • reconstruct_active_formatting_elements: return early when there is
    nothing to reconstruct (avoids a per-character ref write in body text).
  • Quadratic fix: track the count of open <p> elements so
    close_current_p_element can skip a full open-elements scan. Opening N nested
    block elements was O(N²) (each open scans the growing stack for a p to
    close); 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 master and this branch. Run on my Macbook Air, using OCaml 4.13.1.

case before (µs) after (µs) delta
google 1954 1550 −20.7%
xml_spec 12773 9842 −22.9%
stress_cjk 16769 10270 −38.8%
stress_formatting 19294 15676 −18.8%
stress_entities 13038 9584 −26.5%
stress_deep_nesting 31155 850 −97.3%

Verification

  • Full test suite passes (make test, 354 tests) on OCaml 4.13.1.
  • Parser output is byte-identical before/after on the sample and stress inputs.
  • No test changes needed, purely a refactor within the bounds of the existing suite.

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

1 participant