Quali ottimizzazioni ci si può aspettare da GHC in modo affidabile?


183

GHC ha molte ottimizzazioni che può eseguire, ma non so quali siano tutte, né quanto sia probabile che vengano eseguite e in quali circostanze.

La mia domanda è: quali trasformazioni posso aspettarmi che si applichi ogni volta, o quasi? Se guardo un pezzo di codice che verrà eseguito (valutato) frequentemente e il mio primo pensiero è "hmm, forse dovrei ottimizzarlo", nel qual caso il mio secondo pensiero dovrebbe essere "non pensarci nemmeno, GHC ha capito "?

Stavo leggendo l'articolo Stream Fusion: From Lists to Streams to Nothing at All , e la tecnica che usavano per riscrivere l'elaborazione delle liste in una forma diversa che le normali ottimizzazioni di GHC avrebbero poi ottimizzato in modo affidabile in semplici loop era una novità per me. Come posso sapere quando i miei programmi sono idonei per quel tipo di ottimizzazione?

Ci sono alcune informazioni nel manuale GHC, ma fa solo parte del modo di rispondere alla domanda.

EDIT: sto iniziando una taglia. Quello che vorrei è un elenco di trasformazioni di livello inferiore come lambda / let / case-floating, specializzazione dell'argomento tipo / costruttore / funzione, analisi della severità e unboxing, worker / wrapper e qualsiasi altra cosa significativa che GHC fa che ho lasciato fuori , insieme a spiegazioni ed esempi di codice di input e output, e idealmente illustrazioni di situazioni in cui l'effetto totale è superiore alla somma delle sue parti. E idealmente qualche menzione di quando le trasformazioni noaccadere. Non mi aspetto spiegazioni inedite di ogni trasformazione, un paio di frasi ed esempi di codice di una riga in linea potrebbero essere sufficienti (o un collegamento, se non si tratta di venti pagine di articoli scientifici), purché il quadro generale sia chiaro entro la fine. Voglio essere in grado di guardare un pezzo di codice ed essere in grado di fare una buona ipotesi sul fatto che si compili in un ciclo stretto, o perché no, o cosa dovrei cambiare per farlo. (Non sono molto interessato qui ai grandi framework di ottimizzazione come stream fusion (ho appena letto un articolo su questo); più sul tipo di conoscenza che le persone che scrivono questi framework hanno.)


10
Questa è una domanda meritevole. Scrivere una risposta degna è ... difficile.
MathematicalOrchid

1
Un ottimo punto di partenza è questo: aosabook.org/en/ghc.html
Gabriel Gonzalez,

7
In qualsiasi lingua, se il tuo primo pensiero è "forse dovrei ottimizzarlo", il tuo secondo pensiero dovrebbe essere "Lo profilerò per primo".
John L,

4
Mentre il tipo di conoscenza che stai cercando è utile e quindi questa è ancora una buona domanda, penso che tu sia davvero meglio servito cercando di fare il minor ottimizzazione possibile. Scrivete quello che vuoi dire, e solo quando diventa evidente che è necessario quindi pensare di fare il codice meno semplice per il bene di prestazioni. Invece di guardare il codice e pensare "che verrà eseguito frequentemente, forse dovrei ottimizzarlo", dovrebbe essere solo quando stai osservando il codice in esecuzione troppo lentamente che pensi "Dovrei scoprire cosa viene eseguito frequentemente e ottimizzarlo" .
Ben

14
Ho completamente anticipato che quella parte avrebbe suscitato le esortazioni a "profilarlo!" :). Ma immagino che l'altro lato della medaglia sia, se la modifico ed è lenta, forse posso riscriverla o semplicemente modificarla in una forma che è ancora di alto livello ma GHC può ottimizzare meglio, invece di ottimizzarla a mano da sola? Ciò richiede lo stesso tipo di conoscenza. E se avessi avuto quella conoscenza in primo luogo avrei potuto salvarmi un ciclo di modifica del profilo.
glaebhoerl,

Risposte:


110

Questa pagina GHC Trac spiega anche abbastanza bene i passaggi. Questa pagina spiega l'ordinamento dell'ottimizzazione, tuttavia, come la maggior parte del Wiki di Trac, non è aggiornato.

Per i dettagli, la cosa migliore da fare è probabilmente guardare come viene compilato un programma specifico. Il modo migliore per vedere quali ottimizzazioni vengono eseguite è compilare il programma verbalmente, usando il -vflag. Prendendo ad esempio il primo pezzo di Haskell che ho trovato sul mio computer:

Glasgow Haskell Compiler, Version 7.4.2, stage 2 booted by GHC version 7.4.1
Using binary package database: /usr/lib/ghc-7.4.2/package.conf.d/package.cache
wired-in package ghc-prim mapped to ghc-prim-0.2.0.0-7d3c2c69a5e8257a04b2c679c40e2fa7
wired-in package integer-gmp mapped to integer-gmp-0.4.0.0-af3a28fdc4138858e0c7c5ecc2a64f43
wired-in package base mapped to base-4.5.1.0-6e4c9bdc36eeb9121f27ccbbcb62e3f3
wired-in package rts mapped to builtin_rts
wired-in package template-haskell mapped to template-haskell-2.7.0.0-2bd128e15c2d50997ec26a1eaf8b23bf
wired-in package dph-seq not found.
wired-in package dph-par not found.
Hsc static flags: -static
*** Chasing dependencies:
Chasing modules from: *SleepSort.hs
Stable obj: [Main]
Stable BCO: []
Ready for upsweep
  [NONREC
      ModSummary {
         ms_hs_date = Tue Oct 18 22:22:11 CDT 2011
         ms_mod = main:Main,
         ms_textual_imps = [import (implicit) Prelude, import Control.Monad,
                            import Control.Concurrent, import System.Environment]
         ms_srcimps = []
      }]
*** Deleting temp files:
Deleting: 
compile: input file SleepSort.hs
Created temporary directory: /tmp/ghc4784_0
*** Checking old interface for main:Main:
[1 of 1] Compiling Main             ( SleepSort.hs, SleepSort.o )
*** Parser:
*** Renamer/typechecker:
*** Desugar:
Result size of Desugar (after optimization) = 79
*** Simplifier:
Result size of Simplifier iteration=1 = 87
Result size of Simplifier iteration=2 = 93
Result size of Simplifier iteration=3 = 83
Result size of Simplifier = 83
*** Specialise:
Result size of Specialise = 83
*** Float out(FOS {Lam = Just 0, Consts = True, PAPs = False}):
Result size of Float out(FOS {Lam = Just 0,
                              Consts = True,
                              PAPs = False}) = 95
*** Float inwards:
Result size of Float inwards = 95
*** Simplifier:
Result size of Simplifier iteration=1 = 253
Result size of Simplifier iteration=2 = 229
Result size of Simplifier = 229
*** Simplifier:
Result size of Simplifier iteration=1 = 218
Result size of Simplifier = 218
*** Simplifier:
Result size of Simplifier iteration=1 = 283
Result size of Simplifier iteration=2 = 226
Result size of Simplifier iteration=3 = 202
Result size of Simplifier = 202
*** Demand analysis:
Result size of Demand analysis = 202
*** Worker Wrapper binds:
Result size of Worker Wrapper binds = 202
*** Simplifier:
Result size of Simplifier = 202
*** Float out(FOS {Lam = Just 0, Consts = True, PAPs = True}):
Result size of Float out(FOS {Lam = Just 0,
                              Consts = True,
                              PAPs = True}) = 210
*** Common sub-expression:
Result size of Common sub-expression = 210
*** Float inwards:
Result size of Float inwards = 210
*** Liberate case:
Result size of Liberate case = 210
*** Simplifier:
Result size of Simplifier iteration=1 = 206
Result size of Simplifier = 206
*** SpecConstr:
Result size of SpecConstr = 206
*** Simplifier:
Result size of Simplifier = 206
*** Tidy Core:
Result size of Tidy Core = 206
writeBinIface: 4 Names
writeBinIface: 28 dict entries
*** CorePrep:
Result size of CorePrep = 224
*** Stg2Stg:
*** CodeGen:
*** CodeOutput:
*** Assembler:
'/usr/bin/gcc' '-fno-stack-protector' '-Wl,--hash-size=31' '-Wl,--reduce-memory-overheads' '-I.' '-c' '/tmp/ghc4784_0/ghc4784_0.s' '-o' 'SleepSort.o'
Upsweep completely successful.
*** Deleting temp files:
Deleting: /tmp/ghc4784_0/ghc4784_0.c /tmp/ghc4784_0/ghc4784_0.s
Warning: deleting non-existent /tmp/ghc4784_0/ghc4784_0.c
link: linkables are ...
LinkableM (Sat Sep 29 20:21:02 CDT 2012) main:Main
   [DotO SleepSort.o]
Linking SleepSort ...
*** C Compiler:
'/usr/bin/gcc' '-fno-stack-protector' '-Wl,--hash-size=31' '-Wl,--reduce-memory-overheads' '-c' '/tmp/ghc4784_0/ghc4784_0.c' '-o' '/tmp/ghc4784_0/ghc4784_0.o' '-DTABLES_NEXT_TO_CODE' '-I/usr/lib/ghc-7.4.2/include'
*** C Compiler:
'/usr/bin/gcc' '-fno-stack-protector' '-Wl,--hash-size=31' '-Wl,--reduce-memory-overheads' '-c' '/tmp/ghc4784_0/ghc4784_0.s' '-o' '/tmp/ghc4784_0/ghc4784_1.o' '-DTABLES_NEXT_TO_CODE' '-I/usr/lib/ghc-7.4.2/include'
*** Linker:
'/usr/bin/gcc' '-fno-stack-protector' '-Wl,--hash-size=31' '-Wl,--reduce-memory-overheads' '-o' 'SleepSort' 'SleepSort.o' '-L/usr/lib/ghc-7.4.2/base-4.5.1.0' '-L/usr/lib/ghc-7.4.2/integer-gmp-0.4.0.0' '-L/usr/lib/ghc-7.4.2/ghc-prim-0.2.0.0' '-L/usr/lib/ghc-7.4.2' '/tmp/ghc4784_0/ghc4784_0.o' '/tmp/ghc4784_0/ghc4784_1.o' '-lHSbase-4.5.1.0' '-lHSinteger-gmp-0.4.0.0' '-lgmp' '-lHSghc-prim-0.2.0.0' '-lHSrts' '-lm' '-lrt' '-ldl' '-u' 'ghczmprim_GHCziTypes_Izh_static_info' '-u' 'ghczmprim_GHCziTypes_Czh_static_info' '-u' 'ghczmprim_GHCziTypes_Fzh_static_info' '-u' 'ghczmprim_GHCziTypes_Dzh_static_info' '-u' 'base_GHCziPtr_Ptr_static_info' '-u' 'base_GHCziWord_Wzh_static_info' '-u' 'base_GHCziInt_I8zh_static_info' '-u' 'base_GHCziInt_I16zh_static_info' '-u' 'base_GHCziInt_I32zh_static_info' '-u' 'base_GHCziInt_I64zh_static_info' '-u' 'base_GHCziWord_W8zh_static_info' '-u' 'base_GHCziWord_W16zh_static_info' '-u' 'base_GHCziWord_W32zh_static_info' '-u' 'base_GHCziWord_W64zh_static_info' '-u' 'base_GHCziStable_StablePtr_static_info' '-u' 'ghczmprim_GHCziTypes_Izh_con_info' '-u' 'ghczmprim_GHCziTypes_Czh_con_info' '-u' 'ghczmprim_GHCziTypes_Fzh_con_info' '-u' 'ghczmprim_GHCziTypes_Dzh_con_info' '-u' 'base_GHCziPtr_Ptr_con_info' '-u' 'base_GHCziPtr_FunPtr_con_info' '-u' 'base_GHCziStable_StablePtr_con_info' '-u' 'ghczmprim_GHCziTypes_False_closure' '-u' 'ghczmprim_GHCziTypes_True_closure' '-u' 'base_GHCziPack_unpackCString_closure' '-u' 'base_GHCziIOziException_stackOverflow_closure' '-u' 'base_GHCziIOziException_heapOverflow_closure' '-u' 'base_ControlziExceptionziBase_nonTermination_closure' '-u' 'base_GHCziIOziException_blockedIndefinitelyOnMVar_closure' '-u' 'base_GHCziIOziException_blockedIndefinitelyOnSTM_closure' '-u' 'base_ControlziExceptionziBase_nestedAtomically_closure' '-u' 'base_GHCziWeak_runFinalizzerBatch_closure' '-u' 'base_GHCziTopHandler_flushStdHandles_closure' '-u' 'base_GHCziTopHandler_runIO_closure' '-u' 'base_GHCziTopHandler_runNonIO_closure' '-u' 'base_GHCziConcziIO_ensureIOManagerIsRunning_closure' '-u' 'base_GHCziConcziSync_runSparks_closure' '-u' 'base_GHCziConcziSignal_runHandlers_closure'
link: done
*** Deleting temp files:
Deleting: /tmp/ghc4784_0/ghc4784_1.o /tmp/ghc4784_0/ghc4784_0.s /tmp/ghc4784_0/ghc4784_0.o /tmp/ghc4784_0/ghc4784_0.c
*** Deleting temp dirs:
Deleting: /tmp/ghc4784_0

Guardando dal primo *** Simplifier: all'ultimo, in cui avvengono tutte le fasi di ottimizzazione, vediamo molto.

Prima di tutto, il Simplificatore scorre tra quasi tutte le fasi. Questo rende la scrittura di molti passaggi molto più semplice. Ad esempio, quando implementano molte ottimizzazioni, creano semplicemente regole di riscrittura per propagare le modifiche invece di doverle fare manualmente. Il semplificatore comprende una serie di semplici ottimizzazioni, inclusi allineamento e fusione. La principale limitazione di ciò che conosco è che GHC rifiuta di incorporare funzioni ricorsive e che le cose devono essere denominate correttamente perché la fusione funzioni.

Successivamente, vediamo un elenco completo di tutte le ottimizzazioni eseguite:

  • Specializzarsi

    L'idea di base della specializzazione è quella di rimuovere il polimorfismo e il sovraccarico identificando i luoghi in cui viene chiamata la funzione e creando versioni della funzione che non sono polimorfiche: sono specifiche dei tipi con cui vengono chiamate. Puoi anche dire al compilatore di farlo con il SPECIALISEpragma. Ad esempio, prendi una funzione fattoriale:

    fac :: (Num a, Eq a) => a -> a
    fac 0 = 1
    fac n = n * fac (n - 1)

    Poiché il compilatore non conosce alcuna proprietà della moltiplicazione che deve essere utilizzata, non può affatto ottimizzarla. Se tuttavia, vede che viene utilizzato su un Int, ora può creare una nuova versione, differendo solo nel tipo:

    fac_Int :: Int -> Int
    fac_Int 0 = 1
    fac_Int n = n * fac_Int (n - 1)

    Successivamente, le regole menzionate di seguito possono essere attivate e si finisce con qualcosa che funziona su unboxed Int s, che è molto più veloce dell'originale. Un altro modo di considerare la specializzazione è l'applicazione parziale sui dizionari di classe di tipo e sulle variabili di tipo.

    La fonte qui contiene un sacco di note.

  • Galleggia fuori

    EDIT: apparentemente ho capito male prima. La mia spiegazione è completamente cambiata.

    L'idea di base è di spostare i calcoli che non dovrebbero essere ripetuti fuori dalle funzioni. Ad esempio, supponiamo di avere questo:

    \x -> let y = expensive in x+y

    Nella lambda sopra, ogni volta che viene chiamata la funzione, yviene ricalcolata. Una funzione migliore, che produce fluttuando, è

    let y = expensive in \x -> x+y

    Per facilitare il processo, possono essere applicate altre trasformazioni. Ad esempio, ciò accade:

     \x -> x + f 2
     \x -> x + let f_2 = f 2 in f_2
     \x -> let f_2 = f 2 in x + f_2
     let f_2 = f 2 in \x -> x + f_2

    Ancora una volta, il calcolo ripetuto viene salvato.

    La fonte è molto leggibile in questo caso.

    Al momento i collegamenti tra due lambda adiacenti non vengono fatti galleggiare. Ad esempio, ciò non accade:

    \x y -> let t = x+x in ...

    andando a

     \x -> let t = x+x in \y -> ...
  • Galleggiante verso l'interno

    Citando il codice sorgente,

    Lo scopo principale di floatInwardsè fluttuare nei rami di un caso, in modo da non allocare le cose, salvarle nello stack e quindi scoprire che non sono necessarie nel ramo scelto.

    Ad esempio, supponiamo di avere questa espressione:

    let x = big in
        case v of
            True -> x + 1
            False -> 0

    Se vvaluta False, quindi allocando x, che è presumibilmente un grosso colpo, abbiamo perso tempo e spazio. Floating verso l'interno risolve questo problema, producendo questo:

    case v of
        True -> let x = big in x + 1
        False -> let x = big in 0

    , che viene successivamente sostituito dal simplificatore con

    case v of
        True -> big + 1
        False -> 0

    Questo documento , pur trattando altri argomenti, offre un'introduzione abbastanza chiara. Nota che nonostante i loro nomi, fluttuare dentro e fluttuare fuori non entra in un ciclo infinito per due motivi:

    1. Il float in float fa entrare le caseistruzioni, mentre float out si occupa delle funzioni.
    2. C'è un ordine fisso di passaggi, quindi non dovrebbero alternarsi all'infinito.

  • Analisi della domanda

    L'analisi della domanda o l'analisi della rigorosità è meno una trasformazione e più, come suggerisce il nome, di un passaggio di raccolta di informazioni. Il compilatore trova funzioni che valutano sempre i loro argomenti (o almeno alcuni di essi) e passa tali argomenti usando call-by-value, anziché call-by-need. Dato che riesci a eludere le spese generali dei thunk, questo è spesso molto più veloce. Molti problemi di prestazioni in Haskell derivano da questo fallimento o dal fatto che il codice semplicemente non è abbastanza rigoroso. Un semplice esempio è la differenza tra l'utilizzofoldr , foldlefoldl'per riassumere un elenco di numeri interi: il primo causa lo straripamento dello stack, il secondo provoca lo straripamento dell'heap e l'ultimo viene eseguito correttamente, a causa della rigidità. Questo è probabilmente il più facile da capire e meglio documentato di tutti questi. Credo che il polimorfismo e il codice CPS spesso lo sconfiggano.

  • Il lavoratore si avvolge

    L'idea di base della trasformazione worker / wrapper è quella di fare un ciclo stretto su una struttura semplice, convertendola da e verso quella struttura alle estremità. Ad esempio, prendi questa funzione, che calcola il fattoriale di un numero.

    factorial :: Int -> Int
    factorial 0 = 1
    factorial n = n * factorial (n - 1)

    Usando la definizione di Intin GHC, abbiamo

    factorial :: Int -> Int
    factorial (I# 0#) = I# 1#
    factorial (I# n#) = I# (n# *# case factorial (I# (n# -# 1#)) of
        I# down# -> down#)

    Notate come il codice è coperto in I#s? Possiamo rimuoverli facendo questo:

    factorial :: Int -> Int
    factorial (I# n#) = I# (factorial# n#)
    
    factorial# :: Int# -> Int#
    factorial# 0# = 1#
    factorial# n# = n# *# factorial# (n# -# 1#)

    Sebbene questo esempio specifico possa essere stato fatto anche da SpecConstr, la trasformazione worker / wrapper è molto generale nelle cose che può fare.

  • Sottoespressione comune

    Questa è un'altra ottimizzazione davvero semplice che è molto efficace, come l'analisi di rigore. L'idea di base è che se hai due espressioni uguali, avranno lo stesso valore. Ad esempio, se fibè un calcolatore numerico di Fibonacci, CSE si trasformerà

    fib x + fib x

    in

    let fib_x = fib x in fib_x + fib_x

    che dimezza il calcolo. Sfortunatamente, questo può occasionalmente ostacolare altre ottimizzazioni. Un altro problema è che le due espressioni devono trovarsi nello stesso posto e devono essere sintatticamente uguali, non uguali per valore. Ad esempio, CSE non si attiverà nel seguente codice senza un sacco di inline:

    x = (1 + (2 + 3)) + ((1 + 2) + 3)
    y = f x
    z = g (f x) y

    Tuttavia, se esegui la compilazione tramite llvm, potresti ottenerne una parte, a causa del suo pass per la numerazione del valore globale.

  • Caso liberato

    Questa sembra essere una trasformazione terribilmente documentata, oltre al fatto che può causare l'esplosione del codice. Ecco una versione riformattata (e leggermente riscritta) della piccola documentazione che ho trovato:

    Questo modulo si avvicina Coree cerca casevariabili libere. Il criterio è: se esiste una casevariabile libera sulla rotta per la chiamata ricorsiva, la chiamata ricorsiva viene sostituita da una spiegazione. Ad esempio, in

    f = \ t -> case v of V a b -> a : f t

    l'interno fè sostituito. produrre

    f = \ t -> case v of V a b -> a : (letrec f = \ t -> case v of V a b -> a : f t in f) t

    Nota la necessità di ombreggiatura. Semplificando, otteniamo

    f = \ t -> case v of V a b -> a : (letrec f = \ t -> a : f t in f t)

    Questo è un codice migliore, perché aè gratuito all'interno letrec, piuttosto che necessita di proiezioni v. Si noti che si tratta di variabili libere , a differenza di SpecConstr, che si occupa di argomenti di forma nota.

    Vedi sotto per maggiori informazioni su SpecConstr.

  • SpecConstr - questo trasforma programmi come

    f (Left x) y = somthingComplicated1
    f (Right x) y = somethingComplicated2

    in

    f_Left x y = somethingComplicated1
    f_Right x y = somethingComplicated2
    
    {-# INLINE f #-}
    f (Left x) = f_Left x
    f (Right x) = f_Right x

    Come esempio esteso, prendi questa definizione di last:

    last [] = error "last: empty list"
    last (x:[]) = x
    last (x:x2:xs) = last (x2:xs)

    Per prima cosa lo trasformiamo in

    last_nil = error "last: empty list"
    last_cons x [] = x
    last_cons x (x2:xs) = last (x2:xs)
    
    {-# INLINE last #-}
    last [] = last_nil
    last (x : xs) = last_cons x xs

    Successivamente, il simplificatore funziona e abbiamo

    last_nil = error "last: empty list"
    last_cons x [] = x
    last_cons x (x2:xs) = last_cons x2 xs
    
    {-# INLINE last #-}
    last [] = last_nil
    last (x : xs) = last_cons x xs

    Si noti che il programma è ora più veloce, poiché non stiamo ripetutamente boxe e unboxing la parte anteriore dell'elenco. Si noti inoltre che il rivestimento è cruciale, in quanto consente di utilizzare effettivamente le nuove definizioni più efficienti, nonché di migliorare le definizioni ricorsive.

    SpecConstr è controllato da una serie di euristiche. Quelli menzionati nel documento sono come tali:

    1. I lambda sono espliciti e l'arità lo è a.
    2. Il lato destro è "sufficientemente piccolo", qualcosa controllato da una bandiera.
    3. La funzione è ricorsiva e la chiamata specializzabile viene utilizzata nella parte destra.
    4. Tutti gli argomenti della funzione sono presenti.
    5. Almeno uno degli argomenti è un'applicazione di costruzione.
    6. Tale argomento viene analizzato caso da qualche parte nella funzione.

    Tuttavia, l'euristica è quasi certamente cambiata. In effetti, l'articolo menziona un'alternativa sesta euristica:

    Specializzarsi su un argomento xsolo se xviene esaminato solo da a casee non viene passato a una funzione ordinaria o restituito come parte del risultato.

Questo era un file molto piccolo (12 righe) e quindi probabilmente non ha innescato molte ottimizzazioni (anche se penso che le abbia fatte tutte). Anche questo non ti dice perché ha raccolto quei passaggi e perché li ha messi in quell'ordine.


Ora stiamo arrivando da qualche parte! Commenti: sembra che tu abbia una frase di interruzione nella parte relativa a Specializza. Non vedo il punto del float-out: a cosa serve? Come decide se fluttuare dentro o fuori (perché non entra in un ciclo)? Ho avuto l'impressione che da qualche parte che non ha fatto GHC CSE a tutti , ma a quanto pare che sbagliavo. Mi sento come se mi stessi perdendo nei dettagli invece di vedere un quadro generale ... l'argomento è ancora più complicato di quanto pensassi. Forse la mia domanda è impossibile e non c'è proprio modo di ottenere questa intuizione se non una tonnellata di esperienza o lavorare su GHC da soli?
glaebhoerl,

Beh, non lo so, ma non ho mai lavorato su GHC, quindi devi essere in grado di ottenere un po 'di intuizione.
Gereeter,

Ho risolto i problemi che hai menzionato.
Gereeter,

1
Inoltre, per quanto riguarda il quadro generale, ritengo che non ce ne sia davvero uno. Quando voglio indovinare quali ottimizzazioni verranno eseguite, scendo una lista di controllo. Quindi lo faccio di nuovo, per vedere come ogni passaggio cambierà le cose. E di nuovo. In sostanza, suono il compilatore. L'unico schema di ottimizzazione che conosco che ha davvero un "quadro generale" è la supercompilazione.
Gereeter,

1
Cosa intendi con "le cose devono essere nominate correttamente perché la fusione funzioni" esattamente?
Vincent Beffara,

65

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 filterfunzione 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 sumfunzione viene compilata, il compilatore non può +incorporare la funzione, poiché sumfunziona 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 sumhard-coded per il Inttipo, il che significa che +può essere integrato in questa versione.

Nota, tuttavia, che la nostra nuova sumfunzione speciale verrà chiamata solo quando il compilatore può dire che stiamo lavorando Int. Altrimenti sumviene 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 mapapplicato map, lo schiaccia in un singolo passaggio sull'elenco, eliminando l'elenco intermedio.

Il problema è che funziona solo per mapseguito da map. Ci sono molte altre possibilità - mapseguite da filter, filterseguite 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 vectorbiblioteca ha ampie capacità di fusione. Riguarda le biblioteche; il compilatore fornisce solo il RULESpragma. (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 ...


9
it's something guaranteed by the language specification ... work is not performed until you "do something" with the result.- non esattamente. Le specifiche linguistiche promettono una semantica non rigorosa ; non promette nulla sull'esecuzione o meno di lavori superflui.
Dan Burton,

1
@DanBurton Certo. Ma non è molto facile da spiegare in poche frasi. Inoltre, poiché GHC è quasi l'unica implementazione Haskell esistente, il fatto che GHC sia pigro è abbastanza buono per la maggior parte delle persone.
MathematicalOrchid

@MathematicalOrchid: le valutazioni speculative sono un interessante controesempio, anche se sono d'accordo che probabilmente è troppo per un principiante.
Ben Millwood,

5
A proposito di CSE: La mia impressione è che non viene quasi mai fatto, perché può introdurre una condivisione indesiderata e quindi le perdite spaziali.
Joachim Breitner,

2
Ci scusiamo per (a) non rispondere prima e (b) non accettare la tua risposta. Che è lungo e impressionante, ma non copre il territorio che volevo. Quello che vorrei è un elenco di trasformazioni di livello inferiore come lambda / let / case-floating, specializzazione di argomenti di tipo / costruttore / funzione, analisi di rigidezza e unboxing (di cui parli), worker / wrapper e qualsiasi altra cosa GHC faccia, insieme con spiegazioni ed esempi di codice di input e output e idealmente esempi del loro effetto combinato e di quelli in cui le trasformazioni non avvengono. Immagino che dovrei fare una taglia?
glaebhoerl,

8

Se un vincolo let v = rhs viene utilizzato in un solo posto, puoi contare sul compilatore per incorporarlo, anche se rhs è grande.

L'eccezione (che quasi non rientra nel contesto della domanda attuale) è che la lambda rischia la duplicazione del lavoro. Tener conto di:

let v = rhs
    l = \x-> v + x
in map l [1..100]

in questo caso v sarebbe pericoloso perché l'uso (sintattico) si tradurrebbe in 99 valutazioni extra di rhs. Tuttavia, in questo caso, è molto improbabile che tu voglia incorporarlo manualmente. Quindi essenzialmente puoi usare la regola:

Se consideri di inserire un nome che appare solo una volta, il compilatore lo farà comunque.

Come corollario felice, usare un legame let semplicemente per scomporre una lunga dichiarazione (con la speranza di ottenere chiarezza) è essenzialmente gratuito.

Questo viene da community.haskell.org/~simonmar/papers/inline.pdf che include molte più informazioni sull'inline.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.