GHC hat viele Optimierungen, die es durchführen kann, aber ich weiß nicht, was sie alle sind, wie wahrscheinlich und unter welchen Umständen sie durchgeführt werden sollen.
Meine Frage ist: Welche Transformationen kann ich erwarten, dass sie jedes Mal oder fast jedes Mal angewendet werden? Wenn ich mir einen Code ansehe, der häufig ausgeführt (ausgewertet) wird, und mein erster Gedanke lautet "hmm, vielleicht sollte ich das optimieren". In diesem Fall sollte mein zweiter Gedanke lauten: "Denken Sie nicht einmal darüber nach." GHC hat das "?
Ich las die Zeitung Stream Fusion: Von Listen über Streams bis hin zu gar nichts , und die Technik, mit der die Listenverarbeitung in eine andere Form umgeschrieben wurde, die die normalen Optimierungen von GHC dann zuverlässig in einfache Schleifen umwandeln würden, war für mich neu. Wie kann ich feststellen, wann meine eigenen Programme für diese Art der Optimierung in Frage kommen?
Das GHC-Handbuch enthält einige Informationen , die jedoch nur einen Teil zur Beantwortung der Frage beitragen.
EDIT: Ich beginne ein Kopfgeld. Was ich möchte, ist eine Liste von Transformationen auf niedrigerer Ebene wie Lambda / Let / Case-Floating, Spezialisierung auf Typ- / Konstruktor- / Funktionsargumente, Strenge-Analyse und Unboxing, Worker / Wrapper und alles andere, was GHC ausgelassen hat zusammen mit Erklärungen und Beispielen für Eingabe- und Ausgabecode sowie idealerweise Darstellungen von Situationen, in denen der Gesamteffekt mehr als die Summe seiner Teile ist. Und im Idealfall wird erwähnt, wann Transformationen nicht möglich sindgeschehen. Ich erwarte keine neuartigen Erklärungen für jede Transformation, ein paar Sätze und einzeilige Inline-Codebeispiele könnten ausreichen (oder ein Link, wenn es sich nicht um zwanzig Seiten wissenschaftlicher Arbeit handelt), solange das Gesamtbild stimmt klar am Ende davon. Ich möchte in der Lage sein, einen Code zu betrachten und eine gute Vermutung darüber anzustellen, ob er zu einer engen Schleife kompiliert wird oder warum nicht oder was ich ändern müsste, um ihn zu erstellen. (Ich interessiere mich hier nicht so sehr für die großen Optimierungs-Frameworks wie Stream Fusion (ich habe gerade einen Artikel darüber gelesen); mehr für die Art von Wissen, über das Leute, die diese Frameworks schreiben, verfügen.)
quelle
Antworten:
Diese GHC Trac-Seite erklärt auch die Pässe ziemlich gut. Diese Seite erklärt die Reihenfolge der Optimierung, ist jedoch wie der Großteil des Trac-Wikis veraltet.
Für Einzelheiten ist es wahrscheinlich am besten, sich anzusehen, wie ein bestimmtes Programm kompiliert wird. Der beste Weg, um zu sehen, welche Optimierungen durchgeführt werden, besteht darin, das Programm mithilfe des
-v
Flags ausführlich zu kompilieren . Am Beispiel des ersten Stücks Haskell, das ich auf meinem Computer finden konnte:Wenn
*** Simplifier:
wir vom ersten bis zum letzten Blick auf alle Optimierungsphasen schauen, sehen wir ziemlich viel.Zunächst läuft der Simplifier zwischen fast allen Phasen. Dies erleichtert das Schreiben vieler Durchgänge erheblich. Wenn Sie beispielsweise viele Optimierungen implementieren, erstellen sie einfach Umschreiberegeln, um die Änderungen zu verbreiten, anstatt sie manuell ausführen zu müssen. Der Vereinfacher umfasst eine Reihe einfacher Optimierungen, einschließlich Inlining und Fusion. Die Hauptbeschränkung, die ich kenne, ist, dass GHC sich weigert, rekursive Funktionen zu integrieren, und dass die Dinge korrekt benannt werden müssen, damit die Fusion funktioniert.
Als nächstes sehen wir eine vollständige Liste aller durchgeführten Optimierungen:
Spezialisieren
Die Grundidee der Spezialisierung besteht darin, Polymorphismus und Überladung zu beseitigen, indem Orte identifiziert werden, an denen die Funktion aufgerufen wird, und Versionen der Funktion erstellt werden, die nicht polymorph sind - sie sind spezifisch für die Typen, mit denen sie aufgerufen werden. Sie können den Compiler auch anweisen, dies mit dem zu tun
SPECIALISE
Pragma . Nehmen Sie als Beispiel eine Fakultätsfunktion:Da der Compiler keine Eigenschaften der zu verwendenden Multiplikation kennt, kann er diese überhaupt nicht optimieren. Wenn es jedoch sieht, dass es auf einem verwendet wird
Int
, kann es jetzt eine neue Version erstellen, die sich nur im Typ unterscheidet:Als nächstes können die unten genannten Regeln ausgelöst werden, und Sie erhalten am Ende etwas, das an Unboxed arbeitet
Int
s arbeitet, was viel schneller als das Original ist. Eine andere Möglichkeit, die Spezialisierung zu betrachten, ist die teilweise Anwendung auf Typklassenwörterbücher und Typvariablen.Die Quelle hier enthält eine Menge Notizen.
Schweben Sie raus
EDIT: Ich habe das anscheinend schon einmal falsch verstanden. Meine Erklärung hat sich komplett geändert.
Die Grundidee dabei ist, Berechnungen, die nicht wiederholt werden sollen, aus Funktionen zu verschieben. Angenommen, wir hatten Folgendes:
Im obigen Lambda wird jedes Mal, wenn die Funktion aufgerufen wird, neu
y
berechnet. Eine bessere Funktion, die das Herausschwimmen erzeugt, istUm den Prozess zu erleichtern, können andere Transformationen angewendet werden. Zum Beispiel passiert dies:
Wiederum wird eine wiederholte Berechnung gespeichert.
Die Quelle ist in diesem Fall sehr gut lesbar.
Im Moment sind Bindungen zwischen zwei benachbarten Lambdas nicht schwebend. Dies ist beispielsweise nicht der Fall:
gehe zu
Nach innen schweben
Zitieren des Quellcodes,
Der Hauptzweck von
floatInwards
besteht darin, in Zweige eines Falls zu schweben, damit wir keine Dinge zuordnen, sie auf dem Stapel speichern und dann feststellen, dass sie in dem ausgewählten Zweig nicht benötigt werden.Nehmen wir als Beispiel an, wir hätten diesen Ausdruck:
Wenn dies
v
ausgewertet wird, haben wirFalse
durch Zuweisungx
, was vermutlich ein großer Nachteil ist, Zeit und Raum verschwendet. Das Schweben nach innen behebt dies und erzeugt Folgendes:, die anschließend durch den Vereinfacher mit ersetzt wird
Obwohl dieses Papier andere Themen abdeckt, gibt es eine ziemlich klare Einführung. Beachten Sie, dass das Ein- und Ausschwimmen trotz ihrer Namen aus zwei Gründen nicht in eine Endlosschleife gerät:
case
Anweisungen ein, während Float Out Funktionen behandelt.Bedarfsanalyse
Eine Nachfrageanalyse oder Strenge-Analyse ist weniger eine Transformation als vielmehr, wie der Name schon sagt, ein Informationserfassungspass. Der Compiler findet Funktionen, die ihre Argumente (oder zumindest einige von ihnen) immer auswerten, und übergibt diese Argumente mithilfe von Call-by-Value anstelle von Call-by-Need. Da Sie den Overheads von Thunks ausweichen können, ist dies oft viel schneller. Viele Leistungsprobleme in Haskell entstehen entweder dadurch, dass dieser Durchgang fehlschlägt oder der Code einfach nicht streng genug ist. Ein einfaches Beispiel ist der Unterschied zwischen der Verwendung von
foldr
,foldl
undfoldl'
Um eine Liste von Ganzzahlen zusammenzufassen: Die erste verursacht einen Stapelüberlauf, die zweite einen Heap-Überlauf und die letzte läuft aufgrund der Strenge einwandfrei. Dies ist wahrscheinlich die am einfachsten zu verstehende und am besten dokumentierte. Ich glaube, dass Polymorphismus und CPS-Code dies oft zunichte machen.Worker Wrapper bindet
Die Grundidee der Worker / Wrapper-Transformation besteht darin, eine enge Schleife für eine einfache Struktur zu erstellen und diese an den Enden zu und von dieser Struktur zu konvertieren. Nehmen Sie zum Beispiel diese Funktion, die die Fakultät einer Zahl berechnet.
Mit der Definition von
Int
in GHC haben wirBeachten Sie, wie der Code in
I#
s abgedeckt ist ? Wir können sie folgendermaßen entfernen:Obwohl dieses spezielle Beispiel auch von SpecConstr hätte erstellt werden können, ist die Worker / Wrapper-Transformation in Bezug auf die möglichen Funktionen sehr allgemein.
Gemeinsamer Unterausdruck
Dies ist eine weitere wirklich einfache Optimierung, die sehr effektiv ist, wie die Strenge-Analyse. Die Grundidee ist, dass wenn Sie zwei Ausdrücke haben, die gleich sind, sie den gleichen Wert haben. Wenn es sich beispielsweise
fib
um einen Fibonacci-Zahlenrechner handelt, wird CSE transformiertin
das halbiert die Berechnung. Leider kann dies gelegentlich anderen Optimierungen im Wege stehen. Ein weiteres Problem besteht darin, dass sich die beiden Ausdrücke an derselben Stelle befinden müssen und dass sie syntaktisch identisch und nicht wertmäßig identisch sein müssen. Zum Beispiel wird CSE im folgenden Code nicht ohne ein paar Inlining ausgelöst:
Wenn Sie jedoch über llvm kompilieren, erhalten Sie möglicherweise einen Teil davon kombiniert, da die globale Wertnummerierung erfolgreich ist.
Fall befreien
Dies scheint eine schrecklich dokumentierte Transformation zu sein, abgesehen von der Tatsache, dass sie eine Codeexplosion verursachen kann. Hier ist eine neu formatierte (und leicht umgeschriebene) Version der kleinen Dokumentation, die ich gefunden habe:
Dieses Modul geht hinüber
Core
und sucht nachcase
freien Variablen. Das Kriterium lautet: Wenncase
auf der Route zum rekursiven Aufruf eine freie Variable vorhanden ist , wird der rekursive Aufruf durch eine Entfaltung ersetzt. Zum Beispiel indas Innere
f
wird ersetzt. zu machenBeachten Sie die Notwendigkeit der Abschattung. Vereinfachend bekommen wir
Dies ist ein besserer Code, da er
a
im Inneren freiletrec
ist und keine Projektion von benötigtv
. Beachten Sie, dass dies im Gegensatz zu SpecConstr, bei dem Argumente bekannter Form vorliegen, freie Variablen betrifft .Weitere Informationen zu SpecConstr finden Sie weiter unten.
SpecConstr - dies transformiert Programme wie
in
Nehmen Sie als erweitertes Beispiel diese Definition von
last
:Wir transformieren es zuerst zu
Als nächstes läuft der Vereinfacher, und wir haben
Beachten Sie, dass das Programm jetzt schneller ist, da wir die Vorderseite der Liste nicht wiederholt ein- und auspacken. Beachten Sie auch, dass das Inlining von entscheidender Bedeutung ist, da es die tatsächliche Verwendung der neuen, effizienteren Definitionen ermöglicht und die rekursiven Definitionen verbessert.
SpecConstr wird durch eine Reihe von Heuristiken gesteuert. Die in der Zeitung erwähnten sind als solche:
a
.Die Heuristiken haben sich jedoch mit ziemlicher Sicherheit geändert. In der Tat erwähnt das Papier eine alternative sechste Heuristik:
Spezialisieren Sie sich
x
nur dann auf ein Argument , wennx
es nur von a geprüftcase
und nicht an eine normale Funktion übergeben oder als Teil des Ergebnisses zurückgegeben wird.Dies war eine sehr kleine Datei (12 Zeilen) und hat möglicherweise nicht so viele Optimierungen ausgelöst (obwohl ich denke, dass sie alle durchgeführt wurden). Dies sagt Ihnen auch nicht, warum diese Pässe ausgewählt wurden und warum sie in diese Reihenfolge gebracht wurden.
quelle
Faulheit
Es ist keine "Compiler-Optimierung", aber es wird durch die Sprachspezifikation garantiert, sodass Sie immer darauf zählen können, dass dies geschieht. Im Wesentlichen bedeutet dies, dass die Arbeit erst ausgeführt wird, wenn Sie mit dem Ergebnis "etwas tun". (Es sei denn, Sie tun eines von mehreren Dingen, um Faulheit absichtlich auszuschalten.)
Dies ist offensichtlich ein ganzes Thema für sich, und SO hat bereits viele Fragen und Antworten dazu.
Nach meiner begrenzten Erfahrung hat es zu weitaus größeren Leistungseinbußen (in Bezug auf Zeit und Raum), wenn Sie Ihren Code zu faul oder zu streng machen, als bei allen anderen Dingen, über die ich gleich sprechen werde ...
Strenge Analyse
Bei Faulheit geht es darum, Arbeit zu vermeiden, es sei denn, dies ist notwendig. Wenn der Compiler feststellen kann, dass ein bestimmtes Ergebnis "immer" benötigt wird, muss er die Berechnung nicht speichern und später ausführen. es wird es nur direkt ausführen, weil das effizienter ist. Dies ist eine sogenannte "Strenge-Analyse".
Das Problem ist natürlich, dass der Compiler nicht immer erkennen kann , wann etwas streng gemacht werden könnte. Manchmal müssen Sie dem Compiler kleine Hinweise geben. (Mir ist kein einfacher Weg bekannt, um festzustellen, ob die Strenge-Analyse das getan hat, was Sie denken, außer durch die Core-Ausgabe zu waten.)
Inlining
Wenn Sie eine Funktion aufrufen und der Compiler erkennen kann, welche Funktion Sie aufrufen, versucht er möglicherweise, diese Funktion zu "inline", dh den Funktionsaufruf durch eine Kopie der Funktion selbst zu ersetzen. Der Aufwand für einen Funktionsaufruf ist normalerweise recht gering, aber durch Inlining können häufig andere Optimierungen vorgenommen werden, die sonst nicht möglich gewesen wären, sodass Inlining ein großer Gewinn sein kann.
Funktionen werden nur inline gesetzt, wenn sie "klein genug" sind (oder wenn Sie ein Pragma hinzufügen, das speziell nach Inlining fragt). Außerdem können Funktionen nur eingebunden werden, wenn der Compiler erkennen kann, welche Funktion Sie aufrufen. Es gibt zwei Möglichkeiten, die der Compiler möglicherweise nicht erkennen kann:
Wenn die von Ihnen aufgerufene Funktion von einem anderen Ort übergeben wird. Wenn die
filter
Funktion kompiliert wird, können Sie das Filterprädikat beispielsweise nicht inline setzen, da es sich um ein vom Benutzer angegebenes Argument handelt.Wenn die aufgerufene Funktion eine Klassenmethode ist und der Compiler nicht weiß, um welchen Typ es sich handelt. Wenn die
sum
Funktion kompiliert wird, kann der Compiler die+
Funktion beispielsweise nicht einbinden , da ersum
mit mehreren verschiedenen Nummerntypen arbeitet, von denen jeder eine andere+
Funktion hat.Im letzteren Fall können Sie das
{-# SPECIALIZE #-}
Pragma verwenden, um Versionen einer Funktion zu generieren, die für einen bestimmten Typ fest codiert sind. ZB{-# SPECIALIZE sum :: [Int] -> Int #-}
würde eine Version vonsum
fest codiert für denInt
Typ kompilieren , was bedeutet, dass+
in dieser Version eingefügt werden kann.Beachten Sie jedoch, dass unsere neue Spezialfunktion
sum
nur aufgerufen wird, wenn der Compiler erkennen kann, dass wir arbeitenInt
. Andernfalls wird das ursprüngliche polymorphesum
aufgerufen. Auch hier ist der tatsächliche Funktionsaufrufaufwand ziemlich gering. Es sind die zusätzlichen Optimierungen, die Inlining ermöglichen kann, die von Vorteil sind.Häufige Eliminierung von Subexpressionen
Wenn ein bestimmter Codeblock denselben Wert zweimal berechnet, kann der Compiler diesen durch eine einzelne Instanz derselben Berechnung ersetzen. Zum Beispiel, wenn Sie dies tun
dann könnte der Compiler dies optimieren
Sie können erwarten, dass der Compiler dies immer tut. In einigen Situationen kann dies jedoch zu einer schlechteren und nicht zu einer besseren Leistung führen, sodass GHC dies nicht immer tut. Ehrlich gesagt verstehe ich die Details dahinter nicht wirklich. Aber unter dem Strich ist es nicht schwer, diese Transformation manuell durchzuführen, wenn sie für Sie wichtig ist. (Und wenn es nicht wichtig ist, warum machst du dir dann Sorgen?)
Fallausdrücke
Folgendes berücksichtigen:
Die ersten drei Gleichungen prüfen alle, ob die Liste (unter anderem) nicht leer ist. Aber das Gleiche dreimal zu überprüfen, ist verschwenderisch. Glücklicherweise ist es für den Compiler sehr einfach, dies in mehrere verschachtelte Fallausdrücke zu optimieren. In diesem Fall so etwas wie
Dies ist weniger intuitiv, aber effizienter. Da der Compiler diese Umwandlung problemlos durchführen kann, müssen Sie sich darüber keine Sorgen machen. Schreiben Sie einfach Ihren Mustervergleich auf die intuitivste Art und Weise. Der Compiler ist sehr gut darin, dies neu zu ordnen und neu anzuordnen, um es so schnell wie möglich zu machen.
Verschmelzung
Die Standard-Haskell-Sprache für die Listenverarbeitung besteht darin, Funktionen zu verketten, die eine Liste enthalten und eine neue Liste erstellen. Das kanonische Beispiel ist
Während Faulheit garantiert, dass unnötige Arbeit übersprungen wird, sind leider alle Zuweisungen und Freigaben für die Zwischenlisten-Sap-Leistung. Bei "Fusion" oder "Entwaldung" versucht der Compiler, diese Zwischenschritte zu eliminieren.
Das Problem ist, dass die meisten dieser Funktionen rekursiv sind. Ohne die Rekursion wäre es eine elementare Übung beim Inlining, alle Funktionen in einen großen Codeblock zu zerlegen, den Vereinfacher darüber auszuführen und wirklich optimalen Code ohne Zwischenlisten zu erzeugen. Aber wegen der Rekursion wird das nicht funktionieren.
Sie können
{-# RULE #-}
Pragmas verwenden, um einige dieser Probleme zu beheben. Beispielsweise,Jedes Mal, wenn GHC eine
map
Anwendung siehtmap
, wird sie in einem einzigen Durchgang über die Liste gequetscht, wodurch die Zwischenliste entfernt wird.Das Problem ist, dies funktioniert nur für
map
gefolgt vonmap
. Es gibt viele andere Möglichkeiten -map
gefolgt vonfilter
,filter
gefolgt vonmap
usw. Anstatt für jede eine Lösung von Hand zu codieren, wurde die sogenannte "Stromfusion" erfunden. Dies ist ein komplizierterer Trick, den ich hier nicht beschreiben werde.Das lange und kurze daran ist: Dies sind alles spezielle Optimierungstricks, die vom Programmierer geschrieben wurden . GHC selbst weiß nichts über Fusion; Es ist alles in den Listenbibliotheken und anderen Containerbibliotheken. Welche Optimierungen stattfinden, hängt also davon ab, wie Ihre Container-Bibliotheken geschrieben sind (oder realistischer davon, welche Bibliotheken Sie verwenden).
Wenn Sie beispielsweise mit Haskell '98 -Arrays arbeiten, erwarten Sie keinerlei Fusion. Ich verstehe jedoch, dass die
vector
Bibliothek über umfangreiche Fusionsfunktionen verfügt. Es geht nur um die Bibliotheken; Der Compiler liefert nur dasRULES
Pragma. (Das ist übrigens extrem mächtig. Als Bibliotheksautor können Sie damit Client-Code umschreiben!)Meta:
Ich stimme den Leuten zu, die sagen "Code zuerst, Profil zweitens, drittens optimieren".
Ich stimme auch den Leuten zu, die sagen: "Es ist nützlich, ein mentales Modell für die Kosten einer bestimmten Designentscheidung zu haben."
Balance in allen Dingen und all dem ...
quelle
it's something guaranteed by the language specification ... work is not performed until you "do something" with the result.
- nicht genau. Die Sprachspezifikation verspricht eine nicht strenge Semantik ; es verspricht nichts darüber, ob überflüssige Arbeiten ausgeführt werden oder nicht.Wenn eine let-Bindung v = rhs nur an einer Stelle verwendet wird, können Sie sich darauf verlassen, dass der Compiler sie einbindet, auch wenn rhs groß ist.
Die Ausnahme (die im Kontext der aktuellen Frage fast keine ist) besteht darin, dass Lambdas das Risiko einer Doppelarbeit eingehen. Erwägen:
Dort wäre das Inlining von v gefährlich, da die eine (syntaktische) Verwendung zu 99 zusätzlichen Auswertungen von rhs führen würde. In diesem Fall ist es jedoch sehr unwahrscheinlich, dass Sie es auch manuell einbinden möchten. Im Wesentlichen können Sie also die Regel verwenden:
Wenn Sie einen Namen einfügen möchten, der nur einmal vorkommt, wird der Compiler dies trotzdem tun.
Als glückliche Folge ist die Verwendung einer Let-Bindung, um eine lange Aussage einfach zu zerlegen (mit der Hoffnung, Klarheit zu gewinnen), im Wesentlichen kostenlos.
Dies kommt von community.haskell.org/~simonmar/papers/inline.pdf, die viel mehr Informationen über Inlining enthält.
quelle