Haskell (mit dem GHC
Compiler) 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 .
Antworten:
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
SELECT
Aussage 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
union
vonstruct
s. 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 ...
quelle
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 .
quelle
Hier gibt es viel zu kommentieren. Ich werde versuchen, so viel wie möglich zu beantworten.
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.
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.
Das ist keine gute Möglichkeit, darüber nachzudenken, zumal moderne Prozessoren Anweisungen nicht in der richtigen Reihenfolge und möglicherweise gleichzeitig bewerten.
Eigentlich Haskell ist implizit eine Auswertungsreihenfolge definieren.
Sie entsprechen in vielen Fällen, sofern Sie über einen ausreichend fortgeschrittenen Compiler verfügen.
Haskell wird kompiliert, sodass Funktionen höherer Ordnung nicht im laufenden Betrieb erstellt werden.
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]
undf = pure
sind zum Beispiel in Haskell genau dasselbe. Ein guter Compiler würde im ersteren Fall keine bessere Leistung bringen.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 .
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.
quelle
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.
Haben Sie Hinweise auf überprüfbare Ergebnisse, bei denen jemals jemand in die Nähe gekommen ist?
quelle
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.