Skip to content

codegen: a string's .codePointAt in cc's hottest loop lowers to NativeMethodCall{module:"child_process", class_name:"Instance"} #9847

Description

@proggeramlug

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

  1. proggeramlug commented on Sep 6, 2026

    @proggeramlug
    ContributorAuthor

    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_process handle named O in one function makes every other
    O in the module a child_process::Instance — including a for…of
    destructuring binding holding a grapheme string in an unrelated function.

    The reproducer — two files differing by ONE identifier rename

    import * as cp from "child_process";
    
    export function unrelatedSpawner(): any {
      let O: any;                                          // <-- ARM B; ARM A renames this to notO
      try { O = cp.spawn("true", []); } catch (M) { O = null; }
      return O;
    }
    
    const rR_ = new Intl.Segmenter("en", { granularity: "grapheme" });
    const oR_: any = /^[̀-ͯ]$/u;
    const g54: any = { default: () => /[\u{1F300}-\u{1F5FF}]/gu };
    
    export function widthLike(q: string): number {
      let Y = 0;
      for (let { segment: O } of rR_.segment(q)) {         // the SAME name, different function
        let w = O.codePointAt(0)!;                         // <-- this is what gets mislowered
        if (oR_.test(O)) continue;
        if (g54.default().test(O)) { Y += 2; continue; }
        Y += w >= 0x1100 ? 2 : 1;
      }
      return Y;
    }

    perry compile --no-auto-optimize <file>.ts --print-hir, and look at widthLike:

    arm the spawner's variable O.codePointAt(0) in widthLike lowers to
    A notO Call(PropertyGet(LocalGet(O), "codePointAt"), [0]) — correct
    B O NativeMethodCall { 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 assignment X = <native module>.<method>(...):

    let class_name = match (module_name, method_ident.sym.as_ref()) {
        ("mongodb", "connect")        => Some("MongoClient"),
        ("pg", "connect")             => Some("Client"),
        ("readline", "createInterface") => Some("Interface"),
        _ => Some("Instance"),                       // catch-all
    };
    ctx.push_module_native_instance((var_name.clone(), module_name.to_string(), class_name.to_string()));

    push_module_native_instance keys on var_name — the identifier text — and the
    registration 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 to
    be a recognised native import — a CJS require() in a .js file does not
    create the tag, which is why two earlier attempts at this reproducer came back
    negative.

    Blast radius: BROAD, not one local

    cli_2.1.112.js compiles as one module (Found 1 module(s)), it contains
    let O; try { O = fA1.spawn(z.file, z.args, z.options) }, and the identifier
    O is a binding 5,381 times in that file. So every one of those is tagged
    child_process::Instance for the whole 13 MB program. Minified bundles reuse
    single 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=0 on
    all nine for…of-over-.segment() sites in the bundle, not just N$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::Instance method table that ever gains a
    codePointAt, length or test entry would silently capture string calls.

  2. proggeramlug commented on Sep 6, 2026

    @proggeramlug
    ContributorAuthor

    Blast radius, measured: it is not just O — the eight commonest minified identifiers are all tagged

    Independent 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_instance and
    push_module_native_instance in lower/context.rs) and compiling
    cli_2.1.112.js:

    total REGISTER lines: 795          (104 push_module, 691 register)
    
    most-registered identifiers:
       71  "Y"        54  "A"
       65  "z"        52  "O"
       65  "K"        37  "w"
       65  "_"        35  "q"
    

    Every one of them is a single letter, i.e. exactly what a minifier emits,
    and several are registered under mutually exclusive classes. O alone:

    [nativeinst] REGISTER push_module name="O" -> stream::Instance
    [nativeinst] REGISTER push_module name="O" -> child_process::Instance
    [nativeinst] REGISTER register    name="O" -> transform_stream::TransformStream
    [nativeinst] REGISTER register    name="O" -> readable_stream::ReadableStream
    

    and child_process::Instance by itself is registered for the names
    O, 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 codePointAt on a grapheme is simply where a compile-time counter
    happened to notice it.

    Reproducing the census: PERRY_NATIVEINST_DIAG=1 (a diagnostic on those two
    entry points, excluded from the build-level cache) plus
    perry compile --no-auto-optimize <bundle> -o /tmp/out; the report prints
    during 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: register lines include entries with an empty
    module::class, which I have not investigated — they are probably the
    tombstone/shadow path (shadow_native_instance). The push_module count (104)
    and the class-bearing lines are the unambiguous part.

  3. proggeramlug commented on Sep 6, 2026

    @proggeramlug
    ContributorAuthor

    Fixed by #9857, landed on main via merge train #9883. The train merged as its own branch, so the close-keyword never fired — closing manually, verified on main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions