Data.ByteString.Builder周围的包装存在空间泄漏



我有一个围绕Data.ByteString.Builder的包装器类型,它允许我跟踪正在构建的ByteString的长度(参见我之前的问题):

import Data.Monoid
import qualified Data.ByteString.Builder as B
import System.IO (stdout)
data LBuilder = LBuilder { toBuilder :: !B.Builder
                         , lbLength  :: !Int }
instance Monoid LBuilder where
    mempty = LBuilder mempty 0
    (LBuilder x1 l1) `mappend` (LBuilder x2 l2) =
        LBuilder (x1 <> x2) (l1 + l2)
char c = LBuilder (B.char7 c) 1
hPutLBuilder h = B.hPutBuilder h . toBuilder

据我所知,这应该和直接使用Builder一样有效。但尝试以下测试用例似乎揭示了空间泄漏:

parts = replicate 10000000 $ char 'x'
main = hPutLBuilder stdout $ mconcat parts

运行此代码需要几秒钟的时间,并且会消耗大约250MB的内存。使用Builder执行相同的任务要快得多,并且只需要40KB。内存配置文件显示,所有额外的空间都被BuildStepBuilder的实例占用,这在直接使用Builder时不会发生。

是什么让这个代码如此低效?为什么在使用Builder时不会发生这种情况?

编辑:

下面Michael的回答让我了解了parts的实际评估方法。在玩了更多之后,我用以下方式重写了测试代码:

makeStuff !acc 0 = acc
makeStuff !acc i = makeStuff (acc <> char 'x') (i - 1)
stuff = makeStuff mempty 10000000
-- stuffOld = mconcat $ replicate 10000000 $ char 'x'
main = hPutLBuilder stdout stuff

使用此定义,BuilderLBuilder的性能和内存使用量完全相同(即可怕的:-)。因此,在使用Builder时,原始版本看起来非常快,因为编译器可以在编译时以某种方式将mconcat $ replicate n $ char c重写为类似B.lazyByteString $ L.replicate n (toAscii c)的内容,而不是在运行时在堆上编写10000000个函数。我试图通过观察生成的核心来证实这一点。我可以说:

  • stuffOld的定义是对一个相对较短的函数的调用,该函数对Data.ByteString.Builder.Internal中的类型执行某些操作
  • stuff的定义是对makeStuff的调用
  • 所说的核心并不意味着只有凡人才能理解

所以我想这只是一个病态的基准测试,而我的应用程序中的实际性能问题在其他地方。

一个问题是,强制评估mconcat parts表达式会强制评估其lbLength,这反过来又会强制评估所有单独的char 'x'值,这似乎是空间泄漏的来源。然而,我发现让代码的性能与原始Builder相同的唯一方法是在B.Builder周围使用newtype。即使只是data LBuilder = LBuilder !B.Builder也会引入大量开销。

最新更新