我有一个围绕Data.ByteString.Builder
的包装类型,它允许我跟踪正在构建的ByteString的长度(参见my previous question):
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
时为什么不会发生?
修改
迈克尔在下面的回答让我看看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
的调用。所以我猜这只是基准测试的一个病态案例,我的应用程序中的实际性能问题还存在于其他地方。
答案 0 :(得分:0)
一个问题是迫使mconcat parts
表达式的评估强制评估其lbLength
,这反过来会强制评估所有单个char 'x'
值,这是空间泄漏的地方似乎来自。但是,我发现让代码的性能与原始Builder
相同的唯一方法是在newtype
周围使用B.Builder
。即使只是data LBuilder = LBuilder !B.Builder
也会带来很大的开销。