3 Punkte von GN⁺ 2025-03-11 | 1 Kommentare | Auf WhatsApp teilen
  • Der neue Tail-Call-Interpreter von CPython sah zunächst nach einer durchschnittlichen Verbesserung von 10–15 % in pyperformance aus; nach Anpassung der Baseline schrumpft der tatsächliche Gewinn je nach Konfiguration jedoch auf etwa 1–5 %
  • Die großen Verbesserungen waren weniger ein Effekt der neuen Implementierung selbst, sondern eher das Ergebnis einer Umgehung einer LLVM-19-Regression; der zentrale Faktor war, dass Clang 19 den bisherigen computed-goto-Dispatch nicht korrekt replizierte
  • Auf einem Intel Raptor Lake i5-13500 war der clang19-Build 1,09-mal langsamer als clang18, während clang19.tc 1,03-mal schneller war; auf einem Apple M1 MacBook Air war clang19 1,12-mal langsamer und clang19.tc blieb bei 1,00-mal langsamer
  • Durch die Tail-Duplication-Begrenzung in LLVM sank die Zahl indirekter Sprünge von 332 bei clang18 auf 3 bei clang19, wodurch die beabsichtigte Struktur des computed-goto-basierten Interpreters praktisch verschwand
  • Der Tail-Call-Ansatz bleibt eine sinnvolle Verbesserung, doch ein Ansatz, der Compiler-Optimierungen wie musttail explizit verlangt, kann für performancekritischen Code robuster sein

Baseline-Effekt, der wie eine Performance-Verbesserung aussah

  • Das CPython-Projekt hat vor etwa einem Monat eine neue Implementierungsstrategie für den Bytecode-Interpreter gemergt
  • Die ersten Ergebnisse zeigten über verschiedene Plattformen und pyperformance-Benchmarks hinweg im Schnitt 10–15 % Performance-Gewinn
  • Spätere Analysen zeigten, dass diese große Verbesserung hauptsächlich das Ergebnis einer zufälligen Umgehung einer LLVM-19-Regression war
    • Im Vergleich zu GCC, clang-18 oder LLVM 19 mit bestimmten Tuning-Flags sinkt der Gewinn auf etwa 1–5 %
  • Der Tail-Call-Interpreter selbst erzeugt tatsächlich einen Geschwindigkeitsgewinn, aber der Effekt ist moderater, als die ersten Zahlen nahelegten
  • Wer mit clang-19 oder einer späteren Version gebaut hat, hatte möglicherweise einen bisherigen Pfad, der tatsächlich 10–15 % langsamer war
    • Simon Willison reproduzierte einen Geschwindigkeitsgewinn von 10 % beim Vergleich von python-build-standalone-Builds mit Python 3.13

Benchmark-Aufbau und zentrale Zahlen

  • Mehrere CPython-Builds wurden auf einem Intel-Server und einem Apple M1 MacBook Air verglichen
    • Der Intel-Server ist ein bei Hetzner betriebener Raptor Lake i5-13500
    • Alle Builds verwenden LTO und PGO
    • Zur Reproduktion der Builds wurde eine nix-Konfiguration verwendet
  • Verglichen wurden die folgenden Varianten
    • clang18: Clang 18.1.8, computed goto
    • gcc: GCC 14.2.1, computed goto, nur Intel
    • clang19: Clang 19.1.7, computed goto
    • clang19.tc: Clang 19.1.7, neuer Tail-Call-Interpreter
    • clang19.taildup: Clang 19.1.7, computed goto plus -mllvm-Tuning-Flag zur Umgehung der Regression
  • Die durchschnittlichen pyperformance-Ergebnisse mit clang18 als Baseline sind:
    • Raptor Lake i5-13500:
      • clang19: 1,09-mal langsamer
      • clang19.taildup: 1,01-mal schneller
      • clang19.tc: 1,03-mal schneller
      • gcc: 1,02-mal schneller
    • Apple M1 MacBook Air:
      • clang19: 1,12-mal langsamer
      • clang19.taildup: 1,02-mal langsamer
      • clang19.tc: 1,00-mal langsamer
  • Der Tail-Call-Interpreter zeigte gegenüber clang-18 gewisse Geschwindigkeitsgewinne, blieb aber kleiner als der Performance-Einbruch, der durch den Wechsel zu clang-19 entstand
  • clang18.tc konnte nicht gemessen werden
    • Der Grund ist, dass der Tail-Call-Interpreter von einer neu in Clang 19 hinzugekommenen Compiler-Funktion abhängt
    • Wegen dieser Einschränkung waren mehr Benchmark-Kombinationen nötig, um die Situation zu verstehen

Der durch die LLVM-19-Regression verursachte Dispatch-Zusammenbruch

  • Ein traditioneller Bytecode-Interpreter verarbeitet Opcodes über eine switch-Anweisung innerhalb einer while-Schleife
    • Der Compiler kompiliert switch üblicherweise zu einer Sprungtabelle und indirekten Sprüngen
  • Es ist seit Langem bekannt, dass solche Interpreter schneller werden können, wenn man die Dispatch-Logik in den Körper jedes Opcodes repliziert
    • Statt am Ende eines Opcodes zum Schleifenanfang zurückzukehren, gibt es die Logik zum Dekodieren der nächsten Instruktion und zum Indizieren der Sprungtabelle separat pro Opcode
  • C-Compiler stellen eine Funktion bereit, mit der sich Adressen von Labels nehmen und als computed goto verwenden lassen; auch CPython nutzte vor der Tail-Call-Arbeit eine Interpreter-Schleife dieser Art
  • Clang/LLVM fasst aus Gründen der Compiler-Performance mehrere gotos eines computed goto intern zu einer einzelnen indirectbr LLVM instruction zusammen
    • Anschließend repliziert die Codegenerierung per Tail Duplication die Verzweigungslogik wieder an die jeweiligen Stellen
    • Dieser Ablauf ist in einem älteren LLVM-Blogbeitrag auf hoher Ebene dokumentiert
  • LLVM 19 führte eine Begrenzung des Tail-Duplication-Passes ein, um in bestimmten Fällen stark erhöhte Compile-Zeit oder Speichernutzung zu vermeiden
    • In CPython führte diese Begrenzung dazu, dass Clang die Dispatch-Sprünge in zusammengeführter Form beließ
    • Dadurch wurde der Zweck der computed-goto-basierten Implementierung praktisch ausgehebelt
  • Dieses Problem wurde zuerst bei anderen Sprachimplementierungen mit ähnlichen Interpreter-Schleifen identifiziert, aber dass es CPython betrifft, war nicht bekannt
  • Disassembliert man den Objektcode und zählt die indirekten Sprünge, ist der Unterschied direkt sichtbar
    • _PyEval_EvalFrameDefault im clang18-Build: 332 jmp *
    • _PyEval_EvalFrameDefault im clang19-Build: 3 jmp *

Die unklare Rolle von computed goto

  • Dass die Änderung an der Tail-Duplication-Logik die Regression verursachte, wird dadurch bestätigt, dass die Performance nach dem Fix wieder auf clang-18-Niveau zurückkehrt
  • Allerdings ist die Größe der Regression damit nicht vollständig erklärt
    • Historisch wurde die Replikation des Opcode-Dispatchs als Verbesserung genannt, die Interpreter um 20 % bis 100 % schneller machen könne
    • Auf modernen Prozessoren mit verbesserten Branch Predictors zeigen neuere Arbeiten dagegen kleinere Verbesserungen von etwa 2–4 %
  • Python unterstützt per Konfigurationsoption auch einen alten Interpreter mit einer einzelnen switch-Anweisung
    • clang18.nocg: 1,01-mal schneller als clang18
    • clang19.nocg: 1,02-mal langsamer als clang18
    • clang19: 1,09-mal langsamer als clang18
  • Dass clang19.nocg schneller als clang19 ausfällt, ist eine zusätzliche Überraschung
    • Clang 18 oder Clang 19 mit passenden Flags repliziert die Dispatch-Logik auch bei einem switch-basierten Interpreter in jeden Opcode-Körper
  • Auch der Vergleich der Zahl indirekter Sprünge zeigt diesen Unterschied
    • clang18: 332
    • clang18.nocg: 306
    • clang19.nocg: 3
    • clang19: 3
  • In modernen Clang-Versionen könnte der gesamte computed-goto-Interpreter unnötige Komplexität sein
    • Denn der Compiler kann dieselbe Transformation auch bei switch-basiertem Code durchführen
    • Umgekehrt reichte computed goto selbst nicht aus, um die Transformation zu garantieren
  • GCC 14.2.1 replizierte switch nicht, implementierte bei Verwendung von computed goto aber das beabsichtigte Verhalten

Fixes und Workarounds

  • LLVM Pull Request 114990 wurde kurz nach Veröffentlichung des Beitrags gemergt und behebt die Regression
  • Bereits Benchmarks vor dem Merge bestätigten, dass der Fix die erwartete Performance wiederherstellt
  • In Releases vor dem Fix lässt sich der Abbruchschwellwert für Tail Duplication über eine Tuning-Option anpassen, die durch den regressionsverursachenden PR hinzugefügt wurde
    • Setzt man diese Begrenzung in clang-19 auf einen sehr hohen Wert, kann ein ähnliches Verhalten wiederhergestellt werden
  • Bei LTO-Builds ist die Weitergabe dieser Option kompliziert
    • Tail Duplication findet während der Codegenerierung statt, und die Codegenerierung eines LTO-Builds geschieht nicht zur Compile-Zeit, sondern zur Link-Zeit
    • Deshalb muss das Flag nicht nur an den Compiler, sondern auch an lld übergeben werden
  • Das verwendete Konfigurationsbeispiel übergibt in der ./configure-Phase -mllvm -tail-dup-pred-size=5000 an OPT und LDFLAGS

Das Baseline-Problem, das Benchmarks sichtbar machten

  • Benchmarks können Performance-Unterschiede zwischen bestimmten Builds genau messen, doch um daraus eine „allgemeine Performance-Verbesserung“ abzuleiten, braucht es zusätzliche Annahmen
  • Die Benchmarks des Tail-Call-Interpreters zeigten gegenüber dem bisherigen computed-goto-Interpreter 10–15 % bessere Ergebnisse, aber für eine Verallgemeinerung auf breitere Schlussfolgerungen war die Baseline komplex
  • Bei Performance-Arbeit ist womit man vergleicht ein immer wieder schwieriges Problem
    • Selbst wenn man den theoretisch besten bekannten Ansatz versteht, ist es eine eigene Herausforderung, reale Betriebssysteme, Compiler-Optionen und Flags korrekt abzustimmen
    • Öffentliche Benchmarks auf alter Hardware oder in schwer reproduzierbaren Größenordnungen eignen sich möglicherweise nicht für direkte Vergleiche
  • Auch in Machine-Learning-Papers ist bei behaupteten Algorithmusverbesserungen oft zuerst die Frage wichtig, „mit welcher Baseline wurde verglichen?“, noch vor „was wurde gemacht?“
  • Vergleicht man mit einer schlecht getunten Baseline, lassen sich leicht beeindruckende Ergebnisse erzielen

Optimierende Compiler und musttail

  • Der computed-goto-Fall zeigt, dass Erwartungen an optimierende Compiler miteinander kollidieren können
    • Der Compiler soll die Absicht des Programmierers respektieren und dasselbe Verhalten bewahren
    • Gleichzeitig soll er komplexe und nicht intuitive Transformationen durchführen, um Code schneller zu machen
  • clang-19 kompilierte den computed-goto-Interpreter aus Sicht des Programmverhaltens korrekt, erzeugte aber Output, der der Optimierungsabsicht völlig entgegenlief
  • Andere Clang-Versionen wenden dieselbe beabsichtigte Optimierung sogar auf einen einfachen switch()-basierten Interpreter an
  • Computed goto auf Quellcode-Ebene und Dispatch-Replikation auf Maschinencode-Ebene erscheinen fast wie orthogonale Konzepte
    • Da das Ausführungsergebnis gleich ist, können aktuelle Werkzeuge diesen Unterschied schwer konsistent ausdrücken
  • Der Tail-Call-Interpreter basiert auf dem musttail attribute
    • musttail ändert zwar nicht das traditionell beobachtbare Programmverhalten, ist aber eher ein Dialog mit dem Optimizer
    • Der Compiler muss eine bestimmte Optimierung durchführen können; wenn diese Optimierung nicht möglich ist, soll die Kompilierung fehlschlagen
  • Dieser Stil kann eine robustere Art sein, performancekritischen Code zu schreiben, auch während sich Compiler weiterentwickeln
  • Es wäre prüfenswert, ob ein hypothetisches Attribut wie [[clang::musttailduplicate]] computed goto in der Interpreter-while-Schleife ersetzen könnte

Reproduzierbarkeit und Grenzen durch nix

  • nix war sehr hilfreich, um mehrere Python-Interpreter-Builds zu verwalten
    • Im Verlauf der Experimente wurden Dutzende Python-Interpreter mit den vier Compilern gcc, clang-18, clang-19, clang-20 und verschiedenen Flag-Kombinationen gebaut und gebenchmarkt
  • Mit nix ließen sich parallele Versionen reproduzierbar und isoliert halten
    • Es war klar nachvollziehbar, welcher Build von welchem Compiler und welchen Flags stammte
    • Auch die Definition der Build-Matrix ließ sich mit kurzen Abstraktionen verwalten
  • Einen Custom-LLVM mit angewendetem Bugfix-Patch zu bauen und damit anschließend Python zu bauen, war ebenfalls mit etwa 10 Zeilen Code möglich
  • Es bleiben auch Nachteile
    • nix unterscheidet sich in Teilen von der üblichen Softwarenutzung, sodass sich nicht völlig ausschließen lässt, dass solche Unterschiede Benchmarks oder Schlussfolgerungen beeinflusst haben
    • Zum Beispiel baut nix Projekte standardmäßig mit bestimmten Hardening-Flags; früh wurde entdeckt, dass diese Flags den Tail-Call-Interpreter unverhältnismäßig beeinflussen
  • Nix ist stark in Skalierbarkeit und Anpassbarkeit, aber um eine bestimmte Anpassungsmethode zu finden, waren viel Trial-and-Error und das Durchsuchen der nixpkgs-Quellen nötig

1 Kommentare

 
GN⁺ 2025-03-11
Meinungen auf Hacker News
  • Ich bin der Autor des PRs, der den Tail-Call-Interpreter in CPython eingebracht hat.
    Zunächst danke an Nelson, der fast einen Monat damit verbracht hat, die Ursache dieses Problems zu finden.
    Dass der für die Baseline verwendete Compiler so einen Bug haben würde, hatte weder ich noch vermutlich das CPython-Team erwartet. Ich habe einen großen Fehler gemacht und schäme mich sehr dafür; es tut mir leid.
    Ich habe auch einen Entschuldigungsbeitrag veröffentlicht: https://fidget-spinner.github.io/posts/apology-tail-call.htm...

    • Als ich „ein großer Fehler, ich schäme mich sehr und es tut mir leid“ las, dachte ich, die Performance von CPython sei ruiniert worden. Tatsächlich ist das aber überhaupt nicht der Fall.
      Es wurde eine Performance-Steigerung von 10–15 % angekündigt, aber mit einem bugfreien Compiler liegt sie eher bei 1–5 %. Und selbst diese Zahl war nicht völlig falsch, sondern traf nur unter bestimmten Bedingungen zu.
      Die Verbesserung wurde entwickelt, gemessen und der PR wurde reviewt – damit wurde die eigentliche Arbeit erledigt. Dass die Zahlen wegen eines Problems mit der verwendeten clang-Version missverständlich waren, wirkt wie ein nachvollziehbarer Fehler, in den jeder geraten kann.
      Trotzdem wurde eine sinnvolle Performance-Verbesserung erzielt und außerdem eine Compiler-Regression gefunden; dagegen wirken die falschen Zahlen eher klein. Ich weiß auch nicht recht, wer dadurch tatsächlich geschädigt wurde, und es scheint mir nichts zu sein, wofür man sich entschuldigen müsste.
    • Zur Einordnung: Nach dem Schreiben dieses Blogposts wurden die Fixes gemergt ;)
      Wenn bei einem so alten System wie dem Python-Interpreter 3–5 % erhalten bleiben, ist das für sich genommen schon ein großer Erfolg, auf den man durchaus stolz sein kann.
      Nach ungefähr 30 Jahren wird man bei bedeutenden Performance-Verbesserungen in lange bestehenden Systemen, insbesondere bei Verbesserungen über 1 %, erst einmal skeptisch.
      Reale Verbesserungen gibt es zwar, aber sie sind selten; oft wurde die Zeit lediglich an eine andere Stelle verschoben, sodass sie im Benchmark nicht auftaucht. Außerdem laufen Benchmarks in kontrollierten Umgebungen, um Effekte zu isolieren, während reale Software auf VMs oder Desktops zusammen mit allerlei anderen Dingen läuft.
      Ich habe oft gesehen, dass Verbesserungen, die in einer isolierten Umgebung eindeutig groß wirkten, in der Produktionsumgebung verschwanden oder sogar negativ wurden.
      CPython hat es noch schwerer, weil es viele Umgebungen unterstützen muss, und es gibt kein einzelnes Produktionsziel, bei dem man sagen könnte: „Wenn es in Produktion nicht schneller ist, ist es nicht wirklich schneller.“ In so einer Welt Performance zu verbessern, ist wirklich schwierig.
      Letztlich sind Performance-Tuning und Messung sehr schwierig, und das Einzige, wofür man sich vielleicht entschuldigen könnte, ist, diese Tatsache gelernt zu haben.
      Ich hoffe, ihr habt keine Angst davor, falschzuliegen. Jeder liegt ohnehin mal falsch. Man muss es so machen wie hier: sagen „Das haben wir wohl vermasselt“, und dann herausfinden, wie man damit umgeht und es künftig vermeidet.
      [1] Das ist nicht nur bei Performance so, sondern auch bei menschlichen Prozessen häufig. Wenn zum Beispiel ein Team für Code-Review-Tools sagt: „Wir haben die Code-Review-Zeit um 15 % reduziert und damit den Workflow aller beschleunigt“, kann es in Wirklichkeit Arbeit an anderer Stelle im System erzeugt haben, ohne den Gesamtfluss zu beschleunigen; die 15 % wurden nur an eine nicht gemessene Stelle verschoben.
    • Einer der Hauptgründe für das Design des Tail-Call-Interpreters ist meiner Ansicht nach, weniger anfällig für die Launen des Optimierers zu sein. So wurde es auch im ursprünglichen Artikel zu dieser Technik beschrieben (https://blog.reverberate.org/2021/04/21/musttail-efficient-i...).
      Theoretisch sollten dem Compiler bei einem solchen Kontrollflussgraphen und Profil genügend Informationen vorliegen, um für einen traditionellen switch()-basierten Interpreter optimalen Code zu erzeugen. In der Praxis kämpft man jedoch mit dem Compiler, wenn eine Funktion so groß und verwoben ist.
      Er spillt wichtige Variablen, die man in Registern halten möchte, zieht Stack-Frame-Manipulationen nach oben, die man rund um Fallback-Funktionsaufrufe verkleinern wollte, und führt wegen Branch Prediction identische Codepfade zusammen, die man eigentlich trennen wollte. Es kann sich anfühlen, als würde man mit Handschuhen Klavier spielen.
      Genau diese „Zusammenführung identischer Codepfade“ ist auch hier passiert, und der „fehlerhafte“ Compiler hat identische Pfade zusammengeführt und dadurch die Performance verschlechtert.
      Der „korrigierte“ Compiler tut das nicht mehr, aber auch dieser Fix ist letztlich eher eine Anpassung interner Compiler-Heuristiken. Es gibt keine Garantie, dass dieser oder andere Compiler ihre Heuristiken auch künftig in einer für uns günstigen Weise beibehalten.
      Ein Tail-Call-Interpreter hingegen kann das gewünschte Maschinen-Code-Muster im Interpreter selbst ausdrücken. Wenn man die Attribute musttail, noinline und preserve_none zusammen verwendet, kann man das Problem so einschränken, dass man deutlich weniger von Optimierer-Heuristiken abhängig ist.
      Deshalb gehen die Vorteile des Tail-Call-Interpreters über eine bloße Performance-Steigerung von 3–5 % hinaus; bei manchen Compilern kann es eine noch größere verlässliche Performance-Verbesserung sein.
    • Ich habe Respekt vor der Haltung, sagen zu können: „Es tut mir leid, ich habe einen Fehler gemacht.“ Ich hasse diese Kultur des sich fake durchmogeln und so tun, als hätte man Erfolg gehabt, die heutzutage wie der Standard wirkt.
    • Ich frage mich, warum die Performance-Regression der Baseline auf der faster-cpython-Benchmark-Seite [0] nicht aufgetaucht ist – oder ob sie doch aufgetaucht ist.
      Könnte man die Benchmarks verbessern, um ähnliche Fälle zu verhindern?
      [0] https://github.com/faster-cpython/benchmarking-public
  • Benchmarking wirklich korrekt zu machen, ist wahnsinnig schwierig. Es gibt zu viele Faktoren, die einen täuschen können.
    Kürzlich dachte ich, ich hätte eine Methode gefunden, einen Algorithmus um etwa 15 % schneller zu machen. Zumindest sagten das alle Benchmarks.
    Dann kopierte ich die schnellere Funktion in das Test-Harness, rief sie aber tatsächlich gar nicht auf und rief nur weiter die ursprüngliche, langsamere Version auf – und trotzdem war es noch immer 15 % schneller. Nicht einmal ausgeführter Code hatte also den ursprünglichen Code schneller gemacht.
    Natürlich lag es an Code- und Speicherlayout; irgendetwas war verschoben worden und passte dadurch besser in den CPU-Cache.
    Es ist wirklich schwer zu erkennen, ob ein Geschwindigkeitsgewinn daher kommt, dass der Code tatsächlich „besser“ geworden ist, oder ob man irgendwo einfach Glück mit besserer Ausrichtung hatte.
    Casey Muratori schreibt auf Substack eine sehr interessante Serie zu diesem Thema.

    • Erstaunlich, dass so eine Linker-Lotterie bis zu 15 % Verbesserung bringen kann. Ich frage mich, in welchen Fällen so große Zugewinne entstehen, ob das selten ist und wie man es am Ende beurteilt hat.
    • Ich erinnere mich vage an ein Benchmarking-Projekt, das Compiler-Entscheidungen absichtlich randomisierte, um stabiler abzuschätzen, wie gut Code tatsächlich läuft, und weniger davon abhängig zu sein, ob man in der Linker-Lotterie gewinnt oder verliert.
    • Aleksey Shipilёv, der lange als Java-„Performance Engineer“ gearbeitet hat, hat viel über die Schwierigkeiten des Benchmarkings geschrieben und vorgetragen. Seine Blogposts und Vorträge kann ich sehr empfehlen.
  • Respekt an den Autor dafür, den tatsächlichen Hintergründen so gründlich nachgegangen zu sein. Der Tail-Call-Interpreter in Python 3.14 ist weiterhin eine gute Verbesserung, und ein paar Prozent Leistungsgewinn in einer Sprach-Runtime sind hart erarbeitet.
    Nur war es eben kein magisches kostenloses 15-%-Mittagessen.
    Wichtiger ist, dass der Fall sehr gut zeigt, wie wichtig sauberes Benchmarking und Tests in mehreren Umgebungen sind. Er hat außerdem einen Compiler-Bug offengelegt, der allen zugutekommen kann.
    Das ist genau die Art von Tiefenanalyse, die einen dazu bringt, die nächste große Performance-Behauptung noch einmal zu prüfen. Eine weiterführende Frage ist, wie viele der vielen derzeit kursierenden „X % schneller“-Ergebnisse in Wahrheit Benchmark-Artefakte oder unbekannte Regressionen sind.
    Wie können wir solche Fallen künftig besser vermeiden?

    • Die größere Frage ist, warum ein 10-%-Einbruch der Python-Performance nicht erkannt wurde, als ein fehlerhaftes Compiler-Feature hineingeriet.
      Benchmarken sie den Compiler selbst nicht? Haben bestehende Benchmarks auf Compiler- oder Python-Seite diesen Compiler nicht verwendet?
  • Das ist ein gutes Beispiel dafür, wie wenig zutreffend Aussagen sind, C sei „nah an der Maschine“ oder „portable Assembly“. Moderne Optimierer verändern Logik sehr aggressiv, wenn es keine beobachtbaren Effekte gibt.
    Im Artikel hieß es auch: „clang-19 kompiliert den computed-goto-Interpreter in dem Sinne ‚korrekt‘, dass das resultierende Binary alle erwarteten Werte liefert, zugleich steht diese Ausgabe aber völlig im Widerspruch zur Optimierungsabsicht. Außerdem wenden andere Compiler-Versionen Optimierungen auf den ‚naiven‘ switch()-basierten Interpreter an und implementieren damit exakt dieselbe Optimierung, die wir durch Umschreiben des Quellcodes ‚beabsichtigt‘ hatten.“

    • Aus Sicht anderer Systemprogrammiersprachen der 80er und 90er Jahre ist C immer noch ziemlich nah an portable Assembly.
      Bei Cs a += 1 kann man darauf vertrauen, dass ein Zahlenwert erhöht wird, während derselbe Ausdruck in C++ Speicher allozieren, den Call-Stack abwickeln oder unbekannte Dinge tun kann. Ebenso ist a = "a" in C eine einfache Pointer-Zuweisung, während in C++ Speicherallokation und Ähnliches passieren kann.
      Die Formulierung „C ist portable Assembly“ bedeutet nicht, dass jede Anweisung direkt in äquivalenten Maschinencode kompiliert wird.
    • „Keine beobachtbaren Effekte“ ist hier zu einem Blogpost mit 10.000 Wörtern angewachsen.
  • Es ist nicht überraschend, dass der Compiler durch Eingriffe in die Schleifenkonstruktion den gesamten Tail-Call-Interpreter weniger wirksam gemacht hat, als angekündigt.

    1. CPU-Architektur und Version sind extrem wichtig. 95 % des Problems bestehen darin, den Instruction-Dispatch-Code so anzuordnen, dass der Branch Predictor optimal arbeitet; C wurde ursprünglich nicht als Sprache entwickelt, die so etwas unterstützt.
    2. Auch die abstrakte C-Maschine ist nicht low-level genug, um die Absicht sauber auszudrücken. Jede Implementierung wird übermäßig empfindlich gegenüber den Eigenheiten eines bestimmten Compilers und einer bestimmten Version.
      Paranoide Interpreter-Implementierungen schreiben dafür wieder direkt Assembly. LuaJIT ist bekannt dafür, ein Makrosystem implementiert zu haben, um eine sehr effiziente Assembly-Loop-Implementierung architekturübergreifend portabel zu machen. Genau deshalb macht es auch Spaß, an solchen Dingen herumzuschrauben.
      Vor ein paar Jahren habe ich außerdem einen Artikel und Tests zu beliebten Arten der Implementierung von Interpreter-Loops erstellt:
      https://github.com/vkazanov/bytecode-interpreters-post
    • Als Autor habe ich beim Schreiben dieses Artikels gelernt, dass die Aussage „95 % des Problems bestehen darin, den Instruction-Dispatch-Code so anzuordnen, dass der Branch Predictor optimal arbeitet“ heute nicht mehr stimmt.
      Moderne Branch Predictors können selbst einen einzelnen indirekten Sprung nahezu korrekt vorhersagen, wenn der Ausführungsabschnitt lang genug ist und sich das Verhalten des interpretierten Codes selbst stabil verhält.
      Es gibt ein Paper, das das sowohl auf echter Hardware als auch mit bestimmten simulierten Branch Predictors untersucht hat: https://inria.hal.science/hal-01100647/document
      Die Experimente, die ich in diesem Projekt gemacht habe, stützen anekdotisch dieselbe Schlussfolgerung. Ich konnte es nicht in den Artikel aufnehmen, aber ich habe mir einige Interpreter mit Hardware-CPU-Countern und perf stat angesehen, und Branch-Mispredictions traten nicht als dominierender Faktor hervor.
  • Die Performance von Python-Builds zu bewerten, ist extrem schwierig, weil es so viele Build-Techniken gibt, die die Leistung verbessern können.
    Kürzlich hatte auch das astral-Team mit diesem Problem zu tun, als sich zeigte, dass conda-forge-Builds deutlich schneller sind als die meisten anderen:
    https://github.com/astral-sh/python-build-standalone/pull/54...
    Ich frage mich, wie sich der Tail-Call-Interpreter verhält, wenn man ihn zusammen mit anderen bestehenden Build-Optimierungen verwendet.

  • Zugehörige Diskussionen:
    https://docs.python.org/3.14/whatsnew/3.14.html#whatsnew314-... --> https://news.ycombinator.com/item?id=42999672 (66 Punkte | vor 25 Tagen | 22 Kommentare)
    https://blog.reverberate.org/2025/02/10/tail-call-updates.ht... --> https://news.ycombinator.com/item?id=43076088 (124 Punkte | vor 18 Tagen | 92 Kommentare)

  • Guter Artikel. Ein Detail ist mir aufgefallen
    In einem der verlinkten Beiträge, https://simonwillison.net/2025/Feb/13/python-3140a5/, schrieb er: „3.14.0a5 war im Benchmark 1,12-mal schneller als 3.13, auf meinem extrem überlasteten M2 MacBook Pro“
    Das ist ziemlich verwirrend. Heißt das, er hat den Benchmark laufen lassen, während der Computer durch andere Prozesse überlastet war? Wären die Ergebnisse dann nicht völlig unzuverlässig?
    Ich dachte, solche Benchmarks würden in einer sehr kontrollierten Umgebung durchgeführt, um externe Variablen auszuschließen

  • Manche hier nennen 10 % „groß“ und 1 % „normal“, aber Optimierungen wie partielles Inlining bei doppelt rekursivem Fibonacci können die tatsächliche Arbeitsmenge und Laufzeit exponentiell reduzieren
    Bei zweistelligen Argumenten sind mehr als 10-fache Verbesserungen möglich, also Tausende Prozent. Genau genommen ist es exponentiell in Bezug auf den Unterschied der Rekursionstiefe, nicht der Problemgröße [1]
    Auch C-Compiler können sehr empfindlich auf Heuristiken für Code-Inlining reagieren, sodass es stark von der Codeform abhängen kann, ob diese enorme Beschleunigung tatsächlich eintritt
    Ein Teil des Problems ist also, dass CPUs sehr ausgefeilt und komplex geworden sind; ein anderer Aspekt ist aber, dass auch Compiler jenseits von -O0 oder -O1 ausgefeilt und komplex geworden sind
    Der Artikel ist gut und lesenswert, aber er ist auch eines von vielen Beispielen dafür, dass die Interaktion zweier komplexer Dinge sehr überraschende Ergebnisse liefern kann. Das gilt auch außerhalb der Informatik
    Menschen neigen stark dazu, zu stark zu vereinfachen, egal wie oft diese Lektion wiederholt wird
    Zusätzlich verwendet der Artikel mindestens zwei CPUs, Intel und Apple M1, sowie zwei Compiler, gcc und clang; in realen Deployment-Umgebungen kann es aber viel mehr Generationen und Implementierungen von Intel, AMD und ARM sowie andere Compiler geben. Man betrachtet also nur einen sehr kleinen Ausschnitt der Gesamtkomplexität
    Wissenschaftlicher wäre es, insbesondere bei Unterschieden wie „1,01-fach“, irgendeine Form von Fehlerbalken bei den Zeitmessungen anzugeben. Die Standardabweichung des Mittelwerts oder in diesem Fall vielleicht eher die Standardabweichung des Minimums könnte besser sein [2]
    Um Messfehler zu reduzieren, braucht man wahrscheinlich auch eine feste CPU-Core-Zuweisung durch das OS-Scheduling
    [1] https://stackoverflow.com/questions/360748/computational-com...
    [2] https://github.com/c-blake/bu/blob/main/doc/tim.md

  • Ich habe kürzlich Benchmarks von Python 3.9 bis 3.13 durchgeführt, und bis 3.11 wurde es kontinuierlich besser
    Python 3.12 und 3.13 waren jedoch etwa 10 % langsamer als 3.11
    Ich dachte, mein eigener Benchmark sei vielleicht nicht gut genug, habe es aber trotzdem in einem Kerndienst ausgerollt, und die gesammelten Metriken zeigten dieselbe Veränderung
    Hat jemand dasselbe Problem erlebt?