GHC forkIO 双峰性能

Nei*_*ell 7 concurrency performance haskell

我正在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
Run Code Online (Sandbox Code Playgroud)

这会产生 20K 个线程,计算有多少线程使用IORef,当它们全部启动时,完成。使用该命令在 Windows 上的 GHC 8.10.1 上运行时ghc --make -O2 Main -threaded && main +RTS -N4,性能差异很大。有时需要> 1s(例如1.19s),有时需要< 0.1s(例如0.08s)。似乎它在大约 1/6 的时间中处于更快的桶中。为什么性能有差异?是什么让它跑得更快?

当我n放大到 1M 时,效果就会消失,它总是在 5+s 范围内。

leh*_*ins 2

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

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

atomicModifyIORef'是用 CAS(比较和交换)实现的,因此根据线程的执行方式,函数old + 1将被重新计算或多或少的次数。换句话说,如果线程 B 在count线程 A 有机会更新 ref 之前更新 ref 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
Run Code Online (Sandbox Code Playgroud)