Skip to content

Commit 354e926

Browse files
ondrejmirtesclaude
andcommitted
Raise CachedParser source-byte cap to 4 MB, add eviction floor, make it configurable
The 512 KB cap from bf4d513 effectively shrank the 256-entry LRU to ~45 entries on WordPress: its hot working set includes 25 files over 100 KB (formatting.php, post.php, functions.php, ...) that everything else pulls in through reflection, so all evictions were size-based and the hottest files were re-parsed 100x per run. The WordPress integration-test PHPStan step regressed from ~105 s to ~150 s (+42%); locally +39% user CPU (274 s vs 197 s uncapped). Sweep on WordPress (user CPU / median worker peak memory): 512 KB 274 s / 250 MB, 1 MB 250 s / 268 MB, 2 MB 225 s / 316 MB, 4 MB 199 s / 400 MB, uncapped 197 s / 542 MB. A typical project (phpstan-src itself) has a natural LRU appetite of only 1.3 MB and is unconstrained by the new default either way. Since both the memory cost of a cached entry and the CPU cost of re-parsing it scale with its source size, the value-per-byte of an entry is its access frequency alone - eviction order should stay recency-based, and the byte cap is only a memory safety net, so it must be generous enough to hold a big project's hot working set. The new eviction floor guarantees minimum residency: without it, a single source larger than the whole cap would flush the entire cache on every insertion, degenerating the cache to one entry when such sources are hot. The cap is exposed as cache.nodesByStringSourceBytesMax (0 = unlimited) alongside the other cache limits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012SoHaGT1YXNue8VzSKT7aG
1 parent 8c82f1a commit 354e926

5 files changed

Lines changed: 79 additions & 8 deletions

File tree

‎conf/config.neon‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ parameters:
124124
internalErrorsCountLimit: 50
125125
cache:
126126
nodesByStringCountMax: 256
127+
nodesByStringSourceBytesMax: 4194304
127128
resolvedPhpDocBlockCacheCountMax: 2048
128129
nameScopeMapMemoryCacheCountMax: 512
129130
phpStormStubsNodesCountMax: 128

‎conf/parametersSchema.neon‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ parametersSchema:
148148
internalErrorsCountLimit: int()
149149
cache: structure([
150150
nodesByStringCountMax: int(),
151+
nodesByStringSourceBytesMax: int(),
151152
resolvedPhpDocBlockCacheCountMax: int(),
152153
nameScopeMapMemoryCacheCountMax: int(),
153154
phpStormStubsNodesCountMax: int(),

‎conf/parsers.neon‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ services:
6969
arguments:
7070
originalParser: @pathRoutingParser
7171
cachedNodesByStringCountMax: %cache.nodesByStringCountMax%
72+
cachedSourceBytesMax: %cache.nodesByStringSourceBytesMax%
7273
autowired: false
7374

7475
freshStubParser:
@@ -82,4 +83,5 @@ services:
8283
arguments:
8384
originalParser: @freshStubParser
8485
cachedNodesByStringCountMax: %cache.nodesByStringCountMax%
86+
cachedSourceBytesMax: %cache.nodesByStringSourceBytesMax%
8587
autowired: false

‎src/Parser/CachedParser.php‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ final class CachedParser implements Parser
1111
{
1212

1313
/**
14-
* The AST of a parsed file takes up roughly 50-60x more memory
15-
* than the source code itself. Together with the entry count limit,
16-
* this caps the total source size of the cached ASTs so that a few
17-
* large files cannot pin hundreds of megabytes in each worker process.
14+
* Size-based eviction never shrinks the cache below this many entries.
15+
* Without this floor, a single source larger than $cachedSourceBytesMax
16+
* would flush the whole cache on every insertion, degenerating the cache
17+
* to a single entry whenever such a source is hot.
1818
*/
19-
private const CACHED_SOURCE_BYTES_LIMIT = 524_288;
19+
private const SIZE_EVICTION_FLOOR_LIMIT = 32;
2020

2121
/** @var array<string, Node\Stmt[]>*/
2222
private array $cachedNodesByString = [];
@@ -28,9 +28,19 @@ final class CachedParser implements Parser
2828
/** @var array<string, true> */
2929
private array $parsedByString = [];
3030

31+
/**
32+
* The AST of a parsed file takes up roughly 50-60x more memory than the
33+
* source code itself, so alongside the entry count limit, the total source
34+
* size of the cached ASTs is capped by $cachedSourceBytesMax (0 = unlimited)
35+
* so that large files cannot pin hundreds of megabytes in each worker
36+
* process. The cap has to be generous enough to hold a big project's hot
37+
* working set (WordPress needs ~4 MB) because evicting a hot file costs
38+
* a re-parse proportional to the very bytes the eviction saved.
39+
*/
3140
public function __construct(
3241
private Parser $originalParser,
3342
private int $cachedNodesByStringCountMax,
43+
private int $cachedSourceBytesMax,
3444
)
3545
{
3646
}
@@ -105,7 +115,11 @@ private function evictLeastRecentlyUsed(int $incomingSourceBytes): void
105115

106116
while (
107117
$this->cachedNodesByStringCount >= $this->cachedNodesByStringCountMax
108-
|| $this->cachedSourceBytes + $incomingSourceBytes > self::CACHED_SOURCE_BYTES_LIMIT
118+
|| (
119+
$this->cachedSourceBytesMax > 0
120+
&& $this->cachedSourceBytes + $incomingSourceBytes > $this->cachedSourceBytesMax
121+
&& $this->cachedNodesByStringCount > self::SIZE_EVICTION_FLOOR_LIMIT
122+
)
109123
) {
110124
$oldestKey = array_key_first($this->cachedNodesByString);
111125
if ($oldestKey === null) {

‎tests/PHPStan/Parser/CachedParserTest.php‎

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use PHPStan\Testing\PHPStanTestCase;
1212
use PHPUnit\Framework\Attributes\DataProvider;
1313
use PHPUnit\Framework\MockObject\Stub;
14+
use function sprintf;
1415

1516
class CachedParserTest extends PHPStanTestCase
1617
{
@@ -24,6 +25,7 @@ public function testParseFileClearCache(
2425
$parser = new CachedParser(
2526
$this->getParserStub(),
2627
$cachedNodesByStringCountMax,
28+
1048576,
2729
);
2830

2931
$this->assertSame(
@@ -63,6 +65,57 @@ public static function dataParseFileClearCache(): Generator
6365
];
6466
}
6567

68+
public function testSourceBytesLimitEvictsAboveFloor(): void
69+
{
70+
// byte capacity (5000 / 100 = 50 entries) binds before the count limit (500)
71+
$parser = new CachedParser($this->getParserStub(), 500, 5000);
72+
73+
for ($i = 0; $i < 100; $i++) {
74+
$parser->parseString(sprintf('%0100d', $i));
75+
}
76+
77+
$this->assertSame(50, $parser->getCachedNodesByStringCount());
78+
}
79+
80+
public function testSourceBytesLimitNeverEvictsBelowFloor(): void
81+
{
82+
// byte capacity (1000 / 100 = 10 entries) is below the floor of 32,
83+
// so the floor wins and the cache keeps growing to 32 + 1 entries
84+
$parser = new CachedParser($this->getParserStub(), 500, 1000);
85+
86+
for ($i = 0; $i < 100; $i++) {
87+
$parser->parseString(sprintf('%0100d', $i));
88+
}
89+
90+
$this->assertSame(33, $parser->getCachedNodesByStringCount());
91+
}
92+
93+
public function testSourceLargerThanBytesLimitDoesNotFlushCache(): void
94+
{
95+
$parser = new CachedParser($this->getParserStub(), 500, 5000);
96+
97+
for ($i = 0; $i < 20; $i++) {
98+
$parser->parseString(sprintf('%0100d', $i));
99+
}
100+
$this->assertSame(20, $parser->getCachedNodesByStringCount());
101+
102+
// an entry bigger than the whole byte limit must not evict the
103+
// resident entries (count is below the floor) and is cached itself
104+
$parser->parseString(sprintf('%010000d', 0));
105+
$this->assertSame(21, $parser->getCachedNodesByStringCount());
106+
}
107+
108+
public function testSourceBytesLimitZeroMeansUnlimited(): void
109+
{
110+
$parser = new CachedParser($this->getParserStub(), 500, 0);
111+
112+
for ($i = 0; $i < 100; $i++) {
113+
$parser->parseString(sprintf('%0100d', $i));
114+
}
115+
116+
$this->assertSame(100, $parser->getCachedNodesByStringCount());
117+
}
118+
66119
private function getParserStub(): Parser&Stub
67120
{
68121
$mock = $this->createStub(Parser::class);
@@ -88,7 +141,7 @@ public function testParseTheSameFileWithDifferentMethod(): void
88141
self::getContainer()->getService('php8Parser'),
89142
null,
90143
);
91-
$parser = new CachedParser($pathRoutingParser, 500);
144+
$parser = new CachedParser($pathRoutingParser, 500, 4194304);
92145
$path = $fileHelper->normalizePath(__DIR__ . '/data/test.php');
93146
$pathRoutingParser->setAnalysedFiles([$path]);
94147
$contents = FileReader::read($path);
@@ -134,7 +187,7 @@ public function testWithExprCacheHelper(): void
134187
self::getContainer()->getService('php8Parser'),
135188
null,
136189
);
137-
$parser = new CachedParser($pathRoutingParser, 500);
190+
$parser = new CachedParser($pathRoutingParser, 500, 4194304);
138191
$path = $fileHelper->normalizePath(__DIR__ . '/data/parser-cache-bug.php');
139192
$pathRoutingParser->setAnalysedFiles([$path]);
140193
$contents = FileReader::read($path);

0 commit comments

Comments
 (0)