ZURÜCK

Performance Tests #2 - Arrays

Geschwindigkeitstests und Analyse von PHP Arrays vs ArrayObjects

Datum: 2021-06, Tags: php, array, arrayobject, performance, test

The 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

DataArray.php
<?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:

DataArrayObject.php
<?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:

index-Array.php
<?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:

index-ArrayObject.php
<?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.

index-ReduceArray.php
<?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:

index-ReduceArrayObject.php
<?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:

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.php
Array assignment always involves value copying.