Skip to content

perf: this.a inside an object-literal method costs ~1,085 instructions per call (read path misses every cache) #11420

Description

@proggeramlug

Reading this.a inside an object-literal method costs about 1,085 instructions per call on the current perf branches. The same read costs about 180 in an ES5 prototype method, and about 200 when the receiver is passed to a helper as a parameter.

const o: any = { a: 1, m(this: any) { return this.a; } };
let s = 0;
for (let i = 0; i < 1_000_000; i++) s += o.m();
console.log(s);

The cost is in the read path, not in binding this: every lookup cache misses on this route. #11326 bound the literal method's this once for the object-literal fast path, but this call shape still reaches a route whose property read never primes an inline cache.

Found by the this-as-parameter census, measured on the perf-method-calls base, instructions per call from the 1M/5M slope. Wanted: the read should take the ordinary property IC, at the same cost as a class method's this.a.

Activity

  1. proggeramlug commented on Sep 26, 2026

    @proggeramlug
    ContributorAuthor

    Evidence from the package-benchmark attribution work (Phase 3): the harness's bare-loop control does not measure this issue's shape, and on current main it has no ~1,000-instruction floor at all.

    Setup: origin/main 36420d2 (perry 0.5.1654), release compiler, auto-optimize with PERRY_WORKSPACE_ROOT (per-program runtime rebuild), x86-64 (Zen 4), perf stat -e instructions:u, two-N per-iteration method (n = 2M and 8M; startup cancels). Node 26.5.1. Every output was checked equal to Node's.

    1. The current control (benchmarks/packages/control/bare_loop.ts on wip/pkg-bench-phase1 since 21c9f54, loop bounds hoisted) costs 25 instr/iter (Node: 56). No call survives: LLVM inlines op and mulFnv. The 25 are 3 ToInt32 range guards, 2 cvttsd2si, 3 int→double conversions, the arithmetic, and the loop's induction variable kept in both a double and an i32. There is no object-literal method and no this in it.

    2. The historical ~1,040–1,250 was a plain by-name data-property read (it.n in the loop condition), not a call.

    control variant Perry instr/iter Node
    original (first harness commit): Math.imul + i < it.n + header(…, it) 1,178 ~0
    same, Math.imul replaced by mulFnv 936 55
    i < it.n, it from imported iters(), no header(…, it) 69 55
    i < it.n, it a same-module literal passed to a same-module function 110 8
    current: bounds hoisted 25 56

    So about 870 instructions per iteration come from it.n when it is returned by an imported function and passed to an imported function (header). Chain (DWARF call graph): js_class_field_get_ic → js_typed_feedback_class_field_get_guard → class_field_fast_contract → shape_descriptor_by_id (the guard fails), then js_object_get_field_by_name_f64 → inherited_read_cache_lookup → get_field_by_name_past_inherited_cache → try_data_get_bytes → keys_find_slot_by_bytes_resolved / is_anon_shape_class_id. That is the receiver-provenance read family (#10769 / #10761), not an object-literal method. Math.imul added about 240 per call on top.

    3. This issue's own shape, measured the same way:

    const o = { k: 3, m(x: number): number { return (x * this.k + 1) % 1000003; } };
    for (let i = 0; i < N; i++) acc = o.m(acc + i);
    call shape (same body) Perry instr/call Node
    object-literal method + this.k (this issue) 4,267 24
    class method + this.k 51 41
    direct non-inlined function call 86 23
    closure held in a const 298 24
    f.call(o, x) 3,746 113
    function P(){}; P.prototype.m = …; o.m(x) 9,596 34
    private this.#m(x) reading this.#k 1,023 37
    class getter o.k 483 32

    In this microbenchmark the object-literal method costs about 4.3k per call. 76% of that is the dispatcher under js_typed_feedback_native_call_method_by_id (js_native_call_method, js_native_call_value, dispatch_handle, shape_descriptor_by_id). 16% is the this.k read through js_object_get_field_by_name_f64. So the dispatch costs more than the this.a read in this shape. Your ~1,085 may be the read alone; the call adds the rest here.

    Conclusion for the harness: the control is not representative of #11420, and after 21c9f54 it no longer carries any floor: 25 instr/iter, below Node. The Phase-1 interim per-iteration numbers at 2febf42 included about 0.9–1.2k instr/iter of harness overhead per iteration (it.n + Math.imul). That is negligible for the heavy workloads (≥50k/iter) but material for the light ones. The attribution report re-measures everything on the hoisted harness.

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