C-Code in Ruby umschreiben, um Ruby schneller zu machen
(jpcamara.com)- In kleinen Sprach-Benchmarks lag CRuby auf dem drittletzten Platz, doch es zeigte sich, dass der Engpass nicht die Sprache insgesamt ist, sondern die Implementierung von Iterationen, in die YJIT nicht hineinsehen kann
- Ruby 3.3.6 brauchte auf einem M3 MacBook Pro 12,17 Sekunden für Fibonacci und 28,80 Sekunden für Loops, während node.js in beiden Fällen bei etwas über 1 Sekunde lag; auf einem M2 MacBook Air war Ruby mit 16,33 bzw. 33,43 Sekunden noch langsamer
- Schon das Aktivieren von
ruby --yjitsenkt Fibonacci stark von 16,88 auf 2,06 Sekunden, Loops aber nur von 33,43 auf 25,57 Sekunden, sodass Range#each als Engpass bestehen bleibt - Methoden, deren C-Implementierung auf die Ruby-Seite verlagert wurde, wie Integer#times in Ruby 3.3 und Array#each in Ruby 3.4, werden zu Zielen für YJIT-Optimierungen und verbessern den Iterations-Benchmark auf etwa 13 bis 14 Sekunden
- CRuby kann über den Hook
with_yjitbei aktiviertem YJIT die Ruby-Implementierung verwenden und bei deaktiviertem YJIT die C-Implementierung beibehalten, wodurch ein Trend entsteht, Kernbibliotheken in YJIT-freundlichen Ruby-Code umzuwandeln
Rubys Position in den Benchmarks
- Das kürzlich geteilte language comparison repo ist ein Repository, in dem kleine Benchmarks für viele Sprachen gemeinschaftlich erstellt werden
- Die CRuby-Ergebnisse in diesem Repository lagen auf dem drittletzten Platz, nur R und Python waren langsamer als Ruby
- Die Benchmarks betrachten zwei Achsen
- Loops: betont die Performance von Schleifen, Bedingungen und einfachen mathematischen Operationen
- Fibonacci: macht den Overhead von Funktionsaufrufen und die Kosten von Rekursion sichtbar
- Das Loops-Beispiel führt mit verschachtelten Schleifen insgesamt 10.000 × 100.000 Durchläufe aus, also 1 Milliarde Iterationen
- Das Fibonacci-Beispiel verwendet keine optimierte Implementierung, sondern bewusst ein einfaches naive Fibonacci
Erste Messwerte und der Effekt von YJIT
- Auf einem M3 MacBook Pro lag Ruby 3.3.6 ungefähr auf folgendem Niveau
- Fibonacci: 12,17 Sekunden
- Loops: 28,80 Sekunden
- node.js: bei beiden Beispielen etwas über 1 Sekunde
- Auf einem M2 MacBook Air fiel derselbe Benchmark langsamer aus
- Ruby Fibonacci: 16,33 Sekunden
- Ruby Loops: 33,43 Sekunden
- node.js Fibonacci: 1,36 Sekunden
- node.js Loops: 2,07 Sekunden
- Der Ausführungsbefehl im ursprünglichen Repository hatte die Form
ruby ./code.rb 40ohne YJIT - Mit
ruby --yjit ./code.rb 40änderten sich die Ergebnisse- Fibonacci: 2,06 Sekunden
- Loops: 25,57 Sekunden
- YJIT hatte bei Fibonacci einen großen Effekt, bei Loops blieb die Verbesserung aber begrenzt
Warum Range#each für YJIT ungünstig ist
- Die zentrale Schleife im Loops-Code hat die Form
(0...10_000).eachund(0...100_000).each, also Range#each - In Ruby 3.4 ist
Range#eachweiterhin in C geschrieben - In CRubys
range.cverbindet dieRange-Klasse die Methodeeachmit der C-Funktionrange_each range_eachverzweigt über verschiedene Pfade, um unterschiedliche Arten von Bereichen zu behandeln(0...).each(0...100).each("a"..."z").each
- Die C-Funktion selbst kann schnell sein, aber YJIT kann ihr Inneres nicht sehen
- Die Optimierung stoppt am Funktionsaufruf und setzt erst nach der Rückkehr der C-Funktion wieder ein
- YJIT kann Optimierungen erzeugen, die auf heiße Ausführungspfade spezialisiert sind, doch C-Implementierungen begrenzen diesen Vorteil
Was sich mit Integer#times ändert
- In Ruby 3.3 wurde
Integer#timesvon einer C-Funktion in eine Ruby-Methode umgewandelt - Die Kernstruktur ist eine einfache Ruby-Schleife aus
while i < self,yield iundi = i.succ - Weil sie in Ruby geschrieben ist, kann YJIT das Innere analysieren und optimieren
- Wenn man die Range-Iteration durch
10_000.timesund100_000.timesersetzt, sinkt die Loops-Zeit deutlichRange#each: 25,57 SekundenInteger#times: 13,66 Sekunden
- In anderen Messumgebungen sank
Integer#timesbis auf 9 Sekunden, unter Ruby 3.4 sogar auf 8 Sekunden
Integer#succ und Optimierungen auf VM-Ebene
- Die Implementierung von
Integer#timesverwendet für die Inkrementierungi.succstatti += 1 Integer#succist eine Methode, die den nächsten Wert einer Ganzzahl zurückgibt- Im VM-Bytecode von Ruby wird
i.succals ein einzelner Schrittopt_succdargestellt - Dagegen zerfällt
i += 1in zwei Schritteputobject_INT2FIX_1_: legt die Ganzzahl 1 auf den VM-Stackopt_plus: führt die+-Operation aus
- In normalen Ruby-Programmen muss man darauf kaum achten, aber auf JIT- und VM-Ebene kann schon ein Schritt Unterschied bei Hunderten Millionen oder Milliarden Wiederholungen die Performance beeinflussen
Auch Array#each wurde in Ruby 3.4 in Ruby implementiert
- In Ruby 3.4 wurde auch
Array#eachvon C auf eine Ruby-seitige Implementierung umgestellt - Der erste Versuch war einfacher Ruby-Code, hatte aber eine Race Condition im Zusammenhang mit CRuby-Interna
- Die endgültige Implementierung nutzt innerhalb des Ruby-Codes
PrimitivePrimitive.attr! :inline_block, :c_tracePrimitive.cexpr!ary_fetch_next
- Es ist also nicht vollständig reines Ruby, sondern eine Mischform aus C-Code-Auswertung und Ruby-Struktur, aber YJIT kann dennoch erhebliche Optimierungen durchführen
- Wenn man vorab ein Array erstellt und mit
Array#eachiteriert, erreicht man eine ähnliche Performance wie mitInteger#timesRange#each: 25,57 SekundenInteger#times: 13,66 SekundenArray#each: 13,96 Sekunden
Messungen mit Ruby Microbench
- Das separate Repository ruby_microbench vergleicht das ursprüngliche Beispiel mit mehreren Ruby-Formen der Iteration
- Mit aktiviertem YJIT unter Ruby 3.4 ergaben sich folgende Werte
- Fibonacci: 2,19 Sekunden
array#each: 14,02 Sekundenrange#each: 26,61 Sekundentimes: 13,12 Sekundenfor: 14,91 Sekundenwhile: 37,10 Sekundenloop do: 13,95 Sekunden
- Mit deaktiviertem YJIT unter Ruby 3.4 wurden die meisten Varianten langsamer
- Fibonacci: 16,49 Sekunden
array#each: 34,29 Sekundenrange#each: 33,88 Sekundentimes: 33,18 Sekundenfor: 36,32 Sekundenwhile: 37,14 Sekundenloop do: 50,65 Sekunden
- Das
while-Beispiel war langsamer als erwartet, was an der Art der Implementierung liegen könnte for inundarray#eachsind auf Ebene des Ruby-VM-Bytecodes fast identisch und haben daher ähnliche Performancefor inist weitgehend syntaktischer Zucker, der in der VM meist in einen#each-Aufruf umgewandelt wird
Vergleich mit anderen Ruby-Implementierungen
- Derselbe Benchmark wurde auch mit mehreren anderen Ruby-Implementierungen ausgeführt
- Einige Ergebnisse
- TruffleRuby 24.1
- Fibonacci: 0,92 Sekunden
array#each: 0,97 Sekundenrange#each: 0,92 Sekundentimes: 2,39 Sekundenfor: 2,06 Sekundenwhile: 3,90 Sekundenloop do: 0,77 Sekunden
- MRuby 3.3
- Fibonacci: 28,83 Sekunden
array#each: 144,65 Sekundenrange#each: 126,40 Sekundentimes: 128,22 Sekunden
- Artichoke
- Fibonacci: 19,71 Sekunden
array#each: 236,10 Sekundenrange#each: 214,55 Sekundentimes: 214,51 Sekunden
- TruffleRuby 24.1
Experiment: Range#each per Monkey Patch in Ruby
- Wenn man
Range#eachper Monkey Patch durch eine einfache Ruby-Implementierung ersetzt, verbessert sich die Performance deutlich - Die Implementierung ist eine einfache Form mit
begin,end,loop,yieldundi.succ - Die gemessenen Werte
- C-Implementierung von
Range#each: 25,57 Sekunden - Ruby-Implementierung von
Range#each: 16,64 Sekunden
- C-Implementierung von
- Diese Implementierung ist eine stark vereinfachte Version, die nicht alle
Range-Fälle behandelt - Trotzdem kann YJIT durch die Verlagerung von C nach Ruby optimieren, und dadurch sind Performance-Gewinne möglich, die sich mit gewöhnlichem C-Code nur schwer oder gar nicht nachbilden lassen
YJIT-Standardbibliothek und with_yjit
- Aaron Pattersons Ruby Outperforms C beschreibt einen Fall, in dem eine C-Erweiterung für GraphQL-Parsing in Ruby neu geschrieben wurde und der Ruby-Code dank YJIT-Optimierung schneller als C war
- Das zentrale YJIT-Team von CRuby entfernt bei einigen Kernfunktionen C-Code oder nutzt einen Ansatz, bei dem nur bei aktiviertem YJIT die Ruby-Implementierung verwendet wird
- Der Block
with_yjitwendet die jeweilige Ruby-Implementierung nur an, wenn YJIT aktiviert ist- Wenn YJIT deaktiviert ist, läuft weiterhin die C-Implementierung
- Wenn YJIT aktiviert ist, wird die für YJIT optimierbare Ruby-Version verwendet
- Seit Ruby 3.3 unterstützt YJIT verzögerte Initialisierung, und
with_yjitwendet im Moment der YJIT-Aktivierung die passende Methodenversion an with_yjitist ein YJIT-Hook und wird nach dem Aufruf zur Laufzeit mitundef :with_yjitentfernt
So sieht man den von YJIT erzeugten Maschinencode
- Wenn CRuby mit der Option
--enable-yjit=devgebaut wird, kann man die von YJIT erzeugte Disassemblierung des Maschinencodes sehen - Ein Build-Beispiel
./configure --enable-yjit=devmake install
- Beim Ausführen verwendet man das Flag
--yjit-dump-disasm./ruby --yjit --yjit-dump-disasm test.rb 40
- Das
i.succinInteger#timeserscheint im VM-Bytecode alsopt_succ - Die Rust-Implementierung von YJIT macht für
opt_succFolgendes- Sie prüft per Guard, ob der Empfänger ein Fixnum ist
- Ist es kein Fixnum, wird auf einen anderen Ausführungspfad gewechselt
- Ist es ein Fixnum, wird aufgrund der internen Tagged-Repräsentation tatsächlich 2 addiert, um 1 zu erhöhen
- Tritt ein Overflow auf, wird ebenfalls auf einen anderen Pfad gewechselt
- Dieses Beispiel zeigt, dass JIT-Optimierung über viele Ebenen hinweg arbeitet: Ruby-Code, C-Code, VM-Bytecode, Rust-Implementierung und Maschinencode
Die Richtung der CRuby-Optimierung
- Die Implementierungsarbeit an Ruby erfolgt oft in Sprachen unterhalb von Ruby, in CRuby vor allem in C und teilweise in Rust
- Schichten wie YJIT eröffnen die Möglichkeit, mehr Sprachfunktionen in gewöhnlichen Ruby-Code zu verlagern
- Wenn mehr Kernfunktionen in Ruby geschrieben werden, könnte es für Ruby-Entwickler leichter werden, zu CRuby beizutragen
- Als eine mögliche Zukunft für CRuby wird eine Struktur genannt, ähnlich wie bei Java: ein kleiner Low-Level-Kern, auf dem der Großteil der Sprache in sich selbst geschrieben ist
- Im aktuellen Trend bleibt wichtig, nicht einfach die C-Implementierung beizubehalten, sondern auf zentralen Pfaden Ruby-Implementierungen einzuführen, die YJIT optimieren kann
1 Kommentare
Hacker-News-Kommentare
Das Loop-Beispiel wirkt wie ein seltsamer Benchmark, der 1 Milliarde verschachtelte Schleifendurchläufe ausführt, und bei manueller Optimierung dürfte mehr als 99 % der Laufzeit am Anfang konzentriert sein.
Mit einer Liveness Analysis pro Array-Element ließe sich anscheinend die gesamte äußere Schleife entfernen; ich frage mich, ob es Compiler gibt, die so eine Analyse tatsächlich durchführen.
Selbst wenn
uzur Compile-Zeit nicht bekannt ist, ließe sich wohl auch die innere Schleife auf ein paar Instruktionen reduzieren, und das wirkt wie eine standardmäßigere Optimierung, die etwaclangbald leisten könnte.Es wären zu viele Daten nachzuverfolgen, und nützlich wäre das wahrscheinlich nur bei solchem fehlerhaften Code.
Als ich früher einen AI-Compiler gebaut habe, wäre eine Liveness Analysis für einzelne Tensor-Elemente tatsächlich nützlich gewesen, aber wegen der absurden Compile-Zeit und Speicheranforderungen haben wir es nicht gemacht.
result = ((u * (u - 1)) / 2 * (100000/u)) + (100000%u * (100000%u - 1) / 2) + r)Der Artikel behandelt eine kommende Ruby-Version, und aus Neugier habe ich nachgesehen: ruby 3.4.0 erscheint wohl dieses Weihnachten, ruby 3.5.0 dann nächstes Weihnachten.
Außerdem frage ich mich, welchen Effekt Pythons minimaler JIT auf solche Schleifen hätte.
Python 3.13 muss mit aktiviertem JIT gebaut werden; es wäre interessant, wenn jemand in so einer Build-Umgebung den Benchmark laufen ließe: https://drew.silcock.dev/blog/everything-you-need-to-know-ab...
Soweit ich mich erinnere, konnten Performance-Verbesserungen auch in Point Releases einfließen.
Integer#succnutze ich nicht nur wegen der Performance, sondern oft auch wegen der Lesbarkeit.Zum Beispiel habe ich es in der Methode
#byteseiner UUID-Bibliothek zweimal verwendet, damit ich beim Lesen des Codes mental im „Bit-Slicing-Modus“ bleibe.Die Schleife über 16 Durchläufe ist als
0xF.succausgedrückt, die Division durch 256 innerhalb der Schleife als0xFF.succ: https://github.com/okeeblow/DistorteD/blob/ba48d10/Globe%20G...0xF.succin diesem Fall besser anfühlt als0x10.Ich arbeite an https://github.com/bddicken/languages mit, und nachdem ich einen Lua-Ansatz ausprobiert hatte, kam mir auch das irgendwo erwähnte TruffleRuby in den Sinn; als ich aber
main.rbausführte, gab es praktisch keinen großen Unterschied zwischen TruffleRuby und normalem Ruby.Manchmal war normales Ruby sogar schneller als TruffleRuby.
Ich würde gern prüfen, nach welcher Änderung der TruffleRuby-Speed-Benchmark im Artikel entstanden ist, und, falls sich das verifizieren lässt, das als Commit ins Haupt-Repository aufnehmen.
Wenn die TruffleRuby-Implementierung tatsächlich schneller als Node.js und nahe an Bun oder Go liegt, wäre das ziemlich überraschend.
Details siehe https://github.com/oracle/truffleruby.
Ruby ist wirklich schneller geworden, und besonders TruffleRuby ist noch beeindruckender.
Schade, denn diese Kombination hätte wahrscheinlich den größten Einfluss auf Ruby-Performance.
Ich wusste nicht, dass YJIT in Rust geschrieben ist; das ist wirklich interessant.
Der Nachteil ist, dass es beim Build möglicherweise nicht aktiviert wird, wenn keine passende Toolchain oder Plattform vorhanden ist; wirkt aber wie ein guter Kompromiss.
Es gibt auch ein schon länger betriebenes Repository für Sprachvergleiche, das mehr Sprachen abdeckt: https://github.com/niklas-heer/speed-comparison
Die Achsenbeschriftungen und Balkenlabels im Chart überlappen sich, und es gibt auch keine vertikalen Gitternetzlinien.
Eine einfache HTML-Tabelle wäre mir lieber.
Interessant ist, dass Python im Benchmark die langsamste Sprache war und dennoch Stand Oktober 2024 die meistgenutzte Sprache auf GitHub ist.
Es scheint eine Korrelation zu geben: Je langsamer eine Sprache, desto beliebter ist sie.
Obwohl ich Rust ziemlich intensiv mag: Alle Sprachen und Runtimes, Interpreter und Compiler sind Werkzeuge.
Je nach Problem und Lösungsansatz braucht man den passenden Werkzeugkasten, und wenn ein Programm vielleicht nur ein paar Mal ausgeführt wird, ist eine langsame Ausführungsgeschwindigkeit nicht besonders wichtig.
Bei Programmen in Python oder R ist das oft der Fall.
Ruby ist im Allgemeinen langsam, aber es macht wirklich Spaß, darin zu coden, was es für Einsteiger attraktiver macht.
Meine Advent-of-Code-Lösungen sehen erstaunlich ähnlich aus; das wirkt wie eine Veränderung, die das Spielfeld neu ordnet.