C ist keine Low-Level-Sprache (2018)
(queue.acm.org)- C passte zur Zeit des PDP-11 gut zur Hardwareabstraktion, aber auf modernen CPUs weicht die abstrakte Maschine von C mit sequenzieller Ausführung und flachem Speicher stark von der realen Hardware ab
- Spectre und Meltdown hängen damit zusammen, dass Prozessoren sich stark auf Branch Prediction, spekulative Ausführung und Instruction-Level Parallelism stützen, um das sequenzielle Modell von C schnell auszuführen
- Um C-Code schnell zu machen, reicht keine einfache Übersetzung in Maschinencode; nötig sind komplexe Optimierungen im Maßstab von LLVM/Clang, und manche Optimierungen können mit der C-Semantik kollidieren
- Regeln zu Pointer-Provenance, Struktur-Padding, nicht initialisierten Werten und signed integer overflow erschweren die Vorhersage des Laufzeitverhaltens und können auch zu Sicherheitslücken führen
- Ein Modell, das besser zu moderner Hardware passt, nutzt viele Threads, breite Vektoreinheiten und ein einfacheres Speichermodell, aber die Kompatibilität zu bestehendem C-Code bleibt die größte Einschränkung
Warum C wie eine „Low-Level“-Sprache wirkte
- Bei einer Low-Level-Sprache sollte sich die von der Hardware bereitgestellte Abstraktion leicht auf die abstrakte Maschine der Sprache abbilden lassen
- Auf dem PDP-11 konnte man C als Low-Level-Sprache ansehen
- Programme wurden sequenziell ausgeführt
- Speicher wurde wie ein flacher Raum behandelt
- pre-increment- und post-increment-Operatoren passten gut zu den Adressierungsarten des PDP-11
- Alan Perlis definierte: „Eine Sprache ist Low-Level, wenn Programme auf irrelevante Dinge achten müssen“, aber diese Definition allein erklärt nicht vollständig die von Menschen erwartete „Nähe zur Hardware“
Moderne CPUs verhalten sich wie schnelle PDP-11-Emulatoren
- Die Grundursache von Spectre und Meltdown hängt nicht nur mit dem Bau schneller Prozessoren zusammen, sondern auch mit einem Prozessordesign, das eine abstrakte Maschine ähnlich dem PDP-11 schnell sichtbar machen wollte
- C-Code bot bis vor C11 faktisch eine vollständig sequenzielle Maschine, wenn man nicht standardisierte Herstellererweiterungen ausnimmt, und behält auch nach C11 überwiegend eine sequenzielle abstrakte Maschine bei
- Moderne CPUs extrahieren Instruction-Level Parallelism (ILP), um die Ausführungseinheiten ständig beschäftigt zu halten
- Sie prüfen benachbarte Operationen und geben unabhängige Operationen parallel aus
- Dadurch können Programmierer überwiegend sequenziellen Code schreiben, allerdings auf Kosten höherer Komplexität und höheren Stromverbrauchs
- GPUs können ohne diese Logik hohe Leistung erzielen, verlangen dafür aber explizit parallele Programme
Spectre, Meltdown und die Kosten spekulativer Ausführung
- Moderne Intel-Prozessoren können gleichzeitig bis zu 180 Instruktionen im Flug haben
- In C-Code kann man im Mittel etwa von einem Branch alle 7 Instruktionen ausgehen
- Um die Pipeline in einem einzelnen Thread gefüllt zu halten, müssen die Ziele der nächsten 25 Branches erraten werden
- Falsche Vorhersagen erzeugen Ergebnisse, die nach getaner Arbeit verworfen werden, und verschwenden zudem Energie
- Spectre und Meltdown konnten die sichtbaren Nebenwirkungen dieser verworfenen Arbeit über Side Channels ausnutzen
- Die register rename engine moderner High-Performance-Kerne ist einer der großen Verbraucher von Chipfläche und Energie
- Läuft eine Instruktion bereits, lässt sich die Einheit nur schwer abschalten oder stromlos machen
- GPUs besitzen solche Einheiten nicht; dort kommt Parallelität aus vielen Threads
Das flache Speichermodell von C passt nicht zur Cache-Realität
- Der flache Speicher als Kern der abstrakten Maschine von C passt seit mehr als 20 Jahren nicht mehr zur realen Hardware
- Moderne Prozessoren haben zwischen Registern und Hauptspeicher typischerweise drei Cache-Stufen
- Caches sind, ihrem Namen entsprechend, vor Programmierern verborgen und in C nicht sichtbar
- Wer auf modernen Prozessoren schnellen Code schreiben will, muss Caches effizient nutzen
- C-Programmierer müssen für Performance nicht nur die abstrakte Maschine, sondern auch Implementierungsdetails kennen
- So können etwa zwei auf 64 Byte ausgerichtete Werte in derselben Cache-Line liegen
Welche Compiler-Komplexität nötig ist, um C-Code schnell zu machen
- Bei einer Low-Level-Sprache sollte sich schneller Code auch ohne komplexen Compiler leicht erzeugen lassen, aber bei C ist das nicht so
- Clang und die zugehörigen Teile von LLVM umfassen ungefähr 2 Millionen Zeilen
- Zählt man nur die Analyse- und Transformations-Pässe, die nötig sind, um C schnell auszuführen, kommt man ohne Kommentare und Leerzeilen auf fast 200.000 Zeilen
- Wenn in C große Datenmengen verarbeitet werden, schreibt man meist Schleifen, die Elemente nacheinander bearbeiten
- Für optimale Ausführung auf modernen CPUs muss der Compiler zuerst feststellen, ob Iterationen der Schleife voneinander unabhängig sind
- Das Schlüsselwort
restrictkann garantieren, dass Schreibzugriffe über einen Pointer Lesezugriffe über einen anderen Pointer nicht stören
- Fortran ist beim Bereitstellen solcher Informationen gegenüber C im Vorteil, was ein wichtiger Grund dafür ist, dass C Fortran im High Performance Computing nicht verdrängt hat
Konflikte zwischen Vektorisierung und den Speicherlayout-Garantien von C
- Wenn Schleifeniterationen unabhängig sind, versucht der Compiler, das Ergebnis zu vektorisieren
- Moderne Prozessoren erreichen mit Vektorcode einen 4- bis 8-mal höheren Durchsatz als mit Skalarcode
- Für Prozessoren dieser Art wäre es natürlich, wenn eine Low-Level-Sprache native Vektortypen beliebiger Länge hätte
- LLVM IR bietet ein solches Modell, weil sich große Vektoroperationen leichter in kleinere zerlegen lassen als umgekehrt
- Die Speicherlayout-Garantien von C kollidieren mit Optimierungen
- Strukturen mit demselben Prefix können austauschbar verwendet werden
- Offsets von Strukturfeldern sind in der Sprache sichtbar
- Dem Compiler fällt es dadurch schwer, zur besseren Vektorisierung Feldreihenfolgen zu ändern oder Padding einzufügen
- Die Eigenschaft, das Layout von Datenstrukturen fein zu kontrollieren, kann ein Vorteil einer Low-Level-Sprache sein, macht es aber zugleich schwer, C schnell zu machen
Probleme mit Padding, SROA und loop unswitching
- C verlangt Padding am Ende von Strukturen, um zu garantieren, dass es in Arrays kein Padding zwischen den Elementen gibt
- Strukturen müssen typunabhängige Vergleiche wie
memcmperlauben, daher muss eine Strukturkopie auch Padding erhalten- In einigen Experimenten entfiel ein spürbarer Anteil der gesamten Laufzeit bestimmter Workloads auf das Kopieren von Padding
- SROA ist eine Optimierung, die Strukturen und Arrays fester Länge durch einzelne Variablen ersetzen will
- Dadurch lassen sich Zugriffe unabhängig behandeln und Operationen entfernen, deren Ergebnis nicht beobachtbar ist
- In manchen Fällen beseitigt das Padding, aber nicht immer
- loop unswitching ist eine Optimierung, bei der eine Schleife mit Bedingung aus der Bedingung herausgezogen und für beide Pfade getrennt erzeugt wird
- Das kollidiert mit der Vorstellung, dass Programmierer bei Low-Level-Code wissen, welcher Code wann ausgeführt wird
- Außerdem kann es Probleme mit den Konzepten unspecified value und undefined behavior in C verursachen
Nicht initialisierte Werte und undefiniertes Verhalten
- Liest man in C eine nicht initialisierte Variable, erhält man einen unspecified value, der bei jedem Lesen anders sein kann
- Diese Regel erlaubt Verhaltensweisen wie die verzögerte Wiederverwendung von Speicherseiten
- Die
malloc-Implementierung von FreeBSD meldet dem Betriebssystem derzeit ungenutzte Seiten - Das Betriebssystem kann den ersten Schreibzugriff auf eine Seite als Hinweis nutzen, dass diese Seite wieder benötigt wird
- Die
- Wird ein unspecified value zur Ablaufsteuerung verwendet, wird daraus undefined behavior
- Das gilt zum Beispiel, wenn ein nicht initialisierter Wert in einer
if-Bedingung verwendet wird
- Das gilt zum Beispiel, wenn ein nicht initialisierter Wert in einer
- Wenn bei loop unswitching eine Schleife nullmal ausgeführt wird, ist im Originalcode der gesamte Schleifenkörper toter Code
- Nach dem unswitching kann jedoch über eine möglicherweise nicht initialisierte Variable verzweigt werden
- Toter Code wird damit faktisch zu undefiniertem Verhalten
- C-Code kann schnell gemacht werden, aber es braucht Tausende Personenjahre, um einen hinreichend klugen Compiler zu bauen, und manchmal muss man dafür Teile der Sprachregeln verletzen
Warum C schwerer verständlich geworden ist
- Bei einer Low-Level-Sprache sollten Programmierer die Entsprechung zwischen abstrakter Maschine und realer physischer Maschine leicht verstehen können
- Auf dem PDP-11 ließen sich C-Ausdrücke leicht auf ein oder zwei Instruktionen abbilden, und auch lokale Variablen sowie primitive Typen entsprachen der Hardware auf einfache Weise
- Spätere C-Implementierungen wurden immer komplexer, um die Illusion von schnellem Code und Hardware-Nähe aufrechtzuerhalten
- Eine Umfrage unter C-Programmierern, Compiler-Autoren und Mitgliedern von Standardisierungsgremien aus dem Jahr 2015 zeigte diese Verständlichkeitsprobleme
- Wenn eine Struktur mit null initialisiert und danach einige Felder gesetzt werden: 36 % waren sicher, dass dann auch alle Padding-Bits null sind, und 29 % antworteten, dass sie es nicht wissen
- Das tatsächliche Ergebnis kann je nach Compiler und Optimierungsstufe unterschiedlich ausfallen
Pointer-Provenance und Sicherheitslücken
- Das BCPL-Modell war vergleichsweise einfach: Werte waren Wörter, und jedes Wort war entweder Daten oder die Adresse von Daten
- Das C-Modell wurde so entworfen, dass es auf sehr unterschiedlichen Zielsystemen implementiert werden kann, darunter Segmentarchitekturen und virtuelle Maschinen mit Garbage Collection
- Um auf solchen Systemen Probleme zu vermeiden, beschränkt der C-Standard die zulässigen Operationen auf Pointer
- C Defect Report 260 führte für Pointer das Konzept der pointer provenance ein
- Implementierungen können die Herkunft eines Bitmusters verfolgen
- Selbst bei identischem Bitmuster lassen sich Pointer unterschiedlicher Herkunft unterscheiden
- Das Wort
provenancekommt in der C11-Spezifikation nicht vor, sodass Compiler-Autoren seine Bedeutung festlegen müssen- GCC und Clang unterscheiden sich darin, ob die provenance erhalten bleibt, wenn ein Pointer in einen Integer und anschließend wieder in einen Pointer umgewandelt wird
- Es wurden Sicherheitslücken in Code beobachtet, der signed integer overflow auslöst oder Pointer vor einem Null-Check dereferenziert
- Da die Dereferenzierung eines null pointer in C undefined behavior ist, darf der Compiler annehmen, dass ein bereits dereferenzierter Pointer nicht null sein kann
- Ein Beispiel ist CVE-2009-1897
Einen nicht auf C ausgerichteten Prozessor denken
- Vorgeschlagene Korrekturen für Spectre und Meltdown verursachen erhebliche Performance-Einbußen und machen einen großen Teil der mikroarchitektonischen Fortschritte der letzten zehn Jahre zunichte
- Statt C-Code immer schneller zu machen, könnte es an der Zeit sein, das Programmiermodell für schnelle Prozessoren neu zu denken
- Stark multithreaded Chips wie Sun/Oracle UltraSPARC Tx brauchen nicht viele Caches, um Ausführungseinheiten auszulasten
- Gibt es genug Parallelität auf hohem Niveau, kann man Threads, die auf Speicher warten, anhalten und die Ausführungseinheiten mit Instruktionen anderer Threads füllen
- Das Problem ist, dass C-Programme dazu neigen, nur wenige beschäftigte Threads zu haben
- ARM SVE (Scalar Vector Extensions) ist ein Beispiel für eine bessere Schnittstelle zwischen Programm und Hardware
- Herkömmliche Vektoreinheiten legen Vektoroperationen fester Größe offen und erwarten vom Compiler, Algorithmen auf diese Größe zuzuschneiden
- SVE erlaubt es Programmierern, den Grad nutzbarer Parallelität zu beschreiben, und die Hardware bildet ihn auf die Zahl der Ausführungseinheiten ab
- In C muss der autovectorizer Parallelität aus der Schleifenstruktur ableiten, was komplex ist; bei einer funktionalen
map-Operation ist die Länge des Zielarrays hingegen direkt die verfügbare Parallelität, was die Codegenerierung vereinfacht
Einfachere Speichermodelle und paralleles Programmieren
- Das cache coherency protocol gehört auf modernen CPUs zu den am schwierigsten schnell und korrekt zu bauenden Komponenten
- Ein erheblicher Teil der Komplexität entsteht dadurch, dass Sprachen unterstützt werden müssen, die gemeinsam genutzte und veränderbare Daten erwarten
- In einer abstrakten Maschine im Stil von Erlang ist jedes Objekt entweder thread-lokal oder unveränderlich
- Erlang hat ein einfacheres Modell, in dem es pro Thread nur ein veränderbares Objekt gibt
- Das Cache-Kohärenzprotokoll solcher Systeme kann in zwei Fälle zerfallen: mutable oder shared
- Unveränderliche Objekte können Caches vereinfachen und viele Operationen günstiger machen
- Project Maxwell von Sun Labs stellte fest, dass die Menge der Objekte im Cache fast dieselbe ist wie die Menge der Objekte, die in die young generation allokiert werden
- Wenn ein Objekt stirbt, bevor es aus dem Cache verdrängt wird, muss es nicht in den Hauptspeicher zurückgeschrieben werden, was Energie spart
- Mit unveränderlichen Heap-Objekten und einem veränderbaren Stack kann der Garbage Collector zu einer einfachen Zustandsmaschine werden, die sich leicht in Hardware umsetzen lässt
- Ein Prozessor, der rein auf Geschwindigkeit ausgelegt ist, wird wahrscheinlich viele Threads, breite Vektoreinheiten und ein einfacheres Speichermodell unterstützen
- Auf einem solchen System C-Code auszuführen, kann problematisch sein
- Weil weltweit viel Legacy-C-Code existiert, wäre ein kommerzieller Erfolg dennoch schwer
- Die verbreitete Annahme, paralleles Programmieren sei schwierig, trifft eher auf paralleles Programmieren in Sprachen mit einer abstrakten Maschine wie C zu
- Alan Kay brachte Kindern Sprachen nach dem actor-model bei, und sie schrieben funktionierende Programme mit mehr als 200 Threads
- Erlang-Programmierer schreiben häufig Programme mit Tausenden parallelen Komponenten
- In einer Welt mit weit verbreiteten Multicore-CPUs und many-core-GPUs lässt sich C schlecht auf moderne Hardware abbilden
1 Kommentare
Hacker-News-Meinungen
C ist zumindest wegen der manuellen Speicherverwaltung Low-Level
Besonders auf moderner Hardware steht Speicherverwaltung im Zentrum des Programmierens. Dass Rust Speichersicherheit ohne Garbage Collector in den Vordergrund stellt, liegt letztlich daran, dass der Kern von Rusts Existenzgrund ziemlich nah an der Speicherverwaltung liegt. Der Grund, warum C schnell ist, ist Speicher, und der Grund, warum C unsicher ist, ist größtenteils ebenfalls Speicher. Einer der großen Gründe, warum paralleles Computing schwierig ist, ist gleichzeitiger Speicherzugriff. Funktionale Programmierung ist oft von mathematischen Konzepten umgeben, aber zu einem erheblichen Teil geht es darum, Objekte so tun zu lassen, als seien sie unveränderlich, während intern der Compiler mit veränderlichem Speicher umgeht
Wenn man in C einen Allocator benutzt, sind all diese Aufrufe explizit. Das frühere C++ mit
new/deleteund Raw Pointern ruft Allocators zwar explizit auf, aber vieles passiert auch automatisch in Destruktoren. Moderne Smart Pointer in C++ ähneln im Kern Garbage-Collection-Sprachen, da sowohl Allokation als auch Freigabe automatisch geschehenMan kann dem Prozessor nicht anweisen, welche Daten in welcher Cache-Ebene liegen sollen, was in den virtuellen Speicher ausgelagert werden soll usw. Es ist Low-Level im Vergleich zu Python, aber als Low-Level-Speicherverwaltung wie bei C zu PDP-11-Zeiten kann man es schwerlich bezeichnen
Bei Systemen auf Mikrocontroller-Niveau oder Systemen ohne MMU sieht die Sache anders aus, aber das ist wiederum ein eigenes Thema
Selbst ich als Rust-Entwickler arbeite in der Illusion, Pointer als reale physische Objekte wie Speicheradressen zu betrachten. Rust und in gewissem Maße C++ stellen zwar Verwaltungsabstraktionen wie Referenzen und Borrowing in den Vordergrund, aber das Kernkonzept bleibt erhalten
Tatsächlich legt der Betriebssystem-Kernel eine gewaltige Schicht zwischen physischen Speicher und Programm, und „Adressen“ und „Pointer“ sind eher Handles, mit denen OS und MMU allerlei Verarbeitung betreiben
Auch „Raw Pointer“ sind in Wahrheit nicht roh. Sie sind Handles auf Offsets innerhalb einer Page, und die tatsächlichen Pages können überall verstreut sein. Wenn man libc und das C-Modell vollständig hinter sich lässt und in eine Welt reiner Referenzen geht, die direkt mit den Pages des VM-Subsystems interagieren, also eine Art „Object-Handle“-Welt, könnte man dem tatsächlichen Verhalten des darunterliegenden Subsystems sogar näher kommen
mallocundfreesind BibliotheksfunktionenDa es in Hardware keine solche byteweise Allokation gibt, ist das nicht nur eine Abstraktion über der Hardware, sondern abstrahiert auch die Art, wie das Betriebssystem Speicher allokiert
In C kann man nicht einmal direkt auf den Stack zugreifen. Stack Frames sind abstrahiert, und was man verwenden kann, ist ungefähr
longjmpWenn man noch undefiniertes Verhalten und strikte Aliasing-Regeln berücksichtigt, hat man auch nicht besonders viel Zugriffsrecht, beliebig im Speicher herumzustochern
Als C-Programmierer und Compiler-Autor ist C für jemanden, der C versteht und professionell einsetzt, ganz klar eine Low-Level-Sprache.
Wenn man eine Low-Level-Sprache sucht, sind C und seine Verwandten die beste Wahl.
Wenn man C gerade erst lernt und wissen möchte, wie man es wie ein Experte verwendet, sollte man diesen Artikel besser ignorieren. Er kann nur verwirren und die Fähigkeit beeinträchtigen, C effektiv zu nutzen.
Sie bietet Low-Level-Zugriff auf eine Maschine, die reale Maschinen nur mit erheblichem Aufwand emulieren müssen. Die wackeligen Vorrichtungen und Flickarbeiten, die im Lauf der Jahre hinzugefügt wurden, um an reale Maschinen heranzukommen, sind innerhalb von C relativ fremdartige Elemente.
Allerdings stimme ich zu, dass der Titel rhetorisch grob ist. Nur weil es eine falsche Low-Level-Sprache ist, wird sie dadurch nicht zu einer High-Level-Sprache. Auch WASM wäre „falsch“, wenn man behauptete, es entspreche direkt moderner Hardware, aber deshalb ist es nicht High-Level.
Dass C eine schlechte Entsprechung ist, ist für sich genommen nicht frustrierend. Es ist eine Sprache aus den 1970ern, also ist das nachvollziehbar, und sie ist auch heute in vielen Fällen eindeutig nützlich. Frustrierender ist, dass C das Sprachdesign weiterhin stark prägt und die Art, wie Sprachdesigner Hardware betrachten, stark einfärbt. Deshalb beschränkt sich modernes Sprachdesign viel zu oft darauf, C-Bausteine neu zu mischen, statt Sprachen zu schaffen, die gut zur Hardware passen.
Wenn man annimmt, der geschriebene Code werde eine Eins-zu-eins-Beziehung zu Assembly haben, bekommt man Probleme. Wer genauer sehen möchte, wie einem so etwas ein Bein stellt, kann sich https://youtu.be/w3_e9vZj7D8 ansehen.
Sein Kernpunkt dürfte nicht sein, dass „C keine gute Sprache für Systemprogrammierung ist“. In Haskell ist es schwierig, etwas wie
volatile int *dma_register = SCATTER_GATHER_BASE;gleichwertig zu schreiben.Der Punkt des Autors ist, dass der Antrieb, C und andere Sprachen, die „Von-Neumann-Maschinen modellieren“, schnell auszuführen, Compiler sehr komplex gemacht hat, und dass er impliziert: „Wenn etwas Low-Level ist, sollte es einen einfachen Compiler benötigen.“ Auch die Prozessoren, die gebaut wurden, um solchen Code schnell laufen zu lassen, sind sehr komplex, und diese Komplexität hat Kosten.
In vielerlei Hinsicht ist dies ein Text, der zu einem Wechsel des Programmiermodells aufruft; GPUs werden als Beispiel dafür angeführt, was möglich ist, wenn ein „neues Programmiermodell“ und „Silizium, das es unterstützt“ gemeinsam entstehen.
Die ursprüngliche Bedeutung liegt näher an der Verwendung im Artikel. Eine Low-Level-Sprache ist nicht portabel und an die Hardware gebunden, auf der sie läuft; eine High-Level-Sprache kann mehrere Plattformen als Ziel haben. Nach dieser Definition ist C eindeutig eine High-Level-Sprache.
Mein Einwand ist weniger, dass der Autor Wortklauberei betreibt, sondern dass das Festhalten an veralteten Begriffen das Verständnis eher trübt. Die Einteilung in „Generationen“ ist meist aussagekräftiger.
Die erste Generation ist Maschinensprache, die zweite Assembly, die dritte allgemeine Programmiersprachen, die vierte domänenspezifische Anwendungssprachen.
Die Grenze zwischen dritter und vierter Generation verschwimmt manchmal, und in den 80er- und 90er-Jahren gab es auch Gerede von einer fünften Generation, das sich letztlich nicht durchgesetzt hat. Trotzdem halte ich SQL, HyperCard und Mathematica für ziemlich klare Beispiele für Sprachen der vierten Generation.
Das Gute an diesem Ansatz ist, dass er Sprachen nach relativ klaren Unterschieden darin einteilt, wann man sie verwendet. Danach kann man „High-Level/Low-Level“ als relative Begriffe verwenden. Je höher das Abstraktionsniveau einer Sprache ist, desto mehr Details dessen, was der Computer tatsächlich tut, abstrahiert sie tendenziell weg. Damit bleibt erhalten, dass Sprachen höherer Generationen im Allgemeinen auch höher angesiedelt sind, und das Einzige, was man verliert, sind dumme Streitigkeiten über völlig willkürliche und ehrlich gesagt nutzlose Grenzlinien.
Mit dieser Sichtweise kann man .NET IL, WebAssembly und Java-Bytecode interessanterweise als sehr High-Level-Sprachen der zweiten Generation betrachten. Und Forth ist eine Sprache der dritten Generation. Chuck darf gern widersprechen.
Es geht wohl weniger darum, wie man den Hammer benutzt, sondern um die Frage, ob die Art, wie wir den Hammer überall einsetzen – also das C-Design –, uns einschränkt.
Ich stimme der Behauptung des Autors nicht zu, dass der CPU-Befehlssatz mehr von der CPU-Implementierung offenlegen sollte.
Das wurde in der Vergangenheit versucht und ist langfristig gescheitert. Ein Beispiel sind die Branch-Delay-Slots einiger RISC-Prozessoren, die Ende der 80er- und Anfang der 90er-Jahre entworfen wurden, etwa MIPS und SuperH. Für Leute, die das Konzept nicht kennen: Die Instruktion nach einem Sprungbefehl wird ausgeführt, unabhängig davon, ob der Sprung genommen wurde oder nicht.
Kurzfristig konnte man den Prozessor einfacher und billiger machen, indem man es dem Programmierer überließ, Pipeline-Stalls nach einem Sprung zu vermeiden. Mit der Zeit wurden Prozessordesigns und Pipelines jedoch komplexer, und eine einzelne Instruktion reichte nicht mehr aus, um die Sprunglatenz zu überdecken. Am Ende wurde es aus Kompatibilitätsgründen zu Altlast, die künftige Prozessoren behandeln mussten, und machte Branch Prediction und Pipeline-Logik komplexer.
Die falschen Details offenzulegen ist natürlich schlecht. Er sagt lediglich, dass es in der Welt moderner CPUs erhebliche Grenzen des C-Modells gibt.
Ich habe einmal einen Vortrag gehört, in dem Entwickler ein solches Subsystem eines Prozessors genutzt haben. Ohne es verbrachten sie angeblich 95 % des Zeitfensters nur mit dem Kopieren von Daten; wenn sie mit dieser Engine Daten im Voraus anforderten, brauchten sie nur 10 % des Zeitfensters für die Datenerfassung und erledigten die gewünschte Arbeit innerhalb von etwa 50 % des gesamten Zeitfensters, sodass viel Zeit für Zusatzfunktionen und Verbesserungen übrig blieb.
Wenn x86 so eine Funktion gehabt hätte, hätte ich sie während meiner Promotion genutzt, um Matrixdaten, auf die zugegriffen werden sollte, vorab anzufordern. Mein Zugriffsmuster ist nicht linear, aber klar definiert. Derzeit müsste ich, um diesen Code weiter zu beschleunigen, die Matrizen so umorganisieren, dass der Prefetcher zufrieden ist, und die gesamte Codebasis von oben bis unten refaktorisieren.
Man kann so etwas natürlich miserabel entwerfen, wenn man will, aber ich frage mich, wie viele historische Beispiele es gibt, die eine Verallgemeinerung rechtfertigen.
Ich sehe „Low-Level“ bis „High-Level“ nicht als Dichotomie, sondern als Spektrum
C kann man innerhalb des unteren Drittels der Sprachen einordnen; es setzt einen vielen Maschinenprimitiven wie Speicher- und Thread-Verwaltung aus. Es ist zwar nicht so niedrig angesiedelt wie Assembler, aber niedriger als Java oder Go und ganz klar weit entfernt von Python oder JavaScript.
Außerdem eignet sich C ziemlich schlecht für Plattformen mit segmentiertem Speicher oder nicht flachen Adressen. Es gibt Anzeichen, dass solche Dinge wieder in Mode kommen könnten, und die weite Verbreitung von C steht dem wirklich stark im Weg.
Deshalb war mein mentales Modell immer: „C ist die niedrigste Ebene, bis zu der man hinuntergehen kann, bevor man dem Prozessor direkt Befehle gibt.“
„C verhält sich nicht wie eine typische ‚High-Level‘-Sprache, weil es mehrere Funktionen bietet, die man häufiger mit ‚Low-Level‘-Sprachen wie Assemblersprache verbindet. Dazu gehören die Fähigkeit, Daten an bestimmte Speicheradressen zu schreiben und von dort zu lesen, Operationen auf dem Inhalt von Speicherstellen sowie Anweisungen zum Inkrementieren und Dekrementieren ganzzahliger Variablen … Daher bietet C Programmierern die Flexibilität und Effizienz der Arbeit auf niedriger Ebene, zugleich aber auch die Vorteile von High-Level-Operationen wie den fortgeschritteneren Datenstrukturen und der Programmflusssteuerung, die für heutige Computersprachen typisch sind. Aus diesem Grund wird C manchmal als ‚High-Level-Low-Level-Sprache‘ oder als ‚Low-Level-High-Level-Sprache‘ beschrieben.“ - https://archive.org/details/computerprogramm0000ford/page/13...
Der Satz am Ende des Artikels, „In der Softwareentwicklung gibt es den verbreiteten Mythos, dass parallele Programmierung schwierig sei“, ist irreführend
Der Autor nennt zwar konkrete Situationen, in denen sie nicht schwierig ist, aber wenn die Frage allgemein gemeint ist, ist parallele Programmierung schwierig und kein verbreiteter Mythos.
Ist parallele Programmierung schwierig? Wenn man ohne weitere Bedingungen fragt: ja. Es ist deutlich schwieriger, sich vorzustellen, dass Code-Anweisungen gleichzeitig ausgeführt werden, als dass sie nacheinander einzeln ausgeführt werden.
(map inc [0 1 2 3])wirklich unterschiedlich schwierig, sich vorzustellen, dass die Funktionincfür jedes Element sequenziell oder parallel ausgeführt wird?Ich denke, die Schwierigkeit paralleler Programmierung ist weniger angeboren, sondern eher zweierlei:
Erstens sind Sprachen meist standardmäßig auf sequenzielle Ausführung ausgelegt, sodass für Asynchronität dem Programmierer zusätzliche Primitive bereitgestellt werden müssen.
Zweitens muss man wissen, wann parallele Programmierung effektiv eingesetzt werden sollte.
Wenn man eine Liste oder einen Stream unabhängiger Elemente hat, für die nur unabhängige Berechnungen nötig sind, ist parallele Programmierung intuitiv.
Der Punkt, an dem Leute hängen bleiben, ist, wenn sie Asynchronität dort erzwingen, wo sie unnötig ist — also wo sequenzielle Ausführung gleich schnell oder schneller wäre — oder wenn die Berechnungen in Wirklichkeit voneinander abhängen und Asynchronität das Verhalten kaputtmacht.
Wenn man „ohne zusätzliche Details oder Konkretisierung“ sagt, verwendet man tatsächlich die C/C-Familien-Weltsicht als Standard-Framework.
Der Punkt des Autors ist, dass sequenzielle Programmierung nur eine Art einfacher Programmierung ist, nicht die einzige, und dass sie nicht leicht zu moderner Hardware passt.
Dass Erlang existiert und erfolgreich genutzt wird, bedeutet nicht, dass etwas Schwierigeres nicht schwierig ist.
Auch parallele Algorithmen mit Infrastruktur für nebenläufige Programmierung wie Prozessen oder Threads zu implementieren, ist schwierig. Parallele Programmierung hingegen, also viele Verarbeitungselemente dazu zu bringen, gemeinsam dasselbe zu tun, ist mit den richtigen Abstraktionen viel einfacher.
Allerdings gibt es Ausnahmen bei manchen Anwendungsfällen wie Matrixmultiplikation.
Der Artikel hat recht damit, dass Computer keine schnellen PDP-11 sind, liegt aber falsch damit, dass das mit C zu tun habe
Zum Beispiel steht dort: „Ein weiterer Kern des Speichermodells der abstrakten C-Maschine ist flacher Speicher. Das war seit über 20 Jahren nicht mehr wahr.“
Das hat nichts mit C zu tun. Die Hardware erzwingt diese Abstraktion. Und das ist gut so. Andernfalls würden Programme beim Wechsel auf eine Maschine mit anderen Caches stehen bleiben.
Beispiele sind hierarchische Speicherstrukturen, die so tun, als seien sie flacher RAM; CPUs, die viel größer sind, als es der Befehlssatz vermuten lässt, mit Out-of-Order- und spekulativer Ausführung; sowie optimierende Compiler, die geschriebene Programme und tatsächliche Ausführung weiter voneinander trennen.
IBM arbeitete bereits in den 1970ern an solchen Dingen, lange bevor C aufstieg. Dieses Modell zu kritisieren und nach Alternativen zu suchen ist legitim, aber C die Schuld zu geben ist nicht fair.
Dieser Text ist inzwischen fünf Jahre alt, und die Annahme, dass Computer architektonisch nicht mehr viel mit der PDP-11 gemein haben, ist eher noch zutreffender geworden. Die Schlussfolgerung „stellen wir uns einen Nicht-C-Prozessor vor“ wirkt dagegen weniger stark.
Wir sehen eine deutliche Trennung zwischen linearem Code und hochgradig parallelem Code, und das war 2018 bereits so. Das klarste Beispiel ist der Aufstieg von Python im Machine Learning und im wissenschaftlichen Rechnen. Wenn Performance nicht oberste Priorität hat, ist es weiterhin sehr bequem, in einem Single-Thread-Stil und mit einem flachen Speichermodell zu schreiben.
Wenn Performance wichtig wird, ist es sinnvoll, zu Sprachen zu wechseln, die besser für parallele Programmierung geeignet sind. Dazu gehören die Rechengraph-Sprachen von Dingen wie Pytorch, andere Primitive-Sammlungen auf CUDA oder experimentellere Sprachen wie Futhark. Performance-kritischer Code hatte schon immer domänenspezifische Sprachen, und sie scheinen eher häufiger als seltener zu werden. Auch die Hardware wird daraufhin gebaut. Beispiele sind die auf Desktop-PCs übliche CPU+GPU-Kombination, x86-Vektorerweiterungen mit Primitiven, die praktisch eine eigene DSL bilden, oder Dinge wie der M1, der die GPU an die CPU anbindet, sodass beide schnellen Zugriff auf denselben Systemspeicher haben.
Anders gesagt: Wirklich veraltet ist vielleicht nicht C, sondern die Vorstellung einer Allzwecksprache, die für jede Art von Aufgabe gleichermaßen gut geeignet ist.
Wenn C wegen der Raffinesse moderner CPUs keine „Low-Level“-Sprache mehr ist, dann gilt dieselbe Logik auch für Assemblersprache.
Denn Dinge wie Out-of-Order Execution und Register Renaming betreffen auch Assembly.
Dass Compiler in den letzten Jahrzehnten immer ausgefeilter geworden sind, stützt dieses Argument zusätzlich. Selbst das von einem C-Compiler erzeugte Assembly, also der Objektcode, kann durch Dinge wie Loop-Invariant Code Motion oder Common Subexpression Elimination anders ausfallen als erwartet.
Trotzdem halte ich das Konzept, C als „Low-Level“-Sprache zu bezeichnen, weiterhin für ein nützliches Etikett. Wenn nicht, müsste man diese Bezeichnung selbst in Rente schicken.
Es ist zwar eine Abstraktion über einem realen Computer, aber eine deutlich geringere als das, was C auf seinem Modell einer virtuellen Maschine aufbaut. Heutiges Assembly liegt ungefähr auf dem Niveau von C zu der Zeit, als C entstand. Heutiges C ist so High-Level, dass es keine Fähigkeiten bietet, die man nicht auch mit besseren, moderneren Sprachen bekommen könnte.
Allerdings stimme ich zu, dass die Bezeichnungen „Low-Level“ und „High-Level“ heutzutage nicht besonders nützlich sind.
Der Text scheint zwei Argumentationslinien zu verfolgen, die sich nur schwer miteinander in Einklang bringen lassen.
Die erste ist die Behauptung, C sei keine Low-Level-Sprache, mit Beispielen wie Struct Padding und undefiniertem Verhalten bei Überläufen vorzeichenbehafteter Ganzzahlen. Dieser Teil ist nachvollziehbar und wirkt konstruktiv, weil er Sprachfeatures für eine hypothetische „wirklich Low-Level“-Sprache vorschlägt.
Die zweite ist die Behauptung, dass CPU-Designer wegen der Dominanz von C gezwungen waren, sich anzustrengen, um etwas zu bauen, das C natürlich ausführt. Als Beispiele werden Register Renaming, flacher Speicher und Caching genannt. Auch dieses Argument ist nachvollziehbar, aber mir ist nicht ganz klar, wie es mit dem ersten Argument und dem Titel des Textes zusammenhängt. Wörtlich genommen scheint es zu bedeuten, dass es auf moderner Hardware grundsätzlich unmöglich ist, eine Low-Level-Sprache zu entwickeln, und dass sogar Maschinencode „High-Level“ ist. Dann wäre die Schlussfolgerung, dass man zuerst eine neue Hardwaregeneration bauen müsste, die in der Instruction Set Architecture deutlich mehr Komplexität offenlegt, und erst danach eine Low-Level-Sprache entwerfen könnte, die das ausnutzt.
Beide Argumente sind wertvoll, aber sie in einem einzigen Text zusammenzuführen und ihn „C ist keine Low-Level-Sprache“ zu nennen, wirkt etwas instabil. Das erste Argument passt zu diesem Titel; das zweite wäre wohl besser in einem Folgeartikel mit dem Titel „Auch Maschinencode ist keine Low-Level-Sprache“ aufgehoben gewesen.
Allerdings waren die Compile-Zeiten lang, und soweit ich gehört habe, erreichten die Compiler letztlich nicht das erwartete Optimierungsniveau. Dass es nicht mit x86 kompatibel war, half der Verbreitung ebenfalls nicht.
VLIW kommt einem in den Sinn. Laut dem Itanium-Artikel auf Wikipedia gilt Folgendes:
„Ein einzelnes VLIW-Instruktionswort kann mehrere unabhängige Befehle enthalten, die ohne Prüfung auf Unabhängigkeit parallel ausgeführt werden können. Der Compiler muss versuchen, gültige Kombinationen von Befehlen zu finden, die gleichzeitig ausgeführt werden können, und übernimmt damit faktisch das Instruction Scheduling, das bei herkömmlichen superskalaren Prozessoren zur Laufzeit in Hardware erfolgt.“
Wenn die CPU Parallelität innerhalb eines einzelnen Befehlsstroms über ihr Interface sichtbar macht, könnte man das zur Compile-Zeit erledigen oder es per Inline-Assembler direkt festlegen.
Ich frage mich, ob der Grund, warum sich das nicht durchgesetzt hat, in der Geschäftslogik der Branche lag oder ob es echte technische Gründe gibt, warum diese Strategie nicht gut ist.
Erstens waren Compiler nicht besonders gut in dieser Art von Instruction Scheduling, und als sie später besser wurden, war Itanium bereits untergegangen. Zweitens konnten bestehende Befehlssätze, also x86, das zur Laufzeit in Hardware ziemlich gut erledigen und erzielten in der Praxis sogar etwas bessere Ergebnisse als statisches Scheduling. Zur Laufzeit stehen nämlich Profiling-Daten zur Verfügung.
Linus hat zu diesem Thema einen guten Rant hinterlassen, der ein wenig damit zusammenhängt [0]. „Während die RISC-Leute versuchten, Compiler so zu optimieren, dass sie Schleifen erzeugen, die alle 32 Register effizient nutzen, sorgten die x86-Implementierer stattdessen dafür, dass der Chip unter verschiedenen Lasten schnell läuft, und verwendeten enorme Hardware für Register Renaming. Auch Memory Renaming wird schon in Betracht gezogen.“
[0] https://yarchive.net/comp/linux/x86.html
VLIW funktioniert in einigen Nischen wirklich gut. Es ist schwieriger zu programmieren – ob von Hand oder per Compiler – als ein einzelner, der Reihe nach ausgeführter Befehl, vereinfacht aber das Scheduling in der Hardware. Es funktioniert besser, wenn die Latenzen der gebündelten Befehle ähnlich sind.
Das zentrale Designproblem besteht heute darin, dass Speicherzugriffe viel mehr Zyklen verbrauchen als Arithmetik. Es ergibt wenig Sinn, eine Arithmetikoperation von ein paar Zyklen mit einem Speicher-Load von mehreren hundert Zyklen zu bündeln. Daher funktioniert VLIW gut, wenn man weiß, dass der Speicherzugriff schnell ist – ungefähr, wenn man weiß, dass er in den L1-Cache oder etwas Vergleichbares passt. Ich denke, das ist einer der Gründe, warum es gut zu Systemen im DSP-Stil passt.
Exposed Pipelines sind ebenfalls eine interessante Eigenschaft mancher solcher Systeme. Wenn ein Befehl in einem VLIW-Bundle in ein Register schreibt, sehen nachfolgende Befehle, die dasselbe Register lesen, für die nächsten N Zyklen noch den alten Wert; erst danach wird der Schreibvorgang sichtbar. Von Hand zu programmieren ist das wirklich verwirrend, aber ein Compiler kann dieses Scheduling übernehmen.
Bis vor Kurzem waren DSP und HPC nur ein sehr kleiner Teil des Markts, weshalb Architekturen mit dynamischem Scheduling mehr Investitionen erhielten und sogar diese Märkte dominierten.
Bei GPUs ist die Lage natürlich anders, und tatsächlich haben GPUs stärker auf statisches Scheduling gesetzt. Da GPUs jedoch auf vielfältigere Workloads ausgeweitet werden, bekommen auch sie zunehmend dynamische Elemente.
https://news.ycombinator.com/context?id=37900987
Itanium war der wichtigste Versuch, das als CPU auf den Markt zu bringen. Heute dominieren AMD64 und ARM, aber vielleicht sehen wir so etwas in Zukunft wieder.