Skip to content

Split token streams in one pass - #4112

Merged
DmitrySharabin merged 2 commits into
claude/compassionate-brahmagupta-94vewqfrom
claude/split-token-stream-perf
Sep 22, 2026
Merged

DmitrySharabin merged 2 commits into
claude/compassionate-brahmagupta-94vewqfrom
claude/split-token-stream-perf

Conversation

@DmitrySharabin

@DmitrySharabin DmitrySharabin commented Sep 16, 2026

Copy link
Copy Markdown
Member

Follow-up to #4110, on top of its branch.

TL;DR

  • splitTokenStream was quadratic in the number of selected runs: a 12k-line diff:javascript took 3197ms, now 307ms. One forward pass, no remainder copies.
  • Two cases the one-pass form has to preserve, both found while reviewing this PR, both now tested: a zero-length item on a boundary (without it diff:markdown drops a token), and the no-offsets fast path (the common one — :text is the default selector).
  • splitTokenStream / insertTokens are public API via shared.js and had no direct tests. Added.
  • tests/languages/php!+css+css-extras/issue2008.test described a placeholder Declarative $inner selectors and a token overlay helper #4110 deletes, so it could no longer fail for the reason it exists. Note re-pointed at what it now guards.

The perf bug

splitTokenStream called splitAt once per offset, and splitAt copied the rest of the stream each time (stream.slice(i), [b, ...stream.slice(i + 1)]). embed passes one offset per run, i.e. one per diff line, so the copies dominate.

Repro — N reps of a three-line block, Prism.highlight(code, 'diff:javascript'):

for (let i = 0; i < N; i++) {
	code += ` const a${i} = ${i};\n-let b${i} = "x";\n+let b${i} = 'y';\n`;
}
lines #4110 this PR
3,001 189 ms 103 ms
12,001 3197 ms 307 ms
48,001 1290 ms

The 3k row is within noise — at that size the quadratic term is small, and a re-measurement on another machine gave 113 ms before and 120 ms after. The 12k row is the real signal, and it reproduces: 2790 ms before, 313 ms after.

node --cpu-prof on the 12k case put 1829 of ~2300 samples in splitAt.

The two cases that are easy to lose

A zero-length item sitting exactly on an offset belongs to the segment that starts there, not the one that ends there. splitAt got this for free by testing pos >= offset before consuming the item; a strict offsets[next] < pos bound never fires for it. Markdown emits an empty code-block token for an empty fenced block, so the difference is visible:

' ```js\n-\n ```\n'  →  highlight(code, 'diff:markdown')
without:  <span class="token code">\n</span>
with:     <span class="token code"><span class="token code-block"></span>\n</span>

With no offsets there is nothing to split, and that is the path almost everything takes: only src/languages/diff.js sets select, so php, django, handlebars, ejs, erb, smarty, liquid, latte, ftl, etlua and tt2 all reach splitTokenStream with offsets === []. It must not walk the stream measuring items to get there.

200k random streams (nested tokens, aliases, empty items, offsets at 0 / total / past the end / duplicated) split by both implementations: no divergences.

Not fixed

splitItem builds the halves with new Token(type, content, alias), which leaves Token.length at 0. Inert today — split streams are never fed back to _matchGrammar, the only reader — and the field is documented @internal with no guaranteed meaning, so threading real lengths through felt like more machinery than the payoff. Worth revisiting if a consumer migrated to splitTokenStream ever re-tokenizes.

Full suite green (10244 passing, 1 pre-existing pending), lint clean, tsc and tsc -p tests/tsconfig.json clean with no flags.

🤖 Generated with Claude Code

@DmitrySharabin
DmitrySharabin added this pull request to stack #4113 September 16, 2026 07:33
@netlify

netlify Bot commented Sep 16, 2026

Copy link
Copy Markdown

Deploy Preview for dev-prismjs-com ready!

Name Link
🔨 Latest commit 0b53870
🔍 Latest deploy log https://app.netlify.com/projects/dev-prismjs-com/deploys/6ab240ebb088460008c97030
😎 Deploy Preview https://deploy-preview-4112--dev-prismjs-com.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@DmitrySharabin
DmitrySharabin marked this pull request as draft September 16, 2026 07:39
@DmitrySharabin
DmitrySharabin force-pushed the claude/split-token-stream-perf branch from 487e7bb to 38aff0f Compare September 16, 2026 10:45
@DmitrySharabin
DmitrySharabin marked this pull request as ready for review September 16, 2026 16:00
@DmitrySharabin
DmitrySharabin removed this pull request from stack #4113 September 17, 2026 08:01
@DmitrySharabin
DmitrySharabin changed the base branch from claude/compassionate-brahmagupta-94vewq to v2 September 17, 2026 08:01
@DmitrySharabin
DmitrySharabin force-pushed the claude/split-token-stream-perf branch from 38aff0f to 2b5ee8b Compare September 17, 2026 08:01
@DmitrySharabin
DmitrySharabin changed the base branch from v2 to claude/inner-spec-detection September 17, 2026 08:02
@DmitrySharabin
DmitrySharabin added this pull request to stack #4115 September 17, 2026 08:02
@DmitrySharabin
DmitrySharabin removed this pull request from stack #4115 September 22, 2026 08:35
@DmitrySharabin
DmitrySharabin changed the base branch from claude/inner-spec-detection to claude/compassionate-brahmagupta-94vewq September 22, 2026 08:35
DmitrySharabin and others added 2 commits September 22, 2026 10:45
`splitTokenStream` copied the remainder of the stream for every offset
(`stream.slice(i)`, `[b, ...stream.slice(i + 1)]`), and `embed` passes one
offset per selected run, i.e. one per diff line. A 12k-line
`diff:javascript` took 3197ms; `--cpu-prof` put 80% of samples in
`splitAt`. Walk the stream once instead, carrying the straddling item into
the next segment.

`diff:javascript`, this branch vs its base: 3k lines 189ms -> 103ms,
12k lines 3197ms -> 307ms, 48k lines 1290ms, i.e. linear again.

Two details the one-pass form has to preserve, both easy to get wrong and
both covered by new tests:

- A zero-length item sitting exactly on an offset belongs to the segment
  that starts there, not the one that ends there. `splitAt` got this from
  checking `pos >= offset` before consuming the item; without the
  `offsets[next] === start` clause, `diff:markdown` drops the empty
  `code-block` token that markdown emits for an empty fenced block.
- With no offsets there is nothing to split. That is the common path —
  `:text` is the default selector, so every templating language takes it —
  so it must not walk the stream measuring items.

200k random streams split both ways against the previous implementation:
no divergences.

`splitTokenStream` and `insertTokens` are public API via `shared.js` and
had no direct tests; add them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test's note described a placeholder that no longer exists, so it could
no longer fail for the reason it was written. The input still covers the
embedding it was about: the CSS around a PHP block in a `style` attribute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@DmitrySharabin
DmitrySharabin changed the base branch from claude/compassionate-brahmagupta-94vewq to v2 September 22, 2026 08:48
@DmitrySharabin
DmitrySharabin force-pushed the claude/split-token-stream-perf branch from 2b5ee8b to 0b53870 Compare September 22, 2026 08:48
DmitrySharabin added a commit that referenced this pull request Sep 22, 2026
`container.splice(start, n, ...segment)` in `embed` and `stream.splice(i, 1,
...parts)` in `insertTokens` pass every item as a call argument, which overflows
the stack once a document has more inner tokens than the engine takes arguments
— about 460 KB of markup through any templating language, where `v2` was fine.
Both now go through `replaceRange()`, which truncates and pushes instead.

A root-level `ignore` token is dissolved into the `:text` run, but the walk then
took it again, so a selector naming both `:text` and that token fed its text to
the inner language twice, and the second copy changed how the first one parsed.

Also in `token-stream.js`: the two halves of a split token shared the original's
alias array, so `addAlias` on one reached the other and the original;
`insertTokens` silently sliced text away when offsets were not ascending, and
now throws; and `splitTokenStream` no longer claims an isolation it does not
give — segments share their unsplit tokens with the input.

Tests for each, plus the `diff` behaviours nothing pinned: two selectors rather
than one, the normal-diff `<`/`>` blocks, and the single-alias branch of
`tokenMatches`.

Follow-up to #4110 and #4112, whose code these are in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@DmitrySharabin
DmitrySharabin changed the base branch from v2 to claude/compassionate-brahmagupta-94vewq September 22, 2026 08:48
@DmitrySharabin
DmitrySharabin added this pull request to stack #4124 September 22, 2026 08:48
@DmitrySharabin
DmitrySharabin removed this pull request from stack #4124 September 22, 2026 08:49
@DmitrySharabin
DmitrySharabin merged commit 7e18205 into claude/compassionate-brahmagupta-94vewq Sep 22, 2026
20 checks passed
@DmitrySharabin
DmitrySharabin deleted the claude/split-token-stream-perf branch September 22, 2026 08:50
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.

2 participants