Repository navigation
Faster runs with a warm result cache - #6627
Merged
Merged
Conversation
ondrejmirtes
force-pushed
the
result-cache-perf
branch
4 times, most recently
from
September 30, 2026 11:20
e441809 to
31b90c4
Compare
ondrejmirtes
marked this pull request as ready for review
September 30, 2026 11:20
Collaborator
|
This pull request has been marked as ready for review. |
Cache::load() publishes every entry it reads from disk to the arena, and arena records were written once - the first publish of a key won. An entry its caller checks after loading it could therefore get stuck there: FileTypeMapper checks its name scope maps against the hashes of the files they were created from, so after a file changed the stale map got published, was rejected, and the fresh map created instead could never replace it. Every later lookup in every worker got the stale map back and created the map again. With every file of Drupal core edited, the workers created 44k name scope maps for 11k files, parsing the file each time. ArenaCache::replace() takes over the key's slot instead, and Cache::save() uses it: a load publishes a copy of the file only when the key has nothing yet, a save always wins. Callers checking their entries after loading them need to do nothing for it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ
Walking the analysed directories costs a readdir() per directory. On Drupal core, 28k directories, that is about a second in the main process, and the worker building the symbol index walks the same directories again. A directory's listing only changes when an entry in it is added, removed or renamed, and each of those updates its mtime and ctime. DirectoryWalker now keeps the listings in tmpDir and reads a directory again only when its stat changed. A directory modified in the second its listing is read is not kept, because a second change in that same second would not show in its stat. The walk yields the same files in the same order as Symfony Finder, and falls back to Finder on Windows and for stream wrappers or unreadable directories. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ
Every run hashed every analysed file to find the changed ones, on Drupal core almost a second for 11k files. The result cache now also records each file's size, mtime, ctime, inode and device, and the recorded hash is reused while they match - the same check git does against its index. A signature is only recorded for a file last modified before the second its hash was taken, and nothing is reused on Windows, where ctime is the creation time. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ
Written out with the paths in full, the graph repeats each file in the dependent list of everything it depends on. On Drupal core that is half a million paths and 42 MB, and each of them was converted between absolute and relative on every save and restore - about half a second. The graph now lists every file once in a path table and refers to it by position, so only the table is converted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ
The exported nodes are most of the result cache - on Drupal core 135 MB of 160 MB. A run needs the decoded nodes of the few files that changed, and the rest only has to get into the next cache file unchanged. Decoding all of them on restore and serializing them again on save took about half a second of every run that re-analysed anything. The nodes are now stored as an index of files and lengths followed by the serialized nodes back to back. restore() decodes only the ones it compares, and save() copies the bytes of the others from the old file. The old file is closed before the new one is renamed over it, for Windows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ
restore() parses every changed file to compare its exported nodes with the cached ones, which decides whether its dependents, the classes using a trait it declares, and the files with errors are re-analysed too. When all of those files changed themselves and are re-analysed anyway, the answer cannot add anything, so the file is not parsed. After a branch switch or a formatting run that touched most of the project that is most of the changed files. On Drupal core with every file edited, restoring took 9 seconds of parsing in the main process, and the workers forked from it were slower too. The files re-analysed stay the same. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ
DirectoryWalker and ResultCacheManager each built a stat signature, decided on their own whether it could be trusted - not on Windows, not for a file modified in the second the reading began - and had to take the time before reading anything for that to hold. FileStatSignatures::begin() takes the time, and the reader it returns gives a signature only when it can be trusted, so the callers just compare and keep what they get. The directory listings use the same signature as the files now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ
OptimizedDirectorySourceLocatorFactory had two ways of finding the symbols of a directory. Without the turbo extension, or with workers that are not forked, they were cached, and the cache was checked by hashing every file. With the extension and forked workers, every file was scanned natively on every run instead, because hashing cost about as much as the scan - 0.7s on Drupal core, paid by every run that analyses at least one file. There is one way now. The cache entry of a directory keeps each file's stat signature next to its hash (see FileStatSignatures), and a file whose signature matches is not even hashed. When the signature cannot vouch for the file, the hash decides, as before. The arena records that shared the file hashes and the symbol maps between workers that are not forked are gone - the cache entry itself is still shared through the arena by Cache::load(). The cache now also keeps the files that declare no symbols, which used to be scanned again on every run. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ
ondrejmirtes
force-pushed
the
result-cache-perf
branch
from
September 30, 2026 11:40
31b90c4 to
0a50bfa
Compare
Before forking, PreForkDirectorySymbolScanner collects every directory locator and scans them in one go. With a single worker the locators are created lazily in that worker instead, where each directory was scanned on its own. The lazy initializer now batches them the same way. beginBatchedScan() and flushBatchedScan() switched the factory into a collecting mode that everything calling it in between took part in. createBatch() returns an object instead: the locators added to it are scanned by its scan(), and the repository and the Composer locator maker add to it when they are given one. A batch can hold two locators with the same cache key (odsl-installed-files of two Composer projects), and taking the scan lock for the second one does not wait for the lock the same process holds for the first. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ
ondrejmirtes
force-pushed
the
result-cache-perf
branch
from
September 30, 2026 11:52
0a50bfa to
ec0d4b6
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.
Runs with a warm result cache spent most of their time on fixed costs that did not depend on how many files changed. Each commit takes one of them away. The first commit fixes a bug in the turbo arena that also affects released versions.
Measured on Drupal core (11,194 analysed files, 28k directories), PHP 8.5, Apple M1 Pro, turbo extension on, fastest of 3 runs. "Before" is 2.3.x at 7c94037, "after" is an earlier version of this PR:
The "before" column up to "result cache cleared" is from an earlier session; the machine ran about 5% slower during the "after" session (the unchanged 2.3.x build too), so those gains are if anything understated. The "every file edited" row was measured in one session.
The rework after review (one stat-signature service, one way of caching directory symbols, a batch object,
ArenaCache::replace()) does not change these numbers. Before vs. after the rework, medians of 12 interleaved runs each, separate tmpDirs:WordPress core (full analysis, result cache cleared): 20.0 s -> 19.5 s without turbo, 8.2 s -> 7.9 s with turbo (a small tree - its directory walk and symbol scan were cheap to begin with).
Commits
Cache::load()publishes every entry it reads from disk to the arena, and the first publish of a key won. FileTypeMapper checks its name scope maps after loading them, against the hashes of the files they were created from, so after a file changed the stale map got published, was rejected, and the fresh map could never replace it. Every later lookup in every worker recreated the map: with every file of Drupal core edited, the workers created 44k maps for 11k files. NewArenaCache::replace()in the extension, used byCache::save(): a load publishes only when the key has nothing yet, a save always wins. Callers checking their entries after loading need to do nothing for it.FileStatSignatures::begin()takes the time before anything is read, and the reader it returns gives a signature only when it can be trusted,nullotherwise (Windows, stream wrappers, a failed stat, a file modified in the second the reading began). DirectoryWalker and ResultCacheManager just compare what they get.createBatch()returns an object withcreateByDirectory(),createByFiles()andscan(), replacingbeginBatchedScan()/flushBatchedScan().Commits 3 -> 4 -> 5 -> 6 touch the same code in ResultCacheManager and depend on each other textually. 4 and 5 each bump CACHE_VERSION; if both are kept, one bump is enough. Commit 7 builds on 2 and 3, commit 8 on 7, commit 9 on 8.
Safety
dependenciessection is still written because older versions absolutize it before their cacheVersion check.Cache::load().Tests
ArenaCache::replace()inarena-smoke.php(including racing replaces).bug-14718(GNUsed -i) andresult-cache-ci-notification(needs CI env vars), both pass with those adjusted.timeoutandsudomissing on macOS).🤖 Generated with Claude Code
https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ