Repository navigation
codegen: a string's .codePointAt in cc's hottest loop lowers to NativeMethodCall{module:"child_process", class_name:"Instance"} #9847
Description
What the compiler emits
In cli_2.1.112.js (claude-code), N$6 is string-width. Its loop is the
single hottest thing in a claude-code turn: the allocation census ranks it
1/2/3 by count (172,032 segment records + 247,808 substrings per 400-character
reply, 58 % of the top-30 allocation count), and a sample puts 60-85 % of
active main-thread CPU inside the subtree it sits in (ink wrapText → JS
wrap-ansi → string-width).
Source:
for (let {segment: O} of rR_.segment(q)) {
let w = O.codePointAt(0);
...
}O is a grapheme — a string. Here is what it lowers to:
Let { id: 118612, name: "w", ty: Any, mutable: true, init: Some(NativeMethodCall {
module: "child_process",
class_name: Some("Instance"),
object: Some(LocalGet(118611)), // the destructured segment binding `O`
method: "codePointAt",
args: [Integer(0)] }) }
Repro — one minute, no LLVM build
perry compile --no-auto-optimize --trace hir --focus 'N$6' \
<path>/cli_2.1.112.js -o /tmp/out
Find __destruct_118613 in the dump; the Let for w two statements later is
the node above. The whole-bundle HIR pass is ~1 minute, so this is cheap to
re-check.
Cause: UNKNOWN. One hypothesis was tested and refuted
I am reporting what the compiler emits, not why. My one hypothesis was that
lookup_native_instance is keyed by identifier name
(HashMap<String, (String, String)>, lower/context.rs:1587), and that in a
minified bundle the name O collides: cli_2.1.112.js has three
O = <child_process>.spawn/exec(…) bindings and 5,381 bindings named O.
That hypothesis does not reproduce. Controlled experiment: a small probe in
the bundle's module shape whose segment loop classifies correctly, plus one
added, unrelated, never-executed
function __unrelated_spawner(){ let O = require("child_process").spawn("true"); }.
The lowering was unchanged — the segment loop's codePointAt stayed a
generic Call(PropertyGet(O, "codePointAt"), [0]). So whatever tags local
118611 as a child_process Instance, it is not that route.
Note there is also an id-keyed twin of that table
(local_id_native_instances: HashMap<LocalId, …> in
js_transform/cross_module_natives.rs), which I did not investigate.
The way to settle it is a diagnostic at the decision site — which local, which
binding it resolved through, and what evidence it used — not another
hypothesis.
Both halves of this call are already known to be expensive
cc runs correctly, so this dispatch must be falling through to a generic path
on a string receiver — once per grapheme, in the dominant loop. That
fall-through has a cost independent of anything else here.
#9795 ("perf(runtime): dispatch String.prototype.codePointAt natively —
99k String wrappers per reply", open) is the runtime half of this same call.
This issue is the codegen half: the call does not even arrive at the runtime
labelled as a string method. Whoever picks up the resolver should know both
exist.
Why this was found, and the constraint it puts on any fix
Found by a compile-time counter for the segment-view for-of tier (#9843), whose
per-use classifier reconciles against a sound collect_local_refs_stmt count
and books every unrecognised occurrence as "must materialise". It reported
code_point_at=0, materialise=1 for this loop, which is what pointed at the
node.
A fix must not be "teach the classifier to match
NativeMethodCall{module:"child_process", method:"codePointAt"}". That would
launder a misclassification into a fast path. The lowering is the thing to fix.
Activity
proggeramlug commentedon Sep 6, 2026
Mechanism found, with a minimal reproducer and a one-rename A/B
The native-instance tag is keyed by identifier NAME and scoped to the whole
module. A
child_processhandle namedOin one function makes every otherOin the module achild_process::Instance— including afor…ofdestructuring binding holding a grapheme string in an unrelated function.
The reproducer — two files differing by ONE identifier rename
perry compile --no-auto-optimize <file>.ts --print-hir, and look atwidthLike:O.codePointAt(0)inwidthLikelowers tonotOCall(PropertyGet(LocalGet(O), "codePointAt"), [0])— correctONativeMethodCall { module: "child_process", class_name: Some("Instance"), object: Some(LocalGet(O)), method: "codePointAt" }Nothing else differs. The spawner is never called from the loop and the two
bindings are in different functions with different scopes.
Where it comes from
lower/expr_assign.rs:186-205— for a plain assignmentX = <native module>.<method>(...):push_module_native_instancekeys onvar_name— the identifier text — and theregistration is module-wide. The catch-all arm means any method on any
native module, assigned to a variable, tags that name.
Note the shape matters: it is the assignment path (
let O; … O = cp.spawn(…)),not the declaration path (
let O = cp.spawn(…)), and it requires the module tobe a recognised native import — a CJS
require()in a.jsfile does notcreate the tag, which is why two earlier attempts at this reproducer came back
negative.
Blast radius: BROAD, not one local
cli_2.1.112.jscompiles as one module (Found 1 module(s)), it containslet O; try { O = fA1.spawn(z.file, z.args, z.options) }, and the identifierOis a binding 5,381 times in that file. So every one of those is taggedchild_process::Instancefor the whole 13 MB program. Minified bundles reusesingle letters everywhere, so any name that is ever a native handle poisons all
its homonyms.
This is consistent with the segment-view counter reporting
code_point_at=0onall nine
for…of-over-.segment()sites in the bundle, not justN$6,though I have not confirmed each of those nine individually.
Correctness
cc runs and renders correctly, so the mislowered dispatch is falling through to
a generic path at runtime rather than calling a wrong function — this looks like
a missed-optimisation and mis-typing defect, not a wrong-answer defect. I have
not verified that, and "it currently degrades gracefully" is not the same as
"it is safe": a
child_process::Instancemethod table that ever gains acodePointAt,lengthortestentry would silently capture string calls.proggeramlug commentedon Sep 6, 2026
Blast radius, measured: it is not just
O— the eight commonest minified identifiers are all taggedIndependent confirmation of the mechanism from the registry side, and it is
worse than the A/B above suggested. Instrumenting the two entry points through
which any native-instance tag must pass (
register_native_instanceandpush_module_native_instanceinlower/context.rs) and compilingcli_2.1.112.js:Every one of them is a single letter, i.e. exactly what a minifier emits,
and several are registered under mutually exclusive classes.
Oalone:and
child_process::Instanceby itself is registered for the namesO,w,z,Y,_,K.So in a 13 MB bundle that compiles as one module, the tag table is keyed by
identifier text with module-wide scope, and the commonest short identifiers are
each claimed by several unrelated native classes. Every method call on any local
with one of those names is then lowered as a native-instance method call of
whichever class won.
That is the same defect as the reproducer above, but it means the segment loop
is an example rather than the case: the mis-typing is spread across the whole
program, and
codePointAton a grapheme is simply where a compile-time counterhappened to notice it.
Reproducing the census:
PERRY_NATIVEINST_DIAG=1(a diagnostic on those twoentry points, excluded from the build-level cache) plus
perry compile --no-auto-optimize <bundle> -o /tmp/out; the report printsduring HIR lowering, ~30 s, no LLVM needed. I can open that instrumentation as
a PR if it is wanted as a permanent diagnostic; it is currently a local commit
on a lane branch.
One caveat on the numbers:
registerlines include entries with an emptymodule::class, which I have not investigated — they are probably thetombstone/shadow path (
shadow_native_instance). Thepush_modulecount (104)and the class-bearing lines are the unambiguous part.
diag(hir): report every native-instance tag at the two entry points t…
meas(gc): round 2 -- per-removal tail histogram, the big family by ke…
fix(hir): scope a bare-assignment native-instance tag to the binding (#…
proggeramlug commentedon Sep 6, 2026
Fixed by #9857, landed on
mainvia merge train #9883. The train merged as its own branch, so the close-keyword never fired — closing manually, verified on main.