Qua*_*lot 0 assembly memcpy micro-optimization arm64
如何编写一个函数,将给定数量的字节从给定的源复制到 AARCH64 汇编语言的给定目的地?这基本上就像 memcpy,但有一些额外的假设。
到目前为止,我找到的最好的资源是memcpy 的源代码,但我不明白哪些部分与我给出的特定参数相关。
编辑:到目前为止,我已经尝试将 C 中的以下函数转换为程序集。
void memcpy80(unsigned char* dest, unsigned char* sourc, unsigned long count)
{
unsigned char* end = sourc + count;
while (sourc < end)
*(dest++) = *(sourc++)
}
Run Code Online (Sandbox Code Playgroud)
然后我尝试将其归结为符合规范的版本,如下所示:
memcpy80:
add x3, x1, x2
loopstart:
cmp x3, x1
//how do I branch to loopend if x1 > x3?
add x0, x0, 1
add x1, x1, 1
ldr x4, [x1]
str x4, [x0]
b loopstart
loopend:
ret
Run Code Online (Sandbox Code Playgroud)
用于分支到循环末尾的指令是什么?然后我还需要更改什么才能符合规范?
这将在 64 位 ARMv8-a 架构上进行优化。规范中没有说更小或更大的尺寸更常见。代码大小小于 80B 没有任何好处,因此为了提高效率而直接达到极限将是理想的。
你肯定不希望每次只复制1个字节。这是荒谬的。您知道您的问题大小是 16 字节的倍数,因此您至少应该使用 64-bit long,如果不是 128 位类型。
您的 asm 实现使用 8 字节加载/存储(良好),但只将指针增加 1,因此您将相同的数据重新复制 8 次,并且重叠和未对齐。
如果您不知道(还)如何在 asm 中有效地构建循环,条件分支在底部,您可能应该用 C 编写并调整 C + 编译器选项,直到得到您想要的。这不是学习 asm 的坏方法;您可以看到编译器生成的代码如何实现您的简单循环设置 + 循环本身。
这将在 64 位 ARMv8-a 架构上进行优化。
该架构有多种不同的实现,每种都有不同的微架构和不同的性能特征。优化有序内核与无序内核大不相同。在有序 CPU 上,您必须自己隐藏所有延迟才能同时进行多个操作。如果存储的数据未准备好,则执行会停止。但是,乱序 CPU 允许硬件保持循环的多次迭代在运行中,从下一次迭代开始加载,而这次迭代的存储仍在等待数据。(内存和缓存是流水线的,所以这很重要。)
例如,Cortex-A53 具有双重问题、有序流水线,而 Cortex-A57 具有 3 路超标量、深度无序流水线。(维基百科)。
我在Godbolt 编译器资源管理器上使用 AArch64 gcc 5.4 和 6.3对此进行了一些尝试。
void memcpy80_simple_optimizations(unsigned long *restrict dst, unsigned long* restrict src, unsigned long len){
dst = __builtin_assume_aligned(dst, 16);
src = __builtin_assume_aligned(src, 16);
unsigned long* end = src + len; // len is in 8byte units here, but implement however you want
while (src < end) {
unsigned long tmp1 = *src; // do both loads ahead of both stores for better pipelining on in-order chips
unsigned long tmp2 = src[1]; // gcc seems to do a bad job
dst[0] = tmp1; // unroll by 2 because we know len is a multiple of 16 bytes
dst[1] = tmp2;
src+=2;
dst+=2;
}
}
Run Code Online (Sandbox Code Playgroud)
这与gcc6.3 -O3 -fno-builtin -fno-tree-vectorize(因此 gcc 无法识别该模式并将其编译为对 的调用memcpy!)
add x2, x1, x2, lsl 3 # leave out the lsl 3 for count in bytes
cmp x1, x2
bcs .L8 # skip first iteration for small size
.L12:
ldr x3, [x1, 8] # x3 = memory[x1+8]
add x0, x0, 16
ldr x4, [x1], 16 # x4 = memory[x1], x1+=16 (post-increment)
cmp x2, x1
stp x4, x3, [x0, -16] # store pair
bhi .L12
.L8:
ret
Run Code Online (Sandbox Code Playgroud)
这可能是循环结构的一个很好的起点;只有 10 条指令(因此有 40 个字节)。它也可能更有效,使用ldp加载 2 个寄存器可能是最好的,就像它如何使用stp一条指令同时存储 x4 和 x3。如果启动代码根据count & 16(是复制奇数还是偶数的 16 字节块)确定跳入循环的位置,则可能有 80 字节的空间可以展开 32 。但这可能会干扰在两个存储之前执行两个加载。
您可能还可以通过更巧妙的寻址模式选择来减少循环开销。
我认为最好先加载然后再存储。随着*dst++ = *src++两次,我是越来越加载/存储,加载/存储。我不知道 gcc 是否忽略了restrict指针上的限定符(这表明它们不重叠),或者在 AArch64 上交替加载和存储而不是执行多次加载然后多次存储是否更好。有一些关于 LLVM 补丁提案的讨论,关于编译器应该如何内联memcpy固定大小的小型副本,并且有一些建议ldr/str / ldp/stp可能更好,但可能仅与stp存储来自 ldr 和第一个的数据的建议相比半个ldp。我不确定他们在暗示什么。
glibc 的 AArch64memcpy将这个内部循环用于大副本。 dst、src、 和count(A_l等等)是x寄存器的预处理器宏。
L(loop64):
stp A_l, A_h, [dst, 16]
ldp A_l, A_h, [src, 16]
stp B_l, B_h, [dst, 32]
ldp B_l, B_h, [src, 32]
stp C_l, C_h, [dst, 48]
ldp C_l, C_h, [src, 48]
stp D_l, D_h, [dst, 64]!
ldp D_l, D_h, [src, 64]!
subs count, count, 64
b.hi L(loop64)
Run Code Online (Sandbox Code Playgroud)
请注意,每条存储指令都存储在前一次迭代中加载的数据,因此从每次加载到每个存储的距离都是围绕循环,减去 1 条指令。这就是为什么它以存储开始,以负载结束。循环之前的代码加载 4 对x寄存器,之后的代码存储循环在寄存器中留下的内容。
您可以将此技术调整为 2 对。
但无论如何,很明显,编写/调整 glibc 实现的人都认为 AArch64 通常受益于软件流水线,这与存储刚加载的数据相反。
根据作者的邮件列表评论,这是通过在 Cortex-A53 和 A57 上测试得到支持的,报告比以前的版本有加速。