使用AARCH64程序集编写memcpy

Qua*_*lot 0 assembly memcpy micro-optimization arm64

如何编写一个函数,将给定数量的字节从给定的源复制到 AARCH64 汇编语言的给定目的地?这基本上就像 memcpy,但有一些额外的假设。

  1. 源和目标在 16 字节边界上对齐
  2. 内存区域不重叠
  3. 计数是16的倍数
  4. 指令必须在 80 字节以内

到目前为止,我找到的最好的资源是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 没有任何好处,因此为了提高效率而直接达到极限将是理想的。

Pet*_*des 6

你肯定希望每次只复制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将这个内部循环用于大副本。 dstsrc、 和countA_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 上测试得到支持的,报告比以前的版本有加速。