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)。我认为这可能是问题的根源。根据分析报告,该程序在 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 在评论中所建议的那样,我尝试在 valgrind 中运行这两个程序。这就是我观察到的。对于 Haskell 程序(n=200000),它发现:

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

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

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

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

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

7 回答 7

20
  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 秒

于 2009-01-22T09:38:18.513 回答
10

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

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

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

于 2009-06-06T08:44:15.107 回答
4

更一般地说,您可以使用GHC 的堆分析工具找出内存的去向。以我的经验,他们不一定会告诉您数据泄露的原因,但至少可以缩小潜在原因的范围。

您还可能会发现这篇由 Don Stewart 撰写的关于理解严格性、它如何与垃圾收集交互以及如何诊断和修复问题的优秀博文对您有所启发。

于 2009-01-31T15:41:37.263 回答
2

是否使用 $! 强制“不懒惰”?帮助?按照这个答案

于 2009-01-20T06:08:13.073 回答
1

根据 Harleqin 的要求:您是否尝试过设置优化标志?例如,使用 ghc,您可以像使用 gcc 一样使用添加“-O2”。(虽然我不确定 ghc 中存在哪些优化级别;手册页并没有准确地说......)

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

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

于 2009-01-20T06:29:39.597 回答
1

也使用开关-fvia-C

于 2009-01-20T13:27:57.383 回答
0

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

于 2009-01-20T09:40:45.017 回答