如何在Haskell应用程序中减少内存使用?

34
我刚开始学习函数式编程,现在正在学习Haskell。作为练习,我决定实现一维线性扩散方程的显式欧拉方法。虽然下面的代码可以正常工作,但我对其性能不满意。实际上,我担心它的内存消耗。我相信这与惰性求值有关,但无法弄清楚如何减少它的内存使用。
算法的思想非常简单,在命令式术语中使其清晰:它接受一个“数组”,并向每个内部点添加一个值,该值是由点本身和其邻居点的值组合计算得出的。边界点是特殊情况。
所以,这是我的Euler1D.hs模块:
module Euler1D
( stepEuler
, makeu0
) where

-- impose zero flux condition
zeroflux :: (Floating a) => a -> [a] -> [a]
zeroflux mu (boundary:inner:xs) = [boundary+mu*2*(inner-boundary)]

-- one step of integration
stepEuler :: (Floating a) => a -> [a] -> [a]
stepEuler mu u@(x:xs) = (applyBC . (diffused mu)) u
    where
          diffused mu (left:x:[]) = []    -- ignore outer points
          diffused mu (left:x:right:xs) = -- integrate inner points
                   (x+mu*(left+right-2*x)) : diffused mu (x:right:xs)
          applyBC inner = (lbc u') ++ inner ++ (rbc u') -- boundary conditions
               where u' = [head u] ++ inner ++ [last u]
                     lbc = zeroflux mu             -- left boundary
                     rbc = (zeroflux mu) . reverse -- right boundary

-- initial condition
makeu0 :: Int -> [Double]
makeu0 n = [ ((^2) . sin . (pi*) . xi) x | x <- [0..n]]
    where xi x = fromIntegral x / fromIntegral n

还有一个简单的Main.hs:

module Main where

import System ( getArgs )
import Euler1D

main = do
      args <- getArgs
      let n = read $ head args :: Int
      let u0 = makeu0 n
      let un = stepEuler 0.5 u0
      putStrLn $ show $ sum un

为了对比,我还写了一个纯C实现

现在,如果我尝试运行Haskell实现,当数组n足够大时,我会得到:

$ time ./eulerhs 200000
100000.00000000112

real    0m3.552s
user    0m3.304s
sys     0m0.128s

相比之下,C语言版本的速度快了近两个数量级:

$ time ./eulerc 200000
100000

real    0m0.088s
user    0m0.048s
sys     0m0.008s

编辑: 这个比较并不公平,因为Haskell版本是使用分析标志编译的,而C则没有。如果我用-O2编译两个程序,并且都不用分析标志,我可以增加n。在这种情况下,time ./eulerhs 1000000需要0m2.236s,而time ./eulerc 1000000只需要0m0.293s。所以问题仍然存在,即使有所有优化且没有分析,它也只是偏移。

我还想指出的是,Haskell程序的内存分配似乎随着n线性增长。这可能是可以接受的。

但最糟糕的是内存要求。我的Haskell版本需要超过100MB(我估计C的最低要求是4MB)。我认为这可能是问题的根源。根据分析报告,程序花费85%的时间在GC中。

        total time  =        0.36 secs   (18 ticks @ 20 ms)
        total alloc = 116,835,180 bytes  (excludes profiling overheads)

COST CENTRE                    MODULE               %time %alloc

makeu0                         Euler1D               61.1   34.9
stepEuler                      Euler1D               33.3   59.6
CAF:sum                        Main                   5.6    5.5

我很惊讶地发现makeu0是如此昂贵。我认为这是由于它的惰性求值(如果它的thunk保留在内存中直到stepEuler结束)。

我在Main.hs中尝试了这个变化:

   let un = u0 `seq` stepEuler 0.5 u0

但是没有注意到任何区别。我不知道如何减少stepEuler中的内存使用。所以,我的问题是:

  1. 在Haskell中有没有一种方法可以严格构建列表/进行列表推导?在这种情况下,保持惰性没有任何好处。
  2. 在这种情况下,如何减少总体内存使用?我想,我必须使某些东西变得严格,但不知道该怎么做。换句话说,如果我必须放一些seq和bangs,在哪里以及为什么?
  3. 最后,最重要的是,识别这样昂贵的结构的最佳策略是什么?

我阅读了Real World Haskell中关于分析和优化的章节,但仍不清楚我应该如何决定什么应该是严格的,什么不应该是严格的。

请原谅我发这么长的帖子。

编辑2:根据评论中A. Rex的建议,我尝试在valgrind中运行这两个程序。下面是我的观察结果。对于Haskell程序(n=200000),它发现:

malloc/free: 33次分配,30次释放,已分配84,109字节。 ... 已检查55,712,980字节。

而对于C程序(经过小修复):

malloc/free: 2次分配,2次释放,已分配3,200,000字节。

因此,看起来虽然Haskell分配了更小的内存块,但它经常这样做,并且由于垃圾回收延迟,它们会累积并保留在内存中。所以,我有另一个问题:

  • 是否可能在Haskell中避免大量小的分配?基本上,声明需要处理整个数据结构而不仅仅是按需处理其片段。

1
就这个问题而言,当使用了-O2开关后,Haskell版本在我的电脑上运行速度只比gcc慢了4倍。 - A. Rex
很奇怪,valgrind 报告说 Haskell 使用的内存比 C 版本少得多。但我仍然认为这是一个好问题,因为分析 Haskell 的内存性能是困难的。我希望有人能给出比我的略微不够准确的“对我有用”的答案更好的回答。 - A. Rex
Rex,你可以在自己的回答中提及编译器设置的参考,我认为这很有用。 - Svante
如果它只运行慢4倍,我会很高兴。我刚刚尝试了重新编译而没有进行性能分析,结果明显好多了,所以我可以增加n的值。time ./eulerhs 1000000 运行时间为2.236秒,而 time ./eulerc 1000000 运行时间为0.293秒。我将编辑问题。谢谢你的评论! - sastanin
给 A. Rex:valgrind 报告说 Haskell 使用的内存比 C 版本少得多。我没有在 C 中的程序退出时释放已分配的数组,这就是为什么 valgrind 抱怨的原因。修复后,valgrind 报告说 Haskell 程序进行了更多的小型分配...但这给了一个想法! - sastanin
7个回答

20
  1. 对于这种带有大量(++), (last)的代码,列表不是最好的数据结构。你会浪费很多时间在构建和析构列表上。我建议使用Data.Sequence或者数组,就像在C版本中一样。

  2. makeu0的thunks没有机会进行垃圾回收,因为你需要保留它们所有(确切地说是所有“diffuse”的结果),直到计算结束才能执行applyBC中的“reverse”。这是非常昂贵的操作,考虑到你只需要从列表的尾部获取两个项目来进行“zeroflux”。

这里是对你的代码进行快速优化的方法,试图实现更好的列表融合,并减少列表的(解)构造:

module Euler1D
( stepEuler
) where

-- impose zero flux condition
zeroflux mu (boundary:inner:xs) = boundary+mu*2*(inner-boundary)

-- one step of integration
stepEuler mu n = (applyBC . (diffused mu)) $ makeu0 n
    where
          diffused mu (left:x:[]) = []    -- ignore outer points
          diffused mu (left:x:right:xs) = -- integrate inner points
                   let y = (x+mu*(left+right-2*x))
                       in y `seq` y : diffused mu (x:right:xs)
          applyBC inner = lbc + sum inner + rbc -- boundary conditions
               where
                     lbc = zeroflux mu ((f 0 n):inner)             -- left boundary
                     rbc = zeroflux mu ((f n n):(take 2 $ reverse inner)) -- right boundary

-- initial condition
makeu0 n = [ f x n | x <- [0..n]]

f x n = ((^2) . sin . (pi*) . xi) x
    where xi x = fromIntegral x / fromIntegral n

对于 200000 个点,它的完成时间为 0.8 秒,而初始版本需要 3.8 秒。


非常感谢!这正是我在寻找的答案。我不知道Data.Sequence和数组,但我怀疑可以避免大量的列表操作。另外,在diffused中,y seq y是一个很好的模式...applyBC也可以使用take函数。谢谢! - sastanin
尽管在stepEuler中进行求和会改变函数的原始语义,但我已经恢复了原始语义(http://pastebin.com/f5ca77a7f),它仍然运行得相当快(2e5个点需要0.5秒)。我现在会考虑使用Data.Sequence。 - sastanin

10

在我的32位x86系统上,你的程序只使用了大约40 MB的内存。

你是否可能将你输出的性能分析中的“total alloc = 116,835,180 bytes”一行与程序实际每次使用的内存混淆了?total alloc是整个程序运行期间分配的内存总量;其中许多内存会随着垃圾收集器的回收而释放。你可以预期这个数字在Haskell程序中会变得非常大。我有一些程序在整个运行过程中分配了许多TB的内存,尽管它们实际上的最大虚拟内存大小只有100 MB左右。

我不会太担心程序运行期间总分配量很大;这是纯语言的本质,并且GHC的运行时具有非常好的垃圾收集器来帮助弥补这一点。


1
是的,完全正确!我曾将“total alloc”理解为最大内存分配。现在对我来说更加清晰了。谢谢! - sastanin

4
更一般地,您可以使用 GHC的堆分析工具找出您的内存去了哪里。根据我的经验,它们不一定会告诉您为什么数据泄漏,但至少可以缩小潜在原因的范围。
您还可能会发现这篇Don Stewart的优秀博客文章对于理解严格性、它如何与垃圾回收交互以及如何诊断和解决问题非常有启发性。

Don Stewart的帖子非常非常有用。非常感谢! - sastanin
两个链接现在都失效了 :( - 4castle

2

使用$!强制“非惰性”是否有帮助?根据这个答案


谢谢!我尝试严格评估stepEuler的参数uapplyBC中的所有参数(请参见http://pastebin.com/f56a2079d),但效果却相反:程序分配的内存几乎多了50%(根据valgrind的结果),运行时间也增加了近50%。 - sastanin

1

根据Per Harleqin的请求:您尝试过设置优化标志吗?例如,使用ghc时,您可以像使用gcc一样添加“-O2”。 (尽管我不确定ghc中存在哪些优化级别;手册并没有完全说明...)

根据我的过去经验,设置此标志会产生巨大的差异。就我所知,runhugs和未经优化的ghc使用Haskell最基本、最明显的实现;不幸的是,这有时并不是很高效。

但这只是一个猜测。正如我在评论中所说,我希望有人能很好地回答您的问题。我经常遇到分析和修复Haskell内存使用的问题。


是的,我使用了优化标志进行编译:ghc -O2 -c -prof -auto-all -caf-all -fforce-recomp Euler1D.hs; ghc -O2 -o eulerhs Main.hs Euler1D.o -prof -auto-all -caf-all -fforce-recomp - sastanin

1

同时使用 -fvia-C 开关。


0

现在引起我的注意的一件事是,Haskell输出是浮点数,而C输出似乎是整数。我还没有完全掌握Haskell代码,但是在Haskell中是否有使用浮点运算而C使用整数的地方呢?


1
不,C输出不是整数。只是printf在打印时尝试以漂亮的方式表示结果。C算术完全使用double。 - sastanin

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接