【问题标题】:How to make my Haskell program faster? Comparison with C如何让我的 Haskell 程序更快?与 C 的比较
【发布时间】:2011-12-15 04:18:36
【问题描述】:

我正在着手实现其中一位 SHA3 候选者 JH。我的算法通过了 NIST 提供的所有 KAT(已知答案测试),并且还使其成为 Crypto-API 的实例。因此,我开始研究它的性能。但是我对 Haskell 很陌生,并且真的不知道在分析时要寻找什么。

目前,我的代码始终比用 C 编写的参考实现慢,对于所有输入长度,慢 10 倍(C 代码在这里找到:http://www3.ntu.edu.sg/home/wuhj/research/jh/jh_bitslice_ref64.h)。

我的 Haskell 代码可以在这里找到:https://github.com/hakoja/SHA3/blob/master/Data/Digest/JHInternal.hs

现在我不希望您浏览我的所有代码,而只是想要一些关于几个函数的提示。我已经运行了一些性能测试,这是 GHC 生成的(部分)性能文件:

Tue Oct 25 19:01 2011 Time and Allocation Profiling Report  (Final)

   main +RTS -sstderr -p -hc -RTS jh e False

total time  =        6.56 secs   (328 ticks @ 20 ms)
total alloc = 4,086,951,472 bytes  (excludes profiling overheads)

COST CENTRE                    MODULE               %time %alloc

roundFunction                  Data.Digest.JHInternal  28.4   37.4
word128Shift                   Data.BigWord.Word128  14.9   19.7
blockMap                       Data.Digest.JHInternal  11.9   12.9
getBytes                       Data.Serialize.Get     6.7    2.4
unGet                          Data.Serialize.Get     5.5    1.3
sbox                           Data.Digest.JHInternal   4.0    7.4
getWord64be                    Data.Serialize.Get     3.7    1.6
e8                             Data.Digest.JHInternal   3.7    0.0
swap4                          Data.Digest.JHInternal   3.0    0.7
swap16                         Data.Digest.JHInternal   3.0    0.7
swap8                          Data.Digest.JHInternal   1.8    0.7
swap32                         Data.Digest.JHInternal   1.8    0.7
parseBlock                     Data.Digest.JHInternal   1.8    1.2
swap2                          Data.Digest.JHInternal   1.5    0.7
swap1                          Data.Digest.JHInternal   1.5    0.7
linearTransform                Data.Digest.JHInternal   1.5    8.6
shiftl_w64                     Data.Serialize.Get     1.2    1.1

Detailed breakdown omitted ...

现在快速介绍一下 JH 算法:

这是一个哈希算法,由一个压缩函数 F8 组成,只要存在输入块(长度为 512 位),它就会重复。这就是 SHA 函数的运作方式。 F8 函数由 E8 函数组成,该函数应用轮函数 42 次。 round函数本身由三部分组成: 一个 sbox、一个线性变换和一个排列(在我的代码中称为交换)。

因此,大部分时间花在轮函数上是合理的。我仍然想知道如何改进这些部分。例如:blockMap 函数只是一个实用函数,将函数映射到 4 元组中的元素。那么为什么它的表现如此糟糕呢?欢迎提出任何建议,而不仅仅是针对单个功能,即您是否会进行结构更改以提高性能?

我曾尝试查看核心输出,但不幸的是,这超出了我的想象。

我还会在最后附上一些堆配置文件,以备不时之需。

编辑:

我忘了提及我的设置和构建。我在 x86_64 Arch Linux 机器上运行它,GHC 7.0.3-2(我认为),带有编译选项:

ghc --make -O2 -funbox-strict-fields

不幸的是,在通过 C 或 LLVM 编译时,在 Linux 平台上 there seems to be a bug,给了我错误:

错误:XXXX 的 .size 表达式未计算为常量

所以我还没有看到效果。

【问题讨论】:

  • Data.BigWord 来自哪个包?
  • Data.BigWord.Word128 在链接的 git repo 中。

标签: performance haskell profiling


【解决方案1】:

好的,所以我想我会更新一下我所做的工作以及迄今为止获得的结果。所做的更改:

  • 从 Array 切换到 UnboxedArray(使 Word128 成为实例类型)
  • 在 e8 中使用 UnboxedArray + 折叠而不是列表和(前奏)折叠
  • 使用 unsafeIndex 代替 !
  • 将 Block1024 的类型更改为真实数据类型(类似于 Block512),并解压缩其参数
  • 在 Arch Linux 上将 GHC 更新到 7.2.1 版,从而解决了通过 C 或 LLVM 编译的问题
  • 在某些地方将 mod 切换为 rem,但在 roundFunction 中没有。当我在那里执行此操作时,编译时间突然变得非常耗时,运行时间变得慢了 10 倍!有谁知道为什么会这样?它只发生在 GHC-7.2.1 上,而不是 GHC-7.0.3

我使用以下选项进行编译:

ghc-7.2.1 --make -O2 -funbox-strict-fields main.hs ./Tests/testframe.hs -fvia-C -optc-O2

结果呢?大约减少 50% 的时间。在约 107 MB 的输入上,与之前的 6-7 分钟相比,代码现在使用 3 分钟。 C 版本使用 42 秒。

我尝试过的事情,但并没有带来更好的性能:

  • 像这样展开 e8 函数:

    e8 !h = 去 h 0

    去哪里!x!n

          | n == 42   = x
          | otherwise = go h' (n + 1)
          where !h' = roundFunction x n
    
  • 尝试分解 swapN 函数以直接使用底层 Word64':

    swap1 (W xh hl) =

         shiftL (W (xh .&. 0x5555555555555555) (xl .&. 0x5555555555555555)) 1 
         .|. 
         shiftR (W (xh .&. 0xaaaaaaaaaaaaaaaa) (xl .&. 0xaaaaaaaaaaaaaaaa)) 1 
    
  • 尝试使用 LLVM 后端

所有这些尝试的性能都比我目前的差。我不知道那是因为我做错了(尤其是 e8 的展开),还是因为它们只是更糟糕的选择。

我仍然对这些新调整有一些新问题。

  1. 突然间,我的内存使用量出现了这种奇怪的变化。查看以下堆配置文件:

    为什么会这样?是因为 UnboxedArray 吗? SYSTEM是什么意思?

  2. 当我通过 C 编译时,我收到以下警告:

    警告:-fvia-C 标志什么都不做;它将在未来的 GHC 版本中删除

    这是真的吗?那么,为什么我会看到使用它的更好性能,而不是没有呢?

【讨论】:

  • 从 7.2.1 开始,-fvia-C 正式消失。您看到的任何性能差异都可能只是由于计算机上的其他活动而导致的正常抖动。如果您能获得稳定的测量结果,显示使用 -fvia-C 编译的代码与不使用 -fvia-C 编译的代码之间的差异,那将是一个值得深入调查的奇怪现象(错误报告)。
【解决方案2】:
  • 切换到未装箱的向量(来自数组,用于常量)
  • 使用unsafeIndex,而不是从安全索引中产生边界检查和数据依赖(即!
  • 解压 Block1024,就像使用 Block512 一样(或至少使用 UnboxedTuples
  • 使用unsafeShift{R,L},这样您就不会检查班次值(GHC 7.4 中出现)
  • 展开roundFunction,这样你就有了一个相当丑陋和冗长的e8 函数。这在 pureMD5 中很重要(滚动版本更漂亮,但比展开版本慢很多)。您也许可以 use TH to do 这个并保持代码较小。如果你这样做了,那么你就不需要constants,因为这些值将在代码中明确显示,并产生对缓存更友好的二进制文件。
  • 解压您的 Word128 值。
  • Word128 定义您自己的添加,不要解除Integer。请参阅 LargeWord,了解如何做到这一点 an example
  • rem not mod
  • 使用优化编译 (-O2) 并尝试 llvm (-fllvm)

编辑:并将您的 git 存储库与基准测试一起使用,以便我们可以帮助您更轻松 ;-)。在包含一个加密 API 实例方面做得很好。

【讨论】:

  • 感谢您的建议,非常感谢。我很快就会把它搞定……我只是还不知道该怎么做:-) 那东西似乎有点复杂,但我会试试看。当我尝试通过 C(或 ffvlm)编译时,我得到了这个奇怪的编译错误:“XXX 的 .size 表达式不计算为常量”。这似乎是 Linux 平台上的一个错误(我在 Arch Linux x86_64 上运行),所以不幸的是我无法测试它的效果。如何解压 Block1024?尝试时出现编译错误。
  • “..youll 不需要常量,因为这些值将在代码中明确...”是什么意思?我是否必须明确地将它们包含在 e8 函数中?那会不会很混乱?至于我自己对 Word128 的补充:你是绝对正确的。最初我使用的是 LargeWord 包,但它包含一些错误,所以我只是临时推出了自己的。
  • 可能会很乱,但是TH会处理的很乱。
  • @hakoja 要解压Block1024,您需要将其设为data 而不是type 声明(显然,还要添加一个构造函数)。您的 LLVM 错误可能是由于您安装的 GHC 或 LLVM 版本造成的。明确地在 e8 中包含常量很麻烦,但会带来速度优势。如果你决定尝试一下,TH 技巧可以避免混乱。
  • @hakoja 另外,如果您有时间,请考虑发送一个补丁,将模块Test.JH 添加到crypto-api-tests。有关示例,请参见 Test.AESTest.MD5 模块。
【解决方案3】:

下图显示大量内存被列表占用。除非其他模块有更多的潜伏,否则只能来自e8。也许你必须硬着头皮做一个循环而不是折叠,但对于初学者来说,由于Block1024 是一对,foldl' 不会在运行中进行太多评估(除非严格分析器有变得明显更好)。尝试更严格,data Block1024 = B1024 !Block512 !Block512,也许它还需要{-# UNPACK #-} 编译指示。在roundFunction 中,使用rem 而不是mod(这只会产生很小的影响,但会更快一些)并使let 绑定严格。在swapN 函数中,以W x y 形式给出常量而不是128 位十六进制数字可能会获得更好的性能。 我不能保证这些更改会有所帮助,但乍一看,这是最有希望的。

【讨论】:

  • 感谢您的评论,请参阅我对 Thomas M. DuBuisson 的回复,进一步了解展开 e8。向 Block1024 添加 bang 模式会给我一个编译错误:/ 我该如何补救?我将尝试那些 swapN 更改。那就是绕过一层构造函数吧?
  • 嗯,什么编译错误?在数据声明中,“!”是 Haskell98 和 Haskell2010 中的严格性注释,我不记得将它们与 bang 模式一起使用有任何问题。建议对swapN 函数进行更改的要点是,使用W x y,您将获得两个64 位字(希望是文字)而不是中间Integer。如果优化器足够好,它可能没有任何区别。
  • 它给了我:意外的严格性注释:!Block512 在 `Block1024' 的类型同义词声明中
  • 啊,我明白了。它不能是严格注解的类型同义词。这需要data 声明。
【解决方案4】:

看起来你已经做了相当多的调整;我很好奇没有明确的严格性注释(BangPatterns)和各种编译器编译指示(UNPACKINLINE)的性能是什么样的......另外,一个愚蠢的问题:你使用什么优化标志?

无论如何,有两个建议可能非常糟糕:

  1. 尽可能使用未装箱的原始类型(例如,将Data.Word.Word64 替换为GHC.Word.Word64#,确保word128Shift 使用Int# 等)以避免堆分配。当然,这是不可移植的。
  2. 尝试Data.Sequence 而不是[]

无论如何,与其查看核心输出,不如尝试查看中间 C 文件 (*.hc)。这可能很难通过,但有时会在编译器不如您希望的那么清晰的地方变得明显。

【讨论】:

  • 好吧,我宁愿建议使用Data.Vector 而不是Data.Sequence,因为前者有一个很好的循环融合框架,就像一个魅力。
  • 感谢您的建议!我只是使用 -O2 和 -funbox-strict-fields (请参阅上面的编辑了解原因)。注释和 pragma 仅在性能上带来了一点点提升,但足以证明存在的理由,除了绝对关键的一个:如果没有 f8 中的 bangpattern,我会得到堆栈溢出或大量堆分配。现在它在恒定空间和 98% 的效率中运行 :) 我将在哪里使用向量/序列?它能给我的不仅仅是常量数组吗?我使用列表的唯一地方是 e8 和 parseMessage。 e8以外的地方还有融合的潜力吗?
猜你喜欢
  • 1970-01-01
  • 2014-02-27
  • 1970-01-01
  • 2017-03-19
  • 2018-08-23
  • 2018-07-28
  • 2019-08-21
  • 2021-05-11
  • 2012-02-02
相关资源
最近更新 更多