Skip to content

네이티브 SIMD (vec<T, N>)

vec<T, N>는 일급 언어 타입이다 — T 타입의 원소 N개로 이루어진 고정폭 벡터로, LLVM의 <N x T> 벡터 IR로 직접 낮춰진다(lower). 기본적인 사용을 위해 따로 배워야 할 인트린식 기반 API는 없다 — 일반적인 산술 연산자와 첨자(subscript) 문법이 이미 그대로 동작한다.

c
vec<float, 4> a = {1.0, 2.0, 3.0, 4.0};
vec<float, 4> b = {10.0, 20.0, 30.0, 40.0};
vec<float, 4> c = a + b;      // 원소별 덧셈
vec<float, 4> d = c * b;      // 원소별 곱셈

float first = c[0];           // extractelement — 레인 하나를 읽음
c[2] = 999.0;                 // insertelement — 레인 하나에 씀

c[0]은 (벡터의 원소 타입인) float이다 — 이를 double에 대입하면 암묵적으로 확장(widen)되는데, 이는 다른 모든 수치 타입에 적용되는 것과 동일한 안전한 작은-타입-에서-큰-타입으로의 변환이다(타입 참고): double first = c[0];는 캐스트 없이 컴파일되며 정확한 값을 그대로 읽어온다.

T는 어떤 정수 또는 부동소수점 기본 타입도 될 수 있고, N은 컴파일 타임 상수여야 한다. "적당한" 하드웨어 폭으로 제한되지 않는다 — vec<int, 3>도 유효하다 — 하지만 std::simd의 ISA별 편의 계층은 실제 벡터 레지스터에 깔끔하게 대응되는 폭을 선택한다 (128비트 SSE/NEON/SIMD128, 256비트 AVX2, ...).

무엇이 무엇으로 컴파일되는가

컴파일러에는 아키텍처에 종속된 것이 아무것도 하드코딩되어 있지 않다 — vec<T,N>는 이식 가능한 LLVM 벡터 IR로 낮춰지고, 각 타깃의 자체 백엔드가 스칼라 산술과 정확히 마찬가지로 거기서부터 명령어 선택을 수행한다. 컴파일러가 지원하는 모든 백엔드에서 실제로 생성된 코드를 대상으로 검증되었다:

타깃vec<float,4> + vec<float,4>가 무엇이 되는가
x86_64 (SSE2 기본)addps (단일 명령어)
AArch64 / 32비트 ARM (NEON)fadd v0.4s, v0.4s, v1.4s
RISC-V (+v 확장)vsetivli + vfadd.vv
WebAssembly (SIMD128)f32x4.add
SPIR-V%v4float에 대한 OpFAdd
ARM Cortex-M55/M85 (MVE, +mve)vadd.i32 q2, q0, q1
CUDA (NVPTX) / ROCm (AMDGPU)스칼라 레인 연산 4개 — 아래 설명 참고

GPU 타깃은 스칼라화된다. PTX와 GCN에는 SSE/NEON처럼 "packed float 4개를 한 명령어로 더한다"는 개념이 없다 — GPU의 병렬성은 넓은 레지스터가 아니라 락스텝으로 실행되는 다수의 스레드(워프/웨이브프론트)에서 나온다. vec<float,4>는 그곳에서도 정확하게 동작하지만, 하나의 넓은 연산이 아니라 네 개의 독립적인 스칼라 연산으로 컴파일될 뿐이다 — 모든 타깃에서 단일 명령어 처리량을 보장하는 것이 아니라, 관련된 값을 묶는 편리한 수단일 뿐이다.

ARM Cortex-M4/M7의 DSP 확장은 별개의 이야기다. 이 확장의 packed-SIMD 명령어(SADD16, SMLAD, USAD8, ...)는 16비트 레인 2개 또는 8비트 레인 4개를 하나의 32비트 스칼라 레지스터에 채워 넣는데, 이는 실제 벡터 레지스터 파일과는 근본적으로 다른 모델이며 LLVM은 vec<T,N> IR을 이런 명령어로 자동 벡터화하지 않는다. 이들은 std::dsp_* 함수로 별도로 노출된다 — 베어메탈 참고.

복합 초기화자

벡터 초기화자는 정확히 N개의 위치 기반 값만 받는다 — 구조체/배열의 복합 초기화자와 달리 지정자(designator)는 허용되지 않는다:

c
vec<int, 8> ints = {1, 2, 3, 4, 5, 6, 7, 8};

전역 벡터의 초기화자는 다른 전역 변수와 마찬가지로 여전히 컴파일 타임 상수여야 한다:

c
vec<float, 4> global_v = {10.0, 20.0, 30.0, 40.0};   // 괜찮음 — 리터럴이 접혀서(fold) 상수가 됨

첨자 시맨틱

상수 인덱스로 v[i]를 읽는 것은 컴파일 타임에 경계 검사된다(범위를 벗어난 상수 인덱스는 컴파일 에러다). 런타임 인덱스는 unsafe { } 안이 아닌 한 다른 인덱싱 연산과 마찬가지로 경계 검사된다. 배열 원소와 달리 단일 레인은 메모리상에서 독립적으로 주소를 가질 수 없다 — v[i]는 포인터 연산이 아니라, SSA로 존재하는 벡터 값 전체에 대한 extractelement/insertelement로 낮춰진다.

std::simd와의 관계

vec<T,N>는 컴파일러 수준의 빌딩 블록이다. std::simd는 이 위에 완전히 구축된 이식 가능한 라이브러리로 — 타입 별칭(f32x4, i32x8, ...), 벡터와 원시 포인터 간의 로드/스토어, 브로드캐스트, 수평(horizontal) 리덕션, 곱셈-덧셈 융합(FMA)을 제공하며, 여기에 더해 동일한 타입들을 아키텍처에 맞는 이름으로 다시 내보내기만 하는 얇은 ISA별 편의 헤더(x86_64.h, aarch64.h, riscv.h, wasm.h, spirv.h, cortex_m.h, cuda.h, rocm.h)들이 있다. 타깃마다 별도로 손으로 작성된 SIMD 구현은 없다 — 전체 라이브러리는 백엔드마다 한 번씩 컴파일되는 동일한 이식 가능한 소스다.

MIT 라이선스로 배포됩니다.