"a와 b의 32비트마다 단정도(single precision) 실수 원소들마다 AND 연산을 한 결과를 저장하여 리턴한다"
__m128 자료형은 128비트의 크기를 가진다고 했었죠.
128/32 = 4, 그렇다면 위 함수는 단정도 실수인 float 원소 4개를 한번에 비교 연산할 수 있습니다.
그렇다면 또 다른 비교 연산 함수인 andnot은 뭘까요?
Intel에서 제공하는 andnot 함수
Operation에 적혀 있는 내용을 읽어보면 굉장히 독특한 연산임을 눈치채실 수 있습니다.
andnot 함수는 두 피연산자 중 앞의 피연산자에 NOT 연산을 하고나서 b와 AND 연산을 하는 것을 볼 수 있습니다.
andnot 함수가 어떤 결과를 리턴하는지 이해는 되셨을겁니다만..
아마 '도대체 이런 연산은 왜 만든거지? 어디다가 쓰는거야?'라는 생각을 하실 겁니다만...!
잠시 예시를 보기전에 같은지, 혹은 작거나 같은지 등의 부등호 연산을 하는 함수를 먼저 만나보겠습니다.
Intel에서 제공하는 cmpeq 함수
각 값마다 비교해서 같다면 0xffffffff, 다르면 0을 넣고 리턴합니다.
굳이 1이 아니라 0xffffffff를 리턴하는 이유는 무엇일까요?
AND나 OR 연산시에 모든 비트에 값을 대입하기 수월하기 때문입니다.
그렇다면 같을 때의 연산은 이렇다면 같거나 작을 때와 같은 부등호 상황은 어떻게 할까요?
cmplt (compare less than), cmpgt (compare greater than) 등의 함수가 있습니다.
드디어 예시코드를 한번 보겠습니다.
이런 C로 만들어진 코드가 있다고 생각해봅시다.
// p 배열에 from인 값을 to 값으로 변경하는 함수voidChange(float *p, float from, float to, int size){
for (int i = 0; i < size; i++)
if(p[i] == from)
p[i] = to;
}
intmain(){
float buf[4] = { 10, 20, 30, 40 };
Change(buf, 20, 50, 4); // 20을 50으로 변경!
}
위 코드를 SIMD 프로그래밍을 적용해서 만들어야한다라면 어떻게 해야할까요?
물론 저렇게 작은 배열에 하나의 원소만 바꾼다면 직접 바꾸는 것도 나쁘지는 않을 겁니다.
하지만 하나의 __m128 원소값을 접근하여 바꾸는 것은 전체 연산을 하는 것보다 비교적 비용이 비쌉니다..
그때문에 묶어놓은 변수를 풀어해치지 않고 해결하려는 생각을 해야합니다.
저는 이런 방식으로 풀었습니다.
다른 방법들도 있겠지만(cmpneq 등) 위의 함수들을 사용해야한다면 가장 쉬운 방법이라고 생각합니다.
voidChange(float *p, float from, float to, int size){
__m128 vFrom = _mm_set_ps(from, from, from, from);
__m128 vTo = _mm_set_ps(to, to, to, to);
for (int i = 0; i < size; ++i)
{
__m128 vData = _mm_loadu_ps(p + i); // 배열 로드// from과 같은 위치 찾기
__m128 vCmpData = _mm_cmpeq_ps(vData, vFrom);
// from과 같은 값이 아닌 곳의 값 유지
vData = _mm_andnot_ps(vCmpData, vData);
// to가 들어가야할 위치에 to를 넣기
__m128 vToData = _mm_and_ps(vCmpData, vTo);
// 위에서 만든 to만 있는 값과// from과 같지 않은 유지된 값을 더해서 // 최종값 생성
vData = _mm_add_ps(vData, vToData);
// 배열에 저장
_mm_storeu_ps(p + i, vData);
}
}
for 루프 안쪽 코드를 한 줄씩 살펴보겠습니다.
__m128 vData = _mm_loadu_ps(p + i); // 배열 로드
배열 혹은 포인터에서 값을 읽어오는 함수입니다. 그냥 load 함수도 있지만 여기서는 loadu 함수를 사용했습니다.
// from과 같은 위치 찾기
__m128 vCmpData = _mm_cmpeq_ps(vData, vFrom);
from 값(20)이 어느 위치에 있는지 알기 위한 코드입니다.
vData와 vFrom이 같은지 비교하면 vCmpData = (0, 0xffffffff, 0, 0)이 들어 있겠네요.(역순 고려x)
// from과 같은 값이 아닌 곳의 값 유지
vData = _mm_andnot_ps(vCmpData, vData);
위에서 어느 위치에 from값(20)이 들어있는지 찾았으니 from이 아닌 부분의 값을 유지해야겠죠.
이전에 물리엔진을 만들어보겠다고 하고 성능향상에 대해서 고민하다가 발견한적이 있었습니다. 당시 자존감이 낮아서 그랬는지.. 약간의 코드를 보고 너무 어렵다고 생각하고 포기했던 기억이 있습니다. 이번에 공부할 수 있는 좋은 기회를 얻어 정리 겸 포스팅을 계획하고 있습니다.
정말 많은 곳에서 SIMD의 개념을 설명합니다. 그래서 여기서는 간략하게 이야기하려고 합니다.
바로 "한가지 명령으로 많은 수의 데이터를 처리하자"(Single Instruction, Multiple Data)라는 개념입니다.
사실 이게 정의이고 설명의 끝이기도 합니다.
간단한 예시를 들어보겠습니다.
만약 a=10, b=20, c=30, d=40이라는 데이터가 있다고 가정해봅시다.
모든 데이터에 +10을 해주어야하는 상황이 생겼다면 일반적인 프로그래밍 방법은 이와 같을 것입니다.
a = a + 10;
b = b + 10;
c = c + 10;
d = d + 10;
모든 숫자에 직접 +10씩 해주는 방법이죠. 그렇다면 SIMD 프로그래밍에서는 어떨까요?
__m128 x = _mm_set_ps(a,b,c,d);
__m128 ten = _mm_set1_ps(10);
x = _mm_add_ps(x, ten);
맨 처음에 변수 선언부를 제외하면 _mm_add_ps()라는 함수 한번에 해결되는 것을 볼 수 있습니다.
이걸 보고 저는 처음에 이런 생각이 들었습니다.
"오? 한가지 명령으로 다수의 데이터를 처리한다고? 이거 여기도 써보고 저기도 써보고 좋겠다!"
알려주시는 교수님께서 따끔한 충고를 해주셨습니다.
"SIMD를 이용해서 프로그래밍할 수 있는 것은 좋은 무기가 되겠지만,
그 무기를 아무 때서나 사용하면 좋은 코드를 만들 수 없다.
먼저 자료구조와 알고리즘을 단단하게 쌓아올리고나서 SIMD를 적용할 수 있는지 확인해보아야 한다."
프로그램을 만들기 전에(코드를 짜기 전에) 먼저 구조와 알고리즘을 단단하게 보완해야한다는 것입니다.
굉장히 중요한 말이라서 서두에 이렇게 적습니다. 우리 모두 기억합시다!
본격적으로 공부를 시작하면 처음 보는 헤더와 자료형들을 볼 수 있습니다.
#include<xmmintrin.h>intmain(){
__m128 a = _mm_set_ps(1, 2, 3, 4);
__m128 b = _mm_set_ps(5, 6, 7, 8);
__m128 c = _mm_add_ps(a, b);
return0;
}
저도 처음 봤을 때 거부감부터 들었지만, 천천히 보면 그리 어려운 코드는 아닙니다.
#include<xmmintrin.h>
아주 기본적인 헤더입니다. 이후에는 다른 헤더파일들도 사용합니다.
(이후 발전된 헤더를 사용합니다만 여기서는 기본적인 xmmintrin.h을 썼습니다.)
__m128 a = _mm_set_ps(1, 2, 3, 4);
여기서 새로운 친구를 2명이나 만납니다. __m128과 _mm_set_ps()입니다.
__m128은 하나의 자료형이라고 볼 수 있습니다.
xmmintrin.h 안의 __m128을 살펴보았다.
와우! 그 말로만 듣던 union, 공용체를 발견할 수 있습니다.
C언어를 배우면서 보기 힘든 자료형입니다만 이런 곳에서 유용하게 사용되는 것을 볼 수 있습니다.
그리고 여기서 __m128에 float이 4개, unsigned int8이 16개 등등이 들어가는 것을 보고 우리는 __m128에서 "128"이 128 bits를 의미한다는 것을 알 수 있습니다! (128 bits = 16 Bytes = 4(float) * 4 = 8(unsigned int8 = unsigned char) * 16) 이에 대해서는 나중에 더 알아보도록 합시다.
다음으로는 _mm_set_ps()가 있습니다. 이 녀석은 어떤 함수인지 알아보겠습니다.
xmmintrin.h에서의 _mm_set_ps() 선언
우리는 __m128에 float 4개를 넣어서 리턴해주는 것을 알 수 있습니다.
"그럼 밑에 _mm_setr_ps()는 뭐야?"라고 물어보실 수 있습니다.
0번이 뒤인가...
그냥 _mm_set_ps()를 이용하게 되면 4,3,2,1 순으로 저장되는 것을 볼 수 있습니다.
왜 이런 식으로 만들어졌을까요?
바로 인텔 CPU가 리틀 엔디언 방식으로 동작하기 때문입니다.
컴퓨터 구조 시간에 한번쯤 들어보았을 법한 리틀 엔디언, 빅 엔디언 이야기는 여기서 다루지 않겠습니다.