Skip to content

perf: shapes spends 100% of self time in try_read_tracked_gc_header + shape_descriptor_by_id (1.06x Node) #8406

Description

@proggeramlug

Summary

100% of shapes' measured self time is two runtime helpers — no user code appears at
all. shapes currently runs at 1.06x Node with an instruction ratio of 1.25, so this is
work being executed, not scheduling.

Profile evidence

Measured on 8bfdac447, symbols kept, aggregated over 40 runs (the program is short, ~89 ms):

 67.7%  value::addr_class::try_read_tracked_gc_header
 32.3%  object::shapes::shape_descriptor_by_id

That is the entire attributed profile. The benchmark's own functions never appear above the
sampling floor.

try_read_tracked_gc_header also shows up on churn (3.2%), so this is not confined to one row.

Why this looks addressable

shapes exercises polymorphic property access across many object shapes — exactly the path
these two helpers serve. Two questions worth answering with measurements:

  1. shape_descriptor_by_id — is this a map lookup on every property access? A shape
    descriptor is immutable once created, so a per-callsite inline cache (shape id -> descriptor
    pointer, invalidated on shape transition) should turn most lookups into a compare-and-branch.
    Check whether an inline cache already exists and is simply missing here, or whether the id
    -> descriptor step is unavoidably dynamic.
  2. try_read_tracked_gc_header — being the single largest frame in a property-access
    benchmark suggests a validation read on a very hot path. Find out what it is guarding and
    whether the check can be hoisted out of the access loop, or proven redundant when the
    receiver was already validated earlier in the same operation.

Relevant background: crates/perry-runtime/src/value/addr_class.rs centralizes
handle-vs-pointer address classification (#4899), and CLAUDE.md notes that class instances
carry no separate shape word — the shape word is parent_class_id, valid only when
class_id == 0.

Where to look

  • crates/perry-runtime/src/value/addr_class.rs — try_read_tracked_gc_header
  • crates/perry-runtime/src/object/shapes.rs — shape_descriptor_by_id
  • the property-get/set lowering in crates/perry-codegen/src/expr/property_get/

Acceptance

shapes beats Node (< 1.00x), no other row regressed, all 19 byte-exact, RSS not increased.


How to build and measure (shared setup)

Build with the static wrappers or you link a stale runtime and both A/B arms behave
identically — a vacuous result:

cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static
export PERRY_RUNTIME_DIR=<your target>/release

The benchmark corpus and Node-oracle expected output are staged at
/Users/amlug/projects/perry/sweep-artifacts-0819b/ (sources/*.ts, expected/*.stdout).

export PERRY_NO_AUTO_OPTIMIZE=1 PERRY_NO_CACHE=1
perry sources/<row>.ts -o /tmp/x --no-cache
cmp <(/tmp/x) expected/<row>.stdout      # verify BEFORE timing
/usr/bin/time -l /tmp/x                  # instructions retired + peak RSS

Profiling: Perry strips its output binaries, so sample attributes everything to
???. Set PERRY_KEEP_SYMBOLS=1 PERRY_DEBUG_SYMBOLS=1 at compile time — the binary keeps
~1900 symbols and stays byte-exact — then read sample's "Sort by top of stack" section.
These programs are short; aggregate across ~14 runs for a usable sample count.

Constraints that apply to any fix here

Activity

  1. proggeramlug commented on Aug 19, 2026

    @proggeramlug
    ContributorAuthor

    Reopening. #8412 landed a real improvement (instructions −5.2%, wall −3.7%, RSS unchanged), but it does not meet this ticket's acceptance criterion.

    The PR's 0.81x Node figure came from a contended host that measured Node's shapes at 122.4 ms; the quiet-mini sweep measures it at 83.7 ms. Against that baseline shapes moves from roughly 1.14x to 1.10x — still losing to Node.

    The two helpers named in this ticket are still the cost: #8412 removed one redundant descriptor probe from the path rather than the path itself. Re-measure on the quiet mini against gc-handoff/current-sweep-2026-08-19b/results.json before closing.

  2. proggeramlug commented on Aug 21, 2026

    @proggeramlug
    ContributorAuthor

    Implemented in #8520. The bounded canonical-shape dispatch probe cuts shapes from 1,264,885,553 to 1,149,314,946 retired instructions (-9.14%) and from 290,262,830 to 261,445,126 cycles (-9.93%). Interleaved wall medians were 110 ms baseline, 90 ms candidate, and 110 ms Node, with overlapping ranges on the contended host. Peak RSS moved by +64 KiB (+0.19%) and remains about 0.36x Node. All 19 corpus rows stayed byte-exact, no non-target row moved more than 0.48% in instructions, the scoped release suites passed (runtime 2,606; codegen 1,113; Perry 1,008), and all 52 lint gates passed.

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