为什么这个Haskell程序在使用-线程编译时会执行奇怪的操作



考虑以下玩具程序,该程序通过对字典单词应用字符替换来强制执行密码哈希。字典按顺序或并行遍历,在编译时由PARMAP符号触发。

import qualified Control.Parallel.Strategies as Strat
import qualified Crypto.Hash.SHA256 as SHA256
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base16 as BS.Base16
import qualified Data.ByteString.Char8 as BS.Char8
import Data.Char (isLower, toUpper)
import Data.Maybe (listToMaybe)
variants :: String -> [String]
variants "" = [""]
variants (c:s) = [c':s' | s' <- variants s, c' <- subst c]
  where subst 'a' = "aA@"
        subst 'e' = "eE3"
        subst 'i' = "iI1"
        subst 'l' = "lL1"
        subst 'o' = "oO0"
        subst 's' = "sS$5"
        subst 'z' = "zZ2"
        subst x | isLower x = [x, toUpper x]
        subst x = [x]
myMap :: (a -> [a]) -> [a] -> [[a]]
#ifdef PARMAP
myMap = Strat.parMap (Strat.evalList Strat.rseq)
#else
myMap = map
#endif
bruteForce :: [String] -> BS.ByteString -> Maybe String
bruteForce dictionary hash = listToMaybe $ concat $ myMap match dictionary
  where match word = [var | var <- variants word,
                      SHA256.hash (BS.Char8.pack var) == hash]
main :: IO ()
main = do
  dictionary <- lines `fmap` (readFile "dictionary.txt")
  hash <- (fst . BS.Base16.decode . BS.Char8.pack) `fmap` getLine
  case bruteForce dictionary hash of
    Just preimage -> putStrLn preimage
    Nothing -> return ()

无论有没有PARMAP-threaded,我都编译了这个程序。

$ ghc Brute.hs -cpp -fforce-recomp -Wall -O2 -o brute.seq
$ ghc Brute.hs -cpp -fforce-recomp -Wall -O2 -DPARMAP -o brute.par
$ ghc Brute.hs -cpp -fforce-recomp -Wall -O2 -threaded -o brute.seq+th
$ ghc Brute.hs -cpp -fforce-recomp -Wall -O2 -threaded -DPARMAP -o brute.par+th

为了运行这个程序,我制作了一个小词典,并从中提取最后一个单词

$ shuf -n 300 /usr/share/dict/american-english >dictionary.txt
$ tail -n 1 dictionary.txt 
desalinates
$ echo -n 'De$aL1n@teS' | sha256sum
3c76d2487f8094a8bf4cbb9e7de7624fab48c3d4f82112cb150b35b9a1cb9072  -

我在2核CPU上运行这个。这台机器上没有其他CPU密集型进程在运行。

顺序映射版本按预期执行。

$ TIMEFORMAT='real %R   user %U   sys %S'
$ time ./brute.seq <<<3c76d2487f8094a8bf4cbb9e7de7624fab48c3d4f82112cb150b35b9a1cb9072
De$aL1n@teS
real 39.797   user 39.574   sys 0.156
$ time ./brute.seq+th <<<3c76d2487f8094a8bf4cbb9e7de7624fab48c3d4f82112cb150b35b9a1cb9072
De$aL1n@teS
real 44.313   user 44.159   sys 0.088
$ time ./brute.seq+th +RTS -N <<<3c76d2487f8094a8bf4cbb9e7de7624fab48c3d4f82112cb150b35b9a1cb9072
De$aL1n@teS
real 44.990   user 44.835   sys 0.876

在没有-threaded的情况下编译的并行映射版本具有相同的性能。

$ time ./brute.par <<<3c76d2487f8094a8bf4cbb9e7de7624fab48c3d4f82112cb150b35b9a1cb9072
De$aL1n@teS
real 39.847   user 39.742   sys 0.056

当我将并行映射与-threaded结合起来,但还没有使用2个内核时,事情开始看起来很奇怪。

$ time ./brute.par+th <<<3c76d2487f8094a8bf4cbb9e7de7624fab48c3d4f82112cb150b35b9a1cb9072
De$aL1n@teS
real 58.636   user 73.661   sys 17.717

当我使用两个核心时,事情会变得更奇怪。现在,不同运行的性能差异很大,这是以前版本所没有表现出来的。有时它的速度是单核brute.par+th的两倍,这正是我所期望的,因为算法的并行性令人尴尬。但有时它甚至比单核还要慢。

$ time ./brute.par+th +RTS -N <<<3c76d2487f8094a8bf4cbb9e7de7624fab48c3d4f82112cb150b35b9a1cb9072
De$aL1n@teS
real 28.589   user 51.615   sys 2.304
$ time ./brute.par+th +RTS -N <<<3c76d2487f8094a8bf4cbb9e7de7624fab48c3d4f82112cb150b35b9a1cb9072
De$aL1n@teS
real 59.149   user 106.255   sys 4.664
$ time ./brute.par+th +RTS -N <<<3c76d2487f8094a8bf4cbb9e7de7624fab48c3d4f82112cb150b35b9a1cb9072
De$aL1n@teS
real 49.428   user 87.193   sys 3.816

现在我有两个可能相关的问题。

  1. 为什么单核brute.par+th比单核brute.par慢得多?它在这些线程中做什么?它在内核模式下长达17秒的时间在做什么
  2. 为什么2芯brute.par+th的性能变化如此之大,而不是可靠地是1芯brute.par+th性能的两倍

我使用的是GHC 7.4.1和并行-3.2.0.2。我知道我可能应该使用更新的版本,但这是我目前手头上的东西。

我尝试使用-rtsopts进行编译,并使用+RTS -qg禁用线程GC,但没有效果。

我尝试过ThreadScope,但它像疯了一样交换,即使我使用了一个小得多的字典,也无法加载事件日志。

意外的性能可以通过"Crypto.Hash.SHA256"调用不安全的FFI代码来解释。GHC不保证在调用此代码期间不会阻塞其他Haskell线程。如果par生成的线程被GHC阻塞,这将在程序中引起大量争用,从而导致运行时间长和运行时结果不一致。

相关内容

最新更新