为什么StateT在这个例子中更快?

时间:2018-03-03 02:46:06

标签: haskell

我正在尝试对非monadic a - >之间更新字段的性能差异进行基准测试。函数,StateT和IORef。我的基准代码如下:

{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE BangPatterns #-}

module Main where

import Control.Monad.State.Strict
import Criterion.Main
import Data.IORef
import Data.List

newtype MyStateT s m a = MyStateT { unMyStateT :: StateT s m a }
    deriving (Functor, Applicative, Monad, MonadState s)

runMyStateT = runStateT . unMyStateT

data Record = Record
    { ra :: Int
    , rb :: String
    , rc :: Int
    , rd :: Int
    } deriving (Show)

newRecord :: IO (IORef Record)
newRecord = newIORef Record
    { ra = 0
    , rb = "string"
    , rc = 20
    , rd = 30
    }

updateRecordPure :: Record -> Record
updateRecordPure !r = r { ra = ra r + 1 }

updateRecord :: IORef Record -> IO ()
updateRecord ref = do
    r <- readIORef ref
    writeIORef ref $ r { ra = ra r + 1 }

modifyRecord :: IORef Record -> IO ()
modifyRecord ref = modifyIORef' ref (\r -> r { ra = ra r + 1 })

updateRecordM :: (MonadState Record m) => m ()
updateRecordM = modify' $ \r -> r { ra = ra r + 1 }

numCycles :: [Int]
numCycles = [1..10000]

runUpdateRecordPure :: Record -> Record
runUpdateRecordPure rec = foldl' update rec numCycles
  where
    update !r _ = updateRecordPure r

runUpdateRecord :: IO ()
runUpdateRecord = do
    r <- newRecord
    mapM_ (\_ -> updateRecord r) numCycles

runModifyRecord :: IO ()
runModifyRecord = do
    r <- newRecord
    mapM_ (\_ -> modifyRecord r) numCycles

runModifyRecordStateM :: (MonadState Record m) => m ()
runModifyRecordStateM = mapM_ (const updateRecordM) numCycles

main = defaultMain
    [ bgroup "Pure"
        [ bench "update" $ whnf runUpdateRecordPure rec
        ]
    , bgroup "IORef record"
        [ bench "update" $ whnfIO runUpdateRecord
        , bench "modify" $ whnfIO runModifyRecord
        ]
    , bgroup "MyStateT"
        [ bench "modify" $ whnfIO (snd <$> runMyStateT runModifyRecordStateM rec)
        ]
    ]
  where
    rec = Record
        { ra = 0
        , rb = "string"
        , rc = 20
        , rd = 30
        }

基准测试结果如下:

benchmarking Pure/update
time                 124.9 μs   (123.6 μs .. 126.2 μs)
                     0.999 R²   (0.998 R² .. 0.999 R²)
mean                 124.5 μs   (123.0 μs .. 126.1 μs)
std dev              5.039 μs   (4.054 μs .. 6.350 μs)
variance introduced by outliers: 40% (moderately inflated)

benchmarking IORef record/update
time                 70.14 μs   (69.48 μs .. 70.99 μs)
                     0.998 R²   (0.998 R² .. 0.999 R²)
mean                 70.40 μs   (69.53 μs .. 71.51 μs)
std dev              3.141 μs   (2.634 μs .. 3.866 μs)
variance introduced by outliers: 47% (moderately inflated)

benchmarking IORef record/modify
time                 131.9 μs   (130.1 μs .. 133.4 μs)
                     0.999 R²   (0.998 R² .. 0.999 R²)
mean                 131.0 μs   (129.5 μs .. 132.8 μs)
std dev              5.712 μs   (4.667 μs .. 7.476 μs)
variance introduced by outliers: 44% (moderately inflated)

benchmarking MyStateT/modify
time                 31.95 μs   (31.65 μs .. 32.28 μs)
                     0.999 R²   (0.998 R² .. 0.999 R²)
mean                 32.06 μs   (31.72 μs .. 32.49 μs)
std dev              1.243 μs   (985.4 ns .. 1.564 μs)
variance introduced by outliers: 44% (moderately inflated)

从结果看,StateT版本几乎比非monadic版本快四倍,比IORef版本快两倍。

代码是用-O2,-threaded和-fno-full-laziness编译的(结果与添加-fno-full-laziness没有太大变化)。我尝试从whnf / whnfIO切换到nf / nfIO,但唯一改变的是非monadic版本变得更慢。

有人可以解释为什么这个例子中的StateT版本比其他版本更高效吗?

1 个答案:

答案 0 :(得分:6)

除了“你能以多快的速度更新变量”之外,基准测试还会对很多事情进行基准测试。这里的主要问题是Haskell的懒惰。像updateRecordPure这样简单的事情并不像预期的那样:

updateRecordPure :: Record -> Record
updateRecordPure !r = r { ra = ra r + 1 }

肯定会强迫r弱头正常形态。但ra字段评估,我们可以很容易地证明这一点:

-- This just evaluates to (), it doesn't diverge.
updateRecordPure Record {} `seq` ()

所以这里发生的事情是updateRecordPure正在创建一个带有thunk的Record。一般来说这个问题(累积thunk)是优化Haskell程序的常见问题,其他基准测试也遇到了这个问题。

我们可以运行一个简单的实验来查看除了递增变量之外是否还有其他事情发生。所有这些更新都应该占用恒定的时间和恒定的空间,除非它们在内存中累积thunk。尝试将10000调整到100000 ......你会发现运行时增加了10倍以上!

我在Gist中对基准测试进行了修改和清理,它将迭代次数作为命令行参数。它进行了一些其他更改,例如删除列表并使用replicateM_,这更加惯用。在我的系统上,从10000到100000次迭代会产生以下影响:

  • 纯/更新需要80倍的时间,
  • IORef记录/更新需要30倍的时间,
  • IORef记录/修改需要23倍,
  • MyStateT / identity需要1倍的时间和
  • MyStateT / io需要13倍的时间。

MyStateT/identity基准仅MyStateT应用于Identity monad。不知何故,GHC能够完全优化这种情况,这种情况下的运行时间为14 ns ......无论您使用多少次迭代!

但对于其他人来说,因为增加迭代次数10次会使运行时间增加10倍以上,我们知道除了增加整数和分配记录之外,还有其他事情发生。

修复基准

修复基准的懒惰方法是使记录字段严格。

data Record = Record
    { ra :: !Int
    , rb :: String
    , rc :: Int
    , rd :: Int
    } deriving (Show)

通过此更改,从10000次到100000次迭代,运行时间增加了大约10倍的Pure / update,IORef记录/修改和MyStateT / io。 IORef记录/更新仍然如预期的那样缓慢,因为它在堆上构建了一个10000或100000个块的链,然后在最后对它们进行评估(这种行为是众所周知的并记录在modifyIORef文档中,尽管它仍然存在很多人抓住了许多Haskell程序员。

在我的贫血VPS上,具有严格ra字段的新版本具有以下10000次迭代次数,从最快到最慢排列:

  1. MyStateT / identity:13.67 ns
  2. 纯/更新:72.72μs
  3. IORef记录/修改:664.2μs
  4. MyStateT / io:1.170 ms
  5. IORef记录/更新:16.84 ms
  6. 通过这些更改,MyStateT/identity基准测试仍会以某种方式触发一些GHC优化,从而消除了循环。从其他实现中,纯粹的是最快的,这是预期的,并且添加额外的复杂性(使用IORef,然后使用IO + StateT)使得基准更慢。最后,readIORef + writeIORef是最慢的,因为它会产生大量的thunk。

    请注意,纯实现每次迭代只需7 ns。

    在没有-threads的情况下进行编译会大大减少运行时间,使Pure / update,IORef记录/修改和MyStateT / io彼此相差25%。因此,我们可以得出结论,差异是由于在多线程程序中使用IO所需的某种同步,或者可能是多线程程序的代码生成差异导致某些类型的优化无法优化我们的基准测试。