DAMPF im Kessel

PHP-FPM (FastCGI) vs. Apache mod_php

Der Artikel zum DAMPF-Stack beschreibt die Inbetriebnahme des LAMP-Stacks bestehend aus Linux, dem Apache HTTP Server, MariaDB und PHP. Das L wurde zu D wie Debian konkretisiert, und PHP wurde um FPM ergänzt: den FastCGI Process Manager. Von einem Leser kam dabei die folgende Frage auf:

Ich würde nun gerne wissen, wie viel schneller DAMPF im Gegensatz zu DAMP ist. Kann vielleicht jemand ein Script schreiben, wo über einen Webservice eine Vielzahl an Anfragen einmal “langsam” mit mod_php und einmal mit Dampf mit PHP-FPM durchgeführt wird?

Das ist ein berechtigtes Anliegen! Doch wie soll das ganze überprüft werden? Mir erscheint folgende Testanordnung sinnvoll:

  1. Man benötigt ein serverseitiges Skript, das eine mehr oder weniger hohe Last erzeugt.
  2. Weiter muss ein Client simuliert werden, der einerseits viele und/oder “schwere” Anfragen absetzt, die serverseitig eine hohe Last generieren. Ausserdem muss dieser Client sinnvolle Statistiken über die Anfragedauern ausgeben können.

An die Arbeit!

Serverseitiges Skript: Primzahlfaktorisierung

Jede natürliche Zahl >= 2 kann als ein Produkt von Primzahlen ausgedrückt werden:

Um von einer natürlichen Zahl x die Primfaktoren zu erhalten, kann man folgendermassen vorgehen:

  1. Man findet die Primzahlen bis x (bzw. zur Quadratwurzel von x als Optimierung, die hier nicht weiter begründet werden soll).
  2. Man versucht die Zahl x durch die Primzahlen in aufsteigender Reihenfolge zu dividieren.
    • Funktioniert die Division restlos, hat man einen neuen Primfaktor gefunden. Man fährt mit dem Rest und der gleichen Primzahl fort.
    • Andernfalls versucht man die Division mit der nächsten Primzahl.
    • Der Vorgang ist fertig, wenn entweder der Rest bei 1 angelangt ist, oder wenn die Primzahlen durchprobiert worden sind: In diesem Fall ist der Rest auch eine Primzahl und somit ein Primfaktor von x.

Die Primzahlfaktorisierung ist etwa zum Kürzen von Brüchen sinnvoll ‒ oder aber zum Knacken von RSA-Schlüsseln; letzteres mit sehr viel grösseren Zahlen.

Primzahlen können folgendermassen gefunden werden:

function primes_up_to($n) {
	$primes = array();
	if ($n < 2) {
		return $primes;
	}
	for ($i = 2; $i <= $n; $i++) {
		if (is_prime($i)) {
			$primes[] = $i;
		}
	}
	return $primes;
}

function is_prime($x) {
	for ($i = 2; $i <= $x / 2; $i++) {
		if ($x % $i == 0) {
			return false;
		}
	}
	return true;
}

Die Funktion primes_up_to findet alle Primzahlen von 2 bis und mit n. Hierzu verwendet sie die Hilfsfunktion is_prime, welche für eine bestimmte Zahl x überprüft, ob es eine Primzahl ist.

Diese Implementierung ist ineffizient ‒ und somit für einen Lasttest ideal.

Die Faktorisierung einer einzelnen Zahl x funktioniert folgendermassen:

function factorize($x) {
	$factors = array();
	$primes = primes_up_to(sqrt($x));
	$n = count($primes);
	for ($i = 0; $x > 1 && $i < $n) {
		$prime = $primes[$i];
		if ($x % $prime == 0) {
			$factors[] = $prime; 
			$x /= $prime;
		} else {
			$i++;
		}
	}
	if ($x > 1) {
		$factors[] = $x;
	}
	return $factors;
}

Damit der Client mehrere Primzahlen in einer einzigen Anfrage faktorisieren lassen kann, bietet folgende Funktion die Faktorisierung von Zahlen in einem bestimmten Wertebereich an:

function factorize_range($a, $b) {
	$factors = array();
	if ($a > $b) {
		return $factors;
	}
	for ($i = $a; $i <= $b; $i++) {
		$factors[$i] = factorize($i);
	}
	return $factors;
}

Solche Sachen könnte man natürlich auch etwas eleganter mit filter, map, reduce formulieren; doch dazu bei anderer Gelegenheit…

Das PHP-Skript soll Benutzeranfragen entgegennehmen und im Klartext beantworten:

header("Content-Type: text/plain");

if (!array_key_exists("lower", $_GET) || !array_key_exists("upper", $_GET)) {
	die("usage: ?lower=[lower]&upper=[upper]");
}

$result = factorize_range($_GET["lower"], $_GET["upper"]);
foreach ($result as $n => $fs) {
	echo("{$n}:\t");
	foreach ($fs as $f) {
		echo("{$f} ");
	}
	echo("\n");
}

Es wird also mit den GET-Parametern lower und upper aufgerufen. Auf unserem DAMPF-Server als prime_factorization.php hinterlegt, kann es folgendermassen aufgerufen werden:

$ curl 'http://localhost/prime_factorization.php?lower=100&upper=109'
100:	2 2 5 5
101:	101
102:	2 3 17
103:	103
104:	2 2 2 13
105:	3 5 7
106:	2 53
107:	107
108:	2 2 3 3 3
109:	109

So wird es Zeit, etwas DAMPF in den Kessel zu bringen!

Clientseitig Last generieren: Der request0r

Um Performanceunterschiede zwischen mod_php und PHP-FPM ermitteln zu können, müssen mehrere Requests gleichzeitig abgesetzt werden. Sowas liesse sich gut mit einem Shell-Skript, dem curl-Befehl und dem Operator & umsetzen, womit Prozesse im Hintergrund ausgeführt werden können. Das Auswerten der einzelnen Laufzeiten wird aber damit eher umständlich.

Ein kleines Go-Programm namens request0r soll hier Abhilfe schaffen. Das Projekt ist auf GitHub zu finden.

Das Programm lässt sich mit Go bauen und folgendermassen ausführen:

$ go build request0r.go
$ ./request0r -w 2 -r 10 'http://localhost/prime_factorization.php?lower=100&upper=109'
Requests:
          Total          Passed          Failed            Mean
             20              20               0      1.183614ms
Percentiles:
             0%             25%             50%             75%            100%
      829.967µs       950.424µs      1.082723ms      1.368022ms      1.693025ms

Es werden zwei Worker gestartet, die je sequenziell zehn Requests ausführen und deren Antwortzeit messen. (Weicht der Antwortstatus von 200 ab, wird der Request als gescheitert verbucht und dessen Laufzeit ignoriert. Der erwartete Zustand könnte mit dem Flag -s überschrieben werden.)

Als Statistik wird einerseits ausgegeben, wie viele Requests insgesamt abgesetzt worden sind (Total: Anzahl Worker mit der Anzahl Requests pro Worker multipliziert) und wie viele Requests davon erfolgreich zurückkamen (Passed) bzw. gescheitert sind (Failed).

Das arithmetische Mittel der Antwortzeiten wird als Mean ausgewiesen, welches einen guten Indikator für die Performance darstellt. Ein differenzierteres Bild ergibt der Blick auf die Perzentile: Der schnellste Request (0%), der langsamste (100%) sowie diejenigen auf verschiedenen Schwellen (25%, 50% ‒ der Median, 75%) werden ebenfalls ausgewiesen, womit sich Ausreisser besser erkennen lassen.

Im obigen Beispiel kamen die 25% der schnellsten Anfragen in weniger als einer Millisekunde zurück, während die längste Anfrage fast 1.7 Millisekunden auf sich warten liess. Weil das arithmetische Mittel mit 1.18 Millisekunden über dem Median von 1.08 Millisekunden liegt, gibt es offenbar stärkere Ausreisser nach oben (d.h. langsamere) als nach unten (d.h. schnellere).

Client und Server wären also bereit um mal ordentlich DAMPF im Kessel zu machen!

Wechsel zwischen mod_php und PHP-FPM

Für die Lasttests soll einfach zwischen mod_php und PHP-FPM hin- und hergewechselt werden können. Hierzu wird ein kleines Skript namens toggle-fpm.sh zur Verügung gestellt:

#!/usr/bin/bash

set -eu

function usage {
	printf "usage: %s: [enable/disable]\n" $0
	exit 1
}

if [ "$#" -ne 1 ]; then
	usage
elif [ "$1" = 'enable' ]; then
	sudo a2dismod php8.2 >/dev/null
	sudo a2enconf php8.2-fpm >/dev/null
	sudo a2enmod proxy_fcgi >/dev/null
	sudo systemctl restart apache2.service
elif [ "$1" = 'disable' ]; then
	sudo a2disconf php8.2-fpm >/dev/null
	sudo a2dismod proxy_fcgi >/dev/null
	sudo a2enmod php8.2 >/dev/null
	sudo systemctl restart apache2.service
else
	usage
fi

Mit ./toggle-fpm.sh enable wird PHP-FPM aktiviert; mit ./toggle-fpm.sh disbable wird es deaktiviert und mod_php aktiviert. Die derzeitig aktive PHP-Implementierung lässt sich via phpinfo() in Erfahrung bringen.

So soll PHP-FPM deaktiviert werden, um etwas Last unter mod_php zu erzeugen:

./toggle-fpm.sh disable

Worauf unsere PHP-Infoseite meldet:

Server API: Apache 2.0 Handler

Lasttest

Es sollen zwei Testreihen ausgeführt werden:

  1. viele kurze Requests
    • Faktorisierung von 100 bis 999
    • vier Worker mit je 250 Requests
  2. wenige lange Requests
    • Faktorisierung von 10^9 bis (10^9)+10
    • vier Worker mit je zehn Requests

Client und Server laufen auf einer virtuellen Maschine mit Debian 12 Bookworm, welcher 2 CPUs und 1024 MB Memory zur Verfügung stehen.

$ ./request0r -w 4 -r 250 'http://localhost/prime_factorization.php?lower=100&upper=999'
Requests:
          Total          Passed          Failed            Mean
           1000            1000               0      8.236957ms
Percentiles:
             0%             25%             50%             75%            100%
     3.032697ms      4.006084ms      6.557438ms     10.872339ms     36.512993ms

$ ./request0r -w 4 -r 10 'http://localhost/prime_factorization.php?lower=1000000000&upper=1000000010'
Requests:
          Total          Passed          Failed            Mean
             40              40               0    10.35933729s
Percentiles:
             0%             25%             50%             75%            100%
   9.231410123s   10.165946763s   10.259091202s   10.383718102s   11.504672585s

Und nach der Aktivierung von PHP-FPM (./toggle-fpm.sh enable):

$ ./request0r -w 4 -r 250 'http://localhost/prime_factorization.php?lower=100&upper=999'
Requests:
          Total          Passed          Failed            Mean
           1000            1000               0      7.723427ms
Percentiles:
             0%             25%             50%             75%            100%
     2.944284ms      5.398297ms      7.420241ms      9.426545ms     25.895501ms

$ ./request0r -w 4 -r 10 'http://localhost/prime_factorization.php?lower=1000000000&upper=1000000010'
Requests:
          Total          Passed          Failed            Mean
             40              40               0    9.771434526s
Percentiles:
             0%             25%             50%             75%            100% 
   9.378479225s    9.643530472s    9.724563396s      9.8192836s   10.709784636s

Auch wiederholte Testfälle ergeben das folgende Bild: PHP-FPM ist nicht nur im arithmetischen Mittel leicht schneller als mod_php (7.72 vs. 8.24 Millisekunden bzw. 9.77 vs. 10.38 Sekunden), sondern weist auch weniger Ausreisser nach oben aus. Beim Median sieht das Bild aber anders aus: hier hat zwar mod_php die Nase bei vielen kurzen Requests leicht vorne (6.56 vs. 7.42 Millisekunden), PHP-FPM ist aber bei wenigen langen Requests schneller (9.72 vs. 10.26 Sekunden).

Übungen

Nun stellen sich zwei Fragen:

  1. Sind diese Messungen überhaupt statistisch relevant?
  2. Liesse sich PHP-FPM nicht noch etwas tunen?

Wer sich damit beschäftigen möchte, kann gerne folgende Übungen bearbeiten:

  1. Anhand des ursprünglichen DAMPF-Setups und der hier vorliegenden Anleitung soll das Setup nachgebaut und getestet werden. Erscheinen vergleichbare und v.a. wiederholbare Messresultate? Mit welchen Parametern (Anzahl Worker, Anzahl Requests, Unter- und Obergrenze der Faktorisierung pro Request) erhält man welche Zeiten?
  2. Unter /etc/php/8.2/fpm/pool.d/www.conf liesse sich der Prozess-Pool für PHP-FPM umkonfigurieren. Standardmässig wird ein dynamischer Prozesspool verwendet (pm = dynamic). Interessante Optionen wären pm.max_children, pm.start_servers, pm.min_spare_servers und pm.max_spare_servers, womit sich die Anzahl PHP-Prozesse steuern lässt. Weitere Direktiven mit dem Präfix pm könnten auch eine Einfluss auf die Performance haben.