Pigrizia
Non è una "ottimizzazione del compilatore", ma è qualcosa di garantito dalle specifiche del linguaggio, quindi puoi sempre contare sul fatto che accada. In sostanza, questo significa che il lavoro non viene eseguito fino a quando non "fai qualcosa" con il risultato. (A meno che tu non faccia una delle varie cose per disattivare deliberatamente la pigrizia.)
Questo, ovviamente, è un intero argomento a sé stante e SO ha già molte domande e risposte a riguardo.
Nella mia esperienza limitata, rendere il tuo codice troppo pigro o troppo severo ha delle penalità di prestazione molto più grandi (nel tempo e nello spazio) rispetto a qualsiasi altra roba di cui sto per parlare ...
Analisi di rigidità
La pigrizia consiste nell'evitare il lavoro a meno che non sia necessario. Se il compilatore può determinare che un dato risultato sarà "sempre" necessario, allora non si preoccuperà di archiviare il calcolo e di eseguirlo in seguito; lo eseguirà direttamente, perché è più efficiente. Questa è la cosiddetta "analisi di rigore".
Il gotcha, ovviamente, è che il compilatore non può sempre rilevare quando qualcosa potrebbe essere reso severo. A volte è necessario dare piccoli suggerimenti al compilatore. (Non sono a conoscenza di alcun modo semplice per determinare se l'analisi di rigore ha fatto ciò che pensi abbia, oltre a guadare attraverso l'output Core.)
inlining
Se chiamate una funzione e il compilatore può dire quale funzione chiamate, potrebbe provare a "incorporare" quella funzione, ovvero a sostituire la chiamata di funzione con una copia della funzione stessa. Il sovraccarico di una chiamata di funzione è di solito piuttosto piccolo, ma spesso l'allineamento consente altre ottimizzazioni che altrimenti non sarebbero avvenute, quindi l'allineamento può essere una grande vittoria.
Le funzioni sono evidenziate solo se sono "abbastanza piccole" (o se aggiungi un pragma che chiede specificatamente di essere allineato). Inoltre, le funzioni possono essere integrate solo se il compilatore può dire quale funzione stai chiamando. Esistono due modi principali che il compilatore potrebbe non essere in grado di dire:
Se la funzione che stai chiamando viene passata da qualche altra parte. Ad esempio, quando la filter
funzione viene compilata, non è possibile incorporare il predicato del filtro, poiché è un argomento fornito dall'utente.
Se la funzione che stai chiamando è un metodo di classe e il compilatore non sa quale tipo è coinvolto. Ad esempio, quando la sum
funzione viene compilata, il compilatore non può +
incorporare la funzione, poiché sum
funziona con diversi tipi di numeri, ognuno dei quali ha una +
funzione diversa .
In quest'ultimo caso, è possibile utilizzare il {-# SPECIALIZE #-}
pragma per generare versioni di una funzione che sono codificate in base al tipo specifico. Ad esempio, {-# SPECIALIZE sum :: [Int] -> Int #-}
compilerebbe una versione di sum
hard-coded per il Int
tipo, il che significa che +
può essere integrato in questa versione.
Nota, tuttavia, che la nostra nuova sum
funzione speciale verrà chiamata solo quando il compilatore può dire che stiamo lavorando Int
. Altrimenti sum
viene chiamato l'originale, polimorfico . Ancora una volta, l'overhead della chiamata alla funzione effettiva è piuttosto piccolo. Sono le ulteriori ottimizzazioni che l'inline può abilitare e che sono vantaggiose.
Eliminazione della sottoespressione comune
Se un determinato blocco di codice calcola lo stesso valore due volte, il compilatore può sostituirlo con una singola istanza dello stesso calcolo. Ad esempio, se lo fai
(sum xs + 1) / (sum xs + 2)
quindi il compilatore potrebbe ottimizzare questo a
let s = sum xs in (s+1)/(s+2)
Potresti aspettarti che il compilatore lo faccia sempre . Tuttavia, apparentemente in alcune situazioni ciò può comportare prestazioni peggiori, non migliori, quindi GHC non lo fa sempre . Francamente, non capisco davvero i dettagli dietro questo. Ma la linea di fondo è, se questa trasformazione è importante per te, non è difficile farlo manualmente. (E se non è importante, perché ti preoccupi?)
Espressioni
Considera quanto segue:
foo (0:_ ) = "zero"
foo (1:_ ) = "one"
foo (_:xs) = foo xs
foo ( []) = "end"
Le prime tre equazioni controllano tutte se l'elenco non è vuoto (tra le altre cose). Ma controllare la stessa cosa tre volte è dispendioso. Fortunatamente, è molto facile per il compilatore ottimizzare questo in diverse espressioni nidificate. In questo caso, qualcosa del genere
foo xs =
case xs of
y:ys ->
case y of
0 -> "zero"
1 -> "one"
_ -> foo ys
[] -> "end"
Questo è piuttosto meno intuitivo, ma più efficiente. Poiché il compilatore può facilmente eseguire questa trasformazione, non devi preoccuparti. Scrivi il tuo pattern matching nel modo più intuitivo possibile; il compilatore è molto bravo a riordinare e riorganizzare questo per renderlo il più veloce possibile.
Fusione
Il linguaggio standard di Haskell per l'elaborazione di elenchi è quello di concatenare funzioni che prendono un elenco e producono un nuovo elenco. L'esempio canonico è
map g . map f
Sfortunatamente, mentre la pigrizia garantisce di saltare il lavoro non necessario, tutte le allocazioni e le deallocazioni per l'elenco intermedio possono ridurre le prestazioni. "Fusion" o "deforestazione" è il punto in cui il compilatore tenta di eliminare questi passaggi intermedi.
Il problema è che la maggior parte di queste funzioni sono ricorsive. Senza la ricorsione, sarebbe un esercizio elementare nell'integrare schiacciare tutte le funzioni in un unico grande blocco di codice, far passare il simulatore su di esso e produrre un codice davvero ottimale senza liste intermedie. Ma a causa della ricorsione, non funzionerà.
Puoi usare i {-# RULE #-}
pragmi per risolvere alcuni di questi. Per esempio,
{-# RULES "map/map" forall f g xs. map f (map g xs) = map (f.g) xs #-}
Ora, ogni volta che GHC viene map
applicato map
, lo schiaccia in un singolo passaggio sull'elenco, eliminando l'elenco intermedio.
Il problema è che funziona solo per map
seguito da map
. Ci sono molte altre possibilità - map
seguite da filter
, filter
seguite da map
, ecc. Invece di scrivere a mano una soluzione per ognuna di esse, è stata inventata la cosiddetta "fusione del flusso". Questo è un trucco più complicato, che non descriverò qui.
Il lungo e breve è: questi sono tutti trucchi di ottimizzazione speciali scritti dal programmatore . GHC stesso non sa nulla della fusione; è tutto nell'elenco librerie e altre librerie contenitore. Quindi quali ottimizzazioni avvengono dipendono dal modo in cui sono scritte le librerie del contenitore (o, più realisticamente, quali librerie scegli di utilizzare).
Ad esempio, se si lavora con array Haskell '98, non aspettarsi alcuna fusione di alcun tipo. Ma capisco che la vector
biblioteca ha ampie capacità di fusione. Riguarda le biblioteche; il compilatore fornisce solo il RULES
pragma. (Il che è estremamente potente, a proposito. Come autore di librerie, puoi usarlo per riscrivere il codice client!)
Meta:
Sono d'accordo con le persone che dicono "codice prima, profilo secondo, ottimizzazione terzo".
Concordo anche con le persone che affermano che "è utile avere un modello mentale per quanto costa una determinata decisione di progettazione".
Equilibrio in tutte le cose, e tutto ciò che ...