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.