Haskell:如何检测"lazy memory leaks"

Haskell: how to detect "lazy memory leaks"

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

所以这是我的问题:有没有一种方法可以自动检测这些 "lazy memory leaks",它们会无缘无故地减慢程序速度?我仍然很难优化 Haskell 代码,并且很可能会忘记 !,即使你有经验我猜也是如此。

我知道:

例如,您能否向我解释一下如何在像这样的玩具程序中找到 "lazy leaks"?

{-# 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)

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

如果我运行你的玩具程序+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 期间复制的字节数和最大驻留时间令人担忧。假设你有一个程序,你希望 运行 常量 space(即,没有需要其全部内容的不断增长的数据结构),一个正常运行的 Haskell 程序通常不会需要在垃圾收集期间复制大量数据,并且往往有一个最大驻留时间,它只是分配的总字节数的一小部分(例如,100 KB 而不是 0.5 GB),并且这不会随着 "iterations" 无论你在测试什么。

您可以在不打开正式分析的情况下随时间生成快速堆分析。如果你用GHC标志编译-rtsopts,你可以使用:

./Toy +RTS -hT

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

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

这种金字塔图案是危险信号:

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

顺便说一句,x 轴在 "MUT seconds"("mutator" 的秒数是 运行ning,不包括垃圾收集),所以这就是为什么它小于实际 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。 (生成的标识符可能因代码 and/or 编译器标志的细微变化而有所不同,因此最好使用转储的 STG 重新编译,然后重新分析该可执行文件,以确保标识符匹配。)

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

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

是的,这一切都非常技术性,但这是表达 1 + y 的 STG 说法,这将帮助我将罪魁祸首归零。

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

按成本中心创建堆配置文件
./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 <- countNumberCalls $! n - 1 泄漏堆,因为 x 不是强制的,1 + x 泄漏堆栈。您可以启用 BangPatterns 扩展并写入:

!x <- countNumebrCalls  n - 1

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

一些补充说明:

尽管 +RTS -h<x> 标志主要用于堆分析(并在 GHC 文档中作为 "heap profiling" 选项进行讨论),但从技术上讲,它们可以报告驻留的其他用途 除堆外的内存包括每个线程状态,其中包括线程状态对象和堆栈。默认情况下,当 运行 一个分析二进制文件(用 -prof 编译)时,+RTS -h<x> 标志 报告每个线程的状态,包括堆栈,但您可以添加 -xt flag to add it, as in +RTS -hc -xt. Due to a probable unintentional oversight, on a non-profiled binary, the +RTS -hT flag (the only -h<x> flag available) includes stack even without the -xt flag. Due to a compiler bug-hT 标志不适用于 GHC 8.6.x 和更早版本的分析二进制文件,但它适用于 GHC 8.8.x,对于该版本,+RTS -hT 包括未分析二进制文件的堆栈,但不包括分析二进制文件,除非您还指定 -xt。这就是为什么在上面的示例中,"Stack" 仅在 运行 在未分析的二进制文件上进行堆分析时才会出现。您可以添加 -xt 标志以查看所有其他堆配置文件。请注意,此 "STACK" 是实际的堆栈使用,而不是堆上与堆栈有某种关联的对象。

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

你问

return $ 1 + x [...] but how come ghc does not optimize that??

答案是严格求值和惰性求值的语义略有不同,因此让 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 使用 "non-strict semantics" 和 "lazy evaluation"。从技术上讲,非严格语义是 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 变体(默认)不会。

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

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

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