Skip to content

perf(codegen): interp 0.780 -> 0.675 s, iso_miss 1.061 -> 0.967 s — let a declared array type reach the guarded element read and the inline .length - #7890

Merged
proggeramlug merged 2 commits into
mainfrom
perf/interp-round7
Aug 11, 2026
Merged

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Round 7 of the interp campaign (3.96 s → 1.893 → 1.499 → 1.237 → 0.844 → 0.784 → this).

Two halves of one mechanism: what a program may do with an array type that came from an
annotation rather than from an initializer that proved an array.

A. e.vals[i] / p.toks[p.pos] — a property read used directly as a receiver

#7854 taught refine_type_from_init to recover a receiver's declared property type for a
local (const names = e.names on type Env = { names: string[] }), which is why
names[i] is an inline element read today. It did nothing for the same read used directly
as the receiver
e.vals[i], p.toks[p.pos] — because the HIR types a PropertyGet off
a UNION receiver as Any (perry-hir/src/analysis/value_types.rs, the Union arm), so
static_type_of answers Any and expr/index_get.rs routes the read to the
unknown-receiver dispatcher js_dyn_index_get.

declared_array_property_claim answers for that shape, and index_get.rs consumes it in
exactly two places: it suppresses the recv_unknown route, and it admits the receiver to the
array arm.

It is a claim, not a proof, and the tier it unlocks is the one that tolerates a claim.
lower_guarded_array_index_get re-checks GC_TYPE_ARRAY, the forwarding flag, per-array
descriptors, the prototype latch and the bounds on the receiver itself, and routes every
failure to js_typed_feedback_array_index_get_fallback_boxed. A violated claim costs a
predicted branch and returns the same answer — the deal #7854 records for element reads, and
the same guard #6132 relies on to make a typed-array-valued member receiver safe here.

B. .length no longer refuses a declared-only array local

#7854 recorded these locals in FnCtx::declared_only_array_locals and had the inline
.length arm refuse them. The reason was specific and correct at the time: the arm's inline
half was guarded, but its fallback was js_value_length_f64, which answered 0 for
every value that carries no length where JS answers undefined, and continued instead of
throwing for a nullish receiver (#7853).

#7862 replaced that fallback with js_value_length_property_f64 — ordinary property
semantics: undefined for a missing property, the real value for a non-numeric one, normal
object / function / native / proxy dispatch, and a catchable TypeError for a nullish
receiver. It did not lift the refusal that existed only because of the old fallback.

This lifts it, and deletes the set and its classifier with it — a mode that no longer gates
anything is not a decision that has been made. declared_only_numeric_locals (#7773) is
untouched and stays: its consumer is an arithmetic operator with no guarded fallback.

Measured — quiet M1 mini, best-of-5, exit-checked, under the token-guarded lock

Load 2.00 before / 1.81 after, zero foreign benchmark processes at both ends.

bench base (564bd997b) this PR delta
interp 0.7796 0.6748 −13.4%
iso_miss 1.0607 0.9670 −8.8%
the other 17 byte-identical binaries −1.8%..+1.0%

17 of the 19 corpus programs compile byte-identically (same basename, different
directories, both arms on the same PERRY_RUNTIME_DIR — the change touches nothing under
perry-runtime/perry-stdlib), and the two that differ are exactly the two containing a
type-alias-over-array receiver. That is the no-regression evidence and the in-run noise
floor at once: ±1.0%.

vs node 26.5.1 (0.321 / 0.334): interp 2.63× → 2.10×, iso_miss 3.18× → 2.90×.

Verified live, not assumed

  • interp: js_dyn_index_get call sites 1 → 0 (--trace llvm).
  • test_gap_declared_field_type_refine_guarded.ts: plen.fast blocks 0 → 5.

Sabotage — run, and RED

test-files/test_gap_declared_field_type_refine_guarded.ts and
test-files/test_gap_7853_declared_array_length_runtime_value.ts feed a string[]-declared
local an array, a string, a number, an array-like object with a numeric length, an
array-like object with a non-numeric length, a function, a typed array, null and
undefined, through an alias, an interface and a class, and require node-identical output on
every row.

Planting GC_TYPE_OBJECT into the inline .length arm's has_length predicate turns
alias|len=7 into alias|len=1 — the header's object_type word read as a length —
and both tests go red. They detect the exact hazard the refusal used to prevent.

(Worth recording: test_gap_7853_… alone would have been vacuous for the refusal. Its
const bag = makeBag(value) leaves bag at Any, so items never entered
declared_only_array_locals and that file already took the inline arm on main. The
_guarded file is the one whose plen.fast count moves.)

Other validation

  • 19-program corpus byte-exact against node --experimental-strip-types + exit 0, both arms.
  • Canary checksum 437840 misses 0 on both arms, gating on the miss counter.
  • GC stress: whole corpus byte-exact under
    PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=200 PERRY_GC_VERIFY_EVACUATION=1,
    with the instrument shown live — 50 retired sets on iso_miss, 38 on interp, 18 MB+
    protected per set.
  • The measured binaries are the shipped code: every one of the 19 timed binaries was
    re-compiled from branch HEAD after the sabotage was reverted and cmps byte-identical.

https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2

Follow-up commit: coverage for the shape this PR actually adds

#7854's test always routes through an intermediate local (const items = e.items),
so it does not cover a PropertyGet used directly as the receiver — which is
exactly what half A adds. test-files/test_gap_7890_declared_array_receiver_element_read.ts
does: e.items[i] / e.items.length through a type alias, an interface, a class,
a nullable reassigned cursor and a nested chain, handed an array, a string, a number,
an array-like object with numeric and non-numeric length, a typed array, a function,
null and undefined, plus negative / fractional / out-of-range indexes and a store
through the same shape. Byte-identical to node on every row.

Live, not decorative: on that file the new arm's arr.fast guarded-read blocks go
11 → 15 and its js_dyn_index_get calls go 5 → 1.

Summary by CodeRabbit

  • Bug Fixes
    • Improved reads and writes for declared array and tuple properties in guarded element access.
    • .length now follows standard property behavior, with safe handling for non-arrays, missing properties, proxies, native objects, and nullish values.
    • Improved handling across aliases, interfaces, classes, nullable values, nested properties, and runtime values that differ from declarations.
    • Preserved numeric property classification and existing runtime behavior.

…et a declared array type reach the guarded element read and the inline .length

Two halves of one mechanism: what a program may do with an array type that came
from an annotation rather than from an initializer that proved an array.

A. `e.vals[i]` / `p.toks[p.pos]`. #7854 recovered a receiver's declared property
   type for a LOCAL (`const names = e.names`), never for the read used directly
   as a receiver — the HIR types a PropertyGet off a UNION receiver as `Any`, so
   `index_get.rs` routed those to `js_dyn_index_get`. The tier this unlocks,
   `lower_guarded_array_index_get`, re-checks GC_TYPE_ARRAY, forwarding,
   descriptors, the prototype latch and the bounds on the receiver itself, so a
   violated claim costs a branch and returns the same answer.

B. `.length` no longer refuses a declared-only array local. #7854 refused them
   because the arm's fallback was `js_value_length_f64`, which answered 0 where
   JS answers `undefined` and did not throw on a nullish receiver (#7853).
   #7862 replaced that fallback with `js_value_length_property_f64` and left the
   refusal standing. `declared_only_array_locals` and
   `refined_array_type_is_declared_only` are deleted with it;
   `declared_only_numeric_locals` (#7773) is untouched.

Quiet M1 mini, best-of-5, exit-checked: interp 0.7796 -> 0.6748 (-13.4%),
iso_miss 1.0607 -> 0.9670 (-8.8%). 17 of the 19 corpus programs compile
byte-identically and the two that differ are exactly the two with a type-alias
over an array; noise floor from those 17 is +-1.0%.

Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Declared array and tuple property claims now enable guarded element reads and inline .length handling. Obsolete declared-only array/string local tracking was removed, while numeric-local tracking remains. Regression coverage includes invalid runtime receivers and indexes.

Changes

Declared array claim handling

Layer / File(s) Summary
Declared claims for element reads
crates/perry-codegen/src/type_analysis/refine.rs, crates/perry-codegen/src/type_analysis.rs, crates/perry-codegen/src/expr/index_get.rs, test-files/test_gap_7890_declared_array_receiver_element_read.ts
Declared array or tuple property claims are recognized and accepted by guarded element-read lowering. Unknown receivers use the dynamic path only when no claim exists. Tests cover aliases, interfaces, classes, nested properties, invalid runtime values, indexes, and writes.
Length fallback and tracking removal
crates/perry-codegen/src/expr/property_get.rs, crates/perry-codegen/src/stmt/let_stmt.rs, crates/perry-codegen/src/expr/mod.rs, crates/perry-codegen/src/codegen/*.rs, changelog.d/7890-declared-array-claim-element-reads.md
Declared receivers can use guarded .length lowering with ordinary property fallback. Obsolete declared-only array/string tracking and its FnCtx initialization were removed. Numeric-local tracking remains.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant index_get
  participant declared_array_property_claim
  participant guarded_array_fast_path
  index_get->>declared_array_property_claim: check receiver property claim
  declared_array_property_claim-->>index_get: return array or tuple claim
  index_get->>guarded_array_fast_path: lower guarded element read
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7862: Directly precedes this change in declared-array .length fallback handling.
  • PerryTS/perry#7603: Also changes guarded array element-read handling in index_get.rs.
  • PerryTS/perry#7810: Also changes array element-read refinement in type_analysis/refine.rs.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the code-generation change and its performance impact, although it is longer than preferred.
Description check ✅ Passed The description clearly explains the changes and includes extensive validation results, but it omits the template headings and related-issue section.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/interp-round7

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
changelog.d/7890-declared-array-claim-element-reads.md (1)

1-60: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Write one release-note entry for the shipped behavior.

Replace the “Round 7” framing and separate A/B development slices with one coherent summary of the guarded declared-array reads and corrected .length fallback. Keep the validation details after that summary.

Based on learnings: describe the final shipped behavior as one coherent release-note entry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@changelog.d/7890-declared-array-claim-element-reads.md` around lines 1 - 60,
Rewrite the changelog entry’s opening to remove the “Round 7” framing and merge
sections A and B into one concise release-note summary covering guarded element
reads from declared array properties and corrected .length semantics for
declared-only array locals. Preserve the existing validation details and
sabotage/test coverage after the unified summary, without retaining separate
development-oriented sections.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/expr/index_get.rs`:
- Line 1178: Update the claimed_array handling around is_array_expr so
non-numeric and runtime-key accesses do not unbox an unchecked receiver before
js_array_get_index_or_string. Route claimed receivers through the boxed
fallback, or add an equivalent runtime array guard, while preserving the
existing direct path for proven arrays.

---

Nitpick comments:
In `@changelog.d/7890-declared-array-claim-element-reads.md`:
- Around line 1-60: Rewrite the changelog entry’s opening to remove the “Round
7” framing and merge sections A and B into one concise release-note summary
covering guarded element reads from declared array properties and corrected
.length semantics for declared-only array locals. Preserve the existing
validation details and sabotage/test coverage after the unified summary, without
retaining separate development-oriented sections.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ec3599c5-08ff-46fa-a1e8-eaf7f57b6a5b

📥 Commits

Reviewing files that changed from the base of the PR and between c5cf2fd and 534a7d0.

📒 Files selected for processing (11)
  • changelog.d/7890-declared-array-claim-element-reads.md
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/expr/index_get.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/property_get.rs
  • crates/perry-codegen/src/stmt/let_stmt.rs
  • crates/perry-codegen/src/type_analysis.rs
  • crates/perry-codegen/src/type_analysis/refine.rs
💤 Files with no reviewable changes (5)
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/expr/mod.rs

// 3. Anything else → fall back to dynamic object field
// access by stringifying the index at runtime
if is_array_expr(ctx, object) {
if is_array_expr(ctx, object) || claimed_array {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline crates/perry-codegen/src/expr/index_get.rs --match lower --view expanded

rg -n -C 10 \
  '\b(js_array_get_index_or_string|lower_array_index_get_via_runtime_key|js_typed_feedback_array_index_get_fallback_boxed)\b' \
  crates

rg -n -C 6 \
  'declared_array_property_claim|claimed_array|test_gap_declared_field_type_refine_guarded|test_gap_7853_declared_array_length_runtime_value' \
  crates test-files changelog.d 2>/dev/null || true

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- index_get.rs relevant lowering ---'
sed -n '1080,1275p' crates/perry-codegen/src/expr/index_get.rs

printf '%s\n' '--- runtime boxed fallback ---'
sed -n '2088,2165p' crates/perry-runtime/src/typed_feedback.rs

printf '%s\n' '--- array helper implementation ---'
sed -n '1615,1665p' crates/perry-runtime/src/array/indexing.rs

printf '%s\n' '--- claim definitions and consumers ---'
rg -n -C 8 'declared_array_property_claim|claimed_array|array_property_claim' \
  crates/perry-codegen/src crates/perry-codegen/tests

Repository: PerryTS/perry

Length of output: 25147


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- remaining array lowering ---'
sed -n '1230,1325p' crates/perry-codegen/src/expr/index_get.rs

printf '%s\n' '--- guarded array getter ---'
rg -n -A 120 -B 10 'fn lower_guarded_array_index_get' crates/perry-codegen/src/expr/index_get.rs

printf '%s\n' '--- claim implementation ---'
sed -n '90,150p' crates/perry-codegen/src/type_analysis/refine.rs

printf '%s\n' '--- unbox_to_i64 implementation and uses ---'
rg -n -A 20 -B 8 'fn unbox_to_i64|unbox_to_i64\(' crates/perry-codegen/src | head -240

Repository: PerryTS/perry

Length of output: 5375


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- lower_guarded_array_index_get references and definition ---'
rg -n -C 5 'lower_guarded_array_index_get' crates/perry-codegen/src crates/perry-codegen/tests || true

printf '%s\n' '--- unbox_to_i64 definition ---'
rg -n -C 12 'fn unbox_to_i64' crates/perry-codegen/src || true

printf '%s\n' '--- all unbox_to_i64 call sites in index_get.rs ---'
rg -n -C 3 'unbox_to_i64' crates/perry-codegen/src/expr/index_get.rs || true

printf '%s\n' '--- imported lowering helpers at file start ---'
sed -n '1,120p' crates/perry-codegen/src/expr/index_get.rs

Repository: PerryTS/perry

Length of output: 18751


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

index_get = Path("crates/perry-codegen/src/expr/index_get.rs").read_text()
refine = Path("crates/perry-codegen/src/type_analysis/refine.rs").read_text()
runtime = Path("crates/perry-runtime/src/array/indexing.rs").read_text()

checks = {
    "claim_is_restricted_to_property_get": (
        "let Expr::PropertyGet" in refine
        and "declared_array_property_claim" in refine
    ),
    "claim_enters_array_branch": "if is_array_expr(ctx, object) || claimed_array" in index_get,
    "non_numeric_branch_unboxes_receiver": (
        "if !is_numeric_expr(ctx, index)" in index_get
        and "unbox_to_i64(blk, &arr_box)" in index_get
    ),
    "runtime_key_branch_unboxes_receiver": (
        "if numeric_index_needs_runtime_key" in index_get
        and index_get.count("unbox_to_i64(blk, &arr_box)") >= 2
    ),
    "raw_helper_only_null_checks_receiver": (
        "pub extern \"C\" fn js_array_get_index_or_string" in runtime
        and "if arr.is_null()" in runtime
        and "is_valid_obj_ptr" not in runtime[
            runtime.index("pub extern \"C\" fn js_array_get_index_or_string"):
            runtime.index("pub extern \"C\" fn js_array_get_index_or_string") + 250
        ]
    ),
}

for name, result in checks.items():
    print(f"{name}={result}")

assert all(checks.values()), checks
print("reachable_claim_violation_raw_handle_path=True")
PY

Repository: PerryTS/perry

Length of output: 394


Keep claimed receivers on guarded or boxed paths.

When claimed_array is true, the non-numeric and runtime-key branches unbox the receiver and call js_array_get_index_or_string. A violated array claim can therefore pass a non-array value as an ArrayHeader; this helper only checks for null and can read an invalid layout. Route these branches through the boxed fallback or add an equivalent receiver guard.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/expr/index_get.rs` at line 1178, Update the
claimed_array handling around is_array_expr so non-numeric and runtime-key
accesses do not unbox an unchecked receiver before js_array_get_index_or_string.
Route claimed receivers through the boxed fallback, or add an equivalent runtime
array guard, while preserving the existing direct path for proven arrays.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test-files/test_gap_7890_declared_array_receiver_element_read.ts (1)

16-30: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add direct tuple-property receiver coverage.

declared_array_property_claim accepts both arrays and tuples. This test only declares string[] properties. Add a tuple property case that reads e.items[0] directly. Include a malformed runtime receiver to verify the boxed fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test-files/test_gap_7890_declared_array_receiver_element_read.ts` around
lines 16 - 30, Extend the receiver coverage in the declarations around Bag,
IBag, and CBag with a tuple-typed property case that directly reads e.items[0].
Exercise the malformed runtime receiver as well, and assert that
declared_array_property_claim uses the boxed fallback for that case while
preserving the existing array-property coverage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test-files/test_gap_7890_declared_array_receiver_element_read.ts`:
- Around line 16-30: Extend the receiver coverage in the declarations around
Bag, IBag, and CBag with a tuple-typed property case that directly reads
e.items[0]. Exercise the malformed runtime receiver as well, and assert that
declared_array_property_claim uses the boxed fallback for that case while
preserving the existing array-property coverage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d405dd5c-c346-43a3-a490-0bed295c035b

📥 Commits

Reviewing files that changed from the base of the PR and between 534a7d0 and 396644d.

📒 Files selected for processing (1)
  • test-files/test_gap_7890_declared_array_receiver_element_read.ts

@proggeramlug
proggeramlug merged commit c25ee9d into main Aug 11, 2026
1 of 19 checks passed
@proggeramlug
proggeramlug deleted the perf/interp-round7 branch August 11, 2026 21:58
proggeramlug added a commit that referenced this pull request Aug 11, 2026
…claim (#7891) (#7893)

The array arm's two key routes have different receiver-validation strength.
Numeric goes through js_array_get_f64, which classifies the receiver
(clean_arr_ptr / array_object_receiver) and answers correctly for a string, an
array-like object, a typed array or a number. Static string/symbol goes through
js_array_get_index_or_string -> array_get_property_by_key ->
js_object_get_field_by_name, which has no string-receiver index arm and answers
undefined for s["0"] where JS answers the character.

So only the numeric route is claim-safe. #7890's claim now requires a
non-string, non-symbol key; a string or symbol key keeps exactly the generic
path it had before #7890. The undefined answer itself is pre-existing on main
and reachable without any of this through a plain non-union declared receiver —
tracked as #7891.

interp and iso_miss read only numeric indexes, so the measured result is
unchanged: all 19 corpus binaries are byte-identical to the ones timed for
#7890.

Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2

Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant