【问题标题】:How to reduce memory usage in a Haskell app?如何减少 Haskell 应用程序中的内存使用量?
【发布时间】:2010-10-02 08:43:25
【问题描述】:

我是函数式编程的新手,现在学习 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

为了比较,我还写了a pure C implementation。

现在,如果我尝试为足够大的数组 n 运行 Haskell 实现,我有:

$ 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

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

我还想指出,那段记忆 Haskell程序的分配 似乎与n 呈线性增长。 这应该没问题。

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

        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. 在这种情况下如何减少总体内存使用量?我想,我必须做一些严格的事情,但看不到是什么。换句话说,如果我必须放一些seqs 和刘海,在哪里以及为什么?
  3. 最后,最重要的是,识别此类昂贵构造的最佳策略是什么?

我确实在Real World Haskell 中阅读了有关性能分析和优化的一章,但目前尚不清楚我如何准确地决定什么应该严格,什么不应该。

请原谅我这么长的帖子。

EDIT2:正如 A. Rex 在 cmets 中所建议的,我尝试同时运行这两个 valgrind 中的程序。这就是 我观察到。对于 Haskell 程序 (n=200000) 它发现:

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

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

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

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

  • 是否可以避免很多 Haskell中的小分配? 基本上,要声明,我需要 处理整个数据结构 而不仅仅是它的片段 需求。

【问题讨论】:

  • 对于我来说,当 gcc 和 ghc 都被赋予 -O2 开关时,Haskell 版本的运行速度只慢了 4 倍。
  • 奇怪的是,valgrind 报告说 Haskell 使用的内存比 C 版本大大少。 =/ 但我仍然认为这是一个很好的问题,因为在 Haskell 中分析内存性能很困难。我希望有人能给出比我稍微不知情的“为我工作”更好的答案。
  • A. Rex,您可以将此对编译器设置的引用放在自己的答案中,我认为这很有用。
  • 如果它只慢 4 倍,我会很高兴。我刚刚尝试在不进行分析的情况下重新编译,当然,它变得更好了,所以我可以增加n。 time ./eulerhs 1000000 运行时间为 2.236 秒,time ./eulerc 1000000 运行时间为 0.293 秒。我将编辑问题。感谢 cmets!
  • 致 A. Rex:valgrind 报告说 Haskell 使用的内存比 C 版本少得多。我没有在 C 程序退出时释放分配的数组,这就是 valgrind 抱怨的原因。修复后,valgrind 报告说 Haskell 程序做了更多的小分配......但这给出了一个想法!

标签: haskell functional-programming garbage-collection memory-management lazy-evaluation


【解决方案1】:
  1. 列表不是此类代码的最佳数据结构(有很多 (++) 和 (last))。你会浪费很多时间来构建和解构列表。我会使用 Data.Sequence 或数组,就像在 C 版本中一样。

  2. makeu0 的 thunk 没有机会被垃圾收集,因为您需要一直保留所有这些(嗯,确切地说是“扩散”的所有结果)直到最后为了能够在 applyBC 中进行“反向”计算。这是非常昂贵的事情,考虑到您的“zeroflux”只需要列表尾部的两个项目。

这里是你的代码的快速破解,它试图实现更好的列表融合并减少列表(de)构造:

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 和数组,但怀疑可以避免大量列表操作。附言y seq y:…在diffused中是一个很好的模式…也可以在applyBC…谢谢!
  • 虽然,stepEuler 中的求和改变了函数的原始语义。我恢复了原始语义(pastebin.com/f5ca77a7f),它仍然运行得相当快(2e5 点为 0.5 秒)。我现在会考虑使用 Data.Sequence。
【解决方案2】:

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

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

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

【讨论】:

  • 是的,没错!我确实理解“总分配”,比如最大内存分配。现在我更清楚了。谢谢!
【解决方案3】:

更一般地说,您可以使用GHC's heap profiling tools. 找出您的内存去向,根据我的经验,它们不一定会告诉您为什么您的数据会被泄露,但至少可以缩小潜在原因的范围。

您还可以在 excellent blog post by Don Stewart 中找到关于理解严格性、它如何与垃圾收集交互以及如何诊断和修复问题的启发。

【讨论】:

  • 谢谢你的链接,daf!
  • Don Stewart 的帖子非常非常有帮助。非常感谢!
  • 两个链接现在都失效了:(
【解决方案4】:

是否强制使用 $!帮助?根据this answer。

【讨论】:

  • 谢谢!我试图严格评估u、stepEuler 的参数以及applyBC 中的所有参数(请参阅pastebin.com/f56a2079d),但效果相反:程序分配了近 50%(根据 valgrind),并且运行时间增加了近 50%。
【解决方案5】:

根据 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
【解决方案6】:

也使用开关-fvia-C。

【讨论】:

    【解决方案7】:

    现在让我眼前一亮的一件事是 Haskell 输出是浮点数,而 C 输出似乎是整数。我还没有掌握 Haskell 代码,但也许有一些地方你在 Haskell 中有浮点运算,而 C 使用整数?

    【讨论】:

    • 不,C 输出不是整数。只是 printf 试图在打印时很好地表示结果。 C 算术完全是双精度的。
    猜你喜欢
    • 1970-01-01
    • 2010-09-28
    • 2020-12-24
    • 2018-03-06
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2019-12-07
    • 1970-01-01
    相关资源
    最近更新 更多