Native directory symbol scan, and no cache or lock behind it - #6304
Merged
Conversation
ondrejmirtes
force-pushed
the
native-php-file-cleaner
branch
2 times, most recently
from
August 30, 2026 11:04
9e8924a to
a7c7d70
Compare
The optimized source locators build their symbol index by scanning every file
of a directory: php_strip_whitespace(), a prefilter regex,
PhpFileCleaner::clean() and a symbol regex, each materializing a PHP string.
Two thirds of that was clean() alone - a byte-by-byte PHP loop over every file
in the analysed tree.
Both stages are now native, and with them the cache, the scan lock and the
arena records that existed to stop parallel workers repeating the work:
PhpFileCleaner::clean 1.321s -> 0.111s (11.9x)
whole findSymbols pipeline 1.705s -> 0.445s (3.8x)
hash_file('sha256') over the same corpus 0.419s
raw file_get_contents 0.225s
That third line is why the cache can go. It exists to be validated, and
validating it means hashing every file in the tree - which now costs about
what scanning them outright does. So when the extension is active and workers
are forked, the directory is walked and scanned fresh: no file hashing, no
persisted symbol table, no scan lock, no arena record, nothing to go stale.
That works because the parent scans for everyone. PreForkDirectorySymbolScanner
builds the directory locators in the main process just before it forks, so the
children inherit them copy-on-write, and it collects every directory into one
scan rather than one per directory (0.32s -> 0.16s; a file two directories both
reach is read once).
This is the follow-up #5846 left
open. Its lock could only remove the duplicated symbol extraction - the
per-worker FileFinder walk and sha256 of every file ran before the lock was
reached, because those hashes were what validated the cache - and the pre-warm
it started as was dropped for forcing buildSourceLocator() in the main thread
on every parallel run (#5577). Fork-by-default plus a native scan turns that
trade into a plain win: the classmap directories that PR measured as 43 of the
44 scans per worker are now scanned once.
Measured against the current 2.2.x phar, cold caches, ABBA-interleaved, output
byte-identical in every run:
slevomat (12 runs) -1.66% user CPU -4.97% wall -101.7 MB peak RSS
shipmonk (6 runs) -1.07% user CPU -4.37% wall RSS flat
The memory comes free: each worker used to build its own classToFile /
functionToFiles / constantToFile, and they now share the parent's copy-on-write.
Slevomat's run-to-run stdev also drops from 7.09s to 1.41s - that was the flock
queue.
Three gates, each of which alone keeps today's behaviour: without the extension
the PHP twin and the cache stay; with spawned workers rather than forked ones
(Windows, OPcache on) there is no parent to inherit from, so the cache and lock
stay in charge; and a run forking a single worker skips the parent scan, which
keeps #5577's lazy main thread lazy.
Correctness is checked as identical output, not plausible output: two
differential tests compare the native implementations against the PHP twins
over every PHP file in the repository in both supportsEnums modes - 43,656
symbol-triple and 42,392 cleaner-output checks - plus fixtures for heredocs,
attributes vs hash comments, the define()/inDefine string leak, uppercase
keywords, inline HTML and high-byte identifiers. Two things they caught that
reasoning had got wrong: the prefilter is not just an optimization ($typeConfig
always contains `enum`, so on a supportsEnums=false run the cleaner's early
return fires on an enum the symbol regex cannot collect and truncates away
everything after it), and php_strip_whitespace() deletes comments leaving no
separator, so an identifier split by a comment really does reach the cleaner
joined back together - comment removal therefore stays its own pass.
SymbolFinderInFiles::findSymbolsInFile() also gains the `continue` its constant
branch was missing: without it every global constant additionally registered
its own namespace prefix - or the empty string - as a class name in the index.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVTkJADSfAdKoypbfF6nWQ
ondrejmirtes
force-pushed
the
native-php-file-cleaner
branch
from
August 30, 2026 11:10
a7c7d70 to
27843c9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The optimized source locators build their symbol index by scanning every file
of a directory:
php_strip_whitespace(), a prefilter regex,PhpFileCleaner::clean()and a symbol regex, each materializing a PHP string.Two thirds of that time was
clean()alone — a byte-by-byte PHP loop overevery file in the analysed tree.
This ports the scan to the turbo extension and then removes the cache, the
scan lock and the arena records that existed to stop parallel workers from
repeating it.
The scan
PhpFileCleaner::cleanfindSymbolspipelinehash_file('sha256')over the same corpusfile_get_contentsMeasured over a 50 MB / 16.8k file corpus.
Why the cache can go
That reference row is the point. The cache exists to be validated, and
validating it means
hash_file()-ing every file in the tree — 0.419 s, ofwhich 0.225 s is just reading the bytes. A fresh scan now costs 0.445 s. The
cache no longer buys anything, so when the turbo extension is active and
workers are forked the directory is walked and scanned outright: no file
hashing, no persisted symbol table, no scan lock, no arena record, and nothing
that can go stale.
That only works because the parent can scan for everyone.
PreForkDirectorySymbolScannerbuilds the directory locators in the mainprocess just before it forks, so the children inherit them copy-on-write.
This is the follow-up #5846 left open. Its single-flight lock could only
remove the duplicated symbol extraction — the per-worker
FileFinderwalkand sha256 of every file happened before the lock was ever reached, because
those hashes were what validated the cache. And the pre-warm it started as was
dropped because it forced
buildSourceLocator()in the main thread on everyparallel run, breaking #5577's laziness. Fork-by-default (#6260) plus a native
scan turns that trade into a plain win: the classmap directories that PR
identified as "43 of the 44 scans per worker" are now scanned once.
The directories are collected and scanned as one batch rather than one at a
time, so a file two directories both reach is read once (0.32 s -> 0.16 s).
Measured end to end
Against the current 2.2.x phar, cold caches, ABBA-interleaved, output
byte-identical in every run:
The memory comes free: every worker used to build its own
classToFile/functionToFiles/constantToFile, and they now share theparent's copy-on-write. Slevomat's run-to-run stdev also drops from 7.09 s to
1.41 s — that was the
flockqueue.Falling back
Three gates, each of which alone keeps today's behaviour:
The one-worker gate keeps #5577's lazy main thread: a run that forks a single
worker has nothing to share, so nothing is pulled off that worker's lazy path.
Correctness
The cleaned text feeds a regex whose captures become the symbol index, so the
bar is identical output, not merely plausible output. Two differential tests
compare the native implementations against the PHP twins over every PHP file
in the repository in both
supportsEnumsmodes — 43,656 symbol-triplechecks and 42,392 cleaner-output checks — plus fixtures for heredocs,
#[Attr]vs
#comments, thedefine()/inDefinestring leak, uppercase keywords,inline HTML and high-byte identifiers.
Two things those tests caught that reasoning had got wrong:
$typeConfigalways containsenum, so on asupportsEnums=falserun the cleaner's early return fires onan
enumthe symbol regex has no branch for and truncates away every symbolafter it. 22 files exposed it. Reproduced rather than "fixed".
php_strip_whitespace()deletes comments leaving no separator, socl/*x*/ass Fooreally does reach the cleaner asclass Foo. Commentremoval therefore stays its own pass; fusing it into the scan would silently
lose that join.
SymbolFinderInFiles::findSymbolsInFile()also gains thecontinueitsconstant branch was missing: without it every global constant additionally
registered its own namespace prefix — or the empty string — as a class name
in the directory index.
Full suite (21,158 tests / 96,008 assertions) green with the extension loaded,
side-by-side/ smoke / signature-parity green, self-analysis clean, andanalysis output identical with the extension on and with
PHPSTAN_TURBO=0.🤖 Generated with Claude Code
https://claude.ai/code/session_01LVTkJADSfAdKoypbfF6nWQ