SIMD (std::simd)
std/simd/는 컴파일러의 네이티브 벡터 타입인 vec<T, N>
위에 전적으로 구축된 이식 가능한 SIMD 라이브러리입니다 — 이 라이브러리
어디에도 명령어별로 직접 손으로 작성한 구현은 없습니다. 원소 단위 산술
연산과 레인 접근은 이미 일반적인 연산자/첨자 문법을 통해 모든
vec<T,N>에서 동작합니다. 이 라이브러리는 그 위에 ISA에 구애받지 않는
편리한 타입 이름, 포인터 로드/스토어, 브로드캐스트, 수평 리덕션, 융합
곱셈-덧셈(FMA)을 추가합니다.
#include <std/simd/simd.h>
int main() {
float src[4] = {1.0, 5.0, 3.0, 2.0};
float dst[4];
f32x4 v = std::simd_load_f32x4(&src[0]);
f32x4 doubled = v + v;
std::simd_store_f32x4(doubled, &dst[0]);
float total = std::simd_hsum_f32x4(v); // horizontal sum
f32x4 a = std::simd_splat_f32x4(2.0); // broadcast
f32x4 fma = std::simd_fma_f32x4(a, a, v); // a*a + v
return 0;
}
로드와 스토어는 첫 원소에 대한 참조를 받아 그 위치부터 연속된 N개의 원소를 정렬되지 않은(unaligned) 벡터 로드/스토어 하나로 읽거나 씁니다. 배열 원소의 주소(&src[0])를 넘기는 데에는 unsafe가 필요 없습니다.
타입 별칭
명명 규칙: <원소타입><비트수>x<레인수>. 폭은 모든 타겟의 기본 SIMD
레지스터(128비트: SSE, NEON, WASM SIMD128, VLEN≥128인 RVV)와 일반적인
256비트 경우(AVX2 — 네이티브 256비트 레지스터가 없는 타겟에서는
LLVM이 이를 두 개의 128비트 연산으로 레거라이즈하며, 여전히 정확하게
동작하지만 단일 명령어는 아닙니다)를 모두 다룹니다.
| 별칭 | 실제 타입 |
|---|---|
f32x4, f32x8 |
vec<float, 4>, vec<float, 8> |
f64x2, f64x4 |
vec<double, 2>, vec<double, 4> |
i32x4, i32x8 |
vec<int, 4>, vec<int, 8> |
i64x2, i64x4 |
vec<long long, 2>, vec<long long, 4> |
i16x8, i16x16 |
vec<short, 8>, vec<short, 16> |
i8x16, i8x32 |
vec<signed char, 16>, vec<signed char, 32> |
u32x4, u32x8 |
vec<unsigned int, 4>, vec<unsigned int, 8> |
u8x16, u8x32 |
vec<unsigned char, 16>, vec<unsigned char, 32> |
함수
| 함수 | 설명 |
|---|---|
simd_load_<type>(const &T p) |
p부터 N개 레인을 로드 — 구조상 정렬 불필요 |
simd_store_<type>(v, &T p) |
p부터 N개 레인을 스토어 |
simd_splat_<type>(x) |
스칼라를 모든 레인에 브로드캐스트 |
simd_fma_<type>(a, b, c) |
곱셈-덧셈 a*b + c, 타깃에 FMA가 있으면 융합된 명령어 하나 |
simd_min_<type>(a, b) / simd_max_<type>(a, b) |
원소 단위 최소/최대 |
simd_hsum_<type>(v) |
수평 합 — 모든 레인을 하나의 스칼라로 축약 |
simd_hmin_<type>(v) / simd_hmax_<type>(v) |
수평 최소/최대 |
모든 함수가 모든 타입 별칭에 대해 정의되어 있지는 않습니다. 정확한 목록:
| 함수 | 타입 |
|---|---|
simd_load_* / simd_store_* |
f32x4, f32x8, f64x2, f64x4, i32x4, i32x8, u32x4, u32x8, i8x16, u8x16 |
simd_splat_* |
f32x4, f32x8, f64x2, f64x4, i32x4, i32x8, u32x4 |
simd_fma_* |
f32x4, f32x8, f64x2, f64x4 |
simd_min_* / simd_max_* |
f32x4, f32x8, i32x4 |
simd_hsum_* |
f32x4, f32x8, f64x2, f64x4, i32x4, i32x8, u32x4 |
simd_hmin_* / simd_hmax_* |
f32x4 |
그 밖의 경우에는 네이티브 vec<T, N> 연산자를 직접 씁니다(네이티브 SIMD 참고).
#include <std/simd/simd.h>
float dot4(const &float a, const &float b) {
f32x4 va = std::simd_load_f32x4(a);
f32x4 vb = std::simd_load_f32x4(b);
f32x4 acc = std::simd_fma_f32x4(va, vb, std::simd_splat_f32x4(0.0f));
return std::simd_hsum_f32x4(acc);
}
CPU SIMD 수준 — cpu.h
#include <std/simd/cpu.h>
std의 핵심 커널 — sgemm, vmath.h, FFT 버터플라이, dsp_dot_f64 — 은 여러 버전으로 빌드되며, CPU가 지원하는 가장 좋은 것을 이 순서로 씁니다:
| 계열 | 순서 | 선택 시점 |
|---|---|---|
| x86-64 | AVX-512 (F+BW+DQ+VL) > AVX2 + FMA > AVX > SSE4.1 > SSE2 | 실행 시점(cpuid + xgetbv), 한 번 |
| AArch64 | SVE > Neon | Linux에서는 실행 시점(getauxval), 그 밖에는 Neon |
| Cortex-M | Helium (MVE) > DSP 확장 | 컴파일 시점, 타깃으로: Helium은 arm-81m, DSP는 arm-7em / arm-8m |
| WebAssembly | SIMD128 | 컴파일 시점 |
그래서 이식 가능한 베이스라인으로 빌드한 x86-64 바이너리 하나가 CPU에 AVX-512가 있으면 그것을 쓰고, SSE2만 있는 머신에서도 그대로 돕니다. 빌드가 이미 보장하는 수준(예: __AVX2__ 등을 정의하는 --target-cpu=native, ISA 매크로 참고)이 하한입니다. 각 버전은 같은 소스를 __attribute__((target(...)))로 컴파일한 것입니다.
#define SIMD_SSE2 1 // ... SIMD_SSE41 2, SIMD_AVX 3, SIMD_AVX2 4, SIMD_AVX512 5,
// SIMD_NEON 10, SIMD_SVE 11, SIMD_DSP 20, SIMD_HELIUM 21, SIMD_WASM128 30
int simd_level(); // 한 번 감지한 뒤 캐시
const &char simd_level_name(int level); // "AVX-512", "Neon", "Helium", ...
SAFEC_SIMD=sse2|sse4.1|avx|avx2|avx512|neon|sve는 수준의 상한을 정해, 더 느린 경로를 시험하거나 비교할 수 있게 합니다.
행렬 곱셈 — gemm.h
#include <std/simd/gemm.h>
CPU용 단정밀도 GEMM입니다. A와 B를 연속 스트립으로 패킹한 뒤 레지스터 타일 커널로 곱합니다: vec<float, 8>의 4 × 16(같은 본문을 SSE2, AVX, AVX2 + FMA로 빌드) 또는 AVX-512용 vec<float, 16>의 8 × 16. 곱셈-덧셈이 약 백만 회 이상인 곱은 C를 타일로 나누어 스레드 풀에서 실행합니다.
프로그램이 CPU 벤더 BLAS를 링크하면 그 커널이 계산합니다: AMD에서는 AOCL-BLAS, Intel에서는 oneMKL, Apple에서는 Accelerate. safeguard의 [build] blas가 알맞은 것을 링크하며, 직접 링크할 때는 -lblis-mt -lgomp, oneMKL의 mkl_intel_lp64 mkl_gnu_thread mkl_core -lgomp, 또는 -framework Accelerate를 씁니다. sgemm은 cblas_sgemm을 약한 참조로 찾으므로 선택은 링크 시점에 정해집니다. Accelerate를 제외하면 std는 라이브러리를 단일 스레드로 돌리고 C를 자기 풀에 타일로 나누어, 타일마다 라이브러리를 한 번 호출합니다. 라이브러리의 OpenMP 워커는 호출 사이에 바쁜 대기를 하는데, 풀과 같은 코어를 두고 경쟁하면 U-Net 순전파가 6배 느려졌습니다. 이 설정은 프로그램이 직접 라이브러리를 호출할 때도 단일 스레드로 적용됩니다.
#define SGEMM_NO_TRANS 0
#define SGEMM_TRANS 1
// C = alpha * op(A) * op(B) + beta * C, 행 우선; op(A)는 m x k, op(B)는 k x n.
// 'ld' = 두 행의 시작 사이의 원소 수. beta == 0이면 C를 읽지 않음.
int sgemm(int transA, int transB, unsigned long m, unsigned long n, unsigned long k, float alpha, []const float a, unsigned long lda, []const float b, unsigned long ldb, float beta, []float c, unsigned long ldc);
// 같은 연산을 항상 호출한 스레드에서만 (풀 없음; 링크된 벤더 라이브러리는
// 단일 스레드로): 이미 병렬인 코드용.
int sgemm_serial(/* 같은 매개변수 */);
int sgemm_vendor(); // 벤더 BLAS를 쓰고 있으면 1
const &char sgemm_backend(); // "AOCL-BLAS (BLIS)", "oneMKL", "Accelerate", "OpenBLAS", "CBLAS", "built-in"
tensor_matmul(모든 원소 타입의 순전파와 역전파)과 Conv2D::forward가 이것을 기반으로 합니다.
Ryzen 7 7800X3D(Zen 4, 8코어 / 16스레드)에서 N × N × N, 베이스라인 x86-64 빌드 하나로 잰 처리량:
| 커널 | 1스레드, N = 1024 | 16스레드, N = 512 | 16스레드, N = 1024 |
|---|---|---|---|
내장, SSE2 (SAFEC_SIMD=sse2) |
35 GFLOP/s | 252 GFLOP/s | 239 GFLOP/s |
| 내장, AVX | 86 GFLOP/s | 575 GFLOP/s | 614 GFLOP/s |
| 내장, AVX2 + FMA | 100 GFLOP/s | 575 GFLOP/s | 614 GFLOP/s |
| 내장, AVX-512 (이 CPU에서 선택됨) | 105 GFLOP/s | 671 GFLOP/s | 716 GFLOP/s |
| AOCL-BLAS 5.3, std 풀의 타일 | 134 GFLOP/s | 1007 GFLOP/s | 1074 GFLOP/s |
| oneMKL 2026.2, std 풀의 타일 | 130 GFLOP/s | 671 GFLOP/s | 716 GFLOP/s |
벡터 수학 — vmath.h
#include <std/simd/vmath.h>
unsigned long vm_exp_f32([]const float x, []float y);
unsigned long vm_tanh_f32([]const float x, []float y);
unsigned long vm_sigmoid_f32([]const float x, []float y);
y[i] = f(x[i])를 분기 없는 다항식 코드(Cephes 방식 구간 축소)로 계산하며, CPU의 SIMD 수준으로 벡터화되고 32,768개 원소부터 스레드 풀에 나뉩니다. 측정 오차는 exp 1 ulp 미만, tanh 1.3 ulp, sigmoid 2 ulp이며 비정규 결과도 포함합니다. NaN은 전파되고 오버플로는 무한대가 됩니다. 7800X3D에서 원소당 약 0.2 ns입니다. x와 y는 같은 배열이어도 됩니다. std의 활성화(sigmoid, tanh, GELU, SiLU, ELU, GLU, SwiGLU)와 softmax가 이것을 씁니다.
ISA별 편의 헤더
여덟 개의 얇은 헤더가 동일한 이식 가능 타입을 아키텍처에 맞는 이름으로
다시 내보냅니다 — 각각은 순수하게 typedef와 문서로만 이루어져 있고 별도의
로직은 없으며, 각각 대상 아키텍처에 대해 실제 생성된 코드로 검증되었습니다
(단순히 "컴파일된다"가 아니라 llc 출력을 역어셈블한 결과로 확인).
| 헤더 | 대상 | 네이티브 매핑 |
|---|---|---|
std/simd/x86_64.h |
x86_64 | m128/m128i/m128d(SSE/SSE2), m256/m256i/m256d(AVX/AVX2) |
std/simd/aarch64.h |
AArch64 | 128비트 NEON 레지스터 |
std/simd/riscv.h |
RISC-V (+v) |
128비트 RVV 레지스터 그룹(기본 zve* 폭이며, 진정한 스케일러블 벡터 길이는 모델링되지 않음 — 이유는 헤더 참고) |
std/simd/wasm.h |
WebAssembly | 128비트 v128(SIMD128 제안) |
std/simd/spirv.h |
SPIR-V | 컴퓨트 커널 본문 내의 실제 OpTypeVector/OpFAdd — SPIR-V의 호스트 libc 없는 실행 모델에 대한 유의 사항은 해당 헤더 참고 |
std/simd/cortex_m.h |
ARM Cortex-M | MVE(M55/M85) 타입 별칭 + DSP 확장 dsp_* 함수(M4/M7) — 베어메탈 참고 |
std/simd/cuda.h |
CUDA (NVPTX) | 실제 PTX 벡터 레인 코드 생성 — GPU는 N개의 스칼라 연산으로 스칼라화됨, 아래 유의 사항 참고 |
std/simd/rocm.h |
ROCm (AMDGPU) | 실제 GCN 벡터 ALU 코드 생성 — 동일한 스칼라화 유의 사항 |
Cortex-M DSP 내장 함수
Cortex-M4/M7(+dsp)의 패킹된 SIMD 명령어는 32비트 레지스터 하나 안의 2×16비트 또는 4×8비트 레인에서 동작하며, LLVM은 vec<T, N>을 이 명령어로 벡터화하지 않습니다. 그래서 std/simd/cortex_m.h는 이들을 함수로 제공하며, 각 함수는 이름 붙은 명령어 하나로 컴파일됩니다:
| 함수 | 명령어 |
|---|---|
dsp_qadd(a, b) / dsp_qsub(a, b) |
포화 32비트 덧셈 / 뺄셈 |
dsp_qadd16, dsp_qsub16, dsp_qadd8, dsp_qsub8 |
16비트 또는 8비트 레인별 포화 부호 있는 덧셈 / 뺄셈 |
dsp_uqadd16, dsp_uqsub16, dsp_uqadd8, dsp_uqsub8 |
레인별 포화 부호 없는 연산 |
dsp_sadd16, dsp_ssub16, dsp_sadd8, dsp_ssub8 |
레인별 랩어라운드 부호 있는 연산 |
dsp_smlad(a, b, acc) / dsp_smladx |
16×16 이중 곱셈 후 두 곱을 acc에 더함 (x: b의 상하위 교환) |
dsp_smlsd(a, b, acc) / dsp_smlsdx |
이중 곱셈 후 곱의 차를 acc에 더함 |
dsp_smuad(a, b) / dsp_smuadx |
이중 곱셈, 곱의 합 |
dsp_smusd(a, b) / dsp_smusdx |
이중 곱셈, 곱의 차 |
dsp_usad8(a, b) / dsp_usada8(a, b, acc) |
4바이트 절대 차의 합 (acc 더함) |
dsp_sxtab16(a, b) / dsp_uxtab16(a, b) |
b의 두 바이트를 16비트로 부호 / 0 확장해 a의 상하위에 더함 |
부호 있는 변형은 int를 받고 반환하며, uq*, usad8, usada8, uxtab16은 unsigned int를 씁니다. Cortex-M55/M85(+mve)에서는 mve_f32x4, mve_i32x4, mve_i16x8, mve_i8x16이 네이티브 128비트 벡터입니다. Cortex-M0/M0+/M3/M23에는 둘 다 없으며, 거기서도 vec<T, N>은 동작하지만 스칼라로 풀립니다.
GPU 타겟: 단일 명령어 SIMD가 아닌 SIMT
PTX와 GCN에는 SSE/NEON 같은 팩(pack) 산술 명령어가 없습니다 — GPU의
병렬성은 한 스레드 안의 넓은 레지스터가 아니라, 여러 스레드를 락스텝으로
실행하는 데서 나옵니다. vec<float,4> + vec<float,4>는 CUDA/ROCm 타겟에서도
여전히 정확하게 컴파일되지만, 하나의 넓은 명령어가 아니라 네 개의
독립적인 스칼라 연산이 됩니다(확인됨: 실제로 생성된 PTX는 네 개의
add.rn.f32 명령어를, 실제 GCN은 네 개의 v_add_f32_e32 명령어를
보여줍니다). std::simd의 CUDA/ROCm 헤더는 커널 본문 내부에서 사용할
수 있는(디바이스 메모리, 호스트 libc 없음) 이식 가능한 산술 타입만
제공합니다 — 커널 진입점(__global__/ptx_kernel/amdgpu_kernel 호출
규약과 실행 구성 지원)은 정의하지 않으며, 이는 이 라이브러리가 다루지
않는 별개의 영역입니다.
Metal Shading Language
지원되지 않습니다. Apple의 Metal 컴파일러에는 업스트림 LLVM 백엔드가 없습니다
— --target으로 선택할 수 있는 실제 LLVM 타겟인 NVPTX/AMDGPU/SPIR-V와
달리, Metal은 별개의 폐쇄형 툴체인입니다. 이 라이브러리의 SPIR-V 출력에서
연동할 수 있는 유일한 경로는 서드파티 변환기(예: SPIRV-Cross)뿐이며,
safec이나 std::simd가 직접 제공하는 기능이 아닙니다.