惰性求值、WHNF 与性能剖析(Profiling)

Haskell 最显著的语言特征是默认的非严格求值(通常称为惰性求值,Lazy Evaluation)。它让我们可以处理无限数据流、把算法的生成和消费分开写,也会在某些场景下带来”空间泄漏”(Space Leak)的问题。

理解 Thunk、求值范式,以及 GHC 提供的性能剖析工具,是写出内存表现可控的 Haskell 程序的前提。

按需调用与共享

严格求值的语言(如 C、Go、Python)采用”按值调用”(Call by Value):函数参数在传入前必须先求值。

Haskell 采用按需调用(Call by Need):

  1. Thunk:表达式在真正被需要之前,只以一个未求值的闭包(Thunk)形式存在于堆上。

  2. 共享(Sharing):Thunk 一旦被首次求值,其内存单元会被结果就地覆盖。之后对同一个值的引用直接读取结果,不会重复计算。

以 let x = expensive 10 in x + x 为例,x 在堆上只有一个 Thunk,两处引用指向同一个位置:

        graph LR
  subgraph before["求值前"]
    P1["x + x<br/>左操作数"] --> T["Thunk<br/>expensive 10"]
    P2["x + x<br/>右操作数"] --> T
  end
  subgraph after["求值后"]
    Q1["x + x<br/>左操作数"] --> V["已求值<br/>42"]
    Q2["x + x<br/>右操作数"] --> V
  end
  before -. "第一次需要 x 时求值<br/>并覆盖 Thunk" .-> after
    

expensive 10 只会执行一次,第二次读取 x 时直接拿到已经算好的 42。

WHNF 与 NF

弱头范式(Weak Head Normal Form,WHNF)

当表达式的最外层已经求值为数据构造器或 Lambda 抽象 时,该表达式就处于 WHNF。内部的字段可以仍然是未求值的 Thunk:

-- 处于 WHNF 的例子:
(1 + 1, 2 + 2)       -- 最外层是元组构造器 (,),内部表达式未求值
Just (take 5 [1..])  -- 最外层是数据构造器 Just
\x -> x * 2          -- 最外层是函数抽象
1 : [2 + 3, 4 + 5]   -- 最外层是列表构造符 (:)

范式(Normal Form,NF)

表达式被递归求值到底,内部不再包含任何 Thunk。例如整数 42,或者每个元素都已求值的列表 [1, 2, 3]。

严格性控制

在循环累加等不需要保留 Thunk 的场景,可以用以下手段主动引入严格求值:

1. seq 与严格应用符 ($!)

seq 先把第一个参数求值到 WHNF,再返回第二个参数:

seq :: a -> b -> b

$! 是结合了 seq 的严格函数应用:

f $! x = x `seq` f x

2. 深度求值:Control.DeepSeq 与 ($!!)

seq 只求值到 WHNF,对嵌套结构内部的 Thunk 无能为力。Control.DeepSeq 模块的 deepseq 与 $!! 通过 NFData 类型类把整个结构递归求值到 NF:

import Control.DeepSeq (force)
import Data.IORef

summarize :: [Int] -> [(Int, Int)]
summarize xs = [(x, x * x) | x <- xs]

main :: IO ()
main = do
  cache <- newIORef []
  -- 不加 force,写进去的只是一个 Thunk,真正的计算被推迟到第一次读取时
  writeIORef cache (force (summarize [1 .. 5]))
  readIORef cache >>= print

force x 等价于 x `deepseq` x:把值求到 NF 再原样返回。上面如果不加 force,IORef 里存的是一个引用着整个输入的 Thunk,输入本身也因此无法回收;什么时候有人读它,什么时候才真正计算。

3. BangPatterns 与严格字段

在函数参数或记录字段前加感叹号 !,指示编译器在进入函数体或构造数据时立即把它求值到 WHNF:

{-# LANGUAGE BangPatterns #-}

-- 严格累加器,避免空间泄漏
sumStrict :: Num a => a -> [a] -> a
sumStrict !acc []     = acc
sumStrict !acc (x:xs) = sumStrict (acc + x) xs

实践清单

上面四种手段各有位置。日常写代码时不需要每处都想一遍求值顺序,记住下面几条默认做法就能避开绝大多数泄漏:

  1. 累加器用 foldl’ 或 bang。对有限列表做求和、计数、建 Map 时用 Data.List.foldl',手写递归时给累加器加 !。折叠一章的对比表已经说明不带撇号的 foldl 没有使用理由。

  2. 记录字段默认严格。数据字段如果只是用来存结果,就加 !,或者在模块顶部开启 {-# LANGUAGE StrictData #-} 让整个模块的字段默认严格。单子变换子一章的 Env 把 appPort 写成 !Int 就是这个原因:配置在启动时读一次,之后只读不写,没有必要留 Thunk。

  3. 容器选严格版本。Data.Map.Strict 在插入时就把值求到 WHNF,Data.Map.Lazy 会把 insertWith (+) 累积成一串加法。Text 和 ByteString 也各有严格与惰性两种模块,处理整块数据用严格版本,流式处理才用惰性版本。

  4. 存起来之前先 force。写进 IORef、TVar、MVar,通过通道发给另一个线程,或者在计时、缓存之前,用 force 或 $!! 把值算完。否则存进去的是一个 Thunk,计算会推迟到另一个线程、另一个时刻发生,并且拖住它引用的所有输入。

  5. 不用惰性 IO。readFile 和 hGetContents 返回的字符串是边读边生成的,句柄什么时候关闭取决于字符串什么时候被消费完。IO 一章讲了这个问题和替代写法,读整块用 Data.Text.IO 或 Data.ByteString,按行处理用循环。

  6. 惰性是朋友的场合。无限生成器(iterate、[1 ..])配合 take;take 5 (filter p xs) 找到五个就停;any、all、&& 短路;where 里绑定几个备选值,最后只用到一个,没用到的不会计算。这些场合下加严格性反而是错的。

  7. 不确定就先量。+RTS -s 不需要重新编译,一行就能看到最大驻留内存。只有数字异常时才动手加严格性,不要凭感觉到处加 !。

下面是一个真实的泄漏。求平均值,写法看起来再自然不过:

mean :: [Double] -> Double
mean xs = sum xs / fromIntegral (length xs)

main :: IO ()
main = print (mean [1 .. 10000000])

用 -O 编译后带 +RTS -s 运行:

$ ghc -O Mean.hs -o mean
$ ./mean +RTS -s
5000000.5
  234,643,296 bytes maximum residency (9 sample(s))
          537 MiB total memory in use (0 MiB lost due to fragmentation)
  Total   time    0.930s  (  0.928s elapsed)

一千万个 Double 占了五百多兆。问题不在 sum 或 length 本身,而在 xs 被用了两次:sum 遍历时列表元素本来可以边算边回收,但 length 还要再遍历一遍,于是整个列表被完整保留在内存里。修法是一次遍历同时算出和与个数,累加器用严格元组:

{-# LANGUAGE BangPatterns #-}

import Data.List (foldl')

mean :: [Double] -> Double
mean xs = total / fromIntegral count
  where
    (total, count) = foldl' step (0, 0 :: Int) xs
    step (!s, !n) x = (s + x, n + 1)
$ ./mean +RTS -s
5000000.5
       44,328 bytes maximum residency (2 sample(s))
            7 MiB total memory in use (0 MiB lost due to fragmentation)
  Total   time    0.107s  (  0.107s elapsed)

最大驻留从两百多兆降到几十 KB,时间也从接近一秒降到零点一秒,省下的主要是垃圾回收的时间。注意 step 里的两个 !:foldl' 只把元组求到 WHNF,元组里面的 s + x 仍然可以是 Thunk。开 -O 时优化器通常能自己推断出这里需要严格求值,但不要依赖它:同一段代码去掉 bang 用 -O0 编译,最大驻留会涨到八百多兆,比原始版本还糟。

+RTS -s 能告诉你有没有泄漏,但不能告诉你泄漏在哪个函数。程序大了以后要靠下面的剖析工具。

性能剖析与空间泄漏定位

当程序内存占用异常上升时,可以用 GHC 自带的剖析工具定位问题。

第一步:开启剖析编译

使用 -prof 与 -fprof-auto 编译:

$ ghc -prof -fprof-auto -rtsopts Main.hs -o myprog
  • -prof:开启剖析信息收集。

  • -fprof-auto:自动在所有顶层绑定处插入成本中心(Cost Centre)。

  • -rtsopts:允许运行时通过 +RTS 传递参数。

如果是 Cabal 项目,用 cabal build --enable-profiling 即可,依赖库也会以剖析模式重新编译。

第二步:生成时间与分配报告

运行程序并附带 RTS 参数:

$ ./myprog +RTS -p

运行结束后会在当前目录生成 myprog.prof。文件里列出每个成本中心占用的 CPU 时间百分比 与 内存分配量,可以直接找到热点。

第三步:生成堆内存曲线

用 -hc 按成本中心采样堆内存:

$ ./myprog +RTS -hc -p

运行完毕后生成 myprog.hp。用 GHC 自带的 hp2ps 转换成 PostScript 图:

$ hp2ps -e8in -c myprog.hp

查看生成的 myprog.ps:

  • 正常的堆:呈现规律的锯齿状(垃圾回收周期性回收内存)。

  • 有空间泄漏的堆:呈现持续上升的斜坡。图中会标出是哪个成本中心在不断堆积数据,据此就能定位需要加严格性的位置。

小结

  • Haskell 采用按需调用,表达式只在被需要时求值一次,结果被共享。

  • WHNF 只求值最外层构造器,NF 求值到底。

  • seq、$!、deepseq 与 BangPatterns 用于在需要的位置引入严格性。

  • 默认做法:累加器用 foldl' 或 bang,记录字段加 !,容器选严格版本,存进 IORef 或跨线程之前先 force,不用惰性 IO。

  • 同一个大列表被遍历两次就会整个留在内存里,改成一次遍历的严格折叠。

  • +RTS -s 看有没有泄漏,-prof 配合 +RTS -p 和 -hc 定位泄漏在哪。