优化NEON加速下的ARM平台 memcpy操作
0x01 前言
系统里面经常需要大量地搬运数据,一般调用的都是memcpy() C库来实现,因此本着“揪牛角尖”的精神,我们就来探究探究加速方案!毕竟很多事情被分解到底层之后就是一样的呢!
加速这个玩意,其实是跟很多因素相关的,因此我们要就环境来论加速,把当前环境考虑进去然后再设计出合理的优化策略,这才是万全之策!也即鲁棒性很强的策略。
这里我们从上而下地谈谈memcpy的优化问题,一个问题可以如此优化,那么相比其他问题也都是类似罢!这怕就是深度学习之迁移学习的由来了!这个世界真奇妙!
0x02 测试环境
我们在ARM Cortex-A8 环境下进行一系列的测试。
测试时基于源/终地址以及我们要读写的byte数都是L1(64byte)的倍数;
我们需要考虑下对齐,但是这里只测了16MB,影响不大故不考虑;
测试时间是由处理器内部的性能寄存器记录的;
所有的测试中,L1 NEON 位被激活,这意味着当我们使用Neon 加载(load)指令的时候,会使得L1数据缓存进行linefill操作;
我们把分别对应指令和数据的L1、L2缓存使能,同时MMU核分支预测同样也被使能了。
有些地方甚至还使用到了PLD预取指令:这条指令会使得L2缓存在这个数据被使用前的某个时间开始加载数据,它提前发出了内存请求,所以CPU就不需要在那傻等memory把数据给吐出来了!
0x03 测试策略
策略1: 傻搬
傻搬就是使用常规汇编指令一个字一个字地拷贝。
如下汇编代码所示, 我们每次把地址值加个4,然后不断循环直至搬运结束,这是最基本最常规的一种操作了,因此我们 把这个时间作为一个baseline.
汇编代码如下:
WordCopy
LDR r3, [r1], #4
STR r3, [r0], #4
SUBS r2, r2, #4
BGE WordCopy
策略2: 多加载指令
我们之前是只用到了LDR指令,一次搬运32bit也就是1个word,由于只用到了r0~r3寄存器,因此每次调用的时候无需进行堆栈操作;
这里我们使用LDM核STM指令(M是Multiple的简多,意味着一次操作多个),每个迭代过程中能够操作8word数据,由于额外寄存器的使用,因此我们需要有现场保护操作,也就是入栈出栈操作。
LDMCopy
PUSH {r4-r10}
LDMloop
LDMIA r1!, {r3 - r10}
STMIA r0!, {r3 - r10}
SUBS r2, r2, #32
BGE LDMloop
POP {r4-r10}
注:
1、 r0~r3一般作为函数的局部变量,传入的函数的参数按照顺序分给他们四个,超出的就要进入堆栈区了,其中r0一般还会作为函数返回值变量。
2、 r1!表示从r1这个地址处连续搬数据至r3到r10,每搬一个,r1的值就会自动加4。
策略3: NEON搬运
常规的NEON搬运,具体指令啊信息啊什么的,怎么操作啊,去看我上一篇博客吧!
NEONCopy
VLDM r1!, {d0-d7}
VSTM r0!, {d0-d7}
SUBS r2, r2, #0x40
BGE NEONCopy
这里一个d寄存器就是64bit,2个word,8个d寄存器搬运的是16个word了啊!报告老板,有人开挂!
策略4: 傻搬+预取
如题。
- PLD的意思是我从r1地址处开始预先取出256byte数据到cache里面;
- r12表示的是 我要搬运16次,每次都是4个byte,共计搬运64byte每轮;
- 然后每轮结束后,把r2中的计数器减去64byte(0x40)开始下一轮直至结束;
WordCopyPLD
PLD [r1, #0x100]
MOV r12, #16
WordCopyPLD1
LDR r3, [r1], #4
STR r3, [r0], #4
SUBS r12, r12, #1
BNE WordCopyPLD1
SUBS r2, r2, #0x40
BNE WordCopyPLD
同样的tips:这里每轮预取取多了!
策略5: 多加载+预取
这里的优势是。。。如题~
LDMCopyPLD
PUSH {r4-r10}
LDMloopPLD
PLD [r1, #0x80]
LDMIA r1!, {r3 - r10}
STMIA r0!, {r3 - r10}
LDMIA r1!, {r3 - r10}
STMIA r0!, {r3 - r10}
SUBS r2, r2, #0x40
BGE LDMloopPLD
POP {r4-r10}
策略6: NEON + PLD
- 预取192byte;
- d0~d7共计8x64bit=64byte
这里计算刚刚好,都很完美自洽!
NEONCopyPLD
PLD [r1, #0xC0]
VLDM r1!,{d0-d7}
VSTM r0!,{d0-d7}
SUBS r2,r2,#0x40
BGE NEONCopyPLD
策略7: Mixed ARM and NEON memory copy with preload
也就是说把各种指令穿插在一起组合处一个“多元体”来试验看看是不是会速度更快咯~
ARMNEONPLD
PUSH {r4-r11}
MOV r3, r0
ARMNEON
PLD [r1, #192]
PLD [r1, #256]
VLD1.64 {d0-d3}, [r1@128]!
VLD1.64 {d4-d7}, [r1@128]!
VLD1.64 {d16-d19}, [r1@128]!
LDM r1!, {r4-r11}
SUBS r2, r2, #128
VST1.64 {d0-d3}, [r3@128]!
VST1.64 {d4-d7}, [r3@128]!
VST1.64 {d16-d19}, [r3@128]!
STM r3!, {r4-r11}
BGT ARMNEON
POP {r4-r11}
测试时间结果
测试算法 | 时间花销(ms) | 加速比 |
---|---|---|
傻搬 | 104.8 | 100% |
多加载(指令) | 94.5 | 111% |
NEON搬 | 104.8 | 100%(说明等待时间占主要比例) |
傻搬+预取 | 137.5 | 76% |
多加载+预取 | 106.6 | 98% |
NEON+预取 | 70.2 | 149% |
指令大杂烩 | 93.5 | 112% |
小结:有一些奇怪的结论。
多加载指令仅仅提升了11%的性能,但是我们没有那么多指令了啊同时指令少就代表分枝预测里面的分支较少啊!原因是:
- 指令cache100%击中,因此取指令是无须等待的;
- 分枝预测在这里也不需要预测傻啊!
- 单个写(一个接一个地写),memory system也把它当成突发写了,所以说效率并没有显著提升;
NEON指令居然没有提升读写速度:
- 读写循环的执行使用的寄存器很少,因此存在寄存器数据冲突的可能性就小;
- 因为寄存器用的少,因此特别适合搬小数据块,因为我们不需要堆栈操作来恢复现场啊!!!
- Cortex-A8处理器可以配置NEON加载数据的时候加载到L2 cache;可以防止内存copy过程中把L1中的不用数据给替换掉;(????)
尽管上面bb了一大堆好处,但是实践证明效果不咋地!
(这里存疑,我在A53平台试验过,大概会块三倍的样子啊!除非时间是异或操作的时间!?)
PLD可以使得内存控制器在数据被使用前就取到;因此加上NEON如虎添翼。
- 其次,我们知道在burst传输的时候,第一次接入的延时是很大的,因此我们可以发起多次地请求到控制器(当然得控制器够先进够高级),这样子控制器就会把后面的请求合到一起,从而把后面每一次的请求的接入延时相当于去掉了,第一个access latency均摊给每个request之后也忽略不计了,这样子好高效啊!
影响内存拷贝速度的因素
因素1: 要拷贝的数据量
有些实现需要一定的准备时间,然后搬起来了就老快了。
因此搬运大数据块的时候,可以把建立准备的时间均摊,因此还好,但是搬运小块数据的时候就不划算了哟!
比如:在函数的开始stacking许多的寄存器,然后在主循环里面使用LDM跟STM指令来操作多个寄存器;
因素2: 对齐Alignment
ARM架构搬运word对齐的数据会更高效;
courser alignment granularities这玩意能支撑性能;
多加载指令在Cortex-A8 能每个周期从L1 cache加载2个寄存器的值,但是只有地址是64位对齐的时候才可以。(所以NEON虽然一次可以搬运128bit,但是你的CPU 位宽,DRAM位宽都是32bit的,因此数据最终还是被拆分成一个一个的32bit再存储的,而我们的程序确实加速了是因为异或操作那部分时间加速了啊!)
cache对齐也是有影响的啊!Cache behaviour (discussed later) can affect the performance of data accesses depending on its alignment relative to the size of a cache line. For the Cortex-A8, a level 1 cache line is 64 bytes, and a level 2 cache line is 64 bytes.
因素3: Memory特性
这里讨论的操作(见上述诸程序)其性能瓶颈在存储的接口部分。
因为我们的循环很小啊,所以指令cache的就很好了,而且并没有计算部分(注意了,我们March C-代码部分优化是有数学运算的,因此这部分比较费时),因此处理器的逻辑计算部分压力并不大,因此速度因素极大地落在存储器的速度上了!
特定种类的memory在某种特定的读写模式下会性能更优,比如SDRAM的burst传输需要一个很长的延时来完成初始化操作,但是一旦操作完成就能很快地完成后续的读写操作;(我的MARCH算法加速部分把burst传输模式考虑进去)
此外一个好的memory控制器是能够并行接受很多读写请求的,并把这个initial latency给均摊掉。
此外一些特定的代码读写顺序也可能改善性能;
因素4: cache的使用
大量数据搬运的时候,很显然会把cache里面的数据全部“换血”的;
尽管这在内存自身拷贝的时候不会有什么影响,但是它可能会减速后面的代码,最终降低整体的性能。
因素5: Code dependencies
在标准的 memcpy()函数运行时,尤其遇上慢速的memory时,处理器大部分时间都没有被使用。
因此我们可以考虑在memcopy期间运行一些其他的代码;
因为memcpy()时阻塞的,因此只有函数结束才会返回,而此时cpu时被占死了;
我们可以使用管道来实现,把memcpy()放倒后台运行,然后通过poll或者中断来随时监控内存搬运的情况
使用DMA操作,这样完全解放CPU了;并把数据块打碎这样就能一边搬运一遍操作了!效率提高了呢!(都是一些很常规的想法呢!)
cortex-A8内置的预加载引擎
数据预加载到L2 cache;
我们CPU先启动预加载指令,然后就去干别的活,直到接到电话(中断)说加载完成了,那么我就可以去对它进行操作了,操作完之后继续下一轮;
- 使用其他处理器
内嵌的其他核原理同DMA;
附录 PLD基本思想及应用考量
当我们在安卓平台需要处理图像数据的时候,其中一个基本的操作就是把大量的数据从内存搬来搬去,相对于CPU来说这个很耗时间,除了NEON加速之外,上面提到的PLD加速也是很有效的,具体多有效我们看实例。
我们比如在处理摄像头数据的时候,内存中搬数据一般是这样写的:
while (n--) {
*dest++ = *src++;
}
在我当前平台上跑了一下,1MB的空间搬完需要25ms的样子,这个就很费时了,究其根本,是因为数据不在处理器的cache中,因此CPU需要花时间来等你DRAM传过来,也就是说,是我当前的CPU带宽大于存储器的带宽了。
因此解决方案就是我们提前把数据放到cache中,由于cache带宽大于DRAM小于CPU,因此可以减少等待时间。
改进后的代码如下所示,提前预取数据,也就是在真正搬运数据之前目标数据就被放倒cache中来了,等真正搬数据时,一下子就在cache中hit了,马上走你!效率快很多!
这里有个小细节,每次只搬运了32bit也就是4byte,但是我们预取了128byte,这样子岂不是cache很快就溢出了?关于溢出CPU会怎么处理,我就没深究了,这也不是这段代码的主旨,权当留下我自己的一个思考吧!
这个酒厉害了,时间变为8ms了,提速了三倍之多!
while (n--) {
asm ("PLD [%0, #128]"::"r" (src));
*dest++ = *src++;
}
当然了,优化这种事情时做不完的,你可以随着环境的变换不断优化的:
- 比如这里,我们预取后存在溢出问题,也就是说没有物尽其用;
- 其次,循环里面就一句搬运指令,然后就是n--操作,判断操作等,这样子来看的话一个loop里面真正干活的指令占的比例很小,因此也是一种浪费,把资源浪费在一些不重要的事情上了!
算法里面又个概念,就是时间跟空间是可以互相转换的,在这里的表现就是我通过多写一些代码操作,从而将有效操作时间在每个loop中的比例提升上来。
如下所示,我每个loop里面增加3个搬运操作,这样就保证了cache不会溢出,同时每个loop中数据搬运部分所占的CPU时间比例提升了,也即“有效功率”提升了!
这样子做大概又加快了1ms的样子!
n /= 4; //assume it's multiple of 4
while (n--) {
asm ("PLD [%0, #128]"::"r" (src));
*dest++ = *src++;
*dest++ = *src++;
*dest++ = *src++;
*dest++ = *src++;
}
思路总结
这个世界是由基本的元素组成的,操作系统是由一些基本的门电路架起来的,雪花是分形的,因此我们总能在深入一个案例后,发现一些普世的道理!共勉!
我说过:“优化时可以随着环境的变化一直做的!”(名言啊!记住啊!划重点!要考的!)
这里不是有四个搬运操作嘛!编译器优化后也不知道会优化成啥样子,我们可以直接用汇编嘛!汇编里面的四个LDR/STR指令,然后是LDRM/STRM可以节省三次CPU指令操作时间及等待时间,然后就是NEON的并行加速了!哎呀呀~不得了!只会越来愈快!具体多快!你自己去实验咯!
推荐阅读
-
优化NEON加速下的ARM平台 memcpy操作
-
小红书大产品部架构 小红书产品概览--经过性能、稳定性、成本等多个维度的详细评估,小红书最终决定选择基于腾讯云星海自研硬件的SA2云服务器作为主力机型使用。结合其秒级的快速扩缩、超强兼容和平滑迁移能力,小红书在抵御上亿次用户访问、保证系统稳定运行的同时,也实现了成本的大幅降低。 星海SA2云服务器是基于腾讯云星海的首款自研服务器。腾讯云星海作为自研硬件品牌,通过创新的高兼容性架构、简洁可靠的自主设计,结合腾讯自身业务以及百万客户上云需求的特点,致力于为云计算时代提供安全、稳定、性能领先的基础架构产品和服务。如今,星海SA2云服务器也正在为越来越多的企业提供低成本、高效率、更安全的弹性计算服务。 以下是与小红书SRE总监陈敖翔的对话实录。 问:请您介绍一下小红书及其主要商业模式? 小红书是一个面向年轻人的生活方式平台,在这里,他们发现了向上、多元的真实世界。小红书日活超过 3500 万,月活跃用户超过 1 亿,日均笔记曝光量达 80 亿。小红书由社交平台和在线购物两大部分组成。与其他线上平台相比,小红书的内容基于真实的口碑分享,播种不止于线上,还为线下实体店赋能。 问:围绕业务发展,小红书的系统架构经历了怎样的变革和演进? 系统架构变化不大,影响最深的是资源开销。过去三年,资源开销大幅增加,同比增长约 10 倍。在此背景下,我们努力进行优化,包括很早就开始使用 K8S 进行资源调度。到 18 年年中,绝大多数服务已经完全实现了容器化。 问:目前小红书系统架构中的计算基础设施建设和布局是怎样的? 我们目前的建设方式可以简单描述为星型结构。腾讯云在上海的一个区是我们的计算中心,承载着我们的核心数据和在线业务。在外围,我们还有两个数据中心进行计算分流,同时承担灾备和线上业务双活的角色。 与其他新兴电子商务互联网公司类似,小红书的大部分计算能力主要用于线下数据分析、模型训练和在线推荐等平台。随着业务的发展,对算力的需求也在加速增长。
-
【Netty】「萌新入门」(七)ByteBuf 的性能优化-堆内存的分配和释放都是由 Java 虚拟机自动管理的,这意味着它们可以快速地被分配和释放,但是也会产生一些开销。 直接内存需要手动分配和释放,因为它由操作系统管理,这使得分配和释放的速度更快,但是也需要更多的系统资源。 另外,直接内存可以映射到本地文件中,这对于需要频繁读写文件的应用程序非常有用。 此外,直接内存还可以避免在使用 NIO 进行网络传输时发生数据拷贝的情况。在使用传统的 I/O 时,数据必须先从文件或网络中读取到堆内存中,然后再从堆内存中复制到直接缓冲区中,最后再通过 SocketChannel 发送到网络中。而使用直接缓冲区时,数据可以直接从文件或网络中读取到直接缓冲区中,并且可以直接从直接缓冲区中发送到网络中,避免了不必要的数据拷贝和内存分配。 通过 ByteBufAllocator.DEFAULT.directBuffer 方法来创建基于直接内存的 ByteBuf: ByteBuf directBuf = ByteBufAllocator.DEFAULT.directBuffer(16); 通过 ByteBufAllocator.DEFAULT.heapBuffer 方法来创建基于堆内存的 ByteBuf: ByteBuf heapBuf = ByteBufAllocator.DEFAULT.heapBuffer(16); 注意: 直接内存是一种特殊的内存分配方式,可以通过在堆外申请内存来避免 JVM 堆内存的限制,从而提高读写性能和降低 GC 压力。但是,直接内存的创建和销毁代价昂贵,因此需要慎重使用。 此外,由于直接内存不受 JVM 垃圾回收的管理,我们需要主动释放这部分内存,否则会造成内存泄漏。通常情况下,可以使用 ByteBuffer.clear 方法来释放直接内存中的数据,或者使用 ByteBuffer.cleaner 方法来手动释放直接内存空间。 测试代码: public static void testCreateByteBuf { ByteBuf buf = ByteBufAllocator.DEFAULT.buffer(16); System.out.println(buf.getClass); ByteBuf heapBuf = ByteBufAllocator.DEFAULT.heapBuffer(16); System.out.println(heapBuf.getClass); ByteBuf directBuf = ByteBufAllocator.DEFAULT.directBuffer(16); System.out.println(directBuf.getClass); } 运行结果: class io.netty.buffer.PooledUnsafeDirectByteBuf class io.netty.buffer.PooledUnsafeHeapByteBuf class io.netty.buffer.PooledUnsafeDirectByteBuf 池化技术 在 Netty 中,池化技术指的是通过对象池来重用已经创建的对象,从而避免了频繁地创建和销毁对象,这种技术可以提高系统的性能和可伸缩性。 通过设置 VM options,来决定池化功能是否开启: -Dio.netty.allocator.type={unpooled|pooled} 在 Netty 4.1 版本以后,非 Android 平台默认启用池化实现,Android 平台启用非池化实现; 这里我们使用非池化功能进行测试,依旧使用的是上面的测试代码 testCreateByteBuf,运行结果如下所示: class io.netty.buffer.UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeDirectByteBuf class io.netty.buffer.UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf class io.netty.buffer.UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeDirectByteBuf 可以看到,ByteBuf 类由 PooledUnsafeDirectByteBuf 变成了 UnpooledUnsafeDirectByteBuf; 在没有池化的情况下,每次使用都需要创建新的 ByteBuf 实例,这个操作会涉及到内存的分配和初始化,如果是直接内存则代价更为昂贵,而且频繁的内存分配也可能导致内存碎片问题,增加 GC 压力。 使用池化技术可以避免频繁内存分配带来的开销,并且重用池中的 ByteBuf 实例,减少了内存占用和内存碎片问题。另外,池化技术还可以采用类似 jemalloc 的内存分配算法,进一步提升分配效率。 在高并发环境下,池化技术的优点更加明显,因为内存的分配和释放都是比较耗时的操作,频繁的内存分配和释放会导致系统性能下降,甚至可能出现内存溢出的风险。使用池化技术可以将内存分配和释放的操作集中到预先分配的池中,从而有效地降低系统的内存开销和风险。 内存释放 当在 Netty 中使用 ByteBuf 来处理数据时,需要特别注意内存回收问题。 Netty 提供了不同类型的 ByteBuf 实现,包括堆内存(JVM 内存)实现 UnpooledHeapByteBuf 和堆外内存(直接内存)实现 UnpooledDirectByteBuf,以及池化技术实现的 PooledByteBuf 及其子类。 UnpooledHeapByteBuf:通过 Java 的垃圾回收机制来自动回收内存; UnpooledDirectByteBuf:由于 JVM 的垃圾回收机制无法管理这些内存,因此需要手动调用 release 方法来释放内存; PooledByteBuf:使用了池化机制,需要更复杂的规则来回收内存; 由于池化技术的特殊性质,释放 PooledByteBuf 对象所使用的内存并不是立即被回收的,而是被放入一个内存池中,待下次分配内存时再次使用。因此,释放 PooledByteBuf 对象的内存可能会延迟到后续的某个时间点。为了避免内存泄漏和占用过多内存,我们需要根据实际情况来设置池化技术的相关参数,以便及时回收内存; Netty 采用了引用计数法来控制 ByteBuf 对象的内存回收,在博文 「源码解析」ByteBuf 的引用计数机制 中将会通过解读源码的形式对 ByteBuf 的引用计数法进行深入理解; 每个 ByteBuf 对象被创建时,都会初始化为1,表示该对象的初始计数为1。 在使用 ByteBuf 对象过程中,如果当前 handler 已经使用完该对象,需要通过调用 release 方法将计数减1,当计数为0时,底层内存会被回收,该对象也就被销毁了。此时即使 ByteBuf 对象还在,其各个方法均无法正常使用。 但是,如果当前 handler 还需要继续使用该对象,可以通过调用 retain 方法将计数加1,这样即使其他 handler 已经调用了 release 方法,该对象的内存仍然不会被回收。这种机制可以有效地避免了内存泄漏和意外访问已经释放的内存的情况。 一般来说,应该尽可能地保证 retain 和 release 方法成对出现,以确保计数正确。