Repository navigation
perf: private class members #x/#m() are ~1,200× slower than Node, 130× slower than public (marker-string format + by-name brand lookup per access) #10501
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. Each #field read/write and each #method() call runs
js_private_guard, which builds "#<perry:private-field:{class_id}:{name}>" with format! and looks it up by name in
the receiver's own keys — twice (brand check, then "initialized" check) — before the value itself is read or written
under another string key: ≈ 7,500 instructions per private operation, against ≈ 60 for a public one.
Reproduction
bench.ts (15 lines):
// lru-cache style private members vs identical public members
const variant = process.argv[2] || "private"; const N = Number(process.argv[3] || "1000000");
class Priv { #n = 0; #arr: number[] = [1, 2, 3]; #bump(x: number) { this.#n = this.#n + x; } step(i: number) { this.#bump(this.#arr[i % 3]); return this.#n; } }
class Pub { n = 0; arr: number[] = [1, 2, 3]; bump(x: number) { this.n = this.n + x; } step(i: number) { this.bump(this.arr[i % 3]); return this.n; } }
class PrivField { #n = 0; step(i: number) { this.#n = (this.#n + i) | 0; return this.#n; } }
class PrivMethod { n = 0; #bump(x: number) { this.n = (this.n + x) | 0; } step(i: number) { this.#bump(i); return this.n; } }
const p = new Priv(), q = new Pub(), pf = new PrivField(), pm = new PrivMethod();
const V: Record<string, (n: number) => number> = {
private(n) { let a = 0; for (let i = 0; i < n; i++) a = p.step(i); return a; },
public(n) { let a = 0; for (let i = 0; i < n; i++) a = q.step(i); return a; },
private_field_rw(n) { let a = 0; for (let i = 0; i < n; i++) a = pf.step(i); return a; },
private_method_call(n) { let a = 0; for (let i = 0; i < n; i++) a = pm.step(i); return a; },
};
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 public private private_field_rw private_method_call; 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 |
|---|---|---|---|---|---|---|
public (control: same class with public members) |
2.6 | 23.4 | 9× | 0.32 G (230) | 132 | 65 |
private (this.#bump(this.#arr[i % 3]) + this.#n read/write) |
3.4 | 4,268.7 | ~1,270× | 35.95 G (29,930) | 131 | 5,163 |
private_field_rw (one #n read + one write) |
1.7 | 2,057.1 | ~1,200× | 18.53 G (15,410) | 120 | 2,510 |
private_method_call (one this.#bump(i) call) |
1.1 | 1,949.3 | ~1,800× | 16.65 G (13,840) | 132 | 2,421 |
Checksums identical. private vs public on identical logic: 130× more instructions.
Impact
- lru-cache 11.5.2 (set/get/has churn, 10k entries):
js_private_guard48.1 % of Perry CPU; with the
private_member_get_by_namereads and bound private-method trampolines the private-member cost is ≈ 53 % of the
workload, which ran 141–200× Node overall (objects-group report, v0.5.1587). lru-cache is written with ~20 private
fields (#keyMap,#valList,#next,#prev,#head,#tail,#size, …) and private methods (#indexes(),
#isStale(),#moveToTail(), …) on everyget/set/has. - Any modern class-based package using
#privatestate (hono and many ESM-first libraries) pays the same per access.
Mechanism
js_private_guard(crates/perry-runtime/src/object/field_get_set/ic_miss.rs:1589) is called for every private
read, write and call (verified). For instance members it runsprivate_instance_element_is_present
(ic_miss.rs:1419) up to twice — as the brand fallback (ic_miss.rs:1616) and as the "before it has been
initialized" check (ic_miss.rs:1632) (verified).private_instance_element_is_presentbuilds the marker withprivate_field_marker_key→
format!("#<perry:private-field:{declaring_class_id}:{field_name}>")(ic_miss.rs:1393-1411, after afrom_utf8
of the name) and looks it up withjs_object_get_own_field_or_undef(a by-name own-key scan) (verified).- For private methods/accessors the guard additionally
.to_string()s the name and records a
PRIVATE_MEMBER_ACCESS_HINTSentry (ic_miss.rs:1654-1679) (verified). - The value lives under a second string key,
format!("#<perry:private-value:{declaring_class_id}:{field_name}>")
(js_private_field_add,ic_miss.rs:1508), so the read/write itself is another by-name lookup
(private_member_get_by_name,crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs:120)
or a fulljs_put_value_set(verified). - Profile of
private_field_rw(verified):js_private_guard44 % inclusive,private_instance_element_is_present
33 %,js_put_value_set38 % (the write),core::fmt::write+format_inner+String::write_str+
pad_integral+u32::fmt≈ 16 % self,js_object_get_own_field_or_undef7 % self.private_method_call:
js_private_guard28 %,js_object_get_field_by_name27 %,js_closure_call119 %,js_class_method_bindand
intern_class_method_namein self time.
What fast looks like
Private names are statically resolved per class, so they can be ordinary slots: give each private field a fixed slot
in the declaring class's layout, prove the brand once per receiver shape (a shape bit or class-id chain check that an
inline cache can guard), and call private methods directly (they cannot be overridden or shadowed). Targets:
private within 2× of public (≤ ~500 instructions/iteration, from 29,930); private_field_rw ≤ 200;
private_method_call ≤ 300. As an interim step, the guard should not allocate: no format!/to_string per access.
Notes
- The brand check is correctness-relevant (
TypeErroron foreign receivers, "initialize twice"); the target is to prove
it once per shape, not to drop it. - Private-member correctness history: new Hono() throws 'Cannot initialize private elements twice on the same object' at runtime #8962 (closed; hono "Cannot initialize private elements twice").
- Share is from the v0.5.1587 audit profile; ratios and instruction counts are re-measured on 7661bc0.
Activity
perf(runtime, codegen): allocation-free private member access (#10501)
perf(runtime, codegen): allocation-free private member access (#10501)
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×.Private members are 26% of lru-cache's excess (churn is 252× Node, ttl_mixed 64×) and 31% of redis's (pipeline 61×, set_get 31×).
index.min.js, mapped through its source map):#set(index.ts:2180) →js_object_get_field_by_property_id_f64→private_evaluation_brand/private_member_get_by_name/private_value_requestis 9.9% of churn;#evict5.3%;#deleteand#isBackgroundFetchin ttl_mixed.BasicCommandParserconstructorthis.#keyPrefix = …viajs_private_field_add;SinglyLinkedListlength/shiftandDoublyLinkedList.pushreads viatake_private_field_owner; RESPDecoder.#decodeNestedTypechunk[this.#cursor]viaprivate_guard_checked(14.7% of redis/pipeline as an entry point).Call-shape probe:
this.#m(x)readingthis.#kcosts 934 instr/call vs Node 17–36, of whichjs_private_method_callis 75% andjs_set_call_location12%.proggeramlug commentedon Oct 1, 2026
Package matrix re-run (#11723, at 9e29f59): private members are now the largest single cost in two of the top offenders, and their cost went UP while everything around them got cheaper.
alloc::fmt::format_inner(string formatting) shows up when redis adds private fields.Profiles:
benchmarks/packages/profile/rerun-2026-09-30/. Acceptance: lru-cache and redis private-member cost below the 2026-09-26 baseline, with no regression inscripts/package_bench.py.proggeramlug commentedon Oct 3, 2026
Partial progress in #11793: private 7,540 → 1,844; private_field_rw 2,011 → 485; private_method_call 3,478 → 1,953 instructions/op. Follow-up #11791 makes the private brand a birth-shape fact. Leaving open.