Performance Tests #2 - Arrays
Geschwindigkeitstests und Analyse von PHP Arrays vs ArrayObjects
Datum: 2021-06, Tags: php, array, arrayobject, performance, testThe sources are available on gitlab: https://gitlab.com/jlue/php-performance-tests/-/tree/master/test-2-arrays.
In meinem letzten Performance Test habe ich mit der Geschwindigkeit von Data Transfer Objects beschäftigt und festgestellt, dass Array-basierte DTOs keine gute Idee sind, da sie im Vergleich zu allen anderen Varianten deutlich langsamer sind. Das liegt insbesondere daran, dass Arrays als Funktionsargument in PHP nicht per Referenz übergeben werden, sondern als Kopie (siehe Referenzen). Der Aufwand, Arrays zu kopieren und als Return zurückzugeben, wird bei großen Datenmengen deutlich spürbar.
Die Übergabe von Arrays als Kopie im Vergleich zur Übergabe von ArrayObjects als Referenz will ich jetzt einmal genauer untersuchen. Und auch einen Sonderfall, der vielleicht manchmal schwer zu erkennen ist, aber ebenfalls ein erheblicher Flaschenhals bei der Verarbeitung von großen Datenmengen sein kann: array_reduce().
Test Szenario
Für den Test benutze ich eine Klasse, die nichts weiter macht, als einem übergebenen Array immer noch ein 'sample' hinzuzufügen. Nach einigen Iterationen sollte dann das Array so angewachsen sein, dass sich das hin- und herkopieren bemerkbar macht.
Ich benutzte PHP in der Version 7.4.6
Dies ist die Klasse, der vollständige Sourcecode für diesen Test liegt auf gitlab.com: https://gitlab.com/jlue/php-performance-tests/-/tree/master/test-2-arrays
<?php namespace ArrayTest; require_once 'AccumulationInterface.php'; class DataArray implements AccumulationInterface { /** * @return array */ public function addSample($existing): array { $existing[] = 'sample'; return $existing; } }
Was hier nicht sichtbar ist, ist dass das Argument $existing
ein Array ist. Da beide Tests das selbe Interface
benutzen sollen, wird das im Interface nicht festgelegt, sondern in der Test-Implementierung, siehe unten.
Im Vergleich dazu das selbe Vorgehen mit ArrayObject:
<?php namespace ArrayTest; use ArrayObject; require_once 'AccumulationInterface.php'; class DataArrayObject implements AccumulationInterface { /** * @return void */ public function addSample($existing): void { $existing[] = 'sample'; } }
Hier sieht man, dass im Gegensatz zu dem Array-Test das $existing
Object nicht wieder zurückgegeben werden muss,
da es als Referenz übergeben und per Seiteneffekt angereichert wird.
Performance Test Array und ArrayObject
Für den Test soll wie beschrieben ein Array akkumuliert werden. Dabei wird die benötigte Zeit und der Speicherverbrauch gemessen:
<?php namespace ArrayTest; require_once 'DataArray.php'; $iterations = intval($argv[1] ?? 0); $start = microtime(true); $memstart = memory_get_usage(); $uut = new DataArray(); $data = []; for ($i = 0; $i < $iterations; $i++) { $data = $uut->addSample($data); } $end = microtime(true); $memend = memory_get_usage(); echo sprintf("%s: %01.3f s (%s results, %s memory)\n", $iterations, ($end - $start), count($data), ($memend - $memstart));
Und im Vergleich dazu ein ArrayObject:
<?php namespace ArrayTest; use ArrayObject; require_once 'DataArrayObject.php'; $iterations = intval($argv[1] ?? 0); $start = microtime(true); $memstart = memory_get_usage(); $uut = new DataArrayObject(); $data = new ArrayObject(); for ($i = 0; $i < $iterations; $i++) { $uut->addSample($data); } $end = microtime(true); $memend = memory_get_usage(); echo sprintf("%s: %01.3f s (%s results, %s memory)\n", $iterations, ($end - $start), count($data), ($memend - $memstart));
Der Testlauf mit den für mein System relevanten Iterationen zeigt deutlich das erwartete Ergebnis:
host:~/php-performance-tests/test-2-arraysfor i in 1000 2000 4000 8000 16000 32000 64000 128000 256000 ; do php index-Array.php $i ; done 1000: 0.003 s (1000 results, 36960 memory) 2000: 0.010 s (2000 results, 69728 memory) 4000: 0.036 s (4000 results, 135264 memory) 8000: 0.149 s (8000 results, 266336 memory) 16000: 0.603 s (16000 results, 528480 memory) 32000: 2.386 s (32000 results, 1052768 memory) 64000: 11.910 s (64000 results, 2101368 memory) 128000: 58.676 s (128000 results, 4198520 memory) 256000: 276.513 s (256000 results, 8392824 memory) host:~/php-performance-tests/test-2-arraysfor i in 1000 2000 4000 8000 16000 32000 64000 128000 256000 ; do php index-ArrayObject.php $i ; done 1000: 0.001 s (1000 results, 37088 memory) 2000: 0.002 s (2000 results, 69856 memory) 4000: 0.003 s (4000 results, 135392 memory) 8000: 0.005 s (8000 results, 266464 memory) 16000: 0.011 s (16000 results, 528608 memory) 32000: 0.022 s (32000 results, 1052896 memory) 64000: 0.041 s (64000 results, 2101496 memory) 128000: 0.086 s (128000 results, 4198648 memory) 256000: 0.176 s (256000 results, 8392952 memory)
Da sieht man schon ganz deutlich, dass der Aufwand für das Kopieren der Arrays exponentiell anwächst. Im Gegensatz dazu bleibt der Aufwand für die Akkumulation bei Übergabe per Referenz konstant, auch bei Datenmengen, bei denen der Aufwand mit Arrays schon längst den Rahmen sprengt.
Außerdem sieht man an dem "memory"-Wert in Klammen, welchen Speicher die Testfälle in Anspruch nehmen. Die sind annähernd gleich.
Bevor ich mir die Ergebnisse im Detail ansehe, möchte ich jedoch noch das Test-Szenario auf die PHP Funktion array_reduce()
anwenden. Wer gleich
zu den bunten Bildchen springen will, verpasst das wichtigste! Aber vorher noch ein kleiner Test um festzustellen, wann auch der Test
mit ArrayObjects einknickt:
host:~/php-performance-tests/test-2-arraysfor i in 1000 10000 100000 1000000 10000000 100000000 ; do php index-ArrayObject.php $i ; done 1000: 0.001 s (1000 results) 10000: 0.007 s (10000 results) 100000: 0.059 s (100000 results) 1000000: 0.508 s (1000000 results) 10000000: 4.999 s (10000000 results) 100000000: 74.626 s (100000000 results)
Also ja, bei über 10 Millionen Elementen bricht auf meinem Testsystem auch mit ArrayObjects die Performance ein.
Performance Test array_reduce() mit Array und ArrayObject
PHP kennt die Funktion array_reduce(), mit der Arrays "iterativ zu einem Mittelwert per Callback-Funktion" reduziert werden können. Berühmt-berüchtigt ist die Funktion jedoch auch zur Re-Indizierung eines Arrays nach einem darin enthaltenen wert.
Die Funktion wird wie folgt verwendet:
$result = array_reduce($data, function (mixed $carry, mixed $item) { ... }, $inital);
Der Methode wird ein Array $data
übergeben, über das iteriert wird und die Elemente enthält, die innerhalb des callbacks als $item
verfügbar sind.
Zusätzlich wird in dem Callback ein Array/Objekt $carry
übergeben, das anfänglich in die Funktion als $initial
übergeben wird, und am Ende
als $result
zurückgegeben wird.
Auf diese Weise lässt sich, wie in diesem Besipiel, aus einem Array ein anderes erzeugen, das jedoch nach einem in den einzelnen Elementen enthaltenen Schlüssel indiziert ist.
Der Name der Funktion array_reduce()
und auch die Dokumentation
legen nahe, dass diese speziell für die Benutzung von Arrays optimiert ist.
Das ist jedoch nicht so und die Nachteile der Übergabe von Arrays als Kopie werden hier sehr deutlich. In jeder Iteration
wird das $carry
kopiert, wenn es an das callback übergeben wird. Und auch bei der Rückgabe des $carry
aus dem Callback geschieht dies nicht per Referenz, was einen weiteren Kopiervorgang bedeutet.
Genau das soll nun noch einmal untersucht und gemessen werden. Da die Funtion mit einem bestehenden Array bzw. ArrayObject arbeitet, muss der Testfall leicht abgeändert und anfänglich ein entsprechend großes Array erzeugt werden. Dann soll jedoch die Geschwindigkeit von Arrays gegenüber ArrayObject für diese Methode verglichen werden. Die Zeit für die Erzeugung des initialen Arrays soll nicht Bestandteil der Zeitmessung sein.
<?php namespace ArrayTest; require_once 'DataArray.php'; $iterations = intval($argv[1]) ?? 0; $data = []; for ($i = 0; $i < $iterations; $i++) { $data[] = 'sample'; } $start = microtime(true); $memstart = memory_get_usage(); $result = array_reduce($data, function (array $carry, $item) { $carry[] = $item; return $carry; }, []); $end = microtime(true); $memend = memory_get_usage(); echo sprintf("%s: %01.3f s (%s results, %s memory%s memory)\n", $iterations, ($end - $start), count($result), ($memend - $memstart));
Dabei ist $data
ein einfaches Array, das in das array_reduce()
als $data
übergeben wird.
Als $initial
wird ein leeres Array übergeben.
Ebenso ist $result
auch ein einfaches Array, das so viele 'samples' enthält, wie Elemente in $data
enthalten sind. Innerhalb des callbacks werden keine komplizierten Operationen durchgeführt, lediglich das
$item
and das $carry
angehängt.
Die Operation, mit foreach
einen Wert an ein bestehendes Array anzuhängen, wie hier für das initiale $data
Array,
ist überigens auch sehr performant. Wer mag, kann die Zeit der Initialisierung des $data
Arrays separat messen oder ebenfalls
in die Messung einbeziehen.
Der gleiche Test mit ArrayObject:
<?php namespace ArrayTest; use ArrayObject; require_once 'DataArray.php'; $iterations = intval($argv[1]) ?? 0; $data = []; for ($i = 0; $i < $iterations; $i++) { $data[] = 'sample'; } $start = microtime(true); $memstart = memory_get_usage(); $result = array_reduce($data, function (ArrayObject $carry, $item) { $carry[] = $item; return $carry; }, new ArrayObject())->getArrayCopy(); $end = microtime(true); $memend = memory_get_usage(); echo sprintf("%s: %01.3f s (%s results, %s memory)\n", $iterations, ($end - $start), count($result), ($memend - $memstart));
Bei der Verwendung von ArrayObjects wird ebenfalls ein einfaches Array initialisiert und auch eines zurückgegeben.
In das array_reduce()
geben wir jedoch als $initial
ein ArrayObject, mit dem intern
als $carry
weitergearbeitet wird.
Nach Durchlauf von array_reduce()
wird das initiale ArrayObject wieder
zu einem einfachen Array zurück konvertiert. Das soll ebenfalls Bestandteil der Zeitmessung sein.
Da innerhalb von array_reduce()
das ArrayObject benutzt wird,
wird damit die Übergabe des Funktionsparamerters $carry
und dessen Rückgabe
per Referenz ermöglicht.
Ein Testlauf zeigt die folgenden Ergebnisse:
host:~/php-performance-tests/test-2-arraysfor i in 1000 10000 100000 1000000 10000000 100000000 ; do php index-ReduceArray.php $i ; done 1000: 0.003 s (1000 results, 36920 memory) 2000: 0.010 s (2000 results, 69688 memory) 4000: 0.037 s (4000 results, 135224 memory) 8000: 0.154 s (8000 results, 266296 memory) 16000: 0.612 s (16000 results, 528440 memory) 32000: 2.414 s (32000 results, 1052728 memory) 64000: 12.002 s (64000 results, 2101328 memory) 128000: 58.437 s (128000 results, 4198480 memory) 256000: 274.755 s (256000 results, 8392784 memory) host:~/php-performance-tests/test-2-arraysfor i in 1000 10000 100000 1000000 10000000 100000000 ; do php index-ReduceArrayObject.php $i ; done 1000: 0.001 s (1000 results, 36920 memory) 2000: 0.002 s (2000 results, 69688 memory) 4000: 0.004 s (4000 results, 135224 memory) 8000: 0.008 s (8000 results, 266296 memory) 16000: 0.017 s (16000 results, 528440 memory) 32000: 0.031 s (32000 results, 1052728 memory) 64000: 0.059 s (64000 results, 2101328 memory) 128000: 0.122 s (128000 results, 4198480 memory) 256000: 0.258 s (256000 results, 8392784 memory)
Der Unterschied ist hier ebenso deutlich zu erkennen. Und das bei tatsächlich exakt gleicher Speicherauslastung.
Auswertung
Die Messungen sind sicherlich abhängig von der Kapazität des Testsystems, werden aber relativ die gleichen Ergebnisse liefern:
Iterationen \ Sekunden | Array | ArrayObject | ReduceArray | ReduceArrayObject |
---|---|---|---|---|
1000 | 0,003 | 0,001 | 0,003 | 0,001 |
2000 | 0,01 | 0,002 | 0,01 | 0,002 |
4000 | 0,036 | 0,003 | 0,037 | 0,004 |
8000 | 0,149 | 0,005 | 0,154 | 0,008 |
16000 | 0,603 | 0,011 | 0,612 | 0,017 |
32000 | 2,386 | 0,002 | 2,414 | 0,031 |
64000 | 11,91 | 0,041 | 12,002 | 0,059 |
128000 | 58,676 | 0,086 | 58,437 | 0,122 |
256000 | 276,513 | 0,176 | 274,755 | 0,258 |
Als Grafik sieht das wie folgt aus, einmal dargestellt als Zeit für den Testlauf und einmal als Geschwindigkeit "Zyklen pro Sekunde":
So kann man tatsächlich nicht viel erkennen. Die Performance beim "Array" Test und dem "ReduceArray" Test sind annähernd gleich.
Wie schon gesehen steigt der Aufwand für die Verarbeitung von Arrays exponentiell, sowohl bei einfacher Aggregation mittels Argumentübergabe,
als auch bei der Verwendung von array_reduce()
.
Hier noch einmal der relevante Ausschnitt aus der Grafik:
Da wir die gemessenen Iterationen jeweils vedoppeln, stelle ich die y-Achsen einmal logarythmisch dar, was eventuell die Unterschiede in den kleineren Werten deutlicher macht:
Es wird deutlich:
- Der Aufwand für die Aggregation von Arrays steigt exponentiell mit der Größe der Arrays. Und das sehr deutlich, schon bei 256000 Iterationen bzw. Array-Elementen wird für die Arrays einen tausendfachen Aufwand im Vergleich zu ArrayObjects gemessen!
- Der gleiche exponentielle Aufwand wird für Arrays benötigt, die per
array_reduce()
re-indiziert werden. Eine zu erwartende interne Optimierung der Methode ist in PHP 7.4 nicht vorhanden. - Der Aufwand für die Aggregation und Re-Indizierung per
array_reduce()
von ArrayObjects bleibt konstant im Rahmen der System-Resourcen. - Die Memory-Auslastung ist sowohl bei Arrays, als auch bei ArrayObjects für sowohl Aggregation, als auch Re-indizierung per
array_reduce()
gleich.
Zusammenfassung
Arrays als Funktionsargument zu übergeben, ist bei großen Datenmengen ein deutlicher Performance-Killer, da Arrays
sowohl bei Übergabe, als auch bei der Rückgabe kopiert werden. Es ist auf jeden Fall ratsam, mit ArrayObjects zu arbeiten.
Auch PHP-interne Methoden wie array_reduce()
müssen
unter diesem Gesichtspunkt betrachtet werden, da auch dabei der Unterschied zwischen Übergabe als Kopie oder Referenz wirksam wird.
Das ist nicht gerade zu erwarten und muss in der Entwicklung beachtet werden.
Wenn möglich, sollten Arrays in ArrayObjects gewandelt werden, bevor sie massenhaft in der Art wie hier getestet
verarbeitet werden.
Schickt Kommentare oder Anmerkungen bitte über Twitter an @limitland oder @jlue.
Referenzen
Die Übergabe von Objekten als Referenz in PHP wird hier beschrieben:
https://www.php.net/manual/de/language.references.pass.php.
Jedoch ist die Übergabe von Parametern als Referenz mit &
wie beschrieben seit PHP 5.4 veraltet und löst einen fatalen Fehler aus!
Die Übergabe von Arrays (stellvertretend für andere einfache Objekte) als Kopie wird hier beschrieben: https://www.php.net/manual/de/language.types.array.php
https://www.php.net/manual/en/language.types.array.phpArray assignment always involves value copying.