Repository navigation
Guarded parameter specialization refuses class-typed parameters, leaving the runtime class-identity check dead #8099
Description
Summary
crates/perry-codegen/src/codegen/param_guard.rs::build_named refuses class types outright, so
a parameter annotated with a class never gets a runtime descriptor and never enters a
proof-bearing $spec_ clone:
} else if self.classes.contains_key(name) && self.class_ids.contains_key(name) {
// Class identity alone cannot prove mutable field values, while
// compact instances do not expose the ordinary `keys_array`
// needed for read-only field validation. Keep class parameters on
// the generic path until a layout-aware field guard exists.
return None;This is a deliberate scope boundary from #8094, not an inherent limit, and it is measurable.
What it costs, measured
In the tree benchmark (/private/tmp/perry-current-sweep-artifacts/sources/tree.ts) the whole
program is three functions:
function build(depth: number): Tree
function count(t: Tree): number // the hot recursive one — t.left, t.right
function main(): voidcount is the hot function and does all the field reads, and its only parameter is class-typed,
so it is refused. The Tree constructor is refused at one remove: its parameters are
Tree | null, and Type::Union maps every variant through build_type, so the Named("Tree")
variant poisons the union. build(depth: number) ends up the only guardable parameter in the
program.
Against the branch's own base 601a02d23, tree recovered 55.1% where an unsound
annotation-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:399 already implements the class-identity check:
if class_id != 0
&& !crate::object::class_chain_reaches((*object).class_id, class_id)
{
return false;
}Codegen always emits class_id: 0 (both GuardNode::Object constructions pass class_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
- Field validation vs. compact instances. The
OP_OBJECTarm validates each descriptor
field throughown_data_field, which readskeys_array; a compact class instance does not
expose 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. - What identity actually licenses. Layout follows from the class, so a slot index and a
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. - Subclasses.
class_chain_reachesadmits a subclass; confirm subclass layout preserves the
parent's field indices. delete. Adeletemints a shape word and zeroesclass_id, so the guard fails and the
generic path is taken. That looks like it falls the right way, but should be pinned by a test.- The perf(codegen): guarded ordinary-parameter specialization #8094 lifetime rule applies here too. A class-typed parameter is reference-like, so
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.
Activity
proggeramlug commentedon Aug 15, 2026
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) callsset_object_keys_array(ptr, keys_array)with the per-class array codegen buildsonce at module init via
js_build_class_keys_array, soown_data_fieldresolves 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 — leavingit 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 exactlythat first, and measured it. Best-of-5 on the quiet M1 mini, every output
byte-exact:
treetree_wideEvery 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 itroutes around — 325 lines each, identical 11-call multiset:
A 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 theper-call cost is identical for
treeandtree_widedespite the 8 extrafields — 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:
3. 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")isreference-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.leftis
Tree | null, so validation walks the reachable graph on every call, whichis 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 istherefore 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
form validates declared field types, so the proof is the same strength as an
interface's.
class_field_global_indexdocuments that parentfields 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.delete: a delete does not zeroclass_id— it is recorded in a sideregistry (
class_is_key_deleted). It does change the shape, and the guard'sby-name field read goes through
own_data_field, which fails and falls back.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 killpolicy is satisfied without deleting it.
feat(codegen): admit class-typed parameters into the guarded speciali…
feat(codegen): admit class-typed parameters into the guarded speciali…
perf(codegen): prove an all-number class parameter nominally