Skip to content

Commit 2d446df

Browse files
committed
Replace two-pass loop strategy with assignment-depth-bounded iteration
1 parent 645023b commit 2d446df

4 files changed

Lines changed: 562 additions & 382 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3232
- **Editing responsiveness.** Classes evicted from the resolved-class cache after a file edit are now eagerly re-populated in dependency order, eliminating lazy resolution delays for diagnostics and completions.
3333
- **Virtual member resolution.** Virtual member providers (PHPDoc mixins, Laravel model, Eloquent Builder) now resolve completely on every class, eliminating cases where mixins or Eloquent accessors were missing after edits.
3434
- **Faster analysis.** String interning, O(1) method lookup, Arc-shared class metadata through the resolution pipeline, and reduced redundant work cut analysis time significantly on large projects.
35+
- **Loop type propagation.** Replaced the fixed two-pass loop strategy with assignment-depth-bounded iteration. A cheap AST walk computes the dependency chain depth for each loop body, and types are propagated through that many iterations with fixed-point early exit. This improves type accuracy for variables assigned late in loop bodies (visible from the start on subsequent iterations) and avoids the previous approach of skipping multi-pass entirely for nested loops.
3536

3637
### Fixed
3738

docs/todo.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ within the same impact tier.
2525

2626
| # | Item | Impact | Effort |
2727
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ----------- |
28-
29-
| P20 | [Forward walker: bounded iteration and depth-cap cleanup](todo/performance.md#p20-forward-walker-bounded-iteration-and-depth-cap-cleanup) | High | Medium |
28+
| P27 | [Remove `WALK_DEPTH`/`PROCESS_DEPTH` guards and 32 MB stack threads](todo/performance.md#p27-remove-walk_depth--process_depth-guards-and-32-mb-stack-threads) | Low | Low |
3029
| ER5 | [Mago-style separated metadata](todo/eager-resolution.md#er5--mago-style-separated-metadata) | High | High |
3130
| P9 | [`resolved_class_cache` generic-arg specialisation](todo/performance.md#p9-resolved_class_cache-generic-arg-specialisation) | Medium | Medium |
3231
| P18 | [Subtype result caching](todo/performance.md#p18-subtype-result-caching) (per-request HashMap for hierarchy walks) | Medium | Low |

docs/todo/performance.md

Lines changed: 38 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -693,128 +693,6 @@ so this fast-path would apply to the majority of checks.
693693

694694
---
695695

696-
## P20. Forward walker: bounded iteration and depth-cap cleanup
697-
698-
The forward walker's two-pass loop strategy (walk body once to
699-
discover assignments, merge, walk again) interacts multiplicatively
700-
with if-branch merging. This exponential blowup is currently
701-
papered over with three thread-local depth counters:
702-
703-
- `LOOP_DEPTH` / `MAX_LOOP_DEPTH = 6`: hard cap on loop nesting.
704-
Beyond depth 2 (`MAX_TWO_PASS_LOOP_DEPTH`), loops get a single
705-
pass only. Beyond depth 6, the body is skipped entirely.
706-
- `WALK_DEPTH` / `MAX_WALK_DEPTH = 50`: guards `walk_body_forward`
707-
recursion across all block nesting (if/else, try/catch, switch).
708-
- `PROCESS_DEPTH` / `MAX_PROCESS_DEPTH = 80`: guards
709-
`process_statement` recursion for the same nesting.
710-
711-
All three caps silently produce incomplete type information when
712-
they fire. The high values of 50 and 80 (vs real PHP nesting of
713-
10-15) indicate they exist as safety nets for the exponential
714-
blowup, not for real nesting depth.
715-
716-
### How Mago solves the loop problem
717-
718-
Mago (`references/mago/crates/analyzer/src/statement/loop/`) uses
719-
assignment-depth-bounded iteration:
720-
721-
1. **Assignment map.** Before analyzing a loop body, a cheap AST
722-
walk (`assignment_map_visitor.rs`, no type resolution) builds a
723-
`BTreeMap<Atom, BTreeSet<Atom>>` of which variables depend on
724-
which other variables. The function `get_assignment_map_depth`
725-
follows the dependency chain (using `remove` to break cycles)
726-
and returns the longest chain length.
727-
728-
2. **Bounded re-walking.** The loop body is re-walked at most
729-
`assignment_depth` times (typically 1-3), not proportional to
730-
nesting depth. Between iterations, `clean_nodes` clears cached
731-
expression types so the body is re-analyzed fresh.
732-
733-
3. **Fixed-point early exit.** After each re-walk, Mago checks
734-
whether any variable's type actually changed (`has_changes`
735-
flag). If types have stabilized, it breaks immediately
736-
(`loop/mod.rs` lines 561-569).
737-
738-
4. **Type widening.** Integer bounds are widened across iterations
739-
(lines 430-494) to ensure convergence in few iterations.
740-
741-
5. **Union merging.** Changed types are merged via
742-
`combine_union_types` (lines 524-533), not by re-walking
743-
branches. Nesting an if inside a foreach does not multiply walks.
744-
745-
Note: Mago's statement walker itself IS recursive (the `Analyzable`
746-
trait dispatches via match, calling `.analyze()` on sub-statements).
747-
There are no depth limits and no stack size overrides. The walker
748-
does not need them because the loop analysis avoids the exponential
749-
blowup that makes our walker's recursion dangerous.
750-
751-
PHPStan's `NodeScopeResolver::processStmtNodes()` is also recursive
752-
with no depth counters. Phpactor's `FrameResolver` likewise.
753-
754-
### Deliverables
755-
756-
This is a single work item with three steps:
757-
758-
#### Step 1: Assignment-depth-bounded loop iteration
759-
760-
Replace the `LOOP_DEPTH` / `MAX_TWO_PASS_LOOP_DEPTH` /
761-
`MAX_LOOP_DEPTH` system with Mago-style bounded iteration:
762-
763-
- Build an assignment dependency map before each loop body
764-
(cheap AST walk, no type resolution).
765-
- Compute assignment depth from the dependency chain.
766-
- Re-walk the body at most `assignment_depth` times.
767-
- After each re-walk, compare scope before/after. Stop when no
768-
types changed (fixed-point).
769-
- Consider type widening for integer literal types inside loops.
770-
771-
**Success criteria:** Remove `LOOP_DEPTH`, `MAX_TWO_PASS_LOOP_DEPTH`,
772-
and `MAX_LOOP_DEPTH`. No test regressions. Loop-heavy files that
773-
previously hit the depth cap now produce correct types.
774-
775-
#### Step 2: Verify and remove `MAX_WALK_DEPTH` / `MAX_PROCESS_DEPTH`
776-
777-
After Step 1 eliminates the exponential blowup, the 50/80 caps
778-
should never fire on real code (PHP nesting rarely exceeds 15).
779-
780-
- Run the full test suite and `analyse` on the largest available
781-
project. Log when either cap fires.
782-
- If neither fires, remove both counters entirely.
783-
- If either fires on real code, investigate why (it likely indicates
784-
a remaining exponential path that Step 1 missed).
785-
786-
**Success criteria:** `WALK_DEPTH` and `PROCESS_DEPTH` counters
787-
removed from `forward_walk.rs`. No test regressions.
788-
789-
#### Step 3: Verify and remove 32 MB stack threads (diagnostic workers)
790-
791-
The `analyse.rs` Phase 2 diagnostic workers use
792-
`stack_size(32 * 1024 * 1024)` because the forward walker's deep
793-
recursion can overflow the default 8 MB stack. After Steps 1-2,
794-
the recursion depth is bounded by actual PHP nesting (~15 levels).
795-
796-
- Run the full test suite and `analyse` on the largest available
797-
project with default stack sizes (remove or comment out the
798-
`stack_size` calls on diagnostic worker threads).
799-
- If no stack overflows occur, remove the `stack_size` calls.
800-
- Note: the eager-population and index-worker threads also use
801-
32 MB stacks for class resolution depth (addressed by ER5
802-
separately). Only remove the diagnostic worker stacks here.
803-
804-
**Success criteria:** Diagnostic worker threads in `analyse.rs`
805-
run with default stack sizes. The remaining `stack_size` calls
806-
(eager-population, index workers, reference parsing) are tracked
807-
by ER5 and P25 respectively.
808-
809-
### When to implement
810-
811-
After the forward walker stabilizes (it is less than a week old).
812-
The current depth guards are sufficient to prevent hangs; this item
813-
improves accuracy on deeply nested loops and eliminates the safety
814-
nets that mask the root cause.
815-
816-
---
817-
818696
## Appendix: Profiling
819697

820698
### Commands
@@ -847,8 +725,43 @@ like:
847725

848726
---
849727

728+
## P27. Remove `WALK_DEPTH` / `PROCESS_DEPTH` guards and 32 MB stack threads
729+
730+
**Impact: Low · Effort: Low**
731+
732+
After the assignment-depth-bounded loop iteration landed (P20), the
733+
exponential blowup from loop/if-branch interaction is eliminated.
734+
The remaining depth guards in the forward walker are:
735+
736+
- `WALK_DEPTH` / `MAX_WALK_DEPTH = 50`: guards `walk_body_forward`
737+
recursion across all block nesting (if/else, try/catch, switch).
738+
- `PROCESS_DEPTH` / `MAX_PROCESS_DEPTH = 80`: guards
739+
`process_statement` recursion for the same nesting.
740+
741+
PHP code rarely nests beyond 15 levels; these caps (50, 80) exist as
742+
safety nets and should never fire on real code now that the
743+
exponential loop path is gone.
744+
745+
Additionally, the `analyse.rs` Phase 2 diagnostic workers use
746+
`stack_size(32 * 1024 * 1024)` because the forward walker's deep
747+
recursion could overflow the default 8 MB stack. With the loop
748+
depth bounded, the recursion depth is bounded by actual PHP nesting
749+
(~15 levels) and default stack sizes should suffice.
750+
751+
### Fix
752+
753+
1. Run the full test suite and `analyse` on the largest available
754+
projects with logging when either depth cap fires.
755+
2. If neither fires, remove both counters entirely.
756+
3. Remove the `stack_size(32 * 1024 * 1024)` calls on diagnostic
757+
worker threads in `analyse.rs`. Keep the eager-population and
758+
index-worker thread stacks (those are tracked by ER5 separately).
759+
760+
---
761+
850762
# Remaining anti-pattern fixes
851763

852-
Most depth-cap and recursion-guard issues are addressed by P20
853-
(forward walker) and ER5 (class resolution). The items below are
854-
independent fixes that do not depend on either.
764+
Most remaining depth-cap issues are addressed by ER5 (class
765+
resolution). The forward walker loop iteration was addressed by the
766+
assignment-depth-bounded strategy. The items below are independent
767+
fixes that do not depend on either.

0 commit comments

Comments
 (0)