Haskell:两个版本代码之间的速度差异

Posted

技术标签:

【中文标题】Haskell:两个版本代码之间的速度差异【英文标题】:Haskell: speed difference between two versions of code 【发布时间】:2017-08-11 13:35:57 【问题描述】:

通过尝试解决一些小问题,我开始深入研究 Haskell。

我偶然发现 “标准的 haskell 友好型” 解决方案和我的 “非常丑陋且对 haskell 不友好”之间存在很大的性能差异(~100-200 倍) em> 版本。

我相信对于 haskeller 同胞来说,这种性能差异有一个很好的理由,我错过了这一点,并且可以在这个主题上对我进行教育。

问题:在一个数字字符串中找出最大的5位数字

两者在求解中使用相同的概念:生成所有 5 位数字并找到最大值。

优雅快速的代码

digit5 :: String -> Int
digit5 = maximum . map (read . take 5) . init . tails

丑陋且非常慢的代码(一旦字符串很大)

digit5' :: String -> String -> String
-- xs - input string
-- maxim - current maximal value
digit5' xs maxim 
  | (length xs) < 5 = maxim
  | y > maxim = digit5' (drop 1 xs) y -- use new detected maximum value
  | otherwise = digit5' (drop 1 xs) maxim
  where y = take 5 xs

digit5 :: String -> Int
digit5 xs
-- return the original string if the input size is smaller than 6
  | length (xs) < 6 = read xs 
  | otherwise = read $ digit5' xs "00000"

对于小输入,我得到大致相同的执行时间,对于大输入,我开始看到非常大的差异(对于 44550 个元素的输入):

Computation time for ugly version: 2.047 sec
Computation time for nice version: 0.062 sec

我对此的肤浅理解是,快速代码使用的是预先可用的高阶函数。对于较慢的版本,使用递归,但我认为咬尾应该是可能的。但是在幼稚的层面上,对我来说两者似乎都在做同样的事情。

虽然较慢的函数对字符串进行比较而不是将它们转换为数字,但我也尝试将字符串转换为整数,但没有任何大的改进

我尝试使用不带任何标志的 ghc 并使用以下命令进行编译:

ghc 
ghc -O2 
ghc -O2 -fexcess-precision -optc-O3 -optc-ffast-math -no-
recomp 
stack runhaskell 
ghc -O3

为了重现性,我添加了一个包含测试向量的代码链接:https://pastebin.com/kuS5iKgd

【问题讨论】:

您有在列表上递归并在每次迭代时调用length 的函数。 length 是 O(n)。您不小心在运行时添加了 O(n^2) 项。 跳过守卫| length xs &lt; 5 = maxim 以获得额外的模式digit5' [] maxim = maxim 给了我 0.004 秒而不是 1.7 秒。这甚至比优雅的解决方案还要快。 (读取总是会消耗部分字符串的 5 个字节,而字符串比较可能只需要比较第一个字符。) 【参考方案1】:

你的“慢”版本的问题是这一行:

| length xs < 5 = maxim

这会计算xs的长度,因为Haskell链表是单链表,所以这个操作需要完整遍历整个链表,也就是O(n)。它发生在每次迭代中。并且有N次迭代。这使得整个过程 O(n^2)。

另一方面,“快速”代码只是线性的,在大输入时会生动地显示出来。

如果你只是用这个替换有问题的行:

| null xs = maxim

它将使整个事情变得线性,并且与“优雅”的解决方案一样快。当然,这会导致额外的 5 次迭代,但通过降低整体复杂性可以弥补这种损失。

或者,或者,您可以通过过滤掉 5 个字符或更短的尾部来使“优雅”解决方案同样缓慢:

digit5 = maximum . map (read . take 5) . filter ((>= 5) . length) . tails

【讨论】:

一些提高速度的建议:String 上的字典顺序和Integer 上的顺序一致,因此您可以完全跳过read。你也可以用一个花哨的技巧来避免filterzipWith const (tails xs) (drop 4 xs) 将产生xs 的所有至少长度为 5 的尾部。 (...或者你可以移动read,如read . maximum . map (take 5),如果你想保持相同的类型。我在下面的时序测试中这样做了。) 结果:@ 987654333@, 0.32s for a length-10000 String; digit5ReadZipwith, 0.04s; digit5LexFilter, 0.27s; digit5LexZipwith 甚至没有注册(报告 0.00s)。 这是我在学习 Haskell 时遇到的第一个严格的 bug:length xs &lt; 5 这里太严格了,因为Int 太严格了。如果您使用像 Data.Nat 这样的惰性数字类型,它将在 5 个元素后停止评估 xsgenericLength xs &lt; (5 :: Nat)filter ((&gt;= (5 :: Nat)) . genericLength) 也是如此。 试试digit5 = maximum . scanl (\a b -&gt; rem (10 * a + b) 100000) 0 . map digitToInt。我没有对其进行基准测试,但我想缺少字符串操作应该会有所帮助。

以上是关于Haskell:两个版本代码之间的速度差异的主要内容,如果未能解决你的问题,请参考以下文章

安全执行不受信任的 Haskell 代码

工具rest:Haskell的REST开源框架

Haskell趣学指南

Rust闭包和Haskell lambda有什么区别? [关闭]

为啥这两种变体之间的速度差异如此之大?

-bash: ghci: 找不到命令(Haskell 交互式 shell,Haskell 安装)