단일 명령어 스트림 Single instruction stream | 다중 명령어 스트림 Multiple instruction streams | 단일 프로그램 Single program | 다중 프로그램 Multiple programs | |
단일 데이터 스트림 Single data stream | SISD | MISD | ||
다중 데이터 스트림 Multiple data streams | SIMD | MIMD | SPMD | MPMD |
1. 개요
플린 분류Flynn's Taxonomy
스탠퍼드 대학교의 전기공학과 교수인 마이클 J. 플린이 1966년에 제안한 컴퓨터 구조 분류이다.
명령어(Instruction)와 데이터 입력(Data stream)의 개수에 따라 구분한다.
2. 분류
2.1. SISD
Single instruction stream, single data stream.한 번에 데이터 하나를 명령어 하나로 처리하는 기법.
폰 노이만 구조의 컴퓨터는 기본적으로 이 기법을 따른다. 가장 기본적이고 간단한 구조이지만 명령어를 실행할 때마다 명령어와 데이터를 읽어와 처리해야 하기 때문에 효율이 떨어진다.
2.2. SIMD
Single instruction stream, multiple data streams.
한 번에 데이터 여러 개를 명령어 하나로 처리하는 기법.
현재 대부분의 프로그램은 SISD 방식으로 동작한다. 즉 하나의 명령으로 하나의 데이터를 처리하는 것이고 2개의 데이터를 처리하려면 2번 연산한다. 라이브러리 수준의 프로그램이 아닌 경우에는 백이면 백 SISD로 구현되는데 CPU의 성능을 100% 활용하지 못하는 것이다. 다행히 요즘 컴파일러들은 똑똑해서 일괄계산 루프문 정도는 SIMD로 잘 바꿔주는 편이다.
CPU 정보를 볼 수 있는(CPU-Z 같은) 프로그램에서 MMX, SSE, SSE2, SSE3 같은 걸 본 적이 있을 것이다. 이것이 바로 SIMD를 활용한 명령어 세트인데 구체적으로는 이를 CPU에서 하드웨어적으로 지원한다는 것이다. 2011년 샌디브릿지 기반 이후, 불도저 기반 이후 CPU라면 AVX도 지원한다. 이 기술을 사용하려면 특수한 레지스터와 특수한 명령을 사용해야 하는데 최신 컴파일러들은 Loop Unrolling 등의 기술을 통해 자체적으로 SIMD instruction을 사용한 코드로 변환해준다. 단, 코드를 컴파일러가 벡터 연산으로 인식하기 쉽게 작성해주어야 한다. 인텔의 icc, 마이크로소프트의 CL, GNU gcc, LLVM clang 등이 컴파일러 옵션을 통해 루프를 SIMD로 변환하는 것을 지원하며, icc의 경우엔 #pragma simd라는 특수한 전처리기를 지원한다. 이런 거 신경쓰고 싶진 않은데 SIMD의 고성능은 원하는 프로그래머는 이런 기술들을 구현한 수치 연산 라이브러리를 가져다 쓰면 된다. 라이브러리가 요구하는 입출력 데이터 포맷에만 맞게 데이터를 준비해서 던져 주면 나머지는 라이브러리와 컴파일러가 알아서 할 것이다.
Intel 진영과 달리, ARM 진영에서는 NEON이라는 SIMD 명령어 셋을 지원한다. Intel 진영과 비슷한 부분도 있지만, CPU 정책이나 구조가 다른 부분들도 많으므로 주의하여 프로그래밍해야 한다.
icc의 경우에는 옵션을 통해 SIMD뿐만이 아니라 루프를 자체적으로 멀티쓰레딩으로 처리해주는 기능이 있으며, 최신 버전의 OpenMP에서는 SIMD 관련 전처리기를 추가하여 멀티쓰레딩과 SIMD의 두 마리 토끼를 동시에 잡으려는 시도를 하고 있다. 이런 기능을 사용하여 코드 최적화를 할 경우, 수치연산이 많은 코드는 컴파일러가 400% x 코어 개수 이상으로 성능을 뽑아낼 수도 있다. 하지만 데이터 의존성 등의 이슈들에 주의를 기울이지 않고 작성된 코드를 컴파일러가 성능 향상을 위해 Reordering 등을 적용했을 때, 코드 작성자의 의도와 코드의 결과 값이 항상 맞는다는 보장은 없다. 현 시점에서는 단일 스레드 기반의 코드에서만 보장된다. 숙련된 프로그래머들은 이러한 이슈를 회피하기 위해 락을 걸거나 Atomic Type을 활용하거나, 불변 변수를 적절히 사용한다. 데이터 의존성이 거의 없고 스레드간 통신도 거의 없어서 별달리 주의를 기울이지 않아도 멀티스레드로 성능이 매우 잘 향상되는 알고리즘들을 embarrassingly parallel(처치 곤란 병렬)하다고 한다. 예를 들어 거대한 정수배열의 평균값을 구하는 알고리즘은 데이터 헤저드(Data Hazard)가 없기 때문에 스레드 생성 오버헤드를 제외하면 병렬화하는만큼 성능이 올라간다. 한국어로는 '처치 곤란'이라고 번역하는데,[1] 의미상으로는 ‘병렬 연산을 못시키면 창피할 정도로 병렬화하기 쉬운, 자명하게 병렬화 가능한’ 정도의 의미로 이해할 수 있다.
당연히 CPU의 종류에 따라 SIMD 명령어도 달라진다. 위에서 소개한 내용은 주로 Intel CPU들에 국한된 것이며, 예를 들어 모바일에 들어가는 ARM의 경우, NEON이라 불리는 다른 SIMD를 제공한다. 공통적으로 SIMD라는 기본적인 개념은 동일하지만 세세한 부분으로 들어가면 플랫폼에 크게 의존적이라 간주해도 좋을만큼 큰 차이를 보인다.
2.2.1. SIMD 레지스터
CPU에서 정보를 처리하기 위해서는 반드시 레지스터라고 하는 CPU 내부의 임시 기억장소에 데이터를 적재해야 한다. 일반 범용 레지스터로 EAX, EBX, ECX, EDX 같은 게 있고 특수 용도 레지스터로 ESP, EBP 같은게 있는데 이런 것들이 모두 SISD용 레지스터다. SIMD용으로도 레지스터가 존재하며 여러 데이터를 한번에 처리하기 위해 일반 범용 레지스터보다 용량이 크다. XMM0, XMM1 같은 이름이 붙어있고 CPU가 지원하는 기술이 뭐냐에 따라 이 레지스터도 달라진다. CPU 레지스터 레벨의 SIMD를 SWAR(SIMD Within A Register)라고도 부른다.2.2.2. SIMD 명령어
위의 SIMD 레지스터에 저장된 데이터를 처리하는 어셈블리 명령어이다. 이것 하나만 실행하면 SIMD 레지스터에 저장된 데이터 전부가 처리돼서 또다른 SIMD 레지스터에 결과값이 저장된다. 예를 들어1. add eax, ebx
2. paddd xmm0, xmm1
위 두 명령은 eax += ebx, xmm0 += xmm1으로 서로 같아보인다.
하지만 xmm0 자체가 128비트 SIMD 레지스터이고 paddd 명령어는 128비트 SIMD 레지스터를 32비트 정수 네 개의 배열로 간주하고 연산하라는 뜻이다.[2] 2번 연산을 풀어서 쓰자면
1. add xmm0[ 00.. 31], xmm1[ 00.. 31]
2. add xmm0[ 32.. 63], xmm1[ 32.. 63]
3. add xmm0[ 64.. 95], xmm1[ 64.. 95]
4. add xmm0[ 96..127], xmm1[ 96..127]
와 같이 네 번 반복하는 연산이다.[3] 즉 계산 속도를 이론적으로 4배 끌어올린다.[4]
AVX는 YMM 레지스터를 사용하며 연산 명령어도 다르다. YMM 레지스터는 256비트로 SSE의 XMM 명령 2배. 즉, 8배 빠르게 연산이 가능하다.
AVX-512는 AVX의 확장 명령어 세트이며, 제온 파이 나이츠 랜딩(72코어 288쓰레드의 CPU)과 스카이레이크-SP, 스카이레이크-X부터 적용된 SIMD 명령어 세트이다. 이름대로 512비트로, 이론상 기존 AVX의 2배의 속도 즉, 16배로 연산을 가능하게 한다.
2.2.3. SIMD Intrinsics
위에서 소개한 어셈블리 명령어는 충분히 강력하지만 사용자의 입장에서는 그리 직관적이지는 않다. 게다가 관리하기도 어렵다. 그래서 보다 직관적인 방법으로 어셈블리 명령어를 사용할 수 있도록 도와주는 게 바로 Intrinsics다. C/C++ 계열이 메이저이면서도 하드웨어와 상대적으로 친숙한 언어이므로 Intrinsics는 주로 C나 C++ 형식을 따른다. 경우에 따라 디스어셈블 해보면 낭비되는 Instruction도 있지만, 생산성이나 유지, 보수의 측면에서는 훨씬 뛰어나기 때문에 성능 이슈가 그 미세한 차이가 유의미할 정도로 중요한 게 아니라면 일반적으로는 Intrinsics를 사용한다. 인라인 어셈블리 대비 장점은 32비트나 64비트의 코드패스를 별도로 구분 할 필요가 없으며 컴파일러간 호환성이 유지되며 [5] Loop unrolling 과 같은 추가적인 컴파일러의 최적화가 가능하다.예를 들어 AVX-128 (SSE)를 Intrinsic을 사용하면 다음과 같이 표현할 수 있다.
#!syntax cpp
#include <immintrin.h>
extern void int32x4add(int *src1, int *src2, int *dest)
{
__m128i a = _mm_load_si128((__m128i *)src1); // vmovdqa xmm0, *src1
__m128i b = _mm_load_si128((__m128i *)src2); // vmovdqa xmm1, *src2
__m128i c = _mm_add_epi32(a, b); // vpaddd xmm2, xmm0, xmm1
_mm_store_si128((__m128i *)dest, c); // vmovdqa *dest, xmm2
return; // ret
}
위 코드는 니모닉과의 1:1 매칭을 위해 풀어서 쓴 것으로 실제로는 로드 명령을 생략하는 것이 가능하고 어셈블리의 경우 또한 로드 명령을 한 차례만 사용하는 것이 가능한데, 일반적으로 순수 어셈블리로 작성된 코드에 비해 Intrinsic을 사용한 코드의 경우 컴파일러가 코드 흐름에 따라 최적화가 가능하다는 장점이 있기 때문이다.
그리고 당연하지만 이렇게 Intrinsic 을 사용해 직접 최적화 하는 경우 어셈블리와 마찬가지로 이기종 프로세서와의 호환성이 없다. (ARM Neon != Intel AVX)
2.2.4. 컴파일러의 Auto-Vectorization
현대 컴파일러는 루프가 명확한 경우 자동으로 코드를 SIMD화 하는것이 가능하다.#!syntax cpp
extern void int32x4_add_c(const int *src1, const int *src2, int *dest)
{
const int *a = src1;
const int *b = src2;
int *c = dest;
for (int i = 0; i < 4; ++i)
{
c[i] = a[i] + b[i];
}
return;
}
위 코드의 경우 컴파일러에게 옵션을 주면 자동으로 Vectorization하게 되고 Function prologue를 제외하면 동일한 어셈블리 코드를 생성해 낸다.
또한 인트린식이나 어셈블리를 사용하지 않으므로 플랫폼 의존적이지 않아 코드의 Portability가 유지된다.
그렇지만 단점도 있는데 컴파일러의 자동 최적화는 위 예시와 같이 명확하면서 단순한 루프만 벡터화 하는 것이 가능하고 조금만 더 복잡한 구현을 하는 경우는 컴파일러의 최적화는 기대할 수 없다.
또한 컴파일러는 코드의 컨텍스트를 인식하지 못 하는 관계로 대부분의 코드의 최적화는 컴파일러의 수준이 프로그래머의 수준보다 높지만, SIMD 프로그래밍은 프로그래머의 최적화가 컴파일러의 최적화 보다 우수한 몇 안 되는 케이스다. 실제로 위 스칼라 코드와 명시적 SIMD 코드는 연속된 메모리 공간에서 4바이트의 데이터만 사용한다. (4*4 = 16 == 128bit) 그런데 실제로 빌드 해 보면 Autovec 된 코드의 경우 i가 4가 아닌 경우를 비교하는 코드까지 emit 하는 반면 (GCC -O3 -msse2), 명시적 SIMD 코드는 1:1 로 어셈블리 명령을 생성하므로 스칼라가 사용하는 레지스터를 사용하지 않아 레지스터를 적게 사용하고 더 짧은 코드를 생성해 코드 사이즈가 더 작고 속도도 더 빠르다.
2.2.5. GPU에서의 SIMD
GPU의 영역에서는 SIMD가 당연시되고 있었다. 거기는 애초에 코어가 몇천 개씩 달려있는 다중프로세서 환경이라서 처음부터 SIMD형식으로 프로그램을 만들어왔다. 그 기능을 고스펙 게임과 슈퍼컴퓨터 등에서 범용적으로 사용하기 위해 개발된 기술이 GPGPU. NVIDIA가 2006년에 CUDA를 처음 소개했을 때 멀티스레딩과 결합된 SIMT(Single Instruction, Multiple Thread) 개념을 강조한 바 있다.2.2.6. Web에서의 SIMD
WASM에도 SIMD명령어가 존재한다. 문서128비트 벡터 처리가 가능하다.
#!syntax cpp
#include <wasm_simd128.h>
extern void int32x4add (int *src1, int *src2, int *dest) {
v128_t a = wasm_v128_load(src1); // v128.load a, *src1;
v128_t b = wasm_v128_load(src2); // v128.load b, *src2;
v128_t c = a + b; // i32x4.add c, a, b
wasm_v128_store(dest, c); // v128.store *dest, c
return;
}
[6]2.2.7. SIMD의 한계
한번에 여러 개의 데이터를 묶어서 처리하는 개념이다보니 아무래도 프로그래머가 신경쓸게 많아진다. 예를 들어 데이터 개수가 4 혹은 8로 딱 나눠 떨어지지 않는 경우가 발생하지 않게 구조체에 패딩 바이트를 넣어서 원하는 벡터 크기에 맞춘다거나, malloc을 호출 할 때 벡터 크기의 배수만큼 메모리 할당을 하게 하는 기술을 익혀야 한다.현대 CPU들은 비정렬된 데이터들도 x86의 vlddqu와 같은 오류가 나지 않게 처리할 수 있는 기능들을 제공하고 있으나 비정렬된 페이지 특성상 메모리 접근에 사이클을 좀더 소모하게 되므로 통상적으로 unaligned load 시 5%의 성능 저하가 있다.
또한 코드를 작성할 때, 루프를 예쁘게 작성할 수록(CPU의 캐시 크기를 고려한다거나) 프로그램이 빨라지기에 관련된 지식(CPU 아키텍처 수준)을 빠삭하게 알아야 CPU의 진정한 성능을 끌어낼 수 있다는 것이 단점이다. CPU의 SIMD에 대비되는 GPGPU는 그런거 없이 코드를 작성해도 웬만해선 작성한 알고리즘의 극한까지 성능이 나오는 것에 비하면 확실한 단점. 대신 GPGPU를 남용하게 되면 엄청난 전력 손실에 시달리게 되는 것은 물론이고, 심지어 기대했던 성능 향상이 생각보다 이뤄지지 않을 수도 있으니 주의해야 한다. 예를 들어 모바일 환경에서 GPGPU에 크게 의존하게 되면 전력 소모로 인한 열 발생으로 인해, 기기에 쓰로틀링이 걸려 강제로 성능을 제약하는 모드로 진입하거나 강제 재부팅할 수도 있다.
2.2.8. .Net Framework 지원
Microsoft가 시험판인 최신 JIT을 발표하면서 라이브러리 형태로 SIMD을 사용할 수 있도록 Nuget에서 Microsoft.Bcl.Simd을 배포하고 있다.2.3. MISD
Multiple instruction streams, single data stream.한 번에 데이터 한 개를 여러 명령어로 처리하는 기법. 예를 들어 곱셈을 하고 싶은데 명령어는 SHIFT 연산과 ADD 연산만이 있다고 하면, SHIFT와 ADD를 반복하여 곱셈을 구현할 수 있다. 이것을 소프트웨어 레벨이 아닌 하드웨어(프로세서) 레벨로 구현한 것이 MISD이다. CPU에서 흔히 사용하는 파이프라인 기법이 이에 해당한다. CPU 밖에서 보자면 데이터 한 개를 넣어 결과 한 개를 얻어내므로 SISD와 크게 구별하지 않는 경향이 있다.
2.4. MIMD
Multiple instruction streams, multiple data streams.한 번에 데이터 여러 개를 여러 명령어로 처리하는 기법. SIMD와의 차이점은 SIMD는 여러 데이터를 같은 인스트럭션으로 한꺼번에 처리하는 것이고 MIMD는 여러 데이터를 다른 인스트럭션으로 한꺼번에 처리하는 것이다.[7] 현대의 동시적 멀티스레드 프로그램들은 모두 MIMD라고 볼 수 있다. SPMD와 MPMD는 이 MIMD에서 더욱 세분화된 분류이다.
인텔 제온 파이, 슈퍼스칼라를 지원하는 모든 멀티코어 프로세서, 그리고 현재 거의 모든 슈퍼컴퓨터들은 이 MIMD를 기반으로 작동한다.
2.4.1. SPMD
Single programs, multiple data streams.단일 프로세서가 최소 2개 이상의 프로그램을 실행한다.
2.4.2. MPMD
Multiple programs, multiple data streams.다수의 프로세서가 최소 2개 이상의 프로그램을 실행한다.
이것은 ARM의 ARM big.LITTLE 솔루션, 인텔의 하이브리드 프로세서, 인텔과 AMD의 APU의 AMD64+GPGPU HSA, 소니 플레이스테이션 3의 IBM CELL 프로세서에서 사용된다.
3. 관련 분류
- 펭의 분류 (Feng's Classification) : 비트 및 워드 레벨의 순차적 및 병렬 연산 여부로 구분하지만, 파이프라인을 통한 동시 처리를 설명할 수 없는 단점이 있다.
- WSBS (Word Serial Bit Serial)
- WSBP (Word Serial Bit Parallel)
- WPBS (Word Parallel Bit Serial)
- WPBP (Word Parallel Bit Parallel)
[1] 사실 직역하면 "당황스럽게"가 더 어울리며, 의미 전달도 더 매끄럽다.[2] x86 레지스터 중 r로 시작하는 것들은 64비트, e로 시작하는 것들은 32비트이다.[3] 32비트 이외에 16비트, 8비트 단위 연산도 가능.[4] 물론 실제로는 매우 많은 변수가 있으므로 4배 끌어올린다는 보장이 없다. 예를 들어 명령 자체가 실제로는 실행 시간(클럭 수)이 더 걸리거나 데이터를 읽어 오는데 시간이 더 걸리는 등의 이유로 원래 명령 1개를 실행시키는 것에 비해 시간이 더 걸리는 경우도 있고, 현재의 운영체제는 멀티태스킹으로 여러 프로그램이 번갈아가며 작동하며 프로그램이 계산을 한번만 하고 끝나는 것이 아니므로 이전에 처리한 명령 및 데이터에 의해 CPU의 파이프라인, 캐시 등의 내용이 달라져서 실행 시간이 달라질 수도 있다. 또한 CPU나 칩셋, 메모리, 버스 등등의 구조 및 성능에 의해서도 영향을 받을 수 있다. 결론적으로 주어진 환경 및 개발자의 전략에 따라 천차만별의 결과가 나올 수 있다.[5] 어셈블러는 GCC의 GAS나 MSVC의 MASM, 그 외 FASM, YASM등 다른 어셈블러는 비슷하기만 할 뿐이지 문법이 전부 다르다.[6] 실제로 WASM은 스택 머신 기반이므로 레지스터 번호 또한 스택 오프셋 이어야만 하나 편의상 a,b,c로 대체[7] 간단히 설명하자면 SIMD는 (10, 10, 10, 10)→(2, -4, 5, 3)→(4, 7, 2, 9)와 같은 데이터 입력 스트림에 (+), (-)와 같은 명령어 스트림이 있다면 연산 결과로 (12, 6, 15, 13)→(8, -1, 13, 4)가 나온다. 즉, 여러 데이터에 대해 모두 동일한 연산만을 적용할 수 있다. 반면 MIMD는 (10, 10, 10, 10)→(2, -4, 5, 3)→(4, 7, 2, 9)가 있으면 여기에 (+, -, *, %)→(-, +, %, *)를 적용할 수 있고 결과로 (12, 14, 50, 1)→(8, 13, 0, 9)을 얻을 수 있다. MIMD를 활용하면 병렬로 적용할 수 있는 연산을 모두 다르게 줄 수 있는 것이다. MIMD로 SIMD를 시뮬레이션할 수 있으나 그 역은 불가능하며 개념적으로는 SIMD를 MIMD의 특수한 한가지 형태로 간주할 수 있다.