Skip to content

Native directory symbol scan, and no cache or lock behind it - #6304

Merged
ondrejmirtes merged 2 commits into
2.2.xfrom
native-php-file-cleaner
Aug 30, 2026
Merged

Native directory symbol scan, and no cache or lock behind it#6304
ondrejmirtes merged 2 commits into
2.2.xfrom
native-php-file-cleaner

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

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 over
every 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

before after
PhpFileCleaner::clean 1.321 s 0.111 s (11.9x)
whole findSymbols pipeline 1.705 s 0.445 s (3.8x)
reference: hash_file('sha256') over the same corpus 0.419 s
reference: raw file_get_contents 0.225 s

Measured 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, of
which 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.
PreForkDirectorySymbolScanner builds the directory locators in the main
process 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 FileFinder walk
and 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 every
parallel 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:

user CPU wall peak RSS
slevomat (12 runs) -1.66% -4.97% -101.7 MB
shipmonk (6 runs) -1.07% -4.37% flat

The memory comes free: every 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.09 s to
1.41 s — that was the flock queue.

Falling back

Three gates, each of which alone keeps today's behaviour:

situation scan cache + lock
no turbo PHP twin kept
turbo, spawned workers (Windows, OPcache on) native kept — no parent to inherit from
turbo, forked, 1 worker native, in the worker dropped
turbo, forked, >1 worker native, in the parent dropped

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 supportsEnums modes — 43,656 symbol-triple
checks and 42,392 cleaner-output checks — plus fixtures for heredocs, #[Attr]
vs # comments, the define()/inDefine string leak, uppercase keywords,
inline HTML and high-byte identifiers.

Two things those tests 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 has no branch for and truncates away every symbol
    after it. 22 files exposed it. Reproduced rather than "fixed".
  • php_strip_whitespace() deletes comments leaving no separator, so
    cl/*x*/ass Foo really does reach the cleaner as class Foo. Comment
    removal therefore stays its own pass; fusing it into the scan would silently
    lose that join.

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 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, and
analysis output identical with the extension on and with PHPSTAN_TURBO=0.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LVTkJADSfAdKoypbfF6nWQ

@ondrejmirtes
ondrejmirtes force-pushed the native-php-file-cleaner branch 2 times, most recently from 9e8924a to a7c7d70 Compare August 30, 2026 11:04
ondrejmirtes and others added 2 commits August 30, 2026 13:08
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
ondrejmirtes force-pushed the native-php-file-cleaner branch from a7c7d70 to 27843c9 Compare August 30, 2026 11:10
@ondrejmirtes
ondrejmirtes merged commit 27843c9 into 2.2.x Aug 30, 2026
152 of 153 checks passed
@ondrejmirtes
ondrejmirtes deleted the native-php-file-cleaner branch August 30, 2026 11:10
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