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_t | P0~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 / st1w 등 | predicate로 마스킹된 lane만 메모리 접근 (비활성 lane은 읽기/쓰기 안 함) |
svadd_z(pg, a, b) | fadd (predicated) | predicate가 false인 lane은 결과를 0으로(zeroing) 채우는 연산 |
svfloat32_t 등 | Z0~Z31 벡터 레지스터 | sizeless type — sizeof()나 일반 배열/구조체에 담을 수 없고 컴파일러가 레지스터 폭에 맞춰 관리 |
이 조합으로 “predicate가 다 소진될 때까지 반복”하는 루프 하나만 짜면 128비트 코어든 2048비트 코어든 재컴파일 없이 같은 바이너리가 동작한다.
실전: 크로스 컴파일 + QEMU로 벡터 길이 확인하기
SVE는 aarch64 전용이라 x86_64 WSL2에서 네이티브 실행이 불가능하다. gcc-aarch64-linux-gnu로 빌드하고 qemu-user의 qemu-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=1 | 128비트 | 16 | 4 | 189 (정상) |
sve-max-vq=2 | 256비트 | 32 | 8 | 189 (정상) |
sve-max-vq=3 | 384비트 | 48 | 12 | 189 (정상) |
sve-max-vq=4 | 512비트 | 64 | 16 | 189 (정상) |
sve-max-vq=8, 16 요청 | 512비트로 clamp됨 | 64 (요청값 무시) | 16 | 189 (정상, 단 벡터 길이는 실제로 안 늘어남) |
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_b32 → whilelo, svld1/svst1 → predicated ld1w/st1w, svadd_z → predicated fadd로 1:1 대응한다. incw x0가 svcntw()만큼 인덱스를 늘리므로 벡터 폭이 바뀌면 증가폭도 자동으로 달라진다.
주의사항
- 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비트 네 가지 길이로 이를 직접 확인했다.