Skip to content

Inline parse cost linear in spans per region, not quadratic (#109) - #140

Open
wildthink wants to merge 1 commit into
nodes-app:mainfrom
wildthink:perf/inline-span-containment
Open

Inline parse cost linear in spans per region, not quadratic (#109)#140
wildthink wants to merge 1 commit into
nodes-app:mainfrom
wildthink:perf/inline-span-containment

Conversation

@wildthink

Copy link
Copy Markdown

Fixes #109, kept out of the directives PRs as you asked. Independent of #120 — it's the pass underneath, and touches different functions in InlineParser.

The two scans

Both come from the same place, which is why one invariant removes both.

Claimed-range membership was a full array scan. scanEscapes and collectDelimiterRuns asked it once per character, scanLinkFamily once per candidate. So the cost of every pass after the first scaled with how much the earlier passes had claimed — worst for code spans, which claim first and are consulted by all three.

buildTree decided containment pairwise. isChild looped over every span in the region, called once per span via inRegion.filter { !isChild($0) }, plus a second inRegion.filter per emphasis to gather its children.

Both are avoidable for the same reason: the passes walk the string left to right and never look back, and claimed ranges are non-overlapping by construction. So a cursor over the sorted ranges answers membership in amortised constant time — the answer for index i only ever involves the first range ending after i. Containment falls out of the same invariant: sorting by start ascending / length descending puts every span immediately after the one that contains it, so buildTree becomes a single ordered walk with the cursor threaded through the recursion.

That makes the non-overlap invariant load-bearing for cost, not just for correctness, so I noted it in the file header — a pass that claimed a partially overlapping span would now break the walk, not just the tree.

ClaimedIndex sorts in its own initialiser rather than documenting an ordering precondition, so no call site can get it wrong. Three sorts per parse, and they don't show up.

Numbers

ms per DocumentAST.parse of one paragraph with n spans (M-series, debug):

n code before code after links before links after emphasis before emphasis after
40 1.23 0.11 0.59 0.27 0.78 0.17
80 4.01 0.20 1.54 0.59 2.30 0.34
120 8.51 0.29 2.96 0.78 4.26 0.51
240 32.92 0.58 9.31 1.56 16.07 1.02

6x the spans cost ~30x the parse before and ~6x now. At n=240 code spans that's 57x less work — the case where three passes were each rescanning 240 claimed ranges per character.

Ordinary documents won't notice; nothing here changes the constant at low density. What it buys is that a paragraph with a few hundred inline spans stops blowing the frame budget on its own.

That it changes nothing

The risk in this change is behavioural, not performance, so that's what I tested hardest.

InlineSpanDensityTests.corpusFingerprint folds the parsed tree of 4000 pseudo-random inputs into a single value. The corpus is built from bare and paired delimiters, escapes, and the openers of every claimed-span construct, so it's dense in half-formed, overlapping and nested spans rather than in valid markdown — the shapes I wouldn't have thought to write by hand. Deterministic LCG so both sides see identical input, hand-rolled FNV because Hasher is per-process seeded.

The baseline b4b562f2c6be080b is recorded on the pre-rewrite parser at eaed9dd — same idea as your GoldenCorpusTests. It passes on both parsers, which is the point; it's there to fail if the walk ever diverges.

The five scaling assertions are the regression detectors, and they fail on the old parser — 14.6x (links), 15.8x (highlight), 20.1x (emphasis), 25.3x (mixed), 31.0x (code) against a 12x bound. Bound is 2x linear, measured is ~5.3-6x, and it's a minimum of 7 runs rather than a mean, since scheduler noise only ever adds time.

306 tests green, demo builds.

Two deletions worth flagging

Span.containerContent and equalRange are both gone — the ordered walk derives the emphasis content range inline and consumes the span itself before recursing, so neither had a caller left.

equalRange was guarding a case that can't arise: two spans with identical ranges. Under the old code both would be excluded from top and dropped; under the new one the second is skipped as nested in the first. Different handling of an impossible input, and I'd rather say so than have you find it.

The new walk also skips anything nested inside a non-container span. Every claimed span but emphasis is opaque today so nothing ever is, but the old code would have emitted such a span after its parent with the cursor already past it — the skip keeps the walk well-formed instead.

Every pass after the first consulted the claimed ranges by scanning the
whole array: once per character in scanEscapes and collectDelimiterRuns,
once per candidate in scanLinkFamily. buildTree then decided containment
by testing each span against every other one. All fine at ordinary
densities, ~n^2 when a single paragraph carries hundreds of spans.

Both scans are avoidable for the same reason. The passes walk the string
left to right and never look back, and claimed ranges are non-overlapping
by construction, so a cursor over the sorted ranges answers "is this
claimed?" in amortised constant time. Containment falls out of the same
invariant: sorting spans by start ascending and length descending puts
every span immediately after the one containing it, so buildTree becomes
a single ordered walk.

A paragraph of 240 code spans parses in 0.5ms rather than 33ms. 6x the
spans now costs ~6x the parse instead of ~30x. Affects every claimed-span
construct — code, escapes, links, images, wiki links, inline LaTeX,
emphasis, and extension spans.

No parse result changes. InlineSpanDensityTests folds the parsed tree of
a 4000-input pseudo-random corpus into one fingerprint, recorded on the
pre-rewrite parser; the scaling assertions fail on that parser at 15x-31x
against a 12x bound.

Closes nodes-app#109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

Inline parse cost is ~quadratic in spans per region (affects all claimed-span constructs)

2 participants