为什么这个 Haskell 程序在优化编译时会泄漏 space?

Why does this Haskell program leak space when compiled with optimizations?

考虑以下玩具程序,该程序计算一个词中字符替换的所有组合,通常用于密码。

import Data.Char (isLower, toUpper)

variants :: String -> [String]
variants "" = [""]
variants (c:s) = [c':s' | c' <- subst c, s' <- variants s]
  where subst 'a' = "aA@"
        subst 'e' = "eE3"
        subst 'i' = "iI1"
        subst 'l' = "lL1"
        subst 'o' = "oO0"
        subst 's' = "sS"
        subst 'z' = "zZ2"
        subst x | isLower x = [x, toUpper x]
        subst x = [x]

main :: IO ()
main = putStrLn $ show $ length $ variants "redistributables"

我在优化和不优化的情况下编译它:

$ ghc -fforce-recomp -Wall Test.hs -o test0
[1 of 1] Compiling Main             ( Test.hs, Test.o )
Linking test0 ...

$ ghc -fforce-recomp -O -Wall Test.hs -o test1
[1 of 1] Compiling Main             ( Test.hs, Test.o )
Linking test1 ...

现在 test0test1 产生相同的输出,但是 test1 使用更多的内存并且大部分时间都花在垃圾收集上:

$ ./test0 +RTS -s 2>&1 | grep total
               2 MB total memory in use (0 MB lost due to fragmentation)
  Productivity  93.2% of total user, 93.3% of total elapsed

$ ./test1 +RTS -s 2>&1 | grep total
             188 MB total memory in use (0 MB lost due to fragmentation)
  Productivity  15.0% of total user, 15.0% of total elapsed

为什么?

我正在使用 GHC 7.4.1;我可能应该使用更新的编译器,但这是我目前手头上的东西,无论如何,故障可能出在我身上。

诀窍是重新计算后缀,而不是将它们保留在内存中。就像

powerset (x:xs) = map (x:) (powerset xs) ++ powerset xs 

定义,其中添加 where 子句是有害的(或者是 powerset (x:xs) = powerset xs ++ map (x:) (powerset xs) ...?)。

对于您的情况,要尝试的代码是 mapM subst

variants (c:cs) = variants cs >>= \s-> map (:s) (subst c) 

您可以从您的列表理解代码中看到后者在 "opposite direction" 中起作用,所以也许只是

variants (c:s) = [c':s' | s' <- variants s, c' <- subst c]

也可以。

所有这些都是等价的,所以这是编译器的事情。希望有人能提供更多细节。

你想要

variants (c:s) = [c':s' | c' <- subst c, s' <- variants s]

编译成外循环和内循环。但是 GHC 发现内部循环不以任何方式依赖于外部 "loop counter"。因此,完全惰性变换将内循环提升到外循环之外。一个相当有效的技巧是隐藏内循环是独立的这一事实。这是通过将内部循环拆分成一个单独的函数来完成的,该函数接受一个伪参数,并通过将函数标记为 NOINLINE 来隐藏伪参数。然后你就可以用外层循环计数器来调用函数了,GHC一般不会乱来的。