Tiny JITs für schnelleres FFI
(railsatscale.com)- FFI ist praktisch, wenn Ruby native Funktionen aufruft, wird im
strlen-Benchmark aber wegen höherem Call-Overhead als bei C-Erweiterungen zum Performance-Flaschenhals - Im Referenz-Benchmark erreichte ein direkter Aufruf von
String#bytesize39,879M i/s, eine C-Erweiterung 30,661M i/s, ein indirekter Ruby-Aufruf 28,697M i/s und FFI war mit 15,682M i/s am langsamsten - Die Kernidee: Beim Aufruf von
attach_functionsind Funktionsname, Argumenttypen und Rückgabetyp bereits bekannt; daraus wird zur Laufzeit Maschinencode für den Aufruf externer Funktionen erzeugt - Der Proof of Concept FJIT brachte
strlen-Aufrufe in einer Ruby-3.5.0dev-ARM64-Umgebung auf 32,508M i/s – mehr als doppelt so schnell wie FFI und sogar etwas schneller als die C-Erweiterung - Aktuell gibt es Einschränkungen: ARM64, nur ein Argument und ein Rückgabewert, begrenzte Typen,
--rjit --rjit-disablesowie Abhängigkeit von einem bestimmten Ruby-head-Commit; bis zur praktischen Nutzung muss die Implementierung erweitert werden
Wo FFI in Ruby langsam wird
- Die grundsätzliche Richtung ist, so viel Ruby-Code wie möglich zu verwenden und nativen Code nur dann aufzurufen, wenn es wirklich nötig ist
- YJIT kann Ruby-Code optimieren, aber C-Code nicht
- Wenn eine native Bibliothek benötigt wird, ist es ideal, eine dünne C-Erweiterungs-Wrapper-Schicht um die eigentliche Funktion zu legen und den Großteil der Arbeit in Ruby zu erledigen
- Solche einfachen APIs passen gut zu FFI, doch das bestehende FFI erreicht nicht die Performance einer C-Erweiterung
Referenz-Benchmark mit strlen
- Verglichen wurden vier Varianten
- Aufruf von Cs
strlenüber FFI - Indirekter Aufruf von
x.bytesizein der Ruby-MethodeB.strlen - Aufruf einer C-Erweiterung, die mit dem Ruby-Gem
strlenerstellt wurde - Direkter Aufruf von
str.bytesize
- Aufruf von Cs
- Ergebnisse unter Ruby 3.5.0dev auf ARM64:
ruby-direct: 39,879M i/s, 25,08 ns/istrlen-cext: 30,661M i/s, 32,61 ns/i, 1,30-mal langsamer als der direkte Aufrufstrlen-ruby: 28,697M i/s, 34,85 ns/i, 1,39-mal langsamer als der direkte Aufrufstrlen-ffi: 15,682M i/s, 63,77 ns/i, 2,54-mal langsamer als der direkte Aufruf
- Der direkte Aufruf von
String#bytesizeist am schnellsten, zusätzliche indirekte Aufrufe verursachen weiteren Overhead - Der Unterschied zwischen
ruby-directundstrlen-rubyzeigt die Kosten für Push/Pop von Stack-Frames; das Entfernen solcher Overheads ist ein Bereich, den JIT-Compiler wie YJIT gut abdecken - Die Lücke zwischen
strlen-cextundstrlen-ffizeigt, dass beim Aufruf nativer Funktionen über FFI erhebliche Zusatzkosten entstehen
FFI-Aufrufe durch JIT ersetzen
- Zum Zeitpunkt des Aufrufs von
attach_function :strlen, [:string], :intsind die nötigen Informationen bereits vorhanden- Name der aufzurufenden Funktion:
strlen - Argumenttyp: string
- Rückgabetyp: int
- Name der aufzurufenden Funktion:
- Mit diesen Informationen kann Maschinencode erzeugt werden, der Ruby-Werte in native Typen entpackt, die externe Funktion aufruft und den Rückgabewert wieder in ein Ruby-Objekt verpackt
- Benötigt werden drei Bausteine
- AArch64 gem: Erzeugung von ARM64-Maschinencode
- Fisk gem: Erzeugung von x86_64-Maschinencode
- JITBuffer gem: Allokation ausführbaren Speichers
- Maschinencode zu erzeugen reicht allein nicht aus; Ruby muss zu diesem Maschinencode springen können, damit der FFI-Overhead übersprungen wird
Der Weg über RJIT
- RJIT ist ein in Ruby geschriebener JIT-Compiler für Ruby und wird zusammen mit Ruby ausgeliefert
- Seine interne Struktur ähnelt YJIT, war aber nicht für den Produktionseinsatz gedacht und ist daher weniger bekannt als YJIT
- Kokubun reichte einen Feature Request ein, um RJIT als Gem herauszulösen
- Dieser Vorschlag stellt zwei Grundlagen bereit, damit sich Ruby-JIT-Compiler von Drittanbietern leichter erstellen lassen
- RJIT wird als Gem ausgelagert
- Interne Ruby-Typen werden als Ruby-Datenstrukturen erzeugt, sodass Drittanbieter-JITs die Informationen erhalten, die sie zum Verpacken und Entpacken von Ruby-Datentypen benötigen
- Eine weitere Änderung ist, dass ein vorhandener JIT-Entry-Funktionszeiger immer ausgeführt wird
- Wenn ein Drittanbieter-JIT Maschinencode registriert, kann Ruby automatisch zu diesem Code springen
- Mit diesen beiden Elementen lässt sich ein kleiner, zweckgebundener JIT-Compiler bauen, der als FFI-Interface dient
FJIT als Proof of Concept
- FJIT Proof of Concept steht für „FFI JIT“ und erzeugt zur Laufzeit Maschinencode, der externe Funktionen aufruft
- Das Beispiel bindet
strlenüber ein FFI-ähnliches Interface einmodule Cextend FJITattach_function :strlen, [:string], :int
- Wenn
attach_functionaufgerufen wird, erzeugt FJIT Maschinencode, der den Ruby-String entpackt, Csstrlenaufruft und die String-Länge als Ruby-Objekt zurückgibt
Benchmark-Ergebnisse von FJIT
- Ergebnisse unter Ruby 3.5.0dev,
+RJIT +PRISM, auf ARM64:ruby-direct: 41,907M i/s, 23,86 ns/istrlen-fjit: 32,508M i/s, 30,76 ns/i, 1,29-mal langsamer als der direkte Aufrufstrlen-cext: 29,778M i/s, 33,58 ns/i, 1,41-mal langsamer als der direkte Aufrufstrlen-ruby: 28,851M i/s, 34,66 ns/i, 1,45-mal langsamer als der direkte Aufrufstrlen-ffi: 15,629M i/s, 63,98 ns/i, 2,68-mal langsamer als der direkte Aufruf
- Der direkte Aufruf von
String#bytesizebleibt am schnellsten - Der von FJIT erzeugte Maschinencode war am zweitschnellsten und lieferte etwas bessere Ergebnisse als die
strlen-C-Erweiterung - FJIT ist mehr als doppelt so schnell wie der FFI-Aufruf und auch schneller als der indirekte Ruby-Aufruf
- Dieses Ergebnis legt nahe, dass man eine Geschwindigkeit erreichen kann, die einer C-Erweiterung entspricht oder sie übertrifft, und zugleich den Ansatz beibehält, „so viel Ruby wie möglich zu schreiben“
Verbleibende Einschränkungen vor dem praktischen Einsatz
- Der Proof-of-Concept-JIT-Compiler ist derzeit auf die ARM64-Plattform beschränkt
- Für eine echte Implementierung müsste ein x86_64-Backend ergänzt werden
- Noch werden nicht alle Argument- und Rückgabetypen verarbeitet
- Die Unterstützung aller Argumenttypen gilt als möglich und der Arbeitsaufwand dürfte nicht übermäßig hoch sein
- Derzeit werden nur Funktionen mit einem einzigen Argument und einem einzigen Rückgabewert verarbeitet
- Aktuell muss Ruby mit den Flags
--rjit --rjit-disableausgeführt werden- Sobald Kokubuns Feature integriert ist, dürfte diese Bedingung entfallen
- Der Proof of Concept läuft nur auf dem damaligen aktuellen Ruby head
- Nach aktuellem Update wurde RJIT aus Ruby head entfernt; um das Skript auszuführen, muss Ruby daher auf den Commit
f32d5071b7b01f258eb45cf533496d82d5c0f6a1ausgecheckt werden
1 Kommentare
Hacker-News-Kommentare
Ich habe mich intensiv mit FFI beschäftigt, weil der Java-Constraint-Solver Timefold in CPython definierte Funktionen aufrufen sollte; die meisten Performance-Probleme entstanden dabei beim Wechsel per Proxy zwischen Hostsprache und externer Sprache.
Direkte FFI-Aufrufe über JNI oder eine neue Foreign-Schnittstelle sind fast so schnell wie direkte Java-Methodenaufrufe, aber die Garbage Collectors von CPython und Java passen nicht gut zusammen, sodass für die Synchronisierung schwarze Magie nötig ist.
Proxys wie JPype oder GraalPy dagegen müssen Parameter und Rückgabewerte konvertieren und können zusätzlich FFI-Aufrufe in die Gegenrichtung auslösen. Übergibt man ein CPython-Objekt an Java, erstellt Java einen Proxy für dieses Objekt; übergibt man diesen Proxy wieder an CPython, wird er nicht entpackt, sondern es entsteht ein Proxy eines Proxys.
Im Ergebnis war der JPype-Proxy 1402 % langsamer als ein direkter FFI-Aufruf von CPython, der GraalPy-Proxy 453 % langsamer.
Am Ende haben wir CPython-Bytecode in Java-Bytecode übersetzt und Java-Datenstrukturen erzeugt, die den verwendeten CPython-Klassen entsprechen; dadurch wurde es gegenüber Proxys 100-mal schneller. Nebenbei: CPython-Bytecode ist sehr instabil, schlecht dokumentiert und aufgrund seiner VM-Eigenschaften schwierig direkt auf anderen Bytecode abzubilden — man sollte daher besser nicht versuchen, ihn zu übersetzen oder überhaupt zu lesen.
Details habe ich im Artikel zusammengefasst: https://timefold.ai/blog/java-vs-python-speed
Go-Code und C-Code müssen sich darauf einigen, wie sie Ressourcen wie Adressraum, Signal-Handler und Thread-TLS-Slots teilen; in der Praxis heißt das eher, dass Go die Annahmen von C-Code umgehen muss. C-Code kann annehmen, nur auf einem einzigen Thread zu laufen, oder überhaupt nicht auf Mehrthread-Umgebungen vorbereitet sein.
Da C weder Gos Aufrufkonvention noch seine wachsenden Stacks kennt, muss Go beim Aufruf von C-Code Details des Goroutine-Stacks aufzeichnen, auf einen C-Stack wechseln und dann C-Code ausführen, der weder weiß, wie er aufgerufen wurde, noch die gesamte Go-Runtime kennt.
Egal ob man Bindings erstellt oder C-Code mit Python, Java über JNI, Sprachen mit libffi oder Go mit cgo umhüllt: Am Ende lebt man in der Welt von C.
https://dave.cheney.net/2016/01/18/cgo-is-not-go / https://archive.vn/GZoMK
Dank Rails At Scale und den Beiträgen von byroot ist es gerade eine wirklich gute Zeit, um tiefgehende Artikel über Rubys Interna und Performance zu lesen. Wenn man die jüngsten Verbesserungen an Ruby und Rails betrachtet, ist es auch eine ziemlich gute Zeit, Ruby-Entwickler zu sein.
Für bestimmte Arten von Anwendungen ist es immer noch beliebt, aber seine Blütezeit scheint ziemlich vorbei zu sein. Die jüngsten Verbesserungen sind gut, aber ich weiß nicht, ob JIT im Jahr 2025 technisch noch so interessant ist.
Der Ansatz „Könnte man nicht einfach den Code JIT-kompilieren, der nötig ist, um externe Funktionen aufzurufen, statt eine Drittanbieterbibliothek aufzurufen?“ wirkt ziemlich wie die Grundlage von LuaJIT FFI: https://luajit.org/ext_ffi.html
Deshalb scheint LuaJITs FFI sehr schnell zu sein.
„Schreibe so viel wie möglich in Ruby. Insbesondere kann YJIT Ruby-Code optimieren, aber keinen C-Code“ – diese Aussage ist schwer nachzuvollziehen.
Ist Ruby nicht eine ziemlich langsame Sprache? Wenn man ohnehin auf Native heruntergeht, würde man doch möglichst viel als nativen Code haben wollen.
In einem großen Release wurde Java-Code, der das Verhalten eines UI-Elements behandelte, als Bottleneck identifiziert und im nächsten großen Release in C neu geschrieben.
Als der JIT später wirklich nützlich wurde, war der FFI-Overhead größer als der Unterschied zwischen handgetuntem C-Code und dem vom JIT erzeugten Code, und im nächsten großen Release kehrte man wieder zu einer reinen Java-Implementierung zurück.
Für Sprachen dieser Generation war Javas FFI recht schnell, wurde aber ein paar Releases später durch einen besseren Ansatz ersetzt; zu dem Zeitpunkt schrieb ich kaum noch Java-UI-Code und habe es nicht weiter verfolgt. Da zur gleichen Zeit auch die Schnittstellen zwischen plattformspezifischem Code und allgemeinem Java-UI-Code aufgeräumt wurden, bin ich nicht sicher, wie es am Ende ausging.
Bei solcher Arbeit muss man diesen Wippeneffekt ständig im Blick behalten. Man muss abwägen, ob es besser ist, ein paar Meilensteine zu warten und weniger Hand-Tuning zu machen, oder ob es aus politischen bzw. technischen Gründen sofort nötig ist.
Für Aufgaben, die nur einmal laufen und dann enden, kann das ineffizient sein, aber bei langlebigen Desktop- und Server-Workloads zahlt es sich aus Sicht der gesamten Anwendung aus.
Dalviks JIT war zum Beispiel ziemlich schwach, sodass es schneller war, mathematische Funktionen über C aufzurufen; seit ART war das nicht mehr nötig, und der JIT konnte die Kosten eines C-Aufrufs übertreffen.
https://developer.android.com/reference/android/util/FloatMa...
Diese Entwicklung nennt man manchmal „Self-Hosting“; auch Browser nutzen sie häufig, indem sie Teile, die ursprünglich wohl in C/C++ geschrieben worden wären, in privilegiertes JavaScript verlagern. Ein überraschend großer Teil der Standardbibliothek ist kein nativer Code.
Selbst in Sprachen wie C#, die deutlich schneller sind und nahezu keine Interop-Kosten haben, gibt es weiterhin Aufrufkosten, und manchmal kommen noch Kosten für das Ändern von Status-Flags der VM oder für Übergänge bei der Garbage Collection hinzu.
Wenn Ruby YJIT zu einem messbaren Faktor wird, wird auch diese Regel immer wichtiger.
eachwieder in Ruby geschrieben wurde: https://jpcamara.com/2024/12/01/speeding-up-ruby.html / https://bugs.ruby-lang.org/issues/20182Es gibt auch einen Bonusbeitrag von tender love: https://railsatscale.com/2023-08-29-ruby-outperforms-c/
Kurz gesagt: Der JIT gewinnt.
FFI steht für Foreign Function Interface, also die Möglichkeit, aus Ruby heraus C aufzurufen.
Danach kann Ruby dieses C-Programm im Terminal mit Flags oder Daten ausführen, sodass Ruby C-Code ausführen kann.
Ich verstehe nicht, warum man das JIT-kompilieren muss. Wenn man es in C schreiben kann, könnte man es beim Laden nicht einfach kompilieren?
Das erhöht die Produktivität erheblich und ermöglicht, denselben Code in CRuby, JRuby und TruffleRuby zu teilen.
Wenn man beim Start alle Bindings statisch kennen würde, könnte man Stubs schreiben und sie in die Methodentabelle eintragen, aber auch das passiert zur Laufzeit und fällt daher unter JIT. Außerdem kann es sich nicht an die Typen anpassen, die durch das System fließen, also muss es bei akzeptierten Werten und Optimierungen konservativ sein – ziemlich nah an dem, was libffi heute tut.
Der AOT-Ansatz besteht darin, eine native Extension zu schreiben.
Nebenbei: Meist meide ich nicht FFI selbst, sondern Gems, die FFI verwenden. Die Kompilierung ist oft so umständlich, dass es einfacher war, die Zwischenschicht aus Rubygems/bundler zu umgehen und direkt zu bauen.
Etwas verwandt: Diese Bibliothek nutzt JVMCI, um zur Laufzeit arm64-/amd64-Code zu erzeugen, der native Libraries ohne JNI aufruft: https://github.com/apangin/nalim
Ist das nicht genau das, was libffi macht?
In libffi erstellt man ein Descriptor-Objekt für eine Funktion, und diese Laufzeit-Datenstruktur beschreibt die Typen der Argumente und des Rückgabewerts.
Bei einem FFI-Aufruf muss man einen Descriptor sowie ein Array von Zeigern auf die Werte übergeben, die man weiterreichen will. Intern wird vermutlich das Werte-Array zusammen mit dem Descriptor durchlaufen, die Werte werden typgerecht auf dem Stack platziert, und nach Ende der Funktion wird je nach Rückgabetyp der Wert herausgeholt. An vielen Stellen in diesem Ablauf dürfte es typabhängige Verzweigungen geben.
Selbst wenn der Aufrufmechanismus von libffi per JIT erzeugt würde, bliebe das Vorbereiten des Argument-Arrays langsam. Das ist weniger direkt als ein FFI-JIT, der ohne Zwischen-Array direkt auf die Argumente zugreift.
FFI-JIT-Code nimmt Argumentwerte direkt entgegen, konvertiert sie von Ruby-Typen in C-Typen, legt jeden Wert mit Inline-Code an die richtige Stelle auf dem Stack oder in Registern, ruft dann die Funktion auf und wandelt den Rückgabewert wieder in einen Ruby-Typ um. Im Grunde ist das ähnlich, als hätte man den Erweiterungscode von Hand geschrieben.
Wenn Typinferenz vorhanden ist, kann man in der Konvertierungslogik Typprüfungen überspringen. Wenn zum Beispiel garantiert ist, dass
arg1ein Ruby-String ist, kann man eine schnellere, unsichere Version der Konvertierungsfunktion verwenden.Der JIT-Code muss im Worst Case nur ungefähr den Ruby-Typ berücksichtigen; Arrays oder Listen für Argumente sind nicht nötig. Da im Code fest verdrahtet ist, in welchen C-Typ konvertiert wird, muss zur Laufzeit keine Datenstruktur durchlaufen werden, die die C-Seite beschreibt.
Der Vorteil des hier beschriebenen Ansatzes ist, dass die Informationen, die der Nutzer beim Aufruf von
attach_functionübergibt, praktisch im generierten Maschinencode als Code zum Unboxing der Typen gecacht werden.Das im Geschwisterkommentar verlinkte
tramp.cdient für „Reverse FFI“, also dazu, dynamische Nutzerarbeit als Funktionszeiger bereitzustellen; das JIT dort besteht nur aus insgesamt drei Instruktionen, um vorkompilierten Code aufzurufen.https://github.com/libffi/libffi/blob/master/src/tramp.c