使用内联汇编循环遍历数组

7
当使用内联汇编循环遍历数组时,我应该使用寄存器修饰符“r”还是内存修饰符“m”?
让我们考虑一个例子,它将两个浮点数数组x和y相加,并将结果写入z。通常我会像这样使用内置函数来完成此操作:
for(int i=0; i<n/4; i++) {
    __m128 x4 = _mm_load_ps(&x[4*i]);
    __m128 y4 = _mm_load_ps(&y[4*i]);
    __m128 s = _mm_add_ps(x4,y4);
    _mm_store_ps(&z[4*i], s);
}

这是我拟定的使用寄存器修饰符“r”来解决内联汇编问题的方案。

void add_asm1(float *x, float *y, float *z, unsigned n) {
    for(int i=0; i<n; i+=4) {
        __asm__ __volatile__ (
            "movaps   (%1,%%rax,4), %%xmm0\n"
            "addps    (%2,%%rax,4), %%xmm0\n"
            "movaps   %%xmm0, (%0,%%rax,4)\n"
            :
            : "r" (z), "r" (y), "r" (x), "a" (i)
            :
        );
    }
}

这将生成类似于GCC的汇编代码。主要区别在于,GCC将索引寄存器加上16,并使用比例为1,而内联汇编解决方案将索引寄存器加上4,并使用比例为4。
我无法为迭代器使用通用寄存器。我不得不指定一个寄存器,这种情况下是rax。这是有原因的吗?
这是我使用“m”内存修饰符想出的解决方案。
void add_asm2(float *x, float *y, float *z, unsigned n) {
    for(int i=0; i<n; i+=4) {
        __asm__ __volatile__ (
            "movaps   %1, %%xmm0\n"
            "addps    %2, %%xmm0\n"
            "movaps   %%xmm0, %0\n"
            : "=m" (z[i])
            : "m" (y[i]), "m" (x[i])
            :
            );
    }
}

这种方法效率较低,因为它没有使用索引寄存器,而是必须将16添加到每个数组的基址寄存器。生成的汇编代码是(使用gcc(Ubuntu 5.2.1-22ubuntu2),命令为gcc -O3 -S asmtest.c):

.L22
    movaps   (%rsi), %xmm0
    addps    (%rdi), %xmm0
    movaps   %xmm0, (%rdx)
    addl    $4, %eax
    addq    $16, %rdx
    addq    $16, %rsi
    addq    $16, %rdi
    cmpl    %eax, %ecx
    ja      .L22
使用内存修饰符“m”是否有更好的解决方案?是否有办法让它使用索引寄存器?我之所以问这个问题,是因为对我来说,使用内存修饰符“m”似乎更合理,因为我正在读写内存。此外,使用寄存器修饰符“r”,我从未使用过输出操作数列表,这一点起初对我来说很奇怪。

也许使用“r”或“m”以外的更好的解决方案?

这是我用来测试的完整代码:

#include <stdio.h>
#include <x86intrin.h>

#define N 64

void add_intrin(float *x, float *y, float *z, unsigned n) {
    for(int i=0; i<n; i+=4) {
        __m128 x4 = _mm_load_ps(&x[i]);
        __m128 y4 = _mm_load_ps(&y[i]);
        __m128 s = _mm_add_ps(x4,y4);
        _mm_store_ps(&z[i], s);
    }
}

void add_intrin2(float *x, float *y, float *z, unsigned n) {
    for(int i=0; i<n/4; i++) {
        __m128 x4 = _mm_load_ps(&x[4*i]);
        __m128 y4 = _mm_load_ps(&y[4*i]);
        __m128 s = _mm_add_ps(x4,y4);
        _mm_store_ps(&z[4*i], s);
    }
}

void add_asm1(float *x, float *y, float *z, unsigned n) {
    for(int i=0; i<n; i+=4) {
        __asm__ __volatile__ (
            "movaps   (%1,%%rax,4), %%xmm0\n"
            "addps    (%2,%%rax,4), %%xmm0\n"
            "movaps   %%xmm0, (%0,%%rax,4)\n"
            :
            : "r" (z), "r" (y), "r" (x), "a" (i)
            :
        );
    }
}

void add_asm2(float *x, float *y, float *z, unsigned n) {
    for(int i=0; i<n; i+=4) {
        __asm__ __volatile__ (
            "movaps   %1, %%xmm0\n"
            "addps    %2, %%xmm0\n"
            "movaps   %%xmm0, %0\n"
            : "=m" (z[i])
            : "m" (y[i]), "m" (x[i])
            :
            );
    }
}

int main(void) {
    float x[N], y[N], z1[N], z2[N], z3[N];
    for(int i=0; i<N; i++) x[i] = 1.0f, y[i] = 2.0f;
    add_intrin2(x,y,z1,N);
    add_asm1(x,y,z2,N);
    add_asm2(x,y,z3,N);
    for(int i=0; i<N; i++) printf("%.0f ", z1[i]); puts("");
    for(int i=0; i<N; i++) printf("%.0f ", z2[i]); puts("");
    for(int i=0; i<N; i++) printf("%.0f ", z3[i]); puts("");
}

2
为什么需要使用“a”而不是“r”:因为'i'是一个int,所以gcc生成eax(int的正确大小)而不是rax(计算64位偏移量时需要)。您可以将i更改为long long或使用%q3来强制使用完整寄存器。顺便说一句,由于add_asm1修改了内存,因此应该使用内存破坏。 - David Wohlferd
@DavidWohlferd,感谢您的评论。特别是关于“内存”的那一条。也许我没有表达清楚。我的意思是我想做(%1,%4,4)而不是(%1,%%rax,4),其中%4是gcc决定的任何寄存器,而不是强制它成为rax - Z boson
@DavidWohlferd,你说得对,我想要%3。我尝试了一下,它可以工作,我甚至不需要切换到long long i=0。从汇编代码来看,我发现gcc使用%eax。这是一个更好的解决方案,因为没有理由使用%rax作为索引。如果你想写一个答案,我会给你点赞。 - Z boson
1
它是“不可预测的”,因为如果没有解析汇编模板(除了替换标记外,gcc不会这样做),就无法知道您是否仅基于提供的输入和输出读取和写入内存。 - David Wohlferd
1
关于 memory clobber:如果你不能告诉编译器哪个内存被破坏了,那么就使用 memory。在这种情况下,它是可预测的,所以你可以使用Clobbers部分末尾建议的语句表达式技巧:{"m"(({ struct { char x[16]; } *p = (void *)(z+i*4) ; *p; }) )}。我修改了示例以适应您的代码:clobber 16 字节,在 &z[i*4] 处。此外,请注意,如果使用一个内存输出操作数,那么您的 asm 上不需要 __volatile__,因为它知道无法将 store 提升到 z[i] - Peter Cordes
显示剩余4条评论
3个回答

7
尽可能避免使用内联汇编:https://gcc.gnu.org/wiki/DontUseInlineAsm。它会阻止许多优化。但是如果您真的无法手动指导编译器生成所需的汇编代码,则最好将整个循环都写成汇编代码,以便可以手动展开和调整,而不是像这样做。
您可以使用索引的 r 约束。使用 q 修饰符获取64位寄存器的名称,以便在寻址模式中使用。当编译为32位目标时,q 修饰符选择32位寄存器的名称,因此相同的代码仍然有效。
如果您想选择使用哪种寻址模式,则需要自己使用具有 r 约束的指针操作数进行操作。
GNU C 内联汇编语法不会假设您读取或写入指针操作数指向的内存。(例如,您可能在指针值上使用内联汇编的 and)。因此,您需要使用 "memory" 占位符或内存输入/输出操作数来告知编译器您修改了哪些内存。使用 "memory" 占位符很容易,但会强制将除局部变量以外的所有内容溢出/重新加载。请参见文档中的 占位符章节,其中包含使用虚拟输入操作数的示例。
具体来说,"m" (*(const float (*)[]) fptr) 将告诉编译器整个数组对象是一个任意长度的输入。即汇编代码不能与使用 fptr 作为地址一部分(或使用它所指向的数组)的存储器重排序。也适用于带有 "=m""+m" 约束(显然没有 const)。
使用像 "m" (*(const float (*)[4]) fptr) 这样的特定大小可以让您告知编译器您读取/不读取哪些内容。(或写入)。然后,如果允许,它可以(如果允许)将存储器移动到 asm 语句后面的较晚元素,并将其与另一个存储器结合起来(或执行未使用内联汇编的任何存储器的死存消除)。
(请参见如何指示内联 ASM 参数所指向的存储器可能被使用? 的整个问答)。
另一个使用 m 约束的巨大好处是可以通过生成带有常量偏移量的地址来使 -funroll-loops 正常工作。自己处理寻址可以防止编译器每 4 次迭代进行单次增量操作,因为每个源级别的 i 值都需要出现在一个寄存器中。
这是我的版本,其中包含一些调整(如注释所示)。这不是最佳方案,例如编译器无法有效地展开它。
#include <immintrin.h>
void add_asm1_memclobber(float *x, float *y, float *z, unsigned n) {
    __m128 vectmp;  // let the compiler choose a scratch register
    for(int i=0; i<n; i+=4) {
        __asm__ __volatile__ (
            "movaps   (%[y],%q[idx],4), %[vectmp]\n\t"  // q modifier: 64bit version of a GP reg
            "addps    (%[x],%q[idx],4), %[vectmp]\n\t"
            "movaps   %[vectmp], (%[z],%q[idx],4)\n\t"
            : [vectmp] "=x" (vectmp)  // "=m" (z[i])  // gives worse code if the compiler prepares a reg we don't use
            : [z] "r" (z), [y] "r" (y), [x] "r" (x),
              [idx] "r" (i) // unrolling is impossible this way (without an insn for every increment by 4)
            : "memory"
          // you can avoid a "memory" clobber with dummy input/output operands
        );
    }
}

您的版本需要将%xmm0声明为破坏性变量,否则当此内容内联时,您将遭受不良影响。 我的版本使用一个临时变量作为仅输出的操作数,从未被使用。这使编译器完全自由地进行寄存器分配。

如果要避免“memory”破坏,请使用虚拟内存输入/输出操作数,如"m" (*(const __m128*)&x [i]) ,告诉编译器您的函数读取和写入的是哪些内存。 如果您在运行该循环之前执行了类似x [4] = 1.0;的操作,这是必要的以确保正确的代码生成。 (即使您没有写入这么简单的内容,内联和常量传播也会将其简化。)同时还要确保编译器在循环运行之前不会从z []中读取。
在这种情况下,我们得到了可怕的结果:gcc5.x实际上会增加3个额外指针,因为它决定使用 [reg] addressing模式而不是indexed。 它不知道inline asm实际上从未使用过该约束创建的寻址模式引用那些内存操作数!
# gcc5.4 with dummy constraints like "=m" (*(__m128*)&z[i]) instead of "memory" clobber
.L11:
    movaps   (%rsi,%rax,4), %xmm0   # y, i, vectmp
    addps    (%rdi,%rax,4), %xmm0   # x, i, vectmp
    movaps   %xmm0, (%rdx,%rax,4)   # vectmp, z, i

    addl    $4, %eax        #, i
    addq    $16, %r10       #, ivtmp.19
    addq    $16, %r9        #, ivtmp.21
    addq    $16, %r8        #, ivtmp.22
    cmpl    %eax, %ecx      # i, n
    ja      .L11        #,
, 和是内联汇编块没有使用的额外指针。
您可以使用一个约束条件,告诉GCC整个任意长度的数组是输入或输出:"m" (*(const char (*)[]) pStr)。这将把指针转换为指向数组的指针(大小未指定)。请参见
如何表示内联ASM参数所指向的内存可能被使用? 如果我们想要使用索引寻址模式,我们将在寄存器中具有所有三个数组的基地址,并且这种约束形式要求以操作数的形式提供整个数组的基地址(而不是当前操作的内存的指针)。
事实上,这种方法可以在循环内部不使用任何额外的指针或计数器增量来工作(避免了“memory”破坏,但仍然不容易由编译器展开)。
void add_asm1_dummy_whole_array(const float *restrict x, const float *restrict y,
                             float *restrict z, unsigned n) {
    __m128 vectmp;  // let the compiler choose a scratch register
    for(int i=0; i<n; i+=4) {
        __asm__ __volatile__ (
            "movaps   (%[y],%q[idx],4), %[vectmp]\n\t"  // q modifier: 64bit version of a GP reg
            "addps    (%[x],%q[idx],4), %[vectmp]\n\t"
            "movaps   %[vectmp], (%[z],%q[idx],4)\n\t"
            : [vectmp] "=x" (vectmp)
             , "=m" (*(float (*)[]) z)  // "=m" (z[i])  // gives worse code if the compiler prepares a reg we don't use
            : [z] "r" (z), [y] "r" (y), [x] "r" (x),
              [idx] "r" (i) // unrolling is impossible this way (without an insn for every increment by 4)
              , "m" (*(const float (*)[]) x),
                "m" (*(const float (*)[]) y)  // pointer to unsized array = all memory from this pointer
        );
    }
}

这样做可以得到与使用"memory"限制符相同的内部循环:
.L19:   # with clobbers like "m" (*(const struct {float a; float x[];} *) y)
    movaps   (%rsi,%rax,4), %xmm0   # y, i, vectmp
    addps    (%rdi,%rax,4), %xmm0   # x, i, vectmp
    movaps   %xmm0, (%rdx,%rax,4)   # vectmp, z, i

    addl    $4, %eax        #, i
    cmpl    %eax, %ecx      # i, n
    ja      .L19        #,

它告诉编译器每个asm块都读取或写入整个数组,因此可能会阻止它与其他代码交错(例如在低迭代计数完全展开后)。它不会停止展开,但要求每个索引值都在寄存器中使其效果不佳。在同一循环中第二个副本的16(%rsi,%rax,4)寻址模式无法出现,因为我们将寻址隐藏在编译器之外。


一个带有m约束条件的版本,

#include <immintrin.h>
void add_asm1(float *x, float *y, float *z, unsigned n) {
    // x, y, z are assumed to be aligned
    __m128 vectmp;  // let the compiler choose a scratch register
    for(int i=0; i<n; i+=4) {
        __asm__ __volatile__ (
           // "movaps   %[yi], %[vectmp]\n\t"   // get the compiler to do this load instead
            "addps    %[xi], %[vectmp]\n\t"
            "movaps   %[vectmp], %[zi]\n\t"
          // __m128 is a may_alias type so these casts are safe.
            : [vectmp] "=x" (vectmp)         // let compiler pick a stratch reg
              ,[zi] "=m" (*(__m128*)&z[i])   // actual memory output for the movaps store
            : [yi] "0"  (*(__m128*)&y[i])  // or [yi] "xm" (*(__m128*)&y[i]), and uncomment the movaps load
             ,[xi] "xm" (*(__m128*)&x[i])
              //, [idx] "r" (i) // unrolling with this would need an insn for every increment by 4
        );
    }
}

使用[yi]作为+x的输入/输出操作数会更简单,但是以这种方式编写代码可以更小地更改行内汇编的注释,而不是让编译器为我们将一个值放入寄存器中。


这就是我所寻找的答案。谢谢。 - Z boson
1
@Zboson: 检查更新。我最近写了一些内联汇编的答案(https://dev59.com/VpLea4cB1Zd3GeqP1mNy#34450163和http://stackoverflow.com/q/34446928/224132),使用C临时作为仅输出操作数是一个让编译器控制暂存寄存器分配的好方法。更重要的是,m约束条件允许展开循环。我认为这是选择自己的寻址模式的另一个主要缺点。 - Peter Cordes
@Zboson:很高兴能帮到你。你的问题有点跑题,所以直到你向Jester抱怨并我重新阅读你问题的开头,我才真正意识到哪个部分没有得到回答。如果你将其编辑为更清晰地陈述你的目标,并展示一个导致代码中有更多uops的内在尝试,那么这将是一个更好的问题。我猜这是一个两部分的问题,因为你没有找到“q”修饰符来让你使用32位循环计数器操作数作为索引寄存器。 - Peter Cordes
我没有意识到我的问题含糊不清。我不应该包括内置函数,而只应该使用内联汇编。我的问题只涉及内联汇编。我的问题的第一句话是粗体字:“当使用内联汇编循环遍历数组时,我应该使用寄存器修饰符“r”还是内存修饰符“m”?”内置函数大多用于与汇编进行单元测试。 - Z boson
1
@Noah:谢谢,看起来这些是我在修改第一个版本以创建单独的示例时错过的剩余行。我一直想编辑这个答案中的一些东西。现在可以删除大块了,因为我更新了示例代码块的语法,使用 "m" (*(const float (*)[]) x) 而不是带有任意大小数组成员的结构体指针,并且还有另一个关于如何告诉编译器指向的内存的问答,我可以链接。但那是另一天的项目。 - Peter Cordes
显示剩余3条评论

2
当我使用gcc(4.9.2)编译您的add_asm2代码时,我得到以下结果:
add_asm2:
.LFB0:
        .cfi_startproc
        xorl        %eax, %eax
        xorl        %r8d, %r8d
        testl       %ecx, %ecx
        je  .L1
        .p2align 4,,10
        .p2align 3
.L5:
#APP
# 3 "add_asm2.c" 1
        movaps   (%rsi,%rax), %xmm0
addps    (%rdi,%rax), %xmm0
movaps   %xmm0, (%rdx,%rax)

# 0 "" 2
#NO_APP
        addl        $4, %r8d
        addq        $16, %rax
        cmpl        %r8d, %ecx
        ja  .L5
.L1:
        rep; ret
        .cfi_endproc

所以它并不完美(它使用了冗余的寄存器),但确实使用了索引加载...


有趣的是,gcc (Ubuntu 5.2.1-22ubuntu2) 没有这样做(如果您想看到它,我已经在我的问题中添加了汇编输出)。您的结果与我的 add_intrin 函数相同。这就是为什么我使用 add_intrin2 的原因。它不使用冗余寄存器。 - Z boson
1
@Zboson:我猜他们在评估使用各种寻址模式的“成本”的函数中做了一些改变。这不是我第一次看到gcc5自己计算地址而不是使用reg + reg * scale寻址模式。请注意,gcc 5.3对于内置函数也不使用缩放寻址模式,甚至为add_intrin保留了两个单独的循环计数器。我不知道为什么它不对汇编内存操作数执行相同的操作并使用2-reg寻址模式。也许它认为不能将相同的寄存器用于多个操作数? - Peter Cordes
请记住,gcc在处理代码时大部分工作都是在中间表示形式上进行的,与目标架构无关。我试图在谷歌上搜索有关gcc避免比例寻址模式的信息,但只找到了这封几年前讨论此问题的邮件:https://patchwork.ozlabs.org/patch/278187/ - Peter Cordes
@PeterCordes,我在godbolt上检查了GCC 5.3和GCC 4.9.2的add_asm2,而4.9.2给出了这个答案的结果,这再次证实了这一点。这让我相信使用内存修饰符“m”不是正确的解决方案。不同版本的GCC产生如此不同的结果令人不安。 - Z boson
@Zboson:它将寻址模式的决策留给编译器。也许在内联之后,编译器可以使用更简单的寻址模式(例如,在静态数组上运行时使用[disp32 + index])。也许调用者执行了add_asm1(buf1+64, buf2+128, outbuf),因此gcc可以使用[disp8 + buf1 + index]寻址模式,而不是使用额外的指令将buf1+64放入寄存器中。 - Peter Cordes
显示剩余4条评论

2

gcc 还具有内置向量扩展,甚至跨平台使用:

typedef float v4sf __attribute__((vector_size(16)));
void add_vector(float *x, float *y, float *z, unsigned n) {
    for(int i=0; i<n/4; i+=1) {
        *(v4sf*)(z + 4*i) = *(v4sf*)(x + 4*i) + *(v4sf*)(y + 4*i);
    }
}

在我的gcc版本4.7.2上生成的汇编代码如下:
.L28:
        movaps  (%rdi,%rax), %xmm0
        addps   (%rsi,%rax), %xmm0
        movaps  %xmm0, (%rdx,%rax)
        addq    $16, %rax
        cmpq    %rcx, %rax
        jne     .L28

我知道内置的向量扩展和指令集。我的问题是关于内联汇编的。我的问题不是关于为什么不需要内联汇编。 - Z boson
好的,您在帖子中并没有提到这一点,所以我认为提及向量扩展是一个不错的主意。此外,我们不仅仅是在帮助您,还可能是为了未来可能会访问这个帖子的人,他们可能不知道这个。 - Jester
你回答了另一个问题吗?我的问题标题是“使用内联汇编循环数组”。我的问题与内联汇编有关。我用粗体标出“是否有更好的解决方案,可以使用内存修饰符‘m’?是否有一些方法可以让它使用索引寄存器?”我还写道:“也许有比使用‘r’或‘m’更好的解决方案?”我认为这很清楚地指的是使用内联汇编的其他方法。 - Z boson
1
尽管它有两个具有比较内在函数的版本,但它没有向量版本。也许它会帮助其他人。 - Jester
由于您是拥有金色汇编徽章的少数人之一,我真的很希望您能帮助我处理内联汇编。由于类似于内部函数和向量扩展等原因,内联汇编正在逐渐消失,而我由于这个原因没有太多的经验。 - Z boson
1
@Zboson:在评论中没有涵盖到的这个问题,你到底还想知道什么?我认为我们已经确定,如果您想选择代码使用的寻址模式,应该使用约束将地址传递给寄存器。然后告诉编译器哪16或32B的内存被破坏了。我不认为在x86上有需要索引寻址模式的约束。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接