Repository navigation
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
Activity
- addedperformanceRuntime, compile-time, build-size, or memory performanceRuntime, compile-time, build-size, or memory performancepackage-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
on Sep 17, 2026 - added 6 commits that reference this issue
on Sep 25, 2026 Package-level measurement from the Phase 3 attribution (#11464,
benchmarks/packages/PROFILE.md):origin/main36420d2, release compiler, auto-optimize, Linux x86-64,perf record -e instructions:u --call-graph dwarfat 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_f64fromDateTime.isValid(luxon.js:6287return this.invalid === null, 9.3%),Duration.isValid(5.1%) andDateTime.zone(this._zone, 4.7%), with leafnative_get::try_data_get_bytes.A package-free repro of luxon's
DateTimeshape (the same constructor, a getter plustoMillis() { return this.isValid ? this.ts : NaN }, 64 instances read through anany[]) costs ~985 instr/iter vs Node 36–58, in ESM TS, with 60 extra getters, and as a CommonJS package. It lands inget_field_ic_miss_impl→class_accessor_cache::lookup+inherited_read_cache_lookup, which is this issue's getter-dispatch cost. A plaino.kclass getter probe costs 484 instr/access. The in-bodythis.<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.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(Commandconstructor)209k 5.7 date-fns/diff_interval js_date_apply_setter32k 5.5 decimal.js/arith_chain dispatcher → get_accessor_descriptor217k 5.2 dayjs/diff_startof dispatcher + js_put_value_set375k 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
- perf(runtime): cache class getter/setter resolution per receiver shape (#10498) #11348 (09-26) added a per-shape accessor cache.
- perf(runtime): class accessors are real prototype properties; #11348's accessor side table is removed #11416 (merged 09-27) replaced it: class accessors are real accessor properties of the prototype, served through the inherited-access table.
ClassVTable.getters/settersandclass_accessor_cache.rsare gone. Its PR reports getter_read2 902, setter_write2 643 and setter_ctor 1,028. It names the follow-up: accessor entries in the compiled site memo (shape compare, validity compare, direct call), targeting ≤ 400 read and ≤ 700 write. That follow-up has not landed.
Reproducer, fresh numbers (the issue's
bench.tswithout 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_read27,150 1,205 14 84× field_write2(control)230 81 35 2.3× setter_write238,250 829 20 41× field_ctor(control)5,320 411 129 3.2× setter_ctor44,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_read2here 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), andsetter_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.
- added a commit that references this issue
on Oct 3, 2026
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 fromscratch 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.prototypeby name), against≈ 60–120 for the same read/write of a plain field.
Reproduction
bench.ts(19 lines):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.field_ctor(control:new+ 2 field stores)setter_ctor(new+ 2 setter stores in the constructor)field_read2(control)getter_read2(2 getter reads)field_write2(control)setter_write2(2 setter stores)Checksums identical. Per access: setter store ≈ 19,000 instructions, getter read ≈ 3,500. (The
field_ctorcontrol isitself 33× Node — construction cost, outside this issue.)
Impact
RateLimiterMemoryconsume/get/delete): ≈ 55–60 % of Perry CPU (perf-large report,v0.5.1587).
RateLimiterRes,RecordandRateLimiterAbstractexpose their state throughget/setpairs(
get remainingPoints(){…} set remainingPoints(p){…}), and theRateLimiterResconstructor assigns through thesetters;
js_put_value_set*was 41 % inclusive, ≈ 29 % of it under that constructor._fieldspays the same per access.Mechanism
this.points = a/r.points = vlowers to the static write PIC; the PIC primes only an ordinary own-dataoverwrite (
crates/perry-runtime/src/proxy/put_value.rs:365-372, prime atput_value.rs:511-512) (verified), and thekey
pointsis never an own data property, so every store is a miss intojs_put_value_set→ordinary_set_with_receiver(crates/perry-runtime/src/proxy.rs:1754). Profile ofsetter_write2(verified):ordinary_set_with_receiver91 % →prototype_of_for_set(proxy.rs:1467) 45 % →js_object_get_prototype_of40 % →
constructor_dynamic_prototype18 % (readsconstructor, thenprototype, by name — see perf: missing-property reads on functions are ~2,600× andObject.hasOwn/getPrototypeOf40–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_descriptor8 %;key_to_rust_string4 %.get_accessor_descriptor(crates/perry-runtime/src/object/descriptor_state.rs:1276) builds a heapStringfor everylookup —
.get(&(obj, key.to_string()))(descriptor_state.rs:1285) — and SipHashes it (verified).r.pointsmisses 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), andget_field_by_name_object_tailfinds thegetter by name in the class vtable's
getters: HashMap<String, usize>(
crates/perry-runtime/src/object/class_registry/state.rs:327, lookup atcrates/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_impl93 %,hash_one::<&str>+HashMap<String,usize>::get≈ 6 % self).Setters live in the parallel
settersmap (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)guardedlike the method ICs, so the hit is a shape compare plus a direct call of the accessor body. Targets:
getter_read2within 3× offield_read2(≤ ~400 instructions/iteration, from 7,150);setter_write2within 3× offield_write2(≤ ~700, from 38,250);setter_ctorwithin 2× offield_ctor. Independently,get_accessor_descriptorshould look up without allocating a
Stringper call.Notes
Object.definePropertypessimised every later store on that object — per-objectdescriptors; this issue is class-prototype accessors, which never had a cached path). Related open correctness issue:
Attributes-only
Object.defineProperty/definePropertieson a class accessor makes it read-only: the setter is lost and the attributes are not applied #10480 (attributes-onlydefinePropertyon a class accessor loses the setter).module.exports = class RateLimiterRes {…}is also a class expression, so rate-limiter-flexible additionally paysperf:
newon a class expression is 159× slower than Node vs 7× for the identical declaration (constructorthis.x =fields inferred only for declarations) #10499.