抽取数组。使用AVX2排序

2020-05-23 22:35:36

抽取数组,用AVX2排序。最后,我用AVX2内部函数重新实现了数组排序。我没有理由一个人下去。

我最终用AVX2内部函数重新实现了数组排序,没有理由一个人去。

由于这里有很多东西要做,我将把它分成几个部分:

在第1部分中,我们首先回顾一下快速排序,并将其与Array.Sort()进行比较。

在第2部分中,我们回顾了矢量化硬件内部函数、矢量类型的基础知识,并回顾了我们将在第3部分中使用的几条矢量化指令。我们仍然不会对任何东西进行排序。

在第3部分中,我们检查了矢量化排序的初始代码,并开始看到一些回报。在CPU的分支预测器的帮助下,我们完成了令人痛苦的工作,这给我们的尝试带来了麻烦。

在第4部分中,我们回顾了我尝试过的几种优化方法,试图让矢量化分区运行得更快,看看哪些方法有效,哪些方法不起作用。

在这一部分中,我们将深入探讨如何处理内存对齐问题。

在第6部分中,我们将暂停矢量化分区,通过使用更多的AVX2矢量化来实现小的恒定大小的数组排序,以消除几乎100%剩余的标量代码。

在第7部分中,我们将返回并尝试处理矢量化分区代码中留下的令人讨厌的速度减慢。

在第8部分中,我将告诉您一个非常扭曲的优化的悲惨故事,我成功地完成了这个优化,但同时却惨败。

在第9部分中,我将尝试一些算法改进,以便从这段代码中榨取最后的Perf,或者至少是我能想到的那些Perf。

我认为展示我最终试图提高性能的一系列东西会很好,我试图将这些实验中的大多数放在单独的实现中,既有产生积极结果的实现,也有失败的实现。这些可以在Happy和Sad文件夹下的原始回购中看到。

虽然有些管用,有些不管用,但我认为其中有一些值得一提的是,所以这里是这样的:

这句话摘自Hennessy和Patterson的“计算机体系结构:量化方法,第6版”(Computer Architecture:A Quantity Approach,第6版),它可以追溯到1946年的现代计算之父,可以被视为与任何涉及存储器层次结构复杂性的东西相关的痛苦的不祥预兆。

对于现代计算机硬件,当内存自然对齐时(换句话说,当我们使用的地址是某个神奇常数的倍数时),CPU可能会更有效地访问内存。该常量通常是机器字大小,在32/64位机器上为4/8字节。这些常量与CPU的物理布线和内部构造方式有关。从历史上看,较老的处理器过去非常有限,要么不允许,要么严重限制性能,内存访问不对齐。时至今日,非常简单的微控制器(例如,您可能在物联网设备中找到的微控制器)将在内存对齐方面表现出这样的限制,本质上强制内存访问必须符合4/8字节的倍数。随着CPU越来越现代化(也就是说更贵),这些要求变得越来越宽松。大多数程序员可以简单地忽略这个问题。过去十年左右的现代处理器本身都忽略了这个问题,只要我们访问单个高速缓存线内的内存,或者在几乎任何现代处理器上访问64字节的内存。

这是什么高速缓存线?我正在积极地抵制我的内在倾向,所以我不会把这篇文章变成关于计算机微体系结构的迂回。到目前为止,更多才华横溢的作家已经在别处发现了一些令人厌恶的藏品,我无论如何都不会公正地这样做的。相反,我将只做强制性的一段提醒,让我们回想一下CPU并不直接与RAM通信,因为它非常慢;相反,它们从称为高速缓存的内部、片上、特殊/快速的存储器中读取和写入。缓存包含RAM的部分副本。缓存更快、更小,并按多个级别(L1/L2/L3缓存)进行组织,其中每个级别的大小通常更大,延迟稍慢。当CPU接到访问内存的指令时,它会改为与高速缓存单元通信,但它从不以小单元为单位进行通信。即使我们的代码正在读取单个字节,CPU也会在称为高速缓存线的工作单元中与其高速缓存子系统进行通信。从理论上讲,每个CPU型号都可能有自己的高速缓存线定义,但实际上,过去15年的处理器似乎都集中在64字节上作为黄金数字。

如前所述,就CPU而言,工作单元是一条64字节的高速缓存线。因此,这样的读取实际上会导致CPU向下游发出两个读取操作,最终指向高速缓存单元1。这些高速缓存线交叉读取确实对性能2有持续的影响。但是它们多久发生一次?让我们通过示例的方式来考虑这一点:假设我们正在按顺序处理单个数组,一次读取32位整数,即4字节;如果由于某种原因,我们的起始地址不能被4整除,则跨缓存行读取将以4/64或6.25%的读取速率发生。即使这种微不足道的跨缓存行读取速率通常也停留在理论领域,因为我们有内存分配器和编译器在幕后协同工作,以使其消失:

一方面,默认分配器总是返回至少与机器字大小对齐的内存。

编译器/JIT根据需要在成员之间的类/结构中使用填充字节,以确保各个成员与4/8字节对齐。

到目前为止,我已经告诉你为什么/什么时候你不应该关心对齐。这是我既能让你轻松进入这个话题,又能让你感觉良好的方式,如果这对你来说是新闻的话。在很大程度上,你真的可以在不支付任何罚款的情况下不考虑这一点。遗憾的是,对于Vector256<;T&>大小为32字节宽(256位/8)的读取,这不再适用。对于我们的分区问题,这一点更不适用:

交给我们进行分区/排序的内存很少与32字节对齐,除非运气不好。分配32位整数数组的分配器根本不关心32字节对齐。

即使它神奇地对齐到32字节,也不会给我们带来什么好处;一旦单个分区操作完成,快速排序固有的进一步细分将由我们使用的最后一个透视的(随机)新位置确定。我们不可能幸运到每个分区都是32字节对齐的。

现在很明显,我们不会是32字节对齐的,我们最终意识到,当我们按顺序(像我们这样从左到右和从右到左)遍历数组时,在64字节高速缓存线的顶部发出未对齐的32字节读取,我们最终每隔一次读取就跨高速缓存线读取一次!或者是50%的税率!这是从“…”升级而来的。一般不成问题的“休斯敦,我们有问题”很快就变成了“休斯敦,我们有问题”。

到目前为止,您已经忍受了大量的挥手,让我们来看看是否可以通过启动perf来获得一些确凿的证据,这一次跟踪奇怪的特定mem_inst_retired.plit_loadsHW计数器:

$COMPLUS_PerfMapEnabled=1 perf record--fmax-e mem_inst_retired.plit_load\./Example--type-list DoublePumpJedi--size-list 100000\--max-loops 1000--no-check$perf report--stdio-F开销,sym|head-20#要显示Perf.data标头信息,请使用--Header/--Header-Only选项。#事件计数(约):87102613#开销符号86.68%[.].DoublePumpJedi::VectorizedPartitionInPlace(int32*,int32*)5.74%[.].DoublePumpJedi::Sort(int32*,int32*,int32)2.99%[.]_MEMMOVE_AVX_UNALIGN_ERMS。

我们运行相同的排序操作1,000次,得到87,102,613个拆分加载,其中86.68%归因于我们的分区函数。这意味着每种100,000个构件(87102613*0.8668)/1,000或75,500个分割荷载。要达成协议,我们首先需要计算出每个排序要执行多少向量加载;幸运的是,我可以快速生成答案:我的代码中嵌入了统计数据收集代码,因此我可以发出以下命令:

总共,我们在4,194个分区调用中对100,000个元素执行每次排序操作173,597次向量加载。假设我们的数组一开始就对齐到4字节(C#的分配器非常可靠地做到了这一点),每个分区调用都有4/32或12.5%的结果是32字节对齐的:换句话说,总共21,700的向量读取应该完全是对齐的,这就留下了173597-21700或151,898应该是未对齐的,其中,我声称1/2会导致拆分加载:151,898的50%是75,949,而我们测量的是75,500的性能。我不知道你平常的一天是怎么过的,但在我的生活中,现实和我的幻觉很少会像这样相伴而行。

好的,我们现在知道我们有问题了。第一步是承认/接受现实:我们的代码确实生成了大量的内存分割操作。让我们考虑一下与对齐相关的读/写时的内存访问模式,看看是否可以做些什么:

对于写入,我们无处不在:我们总是根据数据的分区方式推进写入指针,例如,它完全依赖于数据,我们对写入地址几乎无话可说。此外,碰巧的是,Intel CPU和几乎所有其他现代CPU一样,采用了另一种常见的存储缓冲区或写组合缓冲区(WCB)形式的技巧。我不会在这里描述它们,但底线是我们都不能/不需要关心算法的编写方面。

对于读取,情况完全不同:一方面,我们总是将读取指针前进8个元素(32字节),而且我们甚至有一个特殊的内部函数:Avx.LoadAlignedVector256()/VMOVDQA 3,它可以帮助我们确保读取与32字节正确对齐。

由于这个冗长的介绍已经结束,现在是我们对这些跨缓存行读取做点什么的时候了。最初,我让“一些东西”快速工作:请记住,我们需要处理数组的其余部分,但无论如何,我们只有不到8个元素。在第三篇文章末尾的原始代码中,我们是在向量化循环之后立即这样做的。如果我们将标量代码从函数末尾移到其开头,同时还修改它以执行标量分区,直到readLeft/readRight指针都对齐到32字节,我们的工作就完成了。在这个原本简单的方法中有一个小问题:

以前,我们在每个分区调用中留下0-7个元素作为剩余的标量分区。

从我们分区的边缘与标量代码对齐意味着我们现在每侧…将有0-7个元素。

换言之,执行这种向内的预对齐优化并不是一件轻而易举的事:一方面,我们最终会得到比以前更多的标量工作(这是不幸的),但另一方面,我们可以将向量加载代码更改为使用Avx.LoadAlignedVector256(),并且可以肯定地知道,我们将不再导致CPU发出单个交叉缓存线读取(后者是性能提升)。在阅读本文时,如果您的直觉认为添加3.5标量运算听起来不是一种权衡,这是可以理解的,但我们必须考虑这一点:

正如前面所讨论的,每个标量比较都可能会导致分支预测错误,因此它的成本比您最初可能计入的成本要高。

更重要的是:我们不能忘记这是一个递归函数,分区大小不断减小。如果您返回到我们在前面的帖子中收集的初始统计数据,您很快就会被提醒,我们对100万个元素数组进行了超过34万次的分区,因此随着分区大小减少…,这种标量工作既堆积在一起,也代表了我们的工作负载的更大部分。

我不会费心显示B5_1_DoublePumpAligned.cs的完整代码清单,但我将显示重写的标量分区块,它现在的任务是在进行完全矢量化分区之前对齐指针。最初它正好在双泵循环之后,看起来是这样的:

//.。While(readLeft<;readRight){var v=*readLeft++;if(v<;=Pivot){*tmpLeft++=v;}Else{*--tmpRight=v;}}。

对齐的变体现在将对齐代码放在函数的顶部,如下所示:

const Ulong ALIGN=32;const ULONG ALIGN_MASK=ALIGN-1;IF(ULong)readLeft&;ALIGN_MASK)!=0){var nextAlign=(int*)(Ulong)readLeft+Align)&;~ALIGN_MASK);While(readLeft<;nextAlign){var v=*readLeft++;if(v<;=Pivot){*tmpLeft++。}Else{*--tmpRight=v;}调试。Assert(Ulong)readLeft;amp;Align_MASK)==0);IF(ULong)readRight&;ALIGN_MASK)!=0){var nextAlign=(int*)((Ulong)readRight&;~Align_MASK);While(readRight&>nextAlign){var v=*--readRight;if(v<;=vot){*tmpLeft++=。}Else{*--tmpRight=v;}调试。Assert(Ulong)readRight&;ALIGN_MASK)==0);

它现在所做的是检查何时需要对齐,然后继续对齐,同时还将每一端分区到临时内存中。

N,100,1K,10K,100K,1M,10M绝地,1,1,1,1,1对齐,1.082653616,1.091733385,0.958578753,0.959159569,0.964604818,0.980102965。

BenchmarkDotNet=v0.12.0,OS=Clear-Linux-os 32120英特尔酷睿i7-7700HQ CPU 2.80 GHz(Kaby Lake),1个CPU,4个逻辑和4个物理核心.NET Core SDK=3.1.100[主机]:.NET Core 3.1.0(CoreCLR 4.700.19.56402,CoreFX 4.700.19.56404),X64 RyuJIT Job-DEARTS:.NET Core 3.1.0(CoreCLR 4.700.19.。x64 RyuJIT InvocationCount=3 IterationCount=15 LaunchCount=2 Unroll factor=1 WarmupCount=10$grep';步进\|型号微码';/proc/cpuinfo|head-4型号:158型号名称:英特尔(R)酷睿(TM)i7-7700HQ [email protected] GHz步进:9微码:0xb4。

在问题大小较低的情况下,由于标量操作计数较高,我们似乎正在减速。

这是一种喜忧参半的坏情况,也许乍一看有点不起眼。然而,当我们停下来记住,我们以某种方式设法既加快了函数的速度,又使完成的标量功翻了一番,对结果的解释就变得更加微妙:对齐本身的纯粹好处比现在显示的结果要大,因为在某种程度上,它被我们附加的额外标量功掩盖了。如果有一种方法可以跳过所有的标量运算就好了,…。如果有办法…就好了。如果只有…就好了。

下一步是针对同一问题的不同优化方法,与上一个问题相比是一个自然的过程。冒着听起来浮夸的风险,我想我可能在这里发现了一些以前没有人在分区4的上下文中做过的事情:这里的基本思想是我们在矢量化代码路径中消除所有(好的,好的,几乎所有的)标量分区。如果我们能够用矢量化代码划分并对齐我们将要处理的段的边缘,我们将减少执行的指令总数。同时,我们将保留上面的路线优化所损失的更多加速。这将产生双重打击的复合效应。但怎么做呢?

我们可以反其道而行之!我们可以向外对齐并放大分区段,以包括每个分区外缘上的更多(最多7个)元素,而不是在每个相应的方向上向内对齐,然后使用我们刚刚选择的新轴心对它们进行重新分区。如果这样做有效,我们最终可以在一个优化中同时执行100%对齐的读取和消除所有标量工作!这听起来可能简单而安全,但这是QuickSort快速分配的那种谦逊体验(抱歉,我不得不用…)。人们试图以错误的方式推动它。在某种程度上,我终于能够正确地考虑到这种重新划分的尝试,并找出我们必须遵守的关键约束才能起作用。

回到约束条件上来。有一件事我们永远不能做:移动以前分区的轴心。我(现在)称它们为“埋葬的枢轴”(因为它们是在它们最后的安息之地,明白吗?);每个人都知道,你不能在身体周围走动,这总是恐怖电影中发生的第一件坏事。这就是我们的动机:不做先死的蠢人。事情就是这样。这听起来很简单,但它需要一些更严肃的解释:当前一个分区操作完成时,在该操作期间使用的轴心将移动到它的最终休息位置。它的新位置用于细分数组,并有效地存储在递归函数的众多调用堆栈中。这里有一个根深蒂固的假设,即埋藏的轴心点的所有左/右数据都小于/大于它。这一假设永远不能被打破。如果我们打算将数据重新分区到给定分区的左侧和右侧,作为重叠对齐工作的一部分,我们需要考虑这些额外的数据可能已经包含隐藏的透视,并且我们在任何情况下都不能再移动它们。简而言之:埋葬的枢轴就埋在我们离开它们的地方,否则就会发生不好的事情。

当我们调用分区操作时,我们必须考虑要分区段的左边缘和右边缘最初看起来不对称的情况:

对于左侧:左侧可能没有额外的空间用于读取额外的数据。我们离左边数组的边缘太近了!从整个阵列左边缘开始的所有分区都会发生这种情况。

我们总是先对任何埋藏的轴心进行左分区,然后再右分区,我们知道在任何给定的时刻,“我们的”分区的所有剩余元素都是排序的。它们都是埋藏的枢轴,我们不能重新排序。

重要提示:我们还知道这些值中的每个值都小于或等于我们将为当前分区操作选择的任何透视值。

对于右侧,它几乎是相同的一组约束:右侧可能没有额外的空间来读取额外的数据。我们离右边数组的边缘太近了!在整个阵列的右边缘结束的所有分区都会发生这种情况。

我们右侧的直接值是一个埋藏的轴心,其右侧的所有其他值都大于或等于它。

重要提示:我们还知道这些值中的每个值都大于或等于我们将为当前分区操作选择的任何透视值。

所有这些信息一开始都很难集成,但归根结底,每当我们加载左侧重叠向量时,左侧有1-7个元素不允许重新排序,而当我们加载右侧重叠向量时,也有1-7个元素不允许在右侧重新排序。这就是挑战所在;好消息是,所有这些重叠的元素也保证也会比我们最终从原始(SAN重叠)分区外选择的任何轴心点都要小/大。这一知识为我们提供了所需的优势:我们预先知道,与分区内的任何轴心相比,额外的元素将生成可预测的比较结果。

我们需要的是稳定的排列条目。我一边往前走,一边自由地创造了这个短语:稳定的分区意味着。

..