Adventskalender 6. Dezember 2023 - DAMPF im Kessel

Smalltalk
Antworten
Benutzeravatar
paedubucher
Beiträge: 856
Registriert: 22.02.2009 16:19:02
Lizenz eigener Beiträge: GNU Free Documentation License
Wohnort: Schweiz
Kontaktdaten:

Adventskalender 6. Dezember 2023 - DAMPF im Kessel

Beitrag von paedubucher » 06.12.2023 06:27:24

DAMPF im Kessel

Der diesjährige Adventskalender wurde mit einem Türchen zum DAMPF-Stack eröffnet. Dahinter versteckte sich nichts weiter als der altbekannte LAMP-Stack 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.

Dabei kam von uname 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:
  • 12 = 2 * 2 * 3
  • 13 = 13
  • 15 = 3 * 5
  • 27 = 3 * 3 * 3
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:

Code: Alles auswählen

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:

Code: Alles auswählen

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:

Code: Alles auswählen

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:

Code: Alles auswählen

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");
}
Das komplette Skript ist auf NoPaste abgelegt.

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:

Code: Alles auswählen

$ 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. Da es im Wesentlichen aus einer einzigen Quellcodedatei besteht, kann es aber auch via NoPaste heruntergeladen werden.

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

Code: Alles auswählen

$ 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 (siehe NoPaste). 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:

Code: Alles auswählen

./toggle-fpm.sh disable
Worauf unsere PHP-Infoseite meldet:

Code: Alles auswählen

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.

Code: Alles auswählen

$ ./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):

Code: Alles auswählen

$ ./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 DAMPF-Setups vom 1. Dezember 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.
Ich bin gespannt auf eure statistischen Überlegungen, Messungen und Konfigurationseinstellungen!
Habe nun, ach! Java
Python und C-Sharp,
Und leider auch Visual Basic!
Durchaus programmiert mit heissem Bemühn.
Da steh' ich nun, ich armer Tor!
Und bin so klug als wie zuvor.

Benutzeravatar
heisenberg
Beiträge: 3567
Registriert: 04.06.2015 01:17:27
Lizenz eigener Beiträge: MIT Lizenz

Re: Adventskalender 6. Dezember 2023 - DAMPF im Kessel

Beitrag von heisenberg » 06.12.2023 09:43:51

Der primäre Vorteil von PHP-FPM gegenüber mod_php erscheint mir doch eher der, dass nicht jeder Apache-Request auch das PHP-mitschleppen muss, obwohl es für viele Requests gar nicht benötigt wird (Bilder, Textdateien, ...). Die Sache, dass gestartete PHP-Interpreter-Instanzen bereit stehen, um schneller loslaufen zu können ist natürlich auch wichtig. D. h. vielleicht wären da Resourcen-Messungen des Gesamtsystems bei mehrheitlich nicht-PHP-Requests interessant?
Zuletzt geändert von heisenberg am 06.12.2023 11:03:56, insgesamt 1-mal geändert.
Jede Rohheit hat ihren Ursprung in einer Schwäche.

Benutzeravatar
TRex
Moderator
Beiträge: 8086
Registriert: 23.11.2006 12:23:54
Wohnort: KA

Re: Adventskalender 6. Dezember 2023 - DAMPF im Kessel

Beitrag von TRex » 06.12.2023 10:16:18

Code: Alles auswählen

$ ./request0r -w 4 -r 250 'http://magni:8008/prime-factorization?lower=199&upper=999'
Requests:
          Total          Passed          Failed            Mean
           1000            1000               0     24.216481ms
Percentiles:
             0%             25%             50%             75%            100% 
    14.296146ms     22.142575ms      24.95174ms     26.017857ms     50.264842ms 
:lol: Kein Blumentopf hier mit python.
Jesus saves. Buddha does incremental backups.
Windows ist doof, Linux funktioniert nichtDon't break debian!Wie man widerspricht

Benutzeravatar
paedubucher
Beiträge: 856
Registriert: 22.02.2009 16:19:02
Lizenz eigener Beiträge: GNU Free Documentation License
Wohnort: Schweiz
Kontaktdaten:

Re: Adventskalender 6. Dezember 2023 - DAMPF im Kessel

Beitrag von paedubucher » 06.12.2023 11:17:31

heisenberg hat geschrieben: ↑ zum Beitrag ↑
06.12.2023 09:43:51
Der primäre Vorteil von PHP-FPM gegenüber mod_php erscheint mir doch eher der, dass nicht jeder Apache-Request auch das PHP-mitschleppen muss, obwohl es für viele Requests gar nicht benötigt wird (Bilder, Textdateien, ...). Die Sache, dass gestartete PHP-Interpreter-Instanzen bereit stehen, um schneller loslaufen zu können ist natürlich auch wichtig. D. h. vielleicht wären da Resourcen-Messungen des Gesamtsystems bei mehrheitlich nicht-PHP-Requests interessant?
Über die genaue Funktionsweise von mod_php weiss ich leider auch zu wenig Bescheid. Bei CGI soll ja für jede Anfrage eigens ein PHP-Prozess gestartet werden, was die Sache natürlich bedeutend langsamer machen würde. Die Systemlast wurde hier ausgeklammert und die Laufzeit priorisiert; der Vergleich wäre aber allemal interessant, gerade was Memory betrifft.
TRex hat geschrieben: ↑ zum Beitrag ↑
06.12.2023 10:16:18

Code: Alles auswählen

$ ./request0r -w 4 -r 250 'http://magni:8008/prime-factorization?lower=199&upper=999'
Requests:
          Total          Passed          Failed            Mean
           1000            1000               0     24.216481ms
Percentiles:
             0%             25%             50%             75%            100% 
    14.296146ms     22.142575ms      24.95174ms     26.017857ms     50.264842ms 
:lol: Kein Blumentopf hier mit python.
Könntest du mal den Code posten? Interessant wäre auch der direkte Vergleich von 100 bis und mit 999 bzw. von einer Milliarde bis eine Milliarde und zehn.
Habe nun, ach! Java
Python und C-Sharp,
Und leider auch Visual Basic!
Durchaus programmiert mit heissem Bemühn.
Da steh' ich nun, ich armer Tor!
Und bin so klug als wie zuvor.

Benutzeravatar
TRex
Moderator
Beiträge: 8086
Registriert: 23.11.2006 12:23:54
Wohnort: KA

Re: Adventskalender 6. Dezember 2023 - DAMPF im Kessel

Beitrag von TRex » 06.12.2023 12:51:40

Code: NoPaste-Eintrag42032

(die Route der flask-app parst die Parameter und ruft factorization auf, ganz analog)

Bei dem größeren Zahlenrange steigt mein Setup aus, weil gunicorn ein Timeout von 30s setzt und den Request auf nem anderen worker erneut startet - was so natürlich nie endet.

PHP (wobei ich nginx behalten hab):

Code: Alles auswählen

$ ./request0r -w 4 -r 250 'http://magni:8008/primes.php?lower=199&upper=999'
Requests:
          Total          Passed          Failed            Mean
           1000            1000               0      7.745172ms
Percentiles:
             0%             25%             50%             75%            100% 
     5.325976ms      6.563382ms      7.376171ms      8.639355ms     25.155945ms
Ich hab die Statistik bei den großen Zahlen etwas abgeschwächt, weil ich nicht so lange warten wollte:

Code: Alles auswählen

$ ./request0r -w 4 -r 20 'http://magni:8008/primes.php?lower=1000000000&upper=1000000010'
Requests:
          Total          Passed          Failed            Mean
             80              80               0   15.726227379s
Percentiles:
             0%             25%             50%             75%            100% 
  12.355417055s   15.260608029s   15.301284154s   15.331806969s   27.343830321s
Man könnte sich ausmalen, wie lange python dafür brauchen würde. Immerhin sind Rechenaufgaben selten das, wofür ich python einsetze.
Jesus saves. Buddha does incremental backups.
Windows ist doof, Linux funktioniert nichtDon't break debian!Wie man widerspricht

Benutzeravatar
oln
Beiträge: 487
Registriert: 05.01.2021 09:41:24

Re: Adventskalender 6. Dezember 2023 - DAMPF im Kessel

Beitrag von oln » 06.12.2023 13:14:11

Moin,
meine Meinug spielen die Lasttests kaum eine Rolle. Viel wichtiger ist es, dass FPM HTTP2 kann. Dadurch lassen sich von modernen Javascript lastigen Frontends HTTP-Requestst parallel abarbeiten. Klar können dadurch andere Probleme entstehen aber das ist das die Sache des Konzepts wie man das umsetzt.
Gruß Ole
AbuseIPDB

Antworten