ARM64 NEON 인트린식으로 SIMD 벡터 연산 가속하기

ARM64 서버나 모바일 SoC에서 배열 연산이 많은 코드를 최적화할 때 “컴파일러가 알아서 벡터화해주겠지”라고 믿고 넘어가기 쉽다. 하지만 최적화 레벨이나 포인터 별칭(aliasing) 여부에 따라 auto-vectorization이 조용히 실패하고 스칼라 코드가 그대로 나오는 경우가 많다. 이 글에서는 ARM NEON intrinsic을 직접 써서 128비트 SIMD 레지스터로 배열 덧셈을 구현하고, 실제 컴파일 결과(어셈블리)로 스칼라 코드와 무엇이 달라지는지, 그리고 컴파일러 auto-vectorization이 언제 작동하고 언제 안 하는지 확인한다.

핵심 개념

Intrinsic동작
float32x4_t128비트 NEON 레지스터(Q register)에 담긴 float 4개
vld1q_f32(ptr)메모리에서 float 4개를 Q 레지스터로 로드
vaddq_f32(a, b)두 Q 레지스터를 lane별로 병렬 덧셈
vst1q_f32(ptr, v)Q 레지스터 값을 메모리에 저장

NEON은 64비트 D 레지스터와 128비트 Q 레지스터를 쓰는 ARM의 SIMD 확장이다. float32x4_t 하나에 float 4개가 들어가므로, 배열을 4개씩 묶어 처리하면 스칼라 루프 대비 이론상 4배의 처리량을 낼 수 있다.

실전: 스칼라 vs NEON 컴파일 결과 비교

같은 배열 덧셈을 스칼라 루프와 NEON intrinsic 버전으로 각각 작성하고, Ubuntu 테스트 호스트에 설치된 aarch64-linux-gnu-gcc로 컴파일해 실제 어셈블리를 비교한다.

#include <arm_neon.h>

void add_arrays_scalar(const float *a, const float *b, float *out, int n) {
    for (int i = 0; i < n; i++) {
        out[i] = a[i] + b[i];
    }
}

void add_arrays_neon(const float *a, const float *b, float *out, int n) {
    int i = 0;
    for (; i + 4 <= n; i += 4) {
        float32x4_t va = vld1q_f32(a + i);
        float32x4_t vb = vld1q_f32(b + i);
        float32x4_t vout = vaddq_f32(va, vb);
        vst1q_f32(out + i, vout);
    }
    for (; i < n; i++) {
        out[i] = a[i] + b[i];
    }
}
$ aarch64-linux-gnu-gcc -O2 -march=armv8-a -c neon_demo.c -o neon_o2.o
$ aarch64-linux-gnu-objdump -d --no-show-raw-insn neon_o2.o

0000000000000000 :
   cmp     w3, #0x0
   b.le    2c 
   sbfiz   x4, x3, #2, #32
   mov     x3, #0x0
   ldr     s0, [x0, x3]
   ldr     s1, [x1, x3]
   fadd    s0, s0, s1
   str     s0, [x2, x3]
   add     x3, x3, #0x4
   cmp     x4, x3
   b.ne    10 
   ret

0000000000000030 :
   cmp     w3, #0x3
   b.le    9c 
   ...
   ldr     q0, [x0, x4]
   ldr     q1, [x1, x4]
   fadd    v0.4s, v0.4s, v1.4s
   str     q0, [x2, x4]
   add     x4, x4, #0x10
   cmp     x6, x4
   b.ne    50 

add_arrays_scalars(32비트 스칼라) 레지스터로 한 번에 float 하나씩만 처리하는데, add_arrays_neonq(128비트) 레지스터와 .4s lane 표기(fadd v30.4s, ...)로 float 4개를 한 명령으로 더한다. -O2에서는 컴파일러가 스칼라 루프를 자동으로 벡터화하지 않았다.

-O3로 올리면 어떻게 될까? 같은 스칼라 코드를 -O3로 다시 컴파일했다.

$ aarch64-linux-gnu-gcc -O3 -march=armv8-a -c neon_demo.c -o neon_o3.o
$ aarch64-linux-gnu-objdump -d --no-show-raw-insn neon_o3.o

0000000000000000 :
   ...
   cmp     x5, #0x8
   ccmp    x4, #0x8, #0x0, hi   // a/out, b/out 포인터가 겹치는지 런타임에 검사
   b.hi    58    // 안 겹치면 벡터화 경로로 분기
   ...(겹칠 때만 타는 스칼라 경로)...
   ldr     q0, [x0, x4]         // 58: 안 겹칠 때 타는 NEON 벡터화 경로
   ldr     q1, [x1, x4]
   fadd    v0.4s, v0.4s, v1.4s
   str     q0, [x2, x4]
   ...

-O3const float *restrict가 없어도 포인터가 실제로 겹치는지 런타임에 검사(ccmp)하는 코드를 추가로 넣고, 겹치지 않을 때만 벡터화된 경로를 타도록 자동 벡터화했다. 즉 이 사례에서는 -O2에서 intrinsic 없이는 벡터화가 안 됐고, -O3에서는 런타임 별칭 검사를 대가로 자동 벡터화가 됐다 — 어느 쪽이든 intrinsic을 직접 쓰면 이 판단을 컴파일러에 맡기지 않고 확정할 수 있다.

실행 검증: qemu-user로 실제 동작 확인

어셈블리 비교만으로는 add_arrays_neon의 tail 루프(n이 4의 배수가 아닌 경우)가 실제로 맞게 동작하는지 알 수 없다. 앞의 두 함수에 main을 추가해 스칼라 결과와 NEON 결과를 직접 비교하는 검증 코드를 작성했다.

int main(void) {
    const int n = 13;  // 4의 배수가 아닌 값으로 tail 루프를 강제로 타게 함
    float a[13], b[13], out_scalar[13], out_neon[13];
    for (int i = 0; i < n; i++) { a[i] = i * 1.5f; b[i] = i * 2.0f; }

    add_arrays_scalar(a, b, out_scalar, n);
    add_arrays_neon(a, b, out_neon, n);

    int mismatch = memcmp(out_scalar, out_neon, sizeof(out_scalar)) != 0;
    printf("n=%d, scalar vs neon 결과 일치: %s\n", n, mismatch ? "실패" : "성공");
    return mismatch;
}

정적으로 빌드한 뒤 qemu-aarch64 사용자 모드 에뮬레이션으로 실행했다.

$ aarch64-linux-gnu-gcc -O2 -march=armv8-a -static -o neon_full neon_full.c
$ qemu-aarch64 ./neon_full
n=13, scalar vs neon 결과 일치: 성공
out[0]: scalar=0.0 neon=0.0
out[1]: scalar=3.5 neon=3.5
...
out[12]: scalar=42.0 neon=42.0
EXIT=0

n=13은 4의 배수가 아니라 NEON 경로가 3번(0~3, 4~7, 8~11) 돈 뒤 나머지 인덱스 12는 스칼라 tail 루프로 처리되는데, 결과가 스칼라 버전과 정확히 일치해 tail 처리가 제대로 됐다는 걸 실제 ARM64 바이너리 실행으로 확인했다.

주의사항

  • NEON intrinsic으로 직접 짠 add_arrays_neona/b/out이 겹치는(overlap) 메모리를 가리켜도 컴파일러가 별도로 검사해주지 않는다. 겹치는 배열에 그대로 쓰면 잘못된 결과가 나올 수 있으므로 필요하면 직접 restrict를 붙이고 호출 규약으로 보장해야 한다.
  • n이 4의 배수가 아니면 남는 원소는 스칼라 tail 루프로 따로 처리해야 한다 — 위 코드의 두 번째 for가 그 역할이다.
  • big.LITTLE처럼 코어마다 NEON 파이프라인 폭이 다른 SoC에서는 같은 코드라도 어느 코어에서 도는지에 따라 처리량 차이가 크다.
  • qemu-aarch64는 사용자 모드 에뮬레이션이라 명령어 단위 정확성은 검증되지만, 사이클/지연시간 같은 실제 하드웨어 성능 수치는 반영하지 않는다 — 처리량 비교가 필요하면 실제 ARM64 장비에서 측정해야 한다.

마무리

NEON intrinsic은 컴파일러의 auto-vectorization에 기대지 않고 SIMD 레지스터 사용을 코드에 명시적으로 박아두는 방법이다. -O2/-O3에 따라 자동 벡터화 여부와 그 대가(런타임 별칭 검사)가 달라진다는 걸 직접 어셈블리로 확인해두면, 성능이 중요한 루프에서 언제 intrinsic을 직접 써야 하는지 판단하기 쉬워진다.

참고

답글 남기기