- Ausgangspunkt war ein Hinweis, dass das Lesen von Dateien über die Python-Bindings von Apache OpenDAL langsamer sei als Pythons eingebautes
open().read(). Der Engpass lag jedoch weder bei OpenDAL noch bei PyO3 selbst - In einem Benchmark zum Lesen einer 64-MiB-Datei wurde
python-fs-readmit etwa 15–19 ms gemessen, während Ruststd::fsund eine C-Implementierung bei etwa 23 ms lagen – Rust/C wirkten damit langsamer als Python - Mit
strace,eBPFundperfzeigte sich, dass der Unterschied mit dem Offset zusammenhing, an dem der Zielpuffer desread-Systemcalls innerhalb einer Speicherseite liegt; nahe0x10ließ sich der Performance-Einbruch reproduzieren - Ähnliche Effekte wurden auf AMD Ryzen 9 5900X, Ryzen 7 5700X und Ryzen 9 5900HX beobachtet; ein zentraler Hinweis war die Ausführungsleistung von
rep movsbinnerhalb von Kernel-_copy_to_iter - Python war nicht grundsätzlich schneller. Das Ergebnis entstand durch einen CPU-Bug rund um FSRM/
rep movsbbei AMD Zen 3 und einen zufälligen Speicher-Offset; auch die Verbesserung durchjemalloclag nicht am Allocator selbst, sondern an einem anderen Offset
Ein merkwürdiger Benchmark, der bei den OpenDAL-Python-Bindings begann
- Apache OpenDAL ist eine Datenzugriffsschicht, um Daten über verschiedene Storage-Services hinweg einheitlich zu lesen und zu schreiben; die Python-Bindings werden über PyO3 bereitgestellt
- Ein Nutzer meldete, dass Code zum Lesen einer 150-MB-Datei über die OpenDAL-Python-Bindings langsamer sei als Pythons eingebautes Datei-Lesen
- Python eingebautes
open(...).read()100-mal:4.470868484000675 - OpenDAL-Python-Bindings 100-mal:
8.993250704006641
- Python eingebautes
- Auch beim vereinfachten Lesen einer 64-MiB-Datei waren die OpenDAL-Bindings langsamer
python-fs-read: durchschnittlich 15,9 mspython-opendal-read: durchschnittlich 32,9 ms- Pythons eingebautes Lesen wurde 2,07-mal schneller gemessen als die OpenDAL-Bindings
Die Spur führt hinunter zu Rust OpenDAL und std::fs
- Selbst wenn dieselbe Logik mit dem OpenDAL-
fs-Service in Rust umgesetzt wurde, war sie langsamer als Pythons eingebautes Lesenrust-opendal-fs-read: durchschnittlich 23,8 mspython-fs-read: durchschnittlich 15,6 ms- Pythons eingebautes Lesen wurde 1,52-mal schneller gemessen als die Rust-OpenDAL-Implementierung
- Da OpenDALs
fs-Service std::fs aus Rust verwendet, wurde zur Prüfung des OpenDAL-Overheads separat eine Implementierung auf Basis vonstd::fsgeschrieben - Auch bei der direkten Rust-
std::fs-Implementierung setzte sich das gleiche Muster fortrust-std-fs-read: durchschnittlich 23,1 mspython-fs-read: durchschnittlich 15,2 ms- Pythons eingebautes Lesen wurde 1,52-mal schneller gemessen als Rust
std::fs
Systemcalls und mmap aus Sicht von strace
- Die Analyse mit
stracezeigte, dass sowohl Rust als auch Python für große Puffer-Allokationenmmapnutzten - Die Ausführung von Rust
std::fsöffnete/tmp/file, las 64 MiB in einem Schritt, rief danach einreadzur EOF-Prüfung auf und schloss die Datei wieder - Pythons eingebautes Lesen führte zwar mehr Systemcalls wie
newfstatat,ioctlundlseekaus, brauchte insgesamt aber weniger Zeit - Der Aufruf
mmap(NULL, 67112960, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)diente nicht dem File-Mapping, sondern der anonymen Speicherallokation67112960entspricht 64 MiB plus 4 KiBMAP_ANONYMOUSbedeutet eine Speicherallokation ohne Bezug zu einer Datei
- Rusts Standard-Build für
x86_64-unknown-linux-gnuverwendetmallocausglibc, undglibckann für große Allokationenmmapeinsetzen
Rust wird mit jemalloc schneller – und die Zwischenfolgerung kippt
- Nachdem der globale Rust-Allocator auf
jemallocator::Jemallocumgestellt wurde, war Rust schneller als Pythonrust-std-fs-read-with-jemalloc: durchschnittlich 9,7 mspython-fs-read: durchschnittlich 15,8 ms- Die Rust-Implementierung mit jemalloc wurde 1,64-mal schneller gemessen als Python
- Zu diesem Zeitpunkt sah es so aus, als läge die Ursache bei
mmapoder beim Standard-Speicherallocator, doch in einem späteren Update wurde diese Interpretation korrigiert - Nach dem Update vom 01.12.2023 entstand der Unterschied nicht, weil
jemalloc,pymallocodermimallocgrundsätzlich schneller wären alsglibc malloc - Der tatsächliche Unterschied kam vom Offset innerhalb der Speicherseite des vom Allocator erzeugten Puffers
rust-std-fs-read: liest beim Offset0x10relativ zurmmap-Startadresserust-std-fs-read-with-jemalloc: liest beim Offset0x740relativ zurmmap-Startadresse
- Der problematische Bereich wurde auf
0x00..0x10innerhalb der Seite eingegrenzt; auch mitjemalloclässt sich dasselbe Problem reproduzieren
Ein Problem, dessen Reproduzierbarkeit stärker vom Gerät als von Software-Einstellungen abhing
- Im weiteren Verlauf der Diskussion wurde bestätigt, dass der Effekt „Rust ist langsamer als Python“ auf dem Gerät des Autors besonders ausgeprägt war
- Die CPU des Autors war ein AMD Ryzen 9 5950X 16-Core Processor, der Arbeitsspeicher bestand aus DDR4 3200 MT/s 16-GB-DIMMs
- Auch durch Änderungen verschiedener Einstellungen verschwand der relative Performance-Unterschied nicht
- Das erneute Aktivieren von Linux-Kernel-
mitigations=offänderte das Ergebnis nicht - Änderungen bei Transparent Hugepage auf
always,madviseoderneveränderten die absoluten Werte, die relative Relation blieb aber erhalten - Auch das Binden an bestimmte CPU-Kerne per
core_affinityführte zum gleichen Ergebnis
- Das erneute Aktivieren von Linux-Kernel-
- Auch eBPF-basierte Latenzmessungen des
read-Systemcalls zeigten, dass die Rust-Seite langsamer war- Python
read file: 8.134.049 ns - Rust
std::fsread file: 24.636.975 ns
- Python
- Aus den Beobachtungen ließ sich der Unterschied nur mit OpenDAL, PyO3 oder der Rust-Standardbibliothek kaum erklären; bereits auf Systemcall-Ebene tat sich die Zeitdifferenz auf
Der Speicher-Offset als Hinweis aus der C-Implementierung
- Auch eine Implementierung desselben Lesens einer 64-MiB-Datei in C mit
fopen/malloc/freadwar langsamer als Pythonc-fs-read: durchschnittlich 23,8 mspython-fs-read: durchschnittlich 19,1 ms- Pythons eingebautes Lesen wurde 1,25-mal schneller gemessen als die C-Implementierung
- Mit
strace -e raw=read,mmapwurden die Pointer-Adressen geprüft; dabei unterschieden sich die Start-Offsets der Puffer von C und Python- C:
readbeim Offset0x10relativ zur vonmmapzurückgegebenen Adresse - Python:
readbeim Offset0x30relativ zur vonmmapzurückgegebenen Adresse
- C:
- Nachdem der Offset in der C-Implementierung auf dieselbe Weise angepasst wurde, verbesserte sich die Performance deutlich
c-fs-read-with-offset: durchschnittlich 8,9 ms- 2,15-mal schneller als Python und 2,68-mal schneller als die ursprüngliche C-Implementierung
- Dieses Problem ließ sich auch auf AMD Ryzen 9 5900X und AMD Ryzen 7 5700X reproduzieren
- In der Rust-Community wurde unter Std::fs::read slow? ein ähnlicher Effekt gemeldet; dort wurde ebenfalls auf den Zusammenhang zwischen Speicherbereich-Offset und Systemcall-Performance hingewiesen
perf-Analyse zeigt auf rep movsb
- Ein Kernel-Entwickler reproduzierte
c-fs-readund die Version mit Offset auf einemAMD Ryzen 9 5900HXund analysierte sie mitperf - Je nach Vorhandensein des Offsets unterschieden sich die Werte für
L1-dcache-prefetchesundL1-dcache-loadsdeutlich- Ohne Offset:
L1-dcache-loadsetwa 127.845.213,L1-dcache-prefetchesetwa 1.843.493 - Mit Offset:
L1-dcache-loadsetwa 13.965.813,L1-dcache-prefetchesetwa 395.578
- Ohne Offset:
- Der Hotspot lag im Kernel-
read-Pfad vonshmem_file_read_iterübercopy_page_to_iterbis_copy_to_iter - Die zentrale Assembly-Instruktion innerhalb von
_copy_to_iterwarrep movsb, und die meisten Samples konzentrierten sich auf diese Instruktion - Spätere Analysen ordneten ein, dass nicht das L1-Prefetching an sich der wichtigere Hinweis war, sondern dass
rep movsbbei seitenausgerichteten Daten schlecht performt und besser wird, wenn diese Seitenausrichtung aufgebrochen wird
FSRM und das AMD-Zen-3-Problem
- Auch der geteilte Ubuntu-glibc-Bugreport Terrible memcpy performance on Zen 3 when using rep movsb behandelt Performance-Probleme mit
rep movsb - Das Beispiel im Bericht erklärt, dass der
rep movsb-Pfad beim Kopieren von 2113 Bytes etwa 3,2 GB/s erreicht und bei einer Änderung der Größe auf 2111 Bytes auf über 100 GB/s steigt - FSRM steht für Fast Short REP MOV und ist eine Funktion, die
rep movsbundrep movsdschneller machen soll - FSRM ist eine ursprünglich von Intel eingeführte Funktion, die auch bei AMD Einzug gehalten hat; auf CPUs, die Unterstützung dafür deklarieren, nutzt
glibcstandardmäßig FSRM - Damit war die Schlussfolgerung: Python ist nicht grundsätzlich schneller als C/Rust; durch einen AMD-CPU-Bug wurde der C/Rust-Lesepfad bei bestimmten Speicher-Offsets langsam
Update: Kenntnisstand bei AMD und Reaktion von glibc
- Laut Update vom 01.12.2023 war AMD dieser Bug offenbar bereits seit 2021 bekannt
- Da nach Veröffentlichung des Artikels mehrere Leser den Link an AMD weitergaben, geht der Autor davon aus, dass AMD von dem Problem weiß
- Der Autor meint, AMD solle diesen Bug in
amd-ucodeverantwortlich beheben; unbestätigten Informationen zufolge könnte einamd-ucode-Fix auf Zen 3 jedoch schwierig sein - Die realistische Hoffnung ist, dass
glibcbei Bedarf FSRM deaktiviert - Auf
glibc-Seite laufen Arbeiten zu x86: Improve ERMS usage on Zen3
Reproduktionscode und weiterführende Materialien
- Xuanwo/when-i-find-rust-is-slow: Sammlung der verwendeten Code-Snippets und Skripte
- Std::fs::read slow?: ähnlicher Bericht aus der Rust-Community
- Terrible memcpy performance on Zen 3 when using rep movsb: bei Ubuntu glibc gemeldetes Zen-3-Performance-Problem mit
rep movsb - binding/python: rust std fs is slower than python fs: Issue zu den OpenDAL-Python-Bindings
1 Kommentare
Meinungen auf Hacker News
Es gibt gleich zwei spezielle CPU-Feature-Flags, die anzeigen, dass
REP STOS/MOVschnell ist und als kurze Befehlssequenz fürmemset/memcpyverwendet werden kann.Seit Jahrzehnten besteht der Schmerz darin, optimierte Routinen für jede neue CPU-Generation von Hand neu zu schreiben; dass das immer noch so ist, lässt einen fragen, ob so etwas nicht in die Timing-Test-Suite der CPU-Hersteller gehören müsste.
Vielleicht gab es ein Problem mit seitenweise ausgerichtetem schnellem
rep movs, oder es war für irgendeinen Angriff anfällig und wurde deshalb deaktiviert.Ich weiß nicht, wie der Fix aussehen sollte – ob dafür etwa ein Runtime-Check nötig ist.
Wenn es eine schnellere „Software“-Implementierung gibt, frage ich mich, warum
REP MOVSnicht zumindest im Microcode dasselbe macht.Der zugehörige glibc-Bug ist hier. Allerdings geht es dort um Zen 4: https://sourceware.org/bugzilla/show_bug.cgi?id=30994
Zuerst habe ich den Artikel gelesen und war bereit, mich darüber lustig zu machen, dass der Autor
std::fsfalsch benutzt hat; tatsächlich war es aber ein erfreulicher Text mit einem Debugging-Kaninchenbau und einem Mysterium nach dem anderen.Gut geschrieben und sehr interessant.
Die Prämisse ist etwas verwirrend. Verglichen wurde nicht reiner Python-Code mit nativem C/Rust-Code, sondern eine Python-Dateilesemethode, also ein Python-Wrapper über nativem Code, mit OpenDAL, einem weiteren Wrapper über nativem Code.
Dass es einen Performance-Unterschied gibt, ist weiterhin interessant, aber das als „langsamer als Python“ zu beschreiben, ist ziemlich seltsam. Hat man erwartet, dass die gesamte Python-Standardbibliothek in reinem Python geschrieben ist? Eher würde ich erwarten, dass die Funktionsimplementierungen der Python-Standardbibliothek nativ und einzeln stark optimiert sind.
Dass die Schlussfolgerung mit der Arbeitsweise von nativem Code zu tun hatte, war nicht überraschend, die konkrete Antwort dagegen schon. Nur der Einstieg war verwirrend; der Artikel selbst war sehr interessant.
Auch der Titel „C is slower than Python with specified offset“ liest sich für Muttersprachler so, als sei „C selbst bei angegebenem Offset langsamer als Python“. Gemeint war tatsächlich das Gegenteil: Als man den in Python verwendeten Offset auch in C angab, wurde C schneller.
Dass eine einfache Aufgabe wie das Lesen einer Datei in der Rust-Standardbibliothek langsamer ist als in der Python-Standardbibliothek, ist überraschend. Selbst wenn man weiß, dass solche Aufrufe der Python-Standardbibliothek in C geschrieben sind, erwartet man, dass ein Aufruf der Rust-Standardbibliothek ungefähr ähnlich schnell ist.
Normalerweise würde man also eine falsche Verwendung oder ein seltsames Verhalten der Rust-Standardbibliothek vermuten; diesmal war es aber keines von beidem, sondern eine von der Allokationsausrichtung abhängige Performance-Klippe auf bestimmter Hardware.
Man erwartet zwar, dass Dateisystem-Lesevorgänge in Python gut optimiert sind, denkt aber, dass das in Rust ebenso gilt. Deshalb ist es überraschend, dass die Rust-Seite viel langsamer war – und besonders, dass es von Hardware und Allocator abhing.
Wenn Code, den ich in Python geschrieben habe, schnell ist, dann ist für mich Python schnell. Ob das daran liegt, dass die Implementierung in einer anderen Sprache geschrieben ist, oder an etwas anderem, ist mir ziemlich egal.
Was im Originalartikel passiert ist, kommt fast einem Zufall gleich. Der C-Code von CPython achtet nicht einmal auf
const-Konsistenz, und es gibt viele dynamische Speicherallokationen sowie Hilfs-/Komfortaufrufe. Sogar Dinge wie Arithmetik führen dynamische Speicherallokationen aus.Wer schon mit CPython gearbeitet hat, erwartet normalerweise keine gute Performance. Wenn man Performance verbessern will, versucht man eher, die dort bereitgestellten Funktionen zu umgehen.
Außerdem hat Python keinen Standard, also streng genommen auch keine Standardbibliothek, und die mitgelieferte Bibliothek ist größtenteils in Python geschrieben. Einige Teile sind in C geschrieben, aber auch von diesem C-Code ist ein beträchtlicher Anteil im Grunde mechanisch von Python nach C übertragen. Zum Beispiel war Pythons Implementierung der binären Suche ursprünglich in Python geschrieben und wurde später mithilfe der Python C API nach C übersetzt.
Worauf man hoffen kann, ist höchstens, dass Funktionen, die schlicht auf Betriebssystemfunktionen abbilden, relativ dünne Wrapper haben. Das heißt: Dateilesen geht im Kern direkt in ein Systeminterface, sodass nicht viel Binding-Code nötig sein dürfte.
Nachdem ähnliche Artikel dutzende Male erschienen sind, haben das alle gemerkt.
Der Artikel selbst ist hervorragend und enthält viele interessante Informationen zu diesem Thema.
Was mich jedoch mehr interessiert und beunruhigt, ist, wie das Problem gemeldet und dokumentiert wurde und wie die Kommunikation gehandhabt wird.
Die Meldung erfolgt auf Discord, einer proprietären Umgebung, die nicht indexiert wird, schwer durchsuchbar ist und nicht dauerhaft erhalten bleibt. Die Diskussion findet auf Discord und Telegram statt; in diesem Kontext könnte Telegram sogar noch schlechter sein.
Dieser Blogbeitrag und das GitHub-Repository sind die einzigen Spuren, die davon geblieben sind. Hätte Xuanwo nicht im Blog darüber geschrieben, wäre es in der Timeline verschwunden. Eine ziemlich interessante Situation.
Es gibt kaum Messenger, die standardmäßig öffentlich zugängliche Logs indexieren und durchsuchbar machen. Nicht jeder IRC-Server stellt öffentliche Logs bereit, und bei Matrix-Gruppen ist es genauso. Ich sehe nicht, warum Diskussionen dort nicht ebenfalls in der Timeline verschwinden sollten.
Öffentliche Logs lassen sich nicht deshalb bereitstellen, weil etwas nicht proprietär ist, sondern weil es eine API gibt, die Logging erlaubt. Telegram hat ebenfalls eine solche API, und die durchsuchbaren Logs unserer Diskussionsgruppe kann man hier sehen: https://luoxu-web.vercel.app/#g=1264662201
Dass es keine öffentliche Indexierung gibt, liegt vor allem an Privatsphäre, nicht daran, dass die Plattform proprietär ist.
Früher konnte man alle Beiträge sauber über DejaNews und später über Google durchsuchen.
Wichtige Kommunikation in wichtigen Open-Source-Projekten, etwa beim Internet-/WWW-Stack und bei zentralen Programmierwerkzeugen und -bibliotheken, sollte über offene Standards laufen.
Das war der interessanteste Artikel, den ich diese Woche gelesen habe. Eine hervorragende Zusammenfassung.
Das Naheliegende scheint zu sein, einen Patch für die Kernel-Methode
copy_user_genericeinzureichen.Wenn eine problematische CPU erkannt wird und sie den Bug auslöst, durch den Speicher-Alignment langsam wird, kann man einfach eine andere Speicherkopier-Implementierung verwenden lassen.
Ein Fix, der für jemanden ohne Kernel-Erfahrung akzeptabel wäre, dürfte nicht trivial sein. Wichtiger noch: Es ist auch nicht offensichtlich, wie der Workaround aktiviert werden sollte. Am besten wäre vermutlich eine Messung beim Booten; andernfalls ist unklar, wie man wissen soll, welche Modelle und Steppings betroffen sind.
Auch eine Software-Abmilderung wäre kompliziert. Denn der Kernel kann die Vektorbefehle, die er normalerweise im alternativen Pfad verwendet, wenn ERMS nicht genutzt werden kann, tatsächlich nicht verwenden.
jemallocwar bis 2018 der Standard-Allocator von Rust.https://internals.rust-lang.org/t/jemalloc-was-just-removed-...
Der Abschnitt „Rust-Entwickler können erwägen, zur Performance-Steigerung auf
jemallocatorumzusteigen“ macht mich neugierigIch weiß nicht, ob damit praktisch jeder nahezu kostenlose Performance-Gewinne bekommen kann oder ob es dabei Fallstricke gibt. Ich frage mich auch, ob C-Codebasen davon profitieren könnten und ob das derzeit einfach verschenkte Performance ist.
jemallocwegenMADV_FREEProbleme mit der Beobachtbarkeit gibt.htopzeigt dann nicht mehr zuverlässig an, wie viel Speicher tatsächlich in Benutzung ist.https://github.com/jemalloc/jemalloc/issues/387#issuecomment...
https://gitlab.haskell.org/ghc/ghc/-/issues/17411
Inzwischen scheint
jemalloc10 Sekunden nachMADV_FREEMADV_DONTNEEDaufzurufen: https://github.com/JuliaLang/julia/issues/51086#issuecomment...Das „behebt“ dieses Problem zwar, erzeugt aber eine verwirrende Verzögerung zwischen dem Zeitpunkt, zu dem Speicher freigegeben wird, und dem Zeitpunkt, zu dem man das in
htopbeobachten kann.Laut https://jemalloc.net/jemalloc.3.html kann man die Verzögerung allerdings durch Setzen von
opt.muzzy_decay_ms = 0beseitigen.Trotzdem ist der Autor von musl zurückhaltend dabei,
jemalloczum Standard zu machen: https://www.openwall.com/lists/musl/2018/04/23/2Der Kernpunkt ist, dass es Probleme mit starkem Aufblähen, geschwächter ASLR und Optimierungen gibt, die darauf ausgerichtet sind, möglichst schnell zu sein, ohne auf den Speicherverbrauch zu achten. Mit den obigen Tuning-Werten lässt sich das wohl bis zu einem gewissen Grad abmildern, aber die grundsätzliche Ausrichtung – ob man Performance oder Speicherverbrauch priorisiert – dürfte weiterhin ein Trade-off bleiben.
Es wird nicht in jeder Situation zwingend schneller sein, aber in fast allen Fällen vermutlich schon. Rust hat früher ebenfalls standardmäßig
jemallocverwendet, aber das wurde geändert, weil es Leute gab, die diesen Default überraschend fanden.Das hängt stark vom Workload ab, daher sind Profiling und Benchmarking nötig. Trotzdem sollten Low-Level-Sprachen wie C/C++/Rust solche Allocators auswählbar machen.
Ein Punkt, auf den man achten sollte, ist die Binary-Größe. Ein benutzerdefinierter Allocator fügt der ausführbaren Datei zusätzliche Bytes hinzu.
jemallocverwendet, ist aber um 2018 herum wieder zum System-malloczurückgekehrt[0].Heute gibt es in Rust das Trait
GlobalAllocund das Attribut#[global_allocator], sodass eine Appjemallocals Allocator verwenden kann, wenn sie möchte. Ich bin mir nicht sicher, ob Nutzer das mit etwas wieLD_PRELOADüberschreiben können.jemallocist nicht immer die beste Wahl für jeden Workload und jeden Anwendungsfall. System-Allocators sind oft alles andere als perfekt, wurden aber zumindest breit als Allzweck-Allocators getestet.[0] https://github.com/rust-lang/rust/issues/36963
jemallockann für manche Anwendungen die richtige Wahl sein, in anderen Fällen kann aber ein anderer Allocator schneller sein. Oder er ist zwar langsamer, passt aber besser zu Zielen wie weniger Dirty Memory, besserer Beobachtbarkeit oder bestimmten Sicherheitsgarantien.Ich habe das an die passenden Leute weitergeleitet.