OBJ_FLAG_HAS_DESCRIPTORS is a per-object header bit, and both property inline caches refuse any receiver that carries it — the read PIC (property_get/generic_dispatch.rs, bit 11 of _reserved) and the write PIC (proxy_reflect.rs, bit 11 of WRITE_PIC_BLOCKING_FLAGS = 0x1987). So one Object.defineProperty on ONE key takes every OTHER key of that object off both fast paths, permanently.
Measured (v0.5.1628, perf stat -e instructions:u, min of 3, per-iteration fitted between N=500k and N=5M, perry flat across the range, output byte-identical to node)
Two fixtures differing by one line. Loop body is O.a = k; h += O.a; in both — a is an ordinary writable data property in both, and is never the key the descriptor is installed on.
const O = {a:1, b:2, c:3, d:4};
// q1 only:
Object.defineProperty(O, 'z', {value:7, writable:false, enumerable:false, configurable:false});
function run(n){let h=0;for(let k=0;k<n;k++){O.a=k;h+=O.a;}return h;}
|
perry |
node |
bun |
| no descriptor anywhere on the object |
126.00 |
17.37 |
10.82 |
one non-writable z installed, never touched again |
1991.00 |
20.76 |
9.84 |
| cost of the unrelated descriptor |
+1865 (15.8×) |
+3.4 |
~0 |
Where the 1865 goes (callgrind --separate-callers=1, --debug-symbols build, per iteration)
Both halves of the loop fall out of cache, not just the store:
- read:
get_field_ic_miss_impl 253, native_get::try_data_get_bytes 215, keys_find_slot_by_bytes_resolved 81, inherited_read_cache_lookup 80, shape_descriptor_by_id 70, is_anon_shape_class_id 54, array_subclass_named_prefix_token_for_slot 51, memcmp 50
- store:
js_put_value_set_ic_miss 128, try_existing_own_data_overwrite 146, own_descriptors_skip_key 75, shape_descriptor_by_id 70, is_class_object_ptr 67, object_keys_array 53, read_plan_lookup 49, runtime_store_jsvalue_slot + layout_note_slot 91, js_put_value_set 45
own_descriptors_skip_key at 75 per store is the tell: the runtime does ask per key whether the key being stored has a descriptor, and answers "no" every time — 200 000 times — while the emitted guard has already refused on the per-object bit before reaching it.
Why this is worth fixing rather than documenting
This is the pattern real code has. A library that calls Object.defineProperty(exports, '__esModule', {value: true}), or installs one non-enumerable name/length/toJSON, or freezes a single constant on a config object, makes every ordinary property of that object 15.8× dearer for the life of the process. The object does not have to be exotic in any way a reader would notice, and there is no diagnostic that points at it.
Design OBJECT_MODEL_SINGLE_PATH_DESIGN_2026-09-20.md §3 already names the fix: per-key attributes belong in the shape, not in an address-keyed side table with a per-object "somewhere in here there is a descriptor" bit. With per-key attributes in the shape, a shape match proves the stored key is a plain writable data property, and the sibling descriptor costs the object nothing. §8 step 3 has it as work; this issue is the measurement that says what it is worth.
Interim, much cheaper option if step 3 is far off: make the bit mean "this object has a descriptor on a key the site has not primed" by consulting the shape's descriptor key set at prime time and letting the emitted guard trust the ShapeId — i.e. move the per-key question to the miss, which runs once, from the hit, which runs 200 000 times. That needs #10824's guarantee that every descriptor install transitions the shape, which landed.
OBJ_FLAG_HAS_DESCRIPTORSis a per-object header bit, and both property inline caches refuse any receiver that carries it — the read PIC (property_get/generic_dispatch.rs, bit 11 of_reserved) and the write PIC (proxy_reflect.rs, bit 11 ofWRITE_PIC_BLOCKING_FLAGS = 0x1987). So oneObject.definePropertyon ONE key takes every OTHER key of that object off both fast paths, permanently.Measured (v0.5.1628,
perf stat -e instructions:u, min of 3, per-iteration fitted between N=500k and N=5M, perry flat across the range, output byte-identical to node)Two fixtures differing by one line. Loop body is
O.a = k; h += O.a;in both —ais an ordinary writable data property in both, and is never the key the descriptor is installed on.zinstalled, never touched againWhere the 1865 goes (callgrind
--separate-callers=1,--debug-symbolsbuild, per iteration)Both halves of the loop fall out of cache, not just the store:
get_field_ic_miss_impl253,native_get::try_data_get_bytes215,keys_find_slot_by_bytes_resolved81,inherited_read_cache_lookup80,shape_descriptor_by_id70,is_anon_shape_class_id54,array_subclass_named_prefix_token_for_slot51,memcmp50js_put_value_set_ic_miss128,try_existing_own_data_overwrite146,own_descriptors_skip_key75,shape_descriptor_by_id70,is_class_object_ptr67,object_keys_array53,read_plan_lookup49,runtime_store_jsvalue_slot+layout_note_slot91,js_put_value_set45own_descriptors_skip_keyat 75 per store is the tell: the runtime does ask per key whether the key being stored has a descriptor, and answers "no" every time — 200 000 times — while the emitted guard has already refused on the per-object bit before reaching it.Why this is worth fixing rather than documenting
This is the pattern real code has. A library that calls
Object.defineProperty(exports, '__esModule', {value: true}), or installs one non-enumerablename/length/toJSON, or freezes a single constant on a config object, makes every ordinary property of that object 15.8× dearer for the life of the process. The object does not have to be exotic in any way a reader would notice, and there is no diagnostic that points at it.Design
OBJECT_MODEL_SINGLE_PATH_DESIGN_2026-09-20.md§3 already names the fix: per-key attributes belong in the shape, not in an address-keyed side table with a per-object "somewhere in here there is a descriptor" bit. With per-key attributes in the shape, a shape match proves the stored key is a plain writable data property, and the sibling descriptor costs the object nothing. §8 step 3 has it as work; this issue is the measurement that says what it is worth.Interim, much cheaper option if step 3 is far off: make the bit mean "this object has a descriptor on a key the site has not primed" by consulting the shape's descriptor key set at prime time and letting the emitted guard trust the ShapeId — i.e. move the per-key question to the miss, which runs once, from the hit, which runs 200 000 times. That needs #10824's guarantee that every descriptor install transitions the shape, which landed.