我有一个围绕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。内存配置文件显示,所有额外的空间都被BuildStep
和Builder
的实例占用,这在直接使用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
使用此定义,Builder
和LBuilder
的性能和内存使用量完全相同(即可怕的:-)。因此,在使用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
也会引入大量开销。