Quando si trova l'ultimo ma il secondo elemento di un elenco, perché usare `last` è il più veloce tra questi?


10

Di seguito sono riportate 3 funzioni che trovano l'ultimo ma il secondo elemento in un elenco. Quello che usa last . initsembra molto più veloce del resto. Non riesco a capire perché.

Per i test, ho usato un elenco di input di [1..100000000](100 milioni). L'ultimo funziona quasi istantaneamente mentre gli altri impiegano diversi secondi.

-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"

-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse

-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init

5
initè stato ottimizzato per evitare di "decomprimere" l'elenco più volte.
Willem Van Onsem,

1
@WillemVanOnsem ma perché è myButLastmolto più lento ?. Sembra che non stia decomprimendo alcun elenco, ma semplicemente attraversandolo come initfa la funzione ...
lsmor

1
@Ismor: [x, y]è l'abbreviazione di (x:(y:[])), quindi decomprime i contro esterni, un secondo contro e controlla se la coda del secondo consè []. Inoltre, la seconda clausola decomprimerà nuovamente l'elenco in (x:xs). Sì, il disimballaggio è ragionevolmente efficiente, ma ovviamente se accade molto spesso, questo rallenterà il processo.
Willem Van Onsem,

1
Guardando hackage.haskell.org/package/base-4.12.0.0/docs/src/… , l'ottimizzazione sembra essere che initnon controlla ripetutamente se il suo argomento è un elenco singleton o un elenco vuoto. Una volta avviata la ricorsione, si presume che il primo elemento verrà applicato al risultato della chiamata ricorsiva.
chepner,

2
@WillemVanOnsem Penso che il disimballaggio probabilmente non sia il problema qui: GHC fa la specializzazione del modello di chiamata che dovrebbe darti la versione ottimizzata di myButLastautomaticamente. Penso che sia più probabile la fusione di elenchi che è la causa dell'accelerazione.
oisdk,

Risposte:


9

Quando si studia la velocità e l'ottimizzazione, è molto facile ottenere risultati incredibilmente sbagliati . In particolare, non puoi davvero dire che una variante è più veloce di un'altra senza menzionare la versione del compilatore e la modalità di ottimizzazione della tua configurazione di benchmarking. Anche allora, i processori moderni sono così sofisticati da presentare predittori di filiali basati su reti neurali, per non parlare di tutti i tipi di cache, quindi, anche con un'attenta configurazione, i risultati del benchmarking saranno sfocati.

Detto ciò...

Il benchmarking è nostro amico.

criterionè un pacchetto che fornisce strumenti di benchmarking avanzati. Ho rapidamente redatto un benchmark come questo:

module Main where

import Criterion
import Criterion.Main

-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"

-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse

-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init

butLast2 :: [a] -> a
butLast2 (x :     _ : [ ] ) = x
butLast2 (_ : xs@(_ : _ ) ) = butLast2 xs
butLast2 _ = error "List too short"

setupEnv = do
  let xs = [1 .. 10^7] :: [Int]
  return xs

benches xs =
  [ bench "slow?"   $ nf myButLast   xs
  , bench "decent?" $ nf myButLast'  xs
  , bench "fast?"   $ nf myButLast'' xs
  , bench "match2"  $ nf butLast2    xs
  ]

main = defaultMain
    [ env setupEnv $ \ xs -> bgroup "main" $ let bs = benches xs in bs ++ reverse bs ]

Come vedi, ho aggiunto la variante che corrisponde esplicitamente a due elementi contemporaneamente, ma per il resto è lo stesso codice alla lettera. Gestisco anche i parametri di riferimento al contrario, in modo da essere consapevole del pregiudizio dovuto alla memorizzazione nella cache. Quindi, corriamo e vediamo!

% ghc --version
The Glorious Glasgow Haskell Compilation System, version 8.6.5


% ghc -O2 -package criterion A.hs && ./A
benchmarking main/slow?
time                 54.83 ms   (54.75 ms .. 54.90 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 54.86 ms   (54.82 ms .. 54.93 ms)
std dev              94.77 μs   (54.95 μs .. 146.6 μs)

benchmarking main/decent?
time                 794.3 ms   (32.56 ms .. 1.293 s)
                     0.907 R²   (0.689 R² .. 1.000 R²)
mean                 617.2 ms   (422.7 ms .. 744.8 ms)
std dev              201.3 ms   (105.5 ms .. 283.3 ms)
variance introduced by outliers: 73% (severely inflated)

benchmarking main/fast?
time                 84.60 ms   (84.37 ms .. 84.95 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 84.46 ms   (84.25 ms .. 84.77 ms)
std dev              435.1 μs   (239.0 μs .. 681.4 μs)

benchmarking main/match2
time                 54.87 ms   (54.81 ms .. 54.95 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 54.85 ms   (54.81 ms .. 54.92 ms)
std dev              104.9 μs   (57.03 μs .. 178.7 μs)

benchmarking main/match2
time                 50.60 ms   (47.17 ms .. 53.01 ms)
                     0.993 R²   (0.981 R² .. 0.999 R²)
mean                 60.74 ms   (56.57 ms .. 67.03 ms)
std dev              9.362 ms   (6.074 ms .. 10.95 ms)
variance introduced by outliers: 56% (severely inflated)

benchmarking main/fast?
time                 69.38 ms   (56.64 ms .. 78.73 ms)
                     0.948 R²   (0.835 R² .. 0.994 R²)
mean                 108.2 ms   (92.40 ms .. 129.5 ms)
std dev              30.75 ms   (19.08 ms .. 37.64 ms)
variance introduced by outliers: 76% (severely inflated)

benchmarking main/decent?
time                 770.8 ms   (345.9 ms .. 1.004 s)
                     0.967 R²   (0.894 R² .. 1.000 R²)
mean                 593.4 ms   (422.8 ms .. 691.4 ms)
std dev              167.0 ms   (50.32 ms .. 226.1 ms)
variance introduced by outliers: 72% (severely inflated)

benchmarking main/slow?
time                 54.87 ms   (54.77 ms .. 55.00 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 54.95 ms   (54.88 ms .. 55.10 ms)
std dev              185.3 μs   (54.54 μs .. 251.8 μs)

Sembra che la nostra versione "lenta" non sia affatto lenta! E la complessità del pattern matching non aggiunge nulla. (Una leggera accelerazione vediamo tra due sequenze consecutive di match2attribuire agli effetti della memorizzazione nella cache.)

C'è un modo per ottenere più dati "scientifici" : possiamo -ddump-simple dare un'occhiata al modo in cui il compilatore vede il nostro codice.

Il controllo delle strutture intermedie è nostro amico.

"Core" è un linguaggio interno di GHC. Ogni file sorgente Haskell viene semplificato in Core prima di essere trasformato nel grafico funzionale finale per l'esecuzione del sistema runtime. Se guardiamo a questo stadio intermedio, che ci dirà che myButLaste butLast2sono equivalenti. Ci vuole guardare, dal momento che, nella fase di ridenominazione, tutti i nostri simpatici identificatori sono casualmente distrutti.

% for i in `seq 1 4`; do echo; cat A$i.hs; ghc -O2 -ddump-simpl A$i.hs > A$i.simpl; done

module A1 where

-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"

module A2 where

-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse

module A3 where

-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init

module A4 where

butLast2 :: [a] -> a
butLast2 (x :     _ : [ ] ) = x
butLast2 (_ : xs@(_ : _ ) ) = butLast2 xs
butLast2 _ = error "List too short"

% ./EditDistance.hs *.simpl
(("A1.simpl","A2.simpl"),3866)
(("A1.simpl","A3.simpl"),3794)
(("A2.simpl","A3.simpl"),663)
(("A1.simpl","A4.simpl"),607)
(("A2.simpl","A4.simpl"),4188)
(("A3.simpl","A4.simpl"),4113)

Sembra che A1e A4siano i più simili. Un'attenta ispezione mostrerà che le strutture del codice sono identiche A1e A4identiche. Anche quello A2e A3sono simili è ragionevole poiché entrambi sono definiti come una composizione di due funzioni.

Se hai intenzione di esaminare coreampiamente l' output, ha senso fornire anche flag come -dsuppress-module-prefixese -dsuppress-uniques. Lo rendono molto più facile da leggere.

Un breve elenco anche dei nostri nemici.

Cosa può andare storto nel benchmarking e nell'ottimizzazione?

  • ghci, essendo progettato per il gioco interattivo e una rapida iterazione, compila il sorgente di Haskell con un certo tipo di codice byte, piuttosto che eseguibile finale, e evita costose ottimizzazioni a favore di un caricamento più rapido.
  • La profilazione sembra uno strumento utile per esaminare le prestazioni dei singoli bit di un programma complesso, ma può rovinare così tanto le ottimizzazioni del compilatore, i risultati saranno ordini di grandezza fuori base.
    • La tua protezione è quella di profilare ogni piccolo bit di codice come un eseguibile separato, con il suo runner di benchmark.
  • La raccolta dei rifiuti è sintonizzabile. Proprio oggi è stata rilasciata una nuova importante funzionalità. I ritardi per la garbage collection influiranno sulle prestazioni in modi che non sono semplici da prevedere.
  • Come ho già detto, diverse versioni del compilatore costruiranno codice diverso con prestazioni diverse, quindi devi sapere quale versione l' utente del tuo codice probabilmente utilizzerà per costruirlo e fare un benchmark con quello, prima di fare qualsiasi promessa.

Questo può sembrare triste. Ma non è proprio la cosa che dovrebbe riguardare un programmatore Haskell, il più delle volte. Storia vera: ho un amico che di recente ha iniziato a studiare Haskell. Avevano scritto un programma per l'integrazione numerica, e la tartaruga era lenta. Quindi ci siamo seduti insieme e abbiamo scritto una descrizione categorica dell'algoritmo, con diagrammi e cose. Quando hanno riscritto il codice per allinearlo con la descrizione astratta, è diventato magicamente, come, un ghepardo veloce e anche poco memoria. Abbiamo calcolato π in pochissimo tempo. Morale della storia? Perfetta struttura astratta e il tuo codice si ottimizzerà da solo.


Molto istruttivo, e anche un po 'travolgente per me in questa fase. In questo caso, tutto il "benchmarking" che ho fatto è stato eseguire tutte le funzioni per un elenco di 100 milioni di articoli e notare che uno impiega più tempo dell'altro. Il benchmark con criterio sembra piuttosto utile. Inoltre, ghcisembra dare risultati diversi (in termini di velocità) rispetto a fare prima una exe, come hai detto.
storm125
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.