Skip to content

Joining an element kind into a boxed parameter does not end the boxed-source walk's generation - #7384

Merged
matz merged 4 commits into
matz:masterfrom
makenowjust:MakeNowJust/boxed-source-walk-param-join
Oct 5, 2026
Merged

matz merged 4 commits into
matz:masterfrom
makenowjust:MakeNowJust/boxed-source-walk-param-join

Conversation

@makenowjust

@makenowjust makenowjust commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Refs #7376 (stacked on #7378, which is stacked on #7377)

This pull request is stacked on #7378 (itself on #7377) and contains their commits with the same SHAs; review those first. Only the last commit is new here.

What changes

The memo of widen_boxed_array_sources starts a new generation at every write the walk makes, and one of those writes is the join of the store's element kind into a boxed parameter's boxed_push_elem and boxed_known_elem. In the first round after the optimistic ones nearly every walk reaches a parameter that has not taken the kind yet, so the memo was dropped again and again. On a 289k-line machine-generated program the compiler was still in that round 150 seconds into its compilation; the rounds before it had taken a few hundred walk steps each.

That join no longer starts a generation:

  • Inside the walk, the two fields are read only at that join (the other readers, infer_write_container_usage, the entry condition in bind_args_params and the backstop in analyze.c, are outside the walk).
  • The join only grows: UNKNOWN, then one kind, then the boxed kind. A visit recorded as changing nothing found the kind already joined in, and still finds it after any later join.
  • The records carry the element kind they were made for (The boxed-source walks of one parameter-binding pass share what they found unchanged #7378), so a parameter that a different kind reaches later is reached by a different visit, which is walked. The read before the join (lv->type != TY_POLY) is of the parameter's type, whose writes still start a generation.

The join still reports the change, so SPINEL_WBAS_SHADOW=1 still stops the compiler if a skipped visit would have joined something.

Why the results do not change

Before / after

Walk steps per binding pass, counted with a local counter (not part of this change), in the first non-optimistic round:

program #7378 this change
18k-line machine-generated 31,111 (38 new generations) 15,925 (6)
289k-line machine-generated still running when the compilation was stopped at 150 s 1,218,633

The total over the whole compilation of the 18k-line program goes from 231,812 to 215,551 steps; the 289k-line program now gets through that round and the following ones (619k, 308k, 201k, 159k steps).

Testing

make gate:

scale-test: instance_eval forwarding work at 2x the wrappers is 1.71x (limit 2.50)
scale-test: work at 4x the program is 4.73x (linear 4.00, limit 5.20)
scale-test: work at 4x the program, compiled to C, is 6.08x (limit 6.90)
scale-test: call-shape work at 4x the units, compiled to C, is 4.22x (linear 4.00, limit 4.50)
Tests: 5928 pass, 0 fail, 0 error
gate: stamp for tree c190c8822c9a on master 92510d6c10a3; git commit --amend --no-edit adds the Gate: trailer
gate: ALL GREEN

SPINEL_INT_OVERFLOW=promote make test: 5909 pass, 15 fail, 13 error. Every failure is one master e5e8f79 already had; none is new.

The gate ran on a branch based on 92510d6 that combines this change (with #7377 and #7378 under it) with #7375 and the other compile-time fixes from the same investigation. #7378 has since gained a commit (99e1459, from review) that only ends the shared generation in more places; this branch was rebased onto it without conflicts.

Summary by CodeRabbit

  • Performance
    • Reduced repeated analysis work during compilation by reusing results from equivalent visits.
  • Reliability
    • Added a diagnostic check that reports an internal error if a repeated analysis unexpectedly changes compiler state.

…ady found unchanged

widen_boxed_array_sources (with widen_boxed_elem_sources) follows every
write of a local to depth 6 with nothing to remember what it already
walked, so a function writing one local W times walked W^6 paths, from
every boxed push or index write and every call binding a boxed argument
whose parameter takes stores. A large machine-generated program
(6.9k lines) that writes the same few locals thousands of times, and
shifts them as `l0 << 1`, which counts as a push, spent 45 of its 48
seconds of C generation here.

The walk writes the analysis state as it goes, so only a visit that
changed nothing is remembered: under the current generation, with the
depth it ran at. A later visit of the same node, at that depth or
deeper and in the same generation, makes a subset of the same calls in
the same state, which change nothing either, and is skipped. Each walk
from outside opens and closes a generation, and so does every call in
it that may write the state, whether or not it reports a change (the
pin a local re-derived from its writes takes again writes without one).
The widenings, and the generated C, are unchanged.
…found unchanged

With each walk remembering only what it found itself, every call site
binding a boxed argument walked the caller's locals again, and a read of
a local walked the local's writes again from every read: a function of
W writes and C calls still cost C * W^2 per pass. A large
machine-generated program (53k lines), where one function calls the
same callee thousands of times with the same locals, stayed over two
minutes in this walk alone.

A boxed local now has its own record, since every read of it walks the
same, and the records carry the element kind they were made for. Within
infer_param_types the walks share one generation, which ends wherever
the pass reports a change: after each node it binds, and before each
walk in bind_args_params once the binding has changed something, as
well as at every write in the walk itself. The work is linear in the
nodes and locals reached per generation.

A write between two walks that the pass does not report would make a
shared record stale. With SPINEL_WBAS_SHADOW=1, every visit skipped on
another walk's record is made anyway, in a fresh generation, and the
compiler stops if it widens or writes anything. The test corpus (5,746
tests, both overflow modes) and large machine-generated programs run
clean under it, and the generated C is unchanged.
…rce generation

Within infer_param_types the walks of widen_boxed_array_sources share
their records until the pass reports a change, and the generation moved
only at the end of each node and, inside bind_args_params, before a walk
once that binding had changed something. A node that binds more than
once (a method and its overrides, each initialize a dynamic `new` may
reach, the candidates of a poly call) could change something after the
last walk of one binding, or between two bindings, and the next binding
then walked on records made before that change.

bind_args_params now ends the generation as it returns if it changed
anything, and as it starts if the pass changed anything since the last
step (the pass hands it its own `changed`), and the direct ivar write
in bind_dynamic_new_initializers ends it too. This only ends
generations sooner, so it can only make the walks skip less.
…-source walk's generation

Every write in widen_boxed_array_sources started a new memo
generation, including the join of the store's element kind into a
boxed parameter's boxed_push_elem and boxed_known_elem. In the first
round after the optimistic ones nearly every walk reaches a parameter
that has not taken the kind yet, so the memo was dropped again and
again: a 289k-line machine-generated program was still in that round
150 seconds into its compilation.

The walk reads those two fields nowhere but at that join, and the join
only grows (UNKNOWN, one kind, then the boxed kind). A visit recorded
as changing nothing found the kind already there, and finds it there
after any later join too; the records also carry the element kind, so
a parameter a different kind reaches later is walked as a different
visit. The join therefore starts no generation. It still reports the
change, so SPINEL_WBAS_SHADOW=1 still catches a skipped visit that
would have joined. The widenings and the generated C are unchanged.
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 5ef51f45-26fb-49a7-b664-5445b926c969
📥 Commits

Reviewing files that changed from the base of the PR and between 92510d6 and 1bc1471.

📒 Files selected for processing (1)
  • src/analyze_pass.c

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The analysis pass adds generation-aware memoization for boxed-source walks. Parameter inference tracks changes across bindings and advances shared generations when analysis state changes.

Changes

Boxed-source memoization

Layer / File(s) Summary
Memoized boxed-source walks
src/analyze_pass.c
Value and local source walks now use memo records keyed by element kind, depth, and generation. Array widening and pinning advance the generation. Shadow mode can rerun skipped walks and report an internal error if analysis state changes.
Shared generations in parameter inference
src/analyze_pass.c
Parameter binding and inference track changes and advance shared generations at binding and pass boundaries. Inference returns the combined change result.

Priority: ⬇️ Low

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

Change: Refactor

Suggested reviewers: matz, francescok

Merge Risk: ⚪ Minimal · up to 1bc14

No actionable issue was established in the changed analysis paths; the PR is mergeable after normal checks.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 1bc14

This internal optimization does not introduce an identified public interface, privilege expansion, or trust-boundary bypass. The inspected lifecycle rules support sequential execution, but concurrent or reentrant execution remains unverified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — Source programs can exercise the changed analysis through existing compilation paths. A cache-invariant failure could affect compilation or generated output for programs using the affected boxed-array flows; no new tenant, service, credential, or privileged-operation reachability was identified in the comparison.

Trust Boundaries and Controls

  • observed — Shadow checking is enabled by SPINEL_WBAS_SHADOW and detects changing cross-walk skips when enabled. It is optional diagnostic checking, not a mandatory production security control.

Resilience and Maintainability Implications

  • inferred — The shared lifecycle assumes serialized, non-reentrant execution: global pointers reference the current pass's change accumulators, and the helpers do not save and restore an enclosing pass's pointers. Concurrent or nested exposure was not established, so this remains an ownership limitation rather than a demonstrated PR security finding.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: joining an element kind into a boxed parameter no longer ends the boxed-source walk's generation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
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.
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@matz
matz merged commit dbe6418 into matz:master Oct 5, 2026
4 checks passed
kieranklaassen pushed a commit to kieranklaassen/spinel that referenced this pull request Oct 5, 2026
… compiler

`h[:a1] = h[:a0]; h[:a2] = h[:a1]; ...` with a String of h changed in
place cost the compiler n! walks for n such stores: on f8abc3e eight
take 12 seconds, and nine do not finish in 90. The
same in an Array's slots, in a Hash an instance variable holds, in one
a parameter holds, and in two Hashes that store each other's elements.

The walk that demands a container's Strings as handles follows a boxed
stored value to the element read it is, and strbuf_demand_elem_arg
starts the walk of that read's container. Since matz#7369 a read whose walk
is under way answers 0, which ended the recursion. A second read of the
same container was still another read, so the stores were walked once
more for it with one read fewer left to meet, and again inside that.

The walk is of the stores into the container a local or an instance
variable holds, whichever element is read. A read of that container met
inside the walk now answers 0 as the same read does: the walk on the
stack reaches every store the inner one would. Each of the n stores
walks the others once.

An element of another element (`r[0][:b] = r[0][:a]`) is keyed by its
read as before; that walk did not grow this way.

The nearest changes upstream are matz#7369, which this completes, and the
walk-speed changes merged since (matz#7365's memo and the per-method one
beside it, matz#7384, matz#7386, matz#7388, matz#7390, matz#7392, matz#7396 to matz#7402). Those
make one walk cheaper or remember a walk that changed nothing; none
touches this guard, and here it is the number of walks that grows. A
walk that reaches a container moves the memo's generation whether it
changed anything or not, so the memo never holds one of these.

The generated C is unchanged for every other test.

Co-Authored-By: Claude Code <noreply@anthropic.com>
kieranklaassen pushed a commit to kieranklaassen/spinel that referenced this pull request Oct 5, 2026
… compiler

`h[:a1] = h[:a0]; h[:a2] = h[:a1]; ...` with a String of h changed in
place cost the compiler n! walks for n such stores: on c6bbdfb eight
take 13 seconds, and nine do not finish in 90. The
same in an Array's slots, in a Hash an instance variable holds, in one
a parameter holds, and in two Hashes that store each other's elements.

The walk that demands a container's Strings as handles follows a boxed
stored value to the element read it is, and strbuf_demand_elem_arg
starts the walk of that read's container. Since matz#7369 a read whose walk
is under way answers 0, which ended the recursion. A second read of the
same container was still another read, so the stores were walked once
more for it with one read fewer left to meet, and again inside that.

The walk is of the stores into the container a local or an instance
variable holds, whichever element is read. A read of that container met
inside the walk now answers 0 as the same read does: the walk on the
stack reaches every store the inner one would. Each of the n stores
walks the others once.

An element of another element (`r[0][:b] = r[0][:a]`) is keyed by its
read as before; that walk did not grow this way.

The nearest changes upstream are matz#7369, which this completes, and the
walk-speed changes merged since (matz#7365's memo and the per-method one
beside it, matz#7384, matz#7386, matz#7388, matz#7390, matz#7392, matz#7396 to matz#7402). Those
make one walk cheaper or remember a walk that changed nothing; none
touches this guard, and here it is the number of walks that grows. A
walk that reaches a container moves the memo's generation whether it
changed anything or not, so the memo never holds one of these.

The generated C is unchanged for every other test.

Co-Authored-By: Claude Code <noreply@anthropic.com>
kieranklaassen pushed a commit to kieranklaassen/spinel that referenced this pull request Oct 5, 2026
… compiler

`h[:a1] = h[:a0]; h[:a2] = h[:a1]; ...` with a String of h changed in
place cost the compiler n! walks for n such stores: on c6bbdfb eight
take 13 seconds, and nine do not finish in 90. The
same in an Array's slots, in a Hash an instance variable holds, in one
a parameter holds, and in two Hashes that store each other's elements.

The walk that demands a container's Strings as handles follows a boxed
stored value to the element read it is, and strbuf_demand_elem_arg
starts the walk of that read's container. Since matz#7369 a read whose walk
is under way answers 0, which ended the recursion. A second read of the
same container was still another read, so the stores were walked once
more for it with one read fewer left to meet, and again inside that.

The walk is of the stores into the container a local or an instance
variable holds, whichever element is read. A read of that container met
inside the walk now answers 0 as the same read does: the walk on the
stack reaches every store the inner one would. Each of the n stores
walks the others once.

An element of another element (`r[0][:b] = r[0][:a]`) is keyed by its
read as before; that walk did not grow this way.

The nearest changes upstream are matz#7369, which this completes, and the
walk-speed changes merged since (matz#7365's memo and the per-method one
beside it, matz#7384, matz#7386, matz#7388, matz#7390, matz#7392, matz#7396 to matz#7402). Those
make one walk cheaper or remember a walk that changed nothing; none
touches this guard, and here it is the number of walks that grows. A
walk that reaches a container moves the memo's generation whether it
changed anything or not, so the memo never holds one of these.

The generated C is unchanged for every other test.

Co-Authored-By: Claude Code <noreply@anthropic.com>
kieranklaassen pushed a commit to kieranklaassen/spinel that referenced this pull request Oct 5, 2026
… compiler

`h[:a1] = h[:a0]; h[:a2] = h[:a1]; ...` with a String of h changed in
place cost the compiler n! walks for n such stores: on c6bbdfb eight
take 13 seconds, and nine do not finish in 90. The
same in an Array's slots, in a Hash an instance variable holds, in one
a parameter holds, and in two Hashes that store each other's elements.

The walk that demands a container's Strings as handles follows a boxed
stored value to the element read it is, and strbuf_demand_elem_arg
starts the walk of that read's container. Since matz#7369 a read whose walk
is under way answers 0, which ended the recursion. A second read of the
same container was still another read, so the stores were walked once
more for it with one read fewer left to meet, and again inside that.

The walk is of the stores into the container a local or an instance
variable holds, whichever element is read. A read of that container met
inside the walk now answers 0 as the same read does: the walk on the
stack reaches every store the inner one would. Each of the n stores
walks the others once.

An element of another element (`r[0][:b] = r[0][:a]`) is keyed by its
read as before; that walk did not grow this way.

The nearest changes upstream are matz#7369, which this completes, and the
walk-speed changes merged since (matz#7365's memo and the per-method one
beside it, matz#7384, matz#7386, matz#7388, matz#7390, matz#7392, matz#7396 to matz#7402). Those
make one walk cheaper or remember a walk that changed nothing; none
touches this guard, and here it is the number of walks that grows. A
walk that reaches a container moves the memo's generation whether it
changed anything or not, so the memo never holds one of these.

The generated C is unchanged for every other test.

Co-Authored-By: Claude Code <noreply@anthropic.com>
kieranklaassen pushed a commit to kieranklaassen/spinel that referenced this pull request Oct 6, 2026
… compiler

`h[:a1] = h[:a0]; h[:a2] = h[:a1]; ...` with a String of h changed in
place cost the compiler n! walks for n such stores: on c6bbdfb eight
take 13 seconds, and nine do not finish in 90. The
same in an Array's slots, in a Hash an instance variable holds, in one
a parameter holds, and in two Hashes that store each other's elements.

The walk that demands a container's Strings as handles follows a boxed
stored value to the element read it is, and strbuf_demand_elem_arg
starts the walk of that read's container. Since matz#7369 a read whose walk
is under way answers 0, which ended the recursion. A second read of the
same container was still another read, so the stores were walked once
more for it with one read fewer left to meet, and again inside that.

The walk is of the stores into the container a local or an instance
variable holds, whichever element is read. A read of that container met
inside the walk now answers 0 as the same read does: the walk on the
stack reaches every store the inner one would. Each of the n stores
walks the others once.

An element of another element (`r[0][:b] = r[0][:a]`) is keyed by its
read as before; that walk did not grow this way.

The nearest changes upstream are matz#7369, which this completes, and the
walk-speed changes merged since (matz#7365's memo and the per-method one
beside it, matz#7384, matz#7386, matz#7388, matz#7390, matz#7392, matz#7396 to matz#7402). Those
make one walk cheaper or remember a walk that changed nothing; none
touches this guard, and here it is the number of walks that grows. A
walk that reaches a container moves the memo's generation whether it
changed anything or not, so the memo never holds one of these.

The generated C is unchanged for every other test.

Co-Authored-By: Claude Code <noreply@anthropic.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.

2 participants