2 Punkte von GN⁺ 2024-12-06 | 1 Kommentare | Auf WhatsApp teilen
  • 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 --yjit senkt 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_yjit bei 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 40 ohne 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).each und (0...100_000).each, also Range#each
  • In Ruby 3.4 ist Range#each weiterhin in C geschrieben
  • In CRubys range.c verbindet die Range-Klasse die Methode each mit der C-Funktion range_each
  • range_each verzweigt ü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#times von einer C-Funktion in eine Ruby-Methode umgewandelt
  • Die Kernstruktur ist eine einfache Ruby-Schleife aus while i < self, yield i und i = i.succ
  • Weil sie in Ruby geschrieben ist, kann YJIT das Innere analysieren und optimieren
  • Wenn man die Range-Iteration durch 10_000.times und 100_000.times ersetzt, sinkt die Loops-Zeit deutlich
    • Range#each: 25,57 Sekunden
    • Integer#times: 13,66 Sekunden
  • In anderen Messumgebungen sank Integer#times bis auf 9 Sekunden, unter Ruby 3.4 sogar auf 8 Sekunden

Integer#succ und Optimierungen auf VM-Ebene

  • Die Implementierung von Integer#times verwendet für die Inkrementierung i.succ statt i += 1
  • Integer#succ ist eine Methode, die den nächsten Wert einer Ganzzahl zurückgibt
  • Im VM-Bytecode von Ruby wird i.succ als ein einzelner Schritt opt_succ dargestellt
  • Dagegen zerfällt i += 1 in zwei Schritte
    • putobject_INT2FIX_1_: legt die Ganzzahl 1 auf den VM-Stack
    • opt_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#each von 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 Primitive
    • Primitive.attr! :inline_block, :c_trace
    • Primitive.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#each iteriert, erreicht man eine ähnliche Performance wie mit Integer#times
    • Range#each: 25,57 Sekunden
    • Integer#times: 13,66 Sekunden
    • Array#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 Sekunden
    • range#each: 26,61 Sekunden
    • times: 13,12 Sekunden
    • for: 14,91 Sekunden
    • while: 37,10 Sekunden
    • loop do: 13,95 Sekunden
  • Mit deaktiviertem YJIT unter Ruby 3.4 wurden die meisten Varianten langsamer
    • Fibonacci: 16,49 Sekunden
    • array#each: 34,29 Sekunden
    • range#each: 33,88 Sekunden
    • times: 33,18 Sekunden
    • for: 36,32 Sekunden
    • while: 37,14 Sekunden
    • loop do: 50,65 Sekunden
  • Das while-Beispiel war langsamer als erwartet, was an der Art der Implementierung liegen könnte
  • for in und array#each sind auf Ebene des Ruby-VM-Bytecodes fast identisch und haben daher ähnliche Performance
    • for in ist 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 Sekunden
      • range#each: 0,92 Sekunden
      • times: 2,39 Sekunden
      • for: 2,06 Sekunden
      • while: 3,90 Sekunden
      • loop do: 0,77 Sekunden
    • MRuby 3.3
      • Fibonacci: 28,83 Sekunden
      • array#each: 144,65 Sekunden
      • range#each: 126,40 Sekunden
      • times: 128,22 Sekunden
    • Artichoke
      • Fibonacci: 19,71 Sekunden
      • array#each: 236,10 Sekunden
      • range#each: 214,55 Sekunden
      • times: 214,51 Sekunden

Experiment: Range#each per Monkey Patch in Ruby

  • Wenn man Range#each per Monkey Patch durch eine einfache Ruby-Implementierung ersetzt, verbessert sich die Performance deutlich
  • Die Implementierung ist eine einfache Form mit begin, end, loop, yield und i.succ
  • Die gemessenen Werte
    • C-Implementierung von Range#each: 25,57 Sekunden
    • Ruby-Implementierung von Range#each: 16,64 Sekunden
  • 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_yjit wendet 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_yjit wendet im Moment der YJIT-Aktivierung die passende Methodenversion an
  • with_yjit ist ein YJIT-Hook und wird nach dem Aufruf zur Laufzeit mit undef :with_yjit entfernt

So sieht man den von YJIT erzeugten Maschinencode

  • Wenn CRuby mit der Option --enable-yjit=dev gebaut wird, kann man die von YJIT erzeugte Disassemblierung des Maschinencodes sehen
  • Ein Build-Beispiel
    • ./configure --enable-yjit=dev
    • make install
  • Beim Ausführen verwendet man das Flag --yjit-dump-disasm
    • ./ruby --yjit --yjit-dump-disasm test.rb 40
  • Das i.succ in Integer#times erscheint im VM-Bytecode als opt_succ
  • Die Rust-Implementierung von YJIT macht für opt_succ Folgendes
    • 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

 
GN⁺ 2024-12-06
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 u zur 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 etwa clang bald leisten könnte.

    • Compiler führen normalerweise keine Liveness Analysis für einzelne Array-Elemente durch.
      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.
    • In den meisten Fällen lässt sich das in geschlossener Form berechnen:
      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...

    • Ruby wird immer an Weihnachten veröffentlicht, ein vorhersehbarer und irgendwie niedlicher Zeitplan.
      Soweit ich mich erinnere, konnten Performance-Verbesserungen auch in Point Releases einfließen.
  • Integer#succ nutze ich nicht nur wegen der Performance, sondern oft auch wegen der Lesbarkeit.
    Zum Beispiel habe ich es in der Methode #bytes einer 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.succ ausgedrückt, die Division durch 256 innerhalb der Schleife als 0xFF.succ: https://github.com/okeeblow/DistorteD/blob/ba48d10/Globe%20G...

    • Mich würde interessieren, warum sich 0xF.succ in diesem Fall besser anfühlt als 0x10.
  • 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.rb ausfü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.

    • Bei TruffleRuby muss man die Startzeit und die Zeit bis zur Spitzenleistung berücksichtigen; das hängt von der Konfiguration der nativen/JVM-Runtime ab.
      Details siehe https://github.com/oracle/truffleruby.
  • Ruby ist wirklich schneller geworden, und besonders TruffleRuby ist noch beeindruckender.

    • Das ist von Oracle: https://github.com/oracle/truffleruby
    • Rails läuft nicht auf Truffle, und soweit ich es verstehe, wird das wohl auch auf absehbare Zeit schwierig bleiben.
      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.

    • Anfangs war es in C geschrieben und wurde dann nach Rust portiert, was offenbar eine gute Entscheidung war.
      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

    • Noch ein Repository für Sprachvergleiche, aber die Darstellung ist schwer zu lesen.
      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.

    • Für einen erneuten Vergleich müsste man auch die Compile-Zeit einbeziehen und über die erwartete Anzahl von Ausführungen dieses Builds amortisieren.
      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.
    • Es ist ähnlich wie bei Essen: Wenn man Zucker darüberstreut, mögen es die Leute viel lieber.
      Ruby ist im Allgemeinen langsam, aber es macht wirklich Spaß, darin zu coden, was es für Einsteiger attraktiver macht.
    • Langsame Sprachen sind höher abstrahiert und daher einfacher zu verwenden.
    • Ich frage mich, ob diese Korrelation auch bei den Top 20 der beliebtesten Sprachen noch gilt.
  • Meine Advent-of-Code-Lösungen sehen erstaunlich ähnlich aus; das wirkt wie eine Veränderung, die das Spielfeld neu ordnet.