Golang中的ARM64 Popcount及其汇编器

2020-07-14 22:26:09

关于苹果ARM的发布,我想我可能会就我最近写的一段代码写一篇帖子,专门研究ARM64,以及它在各种硬件上的基准测试。

我一直在为一个项目实现一些紧凑的数据结构。该实现的CPU热点之一是需要对可能很大的内存位运行快速人口计数。

如果您以前从未见过人口计数,则它是一个字节(或字节列表)中设置为1的位数的计数,例如:

现在,在过去十年左右的时间里,每个合理的x86_64/AMD641CPU都有一个内置指令:POPCNT。它的工作方式如下(在围棋汇编器中):

通过SSA编译器重写(在1.9中添加),GO在数学/位中使用内置指令,但一次最多只能使用uint64;使用汇编在[]字节上循环要高效得多。

@tmthrogd在github.com/tmthrogd/go-popcount上创建了一个非常不错的x86_64汇编优化包。它工作得很好,绕过了一个奇怪的Intel小错误(参见有用的评论),而且仍然比循环和使用Go标准库更快,后者也很有帮助地进行了基准测试。

最近,我买了一部新的8 GB覆盆子PI 4。给它装上漂亮的新款Manjaro 20.06,并设置了我平常的环境。作为测试,我想试一下我最新的WIP数据结构代码。当然,瓶颈就在我预期的地方:人口计数。

我发现ARM64有一条类似POPCNT的指令,逻辑上称为CNT。我想,既然我一直在玩Go Assembly for memmove,为什么不尝试一下新的架构呢?

Go已经在ARM上为OnesCount提供了SSA-Rewrite(在1.11中添加),但是同样,一次只有uint64。桌上可能会有一些表演。

官方建筑指南已经准备好了,我得去工作了。还有很多东西要学。一些注意事项:

neon是向ARM体系结构添加向量指令的名称,因此我将同时使用一个不熟悉的体系结构及其向量指令。

在x86-land中,POPCNT和矢量化是两个独立的概念。POPCNT作为一条指令,处理日常的64位整数寄存器,而不是向量寄存器(尽管它出现的时间与向量指令的加法SSE4大致相同)2。

ARM/霓虹灯对CNT的作用不同。由于您可以加载项目数组(例如,ARM64向量寄存器中的16个字节),因此CNT将对它们进行单独计数。事实上,您只能在单字节的向量中执行此操作。

因此,这意味着以下签名大致是思考问题的方式:

函数popCountx86(在uint64中)uint64//一次寄存器,64位(8字节)函数popCountNeon(在[16]字节中)[16]uint8//16字节全部并行计数。

编写指令以使GO的汇编器对您给出的指令感到满意,这有点令人沮丧。

在golang.org/pkg/cmd/Internal/obj/arm64上有一个很好的说明,它概述了许多不同之处。例如,所有向量指令都以V开头,不同于ARM64切换到的(它们过去通常在32位ARM上以V开头)-因此,虽然我理解连续性的愿望(甚至有点喜欢它,知道它是向量op),但记住它与原始文档相比又有一些不同。

但更令人沮丧的是,即使您的指令受支持(而且大多数指令都受支持),知道如何在Go汇编程序中使用指令归结起来就是“假设数据从左到右,并希望测试套件中有一个示例”

我是围棋的铁杆粉丝,但围棋进入第9计划的历史和附带的汇编器(相关地,还有奇怪的调用约定)是我对围棋的不满之一,甚至比缺乏泛型(这是改天再讨论的话题)更让我不满。当然,计划9中有一些好想法影响了围棋的设计-从设计层面来说,这很棒!-但在实现层面,我有点希望它遵循了精确度。在Intel VS GNU中,无论你想要什么,都可以。并忽略任何已经存在的文档。

实际上,与其说它是一个分支,不如说它是一个扩展--它提供了相同的API,只是提供了针对ARM64芯片的手写汇编。

向量对其部分结果求和(最多32个单独的向量,以适应8位计数),试图避免数据依赖。

另一件需要平衡的事情是从内存加载多少与优化吞吐量需要做多少工作。结果是一次大约是8个向量(128字节),这可能会随CPU的不同而有所不同,但这是一个很好的起点。

这纯粹是主观的,但在编写ARM64汇编时,有很多时候我感觉“嘿,那很方便”。当然,现代x86_64芯片解释了所有这些不同之处,并使它们具有出色的性能-通过更深的指令流水线或具有最终采用相同技巧的微操作指令队列。但同时,当您开始在指令级工作时,这是一种新鲜的空气。

很多时候,当您处理一个数组时,您会将一大块内存放入寄存器,执行一些转换,然后将其放回原处。

读取时将1字节*大小的结构加载到以下矢量寄存器中-到目前为止还不错,这类似于x86上的VMOVDQU指令族(尽管该指令的大小结构变体是最近添加的)。它具有通过地址/偏移量/大小计算在多个背靠背指令中加载多个寄存器的类似能力,但ARM有一个很好的一行程序。

但我真的很喜欢R1在加载后按读取大小(64)自动递增-因此是后递增。许多来自内存的加载都有类似的标志。它具有很强的描述性,意思是“增加偏移寄存器和减小寄存器大小并测试”代码的相应部分,而不是以后再增加(并找到最佳时间)。

这是历史遗留下来的,但是在尝试手工汇编指令时,具有固定大小指令的一致性是一件好事。当我发现并报告了围棋中的一个bug时,我不得不做一些手工组装。它默默地编写了错误版本的指令,同时接受正确的指令作为输入。向围棋社区致敬-专家在一两天内就修复了它,所以我期待着包含修复的下一个版本!

尽管如此,这意味着只要有一只稳定的手和一份体系结构指南的副本,我就可以可行地实现任何缺失的指令。

同样在一致性区域中,大多数二进制操作都使用input1_reg、input2_reg和output_reg,只有少数例外。省略output_reg是GO的汇编程序语法糖,用于将输出设置为input2。同样出于历史原因(试图保持指令较小),x86通常将存储到第二寄存器作为操作的主要或唯一版本,这可能会导致更多的整体操作(以及认知开销IMO)。

让我们来看看一些基准测试。关于人口计数,最有趣的是这个小例程做了一些有用的事情,显示了CPU、片上缓存和主存之间的CPU界限和内存带宽之间的权衡。当阵列大小小到足以装入CPU缓存(但大到足以运行计算循环几次)时,CPU是限制因素-它可以计算多少位。对于较大的数据大小,内存带宽成为界限;CPU正在等待获得足够的数据来处理。

为此,存储库中的基准曲线最大吞吐量约为16K(大多数可能的工作,同时仍在缓存中),然后随着内存的限制逐渐下降到稳定状态。因此,我将截断完整的基准,以比较峰值吞吐量和长尾。

未优化(GO实施):BenchmarkCountBytesGo/16K 297778 8056 ns/OP 2033.78 MB/s BenchmarkCountBytesGo/512M 8 279380432 ns/OP 1921.65 MB/s优化(我的手卷月份):BenchmarkCountBytes/16K 520807 2303 ns/OP 7113.24 MB/s BenchmarkCountBytes/512M 8 131214574 ns/OP 4091.55 MB/s

这是我在当地的PI上的成品。我可能可以做得更好,但在从内存中抓取和为popcount进行向量相加之间改变块大小的情况在这里达到了顶峰,所以我相当满意。

有趣的是,大约4.1 GB/s的内存带宽与PI 4的初始读取基准完全一致,表明它接近饱和,这是个好消息。

所以我的下一个想法是和我在Packet的老朋友一起启动一台ARM64服务器。他们有一只c2大手臂,一定会很棒的!

未优化:BenchmarkCountBytesGo/16K 69939 17208 ns/OP 952.09 MB/s BenchmarkCountBytesGo/512M 2 582293709 ns/OP 921.99 MB/s优化:BenchmarkCountBytes/16K 458394 458394 2614 ns/OP 6267.80 MB/s BenchmarkCountBytes/512M 12 93371433 ns/OP 5749.84 MB/s。

这不一定是Packet的错-他们很早就有ARM硬件可用,更重要的是,eMag只是一个有点老的芯片设计。在Google上搜索它们的相关基准测试显示出类似的特征,它是跨多个内核均匀分布的工作负载的一个相当好的芯片,但我主要是用这个基准测试单核吞吐量,它不是为此而构建的。

未优化:BenchmarkCountBytesGo/16K 1000000 2281 ns/OP 7182.90 MB/s BenchmarkCountBytesGo/512M 31 76826628 ns/OP 6988.08 MB/s优化:BenchmarkCountBytes/16K 4199566 571ns/OP 28673.85 MB/s BenchmarkCountBytes/512M 85 27507419 ns/OP 19517.31 MB/s。

哇。仅使用默认的Go实现,它就接近我优化的PI 4,但是使用真正的汇编程序,它完全是令人印象深刻的。

我手头上最好的CPU可能是我的Haswell(E3-1231v3)文件服务器--当然它有点旧,但根据我之前的帖子,它至少是围棋世界中一个不错的基准:

未优化:BenchmarkCountBytesGo/16K 893949 1330 ns/OP 12316.43 MB/s BenchmarkCountBytesGo/512M 21 53529366 ns/OP 10029.47 MB/s优化:BenchmarkCountBytes/16K 2068282 574 ns/OP 28524.35 MB/s BenchmarkCountBytes/512M 28 40770214 ns/OP 13168.21 MB/s。

我们可以看到Go针对uint64的内置优化所产生的效果,因为它更接近@tmthrog的手滚汇编。显然,使用这个库还是更好,但至少在优化Go标准库的数学/位包方面已经做了一些工作。

尽管如此,哈斯韦尔在这次测试中的表现与Graviton2大致相当,只是较新的芯片似乎有更好的内存带宽。这绝对让我想要玩更大的ARM硬件,我更愿意相信亚马逊的单位成本性能论据是有道理的。

为了好玩,让我们在AWS上尝试更现代的内核(至强白金8175M [email protected] GHz),将其与以下各项进行比较:

未优化:BenchmarkCountBytesGo/16K 795211 47282854 ns/op 10855.33 MB/s BenchmarkCountBytesGo/512M 18 64222468 ns/op 8359.55 MB/s优化:BenchmarkCountBytes/16K 1783059 673ns/op 24344.22 MB/s BenchmarkCountBytes/512M 24 1509 ns/op 11354.45 MB/s。

&;mldr我想我有点失望,但它跟踪的是cpuBenchmark.net上的单核心基准测试。事实上,该列表或多或少总结了此特定情况下的单核Xeon世界。

如果采用新的Graviton2处理器,ARM64的潜力真的给我留下了深刻的印象。此外,PI 4 8 GB已经成为一个非常有趣的小电路板,因为它有足够的RAM来拥有合理的页面缓存。

在arxiv上有一篇整洁的文章,介绍了使用AVX2指令(大约是Haswell或更新的CPU,同样适用于GO)进行更快的人口计数实现。既然这些指令都是可用的,那么可能值得对更多的x86_64GO汇编进行基准测试才能使用它。

与前面提到的AVX-512中的新指令类似;如果它们变得更加常见,那么对它们进行基准测试将是一件很有趣的事情。

如果我每打一次ARM64和AMD64就有五分钱。尽管它们是Go(和Debian)的体系结构绰号,但我更喜欢GNU三元组形式的aarch64和x86_64(为了清楚起见,我在本文的其余部分使用后者)。↩︎。

这在AVX-512中得到了升级,AVX-512是x86_64环境中的新热点,但这些芯片非常昂贵,而且只有少数芯片真正实现了这一点。↩︎