【问题标题】:Tools for analyzing performance of a Haskell program用于分析 Haskell 程序性能的工具
【发布时间】:2011-03-17 14:47:12
【问题描述】:

在解决一些 Project Euler 问题以学习 Haskell(所以目前我完全是初学者)时,我遇到了 Problem 12。我写了这个(天真的)解决方案:

--Get Number of Divisors of n
numDivs :: Integer -> Integer
numDivs n = toInteger $ length [ x | x<-[2.. ((n `quot` 2)+1)], n `rem` x == 0] + 2

--Generate a List of Triangular Values
triaList :: [Integer]
triaList =  [foldr (+) 0 [1..n] | n <- [1..]]

--The same recursive
triaList2 = go 0 1
  where go cs n = (cs+n):go (cs+n) (n+1)

--Finds the first triangular Value with more than n Divisors
sol :: Integer -> Integer
sol n = head $ filter (\x -> numDivs(x)>n) triaList2

n=500(sol 500) 的这个解决方案非常慢(现在运行了 2 个多小时),所以我想知道如何找出这个解决方案为什么这么慢。是否有任何命令可以告诉我大部分计算时间都花在了哪里,所以我知道我的 haskell 程序的哪一部分很慢?类似于一个简单的分析器。

为了清楚起见,我不是在要求 更快的解决方案,而是要求 一种方法 来找到此解决方案。如果你没有 haskell 知识,你会如何开始?

我尝试编写两个triaList 函数,但没有办法测试哪个更快,所以这就是我的问题开始的地方。

谢谢

【问题讨论】:

    标签: haskell performance profiling


    【解决方案1】:

    如何找出这个解决方案如此缓慢的原因。是否有任何命令可以告诉我大部分计算时间都花在了哪里,以便我知道我的 haskell 程序的哪一部分很慢?

    没错! GHC 提供了许多优秀的工具,包括:

    使用时间和空间分析的教程是part of Real World Haskell

    GC 统计数据

    首先,确保您使用 ghc -O2 进行编译。您可以确保它是现代 GHC(例如 GHC 6.12.x)

    我们可以做的第一件事是检查垃圾收集是否不是问题。 用 +RTS -s 运行你的程序

    $ time ./A +RTS -s
    ./A +RTS -s 
    749700
       9,961,432,992 bytes allocated in the heap
           2,463,072 bytes copied during GC
              29,200 bytes maximum residency (1 sample(s))
             187,336 bytes maximum slop
                   **2 MB** total memory in use (0 MB lost due to fragmentation)
    
      Generation 0: 19002 collections,     0 parallel,  0.11s,  0.15s elapsed
      Generation 1:     1 collections,     0 parallel,  0.00s,  0.00s elapsed
    
      INIT  time    0.00s  (  0.00s elapsed)
      MUT   time   13.15s  ( 13.32s elapsed)
      GC    time    0.11s  (  0.15s elapsed)
      RP    time    0.00s  (  0.00s elapsed)
      PROF  time    0.00s  (  0.00s elapsed)
      EXIT  time    0.00s  (  0.00s elapsed)
      Total time   13.26s  ( 13.47s elapsed)
    
      %GC time       **0.8%**  (1.1% elapsed)
    
      Alloc rate    757,764,753 bytes per MUT second
    
      Productivity  99.2% of total user, 97.6% of total elapsed
    
    ./A +RTS -s  13.26s user 0.05s system 98% cpu 13.479 total
    

    这已经给了我们很多信息:你只有一个 2M 的堆,而 GC 占用了 0.8% 的时间。所以不用担心分配是问题。

    时间档案

    为您的程序获取时间配置文件很简单:使用 -prof -auto-all 进行编译

     $ ghc -O2 --make A.hs -prof -auto-all
     [1 of 1] Compiling Main             ( A.hs, A.o )
     Linking A ...
    

    并且,对于 N=200:

    $ time ./A +RTS -p                   
    749700
    ./A +RTS -p  13.23s user 0.06s system 98% cpu 13.547 total
    

    创建一个文件 A.prof,其中包含:

        Sun Jul 18 10:08 2010 Time and Allocation Profiling Report  (Final)
    
           A +RTS -p -RTS
    
        total time  =     13.18 secs   (659 ticks @ 20 ms)
        total alloc = 4,904,116,696 bytes  (excludes profiling overheads)
    
    COST CENTRE          MODULE         %time %alloc
    
    numDivs            Main         100.0  100.0
    

    表示所有你的时间都花在了numDivs上,也是你所有分配的来源。

    堆配置文件

    您还可以通过运行 +RTS -p -hy 来分解这些分配,这会创建 A.hp,您可以通过将其转换为 postscript 文件 (hp2ps -c A.hp) 来查看它,生成:

    这告诉我们您的内存使用没有任何问题:它在恒定空间中分配。

    所以你的问题是 numDivs 的算法复杂性:

    toInteger $ length [ x | x<-[2.. ((n `quot` 2)+1)], n `rem` x == 0] + 2
    

    解决这个问题,这是您运行时间的 100%,其他一切都很容易。

    优化

    这个表达式是stream fusion优化的一个很好的候选,所以我会重写它 使用Data.Vector,像这样:

    numDivs n = fromIntegral $
        2 + (U.length $
            U.filter (\x -> fromIntegral n `rem` x == 0) $
            (U.enumFromN 2 ((fromIntegral n `div` 2) + 1) :: U.Vector Int))
    

    应该融合成一个没有不必要的堆分配的循环。也就是说,它将比列表版本具有更好的复杂性(通过常数因素)。您可以使用 ghc-core 工具(高级用户)检查优化后的中间代码。

    对此进行测试,ghc -O2 --make Z.hs

    $ time ./Z     
    749700
    ./Z  3.73s user 0.01s system 99% cpu 3.753 total
    

    因此它在不改变算法本身的情况下将 N=150 的运行时间减少了 3.5 倍。

    结论

    您的问题是 numDivs。它是您运行时间的 100%,并且具有可怕的复杂性。 想想 numDivs,例如,对于每个 N,您如何生成 [2 .. n div2 + 1] N 次。 尝试记住它,因为值不会改变。

    要衡量您的哪个函数更快,请考虑使用criterion,它将提供有关运行时间亚微秒改进的统计可靠信息。


    附录

    由于 numDivs 是您运行时间的 100%,因此接触程序的其他部分不会有太大区别, 但是,出于教学目的,我们也可以使用流融合重写那些。

    我们也可以重写trialList,依靠fusion变成你在trialList2中手写的循环, 这是一个“前缀扫描”功能(又名 scanl):

    triaList = U.scanl (+) 0 (U.enumFrom 1 top)
        where
           top = 10^6
    

    同样适用于 sol:

    sol :: Int -> Int
    sol n = U.head $ U.filter (\x -> numDivs x > n) triaList
    

    总体运行时间相同,但代码更简洁。

    【讨论】:

    • 只是给像我这样的其他白痴的注释:Don 在 Time Profiles 中提到的 time 实用程序就是 Linux time 程序。它在 Windows 中不可用。因此,对于 Windows(实际上是任何地方)上的时间分析,请参阅 this 问题。
    • 对于未来的用户,-auto-all 已弃用,取而代之的是 -fprof-auto
    【解决方案2】:

    Dons 的回答非常棒,而且没有剧透,直接解决了问题。
    在这里我想推荐一点我最近写的tool。当您想要比默认的ghc -prof -auto-all 更详细的配置文件时,它可以节省您手动编写 SCC 注释的时间。不仅如此,它还色彩斑斓!

    这是您给出的代码示例(*),绿色可以,红色很慢:

    所有时间都在创建除数列表。这表明您可以做一些事情:
    1. 让n rem x == 0的过滤更快,但由于它是一个内置功能,可能已经很快了。
    2. 创建一个较短的列表。您已经在这个方向上做了一些事情,只检查了n quot 2
    3. 完全抛弃列表生成并使用一些数学来获得更快的解决方案。这是项目欧拉问题的常用方法。

    (*) 我通过将您的代码放在一个名为 eu13.hs 的文件中,添加一个主函数 main = print $ sol 90 来得到这个。然后运行visual-prof -px eu13.hs eu13,结果在eu13.hs.html

    【讨论】:

      【解决方案3】:

      Haskell 相关说明:triaList2 当然比triaList 快,因为后者执行了大量不必要的计算。计算 triaList 的 n 个第一个元素需要二次时间,但对于 triaList2 是线性的。还有另一种优雅(且高效)的方式来定义三角形数的无限惰性列表:

      triaList = 1 : zipWith (+) triaList [2..]
      

      数学相关注意事项:不需要检查直到 n / 2 的所有除数,检查到 sqrt(n) 就足够了。

      【讨论】:

      • 还要考虑:scanl (+) 1 [2..]
      【解决方案4】:

      您可以使用标志运行程序以启用时间分析。像这样的:

      ./program +RTS -P -sprogram.stats -RTS
      

      这应该运行程序并生成一个名为 program.stats 的文件,其中包含每个函数花费了多少时间。您可以在 GHC user guide 中找到有关使用 GHC 进行分析的更多信息。对于基准测试,有 Criterion 库。我发现this 博客文章有一个有用的介绍。

      【讨论】:

      • 但是先用ghc -prof -auto-all -fforce-recomp --make -O2 program.hs编译一下
      最近更新 更多