Skip to content

perf: class getters/setters are 430× (setter) to ~1,300× (getter) slower than Node (no accessor inline cache; every store runs the full OrdinarySet walk) #10498

Description

@proggeramlug

Found by the package performance audit (real npm packages compiled from source, profiled against Node 26.5.1) and
re-measured on Perry 7661bc0 (v0.5.1589), Linux x64. get x() / set x(v) declared on a class are resolved from
scratch on every access: a getter read costs ≈ 3,500 instructions and a setter store ≈ 19,000 (a spec-level
OrdinarySet walk that re-derives the receiver's prototype through o.constructor.prototype by name), against
≈ 60–120 for the same read/write of a plain field.

Reproduction

bench.ts (19 lines):

// Class prototype accessors (rate-limiter-flexible RateLimiterRes shape) vs plain fields
const variant = process.argv[2] || "setter_ctor"; const N = Number(process.argv[3] || "1000000");
const ring: any[] = new Array(1024).fill(null);
class ResS { _points = 0; _ms = 0; constructor(a: number, b: number) { this.points = a; this.ms = b; }
  get points() { return this._points; } set points(p: number) { this._points = p; }
  get ms() { return this._ms; } set ms(v: number) { this._ms = v; } }
class ResF { _points = 0; _ms = 0; constructor(a: number, b: number) { this._points = a; this._ms = b; }
  get points() { return this._points; } get ms() { return this._ms; } }
const fixed = new ResF(3, 4); const fixedS = new ResS(3, 4);
const V: Record<string, (n: number) => number> = {
  setter_ctor(n) { let s = 0; for (let i = 0; i < n; i++) { const r = new ResS(i & 7, i & 1023); ring[i & 1023] = r; s += r._points + r._ms; } return s; },
  field_ctor(n) { let s = 0; for (let i = 0; i < n; i++) { const r = new ResF(i & 7, i & 1023); ring[i & 1023] = r; s += r._points + r._ms; } return s; },
  getter_read2(n) { let s = 0; for (let i = 0; i < n; i++) { const r: any = fixed; s += r.points + r.ms; } return s; },
  field_read2(n) { let s = 0; for (let i = 0; i < n; i++) { const r: any = fixed; s += r._points + r._ms; } return s; },
  setter_write2(n) { let s = 0; for (let i = 0; i < n; i++) { const r: any = fixedS; r.points = i & 7; r.ms = i & 3; s += r._points; } return s; },
  field_write2(n) { let s = 0; for (let i = 0; i < n; i++) { const r: any = fixedS; r._points = i & 7; r._ms = i & 3; s += r._points; } return s; },
};
V[variant](N / 5 | 0); const t0 = performance.now(); const cs = V[variant](N);
console.log(`variant=${variant} checksum=${cs} ms=${(performance.now() - t0).toFixed(2)}`);
PERRY_NO_AUTO_OPTIMIZE=1 perry compile bench.ts -o bench
for v in field_ctor setter_ctor field_read2 getter_read2 field_write2 setter_write2; do node bench.ts $v 1000000; ./bench $v 1000000; done

Measurements

Median of 3, shared host (loaded; instruction counts are the load-independent figure). N = 1,000,000; Perry
instructions per iteration = (whole-process instructions:u − 38 M startup) / 1.2 M.

variant Node loop ms Perry loop ms ratio Perry instructions (per iter) Node wall ms Perry wall ms
field_ctor (control: new + 2 field stores) 15.2 506.6 33× 6.42 G (5,320) 174 651
setter_ctor (new + 2 setter stores in the constructor) 15.3 6,635.2 434× 53.24 G (44,330) 238 8,009
field_read2 (control) 4.0 9.4 2.3× 0.18 G (120) 155 53
getter_read2 (2 getter reads) 0.7 849.2 ~1,300× 8.62 G (7,150) 130 1,063
field_write2 (control) 1.2 18.7 16× 0.32 G (230) 118 64
setter_write2 (2 setter stores) 1.0 5,732.0 ~5,600× 45.94 G (38,250) 145 6,912

Checksums identical. Per access: setter store ≈ 19,000 instructions, getter read ≈ 3,500. (The field_ctor control is
itself 33× Node — construction cost, outside this issue.)

Impact

  • rate-limiter-flexible 11.2.0 (RateLimiterMemory consume/get/delete): ≈ 55–60 % of Perry CPU (perf-large report,
    v0.5.1587). RateLimiterRes, Record and RateLimiterAbstract expose their state through get/set pairs
    (get remainingPoints(){…} set remainingPoints(p){…}), and the RateLimiterRes constructor assigns through the
    setters; js_put_value_set* was 41 % inclusive, ≈ 29 % of it under that constructor.
  • Any class-heavy package using accessor pairs over _fields pays the same per access.

Mechanism

  • Store: this.points = a / r.points = v lowers to the static write PIC; the PIC primes only an ordinary own-data
    overwrite (crates/perry-runtime/src/proxy/put_value.rs:365-372, prime at put_value.rs:511-512) (verified), and the
    key points is never an own data property, so every store is a miss into js_put_value_set →
    ordinary_set_with_receiver (crates/perry-runtime/src/proxy.rs:1754). Profile of setter_write2 (verified):
    ordinary_set_with_receiver 91 % → prototype_of_for_set (proxy.rs:1467) 45 % → js_object_get_prototype_of
    40 % → constructor_dynamic_prototype 18 % (reads constructor, then prototype, by name — see perf: missing-property reads on functions are ~2,600× and Object.hasOwn/getPrototypeOf 40–90× slower than Node (Function.prototype re-resolved by name per call) #10497);
    own_set_descriptor (proxy.rs:1346) 20 %; create_or_update_receiver_property (proxy.rs:1679) 23 %;
    get_accessor_descriptor 8 %; key_to_rust_string 4 %.
  • get_accessor_descriptor (crates/perry-runtime/src/object/descriptor_state.rs:1276) builds a heap String for every
    lookup — .get(&(obj, key.to_string())) (descriptor_state.rs:1285) — and SipHashes it (verified).
  • Read: r.points misses the read PIC (not an own key, see perf: reads that resolve on the prototype chain (absent keys, inherited data) are 590–2,100× slower than Node (read IC caches own properties only) #10495), and get_field_by_name_object_tail finds the
    getter by name in the class vtable's getters: HashMap<String, usize>
    (crates/perry-runtime/src/object/class_registry/state.rs:327, lookup at
    crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs:1305) after the usual probe ladder
    (verified; profile: get_field_ic_miss_impl 93 %, hash_one::<&str> + HashMap<String,usize>::get ≈ 6 % self).
    Setters live in the parallel setters map (state.rs:328).

What fast looks like

An accessor-aware property IC: on a miss that resolves to a class-prototype getter/setter whose holder is the
receiver's class prototype (no own shadowing key), prime (receiver shape → getter/setter function pointer) guarded
like the method ICs, so the hit is a shape compare plus a direct call of the accessor body. Targets:
getter_read2 within 3× of field_read2 (≤ ~400 instructions/iteration, from 7,150); setter_write2 within 3× of
field_write2 (≤ ~700, from 38,250); setter_ctor within 2× of field_ctor. Independently, get_accessor_descriptor
should look up without allocating a String per call.

Notes

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
    on Sep 17, 2026
  2. proggeramlug commented on Sep 27, 2026

    @proggeramlug
    ContributorAuthor

    Package-level measurement from the Phase 3 attribution (#11464, benchmarks/packages/PROFILE.md): origin/main 36420d2, release compiler, auto-optimize, Linux x86-64, perf record -e instructions:u --call-graph dwarf at two N (per-iteration, startup cancels), Node 26.5.1 oracle with outputs checked equal. "% of excess" = share of (Perry − Node) instructions/iter, equal-weight over the 23 packages with Perry/Node ≥ 2×.

    cron (160× Node, 474M instr/iter) is dominated by luxon class getters. 48% of its instructions enter through js_object_get_field_by_property_id_f64 from DateTime.isValid (luxon.js:6287 return this.invalid === null, 9.3%), Duration.isValid (5.1%) and DateTime.zone (this._zone, 4.7%), with leaf native_get::try_data_get_bytes.

    A package-free repro of luxon's DateTime shape (the same constructor, a getter plus toMillis() { return this.isValid ? this.ts : NaN }, 64 instances read through an any[]) costs ~985 instr/iter vs Node 36–58, in ESM TS, with 60 extra getters, and as a CommonJS package. It lands in get_field_ic_miss_impl → class_accessor_cache::lookup + inherited_read_cache_lookup, which is this issue's getter-dispatch cost. A plain o.k class getter probe costs 484 instr/access. The in-body this.<field> by-bytes read that luxon shows did not reproduce in isolation. It probably depends on luxon's receiver provenance, and it is left open here rather than filed separately.

  3. proggeramlug commented on Sep 30, 2026

    @proggeramlug
    ContributorAuthor

    Current target (2026-09-30): getter read ≈ 600 instr and setter store ≈ 410 instr per access, vs a plain field ≈ 30–40

    Package impact (#11464)

    Bucket row 10, accessor / property-descriptor handling, is 3.3% of equal-weight excess. It is ≥5% in 6 packages. Share of each package's excess: commander 8, node-forge 7, decimal.js 7, date-fns 6, dayjs 6, axios 5.

    What the bucket actually contains, by entry → dominant leaf:

    workload entry → leaf instr/iter % of excess
    node-forge/hmac · aes_cbc · sha256 dispatcher → descriptor_state::get_accessor_descriptor (72–87% of the chain) 766k · 3.36M · 512k 8.9 · 8.4 · 6.6
    commander/parse_argv js_class_field_add → js_object_define_property (Command constructor) 209k 5.7
    date-fns/diff_interval js_date_apply_setter 32k 5.5
    decimal.js/arith_chain dispatcher → get_accessor_descriptor 217k 5.2
    dayjs/diff_startof dispatcher + js_put_value_set 375k 4.1
    big.js/arith_chain js_put_value_set → get_accessor_descriptor (big.mjs:393) 3.79M 1.8

    So most of row 10 is the address-keyed descriptor table being consulted on ordinary objects and method dispatch. That is #10871's mechanism. Class prototype getters, which are #10498's mechanism, show up in row 1 instead (property lookup): cron/luxon enters 48% of its instructions through getter reads (DateTime.isValid, zone); the evidence is posted on #10498.

    Build caveat: the node-forge binaries (2febf42) predate #11411. The other packages were profiled at 36420d2, which includes #11411 but predates #11416.

    What landed since the issue was filed

    Reproducer, fresh numbers (the issue's bench.ts without its warm-up pass, instructions per iteration, 2 accesses each)

    class ResS { _points = 0; _ms = 0; constructor(a: number, b: number) { this.points = a; this.ms = b; }
      get points() { return this._points; } set points(p: number) { this._points = p; }
      get ms() { return this._ms; } set ms(v: number) { this._ms = v; } }
    const fixedS = new ResS(3, 4);
    function getter_read2(n: number) { let s = 0; for (let i = 0; i < n; i++) { const r: any = fixed; s += r.points + r.ms; } return s; }
    function setter_write2(n: number) { let s = 0; for (let i = 0; i < n; i++) { const r: any = fixedS; r.points = i & 7; r.ms = i & 3; s += r._points; } return s; }
    variant Perry, 7661bc0 (issue) Perry, main Node Perry/Node
    field_read2 (control) 120 62 24 2.6×
    getter_read2 7,150 1,205 14 84×
    field_write2 (control) 230 81 35 2.3×
    setter_write2 38,250 829 20 41×
    field_ctor (control) 5,320 411 129 3.2×
    setter_ctor 44,330 1,118 128 8.7×

    (The issue's column used whole-process ÷ 1.2·N. Main's uses two-N and runs on a different host than #11416's table. getter_read2 here is 1,205, against 902 in #11416's table.)

    Acceptance target

    • Issue's own targets: getter_read2 ≤ ~400 (now 1,205), setter_write2 ≤ ~700 (now 829), and setter_ctor ≤ 2× field_ctor (now 2.7×).
    • No regression on cron (luxon getters), rate-limiter-flexible (get_penalty; consume still fails), date-fns and dayjs.
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

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsperformanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions