Skip to content

perf(string): scan for ASCII a word at a time, and memchr a one-byte split - #10910

Closed
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:perf/10519-split-residue
Closed

proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:perf/10519-split-residue

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Two per-call costs in js_string_split_n, both measured on #10519's own reproduction — a 211-byte JWT split on "." — now that the engine path no longer answers plain-string splits (#10816).

5,424 → 4,268 instructions per call, −21.3%. That row is now 9.2× node, against the 345× and ~230k instructions #10519 records.

The ASCII scan

The source is scanned once per call to decide whether every part can take the all-ASCII metadata shortcut. That scan was bytes.iter().all(|&b| b < 0x80) — one byte per iteration. perf annotate put ~74% of this function's own time in its six-instruction loop, roughly 19% of the entire call:

15.37 : cmpb   $0x0,(%rsi,%rdi,1)
14.40 : setns  %r9b
13.04 : js     ...
10.73 : jne    ...
10.62 : cmp    %rdi,%rdx
 9.64 : lea    0x1(%rdi),%rdi

is_ascii() is the same predicate and std tests a word at a time.

The one-byte delimiter

Parts were found with str::split(&str), which takes the two-way StrSearcher and pays its setup in full for a single-character needle — the common split("."), split(","), split(" ") shape. A one-byte delimiter now takes std's memchr-based char searcher.

The byte < 0x80 guard on that path is load-bearing, not decorative. String storage can hold malformed bytes — the comment above the ASCII scan documents [0x80, b'|', 0xF0] — and 0x80 as char is U+0080, which re-encodes as two bytes and would split on the wrong occurrences. "One byte implies ASCII" only holds for well-formed input.

Evidence

Split conformance unchanged: the 27 cases where a byte scan and a UTF-16 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 the 12 non-string separator forms all still match Node.

perry-runtime lib suite 4200 passed, 0 failed; --locked build, fmt, -D warnings, GC holder audit, file size and the release build all OK. Lint gates: 1 of 85 fails, "Public benchmark evidence freshness", pre-existing on main.

What is left on #10519

Roughly, from the profile: the per-part write barrier ~5.6%, the result-array allocation ~3.5%, a heap Vec of part ranges per call ~2.6%, and ~6% from the admission check #10816 added (api::caught plus a ToString on the separator). None is a single dominant term the way the ASCII scan was.

Summary by CodeRabbit

  • Bug Fixes
    • Improved string splitting for ASCII text and delimiters.
    • Preserved expected behavior when processing malformed or non-ASCII byte sequences.

…split

Two per-call costs in `js_string_split_n`, both measured on PerryTS#10519's own
reproduction -- a 211-byte JWT split on "." -- after the engine path stopped
answering plain-string splits.

The source is scanned once per call to decide whether every part can take the
all-ASCII metadata shortcut. That scan was `bytes.iter().all(|&b| b < 0x80)`,
one byte per iteration; `perf annotate` put about 74% of this function's own
time in its six-instruction loop, roughly 19% of the whole call. `is_ascii()`
is the same predicate and std tests a word at a time.

The parts are then found with `str::split(&str)`, which takes the two-way
`StrSearcher` and pays its setup in full for a single-character needle -- the
common `split(".")`, `split(",")`, `split(" ")` shape. A one-byte delimiter
takes std's memchr-based searcher instead.

The `byte < 0x80` guard on that second path is load-bearing rather than
decorative: string storage can hold malformed bytes (the comment above the
ASCII scan documents `[0x80, b'|', 0xF0]`), and `0x80 as char` is U+0080, which
re-encodes as two bytes and would split on the wrong occurrences.

  JWT split on ".":  5,424 -> 4,268 instructions per call, -21.3%

Against PerryTS#10519's 345x and ~230k instructions, that row is now 9.2x Node 26.5.1.
Split conformance is unchanged: the 27 cases where a byte scan and a UTF-16
unit scan can disagree, and the 12 non-string separator forms, all still match
Node.
@coderabbitai

coderabbitai Bot commented Sep 21, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Understand this PR’s impact

Explore downstream dependencies and potential security impact with Blast Radius.

View blast radius →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 7ccdfca8-b64e-415a-bb97-e3176ff571c3

📥 Commits

Reviewing files that changed from the base of the PR and between 841b605 and 437fe12.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/string/split.rs

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


📝 Walkthrough

Walkthrough

The string split implementation now uses direct ASCII detection and selects character-based splitting for non-empty ASCII delimiters. Non-ASCII and malformed-byte delimiters retain string-pattern splitting.

Changes

String Split ASCII Handling

Layer / File(s) Summary
ASCII detection and delimiter selection
crates/perry-runtime/src/string/split.rs
Whole-source ASCII detection now uses bytes.is_ascii(). Non-empty single-byte ASCII delimiters use character-based splitting, while non-ASCII and malformed-byte delimiters retain string-pattern splitting.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Refactor

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the two main performance changes: word-at-a-time ASCII scanning and optimized one-byte splitting.
Description check ✅ Passed The description provides a detailed summary, implementation changes, performance evidence, compatibility rationale, and test results. It does not use the repository template headings or explicitly pro…
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 1 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 253 (#10918) as v0.5.1633 — merge commit 0fa3915293.

Expedited at the owner's request. Carried with nine other PRs; the stacked ones (#10899/#10900 on #10886, #10901 on #10885) had only their unique commits taken.

Evidence on the assembled tree: perry-runtime full suite 4233 passed / 0 failed / 0 SIGABRT, perry-hir 471 / 0, cargo check --workspace --all-targets under -D warnings clean, cargo fmt --check clean, and all eight ratchets rc=0. The gap sweep and compiler-output suites were not run.

Closing here rather than merging — a train lands the commits directly.

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