关于使用向量指令集对memcpy优化的分析
前段时间写基于Neon的OpenCV算法优化算子,突然在想能不能用Neon加速memcpy
?遂搜了一下,网上大家都说彳亍,我寻思一下,也觉得彳亍,又跑去看产品上的memcpy
实现,发现竟没用Neon指令,于是立马写了个demo验证,而结果令人失望,demo的性能和原版memcpy
几乎没区别,甚至可能更慢!
这个结果让我疑惑,因此探索了一番,并记录下来。
ps:
- 本文需要读者有一定的体系结构知识基础
- 关于Neon的基本介绍见此:https://www.cnblogs.com/tianrenbushuai/p/18362141
一、背景
Neon指令集中的ld/st指令拥有比常见Arm内存读写指令(ldr
、str
、ldp
、stp
)更大的单指令数据吞吐量,例如ld1
、st1
两条指令可以一次从内存中读取四个向量寄存器大小(4 * 128 = 512位)的数据放到向量寄存器中,或将数据从四个向量寄存器中放入内存。
因此,本案例尝试探索Neon的内存读写能力,主要与C标准库的memcpy
函数进行对比。memcpy
目前在各个系统和各个平台上都是通过汇编实现,有机器级优化,可以认为是一个内存读写性能的标杆。
二、测试
1. 测试平台
- CPU:某个不方便透露型号的Arm处理器,分大中小核,三种核心整形计算能力大概是3.3 : 3 : 1
- 系统:Linux 5.x
2. 测试信息
测试1M大小的内存复制
- 为最大化体现性能,手动将复制源数据起点地址和目标数据起点地址进行64字节对齐
- 拷贝前对源数据段和目标数据段写入数据,保证所有页面都是非虚拟页面
- 测试时保证系统平均CPU使用率不超过10%
- 每种拷贝方式连续循环一千轮,取每轮执行平均值作为参考
- 所有时间都是用户态的running时间
3. 拷贝方式
除了作为对比标杆的memcpy
方式外,其余借用C和汇编混合编写实现,主要用到ld1
、st1
、ld4
、st4
四个向量指令,一次最多可操作64个字节。ld4
和st4
与前两者的区别在于它们是对数据交叉操作。
如下有5种拷贝方式,除memcpy
外,其余4中方式都是简单的循环拷贝,区别在于每次循环的展开大小,以及用的指令和指令排布方式。至于memcpy
默认用ldp/stp指令进行大容量拷贝。
-
使用标准库memcpy进行拷贝
memcpy(dst, src, TestSize);
-
使用ld1和st1进行64字节展开拷贝
void memcpy_neon_64(void* dst, void* src, size_t num) { void* srcDst = (void*)((uintptr_t)src + num); while (src != srcDst) { asm volatile ( "ld1 {v0.4s - v3.4s}, [%[src]], #64 \n" "st1 {v0.4s - v3.4s}, [%[dst]], #64 \n" : [src] "+r"(src), [dst] "+r"(dst) : : "memory", "v0", "v1", "v2", "v3" ); } }
-
使用ld1和st1进行128字节展开拷贝
void memcpy_neon_128(void* dst, void* src, size_t num) { void* srcDst = (void*)((uintptr_t)src + num); while (src != srcDst) { asm volatile ( "ld1 {v0.4s - v3.4s}, [%[src]], #64 \n" "ld1 {v4.4s - v7.4s}, [%[src]], #64 \n" "st1 {v0.4s - v3.4s}, [%[dst]], #64 \n" "st1 {v4.4s - v7.4s}, [%[dst]], #64 \n" : [src] "+r"(src), [dst] "+r"(dst) : : "memory", "v0", "v1", "v2", "v3", "v4", "v5", "v6", "v7" ); } }
-
使用ld1和st1进行16字节展开拷贝
void memcpy_neon_16(void* dst, void* src, size_t num) { void* srcDst = (void*)((uintptr_t)src + num); while (src != srcDst) { asm volatile ( "ld1 {v0.4s}, [%[src]], #16 \n" "st1 {v0.4s}, [%[dst]], #16 \n" : [src] "+r"(src), [dst] "+r"(dst) : : "memory", "v0" ); } }
-
使用ld4和st4进行64字节展开拷贝
void memcpy_neon_ld4(void* dst, void* src, size_t num) { void* srcDst = (void*)((uintptr_t)src + num); while (src != srcDst) { asm volatile ( "ld1 {v0.4s - v3.4s}, [%[src]], #64 \n" "st1 {v0.4s - v3.4s}, [%[dst]], #64 \n" : [src] "+r"(src), [dst] "+r"(dst) : : "memory", "v0" ); } }
4. 测试结果
单位:微秒(us)
大核 | 中核 | 小核 | |
---|---|---|---|
memcpy | 75 | 91 | 150 |
ld1/st1 16 | 80 | 91 | 215 |
ld1/st1 64 | 79 | 90 | 183 |
ld1/st1 128 | 81 | 88 | 196 |
ld4/st4 | 95 | 104 | 183 |
三、分析
从测试结果可以看到,在三种核心上,几种方式时间消耗都在一个数量级上,最快和最慢的差距一般不会超过30%。
这个结果令人失望,Neon的加载存储指令不仅没有优化,memcpy
甚至略快一点,这和我对向量指令集的理解以及网上大量说法不符,令人疑惑。
而接下来的内容就是解释疑惑。
要强调的是,上述结果和CPU特性高度相关,因此对上述结果的分析并不适用其它硬件平台和标准库版本,但可以提供分析思路。
1. 基础分析
首先了解一下Arm64平台上memcpy
实现要点:
- 写对齐:对从寄存器向内存的写操作进行对齐。注意,Arm上设备内存访问必须是读写都对齐,普通内存则可非对齐访问。
- 大容量循环展开拷贝:对于较大规模的复制任务,一次循环中的复制量展开为128字节,降低比较和跳转操作对性能的影响。
- 使用ldp/stp指令进行循环展开,两条指令一次可以加载和存储两个通用寄存器(共16字节/128位)的数据
- 分治:将拷贝量分为几个区间,例如128字节以上、64字节、64字节以下,不同区间采用不同拷贝方式。例如大于128的拷贝量,在地址对齐后按128字节展开循环拷贝,剩下不足128字节部分按64字节、不足64字节的部分进行拷贝。
再简略提一些要用到的体系结构的知识:
- 指令多级流水线、多发射超标量、乱序执行、微指令架构是现代CPU标配。用人话讲,就是一个指令分多个阶段执行,一个时钟周期可以有多个指令被CPU执行,并且会将指令分解为更小的执行指令,而且实际执行顺序可能与指令顺序不符。
- 从存储体系来看,CPU是典型的内存延迟敏感型处理器,这里说的内存指整个内存体系,包括物理缓存、物理内存,以及交换到外存上的数据。对于所有涉及到寄存器之外的数据的操作,数据访问延迟可能会造成指令执行stall,成为性能瓶颈。
- 现代CPU的访存顺序是L1 cache、L2 cache、L3 cache(如果有的话)、物理内存。当(可缓存的)物理内存被访问后,所在的cache line(Arm64上为64个字节)会被交换到cache中去,之后CPU会直接去cache上访问,这就是cache命中。一般缓存命中率通常在95%以上。
通过上述知识,首先可以对拷贝任务做这样一些判断:
-
拷贝任务主要瓶颈在数据访问延迟。从代码中可以看到,除内存操作指令外,主要计算指令只有比较地址,它是整数比较,在绝大多数CPU上都属于执行最快的一类操作。而访存指令,即便是访问L1 cache也有至少四个时钟周期的延迟,比整数比较只高不低。
-
拷贝操作对地址的访问是连续的,因此可视为一种空间局部性较好的程序,再加上现代CPU通常都有内存预取机制,1000轮的循环测试也进行了预热,因此可以认为绝大部分读写操作都在缓存上完成,通过perf查看测试程序的缓存命中率也能验证这一点:
50682494 cache-misses # 2.860 % of all cache refs 1772343067 cache-references
基于上述判断,首先得到一个推测:不论使用怎样的指令进行读写操作,都会受到缓存读写效率的影响,Intel的优化手册验证了这个猜测。
2. Intel X64情况
上图是Intel haswell读写cache的数据,可以看到L1 data cache在一个时钟周期可以进行最大64字节的加载操作(由两个32字节微加载指令构成)和32字节的存储操作,而L2 cache上可以做到64字节的读写操作。
haswell是Intel第4代CPU架构,于2013年发布,距今已有十年,现在的架构只会更先进。例如六年后发布的Ice Lake架构,从下图的微架构概览图可以看到新增一个STD单元,对L1 cache的存储操作也上升为每个时钟周期执行两次。
有了基础硬件读写能力的支持,向量指令集才有用武之地,在Intel优化手册里明确建议用SIMD指令集优化memcpy。
可见,向量指令集加速memcpy并非空穴来风,在X86-64上可以成立,并且还是官方推荐方法,但为什么在我用的平台上不行呢?这就是接下来要讨论的问题。
3. Cortex-A55情况
根据了解,测试使用的CPU小核是公版Cortex-A55,中核和大核则是Cortex-A76魔改版本。
我们先来看A55的情况,如下是Cortex-A55的微架构图,iss阶段之前可以认为是CPU的前端解码单元,iss之后是后端执行单元,iss就是将前端指令解码后得到的微指令发送到后端执行。
我们提取一些关注点信息
-
从微架构图上看,A55上普通指令是8级流水线,SIMD(即Neon)是十级流水线。即在最理想的情况下,Neon指令比普通指令多执行2个时钟周期。从这能看出,用Neon指令做普通指令也能做的事,前者执行时间可能比后者高,因此一般情况下并不推荐使用Neon。
-
从微架构图上看,A55上有一个store单元和一个load单元(还能看到有两个普通管道的ALU,也就是说A55是一个两标量的超标量处理器)。Neon部分则有MAC、DIV/SQRT、ALU单元,但没独立的store、load单元。这说明A55上Neon的内存读写指令和普通读写指令依赖相同的单元。
-
如下图所示,A55还是一个双发射处理器
但并非所有指令都能双发射,例如我们关注的load/store就有意外情况。
-
重点来了,如下图所示,A55只支持每周期64位宽的读取和128位宽的写入。
这个位宽和X86的位宽是天壤之别。
Ice Lake上64字节的宽度意味它能在单个周期内执行对一个完整的AVX2向量寄存器(256位)甚至是AVX512寄存器(512位)的读写,而A55上的位宽意味着它一个时钟周期最多只能加载一个通用寄存器或半个128位向量寄存器的数据,以及存储两个通用寄存器或一个128位向量寄存器的数据到内存。
因此,若用Neon加载128位数据,它至少需要两个时钟周期对cache读取,与用两条普通指令读取位相比,耗时并不会比更低,存储同样如此,下面就验证了这个结论。
-
下面两张图描述普通
ld/st
指令在理想情况下的最佳执行延迟和指令吞吐量,理想情况包括L1 cache命中、访问对齐。可以看到,
ldp
指令的执行延迟为4(不确定括号里的3是啥意思),吞吐是1/2。stp
指令的延迟只有1,吞吐为1。再看Neon的加载和存储指令。
(高能预警)
噔噔咚!(心肺停止)
从图中可以看到,大部分Neon的ld/st指令延迟都比普通ld/st高,吞吐量比普通指令低。尤其是红框圈出部分,两个ld指令分别加载一个完整的向量寄存器(128位)和加载四个完整的向量寄存器,它们的执行延迟分别达到了4和10个时钟周期!而吞吐量则是1/2和1/8!同样的,红框圈出的st指令分别将一个或四个完整的向量寄存器数据写入内存,其执行延迟为1和4,吞吐量为1和1/4。
通过简单计算不难发现,在每加载128位数据的执行延迟和指令吞吐量的指标上,
ld1
和st1
比起ldp
和stp
没有任何优势!还要注意到的是,上面都是基于理想情况下得到的数据,即可以认为是在连续不断地执行同一个指令时得到的值,实际情况中并不存在,再考虑到Neon指令的阶段比普通指令多两个,于是可以得到一个结论——在A55上,Neon的ld/st类指令比普通的ld/st类指令不仅可能没有性能优势,甚至可能还有劣势!
看完小核,再来看中大核的情况。
4. Cortex-A75/77情况
目前我没找到A76的具体资料,但是可以通过A75和A77来大致推测一下A76的特性。
A75:
A77:
从上面两张图可以看到,A75和A77微架构大致流程不变,都是先在fetch阶段获得指令,然后在CPU前端解码、寄存器重命名、调度并分解为微操作,最后发射给后端管道执行。
而二者主要区别就在于后端管道数量不一样,可以看到A77的分支和标量处理单元都增加一倍,新增一个store单元。
我推测,测试CPU的中大核能力介于二者之间,可能更接近A77甚至就是A77的水平,但应该不会超出太多。
关于A75和A77的ld/st单元具体读写能力的数据没找到,但指令特性里多少表现出了能力。
A75:
A77:
可以看到,A75和A77的ldp在指令吞吐上高于A55,虽然延迟略大一点,但考虑到一般的二者频率更高,其实际速度是要高于A55的。
至于Neon部分,A75:
A77:
可以大致估算一下,通过指令吞吐量来算每周期的数据吞吐量,Neon和ldp也比没有任何优势,和A55情况一致。
5. 总结
向量指令集虽然对寄存器上的数据处理能力大多数时候强于普通指令,但受制于实际硬件实现的限制,尤其是CPU后端执行单元能力的限制,向量指令集的加载/存储能力可能并不能如其表面上的那样可以达到普通指令几倍的效果。而后端执行单元的能力也受限于计算机体系架构的整体设计,例如缓存读写能力、内存控制器能力、内存芯片读写能力等等。而之所以Arm在这方面弱于X86,主要是二者使用场景不同。Arm处理器大多时候应用于低功耗平台,而X86的使用场景大多数时候允许较高的功耗和较强的散热,这就使得前者在很多方面的设计不能激进,从而在微架构方面与X86呈现出不同的特点。
四、如何写出一个高性能复制函数
本节是延伸话题,主要是在总结在探索Neon读写能力过程中看到的、尝试的关于提升读写能力的方法。
1. 手写汇编
目前各个平台上的memcpy都是手写汇编实现,当然这并不是说一定要手写汇编,用C语言写也不是不行,但编译器的行为总是无法完全预测的,例如下面同一段C语言写的代码,同样的优化等级,在gcc9.3和13.2上编出来的就不一样
显然,下图才是理想中的情况,而上图编译器就做了一些难以理解的动作。
因此,在对执行流程熟悉的情况下,手写汇编也许是一种最佳选择,对memcpy这类函数尤其如此。
2. 使用最大化利用CPU读写后端单元位宽的最小消耗指令
前文提及过,CPU后端执行单元中的加载/存储单元执行一条微指时有最大读写位宽,因此我们要保证读写指令能最大化使用其位宽,在X86-64上,AVX指令就可以满足这个要求,而Arm只需要ldp
/stp
就足以。在这个基础上,我们还需要让指令本身的消耗小,例如Neon虽然可以吃满读写位宽,但由于其可能更多的执行阶段等因素,实际执行周期可能比普通指令更多,因此在不需要执行其它向量操作的情况下,并不建议使用它们用来复制数据。
3. 拷贝循环展开
除非CPU中提供特殊指令,否则我们绕不开通过循环进行拷贝,但可以通过在一次循环中尽可能多地复制数据,从而降低判断和地址跳转造成的性能消耗。一般来说,Arm64上memcpy对大容量复制时会采用128字节的循环展开。当然,在CPU读写能力允许的情况下,可以使用更大读写能力的指令进行更大的循环展开。
4. 分治
对于大容量复制时可以使用较大的循环展开,但可能有不足一次循环拷贝长度的剩余复制内容,此时可以分治处理,例如满足64字节部分使用一个策略,不足64字节部分继续分治,从而达到性能最优化。
5. 地址对齐
虽然大多数现代CPU在大多数情况下都支持地址不对齐的访问,但很多时候地址不对齐的访问会有额外的性能消耗,Arm明确表示,地址不对齐时某些加载/存储指令会有惩罚周期,X86也明确表示建议地址对齐。此外,地址对齐读写也有利于cache line交换和写合并机制发挥作用。
当然,我们不能保证函数被调用时传入的地址一定是对齐的,尤其是使用循环展开和分治时,只能对源数据地址或者目标数据地址进行对齐,一般X86和Arm都建议对齐目标数据地址,memcpy
的实现也是如此。
不过,从调用者角度来说,在调用memcpy
或者类似要读写内存的函数时,尽量传入对齐的地址,提升程序性能。
6. 寄存器交叉读写
如前文所说,CPU后端的加载/存储操作是在不同单元上完成的,而且在一个周期内可以向不同单元发射微指令,这就允许我们在一个时钟周期内同时进行加载和存储。同时,现代CPU缓存和内存都是双端口的,意味着可以同时读写,因此,我们可以利用CPU和缓存/内存的同时读写特性来提高性能。
具体来说,就是让读写指令按一定规律交叉起来,例如先连执行三条读执行,并加载到不同寄存器上,然后再连续执行三条写指令,按读指令的寄存器写入顺序从寄存器中取出数据写入内存,以利用读指令的内存访问延迟,并避免对相同寄存器连续读写造成的指令依赖,提升性能。
A55上的示例:
7. 使用缓存预加载指令
(有可能有用,但是有用不太可能)
某些CPU提供缓存预加载指令,该指令顾名思义,是用来将内存中数据预加载到缓存中的,以降低加载/存储指令的访存延迟。但在目前没见过memcpy实际使用过,原因可能是CPU可能本身就有缓存预取策略,再使用预加载指令可能会和前者冲突或者反而产生性能降低,具体应该如何使用要参考CPU优化手册。
8. 多线程复制
(这位更是重量寄)
关于多线程复制能否提升速度、能提升多少长久以来一直是一个具有争议的话题,在实际工程中很少会看到这种策略,但为了全面,我这里还是要阐述一下这个手段。
多线程复制要考虑的因素比较复杂,首先考虑内存通道数量影响。
我们假设,两个复制线程运行在不同的物理CPU上,当它们访问物理内存时要通过内存控制器。通常一个内存控制器对应一个通道,一个通道一次只能进行一次读/写。当体系中只有一个内存通道时,两个线程很可能会同时对内存控制器发出写或者读请求,从而要进行访问仲裁,并有一个访问请求产生延迟。
这就像我只准备了一桌饭,却来了两桌客人,这个饭怎么吃?(101脸)
通常CPU的频率是高于内存的,因此在复制操作这种密集访存型任务会非常容易在上述的场景产生多线程访存冲突从而造成延迟,一旦延迟发生,意味着内存性能成为了任务瓶颈,线程数量再多也没用。
但如果有两个以上的通道就不一样了。
多通道场景下,一般内存地址按cache line大小(arm64为64byte)交叉编址,各个通道间访问互不冲突。这意味着当不同线程同时向不同通道发出相同的读/写请求,不会相互影响。这时多线程复制的优势便体现了出来。故而理论上,通道数越多,对多线程复制越有利。
上述情况还是基于UMA架构的CPU,现在某些CPU还使用NUMA机制(Non-Uniform Memory Access,非一致性内存访问),该机制下,CPU上的核心会固定分成不同的节点(node),每个节点配备一个单独的内存控制器。当CPU访问非所属节点内存控制器对应通道的物理内存时,则需要通过节点间通信其地址对应的节点去获得数据。
而从复制数据的角度来看,如果复制任务的内存地址在不同的节点对应的内存地址空间上,意味着可以使用多个线程在不同节点的CPU上运行,并只复制当前阶段对应通道的内存地址空间的数据,从而完美规避线程之间对内存控制器和总线的访问冲突。
再来看缓存结构的影响,现代CPU上每个核心有独立的L1 cache,多个核心会组成一个处理器簇(cluster),每个簇所有核心共享L2 cache,所有簇共享L3 cache。因此,如果不同线程运行在不同处理器上,则可只对自己的L1 cache访问而互不影响(原子同步除外),若还在不同簇上,则可对簇所属L2 cache独立访问而互不影响(原子同步除外)。在这种情况下,如果每个线程复制连续且互不重叠的地址空间,配合缓存预取技术,就可以只对相对独立的缓存进行读写而互不影响,从而提升复制效率。
然而,即便可以利用多线程复制实现更快的复制速度,也不意味着CPU使用率会降低。
因为总复制任务量并未减少,而使用多线程,意味着线程创建销毁、调度等本身就会产生额外消耗,此外正如我们前面所说,多线程复制可能产生访问冲突而提高访存延迟,访存延迟造成的CPU stall是会实际反映在CPU运行时间上的,而CPU运行时间就是计算CPU使用率的关键因素。因此,除非是要求尽可能地加快拷贝速度、降低拷贝实践,否则一般是不建议使用多线程复制。
9. 开挂
(小开不算开)
如果你问我什么方案能在几乎不消耗CPU的情况下完整拷贝,那我可以告诉你……还真有。
那就是使用DMA或类似的可以读写内存的异构处理器,CPU只需要向它们发出命令,剩下的交给这些处理器就行啦。因为这些处理单元不在CPU内部,所以可以从某种程度上视为外挂单元,使用外挂单元的功能,简称开挂,合理!
但是要注意的是,DMA访问内存依旧要通过内存控制器,因此如果CPU和DMA或者其它硬件(例如GPU、DSP)同时进行内存访问,会造成相互抢夺总线带宽的问题,反而还会升高CPU的ld/st指令执行时间(如果要访问内存)。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)