Skip to content

perf(proxy): the #6595 store-plan gate costs one shape-table probe per allocated object #8125

Closed

Description

@proggeramlug

Summary

proxy.rs's #6595 store-plan gate calls object::object_is_regular exactly
once per allocated object
. It is the single largest piece of #8113's residual
instruction cost, and reducing it is a #6595-adjacent design question rather
than a mechanical fix — which is why #8113 left it alone.

The counts

Per-callsite counters (#[track_caller] + libc::atexit, on
tls_hot.rs::maybe_install_stats_hook's pattern) over every shape-table entry
point, on the 19-program corpus:

prog calls at object/mod.rs's object_is_regular probe per object
retain 3,000,000 1.00
retain1 1,000,000 1.00
retain_wide (8 fields) 3,000,000 1.00
retain_wide1 (8 fields) 1,000,000 1.00
churn 20,000,002
churn_read 1,000 ~0
tree, push_cls 0

One per object, flat in object width — 8-field literals cost the same as
2-field ones. Reads pay nothing.

Two further counter findings worth keeping:

  • object_live_slot_count is called ZERO times on every hot row. perf(object): remove the derivable object_type and field_count header words — 56 B -> 48 B #8113's
    new bound derivation is not on the measured path at all. Anyone reaching for a
    memo in front of it should check the call count first — two separate memo
    attempts measured null for exactly this reason.
  • deeplist records one call at this site yet carries the largest
    percentage (+8.2%). Whatever dominates deeplist is not this. Unexplained;
    the GC-pacing hypothesis (its RSS fell 4.2%) is plausible and unestablished.

Why it is not a mechanical fix

Before #8113 the gate was (*obj).object_type == OBJECT_TYPE_REGULAR — a free
u32 compare on a header word that no longer exists. The replacement must stay
FALSE for a heap class object or #6595 reopens (bundled zod's ZodX.create
vanishing from ClassRef static dispatch). Three candidates, none a drop-in:

  1. Pass the already-read GcHeader. The site has one in hand for its
    blocking-flags test; object_is_regular re-derives it through
    try_read_gc_header (handle-band, heap-range and slab classification, then
    the load). Tried in perf(object): remove the derivable object_type and field_count header words (56 B -> 48 B) [HELD: #8157 refuted; footprint-coupled residual + new #8094 guard cost] #8122 and REVERTED: semantically identical and
    strictly less work, yet a reproducible interp +9.59% / pipeline +4.43%
    (3-run best-of, quiet host) while doing nothing for retain (+3.26% ->
    +3.04%, noise). Mechanism unexplained — likely codegen/inlining, since the
    predicate provably does not change. Anyone retrying this must measure
    interp and pipeline, not just the retain family.
  2. PLAIN_ORDINARY_OBJ_FLAG (GcHeader._reserved bit 9), already in the
    register at this site and already re-tested by the emitted write PIC. But it
    is a narrow birth marker — set implies ordinary, clear does not imply class
    — so alone it silently drops store-plan eligibility for most ordinary
    objects.
  3. A process-global "any heap class object exists" short-circuit set by
    js_object_mark_class. Sound for the class question, but object_is_regular
    also returns false for FORWARDED and descriptor-less receivers, so skipping
    it changes those answers.

Acceptance

Refs #8113, #8122, #6595, #8098.

Activity

  1. proggeramlug commented on Aug 15, 2026

    @proggeramlug
    ContributorAuthor

    Correction to the record on candidate (1)

    For accuracy about how (1) came to be tried and reverted, since the issue text
    above records it as "tried in #8122 and REVERTED" without saying who proposed it:

    The coordinator recommended landing (1) on a semantic argument — "free and
    exactly semantically equivalent" — and the measurement refuted it.
    I had
    flagged it as "worth doing and low risk" without a number; the recommendation to
    land it, and the justification that it was free, came from the coordinator. The
    semantics claim was correct. The performance conclusion did not follow from it,
    and no number was asked for before recommending.

    What that leaves for anyone picking this up: (1) was refuted by measurement,
    not by review
    , and the refutation only happened because post-fix numbers were
    requested. interp +9.59%, pipeline +4.43%, while retain — the row the
    per-callsite counter said it would help — moved +3.26% -> +3.04%, inside noise.

    The rule this session earned

    Three changes in one session were argued free and measured otherwise:

    1. the ShapeId -> count memo (null; it cached a function called zero times
      on every hot row),
    2. the publish_object_shape_from per-allocation re-lookup (null; the probe it
      removed was already free because shape_descriptor_by_id range-checks first),
    3. candidate (1) here (a 9.59% interp regression).

    On this codebase a change that looks free needs a number before it ships.
    The failure mode is in codegen and inlining, which no amount of reading the diff
    will surface — all three were correct as source-level arguments and wrong as
    claims about the binary.

  2. proggeramlug commented on Aug 15, 2026

    @proggeramlug
    ContributorAuthor

    Retraction: the site named in this issue is WRONG

    I attributed the residual to proxy.rs's #6595 store-plan gate. That
    attribution is wrong and I am retracting it.
    Direct instrumentation at that
    gate — counting every evaluation, not inferring from a location — measures it
    running essentially never on the corpus:

    interp     total=1   forwarded=0  no_descriptor=0  kind_class=0  ordinary=1
    shapes     total=2   ...
    iso_miss   total=1   ...
    retain, retain_wide, retain1, deeplist, churn, pipeline,
    push_cls, tree, churn_read   -> the counter never even armed
    

    How I got it wrong: the same instrumentation trap, one level up

    The counter attributed 3,000,000 calls on retain to object/mod.rs:1736. That
    line is inside object_is_regular. I read it as "called from proxy.rs" — but
    object_is_regular is #[inline] and not #[track_caller], so it swallowed
    its own callers' locations exactly the way object_shape_descriptor had one
    level below. mod.rs:1736 means "somewhere inside object_is_regular", nothing
    more.

    I had already been bitten by precisely this on the first counter build, fixed it
    for object_shape_descriptor, wrote it up as a lesson — and then did not apply
    the same fix to the function one level up. A counter that resolves to a
    plausible-looking line is exactly the "cannot be wrong in a visible way" failure;
    the only thing that caught it was instrumenting the suspected site directly and
    finding it cold.

    What is still true

    • object_is_regular is called once per allocated object, flat in width
      (3 M on retain and on retain_wide's 8-field literals, 20 M on churn,
      ~0 on tree/push_cls, 1000 on churn_read). The per-object shape is solid;
      only the caller was misidentified.
    • object_live_slot_count is called zero times on every hot row. That
      counter did not depend on the swallowed location and stands.
    • Candidate (1) being a null on retain is now explained: it optimised a call
      site that never runs.

    What happens next

    A build with #[track_caller] on object_is_regular itself is running; it will
    name the true caller. Static enumeration makes array/element_shape.rs:258 — the
    element-shape check on array push — the leading suspect, since retain/
    retain_wide push exactly one object each and tree (no array) shows zero. I
    am not asserting that until the counter says so
    , given the above.

    Candidates (1) and (2) as written in the issue body are pinned to the wrong site
    and should not be worked until the caller is confirmed.

  3. proggeramlug commented on Aug 15, 2026

    @proggeramlug
    ContributorAuthor

    Decisive: the residual is NOT derivation cost, so none of these candidates can meet the bar

    With #[track_caller] finally on object_is_regular itself, the true caller
    is measured, not inferred:

    retain        3,000,000  object_is_regular  <- array/element_shape.rs:258
    retain1       1,000,000        "
    churn        20,000,002        "
    

    element_shape.rs:258 is the element-shape check on array push — retain pushes
    exactly one object per iteration, which is the per-object, flat-in-width
    signature.

    And it is pre-existing. At origin/main the call is at the same file and the
    same line 258, object_is_regular's body is byte-identical between the arms, and
    the caller set gains only three sites — exception.rs (uncaught-throw path),
    a map_set_subclass.rs test assert, and the proxy.rs gate that measures
    total=1. The baseline pays this 3M/object too.

    The full shape-table traffic on retain in the shrunk arm:

    calls site new in #8113?
    3,000,000 inside object_is_regular (from element_shape.rs:258) no
    368,633 gc/layout_slot_visit.rs:24 no
    368,633 object/gc_slots.rs:8 (gc_keys_array_slot) no
    368,633 object/gc_slots.rs:43 (gc_field_slot_range) no
    2 shape_descriptor_ensure —

    Essentially 100% of it is pre-existing. #8113 adds ~zero shape-table work on
    the row that regresses. object_live_slot_count, the derivation this rung
    actually introduces, is called zero times there.

    What that means for this issue

    Candidates (1), (2), the ShapeId -> count memo, and the
    publish_object_shape_from re-lookup all cheapen derivation. Derivation is
    measurably not the cost. None of them can recover the instructions, which is
    now the acceptance bar for #8122. This issue as scoped is closed by measurement:
    its premise — that the store-plan gate is the site — was my misattribution, and
    the real traffic it names is work the baseline already does.

    Where the cost actually is: unestablished, and I will not guess again

    I have been wrong three times inferring a mechanism from the diff (the memo, the
    publish_object_shape_from probe, and the site attribution twice). The remaining
    differences between arms are the object size itself (48 B vs 56 B) and the
    emitted-code changes; shape-table traffic is ruled out by measurement.

    Worth noting the sign problem this creates: #8047's pad probe says a pure 16 B
    size reduction improves instructions by 25.63% with copying minors 6 -> 4. So
    "smaller objects cost instructions" is not a general truth here — something
    specific to this change is responsible and counters have not found it.

    The right next instrument is a differential symbol profile (sample / perf,
    self-time diff between the two arms on retain), not another counter. Counters
    only see what you instrument; that is exactly how this went wrong twice. A cheap
    companion measurement, and one #8113's own acceptance asked for and I
    under-reported: copying-minor counts per row on both arms — the pad probe's
    mechanism was 6 -> 4, so if the shrunk arm moves the other way that is the answer.

  4. proggeramlug commented on Aug 15, 2026

    @proggeramlug
    ContributorAuthor

    Arm C result: it is BOTH, and the split differs per row

    Arm C = #8113's code with 8 B of inert padding, i.e. the deletion without the
    shrink
    . It validates as a control: RSS vs baseline is ~0.00% on every row
    (-0.05 .. +0.75), so the footprint variable really is held at the baseline value.

    prog total (B vs A) CODE (C vs A) SIZE (B vs C) RSS B RSS C
    deeplist +8.20 -0.07 +8.28 -4.20 -0.02
    retain1 +7.99 +1.16 +6.75 -5.29 -0.02
    shapes +4.96 +5.17 -0.20 -0.61 -0.56
    push_cls +4.26 +2.50 +1.72 +0.08 +0.08
    churn_alloc +4.25 +2.48 +1.73 +0.23 +0.08
    retain +3.26 +1.74 +1.50 -9.10 +0.01
    retain_wide +2.89 +1.78 +1.09 -5.45 -0.00
    retain_wide1 +2.61 +1.84 +0.76 -6.04 -0.01
    churn +1.76 +1.02 +0.74 +0.15 +0.15
    tree +0.54 +0.62 -0.08 -12.79 -0.05

    My prediction was wrong. I wrote before the run that cache-line arithmetic
    favours 48 B (the read sweep touches 750 lines/1000 objects at 48 B vs 875 at
    56 B), that #8047's pad probe agrees, and that arm C would therefore land near
    +3.26% with size exonerated. It did not: deeplist is 100% size (+8.28 size,
    -0.07 code) and retain1 is mostly size (+6.75 vs +1.16). Smaller objects really
    do cost instructions on those rows, in the opposite direction to the pad probe on
    a neighbouring benchmark. The cache-line count is not the mechanism.

    What this buys

    There is a recoverable CODE component on every allocation-heavy row —
    +1.0 to +2.5%, and +5.17% on shapes with essentially no size component at
    all
    . That is the part that can be killed while keeping the full footprint win,
    which is exactly the owner's ask. shapes is the clean row to chase it on: pure
    code, no size confound.

    The rest is footprint-coupled and is not recoverable by better code:
    deeplist +8.28 and retain1 +6.75 travel with the 8 bytes. Note tree gets
    -12.79% RSS for +0.54% instructions and retain -9.10% for +3.26%, so the
    exchange rate varies a lot by workload.

    Caveat I flagged before running, now load-bearing

    Arm C restores the object size and the field-region offset together (slots
    back at +32). So the "SIZE" column is really "size or field-region offset". For
    the rows where it dominates, separating them needs a trailing-pad arm: add 8 B to
    total_size in the allocators and the inline-new size without touching the
    header struct, keeping the header at 24 B and slots at +24 while the object is
    56 B. That is the strictly cleaner probe and I should have built it first.

    Practically it may not change the plan — if that component is the field offset,
    you cannot move slots to +32 without spending the 8 bytes anyway — but it decides
    whether the 64 B idea is worth testing, and it is one build.

    Next

    Differential symbol profile on shapes, baseline vs arm C — a pure-code row
    with the size variable held, which is the cleanest possible target for finding
    the recoverable regression. Plus the per-row copying-minor counts on both arms.

  5. proggeramlug commented on Aug 15, 2026

    @proggeramlug
    ContributorAuthor

    Symbol profile: blocked structurally, not by time

    The corpus binaries expose no runtime symbols at all. [profile.release] is
    lto = "thin" + codegen-units = 1, which internalizes every Rust function into
    the final link; nm on p_push_cls returns 255 symbols and all of them are
    libc/dyld imports
    (_abort, _acos, _accept, ...). Zero _ZN-mangled
    frames. sample therefore attributes everything to one opaque p_push_cls
    blob — a symbol profile of the runtime is not obtainable from these binaries at
    any sampling rate.

    Making it obtainable needs a build with DWARF retained
    (CARGO_PROFILE_RELEASE_DEBUG=1, keeping lto=thin/cu=1 so the codegen under
    measurement is the shipped one). That is a ~15 GB target dir. The volume is at
    99% with 10 GiB free across 63 worktrees
    , and I aborted my earlier build and
    deleted my target dir once already to avoid ENOSPC-ing sibling agents. I am not
    starting a 15 GB build into 10 GiB.

    Two secondary notes:

    • The programs also run in ~200 ms, so sample's 1 ms floor gives ~200 samples
      per run — too coarse for a 2% effect even with symbols. Aggregating across
      runs helps; a counting profiler would be better, and callgrind is not
      available on macOS arm64.
    • The corpus's own prebuilt perry/p_* binaries are not a usable reference
      arm.
      They report copying_minors=0 while simultaneously reporting
      copied_objects=247346 — self-contradictory, i.e. a different commit with a
      different diag format. I discarded the comparison rather than report it. (This
      is the "a prebuilt binary is not evidence about the commit it is checked out
      to" trap; it nearly produced a whole table of fiction.)

    So the per-row copying-minor counts still need real arm A and arm B builds and
    are not delivered.

    What the next session needs, precisely

    1. CARGO_PROFILE_RELEASE_DEBUG=1 builds of arm A (base) and arm C
      (perf(object): remove the derivable object_type and field_count header words — 56 B -> 48 B #8113 + 8 B inert pad)
      — arm C, not arm B, because arm C holds the
      footprint fixed and therefore isolates the recoverable CODE component with no
      size confound. shapes is the target row: CODE +5.17%, SIZE -0.20%.
    2. Per-row copying-minor counts on arm A and arm B (the pair that differs in
      footprint), which is what perf(object): remove the derivable object_type and field_count header words — 56 B -> 48 B #8113's acceptance asked for.
    3. The trailing-pad arm (add 8 B to total_size in the allocators and the
      inline-new size, header untouched at 24 B) to split the SIZE column into
      "bytes" vs "field-region offset" — the confound I flagged before arm C ran.

    Disk needs to be ~40 GiB free before any of that is safe to start.

  6. proggeramlug commented on Aug 15, 2026

    @proggeramlug
    ContributorAuthor

    Findings, and a correction to this issue's framing

    The three candidates in this issue are aimed at a site that is cold.
    gc-handoff/8113-NOTES.md already carries a retraction that never made it back
    here: direct instrumentation at proxy.rs's #6595 store-plan gate (counting
    evaluations rather than inferring from a #[track_caller] location) measures
    total = 1 on interp, 2 on shapes, 1 on iso_miss, and the counter
    never arming at all on retain, retain_wide, retain1, deeplist, churn,
    pipeline, push_cls, tree, churn_read. The 3,000,000-calls-per-object
    figure belongs to object_is_regular, whose real caller is
    array/element_shape.rs:258 — and that call site is pre-existing on main,
    not something #8113 introduced. So PLAIN_ORDINARY_OBJ_FLAG, the
    "any heap class object exists" latch, and passing the already-read GcHeader
    would all have optimised a line that runs once or twice per program.

    The residual is the PROBE, not the gate. PR #8157.

    The symbol profile this issue's plan asked for is obtainable and needs no
    CARGO_PROFILE_RELEASE_DEBUG=1 build: CARGO_PROFILE_RELEASE_STRIP=none keeps
    symbols in the archives, and PERRY_DEBUG_SYMBOLS=1 skips perry's own
    post-link strip (compile/post_link.rs). The previous "255 symbols, all libc
    imports, so LTO internalized everything" reading was that strip, not LTO.

    sample on shapes, on main, before #8122, self time:

    25  RandomState as BuildHasher>::hash_one     <- SipHash-1-3 on a u32
    22  gc::layout_tables::layout_addr_filter_add
    15  addr_class::try_read_tracked_gc_header
    14  object::shapes::shape_descriptor_by_id
     6  hashbrown HashMap<_, ClassVTable, RandomState>::get
     5  hash_one (second instantiation)
     5  core::hash::sip::Hasher::write
    

    ShapeTableInner::descriptors is a HashMap<u32, ShapeDescriptor> with the
    default RandomState. The sibling field indices on the same struct already
    uses fast_hash::PtrHashMap; this one was never converted. #8157 converts it
    (plus a write_u32 fast path, without which a u32 key takes the generic
    byte-fold), and pins the hash-order dependence in
    rebuild_descriptor_reverse_indices that the conversion exposed.

    Corpus effect of #8157 alone, on main: churn −25.2%, deeplist −17.2%,
    retain_wide −15.2%, retain1 −15.1%, cycles −14.9%, retain −14.7%,
    tree −13.4%, shapes −9.4%, interp −5.9%, iso_miss −4.7%,
    pipeline −3.6%. Peak memory footprint unchanged.

    Acceptance, answered honestly

    What #8157 does change is the absolute position: main + #8157 + #8122 is
    faster than main on 17 of 20 corpus rows while keeping #8122's whole RSS win.
    See #8122 for that table.

    Also measured, and negative

    Hoisting the free class_id != 0 test ahead of object_is_regular in
    element_class_of_bits — strictly less work, conjunction unchanged, and it
    skips the probe entirely for object-literal workloads — regressed retain
    +1.69%, retain_wide +2.11%, retain_wide1 +2.07%, churn +1.09%. Reverted.
    Third "semantically identical and strictly less work" change in this campaign
    to measure worse; the pattern is now strong enough to treat as a prior.

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