¿Por qué este código Haskell se ejecuta más lento con -O?

87

Esta parte del código de Haskell se ejecuta mucho más lento con -O, pero no -Odebería ser peligrosa . ¿Alguien puede decirme qué pasó? Si importa, es un intento de resolver este problema , y usa búsqueda binaria y árbol de segmento persistente:

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

(Este es exactamente el mismo código con la revisión de código, pero esta pregunta aborda otro problema).

Este es mi generador de entrada en 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);
    }
}

En caso de que no tenga un compilador de C ++ disponible, este es el resultado de./gen.exe 1000 .

Este es el resultado de la ejecución en mi computadora:

$ 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

Y este es el resumen del perfil del montón:

$ 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
johnchen902
fuente
1
¡Gracias por incluir la versión GHC!
dfeuer
2
@dfeuer El resultado ahora está incluido en mi pregunta.
johnchen902
13
Una opción más para tratar: -fno-state-hack. Entonces tendré que intentar buscar en los detalles.
dfeuer
17
No conozco demasiados detalles, pero básicamente es una heurística para adivinar que ciertas funciones que crea su programa (es decir, las que están ocultas en los tipos IOo ST) se llaman solo una vez. Por lo general, es una buena suposición, pero cuando es una mala suposición, GHC puede producir un código muy malo. Los desarrolladores han estado tratando de encontrar una manera de obtener lo bueno sin lo malo durante bastante tiempo. Creo que Joachim Breitner está trabajando en ello estos días.
dfeuer
2
Esto se parece mucho a ghc.haskell.org/trac/ghc/ticket/10102 . Tenga en cuenta que ambos programas usan replicateM_, y allí GHC moverá incorrectamente el cálculo desde fuera replicateM_hacia dentro, por lo que lo repetirá.
Joachim Breitner

Respuestas:

42

Supongo que es hora de que esta pregunta obtenga una respuesta adecuada.

¿Qué pasó con tu código con -O

Déjame ampliar tu función principal y reescribirla ligeramente:

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

Claramente, la intención aquí es que NodeArrayse cree una vez y luego se use en cada una de las minvocaciones de query.

Desafortunadamente, GHC transforma este código en, efectivamente,

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 inmediatamente puede ver el problema aquí.

¿Qué es el truco estatal y por qué destruye el rendimiento de mis programas?

La razón es el truco del estado, que dice (aproximadamente): "Cuando algo es de tipo IO a, suponga que se llama sólo una vez". La documentación oficial no es mucho más elaborada:

-fno-state-hack

Desactive el "truco de estado" mediante el cual cualquier lambda con un token de número de estado como argumento se considera de entrada única, por lo que se considera correcto insertar elementos en línea dentro de él. Esto puede mejorar el rendimiento del código de mónada IO y ST, pero corre el riesgo de reducir el uso compartido.

A grandes rasgos, la idea es la siguiente: si define una función con un IOtipo y una cláusula where, por ejemplo

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

Algo de tipo IO apuede verse como algo de tipo RealWord -> (a, RealWorld). En ese punto de vista, lo anterior se convierte (aproximadamente)

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

Una llamada a foo(normalmente) se vería así foo argument world. ¡Pero la definición de foosolo toma un argumento, y el otro solo es consumido más tarde por una expresión lambda local! Va a ser una llamada muy lenta foo. Sería mucho más rápido si el código tuviera este aspecto:

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

Esto se llama expansión eta y se realiza por varios motivos (por ejemplo, analizando la definición de la función , comprobando cómo se llama y, en este caso, heurística dirigida por tipos).

Desafortunadamente, esto degrada el rendimiento si la llamada a fooes realmente de la forma let fooArgument = foo argument, es decir, con un argumento, pero no worldpasado (todavía). En el código original, si fooArgumentse usa varias veces, yse calculará solo una vez y se compartirá. En el código modificado, yse volverá a calcular cada vez, precisamente lo que le sucedió a su nodes.

¿Se pueden arreglar las cosas?

Posiblemente. Consulte el n. ° 9388 para ver un intento de hacerlo. El problema con la fijación de ella es que va a costar el rendimiento en una gran cantidad de casos en los que la transformación ocurre a bien, a pesar de que el compilador no puede saber posiblemente que de seguro. Y probablemente hay casos en los que técnicamente no está bien, es decir, se pierde el uso compartido, pero sigue siendo beneficioso porque las aceleraciones de la llamada más rápida superan el costo adicional del recálculo. Así que no está claro a dónde ir desde aquí.

Joachim Breitner
fuente
4
¡Muy interesante! Pero no he entendido muy bien por qué: "¡el otro solo es consumido más tarde por una expresión lambda local! Esa va a ser una llamada muy lenta a foo"?
imz - Ivan Zakharyaschev
¿Existe alguna solución para un caso local en particular? -f-no-state-hackcuando compilar parece bastante pesado. {-# NOINLINE #-}parece lo obvio, pero no puedo pensar en cómo aplicarlo aquí. ¿Quizás sería suficiente con realizar nodesuna acción IO y confiar en la secuenciación de >>=?
Barend Venter
También he visto que reemplazar replicateM_ n foocon forM_ (\_ -> foo) [1..n]ayudas.
Joachim Breitner