New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[bug] StatementsVolatileCache a cause of large memory usage when cache not in use #9899
Comments
Hey @dkarlovi, can you reproduce the issue on https://psalm.dev ? |
Response to #9889 (comment) : I certainly got interesting results. I'm not sure those results can be generalized (because they were done in my work station with 16G of memory, while I was working and with a big old legacy project) but here they are:
So, in my specific case, this volatile cache is detrimental both in time AND memory. It's likely because it forces my OS to use the swap file to store excess memory usage and degrades time performance with IO on hard drive (it's an SSD) Note that everything was run with --no-cache, because I never could run Psalm with cache enabled (maybe I should try again with this change, though I'm pretty sure the issue is elsewhere) |
@orklah the same effect is noticeable in Psalm's self-analysis. IMO it might make sense to see if removing this class completely would make an impact since you might be better off loading files on demand from disk cache instead of keeping all this in memory. @muglug could you spare some insight here? |
Try max_size=0 and enabling cache, maybe it works 🤞 |
I took a look at this one without thinking about solutions or reading through the code properly. PHP's GC not being effective with the LRU cache isn't an issue here memory wise, we're only caching the top nodes of the AST for each file and the AST is not cyclic. I checked this with a debug_zval_dump and the only ref counts I could see above 1 behind a separate reference usually It's worth noting that storing each file's ASTs separately is obviously going to going to allocate more than it needs because it's not a "one-file" strategy like C or rust's module based parser where details such as namespaces and symbols can be stored once per declaration. For example I'm guessing we might parse and store However phpparser notes it might be slowed down by GC a fair bit, as far as I can see there is no disabling of the GC during parsing which is probably more effort than it's worth as an optimisation. You can read more of that following from the same link as above here. It's worth noting that For example make the following patch: diff --git a/src/Psalm/Internal/Provider/StatementsProvider.php b/src/Psalm/Internal/Provider/StatementsProvider.php
index 6393cd34c..74980942d 100644
--- a/src/Psalm/Internal/Provider/StatementsProvider.php
+++ b/src/Psalm/Internal/Provider/StatementsProvider.php
@@ -133,9 +133,12 @@ class StatementsProvider
|| (!$config->isInProjectDirs($file_path) && strpos($file_path, 'vendor'))
) {
$cache_key = "{$file_content_hash}:{$analysis_php_version_id}";
+ fprintf(STDERR, "\nr{$cache_key}\n");
if ($this->statements_volatile_cache->has($cache_key)) {
+ fprintf(STDERR, "\nh{$cache_key}\n");
return $this->statements_volatile_cache->get($cache_key);
}
+ fprintf(STDERR, "\nm{$cache_key}\n");
$progress->debug('Parsing ' . $file_path . "\n"); Grab a set of stats for threading and otherwise, filtering out fancy output to get a list of cache requests, hits and misses denoted by the first character. ( ./psalm --no-cache --threads=20 2>20threadsnocache && sed -i '/\([%|░]\|^$\)/d;1,2d' 20threadsnocache ./psalm --no-cache --threads=1 2>1threadnocache && sed -i '/\([%|░]\|^$\)/d;1,2d' 1threadnocache Save the following script (filter.php) to filter for misses: <?php
$cachestats = [];
while (($line = fgets(STDIN)) !== false) {
$key = substr($line, 2);
if (!isset($cachestats[$key])) $cachestats[$key] = [
'r' => 0,
'h' => 0,
'm' => 0,
];
$op = substr($line, 0, 1);
if ($op === 'r') $cachestats[$key]['r'] += 1;
if ($op === 'h') $cachestats[$key]['h'] += 1;
if ($op === 'm') $cachestats[$key]['m'] += 1;
}
// Default to filtering for
$countfilter = intval($argv[1] ?? 2); // 2 or more
$opfilter = $argv[2] ?? 'm'; // misses
$opcount = 0;
foreach ($cachestats as $set)
if ($set[$opfilter] >= $countfilter)
$opcount++;
$opmap = ['m' => 'misses', 'h' => 'hits', 'r' => 'requests'];
echo "$opcount keys with $countfilter or more {$opmap[$opfilter]}" . PHP_EOL; Checking for seemingly redundant work?
Given this is 20 threads it's very spread out:
Now to confirm our suspect of threading by seeing if the same miss rate is present on a single thread:
And confirming we are doing the same amount of work:
And finally just to be clear multi-threading does hit the cache but I'm not aware of if this is a single thread hitting it's own cache or some sort of sharing (I didn't look at the threading code):
I suspect I've mucked up somewhere because that is definitely doesn't sound right but maybe it's useful 😄
Here are some numbers for that using
Disabling StatementsVolatileCache with: diff --git a/src/Psalm/Internal/Provider/StatementsProvider.php b/src/Psalm/Internal/Provider/StatementsProvider.php
index 6393cd34c..97642c352 100644
--- a/src/Psalm/Internal/Provider/StatementsProvider.php
+++ b/src/Psalm/Internal/Provider/StatementsProvider.php
@@ -143,7 +143,7 @@ class StatementsProvider
$stmts = self::parseStatements($file_contents, $analysis_php_version_id, $has_errors, $file_path);
- $this->statements_volatile_cache->set($cache_key, $stmts);
+ // $this->statements_volatile_cache->set($cache_key, $stmts);
return $stmts;
}
Hopefully some things to debunk or that could be improved here :) |
This is a good takeaway. Even when not multi-threading, the runtime difference is minor, while the memory difference is major. It seems like this cache could be removed from Psalm completely to be able to focus on the file-based cache all threads can equally use. |
@orklah did you get a chance to test on your mega-codebase when this cache is disabled, but file cache enabled by any chance? |
Yeah, that makes no difference. With cache enabled, Psalm doesn't even make it to the step where there's a progressbar. It stays in "scan files" mode. BTW, every test I did, I do in 1 thread. Using multiple threads actually makes Psalm longer for me :p |
Debunked! I slept on this then realised I blindly trusted psalm's memory usage output only to click on the github notifications today, search the codebase and realise it's Regardless given the cache is definitely not shared between fcntl_fork procs so it's likely not that efficient but the numbers above are gobeldygook from the parent proc not reflective of actual system memory usage. Here is the output confirm there are no duplicate cache misses for each proc:
We should just be using the file cache really. Pool caching multiple full ASTs in memory is ridiculous. If they were actually ref counted it would make sense. |
In the meantime PHPStan author said in Symfony Slack PHPStan doesn't use any sort of AST cache at all, it does it all live. :) Psalm has two layers of cache for the same thing, wonder why that is. |
Well, let's drop that then! If @muglug has some insight afterwards, reverting is always an option :) |
@LiamKearn it's all yours. 👼 |
This cache was originally added in #7876 — the blame falsely points to me because I think I used Git incorrectly when rebasing. This looks like a good change! |
The original performance fix should have been opt-in, not opt-out. Without investigating too deeply, I assume those authors were encountering poor performance when analysing code written in a pre-composer-autoload style, where |
When implementing #9889, a discussion about resource usage vs performance pointed me to investigate why Psalm is using as much memory as it is. It seems
StatementsVolatileCache
is at fault, it can keep a lot of memory in use, making Psalm even unusable in some cases with very very large memory footprint (@orklah claims 8GB+).It seems there would be a better way to find a tradeoff here, Psalm shouldn't hog all the memory.
Suggested fixes in Symfony Slack:
Maybe we could even consider what happens if we disable this cache fully? It doesn't seem to do that much, but I didn't test that throughly.
The text was updated successfully, but these errors were encountered: