Skip to content

Commit 8e60b1a

Browse files
SanderMullerclaude
andauthored
Memoize file contents by path in CachedParser to skip redundant reads (#5928)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9e4cf9d commit 8e60b1a

4 files changed

Lines changed: 411 additions & 55 deletions

File tree

‎src/Internal/LruCache.php‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Internal;
4+
5+
use PHPStan\ShouldNotHappenException;
6+
use function array_key_exists;
7+
use function array_key_first;
8+
use function count;
9+
use function sprintf;
10+
11+
/**
12+
* A least-recently-used cache backed by an array, bounded by entry count, by the total
13+
* weight of its entries, or by both.
14+
*
15+
* PHP arrays keep insertion order, so the least recently used entry is the first one and
16+
* touching an entry means re-inserting it at the end. Callers decide what an entry weighs:
17+
* for a cache of file contents that is the length of the contents, for one keyed by source
18+
* code it is the length of the key.
19+
*
20+
* @template TValue
21+
*/
22+
final class LruCache
23+
{
24+
25+
/** @var array<string, TValue> insertion order is the LRU order, oldest first */
26+
private array $values = [];
27+
28+
/** @var array<string, int> */
29+
private array $weights = [];
30+
31+
private int $weight = 0;
32+
33+
/**
34+
* @param int $maxCount maximum number of entries, 0 for no limit
35+
* @param int $maxWeight maximum total weight, 0 for no limit
36+
* @param int $weightEvictionFloorCount weight eviction stops at this many entries, so a
37+
* single entry heavier than $maxWeight cannot flush the whole cache on every insertion
38+
*/
39+
public function __construct(
40+
private int $maxCount = 0,
41+
private int $maxWeight = 0,
42+
private int $weightEvictionFloorCount = 0,
43+
)
44+
{
45+
}
46+
47+
/**
48+
* The entry, touched so it becomes the most recently used one, or null when there is none.
49+
*
50+
* @return TValue|null
51+
*/
52+
public function get(string $key): mixed
53+
{
54+
if (!array_key_exists($key, $this->values)) {
55+
return null;
56+
}
57+
58+
$value = $this->values[$key];
59+
unset($this->values[$key]);
60+
$this->values[$key] = $value;
61+
62+
return $value;
63+
}
64+
65+
/**
66+
* Stores an entry, evicting least recently used ones until it fits.
67+
*
68+
* An entry already stored under this key is replaced, its weight accounted for again, and
69+
* the entry becomes the most recently used one.
70+
*
71+
* @param TValue $value
72+
* @return list<string> the keys evicted to make room, so a caller keeping data alongside
73+
* this cache can drop the same entries
74+
*/
75+
public function set(string $key, mixed $value, int $weight): array
76+
{
77+
if (array_key_exists($key, $this->values)) {
78+
$this->weight -= $this->weights[$key];
79+
unset($this->values[$key], $this->weights[$key]);
80+
}
81+
82+
$evicted = $this->evict($weight);
83+
84+
$this->values[$key] = $value;
85+
$this->weights[$key] = $weight;
86+
$this->weight += $weight;
87+
88+
return $evicted;
89+
}
90+
91+
/**
92+
* Replaces the value of an existing entry and touches it, leaving its weight as it was.
93+
*
94+
* This is for a value that grew a better representation rather than different contents -
95+
* nothing is evicted, because nothing new is taking up room.
96+
*
97+
* @param TValue $value
98+
*/
99+
public function replace(string $key, mixed $value): void
100+
{
101+
if (!array_key_exists($key, $this->values)) {
102+
throw new ShouldNotHappenException(sprintf('Cannot replace %s, it is not in the cache.', $key));
103+
}
104+
105+
unset($this->values[$key]);
106+
$this->values[$key] = $value;
107+
}
108+
109+
public function count(): int
110+
{
111+
return count($this->values);
112+
}
113+
114+
/**
115+
* @return array<string, TValue> in LRU order, oldest first
116+
*/
117+
public function all(): array
118+
{
119+
return $this->values;
120+
}
121+
122+
/**
123+
* @return list<string>
124+
*/
125+
private function evict(int $incomingWeight): array
126+
{
127+
$evicted = [];
128+
while (
129+
($this->maxCount > 0 && count($this->values) >= $this->maxCount)
130+
|| (
131+
$this->maxWeight > 0
132+
&& $this->weight + $incomingWeight > $this->maxWeight
133+
&& count($this->values) > $this->weightEvictionFloorCount
134+
)
135+
) {
136+
$oldestKey = array_key_first($this->values);
137+
if ($oldestKey === null) {
138+
break;
139+
}
140+
141+
$this->weight -= $this->weights[$oldestKey];
142+
unset($this->values[$oldestKey], $this->weights[$oldestKey]);
143+
$evicted[] = $oldestKey;
144+
}
145+
146+
return $evicted;
147+
}
148+
149+
}

‎src/Parser/CachedParser.php‎

Lines changed: 67 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44

55
use PhpParser\Node;
66
use PHPStan\File\FileReader;
7-
use function array_key_first;
7+
use PHPStan\Internal\LruCache;
8+
use function clearstatcache;
9+
use function filemtime;
10+
use function filesize;
811
use function strlen;
912

1013
final class CachedParser implements Parser
1114
{
1215

1316
/**
14-
* Size-based eviction never shrinks the cache below this many entries.
17+
* Size-based eviction never shrinks the AST cache below this many entries.
1518
* Without this floor, a single source larger than $cachedSourceBytesMax
1619
* would flush the whole cache on every insertion, degenerating the cache
1720
* to a single entry whenever such a source is hot.
@@ -26,16 +29,24 @@ final class CachedParser implements Parser
2629
*/
2730
private const CACHED_SOURCE_BYTES_DEFAULT_LIMIT = 4_194_304;
2831

29-
/** @var array<string, Node\Stmt[]>*/
30-
private array $cachedNodesByString = [];
31-
32-
private int $cachedNodesByStringCount = 0;
32+
/**
33+
* parseFile() is called once per class using a trait, so the same file
34+
* is read from disk over and over (one hot trait file was read 94,000
35+
* times in a cold run of a large Laravel project). Memoizing the contents
36+
* by path skips those redundant reads; the total memoized source size is
37+
* bounded the same way as the AST cache.
38+
*/
39+
private const MEMOIZED_SOURCE_BYTES_LIMIT = 524_288;
3340

34-
private int $cachedSourceBytes = 0;
41+
/** @var LruCache<Node\Stmt[]> keyed by source code, weighing the length of that source */
42+
private LruCache $cachedNodesByString;
3543

3644
/** @var array<string, true> */
3745
private array $parsedByString = [];
3846

47+
/** @var LruCache<array{int, int, string}> path => [mtime, size, source code] */
48+
private LruCache $cachedSourceByFile;
49+
3950
/**
4051
* The AST of a parsed file takes up roughly 50-60x more memory than the
4152
* source code itself, so alongside the entry count limit, the total source
@@ -51,6 +62,12 @@ public function __construct(
5162
private int $cachedSourceBytesMax = self::CACHED_SOURCE_BYTES_DEFAULT_LIMIT,
5263
)
5364
{
65+
$this->cachedNodesByString = new LruCache(
66+
$this->cachedNodesByStringCountMax,
67+
$this->cachedSourceBytesMax,
68+
self::SIZE_EVICTION_FLOOR_LIMIT,
69+
);
70+
$this->cachedSourceByFile = new LruCache(maxWeight: self::MEMOIZED_SOURCE_BYTES_LIMIT);
5471
}
5572

5673
/**
@@ -59,24 +76,23 @@ public function __construct(
5976
*/
6077
public function parseFile(string $file): array
6178
{
62-
$sourceCode = FileReader::read($file);
63-
$isCached = isset($this->cachedNodesByString[$sourceCode]);
64-
if ($isCached && !isset($this->parsedByString[$sourceCode])) {
65-
return $this->markRecentlyUsed($sourceCode);
79+
$sourceCode = $this->readFile($file);
80+
$cachedNodes = $this->cachedNodesByString->get($sourceCode);
81+
if ($cachedNodes !== null && !isset($this->parsedByString[$sourceCode])) {
82+
return $cachedNodes;
6683
}
6784

6885
$nodes = $this->originalParser->parseFile($file);
69-
if ($isCached) {
86+
if ($cachedNodes !== null) {
7087
// upgrade an entry previously produced by parseString() in place -
7188
// no net change to the entry count, just refresh its LRU position
72-
unset($this->cachedNodesByString[$sourceCode], $this->parsedByString[$sourceCode]);
73-
} else {
74-
$this->evictLeastRecentlyUsed(strlen($sourceCode));
75-
$this->cachedNodesByStringCount++;
76-
$this->cachedSourceBytes += strlen($sourceCode);
89+
$this->cachedNodesByString->replace($sourceCode, $nodes);
90+
unset($this->parsedByString[$sourceCode]);
91+
92+
return $nodes;
7793
}
7894

79-
$this->cachedNodesByString[$sourceCode] = $nodes;
95+
$this->store($sourceCode, $nodes);
8096

8197
return $nodes;
8298
}
@@ -86,63 +102,59 @@ public function parseFile(string $file): array
86102
*/
87103
public function parseString(string $sourceCode): array
88104
{
89-
if (isset($this->cachedNodesByString[$sourceCode])) {
90-
return $this->markRecentlyUsed($sourceCode);
105+
$cachedNodes = $this->cachedNodesByString->get($sourceCode);
106+
if ($cachedNodes !== null) {
107+
return $cachedNodes;
91108
}
92109

93110
$nodes = $this->originalParser->parseString($sourceCode);
94-
$this->evictLeastRecentlyUsed(strlen($sourceCode));
95-
$this->cachedNodesByString[$sourceCode] = $nodes;
96-
$this->cachedNodesByStringCount++;
97-
$this->cachedSourceBytes += strlen($sourceCode);
111+
$this->store($sourceCode, $nodes);
98112
$this->parsedByString[$sourceCode] = true;
99113

100114
return $nodes;
101115
}
102116

103117
/**
104-
* LRU bookkeeping: re-insert the entry at the end so genuinely cold sources
105-
* are evicted first, not the ones inserted earliest.
106-
*
107-
* @return Node\Stmt[]
118+
* @param Node\Stmt[] $nodes
108119
*/
109-
private function markRecentlyUsed(string $sourceCode): array
120+
private function store(string $sourceCode, array $nodes): void
110121
{
111-
$nodes = $this->cachedNodesByString[$sourceCode];
112-
unset($this->cachedNodesByString[$sourceCode]);
113-
$this->cachedNodesByString[$sourceCode] = $nodes;
114-
115-
return $nodes;
122+
foreach ($this->cachedNodesByString->set($sourceCode, $nodes, strlen($sourceCode)) as $evictedSourceCode) {
123+
unset($this->parsedByString[$evictedSourceCode]);
124+
}
116125
}
117126

118-
private function evictLeastRecentlyUsed(int $incomingSourceBytes): void
127+
private function readFile(string $file): string
119128
{
120-
if ($this->cachedNodesByStringCountMax === 0) {
121-
return;
129+
// mtime alone has one-second granularity, so a same-second edit could be
130+
// served stale in a long-running process (PHPStan Pro, fixer worker);
131+
// keying by size as well catches edits that change the length. filesize()
132+
// is served from PHP's stat cache populated by filemtime(), so it costs
133+
// no extra syscall.
134+
clearstatcache(true, $file);
135+
$mtime = @filemtime($file);
136+
$size = @filesize($file);
137+
if ($mtime === false || $size === false) {
138+
return FileReader::read($file);
122139
}
123140

124-
while (
125-
$this->cachedNodesByStringCount >= $this->cachedNodesByStringCountMax
126-
|| (
127-
$this->cachedSourceBytesMax > 0
128-
&& $this->cachedSourceBytes + $incomingSourceBytes > $this->cachedSourceBytesMax
129-
&& $this->cachedNodesByStringCount > self::SIZE_EVICTION_FLOOR_LIMIT
130-
)
131-
) {
132-
$oldestKey = array_key_first($this->cachedNodesByString);
133-
if ($oldestKey === null) {
134-
break;
135-
}
136-
137-
unset($this->cachedNodesByString[$oldestKey], $this->parsedByString[$oldestKey]);
138-
$this->cachedNodesByStringCount--;
139-
$this->cachedSourceBytes -= strlen($oldestKey);
141+
$cached = $this->cachedSourceByFile->get($file);
142+
if ($cached !== null && $cached[0] === $mtime && $cached[1] === $size) {
143+
return $cached[2];
140144
}
145+
146+
$sourceCode = FileReader::read($file);
147+
if (strlen($sourceCode) <= self::MEMOIZED_SOURCE_BYTES_LIMIT) {
148+
// set() replaces a stale entry for this path (mtime or size changed) as well
149+
$this->cachedSourceByFile->set($file, [$mtime, $size, $sourceCode], strlen($sourceCode));
150+
}
151+
152+
return $sourceCode;
141153
}
142154

143155
public function getCachedNodesByStringCount(): int
144156
{
145-
return $this->cachedNodesByStringCount;
157+
return $this->cachedNodesByString->count();
146158
}
147159

148160
public function getCachedNodesByStringCountMax(): int
@@ -155,7 +167,7 @@ public function getCachedNodesByStringCountMax(): int
155167
*/
156168
public function getCachedNodesByString(): array
157169
{
158-
return $this->cachedNodesByString;
170+
return $this->cachedNodesByString->all();
159171
}
160172

161173
}

0 commit comments

Comments
 (0)