Repository navigation
perf(proxy): the #6595 store-plan gate costs one shape-table probe per allocated object #8125
Description
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_countis 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.deeplistrecords one call at this site yet carries the largest
percentage (+8.2%). Whatever dominatesdeeplistis 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:
- Pass the already-read
GcHeader. The site has one in hand for its
blocking-flags test;object_is_regularre-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 reproducibleinterp+9.59% /pipeline+4.43%
(3-run best-of, quiet host) while doing nothing forretain(+3.26% ->
+3.04%, noise). Mechanism unexplained — likely codegen/inlining, since the
predicate provably does not change. Anyone retrying this must measure
interpandpipeline, not just the retain family. PLAIN_ORDINARY_OBJ_FLAG(GcHeader._reservedbit 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.- A process-global "any heap class object exists" short-circuit set by
js_object_mark_class. Sound for the class question, butobject_is_regular
also returns false for FORWARDED and descriptor-less receivers, so skipping
it changes those answers.
Acceptance
- The gate stops being one shape-table probe per allocated object, or the
cost is shown to be irreducible without weakening the predicate. - regression: #6541 store-plan cache sharing re-breaks bundled zod (#6530 repros fail again) — Next.js standalone down on main #6595 stays closed — a test that a heap class object is not plan-eligible
already exists (object_is_regular_excludes_a_heap_class_object). - Measured on the corpus with
interpandpipelineincluded, not only the
retain family — candidate 1 looked free on retain and was not.
Activity
docs(changelog): record where the residual instruction cost is, and t…
proggeramlug commentedon Aug 15, 2026
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%, whileretain— the row theper-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:
ShapeId -> countmemo (null; it cached a function called zero timeson every hot row),
publish_object_shape_fromper-allocation re-lookup (null; the probe itremoved was already free because
shape_descriptor_by_idrange-checks first),interpregression).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.
proggeramlug commentedon Aug 15, 2026
Retraction: the site named in this issue is WRONG
I attributed the residual to
proxy.rs's #6595 store-plan gate. Thatattribution 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:
How I got it wrong: the same instrumentation trap, one level up
The counter attributed 3,000,000 calls on
retaintoobject/mod.rs:1736. Thatline is inside
object_is_regular. I read it as "called fromproxy.rs" — butobject_is_regularis#[inline]and not#[track_caller], so it swallowedits own callers' locations exactly the way
object_shape_descriptorhad onelevel below.
mod.rs:1736means "somewhere insideobject_is_regular", nothingmore.
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 applythe 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_regularis called once per allocated object, flat in width(3 M on
retainand onretain_wide's 8-field literals, 20 M onchurn,~0 on
tree/push_cls, 1000 onchurn_read). The per-object shape is solid;only the caller was misidentified.
object_live_slot_countis called zero times on every hot row. Thatcounter did not depend on the swallowed location and stands.
retainis now explained: it optimised a callsite that never runs.
What happens next
A build with
#[track_caller]onobject_is_regularitself is running; it willname the true caller. Static enumeration makes
array/element_shape.rs:258— theelement-shape check on array push — the leading suspect, since
retain/retain_widepush exactly one object each andtree(no array) shows zero. Iam 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.
proggeramlug commentedon Aug 15, 2026
Decisive: the residual is NOT derivation cost, so none of these candidates can meet the bar
With
#[track_caller]finally onobject_is_regularitself, the true calleris measured, not inferred:
element_shape.rs:258is the element-shape check on array push —retainpushesexactly one object per iteration, which is the per-object, flat-in-width
signature.
And it is pre-existing. At
origin/mainthe call is at the same file and thesame line 258,
object_is_regular's body is byte-identical between the arms, andthe caller set gains only three sites —
exception.rs(uncaught-throw path),a
map_set_subclass.rstest assert, and theproxy.rsgate that measurestotal=1. The baseline pays this 3M/object too.The full shape-table traffic on
retainin the shrunk arm:object_is_regular(fromelement_shape.rs:258)gc/layout_slot_visit.rs:24object/gc_slots.rs:8(gc_keys_array_slot)object/gc_slots.rs:43(gc_field_slot_range)shape_descriptor_ensureEssentially 100% of it is pre-existing. #8113 adds ~zero shape-table work on
the row that regresses.
object_live_slot_count, the derivation this rungactually introduces, is called zero times there.
What that means for this issue
Candidates (1), (2), the
ShapeId -> countmemo, and thepublish_object_shape_fromre-lookup all cheapen derivation. Derivation ismeasurably 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_fromprobe, and the site attribution twice). The remainingdifferences 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. Countersonly 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.
proggeramlug commentedon Aug 15, 2026
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.
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:
deeplistis 100% size (+8.28 size,-0.07 code) and
retain1is mostly size (+6.75 vs +1.16). Smaller objects reallydo 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
shapeswith essentially no size component atall. That is the part that can be killed while keeping the full footprint win,
which is exactly the owner's ask.
shapesis the clean row to chase it on: purecode, no size confound.
The rest is footprint-coupled and is not recoverable by better code:
deeplist+8.28 andretain1+6.75 travel with the 8 bytes. Notetreegets-12.79% RSS for +0.54% instructions and
retain-9.10% for +3.26%, so theexchange 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_sizein the allocators and the inline-newsize without touching theheader 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 rowwith 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.
proggeramlug commentedon Aug 15, 2026
Symbol profile: blocked structurally, not by time
The corpus binaries expose no runtime symbols at all.
[profile.release]islto = "thin"+codegen-units = 1, which internalizes every Rust function intothe final link;
nmonp_push_clsreturns 255 symbols and all of them arelibc/dyld imports (
_abort,_acos,_accept, ...). Zero_ZN-mangledframes.
sampletherefore attributes everything to one opaquep_push_clsblob — 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, keepinglto=thin/cu=1so the codegen undermeasurement 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:
sample's 1 ms floor gives ~200 samplesper run — too coarse for a 2% effect even with symbols. Aggregating across
runs helps; a counting profiler would be better, and
callgrindis notavailable on macOS arm64.
perry/p_*binaries are not a usable referencearm. They report
copying_minors=0while simultaneously reportingcopied_objects=247346— self-contradictory, i.e. a different commit with adifferent 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
CARGO_PROFILE_RELEASE_DEBUG=1builds 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.
shapesis the target row: CODE +5.17%, SIZE -0.20%.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.
total_sizein the allocators and theinline-
newsize, 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.
proggeramlug commentedon Aug 15, 2026
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.mdalready carries a retraction that never made it backhere: direct instrumentation at
proxy.rs's #6595 store-plan gate (countingevaluations rather than inferring from a
#[track_caller]location) measurestotal = 1oninterp,2onshapes,1oniso_miss, and the counternever arming at all on
retain,retain_wide,retain1,deeplist,churn,pipeline,push_cls,tree,churn_read. The 3,000,000-calls-per-objectfigure belongs to
object_is_regular, whose real caller isarray/element_shape.rs:258— and that call site is pre-existing onmain,not something #8113 introduced. So
PLAIN_ORDINARY_OBJ_FLAG, the"any heap class object exists" latch, and passing the already-read
GcHeaderwould 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=1build:CARGO_PROFILE_RELEASE_STRIP=nonekeepssymbols in the archives, and
PERRY_DEBUG_SYMBOLS=1skips perry's ownpost-link
strip(compile/post_link.rs). The previous "255 symbols, all libcimports, so LTO internalized everything" reading was that
strip, not LTO.sampleonshapes, onmain, before #8122, self time:ShapeTableInner::descriptorsis aHashMap<u32, ShapeDescriptor>with thedefault
RandomState. The sibling fieldindiceson the same struct alreadyuses
fast_hash::PtrHashMap; this one was never converted. #8157 converts it(plus a
write_u32fast path, without which au32key takes the genericbyte-fold), and pins the hash-order dependence in
rebuild_descriptor_reverse_indicesthat 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
gate was never that; the probe is, and it is now ~3x cheaper. Not removed.
recovered by this. Measured against the fixed baseline it is essentially
unchanged (
deeplist+9.07%,retain1+8.48%,retain_wide+4.80%,retain+4.26%); onlyshapesmoves materially (+5.16% -> +3.02%). perf(object): remove the derivable object_type and field_count header words — 56 B -> 48 B #8113'sarm-C partition already said why:
deeplistis +8.28% SIZE / −0.07% CODE andretain1+6.75% SIZE / +1.16% CODE. The big rows are footprint-coupled,and nothing done to the shape table can reach them.
What #8157 does change is the absolute position:
main+ #8157 + #8122 isfaster than
mainon 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 != 0test ahead ofobject_is_regularinelement_class_of_bits— strictly less work, conjunction unchanged, and itskips 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.
perf(shapes): stop paying SipHash on every ShapeId probe, and pin the…
docs(changelog): record where the residual instruction cost is, and t…
docs(changelog): record where the residual instruction cost is, and t…
perf(gc, codegen): recover the instruction cost of the 56 B → 48 B he…
perf(runtime): resolve the param guard's per-object facts once per ob…
batch: land #8876, #8884 (#8887)
perf(runtime): word-at-a-time integer folds in FastKeyHasher + fast h…
perf(runtime): word-at-a-time integer folds in FastKeyHasher + fast h…