Haskell文本库的瓶颈

2020-10-15 23:18:56

Channable是一个提要处理工具,用户可以在其中定义规则来优化其产品提要。在一个特定的提要中,我们发现处理时间比预期的要长得多。我们开始调查,我们发现的瓶颈来自一个不太可能的地方:文本库。

在这篇博客文章中,我们描述了瓶颈,并比较了四种不同的解决方案。它伴随着一个小项目,该项目包含下面显示的所有代码的基准。试试看!

{-#内联切片#-}--|从@[email protected]到@[email protected]的子字符串::int->;Int->;Text->;Text->;Text Slice Offset len=T.ake len。T.Drop偏移。

起初,这似乎极不可能。文本库以其性能特征而闻名。此外,提要处理软件运行的算法比上面提到的片1复杂得多。但是,用id替换此函数的主体可将此提要的处理速度加快约30%。为什么这个函数会这么慢呢?

让我们来看看Take和Drop函数是如何工作的。由于目前为2,文本库使用UTF-16作为其内部表示。在UTF-16中,字符表示为一个或两个16位代码单元。当一个字符占据两个16位代码单元时,第一个称为“高代理”,第二个称为“低代理”3。具体地说,对于文本,这意味着文本数据结构包含一个表示代码点的Word16数组。除此之外,它还包含Offset::Int和Length::Int,以指示文本表示数组的哪一部分。这对于我们的切片函数非常方便,因为唯一需要做的就是计算新的偏移量和长度。遗憾的是,基本算术不能用来计算这个新的偏移量和长度。这是因为有些字符占据了数组中的两个条目。相反,偏移量和长度都是通过迭代数组、计算字符而不是代码点来计算的。这使得Take和Drop都是O(N)。尽管如此,可以在不修改底层数组的情况下返回答案,因此它们应该不会那么昂贵。以下是Take和Drop(截至Text-1.2.4.0)4的实现:

Take::int->;Text->;Text Take n [email protected](Text Arr Off Len)|n<;=0=空|n>;=len=t|否则=Text Arr Off(IterN N T){-#inline[1]Take#-}{-#Rules";Text Take->;FUSED";[~1]for all n Take n t=unstream(S.ake n(流t))";Text Take->;Unfused";[1]for all n T.unstream(S.ake n(Stream T))=Take n t#-}drop::int->;text->;text drop n [email protected](Text Arr Off Len)|n<;=0=t|n>;=len=空|否则=text arr(off+i)(len-i),其中i=iterN n t{-#inline[1]drop#-}{-#Rules";text drop->;fused";[~1]for all n T.drop n t=unstream(S.drop n(Stream T))";text drop->;unfuse";[1]for all n T.unstream(S.drop n(流t))=drop n t#-}。

在这些实现中,我们确实可以看到一些对iterN的调用,修改了偏移量和len,并原封不动地传递底层数组。不过,有一些重写规则,它们会重写成完全不同的内容,然后再重写回来。也许问题出在那里。

通过重写规则,可以指示GHC用等价但更有效的函数调用替换低效的函数调用。其中一个著名的例子是map f(Map G Xs)可以写成map(Fg)xs。最终结果在语义上是相同的,但是第二个结果只在数据结构上映射一次,从而使其速度更快。有关重写规则的更多信息,请参阅GHC手册。

Data.Text的文档提示了为什么存在这些特定的重写规则:

此模块中的大多数函数都要进行融合,这意味着此类函数的管道通常最多分配一个文本值。

为了实现这种融合,还有文本价值的第二种表示,即流。每个受融合影响的函数都有重写规则,这些规则可以与使用Stream而不是文本的替代实现相互转换。根据这些规则的应用方式,编译后的代码可能最终会完全不同。幸运的是,有一些编译器选项可以显示应用了哪些规则(-ddump-Rule-firings),甚至可以显示它们如何重写代码(-ddump-Rule-rewrite)。这表明应用了三条重要规则:

激发的规则:Text Drop->;Fused(Data.Text)规则激发的规则:Text Take->;Fused(Data.Text)(许多其他无关的规则)激发的规则:流/非流融合(Data.Text.Internal.Fusion)。

前两条规则如上所示,第三条规则如下:

此规则意味着将Stream转换为文本,然后再转换回Stream与仅保留Stream原样相同。之所以可以这样做,是因为流和文本是同一数据的不同表示形式。

切片偏移量len=\t->;Take Len(Drop Offset T)--应用规则";text drop->;fused";~\t-&>;Take Len(unstream(S.drop Offset(Stream T)--应用规则";text Take->;fused";~\t-&>;unstream(stream(unstream(S.drop Offset(Stream T)--应用规则";流/流融合";~\t->;unstream(S.taken len(S.Drop Offset(Stream T)。

最后,我们有一个表达式将文本转换为Stream,应用Drop和Take的一些Stream变体,然后再次转换回文本。这兑现了融合的承诺:即使管道中有多个更改原始值的操作,也只创建一个新文本。对于许多情况,这将是一种优化,因为它可以消除创建中间副本。然而,对于我们的切片函数,原始实现一开始并不创建中间副本。这里的优化比原始优化效率低得多,因为它创建了一个完全不必要的输入字符串副本。

现在我们了解了瓶颈的来源,我们可以研究一个解决方案:确保在这种特定情况下不会发生融合。

在这个问题的内部讨论中,提到了几种解决方案。每个人都以自己的方式处理这个问题。下面列出了它们,并说明了它们解决问题的原因。为了便于访问,这些解决方案的基准结果放在基准repo的readme.md中。

NoInlineTakeSlice::int->;Int->;text->;text noInlineTakeSlice Offset len=noInlineTake len。T.drop Offset--禁用Text';的Take的内联{-#NOINLINE noInlineTake#-}noInlineTake::Int->;Text->;Text noInlineTake=T.Take。

这将导致编译器无法重写Take函数。具体地说,文本Take->;融合规则在调用点不匹配,因为该函数被赋予了不同的名称,并且它在noInlineTake的定义中也不匹配,因为该规则仅在使用其两个参数调用Take函数时才匹配。

相反,有两个规则在DROP上触发,即文本DROP->;FUSED和Text DROP-&gT;UNFUSED。重写拖放到流版本,然后再重写回文本版本。请注意,它实际上不会应用这两条规则。

为什么要这样做,而不是丢弃呢?武断的选择。使用Drop或同时使用Take和Drop执行此操作将具有基本相同的效果。

SequencedSlice::int->;Int->;Text->;Text SequencedSlice Offset Limit Text=let!Suffix=T.Drop Offset Text in T.Take Limit Sufix。

强制求值时,DROP和Take都将被重写为流版本。但是,数据流/非数据流融合规则将无法匹配,因为评估是在两者之间强制进行的。无法合并流,使用文本DROP-&gT;UNFUSED和TEXT Take-&>UNFUSED规则将DROP和Take重写回文本版本。

{-#OPTIONS_GHC-WNO-OBORANS#-}--取消有关孤立规则sliceWithRule::int->;Int->;text->;text sliceWithRule Offset Limit=T.Take Limit的警告。T.Drop Offset{-#Rules";Text Take。DROP-&>;UNFUSED>;UNFUSED";[1]for all len off t.unstream(S.taken(S.drop off(S.stream t)=T.taken(T.drop off t)#-}。

此规则与重写Slice函数后创建的特定问题流相匹配,并将其全部返回到原始实现。此解决方案将影响直接或间接导入此模块的所有代码,因此应牢记这一点。

最后一个解决方案使用内部函数实现略有不同的切片操作版本。为了获得最好的性能,我们作弊:

重新实现Slice::int->;Int->;Text->;Text->;Text重新实现切片偏移量len [email protected](Text.Internal.Text u16data off prevLen)|offset2>;=prevLen=Text.Internal.Empty|len2<;=0=Text.Internal.Empty|Also=Text.Unsafe.takWord16 len2$Text.Unsafe.dropWord16 offset2 t其中offset1=min premisLen$max 0 Offset len1=min(PrevLen-Offset1)$max 0 len offset2=if isLowSurrogate offset1则offset1+1否则offset1 len2=if isHighSurrogate(offset2+len1-1)则len1-1其他len1--|返回给定索引处的代码单元是否启动代理项对。--在有效的UTF-16中,这样的代码单元后面必须跟一个低代理。IsHighSurrogate::int->;Bool isHighSurrogate!i=let w=Text.Array.unsafeIndex u16data(off+i)in i>;=0&;&;i<;prevLen&;&;w>;=0xd800&;&;w<;=0xdbff--|返回给定索引处的代码单元是否结束代理项对。--在有效的UTF-16中,这样的代码单元前面必须有高替代项。IsLowSurrogate::int->;Bool isLowSurrogate!i=let w=Text.Array.unsafeIndex u16data(off+i)in I>;=0&;&;i<;prevLen&;&;w>;=0xdc00&;&;w<;=0xdfff;

如前所述,由于UTF-16编码,字符占据底层数组中的一个或两个条目。此实现基本上忽略了这一点,并计算新的偏移量和长度,而无需迭代数组来正确计算字符。它假设所有字符只占用一个字16。该假设使该实现成为O(1)而不是O(N)。不过,它确实确保了至少不会在高和低代理之间进行切割,因为那样会创建无效的UTF-16。

认为底层数组每个字符包含一个Word16字的假设是明显错误的。尽管如此,还是有一些理由需要认真考虑这一实施。除了速度之外,这种实现更接近于许多常见编程语言(如Java和C#)的子字符串操作。虽然指责其他编程语言几乎不是借口,但当必须与第三方程序或服务交互时,它可能成为一个合理的论据。这是因为在这种情况下,匹配不良行为可能比拥有正确的行为更重要。

幸运的是,最常用的字符是基本多语言平面(BMP),它占用一个单词16。这可能意味着这种实现在实践中可能不会有太大的不同。在它确实起作用的地方,弦被比应有的更早和更短地切断。虽然不是很理想,但对于我们的特定用例来说可能已经足够了。

下面是基准测试结果的图表。基准的来源可以在这里找到。请注意,Y轴是对数的。这是因为naiveSlice的值与其他函数的值相差很大。

作为唯一的恒定时间实现,reimplementedSlice轻而易举地击败了基准测试中的所有其他实现。尽管如此,我们还是没有选择这个实现。事实证明,在我们的现实世界中,所有解决方案之间的性能差异可以忽略不计。基准测试中显示的重新实现的Slice的巨大收益在存在其他瓶颈的情况下丢失了。此外,在生产中使用之前,重新实现Slice需要做大量的工作:测试错误,测量对客户的实际影响,更不用说在文本库更改其实现时的未来工作。这种工作量,再加上它不会产生真正的性能差异的知识,使得它的不正确实现不可能得到辩护。

选择权落到了另外三个人身上。NoInlineTake、SequencedSlice和sliceWithRule在基准测试中的表现都非常相似。然后排除了sliceWithRule解决方案,因为它的重写规则规则将影响直接或间接导入包含文件的任何内容。虽然这可能会使其他代码变得更快,但具体何时发生就变得不清楚了,特别是当包含规则的文件是几个导入的时候。最好将此规则放在文本库本身中,它将始终出现在文本所在的任何位置。

后两种解决方案很难选择,尽管在基准测试中,noInlineTake比SequencedSlice略有优势。这种轻微的优势可能没有任何意义,因为它可以归因于这个特定的基准是如何编制的5。不过,它允许我们决定获胜者。所以,noInlineTake就是这样。

总体而言,这一经历提醒我们,瓶颈可以在不太可能的地方找到。不过,更值得注意的是,您在查找函数时看到的函数的实现不一定是最终出现在编译程序中的实现。重写规则的存在应该会提醒查看者进行更深入的调查。最后,并不是所有的优化都能使代码更快。有时,在边缘情况下,它们最终会造成伤害。

此问题的真正解决方案是将sliceWithRule中显示的规则逆流到文本库。可以在Haskell/text存储库中找到此更改的拉取请求。由于子字符串操作可能相当常见,因此这种特殊的优化可能会对许多项目有利。也许还有其他功能组合可以从类似的规则中受益。我们期待着进行那次讨论。

他说:其中一个算法是Alfred-Margaret,早些时候的一篇博客文章中提到的Aho-Corasick的实现。↩︎。

他说:文本图书馆的黑客页面说,关于改用UTF-8作为内部表示法的调查正在进行中。↩︎。

他说:为了简洁起见,这个解释很简单。要更全面地解释Unicode和UTF编码,我建议到处阅读UTF-8。↩︎

4:代码按照其许可在此处复制,托管在GitHub的Haskell/text下。↩︎。

5:在编写基准时,我们遇到了GHC问题,该问题已通过Package.yaml中的标志解决。这里使用的微基准测试受到编译器实现细节细微差别的影响。↩︎