Skip to content

Commit

Permalink
MemoryManager: Control when cycle garbage collection is run (#6554)
Browse files Browse the repository at this point in the history
This PR replicates the mechanism by which PHP's own GC is triggered: using a dynamically adjusted threshold based on the number of roots and the number of destroyed cycles. This approach was chosen to minimize behavioural changes.

This currently only applies to the main thread. Doing this for other threads is a bit more complicated (and in the case of RakLib, possibly not necessary anyway).

By doing this, we can get more accurate performance profiling. Instead of GC happening in random pathways and throwing off GC numbers, we trigger it in a predictable place, where timings can record it.

This change may also produce minor performance improvements in code touching lots of objects (such as `CraftingDataPacket` encoding`), which previously might've triggered multiple GC runs within a single tick. Now that GC runs wait for `MemoryManager`, it can touch as many objects as it wants during a tick without paying a performance penalty.

While working on this change I came across a number of issues that should probably be addressed in the future:

1) Objects like Server, World and Player that can't possibly be GC'd repeatedly end up in the GC root buffer because the refcounts fluctuate frequently. Because of the dependency chains in these objects, they all drag each other into GC, causing an almost guaranteed parasitic performance cost to GC. This is discussed in php/php-src#17131, as the proper solution to this is probably generational GC, or perhaps some way to explicitly mark objects to be ignored by GC.
2) World's `blockCache` blows up the GC root threshold due to poor size management. This leads to infrequent, but extremely expensive GC runs due to the sheer number of objects being scanned. We could avoid a lot of this cost by managing caches like this more effectively.
3) StringToItemParser and many of the pocketmine\data classes which make heavy use of closures are afflicted by thousands of reference cycles. This doesn't present a major performance issue in most cases because the cycles are simple, but this could easily be fixed with some simple refactors.
  • Loading branch information
dktapps authored Dec 15, 2024
1 parent b10caf7 commit 8f8fe94
Showing 1 changed file with 44 additions and 3 deletions.
47 changes: 44 additions & 3 deletions src/MemoryManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,12 @@
use function fwrite;
use function gc_collect_cycles;
use function gc_disable;
use function gc_enable;
use function gc_mem_caches;
use function gc_status;
use function get_class;
use function get_declared_classes;
use function get_defined_functions;
use function hrtime;
use function ini_get;
use function ini_set;
use function intdiv;
Expand All @@ -55,9 +56,11 @@
use function is_resource;
use function is_string;
use function json_encode;
use function max;
use function mb_strtoupper;
use function min;
use function mkdir;
use function number_format;
use function preg_match;
use function print_r;
use function round;
Expand All @@ -75,6 +78,14 @@ class MemoryManager{
private const DEFAULT_CONTINUOUS_TRIGGER_RATE = Server::TARGET_TICKS_PER_SECOND * 2;
private const DEFAULT_TICKS_PER_GC = 30 * 60 * Server::TARGET_TICKS_PER_SECOND;

//These constants are copied from Zend/zend_gc.c as of PHP 8.3.14
//TODO: These values could be adjusted to better suit PM, but for now we just want to mirror PHP GC to minimize
//behavioural changes.
private const GC_THRESHOLD_TRIGGER = 100;
private const GC_THRESHOLD_MAX = 1_000_000_000;
private const GC_THRESHOLD_DEFAULT = 10_001;
private const GC_THRESHOLD_STEP = 10_000;

private int $memoryLimit;
private int $globalMemoryLimit;
private int $checkRate;
Expand All @@ -91,6 +102,9 @@ class MemoryManager{
private bool $garbageCollectionTrigger;
private bool $garbageCollectionAsync;

private int $cycleCollectionThreshold = self::GC_THRESHOLD_DEFAULT;
private int $cycleCollectionTimeTotalNs = 0;

private int $lowMemChunkRadiusOverride;
private bool $lowMemChunkGC;

Expand All @@ -107,6 +121,7 @@ public function __construct(
$this->logger = new \PrefixedLogger($server->getLogger(), "Memory Manager");

$this->init($server->getConfigGroup());
gc_disable();
}

private function init(ServerConfigGroup $config) : void{
Expand Down Expand Up @@ -152,7 +167,6 @@ private function init(ServerConfigGroup $config) : void{
$this->lowMemClearWorldCache = $config->getPropertyBool(Yml::MEMORY_WORLD_CACHES_LOW_MEMORY_TRIGGER, true);

$this->dumpWorkers = $config->getPropertyBool(Yml::MEMORY_MEMORY_DUMP_DUMP_ASYNC_WORKER, true);
gc_enable();
}

public function isLowMemory() : bool{
Expand Down Expand Up @@ -204,6 +218,18 @@ public function trigger(int $memory, int $limit, bool $global = false, int $trig
$this->logger->debug(sprintf("Freed %gMB, $cycles cycles", round(($ev->getMemoryFreed() / 1024) / 1024, 2)));
}

private function adjustGcThreshold(int $cyclesCollected, int $rootsAfterGC) : void{
//TODO Very simple heuristic for dynamic GC buffer resizing:
//If there are "too few" collections, increase the collection threshold
//by a fixed step
//Adapted from zend_gc.c/gc_adjust_threshold() as of PHP 8.3.14
if($cyclesCollected < self::GC_THRESHOLD_TRIGGER || $rootsAfterGC >= $this->cycleCollectionThreshold){
$this->cycleCollectionThreshold = min(self::GC_THRESHOLD_MAX, $this->cycleCollectionThreshold + self::GC_THRESHOLD_STEP);
}elseif($this->cycleCollectionThreshold > self::GC_THRESHOLD_DEFAULT){
$this->cycleCollectionThreshold = max(self::GC_THRESHOLD_DEFAULT, $this->cycleCollectionThreshold - self::GC_THRESHOLD_STEP);
}
}

/**
* Called every tick to update the memory manager state.
*/
Expand Down Expand Up @@ -236,9 +262,25 @@ public function check() : void{
}
}

$rootsBefore = gc_status()["roots"];
if($this->garbageCollectionPeriod > 0 && ++$this->garbageCollectionTicker >= $this->garbageCollectionPeriod){
$this->garbageCollectionTicker = 0;
$this->triggerGarbageCollector();
}elseif($rootsBefore >= $this->cycleCollectionThreshold){
Timings::$garbageCollector->startTiming();

$start = hrtime(true);
$cycles = gc_collect_cycles();
$end = hrtime(true);

$rootsAfter = gc_status()["roots"];
$this->adjustGcThreshold($cycles, $rootsAfter);

Timings::$garbageCollector->stopTiming();

$time = $end - $start;
$this->cycleCollectionTimeTotalNs += $time;
$this->logger->debug("gc_collect_cycles: " . number_format($time) . " ns ($rootsBefore -> $rootsAfter roots, $cycles cycles collected) - total GC time: " . number_format($this->cycleCollectionTimeTotalNs) . " ns");
}

Timings::$memoryManager->stopTiming();
Expand Down Expand Up @@ -465,7 +507,6 @@ public static function dumpMemory(mixed $startingObject, string $outputFolder, i
$logger->info("Finished!");

ini_set('memory_limit', $hardLimit);
gc_enable();
}

/**
Expand Down

0 comments on commit 8f8fe94

Please sign in to comment.