Skip to content

Guarded parameter specialization refuses class-typed parameters, leaving the runtime class-identity check dead #8099

Description

@proggeramlug

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(): void

count 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

  1. Field validation vs. compact instances. The OP_OBJECT arm validates each descriptor
    field through own_data_field, which reads keys_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.
  2. What identity actually licenses. Layout follows from the class, so a slot index and a
    raw-f64 field 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.
  3. Subclasses. class_chain_reaches admits a subclass; confirm subclass layout preserves the
    parent's field indices.
  4. delete. A delete mints a shape word and zeroes class_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.
  5. 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.

Refs #8079, #8094.

Activity

  1. proggeramlug commented on Aug 15, 2026

    @proggeramlug
    ContributorAuthor

    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_array blocker does not exist

    The refusal's comment says compact class instances do not expose the ordinary
    keys_array needed 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 via js_build_class_keys_array, so own_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
    tree 1.0887 s 1.6460 s +51%
    tree_wide 1.7753 s 2.3039 s +30%

    Every other corpus row was flat to within 0.7%.

    --trace llvm gives the mechanism, and it is not a tuning problem.
    count$spec_b came out structurally identical to the $generic sibling 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__count
    

    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_guard call on each of ~21 M invocations (~26 ns/call, and the
    per-call cost is identical for tree and tree_wide despite 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_add
    

    3. What this means for the tree row

    The tree shortfall 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
    is Tree | 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_index documents 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_chain and a gap row.
    • Q4, delete: a delete does not zero class_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 through own_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_reaches branch now has its first caller, so the kill
    policy is satisfied without deleting it.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions