Skip to content

Shadow TypeTraverser in the turbo extension, binding its callables once per traversal instead of per node - #6424

Closed
phpstan-bot wants to merge 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-r091m0u
Closed

Shadow TypeTraverser in the turbo extension, binding its callables once per traversal instead of per node#6424
phpstan-bot wants to merge 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-r091m0u

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

The issue asks for TypeTraverser to be shadowed by the phpstan_turbo extension: it shows up in profiles right next to NodeTraverser, which is already native.

TypeTraverser::map() runs 810,046 times in a single-threaded self-analysis, visiting 1.41M nodes and making 1.30M Type::traverse() calls. Almost all of that is per-node userland bookkeeping around two calls that have to stay in PHP (the user callback and Type::traverse()): a mapInternal() frame, a traverseInternal() frame, and a freshly allocated [$this, …] callable array for each of them, plus the traverser object itself and — for a TypeTraverserCallable — an adapter closure that adds one more frame per node.

This PR implements the class natively and removes exactly that overhead, keeping the recursion, the callbacks and every Type::traverse() implementation in PHP.

Measured on the standard protocol (single-threaded, result cache cleared, user CPU, self-analysis of src/Type):

  • Inclusive time inside map(): 1.270 s → 1.09 s, i.e. ~0.18 s of a 28.6 s run — 0.62%, above the 0.5% bar the extension's design rules set for a port. Timing the traversal itself rather than the whole run is what makes an effect of this size resolvable: it is measured on the same instrumented region in both modes, and it reproduces to within 20 ms across runs.
  • Six interleaved whole-run A/B pairs (shadowed vs. not, extension loaded in both) agree and are all in the port's favour: 28.28 s → 27.97 s mean user CPU, per-pair deltas 0.02–0.58 s. That spread is the machine's drift, so the 0.62% above is the number to trust.

Changes

  • turbo-ext/src/TypeTraverser.cpp (new) — PHPStanTurbo\TypeTraverser, mirroring the twin method for method: map(), __construct() (private, as in the twin), mapInternal(), traverseInternal(). Registered through the reg::Class builder with raw handler pointers, state in the same cb property slot.
    • Both bound callables ([$this, 'mapInternal'], [$this, 'traverseInternal']) are built on first use and reused for every node of the traversal, then dropped when map() returns so the traverser's lifetime stays tied to the call — the twin allocates one array per node visit.
    • A TypeTraverserCallable is dispatched straight to traverse() via zend_call_known_instance_method(), instead of the closure the twin allocates in its constructor to adapt it (one allocation per traversal plus one frame per node).
    • One traverser object is parked for the next traversal instead of being freed, but only when nothing outlived the previous one (refcount 1 after the bound callables are dropped, and not weakly referenced). A callback that kept its $traverse keeps a working traverser, cb included; nested traversals simply allocate their own, since the parked slot is empty while its object is in use. The parked object is released in RSHUTDOWN.
    • map() instantiates the called scope — the stub subclass PHPStan\Type\TypeTraverser — so the traverser handed to callbacks is the class PHPStan's own code knows.
  • src/Type/TypeTraverser.php#[ShadowedByTurboExtension]. The PHP implementation is unchanged and stays the reference.
  • src/Type/TypeTraverserCallable.php#[ReferencedByTurboExtension(key: 'typeTraverserCallable')]; the native code needs the interface to recognise that form of callback.
  • turbo-ext/src/support.h / support.cpp — the typeTraverserCallable class-reference entry, the registration/rshutdown hooks, and zend_closures.h in the shared engine include block.
  • turbo-ext/src/main.cpp, turbo-ext/config.w32 — registration hook, rshutdown hook, and the new source file for the Windows build (the Makefile and config.m4 glob).
  • turbo-ext/tests/smoke.php — differential coverage (see below).

Parallel constructs probed

  • SimultaneousTypeTraverser — the exact structural sibling (same map()/mapInternal()/traverseInternal() shape over Type::traverseSimultaneously()). Instrumented over a full analysis run: zero calls. A site that is never hit can never pay for a port, so it stays in PHP.
  • Passing bound Closures instead of [$this, …] arrays would be worth another ~30% of the traversal's own cost on both sides, but it needs first-class callable syntax to be worthwhile (Closure::fromCallable() costs more than it saves at the real average of 1.7 visited nodes per traversal), and src/ cannot use that syntax — the downgrade tooling has no visitor for it. Left alone so both implementations keep handing out the same kind of callable.

Root cause

Not a bug — a missing port. The pattern is the one the extension exists for: a tiny class on a very hot path, where the userland cost is not the work itself but the frames and allocations wrapped around it. TypeTraverser pays, per visited node, two userland frames and two array allocations that a native implementation can either absorb (the frames) or hoist out of the loop (the callables), plus one object allocation per traversal that can be reused. The user callback and Type::traverse() — the actual work — keep running as PHP, so results are unchanged by construction.

Test

  • turbo-ext/tests/smoke.php gains a TypeTraverser section registered in $covered: four type shapes (leaf, array, union, nested intersection) × six callback kinds — closure (the documented constant-string-to-object example), a callback that never traverses, a TypeTraverserCallable, an array callable, a first-class callable, and a callback that starts a nested map() — each asserting the native and PHP results describe identically. Plus, for both implementations: an exception thrown by the callback propagates out of map(), a non-Type callback result is a TypeError, and a $traverse callable the callback kept keeps working after map() returned (the case that must defeat object reuse).
  • php turbo-ext/bin/side-by-side.php — 4 methods paired, generated vendor/turbo-* files re-derived and byte-identical.
  • php turbo-ext/tests/signature-parity.php — OK, 82 methods compared.
  • make tests — 21361 tests green, both without the extension and with it loaded (identical assertion counts).
  • make phpstan — green.
  • Analysis output identity: --error-format=raw over src/Type, with and without the extension, diffs empty.
  • Leak check: 200k traversals under gc_disable() add 0 bytes and no GC roots, matching the PHP implementation.

Note for merging: turbo-ext/src/ changed, so this needs the usual follow-up make bump-turbo commit once the change lands on the target branch — until then the extension version does not match and the extension stays inactive.

Fixes phpstan/phpstan#15219

…once per traversal instead of per node

- Add turbo-ext/src/TypeTraverser.cpp — PHPStanTurbo\TypeTraverser, the native
  counterpart of PHPStan\Type\TypeTraverser, with the same map() /
  mapInternal() / traverseInternal() recursion over the unchanged userland
  Type::traverse() implementations and callbacks.
- Mark PHPStan\Type\TypeTraverser with #[ShadowedByTurboExtension] and
  PHPStan\Type\TypeTraverserCallable with #[ReferencedByTurboExtension], and
  register the class-map key in turbo-ext/src/support.{h,cpp}, the
  registration hook in main.cpp and the source in config.w32.
- The port absorbs, per visited node, the mapInternal()/traverseInternal()
  frames and the [$this, ...] callable array the twin allocates for each of
  them: both bound callables are built once per traversal and reused, then
  dropped when map() returns so the traverser's lifetime stays tied to the
  call.
- A TypeTraverserCallable is dispatched straight to traverse(), skipping the
  adapter closure (and its per-node frame) the twin allocates for it.
- One traverser object is parked and reused by the next traversal, but only
  when nothing outlived the previous one (refcount 1, not weakly referenced)
  — a callback that kept its $traverse can still call it, and keeps its cb.
- Extend turbo-ext/tests/smoke.php with differential coverage over four type
  shapes and six callback kinds (closure, non-traversing, TypeTraverserCallable,
  array callable, first-class callable, nested map), plus exception
  propagation, the non-Type return TypeError and an escaped traverse callable.
- Measured on a single-threaded self-analysis (810k map() calls, result cache
  cleared): inclusive time inside map() drops from 1.270s to 1.09s, ~0.62% of
  the 28.6s run; six interleaved whole-run A/B pairs all favour the port.
- Probed the structural sibling SimultaneousTypeTraverser: it is never called
  during an analysis run, so it is left in PHP.

Closes phpstan/phpstan#15219
@staabm staabm closed this Sep 12, 2026
@staabm
staabm deleted the create-pull-request/patch-r091m0u branch September 12, 2026 10:30
@staabm
staabm restored the create-pull-request/patch-r091m0u branch September 12, 2026 13:21
@staabm staabm reopened this Sep 12, 2026

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

revert all changes and shadow FileAnalyserCallback instead

…llables once per traversal instead of per node"

This reverts commit 8484710.
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. Here's what happened.

1. Reverted the PR's changes (as asked)

create-pull-request/patch-r091m0u now ends with a revert of the TypeTraverser commit — the branch's net diff against its base is empty. make tests (21361 tests) and make phpstan are green on that state, and it's pushed.

2. Shadowed FileAnalyserCallback — built, verified, and measured

I implemented it in full (PHPStanTurbo\FileAnalyserCallback: __construct, __invoke, the ten getters; the three trait branches, the rule and collector loops with all four caught exception types and the per-node message dedup, the ignore extensions, the dependency block, Error/InternalError construction natively, 11 new class-reference entries, inline caches for the method lookups).

It is correct: new differential test turbo-ext/tests/file-analyser-callback.php drives a real analysis (real container, real rules/collectors/dependency resolver) through the native class and through the twin's own source eval'd under a second name, plus injected rules/collectors that throw each caught exception type — every getter comes out identical; I mutation-checked the test (two deliberate native bugs, both caught). side-by-side.php 12 methods paired, signature-parity.php 90 methods OK, smoke.php ALL OK, full suite green with the extension loaded, analysis output byte-identical.

But it is not faster — it's slower. Whole-run interleaved A/B on src/Type (single-threaded, result cache cleared, user CPU, extension loaded in both modes, only the shadow toggled), 8 pairs:

  • native: 34.07 / 34.16 / 34.18 / 34.18 / 34.35 / 34.63 / 34.73 / 36.85 — mean 34.6 s
  • PHP twin: 33.70 / 33.81 / 33.92 / 34.02 / 34.06 / 34.30 / 34.77 / 35.13 — mean 34.2 s

7 of 8 pairs favour the twin; ~1% slower, against a bar of ≥0.5% faster.

The per-call benchmark (turbo-ext/tests/file-analyser-callback-bench.php) says why: with 22 rules + 2 collectors per node the native side wins 118 ns/call, but with no rules it loses 35 ns/call. A run's node mix is dominated by nodes with few rules, so the fixed penalty wins. Structurally, this class's per-node cost is the ~24 calls it makes back into PHP (instrumented: 6.42 M calls over 268 903 nodes for src/Type), and zend_call_known_function is no cheaper than the VM's INIT_METHOD_CALL/DO_FCALL with its run-time cache. Its own bookkeeping — the __invoke frame, four instanceof probes, get_class(), property fetches, array appends — measured ~0.3–0.7 s of a ~42 s run, and a port can only take a fraction of that. Ports pay when they absorb leaf work; this one re-dispatches.

So per the extension's design rules (≤0.5% → revert), I did not put it on the PR. The complete implementation, the differential test and the bench are on branch turbo-file-analyser-callback-experiment (pushed, 22 files, +2173) if you want to check the numbers or challenge the benchmark — happy to move it onto the PR if you read the measurement differently.

@staabm staabm closed this Sep 12, 2026
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