Skip to content

perf(regex): answer a plain-string split without the engine - #10816

Closed
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:perf/split-delegate-plain
Closed

proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:perf/split-delegate-plain

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Linking regex-engine replaces String.prototype.split wholesale, so a program using a regex anywhere ran every split through the engine's per-UTF-16-unit subject reader — even "a b".split(" "), where the engine has nothing to contribute.

One source compiled twice, loop-variant receivers, control-subtracted:

instr/split
engine linked 37,558
engine absent 3,662
node 26.5.1 2,714

Both arms auto-optimized, so this is the implementation swap and not the build mode — specialization is ~5% of it. ~48% of the engine path is Units::at, BoundSpan::retarget, Cursor::next_unit, copy_units.

Result: 37,558 → 5,081 instructions, −86.5%, 13.84× → 1.87× node.

What it does

The plain algorithm answers the call when it provably agrees, placed below the @@split check so a custom splitter still wins.

Three input classes are excluded rather than repaired, because the implementations genuinely differ — each was found by a failing test, not by inspection:

  • a separator holding a lone surrogate, which the engine matches against one half of a valid pair and a WTF-8 byte scan cannot ("😀😀".split(lowHalf) is 3 parts, not 1);
  • a separator not already a string, whose ToString can run user code or throw — a Symbol must raise TypeError;
  • a limit not already a number or undefined, whose ToNumber can throw (BigInt, boxed valueOf).

Everything excluded takes the engine path, so this only narrows what the fast path answers. The plain algorithm also reports failure by throwing where this module returns Err, so the call is wrapped in api::caught.

Evidence

  • 27 cases where a byte scan and a unit scan can disagree — empty separator, separator longer than the subject, every limit form, lone surrogates, an astral pair split by units, a separator that is a prefix of itself at the tail, overlapping separators — identical to Node.
  • 12 non-string separator forms, including @@split callable and not callable — identical to Node.
  • perry-runtime lib suite 4100 passed, 0 failed; --locked build, fmt, -D warnings (regex-off and product), GC root holders, file size, release build all OK.

Lint gates: 2 of 85 fail, both pre-existing on pristine main and unreachable from this diff.

Worth knowing separately

The excluded classes exist because split:: — what not(regex-engine) builds ship — diverges from the engine on @@split, surrogate half-pairs, uint32 limits and primitive hooks. Those builds have carried that all along; nothing tests it, because the default feature set never compiles it. Not fixed here.

https://claude.ai/code/session_018M47oWitg2Hf1jzfhLqgQ9

Summary by CodeRabbit

  • Bug Fixes
    • Improved String.prototype.split handling for plain string separators and non-coercive limits.
    • Preserved custom regular-expression split behavior through Symbol.split.
    • Improved consistency of string splitting across runtime configurations.
  • Performance
    • Streamlined common string-splitting cases by avoiding unnecessary regular-expression processing when it is safe to do so.

Linking `regex-engine` replaces `String.prototype.split` wholesale (see
`string::mod`), so a program that uses a regex anywhere ran every split through
the engine's per-UTF-16-unit subject reader -- even `"a b".split(" ")`, where the
engine has nothing to contribute. Measured on one source compiled twice:

  split(" "), engine linked     37,558 instructions
  split(" "), engine absent      3,662
  node 26.5.1                    2,714

Both arms auto-optimized, so that is the implementation swap rather than the
build mode; specialization accounts for about 5% of it. Roughly 48% of the
engine path is `Units::at`, `BoundSpan::retarget`, `Cursor::next_unit` and
`copy_units`.

The plain algorithm now answers the call when it provably agrees, below the
`@@split` check so a custom splitter still wins. Three input classes are
excluded rather than repaired, because the two implementations genuinely differ
on them:

  * a separator holding a lone surrogate, which the engine matches against one
    half of a valid pair and a WTF-8 byte scan cannot;
  * a separator that is not already a string, whose ToString can run user code
    or throw -- a Symbol must raise TypeError;
  * a `limit` that is not already a number or undefined, whose ToNumber can
    throw.

Everything excluded takes the engine path, so this only narrows what the fast
path answers. The plain algorithm also reports failure by throwing where this
module returns Err, so the call is wrapped in `api::caught`.

  split(" "): 37,558 -> 5,081 instructions, -86.5%, 13.84x -> 1.87x node

Answers are identical to Node on 27 cases where a byte scan and a unit scan can
disagree -- empty separator, separator longer than the subject, every `limit`
form, lone surrogates, an astral pair split by units, a separator that is a
prefix of itself at the tail -- and on 12 non-string separator forms including
`@@split` callable and not callable.

Claude-Session: https://claude.ai/code/session_018M47oWitg2Hf1jzfhLqgQ9
@coderabbitai

coderabbitai Bot commented Sep 20, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: e827d3c1-5ecc-45bc-be92-22ac6e245b5f

📥 Commits

Reviewing files that changed from the base of the PR and between b9ba951 and 222bab2.

📒 Files selected for processing (3)
  • crates/perry-runtime/src/regex/perex_split.rs
  • crates/perry-runtime/src/string/mod.rs
  • crates/perry-runtime/src/string/split.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 2 remain after this review.


📝 Walkthrough

Walkthrough

The regex split entry point now delegates eligible inputs to the plain string split implementation after checking @@split. Split helpers compile in both feature configurations, while C symbol exports remain conditional.

Changes

Plain split delegation

Layer / File(s) Summary
Split runtime availability
crates/perry-runtime/src/string/...
Split implementations now compile in both feature configurations. Their C symbol exports remain conditional. The regex-enabled build re-exports the plain split implementation internally.
Regex split dispatch
crates/perry-runtime/src/regex/perex_split.rs
The entry point preserves the @@split check, delegates plain separators and limits to js_string_split_plain, and uses the extracted engine path for other inputs.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Refactor

Sequence Diagram(s)

sequenceDiagram
  participant StringSplit
  participant SymbolSplit
  participant PlainSplit
  participant RegexEngine
  StringSplit->>SymbolSplit: check @@split
  SymbolSplit-->>StringSplit: no custom split
  StringSplit->>PlainSplit: split plain separator and limit
  StringSplit->>RegexEngine: process non-plain split
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: improving regex-enabled performance by answering compatible plain-string splits without the engine.
Description check ✅ Passed The description is detailed and covers the motivation, implementation, excluded inputs, performance results, and validation. It does not use the template headings or explicitly provide a Related issue…
Docstring Coverage ✅ Passed Docstring coverage is 81.82% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 3 files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🧪 Generate unit tests (beta)
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train 242 (#10830) as v0.5.1621e2a0839074.

Eight PRs travelled together because their file sets are disjoint — 30 files, +1,514/−101, zero overlap. Validated as one tree: ten cheap gates, -D warnings across all targets, five pinned artifacts byte-identical before and after, seven unit suites with an empty failing set, both compiler-output suites at failed_workloads=[], repsel_census rc=0, and a 250-fixture sweep with one area per PR (class 84, string 50, object 40, map 21, stream 18, bind 14, url 12, regex 11) — zero unexplained regressions.

Two of the eight needed a fix before they could land, both made in the train rather than bounced back.

#10816 bound sep_jv unconditionally in string/split.rs while reading it only inside #[cfg(feature = "regex-engine")], so RUSTFLAGS="-D warnings" cargo check -p perry --bins failed. Worth knowing why this is invisible in normal review: a one-invocation whole-workspace build unifies cargo features, so the regex engine is always on and the binding always read — only the per-package command, one of six run_lint_gates.sh derives, sees it. Same family as cargo check --lib not compiling cfg(test) code. Gated behind the feature that reads it; lim_jv on the next line was checked separately and is genuinely used outside the block.

#10817 added 15 dispatch entries without regenerating the docs, so the API-docs-drift check failed. Regenerated from a built binary: 2855 → 2870, exactly your 15, with perry.d.ts correctly unchanged at 2026 since those rows are dispatch-table rather than public surface. it_manifest_consistency passes on the assembled tree, which is the stronger signal — a green drift check only proves the files match the binary; that suite proves the manifest is internally consistent.

For future PRs in this area: scripts/regen_api_docs.sh hardcodes <worktree>/target/release/perry and, with that binary absent, regenerates from nothing and leaves both files truncated. A real regeneration moves the header counts and leaves the tail intact — worth checking the tail, not just the count.

One more thing, aimed at whoever cuts the next PR here: verify() flagged an exponential-backoff manifest entry in #10817 as missing from the train. That was correct — train 240 removed the binding, and restoring the entry would have failed manifest sync. main is moving several times an hour at the moment, so a PR cut against a base more than a few hours old is worth rebasing before review rather than after.

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