Skip to content

perf(codegen): recover guarded ordinary-parameter specialization lost by #8033 #8079

Description

@proggeramlug

Summary

PR #8033 correctly stopped treating erased TypeScript binding annotations as runtime representation proofs, but it also removed ordinary function-parameter evidence from generic function bodies. That causes a large, reproducible specialization cliff across the current performance corpus.

Do not revert #8033 or seed the generic body from declared parameter types. The correctness constraint from #7846 is real. We need a guarded parameter-specialized clone (or equivalent entry guard) with the unchanged generic body as fallback.

Current-main regression

Measured at exact main 601a02d235af2d9af1841089426a1da379edac0a.

The clean M1 mini sweep regressed from the previously recorded 843ef621f sweep as follows:

  • Perry/Node wins: 10/19 -> 2/19
  • Perry/scriptc wins: 14/19 -> 8/18
  • Perry geomean across all 19 rows: 3.480x slower
  • clean and separately dirty corroboration agreed within 0.2%

Largest wall-time changes on the clean mini:

benchmark 843ef621f current main change
pipeline 0.1630 s 3.3585 s +1960%
asyncpipe 0.0980 s 1.1216 s +1045%
retain_wide 0.1840 s 1.0826 s +488%
shapes 0.0550 s 0.3183 s +479%
interp 0.8090 s 3.9931 s +394%
iso_miss 0.9380 s 4.4743 s +377%

The current timings also reproduce the older conservative-codegen regbase across 15 common rows: geomean current/regbase is 1.00003x, with a maximum row difference of 1.24%.

Causal A/B

I built a diagnostic-only compiler from exact current main that changed only ordinary compile_function admission:

  • local_types remained unchanged;
  • proven_local_types was seeded from f.params only;
  • module globals and local initializers were excluded;
  • runtime, stdlib, benchmark input, compile flags, and execution environment were otherwise held fixed.

This control is intentionally unsound for lying annotations and is not a proposed patch. It exists only to identify the lost optimization surface.

Every tested benchmark retained byte-exact output. Same-host retired instructions were:

benchmark current main parameter-hint control reduction current/control
asyncpipe 17,054,037,641 1,279,430,024 92.5% 13.329x
interp 64,455,747,802 12,683,767,308 80.3% 5.082x
iso_miss 72,572,449,776 15,332,837,352 78.9% 4.733x
pipeline 52,150,730,952 3,714,983,318 92.9% 14.038x
tree 31,782,383,093 8,896,702,263 72.0% 3.572x

interp was repeated three times in each arm. Current was 64.455-64.478B instructions; the parameter-only control was 12.684-12.699B. The control returns to the previously recorded roughly 11.5-14.1B instruction band.

This proves that lost ordinary-parameter type evidence is the dominant cause of the headline regression.

Mechanism

In current codegen/function.rs:

  1. f.params still populate local_types.
  2. The generic FnCtx initializes proven_local_types empty.
  3. stable_local_type_proof() deliberately reads only proven_local_types and rejects reassigned locals.
  4. Therefore an ordinary typed parameter is unproven throughout the generic body unless a separate guarded clone supplies evidence.

This is especially costly for the interpreter corpus. Hot recursive functions such as evalNode(n: Node, env: Env), lookup(env: Env, name: string), parser functions taking Parser, and lex(src: string) lose typed property, array, string, and numeric lowering at every use.

Current interp --opt-report=json records 8 selections and 64 denials, including:

  • pointer-shape: 0 selected / 49 denied
  • canonical-slot: 7 selected / 3 denied
  • specialized ABI: 1 selected / 12 denied

The diagnostic control does not repair pointer-shape clone routing (it remains 0/49). Its performance recovery comes from letting the ordinary body lowering consume the parameter type again.

Ruled out

  • Mini noise: independent clean/dirty sweeps agree within 0.2%.
  • Native-root GC overhead / Propagate native-root decisions to codegen-unit workers #8071: a compile-time PERRY_RS4GC=0 shadow-root control did not recover performance. asyncpipe retired 17.08B vs 17.07B instructions; interp worsened to 71.30B from 64.63B.
  • Output/correctness differences: Perry remained byte-exact on all 19 sweep rows; the five causal-control programs above were byte-exact in both arms.

Required soundness boundary

TypeScript annotations are erased and may lie. #7846 demonstrates that a declared scalar, class, array, or capture type cannot by itself select an unguarded representation or behavior. The generic body and GC rooting must remain conservative.

A sound recovery should therefore build on guarded clone-and-route machinery:

  1. Select eligible ordinary parameter tuples without changing the generic entry/body semantics.
  2. Emit runtime guards for the complete tuple before entering a specialized body clone.
  3. Populate proven_local_types only inside the successfully guarded clone.
  4. Preserve the generic boxed fallback for failed guards and indirect/unknown callers.
  5. Route direct call sites only when their current argument facts prove the same tuple.
  6. Support recursive calls from a guarded clone without reintroducing annotation trust.
  7. Add object/array/discriminated-union parameter coverage; existing specialized ABI coverage is primarily scalar/string/typed-array and does not cover the hot Node/Env shapes here.

This likely belongs with the clone-and-route work already referenced by the pointer-shape report as #7034 section 1.

Acceptance

  • Lying-annotation regressions, including test_gap_7846_local_binding_type_proofs.ts, pass through the generic fallback with Node-equivalent output.
  • Positive tests prove the runtime guard is present and the specialized clone receives parameter proofs only after that guard.
  • Negative tests prove a wrong scalar/object/array/union value cannot enter the clone.
  • Direct, indirect, and recursive call paths retain a valid generic fallback.
  • Precise-root/forced-moving verification remains green for both clone and fallback paths.
  • interp, iso_miss, pipeline, asyncpipe, and tree are remeasured on the clean mini with exact-output gates.
  • The recovered instruction count is structurally pinned or otherwise guarded so this cliff cannot silently recur.

Measurement provenance

Perry was built with:

cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static

Compiles pinned the exact release directory with PERRY_RUNTIME_DIR, PERRY_NO_AUTO_OPTIMIZE=1, PERRY_NO_CACHE=1, and --no-cache. Nothing was built on the mini. The accepted mini cells were warmed and then measured best-of-five in interleaved order with exit/output validation on every run.

Metadata

Metadata

Assignees

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