Benchmarking von Rails-Anwendungen

Wenn man sich mit der Performance von Webanwendungen beschäftigt, ist es schwierig, über mögliche Probleme zu sprechen, wenn man diese nicht quantifizieren kann.

Ruby on Rails bietet hierzu einen guten ersten Ansatz: In der Logdatei wird für jede Anfrage vermerkt, wie lange es gedauert hat, diese zu verarbeiten. Je länger dies dauert, desto wahrscheinlicher, dass ein Problem vorliegt.

Konkrete Messwerte können also helfen, Probleme zu identifizieren. Unverzichtbar sind sie aber, wenn man versucht, Performance-Probleme zu beheben. Setzt man das selbe Messverfahren für eine mögliche Lösung ein, hat man eine unmittelbare Rückmeldung, ob diese wirklich eine Verbesserung darstellt.

Probleme beim Benchmarking

Die Verarbeitungsdauer aus der Logdatei von Rails ist ein guter erster Ansatz, leidet aber an einer Reihe von Problemen, die sich fast immer stellen, wenn man Benchmarking von Software betreibt. Vor allem ist es immer nur ein einzelner Messwert, der sich nicht nur aus der tatsächlichen Verarbeitungsdauer zusammensetzt. Stattdessen können einige andere Dinge die Messung verfälschen.

Zunächst gibt es eine Reihe von Effekten, die auftreten können, wenn etwas zum allerersten Mal ausgeführt wird. Besonders extrem ist dies, wenn man externe Benchmarking-Werkzeuge verwendet, die die Software unter Test als Teil des Benchmarks zunächst mal starten. Beim Starten von Programmen werden immer Schritte ausgeführt, die nur ein einziges Mal erfolgen müssen. Dazu gehören z.B. das Laden von Bibliotheken, das Parsen von Konfigurationsdateien usw.

Bei einer Rails-Anwendung würde man natürlich immer erst messen, wenn der Applikationsserver bereits gestartet ist. Und dennoch gibt es vergleichbare Effekte: Insbesondere in der development-Umgebung muss man berücksichtigen, dass Konstanten (d.h. insbesondere Klassen und Module) durch den "Autoloader" dynamisch nachgeladen werden, wenn sie das erste Mal auftreten. Bei Änderungen werden diese sogar neu geladen. Insbesondere bei Webanwendungen kann Caching aller Art immer eine Rolle spielen. D.h. beim ersten Aufruf einer Aktion werden ggf. Ergebnisse zwischengespeichert die beim erneuten Aufruf nicht mehr berechnet werden müssen.

Auch auf Sprachebene gibt es diese Effekte. Auch wenn es für die meisten Rails-Anwendungen (noch) keine Rolle spielt, haben neuere Ruby-Versionen und alternative Implementierungen wie JRuby die Möglichkeit von "Just In Time"-Kompilierung (JIT). D.h. immer wieder durchlaufene Ruby-Code-Pfade können zu Maschinensprache kompiliert werden, so dass sie bei späteren Aufrufen nicht mehr teuer interpretiert werden müssen.

Viele Benchmark-Werkzeuge berücksichtigen dies, indem sie die zu testenden Aufrufe zunächst einige Male "kalt" ausführen, bevor sie die eigentliche Messung vornehmen. Man nennt das dann auch Aufwärm- oder Warmup-Phase.

Aber auch während einer solchen Messung von "vorgewärmtem" Programmcode, kann es zu z.T. erheblichen Schwankungen kommen. Das liegt einfach daran, dass man auf einem gängigen Betriebssystem in aller Regel nicht in völliger Isolation testen kann. Parallel laufen dutzende andere Prozesse und das Betriebssystem kann das Benchmarking zu jeder Zeit anhalten und Ressourcen anderen Prozessen zuweisen. Man kann diese Effekte abmildern, indem man während des Messens keine anderen aufwändigen Prozesse laufen lässt. Eine gewisse Unschärfe bleibt jedoch immer.

Bei Rails-Anwendungen kommt noch erschwerend hinzu, dass die Antwortzeiten oft nicht von einem einzelnen Prozess abhängen. Mindestens ein Datenbankserver kommt in den allermeisten Fällen noch dazu. Dessen Performance kann ebenfalls Schwankungen unterliegen. Zudem müssen beide Prozesse kommunizieren. Diese Interprozesskommunikation (IPC) kostet auch etwas Zeit und auch diese ist nicht immer konstant.

In Benchmarking-Werkzeugen begegnet man diesen Problemen, in dem man nicht bloß einmal misst, sondern direkt mehrere Messungen durchführt. Anschließend kann man z.B. einen Durchschnitt über die Messergebnisse bilden und erhält eine etwas robustere Zahl.

All diese Dinge berücksichtigt die Ausführungsdauer in den Rails-Logs natürlich nicht. Von daher ist sie leider ungeeignet, um belastbare Aussagen vor allem über kleinere Optimierungen des Programmcodes zu treffen.

Es lohnt sich daher, sich mit anderen Methoden des Benchmarkings zu beschäftigen.

Benchmark aus der Stdlib

Die Standard Library von Ruby enthält bereits eine Bibliothek für sehr einfaches Benchmarking. Diese Bibliothek liefert u.a. eine Methode ::measure, die einen Code-Block mit dem zu testenden Programmcode übergeben bekommt.

Nehmen wir an, wir haben ein Rails-Model Product. Solche Produkte können Tags haben, die wiederum öffentlich oder privat sein können. Eine naive Implementierung einer Methode, die alle öffentlichen Tags liefert könnte so aussehen:

Mit Hilfe von benchmark könnte ich die Laufzeit eines Aufrufs wie folgt messen:

Dies erzeugt eine Ausgabe wie die folgende:

Die measure-Methode misst die Zeit, die die Ausführung des Code-Blocks benötigt. Die Zeit wird aufgeschlüsselt in 4 verschiedene Werte, die jeweils in Sekunden angegeben sind:

  • User - Die tatsächlich verbrauchte CPU-Zeit (User-Mode)
  • System - Die verbrauchte CPU-Zeit (Kernel-Mode)
  • Total - Die Summe aus den beiden o.g. Zeiten
  • Real - Die tatsächlich vergangene, reale Zeit

Möchte man verschiedene Alternativen miteinander vergleichen, bietet sich die Methode ::bm an. Nehmen wir an, wir möchten als Alternativen die in Ruby eingebaute Methode #select und die direkte Abfrage über die Datenbank mit o.g. naiver Implementierung vergleichen. Die entsprechend ergänzte Klasse sähe so aus:

Einen Vergleich mit ::bm kann man nun wie folgt ausführen:

Der Parameter 8 dient dabei ausschließlich dafür, die Spaltenbreite für die Ausgabe festzulegen, damit diese bündig ist. Das Ergebnis sieht dann in etwa so aus:

Die o.g. Tatsache, dass die erste Ausführung manchmal länger dauert als die folgenden, adressiert die Methode ::bmbm:

Jeder Block wird hier zweimal ausgeführt. Die erste Ausführung ist die Probe (Rehearsal) und die zweite die, die wirklich zählt. In der Ausgabe kann man beides gut unterscheiden:

Eine wiederholte Ausführung mit Bildung von Durchschnittszeiten beherrscht die benchmark-Bibliothek leider nicht.

Das benchmark-ips Gem

Die Lücken der eingebauten Bibliothek schließt ein populäres Rubygem: benchmark-ips(https://github.com/evanphx/benchmark-ips). Diese Bibliothek erweitert benchmark aus der der Standard Library und ist in ihrer Benutzung durchaus ähnlich. Eine einfache Messung sieht hier wie folgt aus:

Der wesentliche Unterschied liegt aber darin, wie gemessen wird. benchmark-ips wird den übergebenen Code immer mehrfach ausführen. Die Anzahl der Ausführungen wird dynamisch ermittelt: Der Code unter Test wird zunächst eine Zeit lang ohne Messung ausgeführt ("Warmup"-Phase). Innerhalb einer vorgegebenen Zeit (Default: 5 Sekunden) wird dann wiederholt ausgeführt und gemessen, bis sich keine größeren Unterschiede mehr zeigen.

Außerdem misst benchmark-ips nicht die Zeit, sondern die Anzahl von Instruktionen pro Sekunde ("instructions per second" oder "ips", daher der Name).

Hierdurch erhält man sehr robuste und vor allem gut vergleichbare Werte. Daher eignet sich benchmark-ips ganz besonders für den Vergleich alternativer Implementierungen.

Das o.g. Beispiel führt zu einer Ausgabe wie der folgenden:

Hinter der Anzahl der Iterationen pro Sekunde wird zusätzlich die Standardabweichung ausgegeben. Damit kann man sich ein Bild machen, wie stabil die Ergebnisse sind.

Benchmark-Skripte in Rails 6.1

Historisch hatte Rails schon einmal eine eingebaute Möglichkeit, Benchmarks zu entwickeln. Die sogenannten "Performance"-Tests waren eine Erweiterung der eingebauten Testmöglichkeiten, wurden aber in Rails 4 entfernt.

Seitdem mussten Entwickler sich eigene Lösungen überlegen. Rails 6.1 liefert erstmals wieder etwas mit, um diese Fragestellung einheitlich zu lösen. Ein neuer Generator ermöglicht es, einfache Gerüste für Benchmarking-Skripte zu erstellen.

                                  $ bin/rails generate benchmark public_tags

Dieser Aufruf erzeugt eine Datei scripts/benchmarks/public_tags.rb mit folgendem Inhalt:

Wie man sieht, ist dies nur ein sehr simpler Ansatzpunkt, um Benchmarking mit benchmark-ips zu betreiben. Der größte Wert des Generators liegt darin, dass erstmals seit Rails 3 jetzt wieder eine klare Konvention besteht, wo genau solche Skripte zu liegen haben.

Und diese kann man natürlich auch in Projekten mit älteren Rails-Versionen nutzen.

Die Skripte können natürlich in jedem Rails-Environment ausgeführt werden. Um Unschärfe aufgrund des Code-Reloadings auszuschließen und von allen Optimierungen profitieren zu können, sollte man das Skript in der production-Umgebung ausführen:

          $ RAILS_ENV=production ruby script/benchmark/public_tags.rb

Dies bedeutet allerdings, dass man auch lokal in der Lage sein muss, diese Umgebung zu nutzen. Das erfordert u.a., dass eine Datenbank dafür konfiguriert sein muss.

Web-Requests mit Skripten benchmarken

Das Ergebnis des o.g. Generators und die meisten Beispiele, die man findet, sind immer darauf ausgelegt, dass man eine ganz bestimmte Methode benchmarken möchte. Wenn ich bereits weiß, wo sich ein Flaschenhals befindet und das auf genau einen Methodenaufruf zurückführen kann, passt das perfekt.

Merke ich aber, dass ein bestimmter Aufruf meiner Webanwendung besonders langsam ist, und ich möchte Benchmarking benutzen, um strukturiert verschiedene Optimierungsmöglichkeiten ausprobieren und bewerten zu können, dann ist das leider wenig hilfreich.

Glücklicherweise ist es recht einfach, die Ausführung ganzer Web-Requests aus den in Rails eingebauten Integration Tests wiederzuverwenden. Nehmen wir an, wir haben eine Produktübersicht mit dem normalen Ressourcen-Routing. Möchte man diese als Teil eines Benchmarks aufrufen, kann man das wie folgt machen:

Alternativen

Die o.g. Methoden haben alle gemeinsam, dass sie versuchen innerhalb von Ruby und Rails zu arbeiten. Es gibt aber auch eine ganze Reihe generischer Benchmarking-Werkzeuge, um Webanwendungen aller Art zu analysieren.

Das Prinzip ist hier immer das selbe: Die Webanwendung wird als "Black Box" betrachtet und es werden echte HTTP-Anfragen an diese gerichtet.

Zwei solcher Werkzeuge seien hier einmal ganz kurz erwähnt: ab und wrk.

Bei ab handelt es sich um die "Apache Bench", eines der ältesten Werkzeuge dieser Art, das zum Testen des Apache Web Servers entwickelt wurde. ab wird heute noch weiterentwickelt, ist für eine Vielzahl von Plattformen verfügbar und sehr gut dokumentiert.

Wenn ich einen bestimmten HTTP-Endpunkt benchmarken möchte, verwende ich im einfachsten Fall folgenden Aufruf:

                                                $ ab http://localhost:3000/

Über zusätzliche Parameter kann ich vieles steuern, wie z.B. die Anzahl von Ausführungen oder wieviele Anfragen parallel gestartet werden dürfen. Natürlich kann man auch die Anfrage selbst weiter parametrisieren, und z.B. HTTP-Methode oder zusätzliche Header angeben.

Die Ausgabe sieht dann wie folgt aus:

ab ist ein robustes und bewährtes Werkzeug. Mit seiner breiten Verfügbarkeit ist es immer eine gute Wahl. Es hat allerdings auch Schwächen, weswegen einige bekannte Alternativen existieren. Eine davon ist wrk (https://github.com/wg/wrk). Dieses Werkzeug ist vor allem auf Parallelität und Durchsatz optimiert. Verglichen mit z.B. ab ist es in der Lage in der selben Zeit mehr Anfragen zu generieren. Damit eignet sich wrk auch für "Stresstests", bei denen ein Endpunkt mit möglichst vielen gleichzeitigen Anfragen konfrontiert werden soll.

Ein einfacher Aufruf von wrk sieht so aus:

                                               $ wrk http://localhost:3000/

Über Kommandozeilen-Parameter kann auch hier einiges konfiguriert werden, vergleichbar mit ab.

Ein entsprechendes Ergebnis könnte so aussehen:

wrk ist sicher die modernere Alternative. Dafür ist sie aber auch leider weniger gut dokumentiert als ab. Es lohnt sich daher, beide Werkzeuge zu kennen.