Skip to content

Tokenize lines with indexOf and slice instead of a regex split - #734

Open
oBusk wants to merge 2 commits into
kpdecker:masterfrom
oBusk:perf/line-tokenizer
Open

oBusk wants to merge 2 commits into
kpdecker:masterfrom
oBusk:perf/line-tokenizer

Conversation

@oBusk

@oBusk oBusk commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

The line tokenizer split the input with /(\n|\r\n)/ and then glued each line back onto its line break with +=, building a new string for every line. This PR finds each \n with indexOf and slices the token straight out of the input. With newlineIsToken, the same loop slices the line and its line break as two tokens. The tokens are unchanged.

This mostly helps when diffing a big file with few changes, where tokenizing is a large part of the time.

Case master This PR Speedup
diffLines, one changed line in ~8.9k 2.07 ms 1.02 ms 2.03x
createPatch, one changed line in ~8.9k 2.28 ms 1.09 ms 2.09x
diffLines, ~800 changed lines in ~8.5k 12.90 ms 12.16 ms 1.06x

Inputs are a yarn.lock before and after a dependency bump, and the newer one with a single line changed. Median of 7 fresh Node 26.10 processes, against 8d706c8.

The savings are modest in the bigger picture, but it's testable and isolated, doesn't collide with #732 or any other PRs.

Errors

value.split(...) used to throw a TypeError on non-string input. Without it, the new loop would diff a number or an object as an empty string, so to retain original behavior tokenize now throws TypeError: Expected a string to split into lines, got <type>.

Testing

I added a tokenize test for the edge cases, and compared the new tokenizer with the old one on 800,000 random strings of a, b, space, \r and \n, with every combination of newlineIsToken and stripTrailingCr. All tokens were identical.

oBusk and others added 2 commits October 29, 2026 12:16
The tokenizer split on /(\n|\r\n)/ and then concatenated each line with
its line break, building a new string for every line. Instead, find each
\n with indexOf and slice the token straight out of the input. With
newlineIsToken, the line and its line break are separate tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The regex split used to throw a TypeError for non-string input as a side
effect. The indexOf loop doesn't, and would diff e.g. a number or an
object as if it were an empty string, so check explicitly. String objects
still work. diffJson also ends up here when its input serializes to
undefined, so the message doesn't mention line diffs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@oBusk

oBusk commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

Another thing I realised: this also cuts peak memory by about a third (33–36% in diffLines on a ~7 MB file with one change). The old tokens were concatenated strings, which V8 copies the first time the diff compares them. The new tokens are slices that point into the input. It doesn't help when every line is shorter than 13 characters (V8 copies those either way), or with newlineIsToken on LF input.

I also tried optimizing the other tokenizers. diffSentences got up to 3.2x faster and diffCss 1.2–1.4x faster. I left them out of this PR to keep it focused. The branch is https://github.com/oBusk/jsdiff/tree/perf/sentence-css-tokenizers

This branch has not been deployed

No deployments
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.

1 participant