您现在的位置是:首页 >技术交流 >ARM嵌入式编译之循环操作(LOOP)优化网站首页技术交流

ARM嵌入式编译之循环操作(LOOP)优化

SOC罗三炮 2024-06-17 11:26:28
简介ARM嵌入式编译之循环操作(LOOP)优化

取决于循环的迭代次数,完成循环可能需要花费大量时间,此外,每次迭代时,需要判断循环条件是否成立,这一操作也会降低循环的性能。

目录

1 循环展开-Loop unrolling

 2 循环向量化

 3 C语言中的循环终止

4 无限循环


1 循环展开-Loop unrolling

为了减少每次循环都需要判断迭代条件带来的性能影响,用户可以将循环展开,以减少判断循环条件的次数。使用 #pragma unroll (<n>)  来展开用户代码中对时间、性能比较敏感的循环。然而,将循环展开也有一个缺点:增加了代码量。下表中的操作仅对 -O2-O3-Ofast, 以及-Omax优化时有效果:

Loop unrolling pragmas
PragmaDescription
#pragma unroll (<n>)展开循环的 n 次迭代
#pragma unroll_completely展开循环中的所有迭代

具体的使用方法见:
#pragma unroll[(n)], #pragma unroll_completelyhttps://developer.arm.com/documentation/101754/0620/armclang-Reference/Compiler-specific-Pragmas/-pragma-unroll--n-----pragma-unroll-completely 需要注意的是:手动在源代码中将循环展开和使用 #pragma unroll (<n>) 的效果是不一样的,手动在源代码中展开循环可能会阻碍编译器对循环的优化操作,ARM建议用户使用 #pragma unroll (<n>) 。如果没有指定 展开的迭代次数 n, 将默认展开循环中的所有迭代。此外,如果编译器无法计算出迭代的次数,使用#pragma unroll_completely,在编译时将不会进行循环展开。

比如有以下示例代码:

int countSetBits1(unsigned int n)
{
    int bits = 0;

    while (n != 0)
    {
        if (n & 1) bits++;
        n >>= 1;
    }
    return bits;
}

使用如下命令进行编译:

armclang --target=arm-arm-none-eabi -march=armv8-a file.c -O2 -S -o file.s

 默认情况下,将得到如下汇编代码:

countSetBits1:
        mov     r1, r0
        mov     r0, #0
        cmp     r1, #0
        bxeq    lr
        mov     r2, #0
        mov     r0, #0
.LBB0_1:
        and     r3, r1, #1
        cmp     r2, r1, asr #1
        add     r0, r0, r3
        lsr     r3, r1, #1
        mov     r1, r3
        bne     .LBB0_1
        bx      lr

如果使用循环展开四次:    #pragma unroll (4)

int countSetBits2(unsigned int n)
{
    int bits = 0;
    #pragma unroll (4)
    while (n != 0)
    {
        if (n & 1) bits++;
        n >>= 1;
    }
    return bits;
}

则生成的汇编代码如下:
 

countSetBits2:
        mov     r1, r0
        mov     r0, #0
        cmp     r1, #0
        bxeq    lr
        mov     r2, #0
        mov     r0, #0
LBB0_1:
        and     r3, r1, #1
        cmp     r2, r1, asr #1
        add     r0, r0, r3
        beq     .LBB0_4
@ BB#2:
        asr     r3, r1, #1
        cmp     r2, r1, asr #2
        and     r3, r3, #1
        add     r0, r0, r3
        asrne   r3, r1, #2
        andne   r3, r3, #1
        addne   r0, r0, r3
        cmpne   r2, r1, asr #3
        beq     .LBB0_4
@ BB#3:
        asr     r3, r1, #3
        cmp     r2, r1, asr #4
        and     r3, r3, #1
        add     r0, r0, r3
        asr     r3, r1, #4
        mov     r1, r3
        bne     .LBB0_1
.LBB0_4:
        bx      lr

如果在编译时就能确定迭代次数,则ARM嵌入式编译器就可以完全将循环展开。

 2 循环向量化

如果用户代码的使用对象包含Advanced SIMD单元,则ARM嵌入式编译器可以使用向量化引擎来优化代码中可以被向量化的部分。在优化等级 -O1上,可以使用 -fvectorize 选项来使能向量化。在比-O1更高的优化等级中,-fvectorize是默认打开的,用户可以使用  -fno-vectorize 选项将其关闭。详情见:-fvectorize, -fno-vectorize 。

示例代码,使用 Advanced SIMD进行向量化:

typedef struct tBuffer {
  int a;
  int b;
  int c;
} tBuffer;
tBuffer buffer[8];

void DoubleBuffer1 (void)
{
  int i;
  for (i=0; i<8; i++)
  {
    buffer[i].a *= 2;
    buffer[i].b *= 2;
    buffer[i].c *= 2;
  }
}

使用 -O2的优化等级编译:

armclang --target=arm-arm-none-eabi -march=armv8-a -O2 file.c -S -o file.s

将得到如下代码:
 

DoubleBuffer1:
.fnstart
@ BB#0:
        movw    r0, :lower16:buffer
        movt    r0, :upper16:buffer
        vld1.64 {d16, d17}, [r0:128]
        mov     r1, r0
        vshl.i32        q8, q8, #1
        vst1.32 {d16, d17}, [r1:128]!
        vld1.64 {d16, d17}, [r1:128]
        vshl.i32        q8, q8, #1
        vst1.64 {d16, d17}, [r1:128]
        add     r1, r0, #32
        vld1.64 {d16, d17}, [r1:128]
        vshl.i32        q8, q8, #1
        vst1.64 {d16, d17}, [r1:128]
        add     r1, r0, #48
        vld1.64 {d16, d17}, [r1:128]
        vshl.i32        q8, q8, #1
        vst1.64 {d16, d17}, [r1:128]
        add     r1, r0, #64
        add     r0, r0, #80
        vld1.64 {d16, d17}, [r1:128]
        vshl.i32        q8, q8, #1
        vst1.64 {d16, d17}, [r1:128]
        vld1.64 {d16, d17}, [r0:128]
        vshl.i32        q8, q8, #1
        vst1.64 {d16, d17}, [r0:128]
        bxlr

如果不使用SIMD:

typedef struct tBuffer {
  int a;
  int b;
  int c;
} tBuffer;
tBuffer buffer[8];

void DoubleBuffer2 (void)
{
  int i;
  for (i=0; i<8; i++)
    buffer[i].a *= 2;
  for (i=0; i<8; i++)
    buffer[i].b *= 2;
  for (i=0; i<8; i++)
    buffer[i].c *= 2;
}

将得到:

DoubleBuffer2:
.fnstart
@ BB#0:
        movw    r0, :lower16:buffer
        movt    r0, :upper16:buffer
        ldr     r1, [r0]
        lsl     r1, r1, #1
        str     r1, [r0]
        ldr     r1, [r0, #12]
        lsl     r1, r1, #1
        str     r1, [r0, #12]
        ldr     r1, [r0, #24]
        lsl     r1, r1, #1
        str     r1, [r0, #24]
        ldr     r1, [r0, #36]
        lsl     r1, r1, #1
        str     r1, [r0, #36]
        ldr     r1, [r0, #48]
        lsl     r1, r1, #1
        str     r1, [r0, #48]
        ldr     r1, [r0, #60]
        lsl     r1, r1, #1
        str     r1, [r0, #60]
        ldr     r1, [r0, #72]
        lsl     r1, r1, #1
        str     r1, [r0, #72]
        ldr     r1, [r0, #84]
        lsl     r1, r1, #1
        str     r1, [r0, #84]
        ldr     r1, [r0, #4]
        lsl     r1, r1, #1
        str     r1, [r0, #4]
        ldr     r1, [r0, #16]
        lsl     r1, r1, #1
        ...
        bx      lr

Advanced SIMD (Single Instruction Multiple Data),单指令多数据,也就是Neon技术,在ARMv7-A系列以及其以后的架构中被使用,它可以让用户写出更加高性能的优化代码,关于Neon的使用,用户可以直接使用C/C++函数接口来调用,关于Neon的使用技巧可以参考文章:
Arm C Language Extensions ACLE Q1 2019

 Cortex-A Series Programmer's Guide

Arm Neon Programmer's Guide.

 使用 -fno-vectorize 选项并不能完全阻止编译器忽略SIMD指令。如果链接库包含了Neon相关指令,编译器或者链接器仍或使用SIMD。

为了防止编译器为AArch64目标发出高级SIMD指令,使用-march或-mcpu指定+nosimd:

armclang --target=aarch64-arm-none-eabi -march=armv8-a+nosimd -O2 file.c -S -o file.s

为了防止编译器为AArch32目标发出Advanced SIMD指令,请将选项-mfpu设置为不包含Advanced SIMD的正确值。例如,-mfpu=fp-armv8。

armclang --target=aarch32-arm-none-eabi -march=armv8-a -mfpu=fp-armv8 -O2 file.c -S -o file.s

 3 C语言中的循环终止

如果写代码的时候没有注意,循环终止条件可能会导致很大的性能开销。比如有以下情况:

  • 使用简单的循环终止条件
  • 编写递减到0的循环,并检查是否等于0
  • 使用无符号整数类型:unsigned int 的计数器

比如有两个求阶乘 n!的函数:
 

int fact1(int n)
{
    int i, fact = 1;
    for (i = 1; i <= n; i++)
        fact *= i;
    return (fact);
}

int fact2(int n)
{
    unsigned int i, fact = 1;
    for (i = n; i != 0; i--)
        fact *= i;
    return (fact);
}

fact1使用的是递加,fact2使用的是递减。

使用如果命令进行编译:

armclang -Os -S --target=arm-arm-none-eabi -march=armv8-a

 最后得到的汇编为:

递增阶乘函数fact1:

; r1 -> n
; r0 -> fact
; r2 -> i


fact1:
        mov     r1, r0
        mov     r0, #1
        cmp     r1, #1
        bxlt    lr
        mov     r2, #0
.LBB0_1:
        add     r2, r2, #1
        mul     r0, r0, r2
        cmp     r1, r2
        bne     .LBB0_1
        bx      lr

递减阶乘函数fact2:

; r1 -> i
; r0 -> fact

fact2:
        mov     r1, r0
        mov     r0, #1
        cmp     r1, #0
        bxeq    lr
.LBB1_1:
        mul     r0, r0, r1
        subs    r1, r1, #1
        bne     .LBB1_1
        bx      lr

通过对比递增函数和递减函数,我们可以发现:

  1. fact1函数比fact2函数多用了一条 CMP r1,r2 指令:fact1先使用 ADD 指令进行自加1操作,然后再用 CMP 指令比较i 与 n的大小。而 fact2函数仅需一条 SUBS 指令进行自减1操作即可,因为 SUBS 操作会更新 CPSR 的 Z 标志位,所以无需使用CMP 指令,即可实现 条件跳转。
  2. fact1另外一个缺点就是比fact2多使用了一个寄存器R2,因为fact1函数的 i自加完 1 之后都需要和 n比较,所以需要额外一个寄存器来保存 n 的值。n 在这个循环的生命周期内不是必不可少的,需要维护的寄存越少,越可以简化程序对寄存器的分配。

综上所述,使用递减的循环更加简单高效。

如果原始终止条件涉及函数调用,则循环的每次迭代都可能调用该函数,即使它返回的值保持不变。在这种情况下,使用递减循环也更加高效。例如:

for (...; i < get_limit(); ...);

将循环计数器(i)初始化为所需的迭代次数(n),然后递减到0的方法也适用于while和do语句。

4 无限循环

 armclang认为没有副作用的无限循环是未定义的行为,正如C11和c++ 11标准中所述。在某些情况下,armclang将会删除或移动没有副作用的无限循环,这有可能导致程序终止,或者不按预期的方式运行。

为了确保循环可以执行无限长的时间,Arm建议编写包含__asm volatile 语句的无限循环。volatile关键字告诉编译器考虑循环有潜在的副作用,从而防止通过优化来删除循环。在这样的循环中,尝试将处理器置于低功耗状态也是一种很好的做法,直到发生事件或中断。下面的示例显示了一个指定为volatile的无限循环,其中包括一条指令WFE,该指令将处理器置于低功耗状态,直到事件发生:

void infinite_loop(void) {
while (1)
  __asm volatile("wfe");
}

volatile关键字告诉armclang不要删除或移动循环。编译器认为循环有副作用,因此在优化期间不会删除它。等待事件(Wait for Event)汇编指令给处理器一个提示。以这种方式编写循环允许实现WFE指令的处理器进入低功耗状态,直到事件或中断发生,因此循环不会不必要地消耗功率。还可以使用WFI(等待中断)来输出包含WFI指令的代码,该指令允许实现WFI(等待中断)的处理器执行。

参考文章:

Optimizing loopshttps://developer.arm.com/documentation/100748/0620/Writing-Optimized-Code/Optimizing-loops?lang=en

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。