Warum ist Haskell (GHC) so verdammt schnell?


246

Haskell (mit dem GHCCompiler) ist viel schneller als erwartet . Bei richtiger Verwendung kann es den Sprachen auf niedriger Ebene nahe kommen. (Eine Lieblingsbeschäftigung von Haskellers ist es, zu versuchen, innerhalb von 5% von C zu kommen (oder es sogar zu übertreffen, aber das bedeutet, dass Sie ein ineffizientes C-Programm verwenden, da GHC Haskell zu C kompiliert).) Meine Frage ist, warum?

Haskell ist deklarativ und basiert auf Lambda-Kalkül. Maschinenarchitekturen sind eindeutig unabdingbar und basieren grob auf Turingmaschinen. In der Tat hat Haskell nicht einmal eine bestimmte Bewertungsreihenfolge. Anstatt sich mit Maschinendatentypen zu befassen, erstellen Sie ständig algebraische Datentypen.

Am seltsamsten sind jedoch Funktionen höherer Ordnung. Sie würden denken, dass das Erstellen von Funktionen im laufenden Betrieb und das Herumwerfen ein Programm langsamer machen würde. Die Verwendung von Funktionen höherer Ordnung macht Haskell jedoch schneller. Um Haskell-Code zu optimieren, müssen Sie ihn anscheinend eleganter und abstrakter gestalten, anstatt maschinenähnlicher. Keine der erweiterten Funktionen von Haskell scheint die Leistung zu beeinträchtigen , wenn sie nicht verbessert werden.

Es tut mir leid, wenn dies eine Garantie ist, aber hier ist meine Frage: Warum ist Haskell (kompiliert mit GHC) angesichts seiner abstrakten Natur und der Unterschiede zu physischen Maschinen so schnell?

Hinweis: Der Grund, warum ich sage, dass C und andere imperative Sprachen Turing Machines etwas ähnlich sind (aber nicht in dem Maße, wie Haskell Lambda Calculus ähnelt), ist, dass Sie in einer imperativen Sprache eine endliche Anzahl von Zuständen haben (auch bekannt als Zeilennummer). zusammen mit einem Band (dem RAM), sodass der Status und das aktuelle Band bestimmen, was mit dem Band geschehen soll. Informationen zum Übergang von Turingmaschinen zu Computern finden Sie im Wikipedia-Eintrag Turing-Maschinenäquivalente .


27
"da GHC Haskell zu C kompiliert" - das tut es nicht. GHC hat mehrere Backends. Der älteste (aber nicht der Standard) ist ein C-Generator. Es generiert zwar Cmm-Code für IR, aber das ist nicht "Kompilieren nach C", wie Sie es normalerweise erwarten würden. ( downloads.haskell.org/~ghc/latest/docs/html/users_guide/… )
Viraptor

19
Ich empfehle dringend, die Implementierung funktionaler Programmiersprachen von Simon Payton Jones (dem Hauptimplementierer von GHC) zu lesen, um viele Ihrer Fragen zu beantworten.
Joe Hillenbrand

94
Warum? 25 Jahre harte Arbeit.
August

31
"Auch wenn es eine sachliche Antwort darauf gibt, wird es nichts anderes tun, als Meinungen einzuholen." - Dies ist der schlechteste Grund, eine Frage zu schließen. Weil es vielleicht eine gute Antwort hat, aber möglicherweise auch minderwertige anzieht. Yuck! Ich habe zufällig eine gute, historische und sachliche Antwort auf die akademische Forschung und wann bestimmte Entwicklungen stattfanden. Aber ich kann es nicht posten, weil die Leute besorgt sind, dass diese Frage auch minderwertige Antworten finden könnte. Wieder yuck.
sclv

7
@cimmanon Ich müsste einen Monat oder mehrere Blog - Posts durch die Grundlagen gehen die Details , wie eine funktionale Compiler funktioniert. Ich brauche nur eine SO-Antwort, um in groben Zügen zu skizzieren, wie eine
Grafikmaschine

Antworten:


264

Ich stimme Dietrich Epp zu: Es ist eine Kombination mehrerer Dinge , die GHC schnell machen.

In erster Linie ist Haskell sehr hochrangig. Auf diese Weise kann der Compiler aggressive Optimierungen durchführen, ohne den Code zu beschädigen .

Denken Sie an SQL. Wenn ich jetzt eine SELECTAussage schreibe , sieht sie vielleicht wie eine Imperativschleife aus, ist es aber nicht . Es sieht möglicherweise so aus, als würde es alle Zeilen in dieser Tabelle durchlaufen, um diejenige zu finden, die den angegebenen Bedingungen entspricht, aber tatsächlich könnte der "Compiler" (die DB-Engine) stattdessen eine Indexsuche durchführen - die völlig andere Leistungsmerkmale aufweist. Aber weil SQL so hoch ist, kann der "Compiler" völlig unterschiedliche Algorithmen ersetzen, mehrere Prozessoren oder E / A-Kanäle oder ganze Server transparent anwenden und vieles mehr.

Ich denke, Haskell ist dasselbe. Sie könnten denken, Sie haben Haskell gerade gebeten, die Eingabeliste einer zweiten Liste zuzuordnen, die zweite Liste in eine dritte Liste zu filtern und dann zu zählen, wie viele Elemente sich ergeben haben. Aber Sie haben nicht gesehen, dass GHC hinter den Kulissen Regeln für das Umschreiben von Stream-Fusion angewendet hat, die das Ganze in eine einzige enge Maschinencodeschleife verwandeln, die den gesamten Job in einem einzigen Durchgang über die Daten ohne Zuordnung erledigt - so etwas würde es tun mühsam, fehleranfällig und nicht wartbar sein, um von Hand zu schreiben. Das ist nur wirklich möglich, weil der Code keine Details auf niedriger Ebene enthält.

Eine andere Sichtweise könnte sein: Warum sollte Haskell nicht schnell sein? Was macht es, das es langsam machen sollte?

Es ist keine interpretierte Sprache wie Perl oder JavaScript. Es ist nicht einmal ein virtuelles Maschinensystem wie Java oder C #. Es wird bis zum nativen Maschinencode kompiliert, sodass dort kein Overhead entsteht.

Im Gegensatz zu OO-Sprachen [Java, C #, JavaScript…] verfügt Haskell über eine vollständige Löschung [wie C, C ++, Pascal…]. Alle Typprüfungen finden nur zur Kompilierungszeit statt. Es gibt also auch keine Laufzeit-Typprüfung, die Sie verlangsamt. (Für diese Angelegenheit gibt es keine Nullzeigerprüfungen. In Java muss die JVM beispielsweise nach Nullzeigern suchen und eine Ausnahme auslösen, wenn Sie eine zurückstellen. Haskell muss sich nicht um diese Prüfung kümmern.)

Sie sagen, es klingt langsam, "Funktionen zur Laufzeit im laufenden Betrieb zu erstellen", aber wenn Sie genau hinschauen, tun Sie das nicht wirklich. Es könnte so aussehen wie Sie, aber Sie tun es nicht. Wenn Sie sagen (+5), ist das in Ihrem Quellcode fest codiert. Es kann sich zur Laufzeit nicht ändern. Es ist also keine wirklich dynamische Funktion. Sogar Curry-Funktionen speichern wirklich nur Parameter in einem Datenblock. Der gesamte ausführbare Code ist zur Kompilierungszeit tatsächlich vorhanden. Es gibt keine Laufzeitinterpretation. (Im Gegensatz zu einigen anderen Sprachen, die eine "Bewertungsfunktion" haben.)

Denken Sie an Pascal. Es ist alt und niemand benutzt es wirklich mehr, aber niemand würde sich beschweren, dass Pascal langsam ist . Es gibt viele Dinge, die man nicht mögen kann, aber Langsamkeit ist nicht wirklich eine davon. Haskell macht nicht wirklich so viel, was sich von Pascal unterscheidet, abgesehen von einer Speicherbereinigung anstelle einer manuellen Speicherverwaltung. Unveränderliche Daten ermöglichen mehrere Optimierungen der GC-Engine [was eine verzögerte Auswertung dann etwas erschwert].

Ich denke, die Sache ist, dass Haskell fortschrittlich und raffiniert und auf hohem Niveau aussieht , und jeder denkt: "Oh wow, das ist wirklich mächtig, es muss erstaunlich langsam sein! " Aber das ist es nicht. Zumindest ist es nicht so, wie Sie es erwarten würden. Ja, es hat ein erstaunliches Typensystem. Aber weißt du was? Das alles passiert zur Kompilierungszeit. Zur Laufzeit ist es weg. Ja, Sie können damit komplizierte ADTs mit einer Codezeile erstellen. Aber weißt du was? Ein ADT ist nur ein einfaches gewöhnliches C unionvon structs. Nichts mehr.

Der wahre Killer ist die faule Bewertung. Wenn Sie die Strenge / Faulheit Ihres Codes richtig eingestellt haben, können Sie dumm schnellen Code schreiben, der immer noch elegant und schön ist. Aber wenn Sie dieses Zeug falsch verstehen, läuft Ihr Programm tausende Male langsamer , und es ist wirklich nicht offensichtlich, warum dies geschieht.

Zum Beispiel habe ich ein triviales kleines Programm geschrieben, um zu zählen, wie oft jedes Byte in einer Datei erscheint. Bei einer 25-KB-Eingabedatei dauerte die Ausführung des Programms 20 Minuten und es wurden 6 Gigabyte RAM verschluckt ! Das ist absurd!! Aber dann wurde mir klar, was das Problem war, ich fügte ein einzelnes Knallmuster hinzu und die Laufzeit sank auf 0,02 Sekunden .

Hier geht Haskell unerwartet langsam. Und es dauert sicher eine Weile, bis man sich daran gewöhnt hat. Mit der Zeit wird es jedoch einfacher, sehr schnellen Code zu schreiben.

Was macht Haskell so schnell? Reinheit. Statische Typen. Faulheit. Vor allem aber muss der Compiler die Implementierung radikal ändern, ohne die Erwartungen Ihres Codes zu verletzen.

Aber ich denke, das ist nur meine Meinung ...


13
@ Cimmanon Ich denke nicht, dass es rein meinungsbasiert ist. Es ist eine interessante Frage, auf die andere wahrscheinlich eine Antwort haben wollten. Aber ich denke, wir werden sehen, was andere Wähler denken.
MathematicalOrchid

8
@cimmanon - diese Suche ergibt nur eineinhalb Threads, und alle haben mit Überprüfungsprüfungen zu tun. und die optimierte Antwort auf den Thread lautet: "Bitte hör auf, Dinge zu moderieren, die du nicht verstehst." Ich würde vorschlagen, wenn jemand der Meinung ist, dass die Antwort darauf notwendigerweise zu weit gefasst ist, würde er von der Antwort überrascht sein und sie genießen, da die Antwort nicht zu weit gefasst ist.
sclv

34
"In Java muss die JVM beispielsweise nach Nullzeigern suchen und eine Ausnahme auslösen, wenn Sie einen respektieren." Die implizite Nullprüfung von Java ist (meistens) kostenlos. Java-Implementierungen können und nutzen den virtuellen Speicher, um die Nulladresse einer fehlenden Seite zuzuordnen. Wenn Sie also einen Nullzeiger dereferenzieren, wird ein Seitenfehler auf CPU-Ebene ausgelöst, den Java als Ausnahme auf hoher Ebene abfängt und auslöst. Daher werden die meisten Nullprüfungen von der Speicherzuordnungseinheit in der CPU kostenlos durchgeführt.
Boann

4
@cimmanon: Vielleicht liegt das daran, dass Haskell-Benutzer die einzige Community zu sein scheinen, die tatsächlich eine freundliche Gruppe von aufgeschlossenen Menschen ist… was Sie als „Witz“ betrachten…, anstatt einer Dog-Eat-Dog-Community von herrschenden Nazis Zerreißen Sie sich bei jeder Gelegenheit einen neuen… was Sie für „normal“ halten.
Evi1M4chine

14
@MathematicalOrchid: Haben Sie eine Kopie Ihres Originalprogramms, deren Ausführung 20 Minuten gedauert hat? Ich denke, es wäre ziemlich lehrreich zu studieren, warum es so langsam ist.
George

79

Lange Zeit dachte man, funktionale Sprachen könnten nicht schnell sein - und besonders faule funktionale Sprachen. Dies lag jedoch daran, dass ihre frühen Implementierungen im Wesentlichen interpretiert und nicht wirklich kompiliert wurden.

Eine zweite Welle von Designs entstand auf der Grundlage der Grafikreduzierung und eröffnete die Möglichkeit einer wesentlich effizienteren Kompilierung. Simon Peyton Jones schrieb über diese Forschung in seinen beiden Büchern Die Implementierung funktionaler Programmiersprachen und die Implementierung funktionaler Sprachen: ein Tutorial (das erste mit Abschnitten von Wadler und Hancock und das zweite mit David Lester). (Lennart Augustsson informierte mich auch darüber, dass eine Hauptmotivation für das frühere Buch darin bestand, die Art und Weise zu beschreiben, wie sein LML-Compiler, der nicht ausführlich kommentiert wurde, seine Kompilierung durchführte).

Der Schlüsselbegriff hinter Graphenreduktionsansätzen, wie sie in diesen Arbeiten beschrieben werden, ist, dass wir ein Programm nicht als eine Folge von Anweisungen betrachten, sondern als einen Abhängigkeitsgraphen, der durch eine Reihe lokaler Reduktionen bewertet wird . Die zweite wichtige Erkenntnis ist, dass die Bewertung eines solchen Diagramms nicht interpretiert werden muss, sondern dass das Diagramm selbst aus Code erstellt werden kann . Insbesondere können wir einen Knoten eines Graphen nicht als "entweder einen Wert oder einen 'Opcode' und die zu bearbeitenden Werte" darstellen, sondern als eine Funktion, die beim Aufrufen den gewünschten Wert zurückgibt. Beim ersten Aufruf werden die Unterknoten nach ihren Werten gefragt und anschließend verarbeitet. Anschließend wird sie mit einer neuen Anweisung überschrieben, die nur "Ergebnis zurückgeben" lautet.

Dies wird in einem späteren Artikel beschrieben, in dem die Grundlagen für die Funktionsweise von GHC bis heute erläutert werden (obwohl viele verschiedene Verbesserungen modulo sind): "Implementierung fauler Funktionssprachen auf Standardhardware: Die spineless Tagless G-Machine." . Das aktuelle Ausführungsmodell für GHC ist im GHC-Wiki ausführlicher dokumentiert .

Die Erkenntnis ist also, dass die strikte Unterscheidung von "Daten" und "Code", die wir als "grundlegend" für die Funktionsweise von Maschinen betrachten, nicht die Art und Weise ist, wie sie funktionieren müssen, sondern von unseren Compilern auferlegt wird. Wir können das also rauswerfen und Code (einen Compiler) haben, der selbstmodifizierenden Code (die ausführbare Datei) generiert, und alles kann ganz gut funktionieren.

Es stellt sich also heraus, dass Maschinenarchitekturen zwar in gewissem Sinne unerlässlich sind, Sprachen jedoch auf sehr überraschende Weise zugeordnet werden können, die nicht wie eine herkömmliche Flusssteuerung im C-Stil aussehen, und wenn wir der Meinung sind, dass dies niedrig genug ist, kann dies auch der Fall sein effizient.

Darüber hinaus eröffnen sich durch die Reinheit viele weitere Optimierungen, da sie einen größeren Bereich "sicherer" Transformationen ermöglichen. Wann und wie diese Transformationen so angewendet werden, dass sie die Dinge verbessern und nicht verschlechtern, ist natürlich eine empirische Frage, und bei dieser und vielen anderen kleinen Entscheidungen wurden jahrelange Arbeiten sowohl in die theoretische Arbeit als auch in das praktische Benchmarking gesteckt. Das spielt natürlich auch eine Rolle. Ein Artikel, der ein gutes Beispiel für diese Art von Forschung darstellt, ist " Ein schnelles Curry machen: Push / Enter vs. Eval / Bewerben für Sprachen höherer Ordnung".

Schließlich ist anzumerken, dass dieses Modell aufgrund von Indirektionen immer noch einen Overhead verursacht. Dies kann in Fällen vermieden werden, in denen wir wissen, dass es "sicher" ist, Dinge streng zu tun und so Graph-Indirektionen zu vermeiden. Die Mechanismen, die auf Strenge / Forderung schließen lassen, werden im GHC-Wiki erneut ausführlich dokumentiert .


2
Dieser Demand Analyzer Link ist Gold wert! Endlich etwas über das Thema, das sich nicht so verhält, als wäre es im Grunde unerklärliche schwarze Magie. Wie habe ich noch nie davon gehört? Es sollte von überall her verlinkt werden, wo jemand fragt, wie man die Probleme mit Faulheit angeht!
Evi1M4chine

@ Evi1M4chine Ich sehe keinen Link zu einem Bedarfsanalysator, vielleicht ist er irgendwie verloren gegangen. Kann jemand den Link wiederherstellen oder den Verweis klären? Das klingt ziemlich interessant.
Cris P

1
@CrisP Ich glaube, der letzte Link ist der, auf den verwiesen wird. Es wird eine Seite im GHC-Wiki über den Bedarfsanalysator in GHC aufgerufen.
Serp C

@ Serpentine Cougar, Chris P: Ja, das habe ich gemeint.
Evi1M4chine

19

Hier gibt es viel zu kommentieren. Ich werde versuchen, so viel wie möglich zu beantworten.

Bei richtiger Verwendung kann es den Sprachen auf niedriger Ebene nahe kommen.

Nach meiner Erfahrung ist es in vielen Fällen normalerweise möglich, die doppelte Leistung von Rust zu erreichen. Es gibt jedoch auch einige (breite) Anwendungsfälle, in denen die Leistung im Vergleich zu Sprachen auf niedriger Ebene schlecht ist.

oder sogar schlagen, aber das bedeutet, dass Sie ein ineffizientes C-Programm verwenden, da GHC Haskell zu C kompiliert.

Das ist nicht ganz richtig. Haskell kompiliert zu C-- (eine Teilmenge von C), das dann über den nativen Codegenerator zur Assembly kompiliert wird. Der native Codegenerator generiert normalerweise schnelleren Code als der C-Compiler, da er einige Optimierungen anwenden kann, die ein gewöhnlicher C-Compiler nicht kann.

Maschinenarchitekturen sind eindeutig unabdingbar und basieren grob auf Turingmaschinen.

Das ist keine gute Möglichkeit, darüber nachzudenken, zumal moderne Prozessoren Anweisungen nicht in der richtigen Reihenfolge und möglicherweise gleichzeitig bewerten.

In der Tat hat Haskell nicht einmal eine bestimmte Bewertungsreihenfolge.

Eigentlich Haskell ist implizit eine Auswertungsreihenfolge definieren.

Anstatt sich mit Maschinendatentypen zu befassen, erstellen Sie ständig algebraische Datentypen.

Sie entsprechen in vielen Fällen, sofern Sie über einen ausreichend fortgeschrittenen Compiler verfügen.

Sie würden denken, dass das Erstellen von Funktionen im laufenden Betrieb und das Herumwerfen ein Programm langsamer machen würde.

Haskell wird kompiliert, sodass Funktionen höherer Ordnung nicht im laufenden Betrieb erstellt werden.

Es scheint den Haskell-Code zu optimieren. Sie müssen ihn eleganter und abstrakter gestalten, anstatt maschinenähnlicher.

Im Allgemeinen ist es unproduktiv, Code "maschinenähnlicher" zu machen, um eine bessere Leistung in Haskell zu erzielen. Aber es abstrakter zu machen ist auch nicht immer eine gute Idee. Was ist eine gute Idee, gemeinsame Datenstrukturen und Funktionen , die stark optimiert (wie verkettete Listen) wurden.

f x = [x]und f = puresind zum Beispiel in Haskell genau dasselbe. Ein guter Compiler würde im ersteren Fall keine bessere Leistung bringen.

Warum ist Haskell (kompiliert mit GHC) angesichts seiner abstrakten Natur und der Unterschiede zu physischen Maschinen so schnell?

Die kurze Antwort lautet: "Weil es genau dafür entwickelt wurde." GHC verwendet die spineless tagless g-machine (STG). Sie können ein Papier darüber lesen Sie hier (es ist ziemlich komplex). GHC macht auch viele andere Dinge, wie die Analyse der Strenge und die optimistische Bewertung .

Der Grund, warum ich sage, dass C und andere imperative Sprachen Turing Machines etwas ähnlich sind (aber nicht in dem Maße, wie Haskell Lambda Calculus ähnelt), ist, dass Sie in einer imperativen Sprache eine endliche Anzahl von Zuständen (auch bekannt als Zeilennummer) haben mit einem Band (dem RAM), so dass der Status und das aktuelle Band bestimmen, was mit dem Band geschehen soll.

Ist der Punkt der Verwirrung dann, dass Veränderlichkeit zu langsamerem Code führen sollte? Die Faulheit von Haskell bedeutet tatsächlich, dass die Veränderlichkeit nicht so wichtig ist, wie Sie denken. Außerdem ist sie auf hohem Niveau, sodass der Compiler viele Optimierungen anwenden kann. Daher ist das Ändern eines Datensatzes an Ort und Stelle selten langsamer als in einer Sprache wie C.


3

Warum ist Haskell (GHC) so verdammt schnell?

Etwas muss sich dramatisch geändert haben, seit ich Haskells Leistung das letzte Mal gemessen habe. Beispielsweise:

Was hat sich also geändert? Ich stelle fest, dass sich weder die Frage noch eine ihrer aktuellen Antworten auf überprüfbare Benchmarks oder sogar Code beziehen.

Eine Lieblingsbeschäftigung von Haskellers ist es, zu versuchen, innerhalb von 5% von C zu kommen

Haben Sie Hinweise auf überprüfbare Ergebnisse, bei denen jemals jemand in die Nähe gekommen ist?


6
Hat jemand Harrops Namen noch dreimal vor einem Spiegel gesagt?
Chuck Adams

2
nicht 10x, aber immer noch, dieser ganze Eintrag ist Hype und Kutteln Marketing. GHC ist in der Tat durchaus in der Lage, C nahe zu kommen oder es manchmal sogar zu überwinden, was die Geschwindigkeit betrifft, aber dies erfordert normalerweise einen ziemlich komplizierten Programmierstil auf niedriger Ebene, der sich nicht wesentlich von der Programmierung in C selbst unterscheidet. Unglücklicherweise. Je höher der Code, desto langsamer ist er normalerweise. Platzlecks, bequeme, aber unterdurchschnittliche ADT-Typen ( algebraisch , nicht abstrakt , wie versprochen) usw. usw.
Will Ness

1
Ich poste dies nur, weil ich es heute gesehen habe chrispenner.ca/posts/wc . Es ist eine Implementierung des in Haskell geschriebenen Dienstprogramms wc, das angeblich die c-Version übertrifft.
Garnison

3
@ Garnison danke für den Link . 80 Zeilen nannte ich "Low-Level-Programmierstil, der sich nicht wesentlich von der Programmierung in C selbst unterscheidet". . "der höhere Code", das wäre der "dumme" fmap (length &&& length . words &&& length . lines) readFile. Wenn die schneller war als (oder sogar vergleichbar) C, hier der Hype völlig gerechtfertigt wäre dann . Wir müssen in Haskell noch hart an Geschwindigkeit arbeiten, wie in C, ist der Punkt.
Will Ness

2
Nach dieser Diskussion auf Reddit reddit.com/r/programming/comments/dj4if3/… zu urteilen , ist der Haskell-Code wirklich fehlerhaft (z. B. Zeilenumbrüche beginnen oder enden mit Leerzeichen, Unterbrechungen auf à) und andere können die behaupteten Leistungsergebnisse nicht reproduzieren.
Jon Harrop
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.