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

Haskell 为什么在这个例子中StateT更快?,haskell,Haskell,我试图对非一元a->a函数、StateT和IORef之间更新字段的性能差异进行基准测试。我的基准代码如下: {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE BangPatterns #-} module Main w

我试图对非一元a->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
        }
从结果来看,StateT版本的速度几乎是非一元版本的四倍,比IORef版本快两倍

代码是使用-O2、-threaded和-fno full laziness编译的(添加-fno full laziness后,结果变化不大)。我试着从
whnf
/
whnfIO
切换到
nf
/
nfIO
,但唯一改变的是非一元版本变得更慢


有人能解释一下为什么本例中的StateT版本比其他版本的性能好得多吗?

除了“更新变量的速度有多快”之外,该基准测试还测试了很多东西。这里的主要问题是哈斯克尔的懒惰。像
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。一般来说,这个问题(累积thunks)是优化Haskell程序的常见问题,其他基准测试也会遇到这个问题

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

我已经在一个应用程序中对基准测试进行了修改和清理,它将迭代次数作为命令行参数。它还做了一些其他更改,如删除列表和使用
replicateM\uuu
,这有点惯用。在我的系统上,从10000到100000次迭代具有以下效果:

  • Pure/update需要80倍的时间
  • IORef记录/更新需要30倍的时间
  • IORef记录/修改所需时间为23倍
  • MyStateT/identity需要1倍的时间,并且
  • MyStateT/io的时间是它的13倍
MyStateT/identity
基准只是应用于
identity
monad的
MyStateT
。不知何故,GHC能够完全优化此案例,此案例的运行时间为14 ns。。。无论您使用多少次迭代

但是对于其他人来说,由于迭代次数增加10倍,运行时间增加了10倍以上,我们知道这里发生了一些事情,而不仅仅是增加一个整数和分配一个记录

确定基准 修复基准测试的惰性方法是使记录字段严格

data Record = Record
    { ra :: !Int
    , rb :: String
    , rc :: Int
    , rd :: Int
    } deriving (Show)
通过此更改,从10000到100000次迭代将Pure/update、IORef-record/modify和MyStateT/io的运行时间增加了约10倍。IORef记录/更新仍然像预期的那样缓慢,因为它正在堆上构建一个由10000或100000个块组成的链,然后在最后对它们进行评估(这种行为众所周知,并记录在
modifyIORef
文档中,尽管它仍然让许多Haskell程序员大吃一惊)

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

  • MyStateT/identity:13.67纳秒
  • 纯/更新:72.72μs
  • IORef记录/修改:664.2μs
  • MyStateT/io:1.170毫秒
  • IORef记录/更新:16.84毫秒
  • 有了这些更改,
    MyStateT/identity
    基准测试仍然以某种方式触发了一些GHC优化,从而消除了循环。从其他实现来看,pure是最快的,这是预期的,并且添加额外的复杂性(使用IORef,然后使用IO+StateT)会使基准测试速度变慢。最后,
    readIORef
    +
    writeIORef
    是最慢的,因为它创建了大量thunk

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


    不使用
    -线程进行编译
    会大大减少运行时间,使Pure/update、IORef-record/modify和MyStateT/io之间的距离不超过25%。因此我们可以得出结论,这些差异是由于在多线程程序中使用IO所需的某种同步造成的,或者可能是多线程程序的代码生成不同,这会阻止某些类型的优化优化我们的基准测试。

    OP已将其问题更新为使用
    Control.Monad.State.Strict
    ,但结果仍然相似。如果你更新了问题,但没有对答案发表评论,我无法知道问题已经改变了。
    data Record = Record
        { ra :: !Int
        , rb :: String
        , rc :: Int
        , rd :: Int
        } deriving (Show)