Skip to content

Fix quadratic parsing of deeply nested markup - #800

Open
josefguenther wants to merge 1 commit into
servo:mainfrom
josefguenther:fix/scope-queries-constant-time
Open

josefguenther wants to merge 1 commit into
servo:mainfrom
josefguenther:fix/scope-queries-constant-time

Conversation

@josefguenther

Copy link
Copy Markdown

Deeply nested markup makes tree building quadratic in the nesting depth, so a small document can keep the parser busy for minutes. This is the parse time reported in #788. A depth cap, which that issue also asks about, would be a separate change, and it would not remove this cost on its own, because the stack of open elements still grows with the input.

Many tree builder steps search the stack of open elements. "Has an element in scope" runs on most start and end tags in body, and some other steps look for an element by name or for the nearest element of some kind: resetting the insertion mode, </option>, end tags in foreign content, the "any other end tag" steps and the adoption agency. Each search walks down from the top of the stack, and when nothing matches it walks all the way to the bottom.

This change keeps the stack in a new OpenElems type in tree_builder/open_elems.rs. Next to the elements, it records the stack positions of the open elements for each name and for each set of elements these steps look for. Push and pop update the positions in constant time. "Is X in scope" then compares the topmost X with the topmost element that bounds the scope, and the other steps read the positions directly.

One parse with a release build, at 64,000 levels of nesting:

Input Before After
<div> nested, then </div> 16 s 40 ms
<ul><li> nested 46 s 40 ms
<div> nested, then </p></p> 88 s 50 ms
<svg> with <g> nested, then </x> 79 s 30 ms

Ordinary documents parse at the same speed. On four large real pages the instruction count went up 1.6% and the cycle count did not change. While parsing, the stack uses about 30 more bytes per open element. Name lookups use a small hasher built on the hash each atom already carries; with the default hasher the same pages were about 7% slower.

Two cases are still quadratic and are not changed here: the adoption agency removing elements from the middle of the stack, and searches of the list of active formatting elements.

Parse results do not change. The html5lib tests pass as before, and the tree and the parse errors match main on every html5lib input and on 45,000 random documents. The new test in tests/tree_builder.rs counts how many elements the tree builder looks at for each token on 19 nested inputs. It sees 1 to 7 with this change and 250 to 1,008 on main.

Many tree builder steps search the stack of open elements. "Has an
element in scope" runs on most start and end tags in body, and a few
other steps look for an element by name or for the nearest element of
some kind. Each search walks down from the top of the stack, and when
nothing matches it walks all the way to the bottom. So on deeply nested
markup every token costs time in proportion to the nesting depth, and
parsing takes quadratic time. A few hundred kilobytes of nested
<ul><li> can keep the parser busy for minutes.

Keep the stack in a new OpenElems type that also records, for each
element name and for each set of elements these steps look for, the
positions in the stack where those elements are open. Pushing and
popping update the positions in constant time. "Is X in scope" then
compares the topmost X with the topmost element that bounds the scope,
and the other steps read the positions directly.

Parse results are unchanged. The new test counts how many elements the
tree builder looks at for each token on nested input, so it fails
quickly if a step goes back to walking the stack.

See servo#788.
@github-actions github-actions Bot added the V-non-breaking A non-breaking change label Oct 14, 2026
jaredlynskey added a commit to jaredlynskey/pyhtml2text that referenced this pull request Oct 18, 2026
Our patch only made the "p in button scope" check O(1). Unmatched end tags
and end tags in foreign content still walked the whole stack of open
elements, so 32,000 nested <span> followed by unmatched </em> took 3.4s and
SVG <g> followed by unmatched </x> took 4.5s. servo/html5ever#800 by Josef
Guenther (67c7e7c, not merged yet) keeps the positions of open elements by
name and by tag set, so every scope check and stack lookup is O(1). Its
src/ changes apply to 0.39.0 with only line offsets.

Checked against stock 0.39.0: html5ever's tests and the 3,515 html5lib
tree-construction tests pass in release and debug builds, and the tree,
quirks mode and exact parse errors are identical on every html5lib input,
300,000 random tag-soup documents and fragments, and 28 real pages.
pyhtml2text's output is unchanged on the benchmark corpus. Costs about
0.4us per call and 0-2% on real pages.

The deep-nesting tests now cover both new shapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PwkfVNzHx1JafFtb559mx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

V-non-breaking A non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant