Haskell MVar 和文本惰性比较
Haskell MVar & Text laziness comparison
我使用 normal/lazy 文本和 normal/strict MVar 的不同变体对以下代码示例进行了基准测试:
import Control.Concurrent.MVar
import qualified Data.Text as T
main :: IO ()
main = do
mvar <- newMVar T.empty
let textArr = map (const $ T.pack "01234567890123456789") [0 .. 15000 :: Int]
mvarWriter = \newText -> modifyMVar_ mvar (\oldText -> return $ oldText <> newText)
mapM_ mvarWriter textArr
print . T.length =<< readMVar mvar
| Version | Execution time in seconds |
| ------------------ | ------------------------- |
| Text + Strict MVar | 0.26 |
| Text + MVar | 8.35 |
| Lazy Text + Strict MVar | 17 |
| Lazy Text + MVar | 17 |
在阅读了一些关于此的文章后,我原以为惰性文本 + 严格 MVar 会是最快的,但令我惊讶的是它不是。
谁能解释一下这是怎么回事?为什么严格的 MVar + 普通文本比普通文本 + 普通 MVar 快得多?为什么无论 MVar 的严格程度如何,惰性文本都这么慢?
懒惰与严格的文本
首先,惰性文本就像一个严格文本的链表。 <>
函数遍历整个列表并将其右参数添加到列表的末尾。这意味着惰性文本版本以包含 15000 个元素的链表结束。每次添加元素时,程序都会遍历整个列表,直到到达末尾并可以追加元素。
严格的<>
只是将两个内存区域复制到一个新区域。这是一个更便宜的操作,因为它可以利用 SIMD 操作一次复制最多 64 个字符(这比一大块惰性文本还多)。此外,与可能位于内存中任何位置的链表指针相比,这对于缓存局部性要好得多。
然后最终在惰性文本中有很多内存开销,因为它必须为块(相当于 8 个字符)存储一个 header 指向下一个块(8 个字符)的指针和 Text
本身包含切片的长度(8 个字符)和偏移量(8 个字符),最后底层 ByteArray#
有另一个长度(8 个字符)。因此惰性版本将存储相当于每块 20 个字符的 40 个额外字符。
我还应该注意到惰性文本在一个重要方面与严格文本列表不同:严格文本被解压缩到惰性文本块中。解包节省了一定程度的间接性,但它也阻止了块之间的共享。在这种情况下,每个块都包含完全相同的文本,因此它们都可以共享。我会在下一部分再谈这个。
懒惰与严格 MVar
这里并没有具体说明MVar的严格性,只是为了方便。如果您在此处使用 $!
,您可能会得到相同的结果:
mvarWriter = \newText -> modifyMVar_ mvar (\oldText -> return $! oldText <> newText)
(或者 $!!
如果你使用惰性文本)
惰性版本和严格版本的区别在于惰性版本在将oldText <> newText
放入MVar
之前实际上并不计算oldText <> newText
。惰性版本将计算推迟到遇到 print . T.length =<< readMVar mvar
行。
(GHC) Haskell 如何存储计算以便它能够 运行 在以后的某个时间点进行计算?作为堆上的闭包。闭包存储一个指向所有源自函数外部的参数(自由变量)的指针。在这种情况下,它只是 newText
.
所以实际上,严格的 Text + lazy MVar 版本与 lazy Text 版本非常相似。两者都在堆中构建了一种链表结构。这需要额外的 space、分配时间并增加间接寻址。
与惰性文本相比的一个区别是严格文本 + 惰性 MVar 版本不必在每次添加新文本时遍历整个结构。此外,这种通过闭包的隐式链表的优点是它可以在结构中共享指针。所以一开始只有一个“01234567890123456789”文本和许多指向它的指针。
我使用 normal/lazy 文本和 normal/strict MVar 的不同变体对以下代码示例进行了基准测试:
import Control.Concurrent.MVar
import qualified Data.Text as T
main :: IO ()
main = do
mvar <- newMVar T.empty
let textArr = map (const $ T.pack "01234567890123456789") [0 .. 15000 :: Int]
mvarWriter = \newText -> modifyMVar_ mvar (\oldText -> return $ oldText <> newText)
mapM_ mvarWriter textArr
print . T.length =<< readMVar mvar
| Version | Execution time in seconds |
| ------------------ | ------------------------- |
| Text + Strict MVar | 0.26 |
| Text + MVar | 8.35 |
| Lazy Text + Strict MVar | 17 |
| Lazy Text + MVar | 17 |
在阅读了一些关于此的文章后,我原以为惰性文本 + 严格 MVar 会是最快的,但令我惊讶的是它不是。
谁能解释一下这是怎么回事?为什么严格的 MVar + 普通文本比普通文本 + 普通 MVar 快得多?为什么无论 MVar 的严格程度如何,惰性文本都这么慢?
懒惰与严格的文本
首先,惰性文本就像一个严格文本的链表。 <>
函数遍历整个列表并将其右参数添加到列表的末尾。这意味着惰性文本版本以包含 15000 个元素的链表结束。每次添加元素时,程序都会遍历整个列表,直到到达末尾并可以追加元素。
严格的<>
只是将两个内存区域复制到一个新区域。这是一个更便宜的操作,因为它可以利用 SIMD 操作一次复制最多 64 个字符(这比一大块惰性文本还多)。此外,与可能位于内存中任何位置的链表指针相比,这对于缓存局部性要好得多。
然后最终在惰性文本中有很多内存开销,因为它必须为块(相当于 8 个字符)存储一个 header 指向下一个块(8 个字符)的指针和 Text
本身包含切片的长度(8 个字符)和偏移量(8 个字符),最后底层 ByteArray#
有另一个长度(8 个字符)。因此惰性版本将存储相当于每块 20 个字符的 40 个额外字符。
我还应该注意到惰性文本在一个重要方面与严格文本列表不同:严格文本被解压缩到惰性文本块中。解包节省了一定程度的间接性,但它也阻止了块之间的共享。在这种情况下,每个块都包含完全相同的文本,因此它们都可以共享。我会在下一部分再谈这个。
懒惰与严格 MVar
这里并没有具体说明MVar的严格性,只是为了方便。如果您在此处使用 $!
,您可能会得到相同的结果:
mvarWriter = \newText -> modifyMVar_ mvar (\oldText -> return $! oldText <> newText)
(或者 $!!
如果你使用惰性文本)
惰性版本和严格版本的区别在于惰性版本在将oldText <> newText
放入MVar
之前实际上并不计算oldText <> newText
。惰性版本将计算推迟到遇到 print . T.length =<< readMVar mvar
行。
(GHC) Haskell 如何存储计算以便它能够 运行 在以后的某个时间点进行计算?作为堆上的闭包。闭包存储一个指向所有源自函数外部的参数(自由变量)的指针。在这种情况下,它只是 newText
.
所以实际上,严格的 Text + lazy MVar 版本与 lazy Text 版本非常相似。两者都在堆中构建了一种链表结构。这需要额外的 space、分配时间并增加间接寻址。
与惰性文本相比的一个区别是严格文本 + 惰性 MVar 版本不必在每次添加新文本时遍历整个结构。此外,这种通过闭包的隐式链表的优点是它可以在结构中共享指针。所以一开始只有一个“01234567890123456789”文本和许多指向它的指针。