Platform-independent SIMD in Go
Go의 플랫폼 독립 SIMD 실험
Go 1.27은 CPU별 벡터 크기와 명령어 차이를 감추는 실험적 SIMD 패키지 `simd`를 도입합니다. 공통 연산과 에뮬레이션으로 여러 아키텍처에서 코드를 재사용하며, 성능과 지원 기능은 하드웨어와 연산에 따라 달라집니다.
- 주제
AI 요약
Go 1.26과 1.27에는 SIMD(Single Instruction Multiple Data) 연산을 위한 실험적 API가 들어갑니다. SIMD는 한 명령으로 여러 데이터에 같은 연산을 적용해 암호화, 데이터 처리, AI 같은 계산을 가속합니다. Go의 Green Tea 가비지 컬렉터도 살아 있는 객체를 찾으려고 메모리를 훑을 때 SIMD를 활용합니다. 새 API 전에는 Go 어셈블리로 직접 작성해야 했기 때문에, 성능이 특히 중요한 코드가 아니면 SIMD 활용을 포기하는 경우가 많았습니다.
아키텍처마다 다른 SIMD
Go 1.26은 amd64용 archsimd를 도입했고, Go 1.27은 arm64의 NEON과 wasm용 API를 더했습니다. 하지만 SIMD는 플랫폼별 차이가 큽니다. amd64는 128·256·512비트 벡터를 지원하고, RISC-V의 riscv64는 실행 중 벡터 길이를 확인해야 합니다. arm64에는 128비트 NEON과 가변 길이 SVE가 있으며, 사용 가능한 기능과 벡터 크기도 CPU별로 다릅니다.
마스킹 방식과 명령어 지원도 제각각입니다. 일부 아키텍처는 별도 마스크 레지스터를 쓰고, 다른 아키텍처는 벡터 비트 연산으로 마스킹을 구현합니다. 벡터 요소 재배열 방식도 다르며, 기본 연산조차 일부 플랫폼에서는 빠져 있습니다. 예를 들어 wasm은 64비트 정수 벡터 비교를 지원하지 않습니다. 아키텍처별 API만으로 이런 차이를 감추려 하면 성능을 희생해야 합니다.
공통 API `simd`
Go 1.27은 아키텍처와 벡터 크기에 덜 얽매이는 실험적 simd 패키지를 추가합니다. C++의 Highway를 느슨하게 참고한 인터페이스로, SIMD를 지원하는 플랫폼에서는 어셈블리 수준에 가까운 성능을 내고, 지원하지 않는 플랫폼에서는 에뮬레이션으로 실행하는 것이 목표입니다. 현재 amd64의 AVX·AVX2·AVX512, arm64의 NEON, wasm SIMD를 지원합니다. 사용하려면 GOEXPERIMENT=simd를 설정합니다.
벡터는 simd.Uint8s, simd.Float32s처럼 요소형을 복수형으로 쓴 타입입니다. 벡터 길이를 타입에 고정하지 않고 Len()으로 확인하며, 슬라이스에서 Load하고 결과를 Store합니다. 글의 내적 예제는 벡터 길이만큼 원소를 묶어 곱셈과 덧셈을 반복하고, 마지막에 남은 원소는 부분 로드로 처리합니다. Go 1.27에는 벡터 요소를 합산하는 공통 연산이 없어 별도 스칼라 합산이 필요합니다. 다음 릴리스에는 ReduceSum을 추가할 계획입니다.
API는 모든 플랫폼의 공통분모를 바탕으로 설계합니다. 공통 지원 기능만 제공하면 빠지는 연산이 많아지므로, 다른 SIMD 명령 조합으로 효율적으로 구현할 수 있는 기능은 archsimd 쪽 에뮬레이션으로 보완합니다. 예를 들어 일부 아키텍처에서 빠진 부호 없는 비교는 부호 있는 비교와 XOR 연산으로 구현합니다. 암호화와 CRC에 쓰이는 carryless multiply도 API에서 제외하지 않고 입력값에 따라 실행 시간이 달라지지 않는 에뮬레이션을 제공합니다.
아키텍처별 코드와 에뮬레이션 연결
공통 API에 필요한 연산이 없거나 에뮬레이션이 충분하지 않으면 ToArch()로 아키텍처별 벡터 타입으로 바꿔 처리한 뒤 simd.<타입>FromArch로 돌아올 수 있습니다. 예제는 각 바이트의 1 비트 개수를 세는 OnesCount를 플랫폼별로 구현합니다. amd64에서는 AVX·AVX2와 AVX512의 지원 차이를 타입 분기로 처리하고, AVX·AVX2에서는 조회 테이블과 벡터 연산을 조합합니다. NEON과 wasm은 해당 명령을 활용합니다. SIMD 하드웨어가 없는 플랫폼에는 스칼라 에뮬레이션을 둡니다. 컴파일러가 simd 코드를 특수화하므로, 겉보기와 달리 타입 변환과 분기 비용은 최적화 과정에서 제거됩니다.
실행 설정과 컴파일러 구현
GODEBUG=simd=0은 하드웨어 지원이 있어도 에뮬레이션을 사용합니다. simd=128, 256, 512는 해당 길이의 벡터 기능을 선택하며, 필요한 기능이 없으면 즉시 패닉을 일으킵니다. simd=+128처럼 앞에 +를 붙이면 일부 기능이 없어도 해당 벡터 길이를 선택합니다. 다만 코드가 실제로 없는 명령을 사용하면 패닉이 발생합니다. 예를 들어 Raspberry Pi는 NEON을 지원하지만 PMULL은 지원하지 않을 수 있습니다.
simd는 패키지 API뿐 아니라 내부 구현과 컴파일러 프런트엔드의 AST 재작성으로 구성됩니다. 컴파일러는 SIMD 타입을 벡터 길이별 내부 타입으로 바꾼 복사본을 만들고, 함수 진입부에서 실행 환경에 맞는 버전을 선택합니다. SIMD 계산 중에는 특수화된 함수를 직접 호출해 매 연산마다 분기하는 비용을 피합니다.
Go 1.28에는 arm64 SVE 지원과 OnesCount, 마스크, 축약, 벡터 셔플 같은 연산을 추가할 계획입니다. 일부 명령만 빠진 하드웨어에서 전체 에뮬레이션으로 떨어지지 않도록 기능별 변형도 검토합니다.
Reddit 반응
- @u/LearnedByError — Go 1.27이 나온 직후, Go 1.26에서 직접 작성했던 SIMD 함수 두 개를 아키텍처 독립 SIMD로 바꿨습니다. 안타깝게도 16배였던 속도 향상이 2.5배에 그쳤습니다. 거의 전적으로 글에서 언급한 ‘벡터 요소 전체를 합산하는 기능’이 없어서 생긴 결과였습니다. arch64에서 시험했을 때는 128비트만 지원해 속도 향상이 2배에도 조금 못 미쳤습니다. 아키텍처별 SIMD는 amd64에서 어느 정도, arch64에서는 꽤 좋은 성능 향상을 냈지만 기대만큼은 아니었습니다. 아키텍처별 구현과 독립 구현의 성능 차이가 크게 날 수 있다는 글의 설명과 같습니다. 이 글을 읽고 기대가 커졌고, 더 나은 구현을 기다립니다. 고맙습니다, lbe.
원문: Go Blog / 번역·요약: Trawling