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
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; doneMeasurements
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 (
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 the
setters;js_put_value_set*was 41 % inclusive, ≈ 29 % of it under that constructor. - Any class-heavy package using accessor pairs over
_fieldspays the same per access.
Mechanism
- Store:
this.points = a/r.points = vlowers 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 atput_value.rs:511-512) (verified), and the
keypointsis 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_of
40 % →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 every
lookup —.get(&(obj, key.to_string()))(descriptor_state.rs:1285) — and SipHashes it (verified).- Read:
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 the
getter by name in the class vtable'sgetters: 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_impl93 %,hash_one::<&str>+HashMap<String,usize>::get≈ 6 % self).
Setters live in the parallelsettersmap (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
- Related, closed: perf: one Object.defineProperty sends every later store on that object to the slow path (zod v4 schemas 29× bun; top OpenCode startup cost) #10287 (one
Object.definePropertypessimised every later store on that object — per-object
descriptors; this issue is class-prototype accessors, which never had a cached path). Related open correctness issue:
Attributes-onlyObject.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 pays
perf:newon a class expression is 159× slower than Node vs 7× for the identical declaration (constructorthis.x =fields inferred only for declarations) #10499.- Share is from the v0.5.1587 audit profile; ratios and instruction counts are re-measured on 7661bc0.
Activity
Merge remote-tracking branch 'origin/main' into claude/lucid-goldberg…
perf(runtime): cache class getter/setter resolution per receiver shape (
Merge remote-tracking branch 'origin/main' into claude/lucid-goldberg…
perf(runtime): cache class getter/setter resolution per receiver shape (
perf(runtime): cache class getter/setter resolution per receiver shape (
perf(runtime): inherited class accessors are served from the inherite…
proggeramlug commentedon Sep 27, 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.proggeramlug commentedon Sep 30, 2026
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:
descriptor_state::get_accessor_descriptor(72–87% of the chain)js_class_field_add→js_object_define_property(Commandconstructor)js_date_apply_setterget_accessor_descriptorjs_put_value_setjs_put_value_set→get_accessor_descriptor(big.mjs:393)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
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)field_read2(control)getter_read2field_write2(control)setter_write2field_ctor(control)setter_ctor(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
getter_read2≤ ~400 (now 1,205),setter_write2≤ ~700 (now 829), andsetter_ctor≤ 2×field_ctor(now 2.7×).perf(runtime,codegen): class accessor sites on shape facts (#10498)