Skip to content

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

@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. 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; 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
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_guard 48.1 % of Perry CPU; with the
    private_member_get_by_name reads 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 every get/set/has.
  • Any modern class-based package using #private state (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 runs private_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_present builds the marker with private_field_marker_key →
    format!("#<perry:private-field:{declaring_class_id}:{field_name}>") (ic_miss.rs:1393-1411, after a from_utf8
    of the name) and looks it up with js_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_HINTS entry (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 full js_put_value_set (verified).
  • Profile of private_field_rw (verified): js_private_guard 44 % inclusive, private_instance_element_is_present
    33 %, js_put_value_set 38 % (the write), core::fmt::write + format_inner + String::write_str +
    pad_integral + u32::fmt ≈ 16 % self, js_object_get_own_field_or_undef 7 % self. private_method_call:
    js_private_guard 28 %, js_object_get_field_by_name 27 %, js_closure_call1 19 %, js_class_method_bind and
    intern_class_method_name in 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

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×.

    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×).

    • lru-cache (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_request is 9.9% of churn; #evict 5.3%; #delete and #isBackgroundFetch in ttl_mixed.
    • @redis/client: BasicCommandParser constructor this.#keyPrefix = … via js_private_field_add; SinglyLinkedList length/shift and DoublyLinkedList.push reads via take_private_field_owner; RESP Decoder.#decodeNestedType chunk[this.#cursor] via private_guard_checked (14.7% of redis/pipeline as an entry point).

    Call-shape probe: this.#m(x) reading this.#k costs 934 instr/call vs Node 17–36, of which js_private_method_call is 75% and js_set_call_location 12%.

  3. proggeramlug commented on Oct 1, 2026

    @proggeramlug
    ContributorAuthor

    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.

    • lru-cache/churn and ttl_mixed: 143× Node on instructions, and about half is private-member access. Public property reads fell 57% since the baseline, but private-member cost rose 19–21%.
    • redis/pipeline: 52× Node, and 45% is private members. 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 in scripts/package_bench.py.
  4. proggeramlug commented on Oct 3, 2026

    @proggeramlug
    ContributorAuthor

    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.

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