Inline parse cost linear in spans per region, not quadratic (#109) - #140
Open
wildthink wants to merge 1 commit into
Open
Inline parse cost linear in spans per region, not quadratic (#109)#140wildthink wants to merge 1 commit into
wildthink wants to merge 1 commit into
Conversation
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>
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.
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.
scanEscapesandcollectDelimiterRunsasked it once per character,scanLinkFamilyonce 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.buildTreedecided containment pairwise.isChildlooped over every span in the region, called once per span viainRegion.filter { !isChild($0) }, plus a secondinRegion.filterper 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
ionly ever involves the first range ending afteri. Containment falls out of the same invariant: sorting by start ascending / length descending puts every span immediately after the one that contains it, sobuildTreebecomes 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.
ClaimedIndexsorts 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.parseof one paragraph with n spans (M-series, debug):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.corpusFingerprintfolds 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 becauseHasheris per-process seeded.The baseline
b4b562f2c6be080bis recorded on the pre-rewrite parser at eaed9dd — same idea as yourGoldenCorpusTests. 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.containerContentandequalRangeare both gone — the ordered walk derives the emphasis content range inline and consumes the span itself before recursing, so neither had a caller left.equalRangewas guarding a case that can't arise: two spans with identical ranges. Under the old code both would be excluded fromtopand 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.