Skip to content

perf25: regex/String construction, ToPrimitive, eval closures, null compare - #158

Merged
Lewin671 merged 10 commits into
mainfrom
agent/perf-23/claude
Sep 26, 2026
Merged

Lewin671 merged 10 commits into
mainfrom
agent/perf-23/claude

Conversation

@Lewin671

Copy link
Copy Markdown
Owner

Semantic fast paths for the cases that were slowest against QuickJS-NG. Each one was found by splitting a case into micro pieces and comparing each piece against NG by its N-versus-2N delta.

  • RegExp literals: built directly from their string arguments. A literal went from 9455 to 3301 instructions (NG 1723).
  • new String(x): built directly as well (function::construct_intrinsic_directly).
  • ToPrimitive: skips the @@toPrimitive walk on ordinary chains that lack it. The generic + now joins primitives in place.
  • Date methods: read the receiver's time value once instead of twice.
  • Closures created by direct-eval code: they drop the eval scope when they resolve none of its names by name, and that scope can gain no names later. xparb's format functions now take the direct call path.
  • x == null in the wide tier: answered without the general operator. The helper is kept out of the shared operator code, because extending that code re-rolled the dispatch loop and cost crypto-md5 4%.
  • Typed loops: compare against string literals without unboxing.
  • Layout: compact_fn::numeric_plan::run is now pinned.

Formal run e34c35b against main 5110c21, 30 blocks, cycles (target/comparison/perf23-e34c35b4-30b):

  • External geomean was 0.990 against main and 0.756 against QuickJS-NG (previously 0.772).
  • date-format-xparb went to 0.677 against main (1.46 → 1.016 against NG). string-validate-input went to 0.928 and access-binary-trees to 0.968.
  • The md5 regression that run showed (1.042) is fixed in 130c27c. By min-of-15 alternation md5 is now 1.005 against main, and the sentinels and canaries are flat on a screen.
  • The formal run was not repeated after that fix.

Test262: the gap scans show 0 actionable gaps against QuickJS-NG for these slices:

  • built-ins/RegExp, String, Date, eval
  • language/eval-code, annexB eval-code, statements/with
  • expressions/equals, does-not-equals, addition, function
  • arguments-object, literals/regexp

Every commit passed check.sh in the pre-push hook. The unit record is in T033.

🤖 Generated with Claude Code

Lewin671 and others added 10 commits September 26, 2026 03:13
compile.rs was at the 2000-line limit; the hoisted global reads and the
sloppy fallback-global writes form one responsibility. No behavior change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`ch < "0" || ch > "9"` over a `charAt` result compiled to an unbox of
`ch`, which deoptimized on its first iteration: string-validate-input's
ZIP scan entered and deoptimized both of its loops on every call. A
relational operator with a string-literal operand now takes the boxed
comparison operation, which compares two strings by code unit and two
numbers as numbers and deoptimizes on anything else (an object's
`valueOf`). Only with a literal: `i < list.length` keeps the pass that
reads the length as a scalar. The scan micro: 20.8k -> 7.3k instructions
an iteration (QuickJS-NG 11.0k).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A regular-expression literal compiles to `new RegExp(pattern, flags)` with
constant strings, and each evaluation went through the general construct
path: a `prototype` property read, a hash-map-backed receiver, IsRegExp and
ToString conversions that copied both strings, a SipHash lookup in the
validation cache, and three property definitions that each allocated a key
and a value. With `new.target` the constructor and string arguments none of
that is observable, so the object is now built in one step with shared keys
and the argument strings themselves, and the last validated pair is
remembered by pointer. string-validate-input's literal went from 9455 to
3301 instructions per evaluation (QuickJS-NG 1723).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`new String(x)` took the general construct path: a `prototype` read, a
receiver, a native call that converted the argument to an owned string and
then defined the wrapper's two properties on hash-map storage. With
`new.target` the constructor and a string or number argument nothing in
between is observable, so the wrapper is now built in one step, like a
RegExp from string arguments; both share `construct_intrinsic_directly`,
which the general and wide-tier construct paths call. date-format-xparb's
`String.leftPad` wrapper went from 1003 to 555 cycles (QuickJS-NG 665).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ToPrimitive read `@@toPrimitive` through the general symbol-property path on
every object operand of `+`, cloning each prototype slot on the way to
`undefined`. An ordinary object whose chain holds no such own property now
answers that in one borrowed walk, the String-wrapper shortcut checks the
receiver's own `valueOf` without cloning it, and the generic `+` joins two
primitives through the same buffer-reusing concatenation the bytecode fast
path uses instead of copying the left string first. `'0' + new String(5)`
went from 646 to 444 cycles (QuickJS-NG 306).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every Date method looked its receiver's time value up twice: once to check
that it is a Date and once to read it. The read is the check. `getTime` in
a loop went from 296 to 183 cycles per call (QuickJS-NG 121). Also re-pins
the hot-function order for the String, RegExp and ToPrimitive changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A function literal in direct eval code carried the eval's dynamic scope,
which keeps every call of it off the slot-seeded direct path and the
compact tiers: each call built a full interpreter frame and found even
`String` by hashing the eval scope. When that code has no `eval` or `with`
of its own, its scope is a fork nothing else writes and gains no name after
the closure exists, so a closure that resolves none of its current names
by name -- and has its own `this` and `arguments`, no `eval`, `with` or
nested function -- now drops it. What such a closure reads is unchanged.
date-format-xparb's generated format functions went from 7614 to 5785
cycles per call; the whole case went from 28.2k to 17.9k cycles per
iteration (QuickJS-NG 17.6k).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`node.left == null` with an object operand fell through the environment-free
comparison, cloned both operands and built a throwaway frame to run the
general loose equality, only to learn that nothing but `null`, `undefined`
and the IsHTMLDDA host object equals `null`. The environment-free helper now
answers `null`/`undefined` against anything and number against number, and
the wide tier's conditional jump asks it before cloning. binary-trees ran
3.3% fewer cycles.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Extending `eval_binary_without_env` with the null and number cases made
rustc re-partition the crate: the wide tier's dispatch loop came out 176
bytes smaller and crypto-md5 ran 2.5-4% more cycles at identical
instructions (the formal run against main measured 1.042). The same
comparison now lives in a small out-of-line helper the wide tier's
conditional jump calls, the shared helper is as it was, and the loop is
back to main's shape: md5 1.005 against main, binary-trees keeps its gain
(0.976). Also pins `compact_fn::numeric_plan::run`, md5's hottest function,
which floated with every edit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Lewin671
Lewin671 merged commit cebad5e into main Sep 26, 2026
7 checks passed
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