Repository navigation
Guarded parameter specialization refuses class-typed parameters, leaving the runtime class-identity check dead #8099
Description
Activity
Implemented in #8165, and two of this issue's premises turned out to be wrong.
Both were load-bearing, so they are worth stating plainly.1. The
keys_arrayblocker does not existThe refusal's comment says compact class instances do not expose the ordinary
keys_arrayneeded for read-only field validation. They do.
object_alloc_class_inline_keys_impl(object/alloc.rs:306) calls
set_object_keys_array(ptr, keys_array)with the per-class array codegen builds
once at module init viajs_build_class_keys_array, soown_data_field
resolves a class instance's fields exactly as it resolves an object literal's.The claim traces to a stale doc comment on
ObjectHeader::keys_array
(object/mod.rs:1699, "NULL for class instances"). #8165 corrects it — leaving
it is how the next reader repeats this.So question 1 in "What a fix has to answer" is void: a class parameter gets the
ordinary by-name field descriptor, plus the class id. That is what landed.2. Identity-only is not a shortcut — it is worse than nothing
The issue suggests an identity-only descriptor (
class_id: Some(id), fields: vec![]) as the way to sidestep field validation. I implemented exactly
that first, and measured it. Best-of-5 on the quiet M1 mini, every output
byte-exact:bench main identity-only delta tree1.0887 s 1.6460 s +51% tree_wide1.7753 s 2.3039 s +30% Every other corpus row was flat to within 0.7%.
--trace llvmgives the mechanism, and it is not a tuning problem.
count$spec_bcame out structurally identical to the$genericsibling it
routes around — 325 lines each, identical 11-call multiset:3x js_typed_feedback_class_field_get_guard 3x js_throw_type_error_property_access 3x js_object_get_field_by_name_f64 2x perry_fn_tree_ts__countA class-annotated receiver already reaches the class-field guard path without
any parameter evidence, so class identity licenses nothing codegen did not
already have. The clone bought zero instructions and cost one
js_param_type_guardcall on each of ~21 M invocations (~26 ns/call, and the
per-call cost is identical fortreeandtree_widedespite the 8 extra
fields — the signature of fixed guard overhead). No amount of making the guard
cheaper turns that positive; the best case for a free guard is break-even.What actually pays is the field VALUE facts, exactly as for interfaces. On a
matched pair of fixtures with the same body:render$spec_b (interface) ... js_get_string_pointer_unified, js_string_concat_value render$generic ... js_dynamic_string_or_number_add renderClass$spec_b (class) ... js_get_string_pointer_unified, js_string_concat_value renderClass$generic ... js_dynamic_string_or_number_add3. What this means for the
treerowThe
treeshortfall this issue was filed about is not the class refusal.
count(t: Tree)is refused by #8094's aliasing rule —Named("Tree")is
reference-like and the body contains a call — and admitting class descriptors
does not change that. The only descriptor cheap enough to admit inside a
recursive walker is the identity-only one measured above, which is a loss.A field-validating descriptor there would be sound but unaffordable:
Tree.left
isTree | null, so validation walks the reachable graph on every call, which
is O(nodes x depth) against O(nodes) of saved field reads on a 262 143-node
tree. #8094's rule already refuses it, which is why #8165 needed no new cost
policy.tree's remaining gap versus the unsound annotation-trusting control is
therefore a different mechanism than the one this issue proposed, and I have not
identified it. Worth a separate issue if it is still wanted; it is not the class
descriptor.4. Question 5 answered, questions 2-4 answered by construction
- Q2, what identity licenses: nothing on its own — measured above. Shipped
form validates declared field types, so the proof is the same strength as an
interface's. - Q3, subclasses: admitted.
class_field_global_indexdocuments that parent
fields come first in the slot layout, so a subclass preserves them, and the
descriptor validates the whole chain (a leaf-only field set would license a
parent field's declared type without having validated it). Pinned by
a_subclass_descriptor_validates_the_whole_inheritance_chainand a gap row. - Q4,
delete: a delete does not zeroclass_id— it is recorded in a side
registry (class_is_key_deleted). It does change the shape, and the guard's
by-name field read goes throughown_data_field, which fails and falls back. - Q5, the perf(codegen): guarded ordinary-parameter specialization #8094 lifetime rule: applies unchanged, and is what bounds the
cost as well as the soundness. A gap row pins the aliased-through-a-global
case with a class receiver.
The runtime's
class_chain_reachesbranch now has its first caller, so the kill
policy is satisfied without deleting it.- Q2, what identity licenses: nothing on its own — measured above. Shipped
- added a commit that references this issue
on Sep 17, 2026
Summary
crates/perry-codegen/src/codegen/param_guard.rs::build_namedrefuses class types outright, soa parameter annotated with a class never gets a runtime descriptor and never enters a
proof-bearing
$spec_clone:This is a deliberate scope boundary from #8094, not an inherent limit, and it is measurable.
What it costs, measured
In the
treebenchmark (/private/tmp/perry-current-sweep-artifacts/sources/tree.ts) the wholeprogram is three functions:
countis the hot function and does all the field reads, and its only parameter is class-typed,so it is refused. The
Treeconstructor is refused at one remove: its parameters areTree | null, andType::Unionmaps every variant throughbuild_type, so theNamed("Tree")variant poisons the union.
build(depth: number)ends up the only guardable parameter in theprogram.
Against the branch's own base
601a02d23,treerecovered 55.1% where an unsoundannotation-trusting control recovered 72.0% — the only row in the sweep where the guarded
design visibly left recovery on the table; every other row matched the control to within half
a percent.
The runtime half is already written, and is currently dead code
crates/perry-runtime/src/param_type_guard.rs:399already implements the class-identity check:Codegen always emits
class_id: 0(bothGuardNode::Objectconstructions passclass_id: None),so this branch has never executed. Per the repo's own kill-policy — an unexercised mode is a
configuration nobody has verified — it should either get a caller or be deleted.
What a fix has to answer
OP_OBJECTarm validates each descriptorfield through
own_data_field, which readskeys_array; a compact class instance does notexpose one. An identity-only descriptor (
class_id: Some(id), fields: vec![]) sidesteps this,but then the guard proves class identity and nothing about field values.
raw-
f64field read are sound given identity. Trusting a field's declared type is not —that is the fix(codegen): require runtime evidence for local binding types #8033 hazard.
class_chain_reachesadmits a subclass; confirm subclass layout preserves theparent's field indices.
delete. Adeletemints a shape word and zeroesclass_id, so the guard fails and thegeneric path is taken. That looks like it falls the right way, but should be pinned by a test.
whatever proof it gets cannot survive a call — see the aliasing fix in perf(codegen): guarded ordinary-parameter specialization #8094.
Deliberately kept out of #8094: it widens the guard surface across every class-typed parameter
in every program, and that PR needs to be correct first.
Refs #8079, #8094.