Inline parse cost linear in spans per region, not quadratic (#109) - #140
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 never PARTIALLY overlap, 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 at this branch's merge base; the scaling assertions fail on that parser at 11.3x-27.8x against an 8x bound. Rebased onto nodes-app#118, which relaxed the invariant this rests on: a link may now contain a claimed code span inside its label, so claimed ranges are disjoint OR properly nested rather than strictly disjoint. Three consequences, all handled here. * ClaimedIndex gains `overlapping`, which enumerates every claimed range meeting a candidate instead of answering yes/no. Only the link case needs it; everything else still short-circuits on the first overlap. It peeks forward from the cursor rather than advancing it, so the left-to-right walk is unaffected and the cost property holds. * `contains` needed no change: a nested range sorts after its container, which already covers it. * The corpus fingerprint was re-recorded on the pre-rewrite parser at the new merge base. It reproduces that parser's trees exactly, which is what makes this a pure performance change on top of nodes-app#118 as well. The scaling bound moved from 12x to 8x. Measured on one machine: worst case after the rewrite is 5.6x (code), best case before it is 11.3x (highlight), so 12x let the two cheapest constructs pass on the OLD parser — the one thing the assertion exists to prevent. 8x is the geometric midpoint of that gap, with ~1.4x of room either side. Closes nodes-app#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reviewed, and the change is right — the cursor over the sorted ranges and the ordered walk are both the obvious thing in hindsight, which is the good kind of fix. Sorry for the slow reply.
Rather than bounce it back, I've rebased it for you and force-pushed — What the rebase changed
One substantive change: the bound moved from 12x to 8xYour scaling assertions are the right idea, but I measured them on the old parser and two of the five don't detect anything. On this machine, pre-rewrite:
Verification312 tests green over repeated runs, #118's link-label tests included. The corpus fingerprint matching main byte-for-byte is what actually convinced me this is behaviour-neutral — a test list can miss a shape, 4000 adversarial inputs folding to the same value can't. Thanks for splitting this out of the directives work rather than folding it in. It's much easier to be confident about in isolation, which is the whole point. |
5d6b842 to
94db93a
Compare
They measure a wall-clock RATIO, which looks portable and isn't. The same parser reads 5.3x on an M-series laptop and 10.9x on a shared macos-15 runner, where `swift test --parallel` keeps 55 suites competing for cores right through the measurement window. #140 tightened the bound to 8x on laptop numbers and turned main red on the first push. No bound fixes this. The pre-rewrite floor is 11.3x on the laptop, which is already above the post-rewrite CI reading, so any threshold that passes CI is one that would have passed on the parser the assertion exists to catch. So they become opt-in via `MDE_PERF=1 swift test`, and the bound stays at 8 with a note to recalibrate per machine. `corpusFingerprint` is unaffected — it folds 4000 parsed trees into one value, holds on every machine, and is the check that actually proves the rewrite changed no behavior. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Heads-up on an overlap with #118, which I found after opening this. #118 (inline code inside link labels) touches the two exact things this PR rewrites — There's a substantive interaction underneath the textual one, in this PR's favour, and I checked it rather than assuming: #118 introduces nesting into #118 gets smaller on top of this PR. It extends And the composition is equivalent to #118 alone. The 4000-input corpus fingerprint is One ordering argument worth having explicitly: #118's new check replaces a short-circuiting Also worth stating: when #118 lands, #120 merges cleanly with this one — the directive hook is inside I've left #118 a note with the same findings and offered them the port. |
|
Thanks for rebasing this rather than sending it back, and for the CI catch. The red was mine and your diagnosis is exact. "Minimum of several runs, noise only adds time" is sound for an absolute measurement and simply doesn't transfer to a ratio — under load there's no quiet run to floor against, and the smaller number inflates proportionally more. That the pre-rewrite floor sits below the post-rewrite CI reading settles it: no bound separates the two parsers, so there was no number for me to argue for. Ignore my comment above — it crossed with your merge, and by then you'd already found all of it independently, including that Your Rather than argue for a different bound I've opened #146, which asserts on counted work instead of elapsed time: 6.0x for 6x the spans against 33.9x with the old pairwise containment restored, both exact and machine-independent. Those go back on CI; your timed ones stay opt-in for absolute numbers. |
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.