【问题标题】:Haskell: how to detect "lazy memory leaks"Haskell:如何检测“惰性内存泄漏”
【发布时间】:2020-08-23 06:46:25
【问题描述】:

经过几个小时的调试,我意识到一个非常简单的玩具示例效率不高,因为表达式 return $ 1 + x 中缺少 !(感谢 duplode!...但是为什么 ghc 没有优化呢? )。我也意识到了这一点,因为我将它与更快的 Python 代码进行比较,但我不会总是编写 Python 代码来对我的代码进行基准测试......

所以这是我的问题:有没有办法自动检测这些“惰性内存泄漏”,无缘无故减慢程序速度?我在优化 Haskell 代码方面仍然很糟糕,而且很可能忘记!,即使我猜你有经验。

我知道:

  • +RTS -s,但我不确定如何解释它:例如,看到一个简单程序的内存 79MB 对我来说似乎很大,但也许不是因为它是我当前程序的原因......对于更大的程序,我猜不可能只检测“延迟泄漏”,因为我不知道我的程序应该占用多少内存。
  • cabal v2-run --enable-profiling mysatsolvers -- +RTS -p 命令,但似乎启用分析器会破坏 GHC 所做的一些优化,因此很难将这些值用于真正的基准测试。尽管如此,我仍然不清楚如何从该输出中找到泄漏。

您能举例说明一下我如何在像这样的玩具程序中找到“惰性泄漏”吗?

{-# LANGUAGE DerivingVia, FlexibleInstances, ScopedTypeVariables #-}
module Main where

--- It depends on the transformers, containers, and base packages.
--- Optimisation seems to be important or the NoLog case will be way to long.
--- $ ghc -O Main.hs

import qualified Data.Map.Strict as MapStrict
import Data.Functor.Identity

import qualified Control.Monad as CM
import qualified Control.Monad.State.Strict as State
import qualified Data.Time as Time

-- Create a class that allows me to use the function "myTell"
-- that adds a number in the writer (either the LogEntry
-- or StupidLogEntry one)
class Monad m => LogFunctionCalls m where
  myTell :: String -> Int -> m ()

---------- Logging disabled ----------
--- (No logging at all gives the same time so I don't put here)
newtype NoLog a = NoLog { unNoLog :: a }
  deriving (Functor, Applicative, Monad) via Identity

instance LogFunctionCalls NoLog where
  myTell _ _ = pure ()

---------- Logging with Map ----------
-- When logging, associate a number to each name.
newtype LogEntryMap = LogEntryMap (MapStrict.Map String Int)
  deriving (Eq, Show)

instance LogFunctionCalls (State.State LogEntryMap) where
  myTell namefunction n = State.modify' $
    \(LogEntryMap m) ->
      LogEntryMap $ MapStrict.insertWith (+) namefunction n m

---------- Logging with Int ----------
-- Don't use any Map to avoid inefficiency of Map
newtype LogEntryInt = LogEntryInt Int
  deriving (Eq, Show)

instance LogFunctionCalls (State.State LogEntryInt) where
  myTell namefunction n = State.modify' $
    \(LogEntryInt m) -> LogEntryInt $! m + n

---------- Function to compute ----------
countNumberCalls :: (LogFunctionCalls m) => Int -> m Int
countNumberCalls 0 = return 0
countNumberCalls n = do
  myTell "countNumberCalls" 1
  x <- countNumberCalls $! n - 1
  return $ 1 + x

main :: IO ()
main = do
  let www = 15000000
  putStrLn $ "Let's start!"
  --- Logging disabled
  t0 <- Time.getCurrentTime
  let n = unNoLog $ countNumberCalls www
  putStrLn $ "Logging disabled: " ++ (show n)
  t1 <- Time.getCurrentTime
  print (Time.diffUTCTime t1 t0)
  -- Logging with Map
  let (n, LogEntryMap log) = State.runState (countNumberCalls www) (LogEntryMap MapStrict.empty)
  putStrLn $ "Logging with Map: " ++ (show n)
  putStrLn $ (show $ log)
  t2 <- Time.getCurrentTime
  print (Time.diffUTCTime t2 t1)
  -- Logging with Int
  let (n, LogEntryInt log) = State.runState (countNumberCalls www) (LogEntryInt 0)
  putStrLn $ "Logging with Int: " ++ (show n)
  putStrLn $ (show $ log)
  t3 <- Time.getCurrentTime
  print (Time.diffUTCTime t3 t2)

【问题讨论】:

  • 请考虑在此处为您的案例开一张 GHC 票。需求分析器在这里无法改进可能是有原因的,但谁知道呢。 GHC 总部可能会喜欢这个独立的例子

标签: haskell optimization memory-leaks lazy-evaluation


【解决方案1】:

检测内存泄漏的主要方法是堆分析。具体来说,您正在寻找驻留(主要是堆)内存量的意外增长,或者是+RTS -s 统计输出中的最大驻留,或者——更可靠地——一个特征“金字塔” " 使用+RTS -h&lt;x&gt; 标志和hp2ps 工具生成的堆配置文件输出中随时间变化的形状。

如果我用+RTS -s 运行你的玩具程序,我明白了:

   3,281,896,520 bytes allocated in the heap
   3,383,195,568 bytes copied during GC
     599,346,304 bytes maximum residency (17 sample(s))
       5,706,584 bytes maximum slop
             571 MB total memory in use (0 MB lost due to fragmentation)

第一行一般可以忽略。 Haskell 程序通常在运行时每秒分配大致恒定的内存量,并且该分配率几乎为零(对于某些不寻常的程序)或每秒 0.5-2.0 GB。该程序运行了 4 秒并分配了 3.8 GB,这并不罕见。

但是,在 GC 和最大驻留期间复制的字节数是相关的。假设您有一个希望在恒定空间中运行的程序(即,没有需要其全部内容的不断增长的数据结构),一个正常运行的 Haskell 程序通常不需要在垃圾收集期间复制大量数据,并且倾向于最大驻留量是分配的总字节数的一小部分(例如,100 KB 而不是 0.5GB),并且不会随着您正在测试的任何“迭代”次数而大幅增长。

您可以随着时间的推移快速生成堆配置文件,而无需打开正式的配置文件。如果您使用 GHC 标志 -rtsopts 进行编译,则可以使用:

./Toy +RTS -hT

然后使用hp2ps 工具以图形方式显示结果:

hp2ps -c -e8in Toy.hp
evince Toy.ps &

这种金字塔模式是一个危险信号:

请注意,堆以每秒数百兆字节的速度快速线性增长,然后是快速线性崩溃。这是您在一次强制执行整个计算之前不必要地构建一个巨大的惰性数据结构时看到的模式。您在这里看到两个金字塔,因为您的第二个和第三个测试都显示内存泄漏。

顺便说一句,x 轴以“MUT 秒”为单位(“mutator”运行的秒数,不包括垃圾收集),这就是为什么它小于实际的 4 秒运行时间。这实际上是另一个危险信号。花费一半时间进行垃圾收集的 Haskell 程序可能无法正常运行。

要获得有关导致此堆金字塔的原因的更多详细信息,您需要在启用分析的情况下进行编译。分析可能会导致程序运行速度稍慢,但通常不会改变哪些优化已经到位。但是,自动插入成本中心的标志-fprof-auto(和相关标志)有可能导致较大的性能变化(通过干扰内联等)。不幸的是,cabal --enable-profiling 标志打开了分析(编译器标志 -prof标志 -fprof-auto-top 自动为顶级功能生成成本中心,因此对于您的玩具示例,这基本上更改第一个测试用例的行为(将运行时间从 0.4 秒增加到 5 秒,即使没有 +RTS 标志)。这可能是您在分析影响结果时看到的问题。对于几种其他类型的堆配置文件,您不需要任何成本中心,因此您可以添加 cabal 标志 --profiling-detail=none 来关闭它,然后您的配置文件程序应该运行时间稍慢但通常与未配置文件类似的性能版本。

我不使用 Cabal,但使用以下内容进行编译(应该相当于 --enable-profiling --profiling-detail=none):

ghc -O2 -rtsopts -prof Toy.hs    # no -fprof-auto...

我可以通过按数据类型分析来运行您的程序:

./Toy +RTS -hy

如果我查看堆剖面图:

这将大部分堆归因于 Int 类型——这将我的问题缩小到一堆未经评估的懒惰 Int 计算,这可能会为我指明正确的方向。

如果我在缩小范围时确实遇到了麻烦,并且感觉自己像是在深入研究技术,我也可以通过闭包来运行堆配置文件(标志 -hd)。这告诉我,两个金字塔的罪魁祸首分别是Main.sat_s7mQMain.sat_s7kP。这看起来很神秘,但它们是“STG”中函数的名称,这是编译器生成的我的程序的低级中间表示。

如果我使用相同的标志重新编译但添加 -fforce-recomp -ddump-stg -dsuppress-all:

ghc -O2 -rtsopts -prof -fforce-recomp -ddump-stg -dsuppress-all Toy.hs

这将转储包含这两个函数定义的 STG。 (生成的标识符可能会因代码和/或编译器标志的微小更改而有所不同,因此最好使用转储的 STG 重新编译,然后重新分析该可执行文件,以确保标识符匹配。)

如果我在 STG 中搜索第一个罪魁祸首,我会找到定义:

sat_s7mQ =
    CCCS \u []
        case ww2_s7mL of {
          I# y_s7mO ->
              case +# [1# y_s7mO] of sat_s7mP {
                __DEFAULT -> I# [sat_s7mP];
              };
        };

是的,这都是非常技术性的,但这是 STG 对表达式 1 + y 的说法,这将帮助我找出罪魁祸首。

如果您不会说 STG,可以尝试介绍一些成本中心。例如,我尝试使用-fprof-auto(Cabal 标志--profiling-detail=all-functions)分析您的第二个测试用例。 Toy.prof 中的配置文件输出对内存泄漏没有 有用,因为它处理的是总分配而不是随时间推移的活动(即常驻而不是垃圾收集)分配,但您可以创建一个堆通过运行按成本中心分析:

./Toy +RTS -hc

在这种情况下,它将所有内容归于一个成本中心,即(315)countNumberCalls。 “315”是成本中心编号,如果名称不清楚,您可以在 Toy.prof 输入中查找确切的源代码行。无论如何,这至少有助于将问题范围缩小到countNumberCalls

对于更复杂的功能,有时您可以通过手动指定成本中心来进一步缩小问题范围,如下所示:

countNumberCalls :: (LogFunctionCalls m) => Int -> m Int
countNumberCalls 0 = return 0
countNumberCalls n = do
  {-# SCC "mytell_call" #-} myTell "countNumberCalls" 1
  x <- {-# SCC "recursive_call" #-} countNumberCalls $! n - 1
  {-# SCC "return_statment" #-} return $ {-# SCC "one_plus_x" #-} 1 + x

这实际上将所有内容都归因于“recursive_call”,因此没有那么有用。

不过,这并没有错。这里实际上有两个内存泄漏——x &lt;- countNumberCalls $! n - 1 泄漏堆,因为x 不是强制的,1 + x 泄漏堆栈。您可以启用BangPatterns 扩展并编写:

!x <- countNumebrCalls $1 n - 1

这实际上会消除其中一个内存泄漏,将第二种情况从 2.5 秒加速到 1.0 秒,并将最大驻留时间从 460 兆降低到 95 兆(在 GC 期间复制的字节从 1.5 Gig 降低到 73 KB! )。但是,堆配置文件将显示线性增长的堆栈几乎占所有常驻内存。因为堆栈不像堆那样被很好地跟踪,所以跟踪起来会更困难。

一些补充说明:

尽管+RTS -h&lt;x&gt; 标志主要用于堆分析(并在 GHC 文档中作为“堆分析”选项进行讨论),但它们在技术上可以报告 resident 的其他用途 除了堆之外的内存,包括每个线程的状态,其中包括线程状态对象和堆栈。默认情况下,当运行分析二进制文件(使用 -prof 编译)时,+RTS -h&lt;x&gt; 标志不会报告每个线程的状态,包括堆栈,但您可以添加 -xt flag 来添加它,如+RTS -hc -xt。由于可能的无意疏忽,在未分析的二进制文件中,+RTS -hT 标志(唯一可用的-h&lt;x&gt; 标志)包括堆栈,即使没有-xt 标志。由于编译器bug-hT 标志不适用于 GHC 8.6.x 及更早版本的已分析二进制文件,但它确实适用于 GHC 8.8.x,对于该版本,+RTS -hT 包括非-profiled 二进制文件,但在已分析的二进制文件中排除它,除非您还指定 -xt。这就是为什么在上面的示例中,“堆栈”仅在对未配置文件的二进制文件运行堆配置文件时显示。您可以添加 -xt 标志以查看所有其他堆配置文件。请注意,此“堆栈”是实际使用的堆栈,而不是堆上与堆栈有某种关联的对象。

黑洞主要是一种支持并发的机制。当一个线程开始评​​估一个 thunk 时,它会将其“黑洞”(即,将其标记为黑洞),因此如果另一个线程出现并想要评估相同的 thunk,它会等待评估而不是尝试重新并行评估它(这将重复运行线程的工作)。它也用在非线程运行时,部分是因为它可以检测无限循环(如果线程遇到自己的黑洞),但也有一些更重要的原因,我不记得了。对于-hT-hd-hy 堆分析,像这样被黑洞化的堆对象将被标记为“BLACKHOLE”。上面配置文件中有限的采样率可能会让人有点不清楚,但是在你的程序中发生的事情是大量的Int thunk 正在链中构建,当最终强制值时,它们变成了BLACKHOLEs 的长链,每一个都代表一个已启动的计算,正在等待链中的下一个计算。

【讨论】:

  • 非常感谢您的回答,它非常有用且完整!我只有几个问题,例如,图中“黑洞”的含义是什么(在我的情况下,黑洞小于 thunk,而在你的情况下,thunk 更大,不知道为什么)?第一张图片中的“堆栈”是指堆中指向堆栈的元素还是其他东西? (我有点迷茫,因为我虽然我们在做堆分析)
  • 我在最后添加了一些关于“BLACKHOLE”和“STACK”的注释。我不知道为什么你的黑洞使用与我的不同。这可能是 GHC 版本的差异,也可能是偶然的——分析是通过采样完成的,并且采样的时间可以改变明显的模式。
  • 可以提高采样率吗?显然这会使程序运行速度变慢,但采样精度会提高。在完美世界中,可以使用例如打开和关闭高频采样。正在发送到程序的信号。
  • 是的,堆配置文件采样率由+RTS -i&lt;xx&gt; 选项设置,该选项每秒提供样本。默认为+RTS -i0.01。不过,我不知道有任何方法可以在正在运行的程序中更改它。
  • 3,281,896,520 bytes allocated in the heap。请向读者解释为什么这一行应该被忽略。如果是实际分配的,那么有问题吗?在那种情况下,为什么要查看通常较低的其他数字?
【解决方案2】:

你问

return $ 1 + x [...] 但是为什么 ghc 没有优化呢??

答案是严格求值和惰性求值的语义略有不同,因此让 GHC 对其进行优化可能会破坏您的程序。

区别在于未定义值的处理。任何评估 undefined 的尝试都会引发异常。在 GHCi 中:

Prelude> undefined
*** Exception: Prelude.undefined
CallStack (from HasCallStack):
  error, called at libraries/base/GHC/Err.hs:79:14 in base:GHC.Err
  undefined, called at <interactive>:1:1 in interactive:Ghci1

如果我有一个包含未定义的表达式,那么同样的事情会发生:

Prelude> 2 + undefined
*** Exception: Prelude.undefined [...]

但是,如果评估永远不会达到未定义,那么一切都很好:

Prelude> True || undefined
True

Haskell 使用“非严格语义”和“惰性求值”。从技术上讲,非严格语义是 Haskell 定义的一部分,惰性求值是 GHC 中的实现机制,但您可以将它们视为同义词。当您定义一个变量时,该值不会立即计算,因此如果您从不使用该变量,那么您就没有问题:

Prelude> let b = undefined
Prelude> b
*** Exception: Prelude.undefined

let 工作正常,但评估它定义的变量会引发异常。

现在考虑一下您的大量未评估的1+ 调用。 GHC 无法提前知道您是否会使用结果(见下文),也无法知道某处是否潜伏着异常。作为一个程序员,你可能知道有一个异常,并没有仔细看结果,而是依赖于 Haskell 的非严格语义。如果 GHC 过早地评估并获得异常,您的程序将在不应该出现的情况下失败。

实际上,GHC 编译器包含一个称为 Demand Analyser 的优化(它曾经被称为 Strictness Analyser),它寻找机会以您想要的方式进行优化。但是它有局限性,因为它只能在证明结果将被评估时优化计算。

这里的另一个问题是您使用了State monad。这实际上有两种变体;懒惰而严格。 Strict 变体在写入时强制状态,但 Lazy 变体(默认)不会。

【讨论】:

  • 我理解是的,但是这里似乎并不难看出状态实际上会显示(打印出来了,功能不能简单得多),而且不能当整数大于 0 时会产生错误。我知道 monad 可能是一个棘手的问题,但考虑到我将它与严格的 state monad 一起使用......我会说它仍然可行。但无论如何,感谢您的评论!
  • @tobiasBora 啊,抱歉,我错过了您导入中的“严格”。我知道这很痛苦,因为我曾经花了一周时间在我编写的程序中追踪这个确切的问题。
【解决方案3】:

可以检测到一类特定的空间泄漏,因为它们在展开过多的堆使用时会使用过多的堆栈。 following website 列出了具体方法以及大量案例研究,但大致如下:

  • 使用有限大小的堆栈编译和运行,使用 +RTS -K10K 将堆栈限制为 10Kb。
  • 检查打破堆栈限制的代码,使用+RTS -xc 获取堆栈跟踪。

这不是一个完美的方法,因为有时您有内存泄漏而没有过多的堆栈使用,有时您有过多的堆栈使用而没有内存泄漏,但对应关系非常好,可以在 CI 上部署工具以停止引入新的泄漏。

【讨论】:

    猜你喜欢
    • 2012-07-16
    • 2011-08-21
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多