我们如何使用循环展开和代码预加载为 C++ 程序编写优化的 ARM 代码?

How can we write optimized ARM code for C++ program using loop unrolling and code pre-loading?

下面是同一程序的给定 C++ 和 ARM 代码。你能告诉我这段 ARM 代码是否经过优化以及循环需要多少次(数组 n 的大小很大,是 64 个元素的倍数和 8 位掩码的异或位操作和产生一个输出数组 outArr。)?我应该如何使用循环展开来优化代码(一次处理 4 个元素)?

C++代码:

// Gray scale image pixel inversion
void invert(unsigned char *outArr, unsigned char *inArr, 
 unsigned char k, int n)
{
for(int i=0; i<n; i++)
 *outArr++ = *inArr++ ^ k; // ^ is bitwise xor
}

手臂代码:

invert:
        cmp     r3, #0
        bxle    lr
        add     ip, r0, r3
.L3:
        ldrb    r3, [r1], #1    @ zero_extendqisi2
        eor     r3, r3, r2
        strb    r3, [r0], #1
        cmp     ip, r0
        bne     .L3
        bx      lr

我不知道 'code preload' 是什么意思。使用 pld 指令进行数据预加载。这在示例代码的上下文中是有意义的。


这是基本的 'C' 版本,给出了假设,

The size of the array n is large, and is a multiple of 64 elements and exclusive-OR bit-wise operation with the 8-bit mask and produces an output array outArr.

代码可能并不完美,但旨在说明。

// Gray scale image pixel inversion
void invert(unsigned char *outArr, unsigned char *inArr, 
 unsigned char k, int n)
{
  unsigned int *out = (void*)outArr;
  unsigned int *in  = (void*)inArr;
  unsigned int mask = k<<24|k<<16|k<<8|k;

  /* Check arguments */
  if( n % 64 != 0) return;
  if((int)outArr & 3) return;
  if((int)inArr & 3) return;
  assert(sizeof(int)==4);

  for(int i=0; i<n/sizeof(int); i+=64/(sizeof(int)) {
   /* 16 transfers per loop 64/4 */
   *out++ = *in++ ^ k; // 1
   *out++ = *in++ ^ k;
   *out++ = *in++ ^ k;
   *out++ = *in++ ^ k;
   *out++ = *in++ ^ k; // 5
   *out++ = *in++ ^ k;
   *out++ = *in++ ^ k;
   *out++ = *in++ ^ k;
   *out++ = *in++ ^ k; // 9
   *out++ = *in++ ^ k;
   *out++ = *in++ ^ k;
   *out++ = *in++ ^ k;
   *out++ = *in++ ^ k; // 13
   *out++ = *in++ ^ k;
   *out++ = *in++ ^ k;
   *out++ = *in++ ^ k;
  }
}

您可以查看 output on godbolt.

ldmstm指令可用于将连续的内存地址加载到寄存器。我们不能使用所有 16 个 ARM 寄存器,因此汇编程序中循环的核心如下所示,

  ldmia  [r1], {r4,r5,r6,r7,r8,r9,r10,r11}  # r1 is inArr
  eor    r4,r4,r2   # r2 is expanded k
  eor    r5,r5,r2
  eor    r6,r6,r2
  eor    r7,r7,r2
  eor    r8,r8,r2
  eor    r9,r9,r2
  eor    r10,r10,r2
  eor    r11,r11,r2
  stmia  [r0], {r4,r5,r6,r7,r8,r9,r10,r11}  # r0 is outArr

这将重复两次,并且可以根据存储在 R3 中的数组限制检查 R0R1。如果你想符合 EABI,你需要保存所有被调用者保存的寄存器。寄存器组r4-r11一般都可以用,但要看系统。你也可以使用 lrfp 等,如果你保存它们并且不是异常安全的话。


来自评论,

I am trying to find that how many cycles does this subroutine take per array element when it is optimized and when it isn't.

循环计数在现代 CPU 上极其困难。但是你在核心中有五个指令和一个简单的循环,

.L3:
        ldrb    r3, [r1], #1    @ zero_extendqisi2
        eor     r3, r3, r2
        strb    r3, [r0], #1
        cmp     ip, r0
        bne     .L3

要做32个字节,这是32 * 5(160)条指令。具有 32 * 2 次内存访问。

扩展的选项只是一个32字节的内存读写。这些将完成,首先提供最低值。然后只有一条 EOR 指令。所以它只有 10 条指令,而不是 160 条指令。在现代处理器上,内存将成为限制因素。由于内存停顿,一次只处理四个单词可能更好,例如,

  ldmia  [r1], {r4,r5,r6,r7}  # r1 is inArr
  eor    r4,r4,r2   # r2 is expanded k
  eor    r5,r5,r2
  eor    r6,r6,r2
  eor    r7,r7,r2
  ldmia  [r1], {r8,r9,r10,r11}  # r1 is inArr
  stmia  [r0], {r4,r5,r6,r7}    # r0 is outArr
  ...

这(或一些排列)将允许 load/store 单元和 'eor' 不互相阻塞,但这将取决于特定的 CPU 类型。这个话题叫做指令调度;它比 pld 或数据预加载更强大。同样,您可以使用 NEON 或 ARM64 指令,以便循环体可以在 load/store.

之前执行更多 eor 操作

这些天,这是这样做的:

void invert(unsigned char* const outArr, unsigned char const* const inArr, 
    unsigned char const k, std::size_t const n) noexcept
{
  std::transform(std::execution::unseq, inArr, inArr + n, outArr,
    [k](auto const i)noexcept{return i ^ k;});
}

你设置了-Ofast,祈祷会产生好的code

编辑:您也可以尝试 this:

void invert(unsigned char* const outArr, unsigned char const* const inArr, 
    unsigned char const k, std::size_t const n) noexcept
{
  std::transform(std::execution::unseq,
    reinterpret_cast<std::uint32_t const*>(inArr),
    reinterpret_cast<std::uint32_t const*>(inArr) + n/4,
    reinterpret_cast<std::uint32_t*>(outArr),
    [k=std::uint32_t(k<<24|k<<16|k<<8|k)](auto const i)noexcept{return i ^ k;});
}