본문 바로가기

BSMath

SSE2를 이용한 Vector, Matrix 구축

SSE2?

 SSE2는 SIMD 명령어 집합 중에 하나로, 2001년에 처음 공개되었다. 그 후에도 SSE3, 4와 AVX 등의 다양한 명령어 집합이 발표되었다. 그중에서 SSE2를 선택한 가장 큰 이유는 128비트 레지스터와 활용에 필요한 명령어가 있고, 거의 모든 컴퓨터가 지원하는 명령어 집합이기 때문이다. 물론 추후 다른 명령어 집합으로 확장할 수 있게끔 구축하였다. 어떻게 구축하였는지 역시 뒤에 설명되어있다.

 

SIMD 명령어와 Vector, Matrix의 분리

 가장 중요한 부분이다. SIMD 명령어를 분리하면 추후 다른 명령어 집합으로 확장해도 Vector, Matrix에는 영향을 주지 않게 된다. 또한 SIMD 명령어의 최적화를 위해서는 Load, Store를 최대한 줄이고 적재되어있는 상태에서 최대한 많은 것을 처리해야 한다. 이를 위해서는 SIMD 명령어를 Vector를 거칠 필요 없이 사용할 수 있어야 한다.

 이를 분리하고자 UE4의 소스 코드를 분석해보았다. UE4는 SIMD 명령어를 Wrapping 하여 Primitive 명령어로 만들고, Vector나 Matrix 등에서는 이 명령어를 조합하여 구현하였다. BSMath 역시 이 방법을 도입하기로 결정하였다. 그리고 SSE2 명령어는 int와 float의 명령어 집합이 완전히 달라 사용하기 어려움이 있기에 Wrapping 할 때 두 명령어를 똑같게 사용할 수 있게끔 설계하였다.

 

타입, 상수

 앞에서 말했듯이 BSMath는 다룰 타입에 관계없이 똑같이 사용할 수 있어야 한다. 함수는 오버로딩을 통해서 이를 해결할 수 있으나, 타입과 상수는 오버로딩이 불가능해 다른 방법을 찾아야 했다.

 그래서 템플릿을 활용하였다. 우선 타입은 현재 Template Argument로 들어온 타입이 정수인지 실수인지를 통해 타입을 결정하였다. STL의 type traits을 통해 깔끔하게 해결할 수 있었다.

 상수는 Template Argument에 따라서 반환될 타입조차 다르다. 그렇기에 Template Specialization을 이용해 구현하였다. 우선 기본이 될 상수는 선언만 해둔 뒤에, 실제 타입이 int면 int에 맞는 상수, float면 float에 맞는 상수를 정의하여 해결할 수 있었다.

타입과 상수의 구현체

배열의 레지스터 적재

 배열을 레지스터에 적재할 때 배열의 크기에 맞추어 함수의 인자를 조절해야한다. 배열의 크기는 템플릿을 활용해 얻어낼 수 있었다. 그리고 크기에 맞추어 인자를 조절하는 것은 보통은 Template Specialization을 이용해서 구현하지만, C++ 17에 있는 if constexpr를 활용하여 간단하게 구현할 수 있었다.

크기에 따른 레지스터 적재 함수

그 외 함수들

 대부분의 함수들은 단순히 SSE2의 명령어를 Wrapping하여 구현했다. 그러나 명령어가 지원되지 않는 등의 이유로 특수하게 구현된 함수들이 있다. Select, 정수 벡터 곱, 역 제곱근이 그 함수들이다.

 Select는 인자로 들어온 mask를 통해 비트가 1인 부분은 lhs, 0인 부분은 rhs의 비트를 선택해야 한다. 이는 아래의 수식을 통해 구현할 수 있었다. 실제로는 SSE2 명령어로 변환하여 구현하였다.

$$ rhs \oplus (mask \And (lhs \oplus rhs)) $$

 위 수식을 이해하기 위해 1비트만 예시로 계산을 해보자. 그리고 mask는 1과 0에 직접 대입해보면 이해할 수 있다.

mask가 1일 경우, $ rhs \oplus (1 \And (lhs \oplus rhs)) = rhs \oplus lhs \oplus rhs = lhs $가 된다.

mask가 0일 경우, $ rhs \oplus (0 \And (lhs \oplus rhs)) = rhs \oplus 0 = rhs $가 된다.

 

 정수 벡터 곱은 SSE4.1부터 지원되기에 SSE2에서는 다른 방법이 필요하다. 그래도 매 64비트마다 32비트 곱을 해주는 명령어가 존재하기에 그것을 이용하여 구현했다. 아래의 코드가 그 코드이다.

정수 벡터 곱 구현 코드

tmp1에는 0, 2번 요소의 곱이, tmp2에는 1, 3번 요소의 곱이 들어간다. 두 변수를 조합하여 값을 구하였다. 자세한 명령어는 인텔의 명령어 가이드를 참고하길 바란다.

 

 역 제곱근은 rsqrt 명령어가 이미 존재한다. 그러나 이 값은 추측값이기에 더욱 정교한 값을 얻기 위해서는 Newton-Raphson iteration이라는 방법이 필요하다. UE4의 코드를 보니 이를 더욱 정교하게 만든 코드가 있어서 이를 반복문처럼 사용할 수 있게 제작하였다.

역 제곱근의 구현 코드

 

 마지막으로 Vector의 Abs 함수를 소개하고 싶다. 특히 Abs는 정수와 실수의 내부 표현 구조가 다르기에 Overload을 통해 정수 버전과 실수 버전을 따로 구현했다.

 먼저 Abs의 정수 버전을 이해하려면 정수의 음수 표현법인 2의 보수를 알아야 한다. 2의 보수를 구하는 가장 간단한 방법은 모든 비트를 반전 시킨 후 1을 더하는 것이다. 즉 양수면 그대로, 음수면 2의 보수를 반환하면 되는 것이다. 여기서 주의할 점은 if를 사용하지 않는 것이다. if를 사용하면 편하게 구할 수는 있겠지만, 모든 요소를 일일이 분기해야 하기에, SIMD를 이용할 이유가 없다. 아래는 그 코드이다. 핵심은 'VectorLessThan'을 통해 0보다 큰 요소는 모든 비트가 1로 아니면 0으로 채워지게 된다는 것과, Xor 한 마스크가 0이면 그대로, 1이면 전부 반전된다는 것이다.

Int Vector의 Abs 코드

 실수는 음수 표현법이 정수와 다르다. 실수는 맨 처음 비트, 즉 부호부가 0이면 양수, 1이면 음수이다. 그러므로 부호부의 비트만 0으로 설정해주면 된다. 그리고 부호부만으로 부호를 표시하기에 -0이 존재한다. 그러므로 부호부를 제외한 모든 비트를 1로 설정한 다음 AND를 해주면 값을 얻을 수 있다.

Float Vector의 Abs 코드