Skip to content

perf(builtin): pattern-based fast paths for char predicates - #4112

Merged
bobzhang merged 1 commit into
mainfrom
hongbo/char-predicate-patterns
Aug 20, 2026
Merged

perf(builtin): pattern-based fast paths for char predicates#4112
bobzhang merged 1 commit into
mainfrom
hongbo/char-predicate-patterns

Conversation

@bobzhang

Copy link
Copy Markdown
Contributor

Summary

Pattern-based fast paths for Char::is_ascii_whitespace, Char::is_whitespace, and Char::is_numeric — the speed of #3703's integer rewrites where patterns can deliver it, with every check staying in the char pattern DSL. Follow-up to the benchmark review posted on #3703 (thanks @mizchi for the bench methodology and for identifying the hot spots), which showed that most of that PR's win is early-exit structure, not integer arithmetic — and that the arithmetic actually loses to patterns on some backends.

Changes

// six singletons -> one range + space
pub fn Char::is_ascii_whitespace(self : Self) -> Bool {
  self is ('\u{09}'..='\u{0D}' | ' ')
}

// ASCII fast path + below-table reject in front of the unchanged range table
pub fn Char::is_numeric(self : Self) -> Bool {
  if self is ('0'..='9') {
    return true
  }
  if self < '\u{B2}' {   // U+00B2 is the table minimum
    return false
  }
  self is (...)          // table unchanged, leading digit range moved to the guard
}

// one ASCII branch deciding via the same range pattern; non-ASCII table unchanged
pub fn Char::is_whitespace(self : Self) -> Bool {
  if self <= '\u{20}' {
    return self is ('\u{09}'..='\u{0D}' | ' ')
  }
  self is (...)          // table minus the two ASCII alternatives
}

Benchmarks

#3703's char_predicate_bench_test.mbt methodology, moon bench --release, same-day interleaved baselines, branch cells are means of two runs (repeats within ~2%):

bench native js wasm-gc
is_numeric ascii n=62000 886 µs → 79 µs (11.2x) 1.44 ms → 224 µs (6.4x) 2.65 ms → 164 µs (16.2x)
is_numeric unicode n=22000 250 → 156 µs 758 → 415 µs 807 → 460 µs
is_ascii_whitespace ascii 13.7 → 13.9 µs (par) 226 → 112 µs (2.0x) 77.8 → 70.5 µs
is_whitespace ascii / unicode par par 147/53.1 → 126/46.0 µs

Versus #3703's integer versions: its is_ascii_whitespace arithmetic measures 28.1 µs on native — 2.0x slower than this pattern — and 1.9x slower on js; its is_numeric ties this version on all three backends. Only is_whitespace keeps an arithmetic edge on native (32 vs 68 µs): every pattern form that reaches it there regresses another backend (char-compare guards are 1.7x slower on wasm-gc than the arithmetic and slower than main; a single ordered match ties wasm-gc but is 1.8x slower on native). This PR takes the only readable form measured to regress nowhere, and treats the remaining gap as a moonc codegen issue (char patterns and comparisons should lower to the same decision tree regardless of surface syntax) rather than a reason to hand-write integer code in core.

Tests

char/predicate_reference_test.mbt keeps the previous flat patterns verbatim as reference functions and checks all three predicates against them for every Unicode scalar value (1,112,064 code points; Int::to_char excludes exactly the surrogates). Mutation-verified: shifting the numeric reject bound by one fails at code point 178; shortening the ASCII whitespace range fails at code point 13.

Test plan

  • Full repo moon test: 7462/7462
  • char suite 41/41 on wasm-gc, native, and js
  • moon check --warn-list +unnecessary_annotation clean, moon fmt applied
  • moon info: no .mbti change (implementation-only)
  • Codex CLI review (xhigh): first-round sign-off — set-equivalence, table-minimum claim, byte-identical references, and surrogate coverage all independently verified; see review comment

🤖 Generated with Claude Code

Restructure Char::is_ascii_whitespace, Char::is_whitespace and
Char::is_numeric for speed while keeping every check in the char
pattern DSL — no .to_int() arithmetic. Follow-up to the benchmark
review of #3703, which proposed integer-comparison rewrites of the
same three functions; the measurements there showed most of that PR's
win comes from early-exit structure, not from integers, and that the
arithmetic actually loses to patterns on some backends.

- is_ascii_whitespace: six singleton alternatives become one range
  plus space: `self is ('\u{09}'..='\u{0D}' | ' ')`.
- is_numeric: an ASCII fast path (`if self is ('0'..='9') { return
  true }`) and a below-table reject (`if self < '\u{B2}' { return
  false }`) in front of the unchanged range table (leading digit range
  removed from the table since the guard owns it).
- is_whitespace: one ASCII branch (`if self <= '\u{20}'`) that decides
  via the same range pattern, with the non-ASCII table unchanged.
  Chosen over faster-on-one-backend alternatives because it is the
  only readable form that regresses nowhere: guard-based variants that
  tie the integer arithmetic on native are 1.7x slower on wasm-gc, and
  a single ordered match that ties on wasm-gc is 1.8x slower on
  native — a backend codegen sensitivity worth fixing in moonc rather
  than working around with integer code.

Benchmarks (#3703's char_predicate_bench_test.mbt methodology,
moon bench --release, same-day interleaved baseline on current main;
main -> this branch):

  is_numeric ascii n=62000:
    native 886us -> 79us (11.2x), js 1.44ms -> 224us (6.4x),
    wasm-gc 2.65ms -> 164us (16.2x)
  is_numeric unicode n=22000:
    native 250us -> 156us, js 758us -> 415us, wasm-gc 807us -> 460us
  is_ascii_whitespace ascii n=62000:
    native 13.7us -> 13.9us (par), js 226us -> 112us (2.0x),
    wasm-gc 77.8us -> 70.5us (1.10x)
  is_whitespace ascii/unicode:
    native 67.4/26.7us -> 68.3/25.8us (par), js par,
    wasm-gc 147/53.1us -> 126/46.0us (1.16x/1.15x)

For comparison, #3703's integer versions of the same functions measure
28.1us (native is_ascii_whitespace, 2.0x SLOWER than this pattern) and
212us on js (1.9x slower); its is_numeric ties this version on all
three backends. Only is_whitespace keeps an arithmetic edge on
native (32.2us vs 68.3us) at the cost of readability everywhere.

Tests: char/predicate_reference_test.mbt keeps the previous flat
patterns verbatim as reference functions and checks all three
predicates against them for every Unicode scalar value (~1.1M code
points). Mutation-verified: shifting the numeric reject bound by one
fails at code point 178; shortening the ASCII whitespace range fails
at code point 13.

Codex CLI review (xhigh, first-round sign-off): "All three rewrites
are set-equivalent; B2 is the numeric table minimum, and the whitespace
guard fully decides every value through U+0020. Reference patterns
match origin/main byte-for-byte. The inclusive sweep covers 1,112,064
scalar values; Int::to_char excludes exactly the surrogate range."

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Codex CLI <codex@openai.com>
Copilot AI lite review requested due to automatic review settings August 20, 2026 00:32
@bobzhang

Copy link
Copy Markdown
Contributor Author

Codex CLI review

Adversarial review by Codex CLI (gpt-5.6-sol, reasoning effort xhigh, read-only sandbox), briefed with the full three-backend benchmark matrix (including the rejected alternatives and the deliberate is_whitespace trade), and asked to attack: set-equivalence of each restructure with the original pattern, the U+00B2 table-minimum claim, the soundness of removing the ASCII alternatives from the is_whitespace tail, Char comparison semantics, blind spots in the exhaustive test itself (reference fidelity, surrogate exclusion), CI cost of the ~1.1M-scalar sweep, and every number in the commit message.

Round 1 — approved, no blocking findings

No blocking findings.

  • All three rewrites are set-equivalent; B2 is the numeric table minimum, and the whitespace guard fully decides every value through U+0020.
  • Reference patterns match origin/main byte-for-byte. The inclusive sweep covers 1,112,064 scalar values; Int::to_char excludes exactly the surrogate range.
  • Char ordering is Unicode code-point ordering, so both guards are semantically sound.
  • The roughly 6.67 million predicate calls are reasonable for CI, and the commit-message ratios and claims agree with the brief.

Signed-off-by: Codex CLI codex@openai.com


Posted on behalf of Codex CLI by Claude Code. Validation outside Codex's read-only sandbox: full repo moon test 7462/7462; char 41/41 on wasm-gc/native/js; no .mbti change; equivalence test mutation-verified at both guard boundaries.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR restructures three hot-path Char predicate implementations in builtin to introduce pattern-based fast paths (early exits) while keeping the underlying Unicode classification tables expressed in the existing char pattern DSL. It also adds an exhaustive reference test to ensure the refactor is behavior-preserving across all Unicode scalar values.

Changes:

  • Rewrote Char::is_ascii_whitespace to use a compact range-pattern fast path ('\u{09}'..='\u{0D}' | ' ').
  • Added an ASCII fast path and an early “below table minimum” reject for Char::is_numeric, keeping the existing non-ASCII table intact.
  • Added an ASCII/low-codepoint fast path for Char::is_whitespace, leaving the non-ASCII table unchanged.
  • Added an exhaustive equivalence test comparing the restructured predicates against the previous flat-pattern references for every Unicode scalar value.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
builtin/char.mbt Introduces pattern-based fast paths and early rejects for whitespace/numeric predicates without changing the non-ASCII classification tables.
char/predicate_reference_test.mbt Adds exhaustive, full-Unicode scalar equivalence tests against the prior flat-pattern reference implementations to prevent off-by-one/table-regression bugs.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@coveralls

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 6204

Coverage decreased (-0.009%) to 90.708%

Details

  • Coverage decreased (-0.009%) from the base build.
  • Patch coverage: 2 uncovered changes across 1 file (4 of 6 lines covered, 66.67%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
builtin/char.mbt 6 4 66.67%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 17972
Covered Lines: 16302
Line Coverage: 90.71%
Coverage Strength: 332710.36 hits per line

💛 - Coveralls

@bobzhang

Copy link
Copy Markdown
Contributor Author

Addendum: why not self is ' ' || … (space-first)?

A natural follow-up question on these predicates: space is the most common whitespace character, so should it get a dedicated first check? Measured on all three backends (Codex-CLI-verified analysis, 4 rounds; the guard variant also lets the ASCII branch of is_whitespace drop its | ' ' alternative):

On mixed, realistic text (code-like, spaces + \n hits, 27% whitespace), space-first is worse on every backend for both functions — e.g. is_ascii_whitespace js 112 → 127 µs, is_whitespace wasm-gc 126 → 137 µs. Reordering the pattern alternatives space-first (without a guard) is also uniformly 2–17% worse.

A controlled sweep (64-char arrays, k literal spaces interleaved with letters, k ∈ {0,16,32,48,64}) shows the trade precisely:

  • At k=0 (pure rejects), five of six cells regress 5–13%; native is_ascii_whitespace is flat.
  • The guard starts winning between k=0 and k=16 on wasm-gc (up to 1.8x at k=64), between k=32 and k=48 on js for is_ascii_whitespace; js is_whitespace showed no benefit at any sampled fraction.

The structural account (verified against the code shapes): the guard is a bet that the evaluated char is a literal space — a space then costs one comparison; every '\t'..'\r' hit costs exactly one comparison more than the current form; and in is_whitespace, every reject above U+0020 (letters, digits, punctuation — the common case) pays one extra comparison, while control-char rejects stay count-neutral (the guard is offset by the dropped | ' ' alternative).

Conclusion: no variant dominates. Space-first only collects on hit streams that are overwhelmingly literal spaces (e.g. skipping space-only indentation) — and a call site that knows it has that shape can simply write c is ' ' || c.is_whitespace() itself. The library default keeps the current form, which is worse nowhere.

Measurements and analysis by Claude Code; verified by Codex CLI (xhigh, sign-off after a 4-round adversarial audit of the claims against the data and code).

@bobzhang
bobzhang merged commit f997206 into main Aug 20, 2026
16 checks passed
@bobzhang
bobzhang deleted the hongbo/char-predicate-patterns branch August 20, 2026 02:17
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.

3 participants