You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
perf(runtime): js_param_type_guard's interpretive walk dominates the remaining ~11pp (fixed per-call overhead now largely removed) #8202
Measured during the #8079 re-sweep at bfb0707be (quiet-host instructions retired, best-of-5, stdout byte-exact). A diagnostic arm that replaces every js_param_type_guard call with true (routing otherwise unchanged) prices the interpretive validator's total cost; #8201 recovers the scalar share by predicate-equivalent leaf guards. What remains is the structural share:
The interp/iso_miss residual is concentrated in exactly two wrappers: asNum(v: Value) (a 3-arm union of small object literals, called per arithmetic op in evalNode) and peek(p: Parser) (an object whose toks: Token[] walk validates every element per call).
Why the per-call cost is mostly fixed overhead
js_param_type_guard (crates/perry-runtime/src/param_type_guard.rs) pays, on every invocation:
Descriptor::parse — re-validates magic/root/offset-table of an immutable, compiler-emitted rodata blob that cannot change between calls;
GuardState construction — a 768-byte inline_visited: [(usize, u32); 64] zero-init plus spill bookkeeping, even when the value is a 2-field {kind, num} object whose walk touches 3 nodes;
seen_or_insert — a linear scan of up to 64 entries per node visited.
Candidate shapes (in the order I would try)
Lazy GuardState: construct the visited set only when the first container op actually recurses; a flat object/small union check needs none of it.
Parse-once descriptor cache: the descriptor pointer is a rodata address — key a one-time-validated Descriptor by pointer (append-only table; the blob is immutable), skipping step 1 per call.
Caller-side static proofs for the evalNode -> asNum(l) shape, so proven sites bypass the wrapper entirely (spec_return_proofs already exists for return values; asNum's argument is evalNode's return).
Deliberately separate: #8169 (Tier-B guarded clones re-entering through their own public wrapper) owns the tree/tree_wide residual.
#8238 implements candidate 1 (the visited set) and the GuardState init. Measuring it also refuted this issue's premise, so both results are below.
What landed
The visited set was kept unconditionally, so every container the walk touched paid a linear scan of up to 64 inline entries and, past that, a HashSet insert — per array element. peek(p: Parser) therefore recorded one entry per token on every call, and not one could ever be consulted: Token lies on no descriptor cycle and is reachable by exactly one path, so a second arrival at the same (address, node) pair is impossible.
The set is load-bearing only for termination (a value cycle can only walk forever through a node that reaches itself) and for the anti-re-walk bound (a node the traversal can enter twice with the same address). Both are properties of the immutable compiler-emitted graph, so the compiler now computes them — Tarjan plus a saturating "ways in" propagation — and folds the answer into the op byte's high bit. peek: 6 nodes, 0 tracked. asNum: 123 nodes, 15 tracked (exactly the recursive Node/Env cluster). inline_visited also became MaybeUninit, so the ~1 KB stack zeroing per call is gone.
Isolating the validator's own cost by differencing against the same runtime archive, so binary-layout effects cancel (a PGT1 blob under a PGT2 runtime fails every guard):
End to end: interp −1.63%, iso_miss −1.36%, peak RSS unchanged. Exactly 2 of the 19 corpus rows emit a js_param_type_guard call site at all (interp and iso_miss, two each — asNum and peek); after #8201 every other row's guards are typed-abi leaf guards, tree/tree_wide included.
The premise does not hold — the fixed overhead is ~15%, not the dominant share
Removing the whole GuardState init and the entire visited set buys 14.9% of the validator's cost. The other ~10.5pp of interp is the structural walk itself. So candidate 2 (parse-once descriptor cache) is worth chasing only if ~20 instructions/call matter against that; I did not implement it.
The larger result: on these two rows the guard is a net loss
Always-accept and always-reject land within 0.3% of each other. iso_miss is the same shape: 14.868 B vs 14.883 B against 16.51 B for main.
So on these two rows the specialization the validator gates is worth ~0.2% (interp) and ~0.1% (iso_miss), while running the validator costs 12.0% and 9.8%. Both diagnostic arms print the correct checksum, and the always-fail arm keeps iso_miss's misses 0.
That is not a per-call-overhead problem and #8238 does not fix it. It says the gating rule is admitting descriptors that cannot repay themselves — peek's walks every token to license an O(1) body, and asNum's is a 123-node graph to license a kind discriminant the clone still has to test. Since #8201 already moved the scalar descriptors off the validator, "structural descriptor" and "runs js_param_type_guard" are now the same set, which makes the policy lever a narrow one. Worth its own issue against #8094/#8079 rather than more work here; I have not touched the rule.
Method notes
Instructions retired via /usr/bin/time -l, best-of-3, each arm built from its own tree with the same -p perry -p perry-runtime-static -p perry-stdlib-static and PERRY_RUNTIME_DIR pinned per arm, all 19 stdouts byte-compared.
★ A caution for anyone re-measuring this corpus: the two arms' libperry_runtime.a differ in exactly two functions out of 11,185 in the crate's codegen unit (js_param_type_guard 808 → 316 bytes, GuardState::matches +28), every other function byte-identical — yet pipeline, which emits zero guard call sites and executes neither function, moved −3.9%. Two main builds from identical source came out byte-identical (archive and perry binary alike) and repeat runs of one binary spread ~0.1%, so that is not build nondeterminism; it is what an address-hash-sensitive program does when the heap layout shifts. On this corpus a whole-program delta under ~1% on a row with no guard call sites should be read as layout noise, not effect.
Measured this at 07c8040bf. The residual reproduces, but the mechanism in the issue body is mostly wrong, so recording the decomposition before anyone else spends a day on it. PR #8242 takes the part that is real (interp −1.51%, iso_miss −1.24%); the rest is not a fixed-overhead problem at all.
The ceiling still stands
SKIPVAL arm (descriptor validation replaced by true, routing otherwise unchanged, all stdout/stderr byte-exact), 19-program corpus, instructions retired, best-of-3:
bench
SKIPVAL
interp
−13.36%
iso_miss
−11.04%
Close enough to the −11.3pp / −9.4pp in the body. So there is genuinely ~11–13% sitting in js_param_type_guard.
Two of the three named costs do not exist
"GuardState construction — a 768-byte inline_visited: [(usize, u32); 64] zero-init". This is never paid. Disassembling the release runtime, js_param_type_guard opens:
sub sp, sp, #0x470
str xzr, [sp]
One store. LLVM already elides the array's zero-init — every element is written by seen_or_insert before it can be read, so the initializing stores are dead. I built the MaybeUninit version first and A/B'd it: −0.06% on interp, −0.00% on iso_miss. A no-op, because there was nothing to remove.
This claim is also in #8201's merged PR description and verbatim in a scalar_descriptor_rep doc comment in crates/perry-codegen/src/codegen/param_guard.rs ("~450 instructions per call — descriptor parse plus a 768-byte GuardState init"). #8201's measured result is unaffected — routing scalars to the leaf guards removes the whole call, whatever it costs — but the stated reason for the cost should be corrected so it stops propagating.
"Descriptor::parse — re-validates magic/root/offset-table". It is O(1) (it checks the first and last offset entries, not the table), and it ablates to −0.26% / −0.19%. Candidate 2 (parse-once descriptor cache) is chasing a quarter of a point, and on Darwin — where TLS access is a call, not a local-exec load — a pointer-keyed cache would plausibly cost more than the parse it replaces.
Where the time actually is
Each dial best-of-3, outputs byte-exact:
ablation
interp
iso_miss
return 1 (ceiling)
−13.36%
−11.04%
no shape-table probe (plain_object)
−0.88%
−0.69%
no accessor-descriptor probe (own_data_field)
−0.74%
−0.65%
no descriptor parse
−0.26%
−0.19%
Everything nameable sums to under 2 of 13.4 points. The other ~11pp is the interpretive walk itself — union-arm iteration, key-string comparison, per-field dispatch — spread thin with no dominant term. sample agrees: the hot leaves include _platform_memcmp, i.e. the per-field key compares.
No fixed-per-call fix reaches that. It has to be avoided, not made cheaper — which is candidate 3 (caller-side static proofs so a proven evalNode -> asNum(l) skips the wrapper) or #8169's wrapper round-trip.
Two corrections to the shapes named in the body
peek(p: Parser) is cold.interp parses three ~28-token programs 40 times, so the whole toks: Token[] walk runs ~200k times against a 13.8B-instruction program. Its descriptor is 6 nodes. The hot guard is asNum's Value union — both evalNode call sites go through the public wrapper.
asNum's descriptor is 123 nodes. Any fix that bounds work by descriptor node count must clear 123, not 64. I lost a build to a 64-node cap that silently switched itself off on exactly the descriptor that mattered.
I did try the "flat objects skip the visited set" idea from candidate 1 (a per-node bounded/acyclic/container-free classification, so Token-shaped elements stop spending an entry each — past the 64-entry inline table every insert allocates into and hashes against a HashSet, and for distinct array elements every one is a guaranteed miss). It is sound and I have it working, but on this corpus it measures as noise, because the array-of-flat-objects shape it targets is exactly the cold peek path. Worth keeping in mind if a workload ever validates a large array per call — it is a real pathology, just not one this corpus has.
Suggest retitling this issue away from "fixed per-call overhead" — the fixed per-call overhead is ~2pp and #8242 takes most of it. The 11pp is the walk.
perf(runtime): resolve the param guard's per-object facts once per object (#8202) #8242 resolves the per-object facts once per object: the per-field get_accessor_descriptor(address, name) probe becomes a single owner_may_have_descriptor_entries meta-word read, kept as a prefilter so the exact per-field check still runs whenever the summary says an accessor may exist.
perf(codegen, runtime): stop recording parameter-guard visits that can never be consulted #8238 stops recording visits for descriptor nodes that can never be consulted (Tarjan over the emitted graph: peek 6 nodes / 0 tracked, asNum 123 nodes / 15 tracked), and makes GuardState::inline_visitedMaybeUninit so a guarded call no longer zeroes ~1 KB of stack. Validator's own cost: interp −14.9%, iso_miss −13.1%.
Two corrections to the issue text
peek(p: Parser) is cold, despite being named here. interp parses three ~28-token programs 40 times, so the per-token walk runs ~200k times against a 13.8 B-instruction program. The hot guard is asNum's Value union.
asNum's descriptor is 123 nodes (peek's is 6). Any fix that caps work by descriptor node count has to clear 123, not 64.
What is actually left
The named fixed costs summed to under 2 of the 13.4 points. The remaining ~11pp is the interpretive walk itself — union-arm iteration, key-string comparison, per-field dispatch — spread thin with no dominant term; sample puts _platform_memcmp (the per-field key compares) among the hot leaves. A cheaper validator does not get there. That needs either caller-side static proofs, so a proven evalNode -> asNum(l) bypasses the wrapper entirely, or #8169's wrapper round-trip.
So this stays open as "the interpretive walk itself", not "fixed per-call overhead" — the fixed overhead is now largely gone.
One correctness note carried in from review of #8238: its tracking decision is derived from the descriptor graph, but "entered twice with the same address" is a property of the value. One object held at several fields re-enters an untracked node at entries == 1, and nesting that duplication multiplies. MAX_DEPTH bounds depth, not total work, so #8238 shipped with a MAX_VISITS cap that fails the guard to the generic function, covered by nested_value_duplication_through_untracked_nodes_is_bounded.
changed the title [-]perf(runtime): js_param_type_guard fixed per-call overhead dominates structural descriptor checks (interp -11.3pp / iso_miss -9.4pp after #8201)[/-][+]perf(runtime): js_param_type_guard's interpretive walk dominates the remaining ~11pp (fixed per-call overhead now largely removed)[/+]on Aug 16, 2026
2026-08-17 four-engine sweep numbers for the two rows this issue is about. The interpretive walk is now the largest remaining Perry-vs-Node gap in the corpus.
benchmark
Perry
Node
P/Node
Perry 08-15
Δ since 08-15
iso_miss
0.9038
0.3351
2.70×
0.9909
−8.8%
interp
0.7305
0.3230
2.26×
0.8528
−14.3%
Those are the #1 and #2 worst ratios against Node across all 19 benchmarks. Instructions: iso_miss 16.622 B vs Node's 6.265 B, interp 13.549 B vs 5.952 B — so this is executed work, not scheduling.
The param-guard fixes that landed since 08-15 bought −8.8% and −14.3%, which is real but consistent with what this issue already concluded: the fixed per-call overhead is largely gone and what remains is the walk itself. #8242 and #8238 took the named fixed costs; the residual is union-arm iteration, key-string comparison and per-field dispatch, spread thin.
Two things the sweep adds:
Perry is now slower than Porffor on interp (0.7117 vs 0.7305) and level on iso_miss (0.9829 vs 0.9038 — Perry ahead). Porffor was 5.58× and 4.55× behind on these rows in the 08-14 sweep. That is a competitive signal, not just an internal one.
Perry beats scriptc comfortably on both (2.0466 → 2.4670 for scriptc), so the gap is specifically against V8's inline caches rather than TS-to-native peers generally.
The remaining ~11pp needs the third candidate named in this issue — caller-side static proofs, so a proven evalNode -> asNum(l) bypasses the wrapper — or #8169's wrapper round-trip. A cheaper validator does not close a 2.7× gap.
Activity
perf(codegen, runtime): stop recording parameter-guard visits that ca…
proggeramlug commentedon Aug 16, 2026
#8238 implements candidate 1 (the visited set) and the
GuardStateinit. Measuring it also refuted this issue's premise, so both results are below.What landed
The visited set was kept unconditionally, so every container the walk touched paid a linear scan of up to 64 inline entries and, past that, a
HashSetinsert — per array element.peek(p: Parser)therefore recorded one entry per token on every call, and not one could ever be consulted:Tokenlies on no descriptor cycle and is reachable by exactly one path, so a second arrival at the same(address, node)pair is impossible.The set is load-bearing only for termination (a value cycle can only walk forever through a node that reaches itself) and for the anti-re-walk bound (a node the traversal can enter twice with the same address). Both are properties of the immutable compiler-emitted graph, so the compiler now computes them — Tarjan plus a saturating "ways in" propagation — and folds the answer into the op byte's high bit.
peek: 6 nodes, 0 tracked.asNum: 123 nodes, 15 tracked (exactly the recursiveNode/Envcluster).inline_visitedalso becameMaybeUninit, so the ~1 KB stack zeroing per call is gone.Isolating the validator's own cost by differencing against the same runtime archive, so binary-layout effects cancel (a
PGT1blob under aPGT2runtime fails every guard):maininterpiso_missEnd to end:
interp−1.63%,iso_miss−1.36%, peak RSS unchanged. Exactly 2 of the 19 corpus rows emit ajs_param_type_guardcall site at all (interpandiso_miss, two each —asNumandpeek); after #8201 every other row's guards are typed-abi leaf guards,tree/tree_wideincluded.The premise does not hold — the fixed overhead is ~15%, not the dominant share
Removing the whole
GuardStateinit and the entire visited set buys 14.9% of the validator's cost. The other ~10.5pp ofinterpis the structural walk itself. So candidate 2 (parse-once descriptor cache) is worth chasing only if ~20 instructions/call matter against that; I did not implement it.The larger result: on these two rows the guard is a net loss
Two diagnostic runtimes, both against
interp:interpinstructionsmainmainAlways-accept and always-reject land within 0.3% of each other.
iso_missis the same shape: 14.868 B vs 14.883 B against 16.51 B formain.So on these two rows the specialization the validator gates is worth ~0.2% (
interp) and ~0.1% (iso_miss), while running the validator costs 12.0% and 9.8%. Both diagnostic arms print the correct checksum, and the always-fail arm keepsiso_miss'smisses 0.That is not a per-call-overhead problem and #8238 does not fix it. It says the gating rule is admitting descriptors that cannot repay themselves —
peek's walks every token to license anO(1)body, andasNum's is a 123-node graph to license akinddiscriminant the clone still has to test. Since #8201 already moved the scalar descriptors off the validator, "structural descriptor" and "runsjs_param_type_guard" are now the same set, which makes the policy lever a narrow one. Worth its own issue against #8094/#8079 rather than more work here; I have not touched the rule.Method notes
Instructions retired via
/usr/bin/time -l, best-of-3, each arm built from its own tree with the same-p perry -p perry-runtime-static -p perry-stdlib-staticandPERRY_RUNTIME_DIRpinned per arm, all 19 stdouts byte-compared.★ A caution for anyone re-measuring this corpus: the two arms'
libperry_runtime.adiffer in exactly two functions out of 11,185 in the crate's codegen unit (js_param_type_guard808 → 316 bytes,GuardState::matches+28), every other function byte-identical — yetpipeline, which emits zero guard call sites and executes neither function, moved −3.9%. Twomainbuilds from identical source came out byte-identical (archive andperrybinary alike) and repeat runs of one binary spread ~0.1%, so that is not build nondeterminism; it is what an address-hash-sensitive program does when the heap layout shifts. On this corpus a whole-program delta under ~1% on a row with no guard call sites should be read as layout noise, not effect.proggeramlug commentedon Aug 16, 2026
Measured this at
07c8040bf. The residual reproduces, but the mechanism in the issue body is mostly wrong, so recording the decomposition before anyone else spends a day on it. PR #8242 takes the part that is real (interp−1.51%,iso_miss−1.24%); the rest is not a fixed-overhead problem at all.The ceiling still stands
SKIPVAL arm (descriptor validation replaced by
true, routing otherwise unchanged, all stdout/stderr byte-exact), 19-program corpus, instructions retired, best-of-3:Close enough to the −11.3pp / −9.4pp in the body. So there is genuinely ~11–13% sitting in
js_param_type_guard.Two of the three named costs do not exist
"
GuardStateconstruction — a 768-byteinline_visited: [(usize, u32); 64]zero-init". This is never paid. Disassembling the release runtime,js_param_type_guardopens:One store. LLVM already elides the array's zero-init — every element is written by
seen_or_insertbefore it can be read, so the initializing stores are dead. I built theMaybeUninitversion first and A/B'd it: −0.06% on interp, −0.00% on iso_miss. A no-op, because there was nothing to remove.This claim is also in #8201's merged PR description and verbatim in a
scalar_descriptor_repdoc comment incrates/perry-codegen/src/codegen/param_guard.rs("~450 instructions per call — descriptor parse plus a 768-byteGuardStateinit"). #8201's measured result is unaffected — routing scalars to the leaf guards removes the whole call, whatever it costs — but the stated reason for the cost should be corrected so it stops propagating."
Descriptor::parse— re-validates magic/root/offset-table". It is O(1) (it checks the first and last offset entries, not the table), and it ablates to −0.26% / −0.19%. Candidate 2 (parse-once descriptor cache) is chasing a quarter of a point, and on Darwin — where TLS access is a call, not a local-exec load — a pointer-keyed cache would plausibly cost more than the parse it replaces.Where the time actually is
Each dial best-of-3, outputs byte-exact:
plain_object)own_data_field)Everything nameable sums to under 2 of 13.4 points. The other ~11pp is the interpretive walk itself — union-arm iteration, key-string comparison, per-field dispatch — spread thin with no dominant term.
sampleagrees: the hot leaves include_platform_memcmp, i.e. the per-field key compares.No fixed-per-call fix reaches that. It has to be avoided, not made cheaper — which is candidate 3 (caller-side static proofs so a proven
evalNode -> asNum(l)skips the wrapper) or #8169's wrapper round-trip.Two corrections to the shapes named in the body
peek(p: Parser)is cold.interpparses three ~28-token programs 40 times, so the wholetoks: Token[]walk runs ~200k times against a 13.8B-instruction program. Its descriptor is 6 nodes. The hot guard isasNum'sValueunion — bothevalNodecall sites go through the public wrapper.asNum's descriptor is 123 nodes. Any fix that bounds work by descriptor node count must clear 123, not 64. I lost a build to a 64-node cap that silently switched itself off on exactly the descriptor that mattered.I did try the "flat objects skip the visited set" idea from candidate 1 (a per-node bounded/acyclic/container-free classification, so
Token-shaped elements stop spending an entry each — past the 64-entry inline table every insert allocates into and hashes against aHashSet, and for distinct array elements every one is a guaranteed miss). It is sound and I have it working, but on this corpus it measures as noise, because the array-of-flat-objects shape it targets is exactly the coldpeekpath. Worth keeping in mind if a workload ever validates a large array per call — it is a real pathology, just not one this corpus has.Suggest retitling this issue away from "fixed per-call overhead" — the fixed per-call overhead is ~2pp and #8242 takes most of it. The 11pp is the walk.
perf(runtime): resolve the param guard's per-object facts once per ob…
perf(codegen, runtime): stop recording parameter-guard visits that ca…
proggeramlug commentedon Aug 16, 2026
Status after this round. Three PRs landed against the fixed per-call overhead; the issue's headline premise needs correcting rather than closing.
Landed
get_accessor_descriptor(address, name)probe becomes a singleowner_may_have_descriptor_entriesmeta-word read, kept as a prefilter so the exact per-field check still runs whenever the summary says an accessor may exist.peek6 nodes / 0 tracked,asNum123 nodes / 15 tracked), and makesGuardState::inline_visitedMaybeUninitso a guarded call no longer zeroes ~1 KB of stack. Validator's own cost:interp−14.9%,iso_miss−13.1%.Two corrections to the issue text
peek(p: Parser)is cold, despite being named here.interpparses three ~28-token programs 40 times, so the per-token walk runs ~200k times against a 13.8 B-instruction program. The hot guard isasNum'sValueunion.asNum's descriptor is 123 nodes (peek's is 6). Any fix that caps work by descriptor node count has to clear 123, not 64.What is actually left
The named fixed costs summed to under 2 of the 13.4 points. The remaining ~11pp is the interpretive walk itself — union-arm iteration, key-string comparison, per-field dispatch — spread thin with no dominant term;
sampleputs_platform_memcmp(the per-field key compares) among the hot leaves. A cheaper validator does not get there. That needs either caller-side static proofs, so a provenevalNode -> asNum(l)bypasses the wrapper entirely, or #8169's wrapper round-trip.So this stays open as "the interpretive walk itself", not "fixed per-call overhead" — the fixed overhead is now largely gone.
One correctness note carried in from review of #8238: its tracking decision is derived from the descriptor graph, but "entered twice with the same address" is a property of the value. One object held at several fields re-enters an untracked node at
entries == 1, and nesting that duplication multiplies.MAX_DEPTHbounds depth, not total work, so #8238 shipped with aMAX_VISITScap that fails the guard to the generic function, covered bynested_value_duplication_through_untracked_nodes_is_bounded.[-]perf(runtime): js_param_type_guard fixed per-call overhead dominates structural descriptor checks (interp -11.3pp / iso_miss -9.4pp after #8201)[/-][+]perf(runtime): js_param_type_guard's interpretive walk dominates the remaining ~11pp (fixed per-call overhead now largely removed)[/+]proggeramlug commentedon Aug 17, 2026
2026-08-17 four-engine sweep numbers for the two rows this issue is about. The interpretive walk is now the largest remaining Perry-vs-Node gap in the corpus.
iso_missinterpThose are the #1 and #2 worst ratios against Node across all 19 benchmarks. Instructions:
iso_miss16.622 B vs Node's 6.265 B,interp13.549 B vs 5.952 B — so this is executed work, not scheduling.The param-guard fixes that landed since 08-15 bought −8.8% and −14.3%, which is real but consistent with what this issue already concluded: the fixed per-call overhead is largely gone and what remains is the walk itself. #8242 and #8238 took the named fixed costs; the residual is union-arm iteration, key-string comparison and per-field dispatch, spread thin.
Two things the sweep adds:
interp(0.7117 vs 0.7305) and level oniso_miss(0.9829 vs 0.9038 — Perry ahead). Porffor was 5.58× and 4.55× behind on these rows in the 08-14 sweep. That is a competitive signal, not just an internal one.The remaining ~11pp needs the third candidate named in this issue — caller-side static proofs, so a proven
evalNode -> asNum(l)bypasses the wrapper — or #8169's wrapper round-trip. A cheaper validator does not close a 2.7× gap.Sweep evidence:
gc-handoff/current-sweep-2026-08-17/.