Perché questo codice Haskell viene eseguito più lentamente con -O?


87

Questo pezzo di codice Haskell viene eseguito molto più lentamente -O, ma non -Odovrebbe essere pericoloso . Qualcuno può dirmi cosa è successo? Se è importante, è un tentativo di risolvere questo problema e utilizza la ricerca binaria e l'albero dei segmenti persistenti:

import Control.Monad
import Data.Array

data Node =
      Leaf   Int           -- value
    | Branch Int Node Node -- sum, left child, right child
type NodeArray = Array Int Node

-- create an empty node with range [l, r)
create :: Int -> Int -> Node
create l r
    | l + 1 == r = Leaf 0
    | otherwise  = Branch 0 (create l m) (create m r)
    where m = (l + r) `div` 2

-- Get the sum in range [0, r). The range of the node is [nl, nr)
sumof :: Node -> Int -> Int -> Int -> Int
sumof (Leaf val) r nl nr
    | nr <= r   = val
    | otherwise = 0
sumof (Branch sum lc rc) r nl nr
    | nr <= r   = sum
    | r  > nl   = (sumof lc r nl m) + (sumof rc r m nr)
    | otherwise = 0
    where m = (nl + nr) `div` 2

-- Increase the value at x by 1. The range of the node is [nl, nr)
increase :: Node -> Int -> Int -> Int -> Node
increase (Leaf val) x nl nr = Leaf (val + 1)
increase (Branch sum lc rc) x nl nr
    | x < m     = Branch (sum + 1) (increase lc x nl m) rc
    | otherwise = Branch (sum + 1) lc (increase rc x m nr)
    where m = (nl + nr) `div` 2

-- signature said it all
tonodes :: Int -> [Int] -> [Node]
tonodes n = reverse . tonodes' . reverse
    where
        tonodes' :: [Int] -> [Node]
        tonodes' (h:t) = increase h' h 0 n : s' where s'@(h':_) = tonodes' t
        tonodes' _ = [create 0 n]

-- find the minimum m in [l, r] such that (predicate m) is True
binarysearch :: (Int -> Bool) -> Int -> Int -> Int
binarysearch predicate l r
    | l == r      = r
    | predicate m = binarysearch predicate l m
    | otherwise   = binarysearch predicate (m+1) r
    where m = (l + r) `div` 2

-- main, literally
main :: IO ()
main = do
    [n, m] <- fmap (map read . words) getLine
    nodes <- fmap (listArray (0, n) . tonodes n . map (subtract 1) . map read . words) getLine
    replicateM_ m $ query n nodes
    where
        query :: Int -> NodeArray -> IO ()
        query n nodes = do
            [p, k] <- fmap (map read . words) getLine
            print $ binarysearch (ok nodes n p k) 0 n
            where
                ok :: NodeArray -> Int -> Int -> Int -> Int -> Bool
                ok nodes n p k s = (sumof (nodes ! min (p + s + 1) n) s 0 n) - (sumof (nodes ! max (p - s) 0) s 0 n) >= k

(Questo è esattamente lo stesso codice con la revisione del codice, ma questa domanda risolve un altro problema.)

Questo è il mio generatore di input in C ++:

#include <cstdio>
#include <cstdlib>
using namespace std;
int main (int argc, char * argv[]) {
    srand(1827);
    int n = 100000;
    if(argc > 1)
        sscanf(argv[1], "%d", &n);
    printf("%d %d\n", n, n);
    for(int i = 0; i < n; i++)
        printf("%d%c", rand() % n + 1, i == n - 1 ? '\n' : ' ');
    for(int i = 0; i < n; i++) {
        int p = rand() % n;
        int k = rand() % n + 1;
        printf("%d %d\n", p, k);
    }
}

Nel caso in cui non si disponga di un compilatore C ++ disponibile, questo è il risultato di./gen.exe 1000 .

Questo è il risultato dell'esecuzione sul mio computer:

$ ghc --version
The Glorious Glasgow Haskell Compilation System, version 7.8.3
$ ghc -fforce-recomp 1827.hs
[1 of 1] Compiling Main             ( 1827.hs, 1827.o )
Linking 1827.exe ...
$ time ./gen.exe 1000 | ./1827.exe > /dev/null
real    0m0.088s
user    0m0.015s
sys     0m0.015s
$ ghc -fforce-recomp -O 1827.hs
[1 of 1] Compiling Main             ( 1827.hs, 1827.o )
Linking 1827.exe ...
$ time ./gen.exe 1000 | ./1827.exe > /dev/null
real    0m2.969s
user    0m0.000s
sys     0m0.045s

E questo è il riepilogo del profilo dell'heap:

$ ghc -fforce-recomp -rtsopts ./1827.hs
[1 of 1] Compiling Main             ( 1827.hs, 1827.o )
Linking 1827.exe ...
$ ./gen.exe 1000 | ./1827.exe +RTS -s > /dev/null
      70,207,096 bytes allocated in the heap
       2,112,416 bytes copied during GC
         613,368 bytes maximum residency (3 sample(s))
          28,816 bytes maximum slop
               3 MB total memory in use (0 MB lost due to fragmentation)
                                    Tot time (elapsed)  Avg pause  Max pause
  Gen  0       132 colls,     0 par    0.00s    0.00s     0.0000s    0.0004s
  Gen  1         3 colls,     0 par    0.00s    0.00s     0.0006s    0.0010s
  INIT    time    0.00s  (  0.00s elapsed)
  MUT     time    0.03s  (  0.03s elapsed)
  GC      time    0.00s  (  0.01s elapsed)
  EXIT    time    0.00s  (  0.00s elapsed)
  Total   time    0.03s  (  0.04s elapsed)
  %GC     time       0.0%  (14.7% elapsed)
  Alloc rate    2,250,213,011 bytes per MUT second
  Productivity 100.0% of total user, 83.1% of total elapsed
$ ghc -fforce-recomp -O -rtsopts ./1827.hs
[1 of 1] Compiling Main             ( 1827.hs, 1827.o )
Linking 1827.exe ...
$ ./gen.exe 1000 | ./1827.exe +RTS -s > /dev/null
   6,009,233,608 bytes allocated in the heap
     622,682,200 bytes copied during GC
         443,240 bytes maximum residency (505 sample(s))
          48,256 bytes maximum slop
               3 MB total memory in use (0 MB lost due to fragmentation)
                                    Tot time (elapsed)  Avg pause  Max pause
  Gen  0     10945 colls,     0 par    0.72s    0.63s     0.0001s    0.0004s
  Gen  1       505 colls,     0 par    0.16s    0.13s     0.0003s    0.0005s
  INIT    time    0.00s  (  0.00s elapsed)
  MUT     time    2.00s  (  2.13s elapsed)
  GC      time    0.87s  (  0.76s elapsed)
  EXIT    time    0.00s  (  0.00s elapsed)
  Total   time    2.89s  (  2.90s elapsed)
  %GC     time      30.3%  (26.4% elapsed)
  Alloc rate    3,009,412,603 bytes per MUT second
  Productivity  69.7% of total user, 69.4% of total elapsed

1
Grazie per aver incluso la versione GHC!
dfeuer

2
@dfeuer Il risultato è ora integrato nella mia domanda.
johnchen902

13
Un'altra opzione per provare: -fno-state-hack. Quindi dovrò effettivamente provare a esaminare i dettagli.
dfeuer

17
Non conosco molti dettagli, ma fondamentalmente è un'euristica per indovinare che alcune funzioni create dal programma (vale a dire quelle nascoste nei tipi IOo ST) vengono chiamate solo una volta. Di solito è una buona ipotesi, ma quando è una cattiva ipotesi, GHC può produrre un codice pessimo. Gli sviluppatori hanno cercato di trovare un modo per ottenere il bene senza il male per un periodo piuttosto lungo. Penso che Joachim Breitner ci stia lavorando in questi giorni.
dfeuer

2
Questo assomiglia molto a ghc.haskell.org/trac/ghc/ticket/10102 . Nota che entrambi i programmi usano replicateM_, e lì GHC sposterà erroneamente il calcolo dall'esterno replicateM_all'interno, ripetendolo quindi.
Joachim Breitner

Risposte:


42

Immagino sia ora che questa domanda riceva una risposta adeguata.

Cosa è successo al tuo codice con -O

Fammi ingrandire la tua funzione principale e riscriverla leggermente:

main :: IO ()
main = do
    [n, m] <- fmap (map read . words) getLine
    line <- getLine
    let nodes = listArray (0, n) . tonodes n . map (subtract 1) . map read . words $ line
    replicateM_ m $ query n nodes

Chiaramente, l'intenzione qui è che NodeArrayvenga creato una volta e quindi utilizzato in ciascuna delle minvocazioni di query.

Sfortunatamente, GHC trasforma questo codice in, efficacemente,

main = do
    [n, m] <- fmap (map read . words) getLine
    line <- getLine
    replicateM_ m $ do
        let nodes = listArray (0, n) . tonodes n . map (subtract 1) . map read . words $ line
        query n nodes

e puoi immediatamente vedere il problema qui.

Cos'è l'hack di stato e perché distrugge le prestazioni dei miei programmi

Il motivo è l'hack di stato, che dice (approssimativamente): "Quando qualcosa è di tipo IO a, supponi che sia chiamato solo una volta.". La documentazione ufficiale non è molto più elaborata:

-fno-state-hack

Disattiva lo "state hack" per cui qualsiasi lambda con un token State # come argomento è considerato a voce singola, quindi è considerato OK inline le cose al suo interno. Ciò può migliorare le prestazioni del codice IO e monade ST, ma corre il rischio di ridurre la condivisione.

Approssimativamente, l'idea è la seguente: se si definisce una funzione con un IOtipo e una clausola where, ad es

foo x = do
    putStrLn y
    putStrLn y
  where y = ...x...

Qualcosa di tipo IO apuò essere visto come qualcosa di tipo RealWord -> (a, RealWorld). In questa prospettiva, quanto sopra diventa (approssimativamente)

foo x = 
   let y = ...x... in 
   \world1 ->
     let (world2, ()) = putStrLn y world1
     let (world3, ()) = putStrLn y world2
     in  (world3, ())

Una chiamata a foosarebbe (tipicamente) simile a questa foo argument world. Ma la definizione di foorichiede solo un argomento e l'altro viene consumato solo in seguito da un'espressione lambda locale! Questa sarà una chiamata molto lenta a foo. Sarebbe molto più veloce se il codice avesse questo aspetto:

foo x world1 = 
   let y = ...x... in 
   let (world2, ()) = putStrLn y world1
   let (world3, ()) = putStrLn y world2
   in  (world3, ())

Questa si chiama espansione eta e viene eseguita per vari motivi (ad esempio, analizzando la definizione della funzione , controllando come viene chiamata e, in questo caso, euristica diretta di tipo).

Sfortunatamente, questo peggiora le prestazioni se la chiamata a fooè effettivamente nella forma let fooArgument = foo argument, cioè con un argomento, ma non è stata world(ancora) passata. Nel codice originale, se fooArgumentviene poi utilizzato più volte, yverrà comunque calcolato una sola volta e condiviso. Nel codice modificato,y verrà ricalcolato ogni volta - esattamente quello che è successo al tuo nodes.

Le cose possono essere risolte?

Possibilmente. Vedi # 9388 per un tentativo in tal senso. Il problema con il fissaggio è che avrà un costo di prestazioni in molti casi in cui avviene la trasformazione di ok, anche se il compilatore non può saperlo per certo. E ci sono probabilmente casi in cui tecnicamente non va bene, cioè la condivisione è persa, ma è comunque vantaggiosa perché le accelerazioni derivanti dalla chiamata più veloce superano il costo aggiuntivo del ricalcolo. Quindi non è chiaro dove andare da qui.


4
Molto interessante! Ma non ho capito bene il motivo: "l'altro viene consumato solo in seguito da un'espressione lambda locale! Sarà una chiamata molto lenta a foo"?
imz - Ivan Zakharyaschev

C'è qualche soluzione alternativa per un caso locale particolare? -f-no-state-hackquando la compilazione sembra piuttosto pesante. {-# NOINLINE #-}sembra la cosa ovvia ma non riesco a pensare a come applicarlo qui. Forse sarebbe sufficiente solo fare nodesun'azione IO e fare affidamento sulla sequenza di >>=?
Barend Venter

Ho anche visto che la sostituzione replicateM_ n foocon forM_ (\_ -> foo) [1..n]aiuta.
Joachim Breitner
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.