суббота, 1 августа 2015 г.

Haskell, производительность, criterion

Стали с приятелем меряться скоростями на больших данных в задаче о монетах из предыдущей статьи. Он на C++, я на haskell. Само собой, даже самое быстрое решение из всех в ней представленных — с Mapочень, значительно уступает простому оптимизированному итеративному алгоритму на C. Из серьезных проблем решения с Map я вижу как минимум две. Во-первых, minNumCoinsMap генерирует огромный список от 0 до m, а затем берет его последний элемент. Список — классная структура данных в теории функционального программирования, вот только не очень быстрая, а для получения последнего элемента нужно пробежаться по всем элементам, начиная с первого. Все это очень нездо́рово, когда число m равно нескольким миллионам. Во-вторых, я использовал хотя и быструю, но неизменяемую (immutable) реализацию Map. Это значит, что на каждом шаге внутри функции step функция insert возвращает новый ассоциативный массив с уже накопленными данными и одним новым — очень расточительный подход! Чтобы хотя бы на порядок приблизиться к скорости алгоритма на C++, о списке и неизменяемом Map нужно забыть. Хотя на самом деле у меня было четыре итерации постепенного улучшения производительности за счет изменения деталей алгоритма и примитивов, его реализующих. На первой итерации я оставил immutable Map, но в функции step перестал копировать его полностью. В самом деле, алгоритм на шаге m проверяет уже полученные решения, записанные в массив, начиная со значения m - max c, где max c — максимальный номинал монеты в списке c. Соответственно, строки с определением функции step приняли следующий вид.
            where step a x = let cur = min (s ! (m - x) + 1) $ snd a
                             in (insert m cur (snd $ split (m - mc) s), cur)
                    where mc = maximum c
Функция split определена в модуле Data.IntMap.Strict и ее нужно импортировать. Это немного улучшило производительность. На второй итерации я заменил неизменяемый Map на изменяемый (mutable) Array из модуля Data.Array.IO. Новая функция minNumCoinsArray представлена ниже.
import           Data.Array.IO
import           Data.Array.Base             (unsafeRead, unsafeWrite)

-- ----------

minNumCoinsArray :: [Int] -> Int -> IO Int
minNumCoinsArray c m = do
    a <- newArray (0, m) 0 :: IO (IOUArray Int Int)
    mapM_ (mnc cs a) [1 .. m]
    unsafeRead a m
    where cs = sortBy (flip compare) c
          mnc :: [Int] -> IOUArray Int Int -> Int -> IO ()
          mnc c a m = foldM (step a) m (dropWhile (> m) c) >>= unsafeWrite a m
            where step a b x = liftM (min b . succ) $ unsafeRead a $ m - x
Этот массив работает с unboxed данными, расположенными непрерывно на участке памяти в куче от 0 до m — это очень эффективная структура данных (и уже не чистая). Кроме того, здесь оптимизирована свертка по списку монет c: сначала монеты сортируются в обратном порядке, а внутри свертки линейная функция filter заменена на dropWhile: в нормальной ситуации, когда самый старший номинал значительно меньше полной стоимости m, функция dropWhile будет почти всегда останавливаться на первом же элементе. Эта реализация при больших значениях m отставала от референсной программы на C++ на порядок. На третьей итерации я всего лишь заменил mutable unboxed Array на mutable unboxed Vector — чисто посмотреть, что из этого выйдет. Вот реализация функции minNumCoinsVector.
import qualified Data.Vector.Unboxed.Mutable as V

-- ----------

minNumCoinsVector :: [Int] -> Int -> IO Int
minNumCoinsVector c m = do
    v <- V.new $ m + 1
    V.unsafeWrite v 0 0
    mapM_ (mnc cs v) [1 .. m]
    V.unsafeRead v m
    where cs = sortBy (flip compare) c
          mnc c v m = foldM (step v) m (dropWhile (> m) c) >>= V.unsafeWrite v m
            where step v a x = liftM (min a . succ) $ V.unsafeRead v $ m - x
Результат не сильно отличался от предыдущего. И тогда я решил выжать из этого решения все что можно до последней капли (ну, наверное, все-таки не до последней). Никаких функциональных штучек вроде списков и сверток, никаких min, succ, dropWhile и тому подобного. Берем тупой итеративный алгоритм как в C и применяем его к тупой C-подобной структуре данных mutable unboxed Vector. Список монет реализуем как immutable unboxed Vector — ведь он не изменяется.
import qualified Data.Vector.Unboxed         as VI

-- ----------

minNumCoinsVectorBasic :: VI.Vector Int -> Int -> IO Int
minNumCoinsVectorBasic c m = do
    v <- V.new $ m + 1
    V.unsafeWrite v 0 0
    mapM_ (mnc c v) [1 .. m]
    V.unsafeRead v m
    where mnc c v m = VI.foldM_ (step v) m c
            where step v a x =
                    if x > m
                        then return a
                        else do y <- V.unsafeRead v $ m - x
                                let cur = y + 1
                                if cur < a
                                    then do V.unsafeWrite v m cur
                                            return cur
                                    else return a
Список монет теперь имеет тип VI.Vector Int и должен формироваться перед вызовом minNumCoinsVectorBasic, например так.
          coins    = [24, 18, 14, 11, 10, 8, 5, 3, 1]
          vcoins   = VI.fromList $ sortBy (flip compare) coins
Предельно просто и уныло, бейсик какой-то, но … Вы будете смеяться, но это заработало на порядок быстрее и догнало программу на C++. Репутация haskell была восстановлена! Для измерения производительности я использовал библиотеку criterion. Функция main приняла вид
import           Criterion.Main
import           System.Environment

-- ----------

main = do
    args <- getArgs
    if benchArg `elem` args
        then withArgs (filter (/= benchArg) args) $ defaultMain
             [
                 bench ("map " ++ show m2)          $ whnf
                     (minNumCoinsMap        coins) m2,
                 bench ("array " ++ show m2)        $ whnfIO $
                     minNumCoinsArray       coins  m2,
                 bench ("vector " ++ show m2)       $ whnfIO $
                     minNumCoinsVector      coins  m2,
                 bench ("vector basic " ++ show m2) $ whnfIO $
                     minNumCoinsVectorBasic vcoins m2
             ]
        else {-print $ minNumCoinsPlain [1, 2, 5] 20   -- m = 50 will hang it!-}
             {-print $ minNumCoins_     [1, 2, 5] 20   -- m = 50 will hang it!-}
             {-print $ minNumCoins      [1, 2, 5] 10002-}
             {-print $ minNumCoinsMemo  [1, 2, 5] 10001-}
             {-print $ minNumCoinsMap   [1, 2, 5] 10001-}
             {-print $ minNumCoins      coins m1-}
             {-print $ minNumCoinsMap   coins m1-}
             {-print $ minNumCoinsMap   coins m2-}
             {-minNumCoinsArray         coins m2 >>= print-}
             minNumCoinsVectorBasic   vcoins m2 >>= print
    where benchArg = "-b"
          coins    = [24, 18, 14, 11, 10, 8, 5, 3, 1]
          vcoins   = VI.fromList $ sortBy (flip compare) coins
          m1       = 16659
          m2       = 1665900
То есть, для запуска criterion в эту программу нужно предать опцию -b. Скомпилировав программу с оптимизацией под LLVM,
ghc --make -O2 -fllvm -optlo-O3 minNumCoins.hs
и запустив ее с опцией -b,
./minNumCoins -b -o minNumCoins.html
benchmarking map 1665900
time                 3.304 s    (3.158 s .. 3.478 s)
                     1.000 R²   (0.999 R² .. 1.000 R²)
mean                 3.394 s    (3.370 s .. 3.411 s)
std dev              25.30 ms   (0.0 s .. 29.13 ms)
variance introduced by outliers: 19% (moderately inflated)

benchmarking array 1665900
time                 145.4 ms   (143.3 ms .. 147.7 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 144.1 ms   (143.6 ms .. 145.0 ms)
std dev              890.1 μs   (399.6 μs .. 1.244 ms)
variance introduced by outliers: 12% (moderately inflated)

benchmarking vector 1665900
time                 136.2 ms   (135.4 ms .. 137.5 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 137.7 ms   (137.0 ms .. 139.8 ms)
std dev              1.518 ms   (389.3 μs .. 2.314 ms)
variance introduced by outliers: 11% (moderately inflated)

benchmarking vector basic 1665900
time                 20.87 ms   (20.48 ms .. 21.20 ms)
                     0.996 R²   (0.988 R² .. 0.999 R²)
mean                 21.22 ms   (20.88 ms .. 21.96 ms)
std dev              1.090 ms   (452.6 μs .. 1.646 ms)
variance introduced by outliers: 18% (moderately inflated)
я получил вот такой замечательный графический отчет. Все картинки внутри отчета интерактивные — для их просмотра нужно разрешить javascript. Я не хочу делать никаких выводов, хотя они напрашиваются сами собой. Исходный код программы по-прежнему доступен отсюда.

Комментариев нет:

Отправить комментарий