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 er sum
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 von sum
fest codiert für den Int
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 arbeiten Int
. Andernfalls wird das ursprüngliche polymorphe sum
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
(sum xs + 1) / (sum xs + 2)
dann könnte der Compiler dies optimieren
let s = sum xs in (s+1)/(s+2)
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:
foo (0:_ ) = "zero"
foo (1:_ ) = "one"
foo (_:xs) = foo xs
foo ( []) = "end"
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
foo xs =
case xs of
y:ys ->
case y of
0 -> "zero"
1 -> "one"
_ -> foo ys
[] -> "end"
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
map g . map f
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,
{-# RULES "map/map" forall f g xs. map f (map g xs) = map (f.g) xs #-}
Jedes Mal, wenn GHC eine map
Anwendung sieht map
, 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 von map
. Es gibt viele andere Möglichkeiten - map
gefolgt von filter
, filter
gefolgt von map
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 das RULES
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 ...