ARM SVE(Scalable Vector Extension)로 가변 길이 벡터 연산 다루기

NEON은 레지스터 폭이 128비트로 고정돼 있어 배열 길이가 4의 배수가 아니면 스칼라 tail 루프를 따로 짜야 하고, 128비트부터 2048비트까지 벡터 폭이 다양한 ARM 생태계에서도 컴파일 타임에 고정된 128비트씩만 처리한다 — x86이 SSE→AVX2→AVX-512로 넘어갈 때마다 재빌드해야 했던 것과 같은 문제다. ARM은 SVE(Scalable Vector Extension)로 이를 풀었다: ISA에서 벡터 폭을 아예 명시하지 않고, 프로그램이 런타임에 “지금 벡터 폭이 몇 바이트인지” 질의해 그에 맞춰 동작하는 벡터 길이 비종속(VLA, Vector Length Agnostic) 코드를 작성한다. 이 글에서는 SVE의 핵심 개념을 정리하고, x86_64에서 크로스 컴파일 + QEMU 에뮬레이션으로 SVE 인트린식 코드를 직접 컴파일·실행해 벡터 길이에 따라 동일 바이너리가 어떻게 동작하는지 확인한다.

SVE 핵심 개념

SVE는 런타임 벡터 길이 질의predicate 레지스터 기반 lane 마스킹 두 가지로 VLA를 구현한다. 자주 쓰는 ACLE 함수는 아래와 같다.

ACLE 함수/타입대응 명령어역할
svcntb() / svcntw()cntb / cntw현재 하드웨어의 벡터 길이를 바이트/32비트 lane 개수로 반환 (런타임 값)
svbool_tP0~P15 predicate 레지스터lane별 활성/비활성 마스크. 모든 SVE 연산은 predicate로 게이팅됨
svwhilelt_b32(i, n)whilelo인덱스 i부터 lane마다 1씩 증가시키며 n보다 작은 동안만 활성인 predicate 생성 — 루프 나머지 처리를 이 한 줄로 대체
svptrue_b8()ptrue모든 lane이 활성인 predicate (경계 조건이 없을 때)
svld1(pg, ptr) / svst1(pg, ptr, v)ld1w / st1wpredicate로 마스킹된 lane만 메모리 접근 (비활성 lane은 읽기/쓰기 안 함)
svadd_z(pg, a, b)fadd (predicated)predicate가 false인 lane은 결과를 0으로(zeroing) 채우는 연산
svfloat32_tZ0~Z31 벡터 레지스터sizeless type — sizeof()나 일반 배열/구조체에 담을 수 없고 컴파일러가 레지스터 폭에 맞춰 관리

이 조합으로 “predicate가 다 소진될 때까지 반복”하는 루프 하나만 짜면 128비트 코어든 2048비트 코어든 재컴파일 없이 같은 바이너리가 동작한다.

실전: 크로스 컴파일 + QEMU로 벡터 길이 확인하기

SVE는 aarch64 전용이라 x86_64 WSL2에서 네이티브 실행이 불가능하다. gcc-aarch64-linux-gnu로 빌드하고 qemu-userqemu-aarch64로 에뮬레이션했다.

#include <arm_sve.h>
#include <stdio.h>

int main(void) {
    printf("svcntb() = %lu bytes per vector\n", (unsigned long)svcntb());
    printf("svcntw() = %lu 32-bit lanes\n", (unsigned long)svcntw());

    float a[64], b[64], c[64];
    for (int i = 0; i < 64; i++) { a[i] = (float)i; b[i] = (float)(i * 2); }

    uint64_t n = 64;
    for (uint64_t i = 0; i < n; ) {
        svbool_t pg = svwhilelt_b32(i, n);
        svfloat32_t va = svld1(pg, &a[i]);
        svfloat32_t vb = svld1(pg, &b[i]);
        svfloat32_t vc = svadd_z(pg, va, vb);
        svst1(pg, &c[i], vc);
        i += svcntw();
    }

    printf("c[63] = %.0f (expected %d)\n", c[63], 63 + 63*2);
    return 0;
}

루프 증분값이 상수 4(NEON)가 아니라 런타임에 읽은 svcntw()라, 배열 길이 64가 벡터 폭으로 안 나누어떨어져도 tail 처리 코드가 따로 없다.

$ aarch64-linux-gnu-gcc -march=armv8-a+sve -O2 -static -o sve_test sve_test.c
$ qemu-aarch64 -cpu max ./sve_test
svcntb() = 64 bytes per vector
svcntw() = 16 32-bit lanes
c[63] = 189 (expected 189)

같은 바이너리를 QEMU의 sve-max-vq 옵션(128비트 단위 개수)만 바꿔가며 재실행한 결과다.

QEMU 옵션벡터 길이svcntb()svcntw()c[63] 결과
sve-max-vq=1128비트164189 (정상)
sve-max-vq=2256비트328189 (정상)
sve-max-vq=3384비트4812189 (정상)
sve-max-vq=4512비트6416189 (정상)
sve-max-vq=8, 16 요청512비트로 clamp됨64 (요청값 무시)16189 (정상, 단 벡터 길이는 실제로 안 늘어남)

384비트(vq=3)처럼 2의 거듭제곱이 아닌 길이도 정상 동작한다 — SVE 스펙이 128비트 단위 임의 길이를 허용하기 때문이다. objdump로 실제 명령어를 보면 이렇다.

$ aarch64-linux-gnu-objdump -d sve_test | sed -n '/whilelo/,/b.ne/p' | head -8
  4006c0:	25a61c60 	whilelo	p0.s, x3, x6
  4006c8:	a5404020 	ld1w	{z0.s}, p0/z, [x1, x0, lsl #2]
  4006cc:	a5404041 	ld1w	{z1.s}, p0/z, [x2, x0, lsl #2]
  4006d4:	65808020 	fadd	z0.s, p0/m, z0.s, z1.s
  4006d8:	e5404060 	st1w	{z0.s}, p0, [x1, x0, lsl #2]
  4006dc:	04b0c3e0 	incw	x0
  4006e0:	25a30c00 	whilelo	p0.s, w0, w3
  4006e4:	54ffff01 	b.ne	4006c8 <main+0xc8>

svwhilelt_b32whilelo, svld1/svst1 → predicated ld1w/st1w, svadd_z → predicated fadd로 1:1 대응한다. incw x0svcntw()만큼 인덱스를 늘리므로 벡터 폭이 바뀌면 증가폭도 자동으로 달라진다.

주의사항

  • QEMU의 SVE 길이에는 실측 상한이 있다. 이번 환경(QEMU 8.2.2, -cpu max)에서는 sve-max-vq를 5 이상으로 줘도 조용히 4(512비트)로 clamp됐다. 다른 환경에서는 svcntb() 출력으로 실제 적용된 길이를 확인해야 한다.
  • QEMU 유저모드 에뮬레이션은 기능 검증용이지 성능 측정용이 아니다. 명령어를 소프트웨어로 해석 실행하므로 처리량은 의미가 없고, 벡터 길이별 정확성 확인 용도로만 썼다.
  • sizeless type 제약. svfloat32_t 같은 타입은 sizeof나 배열/malloc 버퍼에 직접 담을 수 없다. 메모리 저장은 항상 svld1/svst1로 오간다.
  • 실제 aarch64 하드웨어에서는 결과가 다를 수 있다. 이 글의 실행 결과는 전부 QEMU 에뮬레이션이며, Neoverse V1(256비트) 같은 실제 SVE 코어에서 재현한 것이 아니다.

마무리

SVE의 핵심은 “벡터 폭을 코드에 박아넣지 않는다”는 한 문장으로 요약된다. svcntb()로 벡터 폭을 질의하고 svwhilelt로 매 이터레이션의 유효 lane을 predicate로 계산하는 패턴만 지키면 재컴파일 없이 128~2048비트 전 구간에서 같은 바이너리가 동작하며, 이번 실습에서 128/256/384/512비트 네 가지 길이로 이를 직접 확인했다.

참고

답글 남기기