Skip to content

Commit ccc48ff

Browse files
committed
fix(ClusterFacesJob): Adjust clustering badge size to memory limit automatically
fixes #1558 Signed-off-by: Marcel Klehr <mklehr@gmx.net>
1 parent 64ff43c commit ccc48ff

3 files changed

Lines changed: 43 additions & 9 deletions

File tree

lib/BackgroundJobs/ClusterFacesJob.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ final class ClusterFacesJob extends QueuedJob {
1919
private FaceClusterAnalyzer $clusterAnalyzer;
2020
private IJobList $jobList;
2121
private LoggerInterface $logger;
22-
public const BATCH_SIZE = 10000;
2322
private SettingsService $settingsService;
2423

2524
public function __construct(ITimeFactory $time, Logger $logger, IJobList $jobList, FaceClusterAnalyzer $clusterAnalyzer, SettingsService $settingsService) {
@@ -34,9 +33,20 @@ public function __construct(ITimeFactory $time, Logger $logger, IJobList $jobLis
3433
* @param array{storageId: int, rootId: int, userId: string} $argument
3534
*/
3635
protected function run($argument): void {
37-
$userId = $argument['userId'];
36+
$userId = (string)$argument['userId'];
3837
try {
39-
$this->clusterAnalyzer->calculateClusters($userId, self::BATCH_SIZE);
38+
$iniValue = ini_get('memory_limit');
39+
if ($iniValue === false) {
40+
$batchSize = 10_000;
41+
} else {
42+
$memoryBytes = ini_parse_quantity($iniValue);
43+
if ($memoryBytes === -1) {
44+
$batchSize = 10_000;
45+
} else {
46+
$batchSize = (int)($memoryBytes * 5_000 / 120_000_0000);
47+
}
48+
}
49+
$this->clusterAnalyzer->calculateClusters($userId, $batchSize);
4050
} catch (\Throwable $e) {
4151
$this->settingsService->setSetting('clusterFaces.status', 'false');
4252
$this->logger->error('Failed to calculate face clusters', ['exception' => $e]);

lib/Command/ClusterFaces.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public function __construct(Logger $logger, FaceDetectionMapper $detectionMapper
4141
protected function configure() {
4242
$this->setName('recognize:cluster-faces')
4343
->setDescription('Cluster detected faces per user (Memory usage will grow with O(n²): n=2000: 450MB, n=4000: 700MB, n=5000: 1200MB)')
44-
->addOption('batch-size', 'b', InputOption::VALUE_REQUIRED, 'The number of face detections to cluster in one go. 0 for no limit.', 0);
44+
->addOption('batch-size', 'b', InputOption::VALUE_REQUIRED, 'The number of face detections to cluster in one go. 0 for no limit.', 10_000);
4545
}
4646

4747
/**

lib/Service/FaceClusterAnalyzer.php

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ final class FaceClusterAnalyzer {
2323
public const DIMENSIONS = 128;
2424
public const MAX_OVERLAP_NEW_CLUSTER = 0.1;
2525
public const MIN_OVERLAP_EXISTING_CLUSTER = 0.5;
26+
public const REFERENCE_SAMPLE_BUDGET_SHARE = 0.5;
27+
public const MIN_REFERENCE_SAMPLE_SIZE = 2;
2628

2729
private FaceDetectionMapper $faceDetections;
2830
private FaceClusterMapper $faceClusters;
@@ -57,15 +59,25 @@ public function calculateClusters(string $userId, int $batchSize = 0): void {
5759
$existingClusters = $this->faceClusters->findByUserId($userId);
5860
/** @var array<int,int> $maxVotesByCluster */
5961
$maxVotesByCluster = [];
62+
$referenceSampleSize = $this->getReferenceSampleSize(count($existingClusters), $batchSize);
6063
foreach ($existingClusters as $existingCluster) {
61-
$sampled = $this->faceDetections->findClusterSample($existingCluster->getId(), $this->getReferenceSampleSize(count($existingClusters)));
64+
$sampled = $this->faceDetections->findClusterSample($existingCluster->getId(), $referenceSampleSize);
6265
$sampledDetections = array_merge($sampledDetections, $sampled);
6366
$maxVotesByCluster[$existingCluster->getId()] = count($sampled);
6467
}
6568

69+
// Every existing cluster must be represented, otherwise its faces would be clustered
70+
// into a duplicate cluster. If that alone exceeds the batch budget, we cannot honour it.
71+
if ($batchSize > 0 && count($sampledDetections) > $batchSize) {
72+
$this->logger->warning('ClusterDebug: The batch size of ' . $batchSize . ' detections is too small for the ' . count($existingClusters) . ' existing clusters of user ' . $userId . '; loading ' . count($sampledDetections) . ' reference detections instead. Consider raising the PHP memory limit.');
73+
}
74+
6675
if ($batchSize > 0) {
6776
$rejectedDetections = $this->faceDetections->sampleRejectedDetectionsByUserId($userId, $this->getRejectSampleSize($batchSize), self::MIN_DETECTION_SIZE, self::MIN_DETECTION_SIZE);
68-
$requestedFreshDetectionCount = max($batchSize - count($rejectedDetections) - count($sampledDetections), 500);
77+
// Guarantee forward progress even when samples and rejects have eaten the whole
78+
// budget, but keep the floor relative so a small batch size stays a small batch.
79+
$freshDetectionFloor = min(500, (int)round($batchSize * (1.0 - self::REFERENCE_SAMPLE_BUDGET_SHARE)));
80+
$requestedFreshDetectionCount = max($batchSize - count($rejectedDetections) - count($sampledDetections), $freshDetectionFloor);
6981
$freshDetections = $this->faceDetections->findUnclusteredByUserId($userId, $requestedFreshDetectionCount, self::MIN_DETECTION_SIZE, self::MIN_DETECTION_SIZE);
7082
} else {
7183
$freshDetections = $this->faceDetections->findUnclusteredByUserId($userId, 0, self::MIN_DETECTION_SIZE, self::MIN_DETECTION_SIZE);
@@ -266,12 +278,24 @@ private function getMinSampleSize(int $batchSize) : int {
266278

267279
/**
268280
* Grows to ~5000 detections for ~200-800 clusters (detections per cluster drop exponentially)
269-
* and then grows linearly with 5 detections per cluster
281+
* and then grows linearly with 5 detections per cluster.
282+
*
283+
* Capped so that all reference samples together stay within their share of the batch
284+
* budget: without that cap the ~5000+ sampled detections dwarf a batch size derived
285+
* from a low memory limit, and the budget would bound nothing.
270286
* @param int $numberClusters
287+
* @param int $batchSize 0 for an unbounded run
271288
* @return int
272289
*/
273-
private function getReferenceSampleSize(int $numberClusters) : int {
274-
return (int)round(75.0 * 2.0 ** (-0.007 * $numberClusters) + 5.0);
290+
private function getReferenceSampleSize(int $numberClusters, int $batchSize = 0) : int {
291+
$sampleSize = (int)round(75.0 * 2.0 ** (-0.007 * $numberClusters) + 5.0);
292+
293+
if ($batchSize <= 0 || $numberClusters === 0) {
294+
return $sampleSize;
295+
}
296+
297+
$sizePerClusterBudget = (int)floor($batchSize * self::REFERENCE_SAMPLE_BUDGET_SHARE / $numberClusters);
298+
return max(self::MIN_REFERENCE_SAMPLE_SIZE, min($sampleSize, $sizePerClusterBudget));
275299
}
276300

277301
private function getRejectSampleSize(int $batchSize): int {

0 commit comments

Comments
 (0)