Skip to content

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

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. Several hot object helpers look up an intrinsic prototype by
NAME on every call — builtin_prototype_value("Function") = a globalThis.Function lookup plus a
closure_get_dynamic_prop("prototype") — so axios's isBuffer(val) (val.constructor.isBuffer) costs ~38,000
instructions, Object.hasOwn(o, k) ~4,500 and Object.getPrototypeOf(o) ~8,000, where Node spends a few
nanoseconds.

Reproduction

bench.ts (22 lines):

// Reads on function values and Object.prototype helpers (axios utils: isBuffer, hasOwnProperty.call, isPlainObject)
const variant = process.argv[2] || "ctor_isBuffer"; const N = Number(process.argv[3] || "1000000");
const { hasOwnProperty } = Object.prototype; const { getPrototypeOf } = Object;
const objs: any[] = [{ a: 1, b: 2 }, { url: "/x", method: "get" }, { headers: { x: 1 } }, { a: 3 }];
const mono: any[] = [{ a: 1, b: 2 }, { a: 2, b: 3 }, { a: 3, b: 4 }, { a: 4, b: 5 }];
function plainFn() { return 1; }
const fns: any[] = [plainFn, function other() { return 2; }, plainFn, plainFn];
function isBuffer(val: any) { return val !== null && val !== undefined && val.constructor !== null && val.constructor !== undefined && typeof val.constructor.isBuffer === "function" && val.constructor.isBuffer(val); }
const V: Record<string, (n: number) => number> = {
  own_read_mono(n) { let s = 0; for (let i = 0; i < n; i++) s += mono[i & 3].a === 1 ? 1 : 0; return s; },
  ctor_eq_Object_mono(n) { let s = 0; for (let i = 0; i < n; i++) s += mono[i & 3].constructor === Object ? 1 : 0; return s; },
  ctor_eq_Object(n) { let s = 0; for (let i = 0; i < n; i++) s += objs[i & 3].constructor === Object ? 1 : 0; return s; },
  ctor_isBuffer(n) { let s = 0; for (let i = 0; i < n; i++) s += objs[i & 3].constructor.isBuffer === undefined ? 1 : 0; return s; },
  fn_missing_prop(n) { let s = 0; for (let i = 0; i < n; i++) s += fns[i & 3].isBuffer === undefined ? 1 : 0; return s; },
  axios_isBuffer(n) { let s = 0; for (let i = 0; i < n; i++) s += isBuffer(objs[i & 3]) ? 0 : 1; return s; },
  hasOwn_call(n) { let s = 0; for (let i = 0; i < n; i++) s += hasOwnProperty.call(objs[i & 3], "a") ? 1 : 0; return s; },
  hasOwn_static(n) { let s = 0; for (let i = 0; i < n; i++) s += Object.hasOwn(objs[i & 3], "a") ? 1 : 0; return s; },
  in_operator(n) { let s = 0; for (let i = 0; i < n; i++) s += ("a" in objs[i & 3]) ? 1 : 0; return s; },
  getProto_eq(n) { let s = 0; for (let i = 0; i < n; i++) s += getPrototypeOf(objs[i & 3]) === Object.prototype ? 1 : 0; 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 own_read_mono ctor_eq_Object_mono fn_missing_prop ctor_isBuffer axios_isBuffer in_operator hasOwn_static hasOwn_call getProto_eq; 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
own_read_mono (control) 1.9 11.2 5.7× 0.19 G (125) 122 50
ctor_eq_Object_mono (o.constructor === Object) 1.3 608.7 460× 6.06 G (5,020) 173 787
fn_missing_prop (fn.isBuffer on a plain function) 1.3 3,372.5 ~2,600× 32.22 G (26,820) 168 4,161
ctor_isBuffer (o.constructor.isBuffer) 2.7 3,953.3 1,475× 37.23 G (30,990) 131 4,937
axios_isBuffer (axios utils.isBuffer) 3.6 4,834.4 1,340× 46.18 G (38,450) 143 5,944
in_operator ("a" in o, reference) 2.4 436.7 180× 3.61 G (2,980) 129 586
hasOwn_static (Object.hasOwn(o, "a")) 14.8 578.9 39× 5.42 G (4,480) 173 746
hasOwn_call (hasOwnProperty.call(o, "a")) 15.2 1,412.0 93× 10.43 G (8,660) 154 1,751
getProto_eq (getPrototypeOf(o) === Object.prototype) 23.5 1,116.5 48× 9.59 G (7,960) 156 1,369

Checksums identical. hasOwn_static does an own-key check on ordinary objects, yet 78 % of its time is spent proving
the receiver is not %Function.prototype%.

Impact

Audit profiles, v0.5.1587:

  • axios 1.19.0 (2,000 GETs against a local server): ≈ 20 % of Perry CPU — property reads on function values through
    the Function.prototype fallback 9.5 %, hasOwnProperty.call 4.7 %, isBuffer's val.constructor 5.6 %
    (io-group report). Hot upstream helpers: utils.isBuffer, utils.forEach, hasOwnProperty wrapper,
    isPlainObject (getPrototypeOf(val) === Object.prototype), mergeConfig.
  • lodash calls hasOwnProperty.call(result, key) in groupBy/baseAssignValue and objectToString.call in its type
    checks (objects-group report; that dispatch cost is shared with the .call trampoline).

Mechanism

  • is_function_prototype_object_value (crates/perry-runtime/src/object/global_this/ctor_thunks.rs:179) calls
    builtin_prototype_value("Function") (ctor_thunks.rs:184), which does
    js_get_global_this_builtin_value("Function") + closure_get_dynamic_prop(ctor, "prototype") (ctor_thunks.rs:188-199)
    on every call (verified). Its doc comment (ctor_thunks.rs:176-178) says it is "only consulted on slow paths", but
    js_object_has_own (crates/perry-runtime/src/object/object_ops/has_own.rs:66) calls it for every ordinary receiver
    (has_own.rs:351) (verified). Profile of hasOwn_static: is_function_prototype_object_value 78.5 % inclusive,
    js_get_global_this_builtin_value 59 % (verified).
  • A missing property on a closure: get_field_ic_miss_impl → closure_get_dynamic_prop
    (crates/perry-runtime/src/closure/dynamic_props.rs:900) → function_prototype_fallback_target
    (dynamic_props.rs:1111, call site dynamic_props.rs:1062), which again runs builtin_prototype_value("Function")
    (dynamic_props.rs:1137), then walks Function.prototype → Object.prototype by name (verified). Profile of
    fn_missing_prop: closure_get_dynamic_prop 90 %, function_prototype_fallback_target 28 %,
    js_get_global_this_builtin_value 20 %, the rest the uncached prototype walk of 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.
  • Object.getPrototypeOf(o) on a plain object: js_object_get_prototype_of
    (crates/perry-runtime/src/object/object_ops/prototype.rs:188) reaches constructor_dynamic_prototype
    (crates/perry-runtime/src/object/prototype_helpers.rs:3, call sites prototype.rs:622 and prototype.rs:767), which reads o.constructor
    by name and then closure_get_dynamic_prop(ctor, "prototype") (verified). Profile: constructor_dynamic_prototype
    58 % of getProto_eq.
  • None of these reads is cached, so o.constructor.isBuffer pays the inherited constructor read, the Function.prototype
    re-resolution, and a full absent walk each time.

What fast looks like

Resolve %Function.prototype% (and %Object.prototype% for getPrototypeOf of an ordinary object) from a memoized,
GC-rooted intrinsic slot — the same treatment #7795 gave Object.prototype on the ordinary miss path — and let
getPrototypeOf of a plain object literal return the default prototype without a constructor round-trip. Targets:
hasOwn_static ≤ 300 instructions/iteration (within 3× of own_read_mono), getProto_eq ≤ 300, hasOwn_call ≤ 600;
with #10495's negative cache, fn_missing_prop and ctor_isBuffer ≤ 1,000 instructions/iteration (from 27,000–31,000).

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

    The by-name Function.prototype lookup is on the hot path of every runtime-dispatched method call, not only hasOwn/getPrototypeOf. is_function_prototype_object_value accounts for 5.4% of total excess across 14 packages, and js_get_global_this_builtin_value for 6.2% across 16. The dominant chain is:

    js_typed_feedback_native_call_method_by_id → own_override::resolve_own_user_method → js_object_has_own → ctor_thunks::is_function_prototype_object_value → builtin_prototype_value("Function") → js_get_global_this_builtin_value → get_field_by_name_* (+ OwnedStringBytes::copy_of_key, from_utf8) → closure_get_dynamic_prop("prototype") → get_accessor_descriptor.

    Worst workloads (share of excess): node-forge/hmac 29%, node-forge/aes_cbc 27%, date-fns/diff_interval 23% (via js_get_global_this_builtin_value), node-forge/sha256 20%, qs/parse_nested 17%, dayjs/diff_startof 16%, axios/get_json 14%. The value never changes after startup, so caching its identity once would remove about 5% of total package excess on its own.

  3. added 3 commits that reference this issue on Sep 27, 2026
  4. proggeramlug commented on Sep 28, 2026

    @proggeramlug
    ContributorAuthor

    Progress: #11594 landed. The inherited-read cache now serves object-literal receivers, through the default Object.prototype link, and confirmed-absent keys, and it primes from by-name reads. Package workloads, instructions per iteration: moment/diff_duration −51%, lru-cache/churn −42%, lru-cache/ttl_mixed −35%, moment/parse_format −29%, dayjs −21…26%, validator −24%, jsonwebtoken/hs256 −22%, date-fns −18%, rate-limiter-flexible −11…13%. The trade is +0.4–3.1 MB peak RSS, approved by the owner.

    This is one slice of the prototype-chain read gap this issue tracks. Leaving it open for the remaining receiver kinds and the gap to Node.

  5. proggeramlug commented on Oct 3, 2026

    @proggeramlug
    ContributorAuthor

    Partial progress in #11796 (by-name reads from holder shapes): proto_data3 18.8k → 556, fn_missing_prop 20k → 167 instructions/op; Zod ×5000 −2.36%, qs −4.6%, tsc −0.34%. Leaving open for the rest.

  6. proggeramlug commented on Oct 4, 2026

    @proggeramlug
    ContributorAuthor

    More progress in #11885 (by-name stores, literal definitions from the shape, builtin globals as static sites, Function.prototype intrinsic entries): Zod −14.6%, qs −10.4%, commander −7.5%, tsc −0.01% (fulls 76=76), RSS lower on all four (n=5). Leaving open.

  7. proggeramlug commented on Oct 5, 2026

    @proggeramlug
    ContributorAuthor

    More progress in #12024: in, hasOwn and getPrototypeOf now read the current shape keys and prototype edges. tsc −0.93%, Zod −3.45%, qs −1.8%. 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