Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion src/Parser/CachedParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
use PhpParser\Node;
use PHPStan\File\FileReader;
use function array_key_first;
use function clearstatcache;
use function filemtime;
use function filesize;
use function strlen;

final class CachedParser implements Parser
Expand Down Expand Up @@ -36,6 +39,20 @@ final class CachedParser implements Parser
/** @var array<string, true> */
private array $parsedByString = [];

/**
* parseFile() is called once per class using a trait, so the same file
* is read from disk over and over (one hot trait file was read 94,000
* times in a cold run of a large Laravel project). Memoizing the contents
* by path skips those redundant reads; the total memoized source size is
* bounded the same way as the AST cache above.
*/
private const MEMOIZED_SOURCE_BYTES_LIMIT = 524_288;

/** @var array<string, array{int, int, string}> path => [mtime, size, source code] */
private array $cachedSourceByFile = [];

private int $memoizedSourceBytes = 0;

/**
* The AST of a parsed file takes up roughly 50-60x more memory than the
* source code itself, so alongside the entry count limit, the total source
Expand All @@ -59,7 +76,7 @@ public function __construct(
*/
public function parseFile(string $file): array
{
$sourceCode = FileReader::read($file);
$sourceCode = $this->readFile($file);
$isCached = isset($this->cachedNodesByString[$sourceCode]);
if ($isCached && !isset($this->parsedByString[$sourceCode])) {
return $this->markRecentlyUsed($sourceCode);
Expand Down Expand Up @@ -100,6 +117,61 @@ public function parseString(string $sourceCode): array
return $nodes;
}

/**
* Read a file's contents, memoized by path and keyed by mtime; clearstatcache
* keeps this correct when a file changes between calls in a long-running
* process (PHPStan Pro, fixer worker). Bounded by MEMOIZED_SOURCE_BYTES_LIMIT
* with least-recently-used eviction.
*/
private function readFile(string $file): string
{
// mtime alone has one-second granularity, so a same-second edit could be
// served stale in a long-running process (PHPStan Pro, fixer worker);
// keying by size as well catches edits that change the length. filesize()
// is served from PHP's stat cache populated by filemtime(), so it costs
// no extra syscall.
clearstatcache(true, $file);
$mtime = @filemtime($file);
$size = @filesize($file);
if ($mtime === false || $size === false) {
return FileReader::read($file);
Comment on lines +133 to +137

@staabm staabm Jul 8, 2026

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.

I think this parts adds a additional cost for the common path (namely a file which does not contain a trait and will likely only be parsed a few times gets a perf penality).

the cache itself is mostly useful for traits. maybe we can optimize this cache for the trait case, without taking a perf hit for the common path which does not involve traits (I have no idea yet how this can/should work).

running this PR on phpstan-src does not yield a meaningful improvement yet in analysis time in my testing

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.

in addition my testing using make phpstan somehow shows, that this path is only taken for .stub files, when adding a echo "building cache $file \n"; in CachedParser->readFile

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The ".stub only" is a parallel-mode artifact, not the real picture. make phpstan runs the analysis in worker child processes, and only the main process's stdout reaches your terminal. The main process parses almost only stubs; the source files are parsed in the workers, whose stdout the parallel runner captures rather than prints. Counting parseFile calls per process on a cold run of phpstan-src:

  • main process: 10 .php, 186 .stub
  • all processes together: 71,978 .php, 586 .stub

So source files do go through readFile (~72k times), the workers' echo just isn't shown. Run with --debug (single process) and you'll see it directly: 68,174 .php reads in the one visible process.

On the common-path cost, fair point: for a file parsed once, the clearstatcache + filemtime + filesize is pure overhead. It's easy to only start memoizing (and doing the stat) from a path's second read, so single-parse files pay nothing. Happy to do that if you think it's worth keeping.

On who benefits, to be precise: the redundancy is high wherever many classes pull the same traits/base files, so the same file gets re-parsed a lot in one cold run: phpstan-src re-reads the average file ~31x, a fresh Laravel ~40x, a doctrine/symfony app ~3x. But it's a sys/IO reduction, not a CPU/latency win. Reads are only ~0.6-3.4% of cold CPU in my measurements, so dropping ~94% of them is a real cut in sys time and syscalls on cold runs of large high-fan-in projects, but it won't move total analysis time on phpstan-src meaningfully, and it does nothing for warm runs. That's the honest scope: a bounded cold-run IO win, largest on big high-fan-in codebases.

}

if (
isset($this->cachedSourceByFile[$file])
&& $this->cachedSourceByFile[$file][0] === $mtime
&& $this->cachedSourceByFile[$file][1] === $size
) {
$entry = $this->cachedSourceByFile[$file];
unset($this->cachedSourceByFile[$file]);
$this->cachedSourceByFile[$file] = $entry;
Comment thread
staabm marked this conversation as resolved.

return $entry[2];
}

$sourceCode = FileReader::read($file);
$incomingBytes = strlen($sourceCode);
if ($incomingBytes <= self::MEMOIZED_SOURCE_BYTES_LIMIT) {
if (isset($this->cachedSourceByFile[$file])) {
// stale entry for this path (mtime or size changed)
$this->memoizedSourceBytes -= strlen($this->cachedSourceByFile[$file][2]);
unset($this->cachedSourceByFile[$file]);
}
while ($this->memoizedSourceBytes + $incomingBytes > self::MEMOIZED_SOURCE_BYTES_LIMIT) {
$oldestPath = array_key_first($this->cachedSourceByFile);
if ($oldestPath === null) {
break;
}
$this->memoizedSourceBytes -= strlen($this->cachedSourceByFile[$oldestPath][2]);
unset($this->cachedSourceByFile[$oldestPath]);
}
$this->cachedSourceByFile[$file] = [$mtime, $size, $sourceCode];
$this->memoizedSourceBytes += $incomingBytes;
}

return $sourceCode;
}

/**
* LRU bookkeeping: re-insert the entry at the end so genuinely cold sources
* are evicted first, not the ones inserted earliest.
Expand Down
88 changes: 88 additions & 0 deletions tests/PHPStan/Parser/CachedParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@
use Generator;
use PhpParser\Node;
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\Node\Stmt\Nop;
use PHPStan\BetterReflection\Reflection\ExprCacheHelper;
use PHPStan\File\FileHelper;
use PHPStan\File\FileReader;
use PHPStan\Testing\PHPStanTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\Stub;
use function file_put_contents;
use function sprintf;
use function str_repeat;
use function sys_get_temp_dir;
use function time;
use function touch;
use function uniqid;
use function unlink;

class CachedParserTest extends PHPStanTestCase
{
Expand Down Expand Up @@ -237,4 +245,84 @@ public function testWithExprCacheHelper(): void
$this->assertSame(['startLine' => 1, 'startTokenPos' => 35, 'startFilePos' => 137, 'endLine' => 10, 'endTokenPos' => 35, 'endFilePos' => 143, 'kind' => 1, 'rawValue' => "'hello'"], $reImported->getAttributes());
}

public function testParseFileSkipsReadingUnchangedFileAndRereadsAfterChange(): void
{
$parser = new CachedParser($this->getContentEchoingParserStub(), 500);
$path = sys_get_temp_dir() . '/phpstan-cached-parser-' . uniqid() . '.php';
$baseTime = time() - 10;

try {
file_put_contents($path, 'contents A');
touch($path, $baseTime);
$this->assertSame('contents A', $parser->parseFile($path)[0]->getAttribute('content'));

// Same-length contents change with an unchanged mtime is not detectable
// by the [mtime, size] key: the memoized contents are returned without re-reading.
file_put_contents($path, 'contents B');
touch($path, $baseTime);
$this->assertSame('contents A', $parser->parseFile($path)[0]->getAttribute('content'));

// A size change invalidates the memo even when the mtime is unchanged.
file_put_contents($path, 'contents B longer');
touch($path, $baseTime);
$this->assertSame('contents B longer', $parser->parseFile($path)[0]->getAttribute('content'));

// A newer mtime invalidates the memo, so the file is read again.
file_put_contents($path, 'contents C longer');
touch($path, $baseTime + 10);
$this->assertSame('contents C longer', $parser->parseFile($path)[0]->getAttribute('content'));
} finally {
@unlink($path);
}
}

public function testFileContentsMemoIsBoundedByTotalSourceBytes(): void
{
$parser = new CachedParser($this->getContentEchoingParserStub(), 500);
$baseTime = time() - 10;
$bigA = sys_get_temp_dir() . '/phpstan-cached-parser-a-' . uniqid() . '.php';
$bigB = sys_get_temp_dir() . '/phpstan-cached-parser-b-' . uniqid() . '.php';
$huge = sys_get_temp_dir() . '/phpstan-cached-parser-h-' . uniqid() . '.php';

try {
// A file larger than the memo limit is never memoized: a content change
// with an unchanged mtime is still picked up because the file is re-read.
file_put_contents($huge, 'huge first ' . str_repeat('x', 600_000));
touch($huge, $baseTime);
$this->assertStringStartsWith('huge first', $parser->parseFile($huge)[0]->getAttribute('content'));
file_put_contents($huge, 'huge second ' . str_repeat('x', 600_000));
touch($huge, $baseTime);
$this->assertStringStartsWith('huge second', $parser->parseFile($huge)[0]->getAttribute('content'));

// Two ~300 KB files exceed the limit together: memoizing the second
// evicts the first (least recently used), so a content change to the
// first with an unchanged mtime is picked up by the forced re-read.
file_put_contents($bigA, 'big-a first ' . str_repeat('a', 300_000));
touch($bigA, $baseTime);
$this->assertStringStartsWith('big-a first', $parser->parseFile($bigA)[0]->getAttribute('content'));

file_put_contents($bigB, 'big-b ' . str_repeat('b', 300_000));
touch($bigB, $baseTime);
$this->assertStringStartsWith('big-b', $parser->parseFile($bigB)[0]->getAttribute('content'));

file_put_contents($bigA, 'big-a second ' . str_repeat('a', 300_000));
touch($bigA, $baseTime);
$this->assertStringStartsWith('big-a second', $parser->parseFile($bigA)[0]->getAttribute('content'));
} finally {
@unlink($bigA);
@unlink($bigB);
@unlink($huge);
}
}

private function getContentEchoingParserStub(): Parser&Stub
{
$mock = $this->createStub(Parser::class);
$mock->method('parseFile')->willReturnCallback(
static fn (string $file): array => [new Nop(['content' => FileReader::read($file)])],
);

return $mock;
}

}
Loading