Statt eine vollständige Anwendung in Rust neu zu schreiben, schlägt Lily Mara, Staff Engineer bei Discord, vor, die ressourcenintensivsten Funktionen schrittweise aus einer dynamischen Sprache wie Python nach Rust zu übertragen und beide Implementierungen innerhalb desselben Prozesses zu verbinden. Mara stellte diese Methode in einem über InfoQ veröffentlichten Vortrag vor. Dabei stützte sie sich auf ihre Erfahrung beim Aufbau verteilter Systeme, die täglich Dutzende Milliarden Benachrichtigungen an Discord-Nutzer senden, sowie auf ihr Buch Refactoring to Rust.
Die grundlegende Idee besteht nicht darin, eine Sprache durch eine andere zu ersetzen, nur weil Rust schneller ist, sondern darin, eng begrenzte Teile des Systems zu identifizieren, deren Optimierung die größte Wirkung erzielt. Dieser Ansatz begrenzt den Umfang der Änderungen, ermöglicht den Vergleich des neuen Verhaltens mit dem bisherigen und erlaubt es, die ursprüngliche Implementierung in Fällen beizubehalten, in denen sich eine Übereinstimmung der Ergebnisse nur schwer garantieren lässt.
Warum sollte eine vollständige Neuentwicklung nicht der Ausgangspunkt sein?
Mara zufolge ist die Begeisterung für den vollständigen Neuaufbau eines alten Systems in einer moderneren Sprache verständlich, birgt jedoch erhebliche praktische Risiken. Projekte zur Neuentwicklung können Termine überschreiten, komplexer werden als erwartet oder Fehler erneut einführen, die das alte System bereits behoben hatte. Außerdem ist alter Code nicht immer aufgrund seines Alters komplex. Er kann Einschränkungen und Details widerspiegeln, die sich aus der praktischen Nutzung und aus schwer übertragbarem institutionellem Wissen entwickelt haben.
Der Vortrag warnt außerdem davor, Leistungsprobleme auf die Programmiersprache allein zu reduzieren. Die Ursache der Langsamkeit kann im Datenbankschema, in Abfrage- und Caching-Mustern oder im Servicedesign liegen und nicht in den Kosten der Ausführung einer Codezeile. Die Nutzung von Rust bedeutet daher nicht, dass eine Neuentwicklung architektonische Engpässe automatisch beseitigt.
Was bedeutet Refactoring über eine FFI?
Die Methode nutzt das, was Mara FFI refactoring nennt: das Umschreiben einer Funktion oder eines kleinen Funktionsteils in einer anderen Sprache und dessen Anbindung an die bestehende Anwendung über eine Foreign Function Interface. Im gezeigten Beispiel nimmt die Flask-Anwendung weiterhin eine HTTP-Anfrage entgegen und dekodiert JSON. Anschließend übergibt sie die Daten an eine in Rust geschriebene Statistikfunktion, bevor die Ergebnisse an Python zurückgegeben und die Antwort versendet werden.
Die Verbindung basiert auf der C-Schnittstelle, die praktisch als gemeinsame Sprache zwischen vielen Systemen und Sprachen dient. Für Python stellt das Projekt PyO3 Werkzeuge zum Erstellen eines aus Python importierbaren Moduls bereit, während Maturin zum Erstellen dieses Moduls verwendet werden kann. Programmierattribute wie pyfunction und pyclass verwandeln Funktionen und Datenstrukturen in Rust in Schnittstellen, die aus Python aufgerufen werden können.
Die richtige Funktion auswählen und die Wirkung messen
Die Erfahrung empfiehlt, nach Funktionen zu suchen, die entweder häufig aufgerufen werden oder deren Aufruf hohe Kosten verursacht. Eine Funktion kann bei jedem einzelnen Aufruf relativ günstig sein, aber bei jeder Anfrage ausgeführt werden, etwa die Validierungslogik vor API-Handlern. Umgekehrt kann es seltene, aber äußerst teure Vorgänge geben. Entscheidend ist die kumulative Auswirkung auf die Prozessorzeit und nicht der Eindruck, dass eine bestimmte Funktion langsam wirkt.
Im Statistikbeispiel war die Rust-Implementierung bei der Messung der Funktion selbst über Python knapp hundertmal schneller, obwohl der Test beide Anwendungen über den Python-Interpreter ausführte. Bei der Messung eines vollständigen HTTP-Handlers, einschließlich Flask sowie der JSON-Serialisierung und -Deserialisierung, betrug die Verbesserung jedoch nur etwa 15 %. Die Ausführungszeit der ursprünglichen Funktion lag bei ungefähr 86 Mikrosekunden. Für sich genommen ist das wenig, doch bei umfangreicher Wiederholung kann daraus ein erheblicher Kostenfaktor werden.
Diese Differenz zwischen partieller und vollständiger Messung gehört zu den wichtigsten Lehren des Vortrags: Es reicht nicht aus, die Beschleunigung einer Funktion zu erfassen. Es müssen vielmehr Gesamttests durchgeführt werden, die den tatsächlichen Nutzungspfad abbilden und bei Bedarf Netzwerk, Webframework und Datenserialisierung einschließen.
Funktionale Kompatibilität, Tests und Einschränkungen
Die erneute Implementierung des Beispiels zeigte Unterschiede zwischen den Ergebnissen der Statistikbibliothek in Python und der Rust-Bibliothek. Eine der Bibliotheken berechnete die Quartile präzise, während die andere für sehr große Datensätze geeignete Schätzungen verwendete. Außerdem traten geringfügige Unterschiede bei der Dezimalrundung auf. Daher sollte nicht automatisch davon ausgegangen werden, dass ein Bibliothekswechsel dasselbe Verhalten beibehält.
Wenn eine Übereinstimmung erforderlich ist, kann nach einer anderen Bibliothek gesucht, der ursprüngliche Algorithmus in Rust erneut implementiert oder der kritische Teil in Python belassen werden. Der Vorteil der Übertragung auf Funktionsebene besteht darin, dass beide Sprachen innerhalb desselben Prozesses kombiniert werden können, anstatt die Komponente in einen eigenständigen Dienst auszulagern und zusätzliche Netzwerkkommunikation und Betriebskosten einzuführen.
Die Tests umfassen direkte Rust-Tests, bestehende Python-Tests für Flask-Handler sowie Tests, die die Ergebnisse beider Implementierungen vergleichen, ergänzt durch zufallsbasierte Eigenschaftstests, sofern dies angemessen ist. Die Ergänzung um nativen Code bringt jedoch betriebliche Komplikationen mit sich: Entwicklungsumgebungen benötigen einen Rust-Compiler oder mit Betriebssystem und Architektur kompatible Binärdateien, die Bereitstellung wird komplexer und neue Fehler können auftreten.
In der Praxis bietet diese Methode einen vergleichsweise risikoarmen Weg, ausgewählte Teile bestehender Systeme zu optimieren. Sie macht jedoch weder eine architektonische Analyse noch Kompatibilitätstests und Gesamtmessungen überflüssig. Die tatsächliche Einsparung ist erst dann nachgewiesen, wenn sich die Verbesserung im vollständigen Dienstpfad widerspiegelt und nicht nur in einem isolierten Funktionsbenchmark.