使用AARCH64程序集编写memcpy

时间:2018-03-25 09:30:45

标签: assembly memcpy arm64 micro-optimization

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

  1. 源和目标在16字节边界上对齐
  2. 内存区域不重叠
  3. 计数是16的倍数
  4. 说明必须符合80字节
  5. 到目前为止,我发现的最佳资源是this source code for memcpy,但我不明白哪些部分与我给出的具体参数相关。

    编辑:到目前为止,我已经尝试将C中的以下功能转换为汇编。

    void memcpy80(unsigned char* dest, unsigned char* sourc, unsigned long count)
    {
    unsigned char* end = sourc + count;
    while (sourc < end)
        *(dest++) = *(sourc++)
    }
    

    然后我尝试将其归结为符合规范的版本:

    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
    

    用于分支到循环结束的指令是什么?那么我还需要改变什么才能符合规范?

    这将在64位ARMv8-a架构上进行优化。规范中没有任何内容可以说更小或更大的尺寸更常见。代码大小小于80B没有任何好处,因此为了提高效率而直接达到极限将是理想的。

1 个答案:

答案 0 :(得分:3)

你肯定希望一次只复制1个字节。这是荒谬的。您知道您的问题大小是16字节的倍数,因此您至少应该使用64位long,如果不是128位类型。

你的asm实现使用8字节加载/存储(好),但只将指针递增1,所以你重复复制相同的数据8次,重叠和错位。

如果您还不知道({3}},在底部使用条件分支,您应该用C语言编写并调整C +编译器选项,直到得到您想要的结果。这不是一个学习asm的坏方法;你可以看到编译器生成的代码如何实现你的简单循环设置+循环本身。

  

这将在64位ARMv8-a架构上进行优化。

该架构有多种不同的实现,每种实现都有不同的 micro 架构和不同的性能特征。对有序核心进行优化与无序有很大不同。在有序CPU中,您必须自己隐藏所有延迟,以便一次保持多个操作在飞行中。如果商店的数据尚未就绪,则执行会停止。但是,无序CPU允许硬件在循环中保持循环的多次迭代,从下一次迭代开始加载,而此次迭代的存储仍在等待数据。 (内存和缓存是流水线的,所以这很重要。)

例如,Cortex-A53有一个双重问题,即有序流水线,而Cortex-A57有一个3路超标量,深度无序流水线。 (how to structure a loop efficiently in asm)。

我玩了一些wikipedia,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;
    }
}

这会使用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

这可能是循环结构的一个很好的起点;只有10条指令(因而是40字节)。它也可以更高效,使用ldp加载2个寄存器可能是最好的,就像它使用stp将x4和x3存储为一条指令一样。如果启动代码根据count & 16计算跳转到循环的位置(是否复制奇数或偶数个16字节块),则可能有80个字节的空间展开32。但这可能会干扰两个商店之前的两个负载。

您可以通过更聪明的寻址模式选择来减少循环开销。

认为做两个加载然后两个商店都是更优化的。 *dst++ = *src++两次,我正在加载/存储,加载/存储。我不知道gcc是否忽略了指针上的restrict限定符(它告诉它们不重叠),或者它是否以某种方式更好来替代AArch64上的加载和存储做多个负载然后多个商店。有一些on the Godbolt compiler explorer关于编译器应如何为小型固定大小的副本内联memcpy,并且有一些建议ldr/str / ldp/stp可能更好,但可能只与其中一个相比较stp存储来自ldr和ldp的前半部分的数据。我不确定他们在暗示什么。

discussion on an LLVM patch proposal将此内循环用于大型副本。 dstsrccount(以及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)

请注意,每个存储指令都存储在前一次迭代中加载的数据,因此从每个加载到每个存储的距离一直是循环,减去1指令。这就是为什么它从商店开始,以负载结束。循环之前的代码加载4对x寄存器,然后代码存储循环留在寄存器中的代码。

您可以将此技术调整为2对。

但无论如何,很明显,编写/调整glibc实现的人认为AArch64通常受益于glibc's AArch64 memcpy,与存储 加载的数据相反。

通过对Cortex-A53和A57 software pipelining的测试进行备份,报告速度超过之前的版本。