algorithm - Usando vectores para mejorar el rendimiento en Haskell
(2)
Soy muy nuevo en Haskell, y tengo una pregunta sobre qué mejoras de rendimiento se pueden lograr al usar estructuras de datos impuras (mutables). Estoy tratando de juntar algunas cosas diferentes que he escuchado, así que tenga paciencia si mi terminología no es del todo correcta o si hay algunos errores pequeños.
Para concretar esto, considere el algoritmo de ordenación rápida (tomado de la wiki de Haskell).
quicksort :: Ord a => [a] -> [a]
quicksort [] = []
quicksort (p:xs) = (quicksort lesser) ++ [p] ++ (quicksort greater)
where
lesser = filter (< p) xs
greater = filter (>= p) xs
Esto no es "verdadero quicksort" Un algoritmo "verdadero" de ordenación rápida está en el lugar, y esto no lo es. Esto es muy ineficiente de la memoria.
Por otro lado, es posible usar vectores en Haskell para implementar una orden rápida in situ. Un ejemplo se da en esta respuesta de stackoverflow.
¿Cuánto más rápido es el segundo algoritmo que el primero? La notación Big O no ayuda aquí, porque la mejora del rendimiento será a partir del uso más eficiente de la memoria, no de tener un algoritmo mejor (¿verdad?). Me cansé de construir algunos casos de prueba por mi cuenta, pero me costó poner las cosas en marcha.
Una respuesta ideal daría una idea de lo que hace que el algoritmo de Haskell en el lugar sea más rápido en teoría, y una comparación ejemplar de los tiempos de ejecución en algunos conjuntos de datos de prueba.
Por otro lado, es posible usar vectores en Haskell para implementar una orden rápida in situ.
¿Cuánto más rápido es el segundo algoritmo que el primero?
Eso depende de la implementación, por supuesto. Como se puede ver a continuación, para listas no demasiado cortas, una clasificación en el lugar decente en un vector o matriz mutable es mucho más rápida que las listas de clasificación, incluso si se incluye el tiempo para la transformación de y a las listas ( y la conversión se completa) la mayor parte del tiempo ).
Sin embargo, los algoritmos de la lista producen resultados incrementales, mientras que los algoritmos de matriz / vector no producen ningún resultado antes de que se hayan completado, por lo tanto, las listas de clasificación aún pueden ser preferibles.
No sé exactamente qué hicieron mal los algoritmos de matriz / vector vinculados vinculados. Pero hicieron algo muy mal.
Para el código vectorial mutable, parece que usó vectores en caja , y fue polimórfico, ambos pueden tener un impacto significativo en el rendimiento, aunque el polimorfismo no debería importar si las funciones son {-# INLINABLE #-}
.
Para el código IOUArray
, bueno, parece divertido, pero lento. Utiliza un IORef
, readArray
y writeArray
y no tiene ningún rigor obvio. Los tiempos abismales que toma no son demasiado sorprendentes, entonces.
Usando una traducción más directa del código C (monomorfo) usando un STUArray
, con una envoltura para hacer que funcione en listas¹,
{-# LANGUAGE BangPatterns #-}
module STUQuickSort (stuquick) where
import Data.Array.Base (unsafeRead, unsafeWrite)
import Data.Array.ST
import Control.Monad.ST
stuquick :: [Int] -> [Int]
stuquick [] = []
stuquick xs = runST (do
let !len = length xs
arr <- newListArray (0,len-1) xs
myqsort arr 0 (len-1)
-- Can''t use getElems for large arrays, that overflows the stack, wth?
let pick acc i
| i < 0 = return acc
| otherwise = do
!v <- unsafeRead arr i
pick (v:acc) (i-1)
pick [] (len-1))
myqsort :: STUArray s Int Int -> Int -> Int -> ST s ()
myqsort a lo hi
| lo < hi = do
let lscan p h i
| i < h = do
v <- unsafeRead a i
if p < v then return i else lscan p h (i+1)
| otherwise = return i
rscan p l i
| l < i = do
v <- unsafeRead a i
if v < p then return i else rscan p l (i-1)
| otherwise = return i
swap i j = do
v <- unsafeRead a i
unsafeRead a j >>= unsafeWrite a i
unsafeWrite a j v
sloop p l h
| l < h = do
l1 <- lscan p h l
h1 <- rscan p l1 h
if (l1 < h1) then (swap l1 h1 >> sloop p l1 h1) else return l1
| otherwise = return l
piv <- unsafeRead a hi
i <- sloop piv lo hi
swap i hi
myqsort a lo (i-1)
myqsort a (i+1) hi
| otherwise = return ()
y una envoltura alrededor de un buen tipo (Introsort, no quicksort) en vectores sin caja,
module VSort where
import Data.Vector.Algorithms.Intro
import qualified Data.Vector.Unboxed as U
import Control.Monad.ST
vsort :: [Int] -> [Int]
vsort xs = runST (do
v <- U.unsafeThaw $ U.fromList xs
sort v
s <- U.unsafeFreeze v
return $ U.toList s)
Me pongo a veces más en línea con las expectativas ( Nota: para estos tiempos, la lista aleatoria se ha deepseq
antes de llamar al algoritmo de clasificación. Sin eso, la conversión a un STUArray
sería mucho más lenta, ya que primero evaluaría una lista larga de thunks para determinar la longitud. La conversión fromList
del paquete vector no sufre este problema. Mover deepseq
a la conversión a STUArray
, los otros algoritmos de clasificación [y conversión, en el caso del vector] toman un poco menos de tiempo, por lo que la diferencia entre el introsort de los hackage.haskell.org/package/vector-algorithms vectoriales y el quicksort de STUArray
hace un poco más grande.
list size: 200000 -O2 -fllvm -fllvm-O2
──────── ──────── ──────── ──────── ────────
Data.List.sort 0.663501s 0.665482s 0.652461s 0.792005s
Naive.quicksort 0.587091s 0.577796s 0.585754s 0.667573s
STUArray.quicksort 1.58023s 0.142626s 1.597479s 0.156411s
VSort.vsort 0.820639s 0.139967s 0.888566s 0.143918s
Los tiempos sin optimización son malos para el STUArray
. unsafeRead
y unsafeWrite
deben estar en línea para ser rápidos. Si no está en línea, obtiene una búsqueda de diccionario para cada llamada. Por lo tanto, para el conjunto de datos grande, omito las formas no optimizadas:
list size: 3000000 -O2 -fllvm-O2
──────── ──────── ────────
Data.List.sort 16.728576s 16.442377s
Naive.quicksort 14.297534s 12.253071s
STUArray.quicksort 2.307203s 2.200807s
VSort.vsort 2.069749s 1.921943s
Puede ver que una ordenación in situ en una matriz no encajable mutable es mucho más rápida que una ordenación basada en listas si se realiza correctamente. No La diferencia entre el quicksort de STUArray
si la diferencia entre la clasificación STUArray
y la clasificación en el vector mutable sin caja se debe al diferente algoritmo o si los vectores son más rápidos aquí. Como nunca he observado que los vectores sean más rápidos² que los de STUArray
, tiendo a creer lo primero. STUArray
y el introsort se debe en parte a la mejor conversión desde y hacia las listas que ofrece el paquete de vector , en parte debido a los diferentes algoritmos.
A sugerencia de Louis Wasserman , he ejecutado un punto de referencia rápido utilizando los otros algoritmos de clasificación del paquete de hackage.haskell.org/package/vector-algorithms , utilizando un conjunto de datos no muy grande. Los resultados no son sorprendentes, los buenos algoritmos de propósito general heapsort, introsort y mergesort funcionan bien, los tiempos se acercan al quicksort en la matriz mutable sin caja (pero, por supuesto, el quicksort se degradaría al comportamiento cuadrático en la entrada casi ordenada, mientras que estos están garantizados O (n * log n) peor caso). Los algoritmos de clasificación de propósito especial AmericanFlag
y la clasificación de radix funcionan mal, ya que la entrada no se ajusta bien a su propósito (la clasificación de radix se haría mejor en entradas más grandes con un rango mayor, como es, hace demasiados pases más de los necesarios para los datos). El orden de inserción es, con mucho, el peor, debido a su comportamiento cuadrático.
AmericanFlag:
list size: 300000 -O2 -fllvm-O2
──────── ──────── ────────
Data.List.sort 1.083845s 1.084699s
Naive.quicksort 0.981276s 1.05532s
STUArray.quicksort 0.218407s 0.215564s
VSort.vsort 2.566838s 2.618817s
Heap:
list size: 300000 -O2 -fllvm-O2
──────── ──────── ────────
Data.List.sort 1.084252s 1.07894s
Naive.quicksort 0.915984s 0.887354s
STUArray.quicksort 0.219786s 0.225748s
VSort.vsort 0.213507s 0.20152s
Insertion:
list size: 300000 -O2 -fllvm-O2
──────── ──────── ────────
Data.List.sort 1.168837s 1.066058s
Naive.quicksort 1.081806s 0.879439s
STUArray.quicksort 0.241958s 0.209631s
VSort.vsort 36.21295s 27.564993s
Intro:
list size: 300000 -O2 -fllvm-O2
──────── ──────── ────────
Data.List.sort 1.09189s 1.112415s
Naive.quicksort 0.891161s 0.989799s
STUArray.quicksort 0.236596s 0.227348s
VSort.vsort 0.221742s 0.20815s
Merge:
list size: 300000 -O2 -fllvm-O2
──────── ──────── ────────
Data.List.sort 1.087929s 1.074926s
Naive.quicksort 0.875477s 1.019984s
STUArray.quicksort 0.215551s 0.221301s
VSort.vsort 0.236661s 0.230287s
Radix:
list size: 300000 -O2 -fllvm-O2
──────── ──────── ────────
Data.List.sort 1.085658s 1.085726s
Naive.quicksort 1.002067s 0.900985s
STUArray.quicksort 0.217371s 0.228973s
VSort.vsort 1.958216s 1.970619s
Conclusión: a menos que tenga una razón específica para no hacerlo, usar uno de los buenos algoritmos de clasificación de propósito general de hackage.haskell.org/package/vector-algorithms , con una envoltura para convertir desde y a listas si es necesario, es la forma recomendada para ordenar listas grandes. (Estos algoritmos también funcionan bien con vectores en caja, en mis mediciones aproximadamente un 50% más lento que sin caja). Para listas cortas, la sobrecarga de la conversión sería tan grande que no se paga.
Ahora, a sugerencia de @ aplicative, un vistazo a los tiempos de clasificación para el introsort de hackage.haskell.org/package/vector-algorithms vectoriales, un quicksort en vectores sin caja y un quicksort mejorado (robando descaradamente la implementación de STUArray
unstablePartition
) en STUArray
s.
La mejorada STUArray
rápida de STUArray
:
{-# LANGUAGE BangPatterns #-}
module NQuick (stuqsort) where
import Data.Array.Base (unsafeRead, unsafeWrite, getNumElements)
import Data.Array.ST
import Control.Monad.ST
import Control.Monad (when)
stuqsort :: STUArray s Int Int -> ST s ()
stuqsort arr = do
n <- getNumElements arr
when (n > 1) (myqsort arr 0 (n-1))
myqsort :: STUArray s Int Int -> Int -> Int -> ST s ()
myqsort a lo hi = do
p <- unsafeRead a hi
j <- unstablePartition (< p) lo hi a
h <- unsafeRead a j
unsafeWrite a j p
unsafeWrite a hi h
when (j > lo+1) (myqsort a lo (j-1))
when (j+1 < hi) (myqsort a (j+1) hi)
unstablePartition :: (Int -> Bool) -> Int -> Int -> STUArray s Int Int -> ST s Int
{-# INLINE unstablePartition #-}
unstablePartition f !lf !rg !v = from_left lf rg
where
from_left i j
| i == j = return i
| otherwise = do
x <- unsafeRead v i
if f x
then from_left (i+1) j
else from_right i (j-1)
from_right i j
| i == j = return i
| otherwise = do
x <- unsafeRead v j
if f x
then do
y <- unsafeRead v i
unsafeWrite v i x
unsafeWrite v j y
from_left (i+1) j
else from_right i (j-1)
El vector quicksort:
module VectorQuick (vquicksort) where
import qualified Data.Vector.Unboxed.Mutable as UM
import qualified Data.Vector.Generic.Mutable as GM
import Control.Monad.ST
import Control.Monad (when)
vquicksort :: UM.STVector s Int -> ST s ()
vquicksort uv = do
let li = UM.length uv - 1
ui = UM.unsafeSlice 0 li uv
p <- UM.unsafeRead uv li
j <- GM.unstablePartition (< p) ui
h <- UM.unsafeRead uv j
UM.unsafeWrite uv j p
UM.unsafeWrite uv li h
when (j > 1) (vquicksort (UM.unsafeSlice 0 j uv))
when (j + 1 < li) (vquicksort (UM.unsafeSlice (j+1) (li-j) uv))
El código de tiempo:
{-# LANGUAGE BangPatterns #-}
module Main (main) where
import System.Environment (getArgs)
import System.CPUTime
import System.Random
import Text.Printf
import Data.Array.Unboxed
import Data.Array.ST hiding (unsafeThaw)
import Data.Array.Unsafe (unsafeThaw)
import Data.Array.Base (unsafeAt, unsafeNewArray_, unsafeWrite)
import Control.Monad.ST
import Control.Monad
import qualified Data.Vector.Unboxed as U
import qualified Data.Vector.Unboxed.Mutable as UM
import NQuick
import VectorQuick
import qualified Data.Vector.Algorithms.Intro as I
nextR :: StdGen -> (Int, StdGen)
nextR = randomR (minBound, maxBound)
buildArray :: StdGen -> Int -> UArray Int Int
buildArray sg size = runSTUArray (do
arr <- unsafeNewArray_ (0, size-1)
let fill i g
| i < size = do
let (r, g'') = nextR g
unsafeWrite arr i r
fill (i+1) g''
| otherwise = return arr
fill 0 sg)
buildVector :: StdGen -> Int -> U.Vector Int
buildVector sg size = U.fromList $ take size (randoms sg)
time :: IO a -> IO ()
time action = do
t0 <- getCPUTime
action
t1 <- getCPUTime
let tm :: Double
tm = fromInteger (t1 - t0) * 1e-9
printf "%.3f ms/n" tm
stu :: UArray Int Int -> Int -> IO ()
stu ua sz = do
let !sa = runSTUArray (do
st <- unsafeThaw ua
stuqsort st
return st)
forM_ [0, sz `quot` 2, sz-1] (print . (sa `unsafeAt`))
intro :: U.Vector Int -> Int -> IO ()
intro uv sz = do
let !sv = runST (do
st <- U.unsafeThaw uv
I.sort st
U.unsafeFreeze st)
forM_ [0, sz `quot` 2, sz-1] (print . U.unsafeIndex sv)
vquick :: U.Vector Int -> Int -> IO ()
vquick uv sz = do
let !sv = runST (do
st <- U.unsafeThaw uv
vquicksort st
U.unsafeFreeze st)
forM_ [0, sz `quot` 2, sz-1] (print . U.unsafeIndex sv)
main :: IO ()
main = do
args <- getArgs
let !num = case args of
(a:_) -> read a
_ -> 1000000
!sg <- getStdGen
let !ar = buildArray sg num
!vc = buildVector sg num
!v2 = buildVector sg (foo num)
algos = [ ("Intro", intro v2), ("STUArray", stu ar), ("Vquick", vquick vc) ]
printf "Created data to be sorted, last elements %d %d %d/n" (ar ! (num-1)) (vc U.! (num-1)) (v2 U.! (num-1))
forM_ algos $ /(name, act) -> do
putStrLn name
time (act num)
-- For the prevention of sharing
foo :: Int -> Int
foo n
| n < 0 = -n
| n > 0 = n
| otherwise = 3
Los resultados (solo veces):
$ ./timeSorts 3000000
Intro
587.911 ms
STUArray
402.939 ms
Vquick
414.936 ms
$ ./timeSorts 1000000
Intro
193.970 ms
STUArray
131.980 ms
Vquick
134.979 ms
La STUArray
rápida prácticamente idéntica en el STUArray
y el vector sin caja toman prácticamente el mismo tiempo, como se esperaba. (La antigua implementación de ordenación rápida fue aproximadamente un 15% más lenta que la introsort. En comparación con los tiempos anteriores, alrededor del 70-75% se gastó la conversión de / a las listas).
En la entrada aleatoria, las acciones rápidas tienen un rendimiento significativamente mejor que el introsort, pero en la entrada casi ordenada, su rendimiento se degradaría, mientras que el introsort no.
¹ Hacer que el código sea polimórfico con STUArray
s es un dolor en el mejor de los casos, hacerlo con IOUArray
s y tener tanto la clasificación como el envoltorio {-# INLINABLE #-}
produce el mismo rendimiento con optimizaciones; sin {-# INLINABLE #-}
, el código polimórfico es significativamente más lento.
² Usando los mismos algoritmos, ambos siempre fueron igual de rápidos en la precisión de la medición cuando los comparé (no muy a menudo).
No hay nada mejor que una prueba, ¿verdad? Y los resultados no son sorprendentes: para listas de enteros aleatorios en el rango [0 .. 1000000]
,
list size: 200000 ghc -O2 -fllvm -fllvm-O2
──────── ──────── ──────── ──────── ────────
Data.List.sort 0.878969s 0.883219s 0.878106s 0.888758s
Naïve.quicksort 0.711305s 0.870647s 0.845508s 0.919925s
UArray_IO.quicksort 9.317783s 1.919583s 9.390687s 1.945072s
Vector_Mutable.quicksort 1.48142s 0.823004s 1.526661s 0.806837s
Aquí, Data.List.sort
es exactamente lo que es, Naïve.quicksort
es el algoritmo que usted cita, UArray_IO.quicksort
y Vector_Mutable.quicksort
se toman de la pregunta que vinculó a: klapaucius'' y la respuesta de Dan Burton que resulta ser muy En cuanto al rendimiento óptimo, vea qué mejor podría hacerlo Daniel Fischer , ambos envueltos para aceptar las listas (no estoy seguro de haberlo hecho bien):
quicksort :: [Int] -> [Int]
quicksort l = unsafePerformIO $ do
let bounds = (0, length l)
arr <- newListArray bounds l :: IO (IOUArray Int Int)
uncurry (qsort arr) bounds
getElems arr
y
quicksort :: Ord a => [a] -> [a]
quicksort = toList . iqsort . fromList
respectivamente.
Como puede ver, el algoritmo ingenuo no está muy por detrás de la solución mutable con Data.Vector
en términos de velocidad para clasificar una lista de enteros generados aleatoriamente, y el IOUArray
es en realidad mucho peor . La prueba se llevó a cabo en una computadora portátil Intel i5 con Ubuntu 11.10 x86-64.
Tenga en cuenta que esto no significa que un buen programa basado en listas siempre pueda mantenerse al día con sus equivalentes implementados de manera que se pueda realizar, pero GHC hace un gran trabajo para acercar el rendimiento. Además, depende, por supuesto, de los datos: estos son los momentos en que las listas generadas aleatoriamente contienen valores entre 0 y 1000 en lugar de 0 y 1000000 como antes, es decir, con muchos duplicados:
list size: 200000 ghc -O2 -fllvm -fllvm-O2
──────── ──────── ──────── ──────── ────────
Data.List.sort 0.864176s 0.882574s 0.850807s 0.857957s
Naïve.quicksort 1.475362s 1.526076s 1.475557s 1.456759s
UArray_IO.quicksort 24.405938s 5.255001s 23.561911s 5.207535s
Vector_Mutable.quicksort 3.449168s 1.125788s 3.202925s 1.117741s
Por no hablar de matrices pre-ordenadas.
Lo que es bastante interesante, (solo se hace evidente con tamaños realmente grandes, que requieren rtsopts para aumentar la capacidad de pila), es cómo ambas implementaciones mutables se vuelven significativamente más lentas con -fllvm -O2
:
list size: 3⋅10⁶ ghc -O1 -fllvm-O1 -O2 -fllvm-O2
──────── ──────── ──────── ──────── ────────
Data.List.sort 23.897897s 24.138117s 23.708218s 23.631968s
Naïve.quicksort 17.068644s 19.547817s 17.640389s 18.113622s
UArray_IO.quicksort 35.634132s 38.348955s 37.177606s 49.190503s
Vector_Mutable.quicksort 17.286982s 17.251068s 17.361247s 36.840698s
Me parece lógico que a las implementaciones inmutables les vaya mejor en llvm (¿no hace todo de manera inmutable en algún nivel?), Aunque no entiendo por qué esto solo se hace evidente como una ralentización de las versiones mutables con una optimización alta. y grandes tamaños de datos.
Programa de pruebas:
$ cat QSortPerform.hs
module Main where
import qualified Data.List(sort)
import qualified Naïve
import qualified UArray_IO
import qualified Vector_Mutable
import Control.Monad
import System.Random
import System.Environment
sortAlgos :: [ (String, [Int]->[Int]) ]
sortAlgos = [ ("Data.List.sort", Data.List.sort)
, ("Naïve.quicksort", Naïve.quicksort)
, ("UArray_IO.quicksort", UArray_IO.quicksort)
, ("Vector_Mutable.quicksort", Vector_Mutable.quicksort) ]
main = do
args <- getArgs
when (length args /= 2) $ error "Need 2 arguments"
let simSize = read $ args!!1
randArray <- fmap (take simSize . randomRs(0,1000000)) getStdGen
let sorted = case filter ((== args!!0) . fst) sortAlgos of
[(_, algo)] -> algo randArray
_ -> error $ "Argument must be one of "
++ show (map fst sortAlgos)
putStr "First element: "; print $ sorted!!0
putStr "Middle element: "; print $ sorted!!(simSize`div`2)
putStr "Last element: "; print $ sorted!!(simSize-1)
que toma el nombre del algoritmo y el tamaño de la matriz en la línea de comandos. La comparación de tiempo de ejecución se realizó con este programa:
$ cat PerformCompare.hs
module Main where
import System.Process
import System.Exit
import System.Environment
import Data.Time.Clock
import Data.List
import Control.Monad
import Text.PrettyPrint.Boxes
compiler = "ghc"
testProgram = "./QSortPerform"
flagOpts = [[], ["-O2"], ["-fllvm"], ["-fllvm","-O2"]]
algos = ["Data.List.sort","Naïve.quicksort","UArray_IO.quicksort","Vector_Mutable.quicksort"]
main = do
args <- getArgs
let testSize = case args of
[numb] -> read numb
_ -> 200000
results <- forM flagOpts $ /flags -> do
compilerExitC <- verboseSystem
compiler $ testProgram : "-fforce-recomp" : flags
when (compilerExitC /= ExitSuccess) .
error $ "Compiler error /"" ++ show compilerExitC ++"/""
algoCompare <- forM algos $ /algo -> do
startTime <- getCurrentTime
exitC <- verboseSystem testProgram [algo, show testSize]
endTime <- getCurrentTime
when (exitC /= ExitSuccess) .
error $ "Program error /"" ++ show exitC ++"/""
return . text . show $ diffUTCTime endTime startTime
return . vcat right $ text(concat flags)
: text("────────")
: algoCompare
let table = hsep 2 bottom
$ vcat left (map text $ ("list size: "++show testSize)
: "────────"
: algos )
: results
printBox table
verboseSystem :: String -> [String] -> IO ExitCode
verboseSystem cmd args = do
putStrLn . unwords $ cmd : args
rawSystem cmd args