不知名的它 竟然用80行代码打败了C语言

Beating C with 80 lines of : wc

不知名的它 竟然用80行代码打败了C语言

Haskell是何方神圣?竟然用寥寥数行代码打败千年老二C。Haskell,一种标准化的,通用的纯函数编程语言,有非限定性语义和强静态类型。它是1990年在编程语言Miranda的基础上标准化的,并以λ演算为基础发展而来。作为一门开放的、已发布标准的且多种实现的语言,具有“证明即程序、结论公式即程序类型”的特征,支持惰性求值、模式匹配、列表解析、类型类、类型多态……Haskell拥有一个活跃的社区,在线上包仓库Hackage上有丰富的第三方开源库或工具。

不知名的它 竟然用80行代码打败了C语言

▲TIOBE Index for October 2019

2019年10月编程语言排行榜,Haskell差点跌出前50,较之Java、C、Python、C++等编程语言可谓毫无可比性,但它又是怎么用80行代码击败C的呢?以下为译文。

The challenge is to build a faster clone of the hand-optimized C implementation of the wc utility in our favourite high-level garbage-collected runtime-based language: Haskell! Sounds simple enough right?

使用我们最喜欢的支持垃圾回收且基于运行时的语言-Haskell,构建wc实用程序比手动优化过的C实现来的更快。听起来很简单吧?

以下是我们运行时需要考虑的标准:

1. 正确性:应该在测试文件上返回与wc相同的字符数、单词数和行数。

2. 速度:如何与wc的执行时间进行比较?

3. 最大常驻内存:内存使用高峰是多少?内存使用量是常量、线性还是其他?

这些是我们需要担心的主要问题。

01 The dumbest thing that could possibly work

与往常一样,我们应该从尝试最愚蠢的实现开始,看看它是如何进行的,并以此为起点建立。在Haskell中计算字符数、单词数和行数,最愚蠢的方法是什么呢?我们可以读取文件,然后运行length,length . words, and length . lines计数!

stupid :: FilePath -> IO (Int, Int, Int)

stupid fp = do

contents <- readFile fp

return (length s, length (words s), length (lines s))

令人惊讶的是,如果你愿意等待它,它实际可以正常工作,并为我们提供与wc相同的答案。但是在大型测试文件上需要耗费一些时间。小文件(90 MB)测试结果如下:

不知名的它 竟然用80行代码打败了C语言

90 MB test file:

这里就不必废话了,还有很大的改进余地啦!

02 Being slightly less dumb

让我们一起思考下,为什么这样做,代码性能让人不忍直视?首先,遍历文件内容3次! 没错,是3次。但GHC无法遍历我们的列表,因为我们仍在其他地方使用它,并且它不能对我们的列表进行垃圾回收。以致于我们文件中的每个字符都被保存在一个链表中,这也就是为什么该文件只有90 MB却占用2.4 GB内存!

Okay, so that's REALLY not great. 让我们看看能否简化为结构上的单次传递。When iterating through a structure to get one final result I reach for folds!

使用fold对字符和行进行计数非常容易,字符数总是加1,当字符是换行符时行数加1,单词数我们不能在每个空格字符上加1,因为连续的空格不算新单词,我们需要跟踪前一个字符是否为空格,并且每当我们开始一个新单词时,才在计数器中加1。这样做并不难,我们将使用Data.List中的foldl'作为实现。

import Data.List

import Data.Char

simpleFold :: FilePath -> IO (Int, Int, Int)

simpleFold fp = do

countFile <$> readFile fp

countFile :: String -> (Int, Int, Int)

countFile s =

let (cs, ws, ls, _) = foldl' go (0, 0, 0, False) s

in (cs, ws, ls)

where

go :: (Int, Int, Int, Bool) -> Char -> (Int, Int, Int, Bool)

go (cs, ws, ls, wasSpace) c =

let addLine | c == '\n' = 1

| otherwise = 0

addWord | wasSpace = 0

| isSpace c = 1

| otherwise = 0

in (cs + 1, ws + addWord, ls + addLine, isSpace c)

结果,程序刚刚运行几分钟,内存迅速增加到3GB!Oh my god.我们使用了foldl的严格版本(由尾部打勾'表示)。但只有"Weak Head Normal Form"是严格的,这就意味着我们正在建立大量附加内容,直到完成对整个文件的迭代,我们才能对其进行全面评估!有时惰性求值会像这样悄悄给我们埋下不知名的隐患,稍不注意,内存泄露还会导致web服务器崩溃。

不知名的它 竟然用80行代码打败了C语言

90 MB test file:

可以通过告诉GHC在每次迭代中严格评估元组的内容来修复它。通过使用BangPatterns扩展程序,但在参数列表中会强制对函数的每次运行进行求值。以下是go的新版本:

{-# LANGUAGE BangPatterns #-}

go :: (Int, Int, Int, Bool) -> Char -> (Int, Int, Int, Bool)

go (!cs, !ws, !ls, !wasSpace) c =

let addLine | c == '\n' = 1

| otherwise = 0

addWord | wasSpace = 0

| isSpace c = 1

| otherwise = 0

in (cs + 1, ws + addWord, ls + addLine, isSpace c)

这种简单的改变可以加快像CRAZY的速度; 这是我们的新性能细分:

不知名的它 竟然用80行代码打败了C语言

90 MB test file:

现在内存方面已经做得很好了,一个90 MB的文件上只有几MB的内存。尽管懒惰求值仍困扰着我们,但我们将懒惰定位到正确的位置,它便会自然地以流的形式处理。

03 Better with ByteStrings

我们可以暂时不再担心内存问题,但我们将重新考虑性能!可以尝试使用ByteString替代String。 使用String意味着我们在读取文件时对文件进行隐式解码,这需要花费时间,并且我们在整个过程中都使用了链表,因此我们无法轻易利用批处理或缓冲数据的优势。

实际上,这种更改很容易做到,bytestring提供了以下模块:Data.ByteString.Lazy.Char8,该模块提供用于处理惰性ByteString的操作,就好像它们是字符串一样,但具有ByteString的所有性能优势。但要注意,它实际上并没有验证每个字节是否是有效字符,也没有进行任何解码,因此确保传递有效数据是我们的责任。 默认情况下,wc假定输入为ASCII,因此我们可以这样假设。如果我们的输入是ASCII,那么该模块中的功能将表现得很合理。

我们要做的唯一更改便是将Data.List导入切换到Data.ByteString.Lazy.Char8,然后将readFile和foldl'函数切换到其ByteString版本:

import Data.Char

import qualified Data.ByteString.Lazy.Char8 as BS

simpleFold :: FilePath -> IO (Int, Int, Int)

simpleFold fp = do

simpleFoldCountFile <$> BS.readFile fp

simpleFoldCountFile :: BS.ByteString -> (Int, Int, Int)

simpleFoldCountFile s =

let (cs, ws, ls, _) = BS.foldl' go (0, 0, 0, False) s

in (cs, ws, ls)

where

go :: (Int, Int, Int, Bool) -> Char -> (Int, Int, Int, Bool)

go (!cs, !ws, !ls, !wasSpace) c =

let addLine | c == '\n' = 1

| otherwise = 0

addWord | wasSpace = 0

| isSpace c = 1

| otherwise = 0

in (cs + 1, ws + addWord, ls + addLine, isSpace c)

这个小小的变化将我们缩短近一半运行时间。

不知名的它 竟然用80行代码打败了C语言

90 MB test file:

我们显然取得了一些进展,像内存使用量略有增加,但仍是一个固定开销。不幸的是,我们离wc还有几个数量级。

04 Moving to Monoids

现代PC倾向于具有多个内核,并且新电脑内核数量似乎比处理器速度增长更快,因此利用这一点将是有益的。

但这样拆分计算并非易事。 为了使用多个核心,我们需要将工作分解为多个部分。 从理论上讲,很容易,只需将文件拆分为多个块,然后为每个内核分配一个块! 当你深入思考时,就会发现问题。组合字符计数非常容易,我们可以将每个块的总数相加。行数相同,但字数构成却会出现问题! 如果我们在一个单词的中间或在几个连续的空格中间分开,会发生什么呢? 为了合并字数统计,我们需要跟踪每个块的开始和结束状态,并在将它们组合在一起时保持智能。 我并不想做这些工作。

Monoids to the rescue! Monoid的关联定律意味着,只要我们能够发展出合法的monoid,尽管存在这种并行性,它仍将正常工作。 确实,这只是顺理成章,但是否有可能编写一个Monoid来处理像这样的单词计数的复杂性?

当然可以!我们需要计算给定的不变量从序列的开始到结束变化的次数。 之前,我已经概括了此类一元体的名称,把它们命名为通量幺半群。 我们需要做的是计算从空格字符变为不空格字符的次数。 我们可能会使用Flux monoid本身来表达这一点,但是由于我们需要非常严格地对待严格性和性能,因此我将为我们的目的定义定制版本的Flux monoid。 看一下这个:

data CharType = IsSpace | NotSpace

deriving Show

data Flux =

Flux !CharType

{-# UNPACK #-} !Int

!CharType

| Unknown

deriving Show

我们在解决方案的单词计数部分需要这些类型。

CharType表示给定字符是否为空格; 然后Flux类型代表一小段文本,存储以下字段:最左边的字符是否为空格,整个文本块中有多少个单词以及最右边的字符是否为空格。 实际上,我们没有将文本保留在结构中,因为对于此问题我们不需要它。 我已经取消了对Int的包装,并严格限制了所有字段,以确保我们不会遇到与之前使用懒惰元组问题。 使用严格的数据类型意味着在计算中不需要使用BangPatterns。

接下来,我们一起看一个实例!

instance Semigroup Flux where

Unknown <> x = x

x <> Unknown = x

Flux l n NotSpace <> Flux NotSpace n' r = Flux l (n + n' - 1) r

Flux l n _ <> Flux _ n' r = Flux l (n + n') r

instance Monoid Flux where

mempty = Unknown

Unknown构造函数在这里只表示一个Monoidal身份,实际上可以省略它,并使用Maybe来将Semigroup提升为Monoid,但是Maybe会在我们的Semigroup附加项中引入不必要的惰性! 为了简单起见,我将其定义为类型的一部分。

我们定义的(<>)操作检查两个文本块的连接点是否出现在一个单词的中间,如果确实如此,则必须分别计算同一单词的开始和结束,因此在统计单词总数时需减去1使其平衡。

最后,我们需要一种从单个字符构建Flux对象的方法。

flux :: Char -> Flux

flux c | isSpace c = Flux IsSpace 0 IsSpace

| otherwise = Flux NotSpace 1 NotSpace

这很简单,我们将非空格字符视为以非空格字符开头和结尾的“单词”,对于空格,在其两边都带有空格字符的空单词计数。

可能还不清楚,但这就是我们单字计算单词所需的全部!

>>> foldMap flux "testing one two three"

Flux NotSpace 4 NotSpace

>>> foldMap flux "testing on" <> foldMap flux "e two three"

Flux NotSpace 4 NotSpace

>>> foldMap flux "testing one " <> foldMap flux " two three"

Flux NotSpace 4 NotSpace

看起来一切正常!

我们已经实现了字数统计部分,现在我们需要字符计数和行数的Monoidal版本。 这是一个快速的构建:

data Counts =

Counts { charCount :: {-# UNPACK #-} !Int

, wordCount :: !Flux

, lineCount :: {-# UNPACK #-} !Int

}

deriving (Show)

instance Semigroup Counts where

(Counts a b c) <> (Counts a' b' c') = Counts (a + a') (b <> b') (c + c')

instance Monoid Counts where

mempty = Counts 0 mempty 0

没问题! 同样,我们需要一种将单个char转换为Counts对象的方法:

countChar :: Char -> Counts

countChar c =

Counts { charCount = 1

, wordCount = flux c

, lineCount = if (c == '\n') then 1 else 0

}

让我们也尝试一下:

>>> foldMap countChar "one two\nthree"

Counts {charCount = 13, wordCount = Flux NotSpace 3 NotSpace, lineCount = 1}

看上去很好! 你可以进行试验,以使自己确信这是合法的Monoid。

使用合法的Monoid,我们不再需要担心我们如何拆分文件!

在继续之前,让我们尝试将Monoid与现有代码结合使用,并确保其获得相同的答案。

module MonoidBSFold where

import Data.Char

import qualified Data.ByteString.Lazy.Char8 as BS

monoidBSFold :: FilePath -> IO Counts

monoidBSFold paths = monoidFoldFile <$> BS.readFile fp

monoidFoldFile :: BS.ByteString -> Counts

monoidFoldFile = BS.foldl' (\a b -> a <> countChar b) mempty

我们已经将一些复杂性移到Counts类型中,这使我们能够真正简化此处的实现。这是很好的,因为测试单个数据类型比测试我们可以折叠的任何地方都容易得多。

附带好处,这种更改进一步加快了速度!

We're in the ballpark now!

不知名的它 竟然用80行代码打败了C语言

90 MB test file:

这一更改,节省了很多时间和记忆。虽然我不知道是为什么。也许是使用完全严格的数据结构,我们可能已经消除了潜伏在某处的惰性。 但我不确定。

更新:guibou指出,Flux和Counts类型使用UNPACK编译指示,而我们之前使用的是常规ol'元组。 显然,GHC有时对于UNPACK元组足够聪明,通过UNPACK可以节省一些指针间接操作并使用更少的内存!

05 Inlining away!

接下来,我们要把定义改为内联。我们可以使用INLINE编译指示告诉GHC我们的功能对性能至关重要,它将为我们内联。 可能会触发进一步的优化。

monoidBSFold :: FilePath -> IO Counts

monoidBSFold paths = monoidBSFoldFile <$> BS.readFile fp

{-# INLINE monoidBSFold #-}

monoidBSFoldFile :: BS.ByteString -> Counts

monoidBSFoldFile = BS.foldl' (\a b -> a <> countChar b) mempty

{-# INLINE monoidBSFoldFile #-}

我还继续将INLINE添加到我们的countChar和flux函数中。 让我们看看它是否有所不同:

不知名的它 竟然用80行代码打败了C语言

90 MB test file:

有趣的是,这似乎使我们的执行时间减少了75%! 尽管内存量有所增加。但还不足以让我担心。

现在,我们将其与C语言进行比较:

不知名的它 竟然用80行代码打败了C语言

90 MB test file:

在这一点上,与wc差不多。但我们人像缩小秒的时间,所以我想提高测试文件的大小并运行几次,看看有没有发现。

我选择一个543 MB的纯文本文件,连续运行了几次以预热缓存。 记住这很重要,因为运行几次后时间下降了整整33%。 我知道我的测试方法并不完全是“科学的”,但是可以很好地估计我们的工作方式。 无论如何,在更大的文件上,这是我们的执行方式:

不知名的它 竟然用80行代码打败了C语言

543 MB test file:

从这里我们可以看到我们实际上已经很接近了! 考虑到我们使用的是支持垃圾回收的高级语言,而代码只有80行左右,我认为我们做的还不错!

06 Using our Cores

人们可能不会期望并行化到多个内核会做很多事情,因为大概整个操作都受到IO的限制,但是无论如何我还是想试试。

我们已经以Monoid表示了我们的问题,这意味着拆分计算应该很简单! 实际难度在于读取数据部分。 如果尝试读取所有数据,然后将其拆分为多个块,则必须立即将整个文件加载到内存中,这对于我们的内存驻留确实是非常不利的,而且也可能会损害我们的性能! 我们可以尝试将其流式传输并以这种方式拆分,但是随后我们必须处理第一个块,然后才能进行第二个拆分。 希望您能在那里看到问题。 相反,我将为每个核心启动一个单独的线程,然后在每个这些线程中打开一个单独的文件句柄。 然后,在将计数组合在一起之前,我将寻找每个句柄以消除相交的偏移量,并对文件的每个非重叠部分执行我们的操作。

这就是全部代码,我之前有没有说过有多喜欢在Haskell中编写并发代码?

import Types

import Control.Monad

import Data.Traversable

import Data.Bits

import GHC.Conc (numCapabilities)

import Control.Concurrent.Async

import Data.Foldable

import System.IO

import System.Posix.Files

import qualified Data.ByteString.Lazy.Char8 as BL

import Data.ByteString.Internal (c2w)

import GHC.IO.Handle

multiCoreCount :: FilePath -> IO Counts

multiCoreCount fp = do

putStrLn ("Using available cores: " <> show numCapabilities)

size <- fromIntegral . fileSize <$> getFileStatus fp

let chunkSize = fromIntegral (size `div` numCapabilities)

fold <$!> (forConcurrently [0..numCapabilities-1] $ \n -> do

-- Take all remaining bytes on the last capability due to integer division anomolies

let limiter = if n == numCapabilities - 1

then id

else BL.take (fromIntegral chunkSize)

let offset = fromIntegral (n * chunkSize)

fileHandle <- openBinaryFile fp ReadMode

hSeek fileHandle AbsoluteSeek offset

countBytes . limiter <$!> BL.hGetContents fileHandle)

{-# INLINE handleSplitUTF #-}

countBytes :: BL.ByteString -> Counts

countBytes = BL.foldl' (\a b -> a <> countChar b) mempty

{-# INLINE countBytes #-}

这里涉及很多知识,我会尽量将其分解。

我们可以从GHC.Conc导入程序可用的“功能”数量(即我们可以访问的核心数量)。 在要计数的文件上运行fileStat,以获取文件中的字节数。使用整数除法确定每个核心应处理多少字节。 整数除法会将结果四舍五入,因此我们必须谨慎选择以后可能遗漏的字节。然后,使用Control.Concurrent.Async中的forConcurrently为每个功能运行一个单独的线程。

在每个线程内,我们检查是否在处理文件的LAST块的线程内,如果是,则应读取直到EOF为止,以避免之前提到的遗漏字节问题。否则,就只能处理chunkSize字节。 然后,我们可以通过将块大小乘以线程号来计算文件的偏移量。以二进制方式打开文件,使用hSeek将我们的描述符移动到起始偏移量位置。 从这一点来看,我们可以简单地读取分配的字节数,并使用与以前相同的逻辑进行fold。 处理完每个线程后,我们将使用简单的fold将每个块的计数合并为总计数。

我们在一些地方使用<$!>来增加其他严格性,因为我们要确保fold操作发生在每个线程内,而不是在连接线程之后。我可能会在严格性注释上有些过分,但是添加过多的内容要比查找我们偶然错过它们的地方容易。

Let's take this puppy out for a spin!

缓存预热后,我在配备SSD的4核2013 Macbook Pro上对它们分别运行了几次,并将结果平均:

不知名的它 竟然用80行代码打败了C语言

似乎有很大的不同! 实际上,比经过数十年手工优化的某些C代码还要快。可能是多层磁盘缓存正在发生。也许多线程仅在从缓存中读取文件时有效吗?

我做了一些测试,似乎某些存储设备可能会通过并行执行文件读取来加快速度,有些实际上可能会变慢。情况会有改变。

UPDATE: Turns out some folks out there ARE experts on SSDs! Paul Tanner wrote me an email explaining that modern NVME drives can typically benefit from this sort of parallelism, so long as we're not accessing the same block (and here we're not). Unfortunately, my ancient macbook doesn't have one, but on the plus side that means this code might actually run even FASTER on a modern drive. Thanks Paul!

程序的实际用户时间为4.22s(分为4个内核),这意味着就实际处理器周期而言,并行程序的效率不如简单版本,但使用多个内核可以减少运行时间。

07 Handling Unicode

到目前为止,我们一直忽略一个问题,假设每个文件都是简单的ASCII! 但事实并非如此。如今,许多文档都在使用UTF-8编码,仅当文件包含有效的ASCII字符时,它才与ASCII文件相同。但是,如果年轻人在其中放了一些Emoji表情,那么它将把所有事情搞砸。

这个问题有两个方面。 首先,我们目前将BYTES而不是CHARACTERS视为计数,因为在ASCII码域中,它们在语义上是相同的。 在我们当前的代码中,如果遇到UTF-8编码的皱眉脸,当它仅算作一个字符时,我们将把它算作至少2个字符。所以也许我们实际上应该在解码这些东西,但是说起来容易做起来难,因为我们将文件分割成任意字节数的块。这意味着我们可能最终将那张皱眉符号分成两个不同的块,导致解码无效!

这就是为什么执行多线程wc可能并不是一个好主意的原因,但我仍将做一些假设:

▪ 输入ASCII或UTF-8编码。当然,还有其他流行的编码。 但以我有限的经验,大多数现代文本文件都更喜欢其中一种编码。 实际上,有很多网站致力于将UTF-8当作一种可以全部统治的格式。

▪ 我们仅将ASCII空格和换行符视为空格和换行符处理。

通过做出这两个假设,我们可以利用UTF-8编码方案的一些细节来解决我们的问题。 首先,从UTF-8规范我们知道它与ASCII完全向后兼容。 这意味着每个ASCII字节都以UTF-8编码为完全相同的字节。 其次,我们知道文件中的其他字节与有效ASCII字节的编码不会冲突。 可以在UTF-8维基百科页面上看出。 连续字节以前导“ 1”开头,没有ASCII字节以“ 1”开头。

这两个事实意味着我们可以安全地保持当前的“空格”检测逻辑不变! 不可能“分割”空格或换行符,因为它们都被编码在单个字节中,而且我们不会统计不同代码点的某个字节,因为ASCII字节的编码没有重叠 。 但是,我们确实需要更改字符计数逻辑。

关于UTF-8的最后一个事实是,每个UTF-8编码的代码点都只包含集合中的一个字节:0xxxxxxx,110xxxxx,1110xxxx,11110xxx。 之后字节全部以10开始,因此,我们只需统计不是10的所有字节便可。那么即使将代码点划分为不同的块,我们也将对每个代码点进行精确计数!

综合以上内容就意味着我们可以编写一个按字节处理的monoid,它可以一并计算UTF-8代码点和ASCII字符!

请注意,从技术上讲Unicode代码点与“字符”不同,有许多类似变音符号的代码点会“融合”为单个字符显示,但据我所知wc也不单独处理它们。

实际上,我们当前的Counts monoid很好,只需要调整countChar函数即可:

import Data.Bits

import Data.ByteString.Internal (c2w)

countByte :: Char -> Counts

countByte c =

Counts {

-- Only count bytes at the START of a codepoint, not continuation bytes

charCount = if (bitAt 7 && not (bitAt 6)) then 0 else 1

, wordCount = flux c

, lineCount = if (c == '\n') then 1 else 0

}

where

bitAt = testBit (c2w c)

{-# INLINE countByte #-}

现在我们可以处理UTF-8或ASCII, 我们甚至不需要知道正在处理哪种编码,就能给出正确的答案。

wc(至少是Macbook上的版本)具有-m标志,用于在计数时处理多字节字符。 一些快速实验表明,wc处理多字节字符会大大减慢该过程(现在它解码每个字节)。 让我们比较一下我们的版本。 (我已经确认,当它们在带有许多非ASCII字符的大型UTF-8编码文档上运行时,它们将获得相同的结果)

不知名的它 竟然用80行代码打败了C语言

543 MB test file:

就如我们猜测的一样,遥遥领先于C! 我们的新版本比只计算每个字节时要慢一些(我们现在要进行一些额外的位检查),因此在程序中添加utf标志可能是个好主意,这样我们就可以始终以最快的速度运行,达到最好的性能。

08 Interjection!

自从发布该文章以来,Harendra Kumar为我提供了新的性能调整,可以尝试,以获得更好的性能,同时还支持从stdin中流输入! 哇! 代码也很漂亮!

秘密在于 streamly 库,这是一个出色的高级高性能流式库。看一下代码:

module Streaming where

import Types

import Data.Traversable

import GHC.Conc (numCapabilities)

import System.IO (openFile, IOMode(..))

import qualified Streamly as S

import qualified Streamly.Data.String as S

import qualified Streamly.Prelude as S

import qualified Streamly.Internal.Memory.Array as A

import qualified Streamly.Internal.FileSystem.Handle as FH

streamingBytestream :: FilePath -> IO Counts

streamingBytestream fp = do

src <- openFile fp ReadMode

S.foldl' mappend mempty

$ S.aheadly

$ S.maxThreads numCapabilities

$ S.mapM countBytes

$ FH.toStreamArraysOf 1024000 src

where

countBytes =

S.foldl' (\acc c -> acc <> countByte c) mempty

. S.decodeChar8

. A.toStream

{-# INLINE streamingBytestream #-}

注意:这里使用的streamly7.10直接从Github存储库中获得,可能很快就会发布到hackage上。 它还使用了一些内部模块,但是希望这样的用例可以证明这些组合器具有足够的有效用法来公开它们。

首先,我们只需打开文件即可。

接下来就是刘代码,我们将从下往上阅读。

FH.toStreamArraysOf 1024000 src

将自文件描述符中读取的字节分块放到Byte数组流中。 使用字节数组最终比使用惰性ByteString更快! 每1MB使用一个单独的数组,可以根据自己的喜好调整。

S.mapM countBytes

它使用mapM在数组上运行countBytes函数。 countBytes本身会从数组创建一个流,并使用Monoidal字节计数器对其进行流式折叠:

countBytes =

S.foldl' (\acc c -> acc <> countByte c) mempty

. S.decodeChar8

. A.toStream

接下来,我们告诉streamly在数组上运行map,从而允许单独的线程处理每个1MB的块。我们将线程数限制在核心数量。 一旦读完数据,我们就可以立即对其处理,并且我们的计数代码没有任何阻塞理由。因此,添加更多线程可能只会增加调度程序的工作量。

S.maxThreads numCapabilities

Streamly提供了许多不同的流评估策略,我们使用aheadly,它可以并行处理流元素,但仍可保证将按照与输入相对应的顺序发出输出。 由于我们使用的是Monoid,因此只要一切都以适当的顺序结束,我们就可以按照自己喜欢的任何方式对计算进行分块:

S.aheadly

至此,我们已经计算了1 MB的输入块,但是我们仍然需要将所有块聚合在一起,在另一个流fold中借助mappend来实现:

S.foldl' mappend mempty

这些就是要点,让我们一起看看结果吧!

Here's the non-utf version on our 543 MB test file:

不知名的它 竟然用80行代码打败了C语言

我们可以看到它运行更快,但代价却是消耗大量内存,我怀疑可以通过调整输入分块来缓解这种情况,让我们尝试一下。 这是100 KB块与1 MB块的比较:

不知名的它 竟然用80行代码打败了C语言

如我所料,我们可以用一些性能换取大的内存。

最后,让我们在543 MB测试文件上尝试UTF8版本,以下是所有内容:

不知名的它 竟然用80行代码打败了C语言

我们还在变得越来越快! 对于最终版本,我们可能要减少内存使用量!

总的来说,我认为流媒体版本最好,高级,易读,支持多种描述符(包括stdin)读取,这是wc的一种非常常见的用例。 Streamly非常酷。

Conclusions

Haskell,这个支持垃圾回收,基于运行时的高级编程语言是如何堆积的呢?很漂亮。我们的单核lazy-bytestring wc确实非常接近。 切换到多核方法超过wc, 在没有预热磁盘缓存的情况下,仅从性能看,我们设法构建了比C语言更快的东西。

Haskell作为一种语言并不完美,但是如果我在编写高级的完全经过类型检查的代码时能获得与C程序相当的性能,那么它赢了。

相关推荐