Skip to content

Memoize Scope::getPhpVersion() per scope - #7014

Merged
ondrejmirtes merged 1 commit into
2.3.xfrom
cheaper-get-php-version
Nov 9, 2026
Merged

ondrejmirtes merged 1 commit into
2.3.xfrom
cheaper-get-php-version

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

Since rules and expression handlers ask the scope for the PHP version (8cf6b64), PropertyFetchHandler calls Scope::getPhpVersion() on every property fetch, and isReadonlyPropertyFetch() calls it on every readonly check. Each call looks up PHP_VERSION_ID among the expression types and builds a new PhpVersions. On tests/bench/data/nullsafe-chain-walk.php that is 53,911 calls on only 51 distinct scope objects.

This PR memoizes the answer per scope, together with the expression-types array it was computed from:

  • Unchanged expression types: both properties hold the same array, so the === check is a pointer comparison.
  • After a write in place (specifyExpressionTypeInPlace() and co. on a working copy): the scope's array is separated, the arrays no longer match, and the answer is computed again.

So the memo doesn't rely on scopes never being written to after being read. The native MutatingScope mirrors it: two new property slots, the same check (Z_ARR pointer equality, then zend_is_identical()), and regenerated MutatingScope.h/NodeCallbackScope.h (generated with PHP 8.5, as CI's side-by-side.php uses).

Measurements

Excimer CPU samples, --debug, bleeding edge, level 8, 3 interleaved rounds:

HEAD this PR
nullsafe-chain-walk.php, whole run 12,233 11,720 (−4.2%, lower in every round)
…of which getPhpVersion() 416 20

On the whole tests/bench/data corpus the change is within noise (user CPU 49.6/52.7/53.0 s vs 50.5/53.2/51.0 s). The gain is in property-fetch-heavy code.

Two alternatives I measured and left out:

  • Memoizing the unnarrowed PhpVersions in ConfiguredPhpVersionRangeHelper: about −1%, and redundant once the scope memoizes. In ordinary code there are about 2 calls per distinct scope, and getPhpVersion() is negligible there.
  • An allocation-free PhpVersions::supports*(): about −0.8%, not worth editing 67 methods.

Verification

  • ScopePhpVersionTest::testPhpVersionFollowsExpressionTypesWrittenInPlace (new) passes. A plain memo without the array check fails it.
  • Full suite: PHP only, all pass except ResultCacheManagerCollectedDataTest::testFileChangedAndChangedBackDuringTheAnalysisIsAnalysedAgain, which also fails 1 in 15 on unmodified 2.3.x and passed 15/15 with this change. With the extension loaded, 23,562 tests, 0 failures.
  • Turbo: strict build, tests/smoke.php ALL OK (scope-family.php already asks getPhpVersion() twice per scope on both sides), signature-parity.php OK, bin/side-by-side.php OK on PHP 8.5, walk-trace.php identical (703,814 lines), clang-tidy 21 clean on MutatingScope.cpp.
  • Output identity: --error-format=raw on tests/bench/data (3,021 lines) is byte-identical for HEAD, this PR in PHP mode, and this PR with the extension. make phpstan and phpcs pass.

No code shipped in the PHAR uses syntax that needs the downgrade to handle anything new.

Since rules and expression handlers ask the scope for the PHP version (8cf6b64),
PropertyFetchHandler asks on every property fetch and isReadonlyPropertyFetch() on
every readonly check. On tests/bench/data/nullsafe-chain-walk.php that is 53,911
calls on 51 distinct scopes, each looking up PHP_VERSION_ID among the expression
types and building a new PhpVersions.

The answer depends only on the scope's expression types, so the scope remembers
it with the array it was computed from. While the expression types are untouched
both properties hold the same array and the check is a pointer comparison; a
write in place (specifyExpressionTypeInPlace() and co. on a working copy)
separates them and the answer is computed again.

The native MutatingScope mirrors it.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1976c-1bde-72f8-b96c-072279109d59
@ondrejmirtes
ondrejmirtes merged commit ccd2611 into 2.3.x Nov 9, 2026
890 of 892 checks passed
@ondrejmirtes
ondrejmirtes deleted the cheaper-get-php-version branch November 9, 2026 12:45
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