Skip to content

perf(runtime): js_param_type_guard's interpretive walk dominates the remaining ~11pp (fixed per-call overhead now largely removed) #8202

Closed

Description

@proggeramlug

Where this number comes from

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:

bench validator total recovered by #8201 (scalar) remaining (structural)
interp −19.6% −8.3% −11.3pp (~1.70B instr)
iso_miss −16.3% −7.0% −9.4pp (~1.66B instr)
tree −33.8% −31.8% −2.0pp (leaf-call + wrapper hop on 10.5M Tier-B self-recursion round-trips — that part is #8169)
tree_wide −17.0% −15.9% −1.1pp (same, #8169)

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:

  1. Descriptor::parse — re-validates magic/root/offset-table of an immutable, compiler-emitted rodata blob that cannot change between calls;
  2. 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;
  3. seen_or_insert — a linear scan of up to 64 entries per node visited.

Candidate shapes (in the order I would try)

  1. Lazy GuardState: construct the visited set only when the first container op actually recurses; a flat object/small union check needs none of it.
  2. 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.
  3. 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.

Refs #8079, #8201, #8169.

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    on Aug 16, 2026
  2. proggeramlug commented on Aug 16, 2026

    @proggeramlug
    ContributorAuthor

    #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):

    row validator cost, main after #8238 delta share of program
    interp 1.671 B 1.422 B −14.9% 12.05% → 10.45%
    iso_miss 1.611 B 1.400 B −13.1% 9.76% → 8.60%

    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

    Two diagnostic runtimes, both against interp:

    arm interp instructions vs main
    main 13.83 B —
    guard always returns 1 (always specialize) 12.157 B −12.1%
    guard always fails (magic mismatch → always generic) 12.182 B −12.1%

    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.

  3. proggeramlug commented on Aug 16, 2026

    @proggeramlug
    ContributorAuthor

    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.

  4. proggeramlug commented on Aug 16, 2026

    @proggeramlug
    ContributorAuthor

    Status after this round. Three PRs landed against the fixed per-call overhead; the issue's headline premise needs correcting rather than closing.

    Landed

    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.

  5. 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
  6. proggeramlug commented on Aug 17, 2026

    @proggeramlug
    ContributorAuthor

    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.

    Sweep evidence: gc-handoff/current-sweep-2026-08-17/.

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

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions