【问题标题】:How to improve the performance of this Haskell program?如何提高这个 Haskell 程序的性能?
【发布时间】:2011-04-08 11:04:51
【问题描述】:

我正在解决 Project Euler 中的问题作为学习 Haskell 的一种方式,我发现我的程序比可比较的 C 版本慢很多,即使在编译时也是如此。我可以做些什么来加快我的 Haskell 程序?

例如,我对Problem 14 的蛮力解决方案是:

import Data.Int
import Data.Ord
import Data.List

searchTo = 1000000

nextNumber :: Int64 -> Int64
nextNumber n
    | even n    = n `div` 2
    | otherwise = 3 * n + 1

sequenceLength :: Int64 -> Int
sequenceLength 1 = 1
sequenceLength n = 1 + (sequenceLength next)
    where next = nextNumber n

longestSequence = maximumBy (comparing sequenceLength) [1..searchTo]

main = putStrLn $ show $ longestSequence

这大约需要 220 秒,而“等效”的蛮力 C 版本只需要 1.2 秒。

#include <stdio.h>

int main(int argc, char **argv)
{
    int longest = 0;
    int terms = 0;
    int i;
    unsigned long j;

    for (i = 1; i <= 1000000; i++)
    {
        j = i;
        int this_terms = 1;

        while (j != 1)
        {
            this_terms++;

            if (this_terms > terms)
            {
                terms = this_terms;
                longest = i;
            }

            if (j % 2 == 0)
                j = j / 2;
            else
                j = 3 * j + 1;
        }
    }

    printf("%d\n", longest);
    return 0;
}

我做错了什么?还是我天真地认为 Haskell 甚至可以接近 C 的速度?

(我使用 gcc -O2 编译 C 版本,使用 ghc --make -O 编译 Haskell 版本)。

【问题讨论】:

  • 您的 unsigned long 可能只有 32 位长。为了公平比较,请使用unsigned long longuint64_t
  • @KennyTM - 公平点 - 我在 32 位 Ubuntu 上进行测试,其中 long 恰好是 64 位。
  • @stusmith:我明白了。那没关系。
  • @stusmith:你确定吗?我可以发誓 sizeof(long) 在 32 位平台上使用 gcc 是 4。
  • @stusmith:Linux 使用 ILP32 和 LP64,这意味着 int总是 32 位,long long总是 64位(尽管我相信有一些关于将 DEC Alpha CPU 迁移到 128 位的讨论)和 long 始终与指针相同。所以,如果你在 32 位 Linux 上运行,那么你的 Haskell int 确实是两倍大小。

标签: c performance haskell


【解决方案1】:

出于测试目的,我刚刚设置了searchTo = 100000。耗时7.34s。一些修改导致一些重大改进:

  1. 使用Integer 代替Int64。这将时间缩短到 1.75s

  2. 使用累加器(你不需要 sequenceLength 来偷懒吧?)1.54s

    seqLen2 :: Int -> Integer -> Int
    seqLen2 a 1 = a
    seqLen2 a n = seqLen2 (a+1) (nextNumber n)
    
    sequenceLength :: Integer -> Int
    sequenceLength = seqLen2 1
    
  3. 使用quotRem 重写nextNumber,从而避免计算除法两次(一次在even 中,一次在div 中)。 1.27 秒

    nextNumber :: Integer -> Integer
    nextNumber n 
        | r == 0    = q
        | otherwise = 6*q + 4
        where (q,r) = quotRem n 2 
    
  4. 使用Schwartzian transform 而不是maximumBymaximumBy . comparing 的问题是sequenceLength 函数对于每个值都被多次调用。 0.32s

    longestSequence = snd $ maximum [(sequenceLength a, a) | a <- [1..searchTo]]
    

注意:

  • 我通过使用ghc -O 编译来检查时间并使用+RTS -s 运行)
  • 我的机器在 Mac OS X 10.6 上运行。 GHC 版本是 6.12.2。编译后的文件采用 i386 架构。)
  • C 问题在 0.078s 运行,并带有相应的参数。使用gcc -O3 -m32编译。

【讨论】:

  • 好吧,这真的很有趣。我假设(显然错误地)任意大小的 Integer 类型会比 64 位 Int64 类型慢。另外,我假设尾调用递归将优化为循环。你有这些提示的链接吗?
  • @stusmith: 1 + (sequenceLength next) 并不是真正的尾递归,因为sequenceLength 不在顶层。优化提示见book.realworldhaskell.org/read/profiling-and-optimization.html
  • @stusmith:如果您在 64 位操作系统上使用 Int64 可能会更快,但 Integer 类型经过高度优化以尽可能使用字大小的数据。由于在这个问题中大部分时间都是如此,因此整数是更快的选择。
  • @stusmith:这是一个示例,其中 Lisp 风格的前缀表示法或 Forth 风格的后缀表示法比数学混合前缀表示法更容易阅读。在 Lisp 中,sequenceLength 的最后一行是 (+ 1 (sequenceLength next)),在 Forth 中是 next sequenceLength 1 +。在这两种情况下,很容易看出+ 位于尾部位置,而不是sequenceLength,因此函数不是 尾部递归。你甚至可以在 Haskell 中看到,如果你用前缀(又名函数)表示法编写所有内容:sequenceLength n = (+) 1 (sequenceLength next)
  • @stusmith:比较器函数每对参数调用一次,但comparing sequenceLength 因为比较器函数调用sequenceLength 两次。更糟糕的是,sequenceLength 所花费的时间与它的输出成正比,而你正在寻找最大值......
【解决方案2】:

Haskell 的列表是基于堆的,而您的 C 代码非常紧凑,根本不使用堆。您需要重构以消除对列表的依赖。

【讨论】:

    【解决方案3】:

    比较可能重新计算sequenceLength 太多。这是我最好的版本:

    type I = Integer
    data P = P {-# UNPACK #-} !Int {-# UNPACK #-} !I deriving (Eq,Ord,Show)
    
    searchTo = 1000000
    
    nextNumber :: I -> I
    nextNumber n = case quotRem n 2 of
                      (n2,0) -> n2
                      _ -> 3*n+1
    
    sequenceLength :: I -> Int
    sequenceLength x = count x 1 where
      count 1 acc = acc
      count n acc = count (nextNumber n) (succ acc)
    
    longestSequence = maximum . map (\i -> P (sequenceLength i) i) $ [1..searchTo]
    
    main = putStrLn $ show $ longestSequence
    

    答案和时间比C慢,但它确实使用任意精度整数(通过Integer类型):

    ghc -O2 --make euler14-fgij.hs
    time ./euler14-fgij
    P 525 837799
    
    real 0m3.235s
    user 0m3.184s
    sys  0m0.015s
    

    【讨论】:

      【解决方案4】:

      即使我有点晚了,这是我的,我删除了对列表的依赖,这个解决方案也完全不使用堆。

      {-# LANGUAGE BangPatterns #-}
      -- Compiled with ghc -O2 -fvia-C -optc-O3 -Wall euler.hs
      module Main (main) where
      
      searchTo :: Int
      searchTo = 1000000
      
      nextNumber :: Int -> Int
      nextNumber n = case n `divMod` 2 of
         (k,0) -> k
         _     -> 3*n + 1
      
      sequenceLength :: Int -> Int
      sequenceLength n = sl 1 n where
        sl k 1 = k
        sl k x = sl (k + 1) (nextNumber x)
      
      longestSequence :: Int
      longestSequence = testValues 1 0 0 where
        testValues number !longest !longestNum
          | number > searchTo     = longestNum
          | otherwise            = testValues (number + 1) longest' longestNum' where
          nlength  = sequenceLength number
          (longest',longestNum') = if nlength > longest
            then (nlength,number)
            else (longest,longestNum)
      
      main :: IO ()
      main = print longestSequence
      

      我用ghc -O2 -fvia-C -optc-O3 -Wall euler.hs 编译了这篇文章,它在 5 秒内运行,而最初的实现是 80 秒。没有使用Integer,但是因为我是64位机器,结果可能会被骗。

      在这种情况下,编译器可以将所有Ints 拆箱,从而产生非常快的代码。它的运行速度比我目前看到的所有其他解决方案都快,但 C 仍然更快。

      【讨论】:

        【解决方案5】:

        虽然这已经相当老了,让我插一句,有一个关键点以前没有解决。

        首先,我的盒子上不同程序的时间安排。由于我使用的是 64 位 linux 系统,它们显示出一些不同的特征:使用 Integer 而不是 Int64 不会像使用 32 位 GHC 那样提高性能,其中每个 Int64 操作都会导致C 调用的成本,而 Integers 适合有符号 32 位整数的计算不需要外部调用(因为这里只有少数操作超出该范围,Integer 是 32 位的更好选择GHC)。

        • C:0.3 秒
        • 原始 Haskell:14.24 秒,使用 Integer 代替 Int64:33.96 秒
        • KennyTM 改进版:5.55 秒,使用Int:1.85 秒
        • Chris Kuklewicz 的版本:5.73 秒,使用Int:1.90 秒
        • FUZxxl 的版本:3.56 秒,使用quotRem 而不是divMod:1.79 秒

        那么我们有什么?

        1. 使用累加器计算长度,以便编译器(基本上)将其转换为循环
        2. 不要为比较重新计算序列长度
        3. 不要使用div resp。 divMod 不需要时,quot 分别。 quotRem 更快

        还缺少什么?

        if (j % 2 == 0)
            j = j / 2;
        else
            j = 3 * j + 1;
        

        我使用的任何 C 编译器都将测试 j % 2 == 0 转换为位掩码,并且不使用除法指令。 GHC(尚未)这样做。所以测试even n 或计算n `quotRem` 2 是一项相当昂贵的操作。将 KennyTM 的 Integer 版本中的 nextNumber 替换为

        nextNumber :: Integer -> Integer
        nextNumber n
            | fromInteger n .&. 1 == (0 :: Int) = n `quot` 2
            | otherwise = 3*n+1
        

        将其运行时间减少到 3.25 秒(注意:对于 Integern `quot` 2n `shiftR` 1 快,需要 12.69 秒!)。

        Int 版本中执行相同操作会将其运行时间减少到 0.41 秒。对于 Ints,除以 2 的位移比 quot 操作快一点,将其运行时间减少到 0.39 秒。

        消除列表的构造(在C版本中也没有出现),

        module Main (main) where
        
        import Data.Bits
        
        result :: Int
        result = findMax 0 0 1
        
        findMax :: Int -> Int -> Int -> Int
        findMax start len can
            | can > 1000000 = start
            | canlen > len = findMax can canlen (can+1)
            | otherwise = findMax start len (can+1)
              where
                canlen = findLen 1 can
        
        findLen :: Int -> Int -> Int
        findLen l 1 = l
        findLen l n
            | n .&. 1 == 0  = findLen (l+1) (n `shiftR` 1)
            | otherwise     = findLen (l+1) (3*n+1)
        
        main :: IO ()
        main = print result
        

        产生进一步小的加速,导致运行时间为 0.37 秒。

        因此与 C 版本密切对应的 Haskell 版本不会花费那么多时间,它是 ~1.3 的一个因子。

        好吧,说句公道话,C 版本的效率低下,而 Haskell 版本中没有,

        if (this_terms > terms)
        {
            terms = this_terms;
            longest = i;
        }
        

        出现在内循环中。在 C 版本中将其从内部循环中取出可将其运行时间减少到 0.27 秒,使因子约为 1.4。

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 2012-01-13
          • 1970-01-01
          • 2011-02-28
          • 2015-07-14
          • 2014-07-17
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多