Repository navigation
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
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; 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 |
|---|---|---|---|---|---|---|
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.call4.7 %,isBuffer'sval.constructor5.6 %
(io-group report). Hot upstream helpers:utils.isBuffer,utils.forEach,hasOwnPropertywrapper,
isPlainObject(getPrototypeOf(val) === Object.prototype),mergeConfig. - lodash calls
hasOwnProperty.call(result, key)ingroupBy/baseAssignValueandobjectToString.callin its type
checks (objects-group report; that dispatch cost is shared with the.calltrampoline).
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 ofhasOwn_static:is_function_prototype_object_value78.5 % inclusive,
js_get_global_this_builtin_value59 % (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 sitedynamic_props.rs:1062), which again runsbuiltin_prototype_value("Function")
(dynamic_props.rs:1137), then walksFunction.prototype→Object.prototypeby name (verified). Profile of
fn_missing_prop:closure_get_dynamic_prop90 %,function_prototype_fallback_target28 %,
js_get_global_this_builtin_value20 %, 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) reachesconstructor_dynamic_prototype
(crates/perry-runtime/src/object/prototype_helpers.rs:3, call sitesprototype.rs:622andprototype.rs:767), which readso.constructor
by name and thenclosure_get_dynamic_prop(ctor, "prototype")(verified). Profile:constructor_dynamic_prototype
58 % ofgetProto_eq.- None of these reads is cached, so
o.constructor.isBufferpays the inheritedconstructorread, 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
in_operator(180×) is included for reference; its cost is the absent-key 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 (one of the four objects
lacksa).- Related: perf(runtime): gate the rare-subclass probes on the object property-miss path (asyncpipe 9.69x -> 1.76x node) #7795 (merged; memoized
Object.prototypeon the ordinary miss path — the pattern to reuse),indoes not see inherited Function.prototype members ("call" in f is false) #10366 (open
correctness:indoes not see inherited Function.prototype members),Object.prototypehas no own__proto__accessor:hasOwnProperty/Object.hasOwn/getOwnPropertyNames/getOwnPropertyDescriptor/"__proto__" in {}all report it missing #10482 (open correctness: missing own
__proto__accessor onObject.prototype, touches the same helpers). - Shares are from the v0.5.1587 audit profiles; ratios and instruction counts are re-measured on 7661bc0.
Activity
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×.The by-name
Function.prototypelookup is on the hot path of every runtime-dispatched method call, not onlyhasOwn/getPrototypeOf.is_function_prototype_object_valueaccounts for 5.4% of total excess across 14 packages, andjs_get_global_this_builtin_valuefor 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.test: trim the #10497 gap test to shapes Perry already models
perf(runtime): memoize %Function.prototype% identity per realm in the…
test: trim the #10497 gap test to shapes Perry already models
proggeramlug commentedon Sep 28, 2026
Progress: #11594 landed. The inherited-read cache now serves object-literal receivers, through the default
Object.prototypelink, 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.
perf(object): function-receiver reads answered from the site holder e…
perf(object): multi-shape inherited data entries, plain-object constr…
changelog: by-name lookups answered from holder shapes (#10495, #10497)
perf(object): function-receiver reads answered from the site holder e…
perf(object): multi-shape inherited data entries, plain-object constr…
changelog: by-name lookups answered from holder shapes (#10495, #10497)
proggeramlug commentedon Oct 3, 2026
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.
perf(object): object literals define their properties from the receiv…
perf(object): key-adds in the generic store and class fields take the…
perf(codegen): a builtin global identifier is a static read site of t…
gc_call_effects, wasm ABI: the literal define and spread entries; cha…
proggeramlug commentedon Oct 4, 2026
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.
runtime: answer ordinary property presence from owned shapes (#10497)
runtime: answer ordinary property presence from owned shapes (#10497)
proggeramlug commentedon Oct 5, 2026
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.