【问题标题】:GHC forkIO bimodal performanceGHC forkIO 双峰性能
【发布时间】:2020-09-10 06:06:37
【问题描述】:

我正在使用以下代码对forkIO 进行基准测试:

import System.Time.Extra
import Control.Concurrent
import Control.Monad
import Data.IORef


n = 200000

main :: IO ()
main = do
    bar <- newEmptyMVar
    count <- newIORef (0 :: Int)
    (d, _) <- duration $ do
        replicateM_ n $ do
            forkIO $ do
                v <- atomicModifyIORef' count $ \old -> (old + 1, old + 1)
                when (v == n) $ putMVar bar ()
        takeMVar bar
    putStrLn $ showDuration d

这会产生 20K 线程,计算有多少线程使用 IORef 运行,当它们全部启动时,完成。在 Windows 上使用命令 ghc --make -O2 Main -threaded &amp;&amp; main +RTS -N4 在 GHC 8.10.1 上运行时,性能差异很大。有时需要 > 1 秒(例如 1.19 秒),有时需要

当我将n 缩放到 1M 时,效果消失了,并且始终在 5+s 范围内。

【问题讨论】:

    标签: performance haskell concurrency


    【解决方案1】:

    我也可以在 Ubuntu 上确认相同的行为。除非我设置 n=1M,否则此行为不会消失,我的运行时间范围为 2 到 7 秒。

    我相信调度程序的不确定性是导致运行时出现如此显着差异的原因。当然,这不是一个确定的答案,因为这只是我的猜测。

    atomicModifyIORef' 是用 CAS(比较和交换)实现的,因此根据线程的执行方式,old + 1 函数将或多或少地重新计算。换句话说,如果线程 B 在线程 A 有机会更新 count 引用之前更新了 count 引用,但是在它开始更新之后,它必须从头开始更新操作,因此读取来自 ref 的新更新值并再次重新计算 old + 1

    如果您运行main +RTS -N1,您会发现不仅运行程序所需的时间少了很多,而且执行之间的运行时间也非常一致。我怀疑这是因为任何时候只能运行一个线程,并且在atomicModifyIORef' 完成之前没有抢占。

    希望对 Haskell RTS 有深入了解的其他人可以提供有关此行为的更多见解,但这是我的看法。

    编辑

    @NeilMitchel 评论道:

    我根本不相信这与原子修改有任何关系

    为了证明 IORef 确实存在错误,这里有一个使用 PVar 的实现,它依赖于下面的 casIntArray#。它不仅快了 10 倍,而且没有观察到差异:

    import System.Time.Extra
    import Control.Concurrent
    import Control.Monad
    import Data.Primitive.PVar -- from `pvar` package
    
    
    n = 1000000
    
    main :: IO ()
    main = do
        bar <- newEmptyMVar
        count <- newPVar (0 :: Int)
        (d, _) <- duration $ do
            replicateM_ n $ do
                forkIO $ do
                    v <- atomicModifyIntPVar count $ \old -> (old + 1, old + 1)
                    when (v == n) $ putMVar bar ()
            takeMVar bar
        putStrLn $ showDuration d
    

    【讨论】:

    • old+1 只计算一次。 atomicModifyIORef' 创建一个表示加法结果的 thunk,然后 mutates 每个失败的 CAS 上的 thunk 以将参数更改为新读取的值。只有在成功安装 thunk 后才会强制执行,计算总和。我知道这一点是因为我已经阅读(并稍加修改)了底层 primop 的源代码。
    • @dfeuer 看起来您熟悉原子 IORef 更新的内部结构,那么您是否会说失败的 CAS 本身可能会造成这种开销?加法一开始就非常便宜,所以当我写它的时候,我对这个解释很怀疑。然而,我确实怀疑更多的功能会增加失败的 CAS 的数量,从而增加运行时间。是否可以用更少或更多失败的 CAS 来解释运行时的差异?
    • 我根本不相信这与原子修改有任何关系(一些直觉,一些其他的基准是暗示性的但不是确定的)。不幸的是,没有等待所有线程启动/停止的原语,所以这是我能想到的使基准测试正确的唯一方法。如果我们可以消除那部分,那么将这些部分隔离起来会很有用。
    • @NeilMitchell 我不太确定。我非常怀疑它与 IORef 而不是实际线程有关。巧合的是,我昨天对具有标准的 IORef 的争用进行了基准测试,以便将其与我的替代实现进行比较,后者做同样的事情,但使用自旋锁而不是 CAS,并在 IORef 上观察到相同的差异,但在自旋锁上没有。跨度>
    • @lehins 谢谢!我确认你的基准测试速度更快,所以它肯定是 IORef,而 PVar 更快。
    猜你喜欢
    • 2014-01-15
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-05-19
    • 2012-09-02
    相关资源
    最近更新 更多