'SSE'에 해당되는 글 3건

  1. 2009.09.07 16바이트 정렬
  2. 2008.06.06 SSE의 오묘함 2
  3. 2008.02.05 xmmintrin.h 2
dev.log2009. 9. 7. 02:39
대략적으로, VC++에서 구조체 멤버의 배치와 관련된 개념은 2가지. #pragma pack, 혹은 컴파일러의 /Zp 옵션으로 지정하는 패킹과, __declspec(align)으로 지정하는 정렬이 있다.

원칙적으로는 SSE명령이나 캐쉬에 적합성을 높이기 위한 메모리 배치는 __declspec(align)으로 하는 것이 맞다. 그런데 문제는 __declspec(align) 속성을 가진 벡터 객체, 혹은 이를 포함하는 클래스의 객체가 STL 컨테이너에 들어갈 수 없다는 것이다. 이는 다음 두가지 사실에서 기인한다.
1. VC++컴파일러는 __declspec(align) 속성을 가진 객체를 함수의 밸류타입 인자로 넘길 수 없게 구현돼 있다.
2. STL컨테이너들의 기본생성자는 초기화 과정에서 resize메소드를 호출하는데, 표준에 의하면 resize 메소드는 밸류 타입으로 인자를 받도록 되어 있다.
그러한 고로, 주소 정렬이 포함돼 있는 클래스의 객체는 STL컨테이너에 넣을 수가 없다는 당혹스러운 결과에 직면하게 된다.

전에 회사에서 쓰던 엔진은 vector3 클래스의 선언부를 #pragma pack(16)으로 감싸놓고 할당자만 재정의해서 쓰고 있었다. 요 벡터 클래스가 심심하면 뻗을 때가 있었는데, 대개의 경우는 객체의 주소가 16바이트 정렬이 안된 채로 생성이 되어 SSE 내장 함수에서 뻗어버리는 것이었다. 이때의 경험으로 미루어 안될거 같다는 생각을 하면서도, 집에서 놀던 차에 잉여력을 활용하여 둘 간의 차이점에 대해서 삽질을 좀 해봤다.

사용한 선언은,
#pragma pack( push, 16 )
struct test_pack
{
    float x;
    float y;
    float z;
};
#pragma pack(pop)

struct __declspec(align(16)) test_align
{
    float x;
    float y;
    float z;
};
이렇게 해놓고서 배열을 선언해서 비교해 보았다.
    FILE* out = fopen( "align_pack.txt", "w");
#define LOG_OFFSET(tag,member) fprintf( out, "offset of "  #tag "::" #member " = %d\n", offsetof( tag, member ) );
#define LOG_SIZE(tag) fprintf( out, "size of "  #tag " = %d\n", sizeof( tag ) );
#define LOG_ADDR(pointer) fprintf( out, "address of "  #pointer " = 0x%x\n", pointer );
    LOG_OFFSET( test_pack, x );
    LOG_OFFSET( test_pack, y );
    LOG_OFFSET( test_pack, z );
    LOG_SIZE( test_pack );

    test_pack tp[3];
    LOG_ADDR( &tp[0] );
    LOG_ADDR( &tp[1] );
    LOG_ADDR( &tp[2] );

    LOG_OFFSET( test_align, x );
    LOG_OFFSET( test_align, y );
    LOG_OFFSET( test_align, z );
    LOG_SIZE( test_align );

    test_align ta[3];
    LOG_ADDR( &ta[0] );
    LOG_ADDR( &ta[1] );
    LOG_ADDR( &ta[2] );

    fclose( out );
결과는,
offset of test_pack::x = 0
offset of test_pack::y = 4
offset of test_pack::z = 8
size of test_pack = 12
address of &tp[0] = 0x12fecc
address of &tp[1] = 0x12fed8
address of &tp[2] = 0x12fee4
offset of test_align::x = 0
offset of test_align::y = 4
offset of test_align::z = 8
size of test_align = 16
address of &ta[0] = 0x12fe90
address of &ta[1] = 0x12fea0
address of &ta[2] = 0x12feb0


패킹으로 선언된 test_pack의 경우는 주소 자체가 16바이트 정렬이 안되기 때문에, 패딩 바이트를 주더라도 SSE나 캐쉬라인에 맞추기 위한 목적으로는 쓰일 수 없다는 것이 분명해졌다. 구조체 멤버를 다른 타입으로 (char, short 등) 몇가지 더 추가하면서 알아낸 바로는, #pragma pack으로 지정하는 패킹은 멤버들의 구조체 내에서의 옵셋에만 영향을 주고, 구조체 자체의 배치에는 영향을 끼치지 않는다는 것.

vector3 클래스를 생성 영역에 따라 분리하는 것도 방법. 힙에서 생성할 객체들은 할당자에 의해 정렬이 되는 클래스로 분리하고, 스택이나 정적 데이터 영역에 생성할 객체들은 __declspec(align)으로 분리한다는 것인데... 이럴 경우에도 STL의 메소드가 내부적으로 사용하는 임시 객체가 정렬이 안이루어지면 뻗을 수밖에 없다.

이제 vector3 클래스를 SSE를 활용하면서 컨테이너에 넣기 위한 방법은 1가지밖에 없다는 결론이 도출된다. 바로 사용하고자 하는 모든 컨테이너 클래스의 메소드를 밸류 타입으로 인자를 받지 않도록 상수 참조의 형태로 바꿔서 재정의한다는 것. 대부분의 STL컨테이너를 커스터마이징해야 한다는 얘기. 이건 삽질도 보통 삽질이 아닌데.....


Posted by uhm
dev.log2008. 6. 6. 09:11
요즘은 SSE 수학 클래스 만들기에 열을 올리고 있다. 아직 행렬 클래스는 (노느라) 바뻐서 못만들고는 있지만 벡터 클래스는 요모조모 손봐가면서 테스트중. 전에 쓴 대로, SSE는 확실히 성능향상에 도움은 된다. 그런데 그게 좀 오묘하다. 명령의 순서가 희한하게 걸리면 오히려 속도가 느려진다.

다음은 주어진 연산순서를 각각 1000만번씩 반복했을 때 걸린 시간을 10번씩 측정한 결과이다. (CPU - Core2Duo E6600, Memory - DDR2 PC2-5300 6GB, 32bit application on 64bit Windows)

연산순서 기본구현
SSE구현
cross->length->dot 0.307732
0.307663
0.307266
0.307756
0.308705
0.307553
0.307968
0.307418
0.307018
0.307521
0.173833
0.173594
0.17392
0.17435
0.173717
0.17394
0.173853
0.173623
0.173899
0.173708
cross->dot->length 0.308549
0.307164
0.307153
0.307491
0.307836
0.307346
0.307413
0.308006
0.307355
0.307362
0.366581
0.366742
0.366583
0.367308
0.366557
0.366436
0.36658
0.366613
0.36664
0.366343

첫번째 결과는 당연히 SSE가 빠르겠거니.. 하는 추정에 부합하는데, 두번째 결과를 보면 연산 순서만 바뀌었을 뿐인데 SSE구현쪽의 결과가 훨씬 더 느리다. 반면, 기본 구현은 연산 순서랑 관계없이 거의 일정한 속도를 보장해 준다. 내 생각으로는 SSE명령을 수행 후에 메모리에 연산 결과를 쓰면서 레지스터중 일부를 초기화하고 메모리에 억세스하는 비용이 크기 때문일거 같은데, 어셈코드를 봐도 사실 정확한 건 잘 모르겠다. (공부하자)

들쭉날쭉한 속도를 개선하기 위해 요모조모로 테스트해 보다가 내적의 문제점을 깨달았다. 내적은, SSE로 구현할 때 애로사항이 많다. SSE의 기본은 (사실은 SSE가 아니라 그 근간인 SIMD라고 해야 맞지만) 부동소수 4개를 각기 따로따로 별도의 데이터 패쓰를 거쳐서 처리한다는, 일종의 data parallelism 인데, 내적은 따로따로 처리되어야 하는 데이터 3개, 즉 xyz좌표를 필수적으로 한군데에서 처리하도록 해야 한다. SSE의 설계 이념과 매우 동떨어진 연산이 될 수 밖에 없다. 물론 SSE3에서는 패킹된 데이터4개를 하나로 더해주는 명령이 추가되었지만, 아직 SSE3를 지원하지 않는 CPU가 많이 사용되고 있으므로 적용하기는 약간 무리다. 따라서 지금으로썬 내적의 SSE 구현이 병목이므로 내적을 SSE를 쓰지 않은 일반 구현으로 바꾸는 것이 최선일듯.

SSE로 구현한 벡터클래스의 내적을 일반구현으로 바꿔서 테스트해봤다.다음이 그 결과다.
연산순서 기본구현 SSE구현 
cross->length->dot 0.31281
0.328362
0.326691
0.312755
0.312775
0.325598
0.318856
0.31096
0.325269
0.308923
0.163027
0.1661
0.163232
0.163836
0.163102
0.162626
0.163001
0.169913
0.163181
0.162604
cross->dot->length 0.307485
0.307058
0.306469
0.307107
0.308057
0.306862
0.308106
0.307052
0.307446
0.306855
0.161829
0.161945
0.162188
0.16885
0.163519
0.161771
0.161899
0.162052
0.16204
0.162045
결과가 아주 이쁘장하다. 흡족하다. 저정도면 어느 상황에서도 비교적 빠른 속도를 보장해주는 벡터클래스가 되지 않을까.

그래서 결과적으로 완성된 코드는 다음과 같다.
#if !defined( __VECTOR3_H__ )
#define __VECTOR3_H__

#include <cmath>


#pragma intrinsic( sqrt )
// Internal Combustion Engine
namespace ice
{
template <class scalar_t, bool use_implicit = true > struct vector3_t
{
    typedef scalar_t scalar_type;
    scalar_type x, y, z;

public:
    vector3_t() {}
    vector3_t( scalar_type a, scalar_type b, scalar_type c ) : x(a), y(b), z(c) {}
    vector3_t( const scalar_type* xyz ) : x(xyz[0]), y(xyz[1]), z(xyz[2]) {}

    scalar_type operator[] ( int i ) const { return *(&x + i); }
    scalar_type& operator[] ( int i ) { return *(&x +i); }

    vector3_t& operator+= ( const vector3_t& v )
    {
        x += v.x; y += v.y; z += v.z;
        return *this;
    }
    vector3_t operator+ ( const vector3_t& v ) const
    {
        return vector3( x+v.x, y+v.y, z+v.z );
    }
    vector3_t& operator-= ( const vector3_t& v )
    {
        x -= v.x; y -= v.y; z -= v.z;
        return *this;
    }
    vector3_t operator- ( const vector3_t& v ) const
    {
        return vector3( x-v.x, y-v.y, z-v.z );
    }
    vector3_t& operator*= ( scalar_type s )
    {
        x *= s; y *= s; z *= s;
        return *this;
    }
    vector3_t operator* ( scalar_type s ) const
    {
        return vector3( x*s, y*s, z*s );
    }
    vector3_t& operator/= ( scalar_type s )
    {
        x /= s; y /= s; z /= s;
        return *this;
    }
    vector3_t operator/ ( scalar_type s ) const
    {
        return vector3( x/s, y/s, z/s );
    }

    static scalar_type dot( const vector3_t& v1, const vector3_t& v2 )
    {
        return v1.x*v2.x + v1.y*v2.y + v1.z*v2.z;
    }
    static vector3_t cross( const vector3_t& v1, const vector3_t& v2 )
    {
        return vector3_t(  v1.y * v2.z - v1.z * v2.y,
                                v1.z * v2.x - v1.x * v2.z,
                                v1.x * v2.y - v1.y * v2.x );
    }

    scalar_type squared() const { return x*x + y*y + z*z; }
    scalar_type length() const { return std::sqrt(squared()); }
    void normalize()
    {
        *this /= length();
    }
    vector3_t unit() const
    {
        return *this/length();
    }
};
}

#if defined( _USE_SSE_ )
#pragma pack( push, 16 )

#include <intrin.h>
#define ALIGN_MMX __declspec(align(16))

namespace ice
{

template<> struct ALIGN_MMX vector3_t<float, false>
{
    typedef float scalar_type;
    scalar_type x, y, z;

    vector3_t() {}
    vector3_t( scalar_type _a, scalar_type _b, scalar_type _c )
    {
        _mm_store_ps( &x, _mm_set_ps( 0, _c, _b, _a ) );
    }
    vector3_t( const scalar_type* xyz )
    {
        _mm_store_ps( &x, _mm_load_ps( xyz ) );
    }
    vector3_t( const vector3_t& v )
    {
        _mm_store_ps( &x, _mm_set_ps( 0, v.z, v.y, v.x ) );
    }

    scalar_type operator[] ( int i ) const { return *(&x + i); }
    scalar_type& operator[] ( int i ) { return *(&x +i); }

    vector3_t& operator+= ( const vector3_t& v )
    {
        _mm_store_ps( &x, _mm_add_ps( _mm_load_ps(&x), _mm_load_ps(&v.x) ) );
        return *this;
    }
    vector3_t operator+ ( const vector3_t& v ) const
    {
        return vector3_t( _mm_add_ps( _mm_load_ps(&x), _mm_load_ps(&v.x) ) );
    }
    vector3_t& operator-= ( const vector3_t& v )
    {
        _mm_store_ps( &x, _mm_sub_ps( _mm_load_ps(&x), _mm_load_ps(&v.x) ) );
        return *this;
    }
    vector3_t operator- ( const vector3_t& v ) const
    {
        return vector3_t( _mm_sub_ps( _mm_load_ps(&x), _mm_load_ps(&v.x) ) );
    }
    vector3_t& operator*= ( scalar_type s )
    {
        _mm_store_ps( &x, _mm_mul_ps( _mm_load_ps(&x), _mm_set1_ps( s ) ) );
        return *this;
    }
    vector3_t operator* ( scalar_type s ) const
    {
        return vector3_t( _mm_mul_ps( _mm_load_ps(&x), _mm_set1_ps( s ) ) );
    }
    vector3_t& operator/= ( scalar_type s )
    {
        _mm_store_ps( &x, _mm_div_ps( _mm_load_ps(&x), _mm_set1_ps( s ) ) );
        return *this;
    }
    vector3_t operator/ ( scalar_type s ) const
    {
        return vector3_t( _mm_div_ps( _mm_load_ps(&x), _mm_set1_ps( s ) ) );
    }

    static scalar_type dot( const vector3_t& v1, const vector3_t& v2 )
    {
        return v1.x*v2.x + v1.y*v2.y + v1.z*v2.z;
    }
    static vector3_t cross( const vector3_t& v1, const vector3_t& v2 )
    {
        //v1.y * v2.z - v1.z * v2.y
        //v1.z * v2.x - v1.x * v2.z
        //v1.x * v2.y - v1.y * v2.x
        __m128 a = _mm_shuffle_ps( *(__m128*)&v1, *(__m128*)&v1,
_MM_SHUFFLE( 3, 1, 2, 0 ) );
        __m128 c = _mm_shuffle_ps( *(__m128*)&v2, *(__m128*)&v2,
_MM_SHUFFLE( 3, 2, 0, 1 ) );
        __m128 b = _mm_shuffle_ps( *(__m128*)&v1, *(__m128*)&v1,
_MM_SHUFFLE( 3, 2, 0, 1 ) );
        __m128 d = _mm_shuffle_ps( *(__m128*)&v2, *(__m128*)&v2,
_MM_SHUFFLE( 3, 1, 2, 0 ) );
        return vector3_t( _mm_sub_ps( _mm_mul_ps( a, c ), _mm_mul_ps(b,d) ) );
    }

    scalar_type squared() const { return dot(*this,*this); }
    scalar_type length()  const
    {
        return _mm_sqrt_ss( _mm_set_ss( squared() ) ).m128_f32[0];
    }
    void normalize()
    {
        _mm_store_ps( &x, _mm_div_ps( _mm_load_ps(&x),
_mm_sqrt_ps( _mm_set1_ps( squared() ) ) ) );
    }
    vector3_t unit() const
    {
        return vector3_t( _mm_div_ps( _mm_load_ps(&x),
_mm_sqrt_ps( _mm_set1_ps( squared() ) ) ) );
    }

private:
    vector3_t( __m128 v )
    {
        _mm_store_ps( &x, v );
    }
};

typedef vector3_t<float, false>  vector3;
}
#pragma pack ( pop )
#else

namespace ice
{
typedef vector3_t<float>  vector3;
}

#endif // #if defined( _USE_SSE_ )

#endif // #if defined( _VECTOR3_H_ )

Posted by uhm
dev.log2008. 2. 5. 10:50

난 원래 로우레벨 최적화는 별로 좋아하지 않는다 -_-

로우레벨 최적화를 하면 코드를 알아보기 힘들어지게 되기 때문이고.. 알아볼 수 있게 만든답시고

주석을 덕지덕지 달거나, 혹은 함수 안에서 최적화 레벨에 따라 '알아보기 좋은 코드'와 '최적화한 코드'를 #ifdef 따위로 갈라놓아야 하는 불상사가 생기기도 한다.

그래서 로우레벨 최적화는 컴파일러에게 맡기자;는 사상이었는데, 지난주에 회사 엔진 개발팀에서 받아온 코드를 성능측정해보고 깜짝놀랐다. SSE 내장(intrisic) 함수를 쓴 벡터 정규화 코드가 컴파일러의 SSE2 최적화 코드보다 2배나(!) 빨랐던 것이다.

그래서, SSE 내장함수를 보기 좋게.. 코드에 넣는 방법을 모색해 봤는데, 요구조건은 다음 2가지이다.

  1. 코드가 의도하고자 하는 의미에 대한 명료하고 직관적인 표현이 들어갈 것.
  2. 하드웨어 의존적인 코드를 프로그래밍 단위별로 분할 할 수 있을 것.

 1번 요구조건은 이 얘기이다. 코드에

// (1)
_mm_store_ps( &x, _mm_add_ps( _mm_load_ps(&x), _mm_load_ps(&v.x) ) )

// (2)
x += rhs.x;
y += rhs.y;
z += rhs.z;

(1)과 (2)중에서 어느 쪽이 더 우아한 지는 자명하다. 일단 (1)번은 딱 보기에도 뭔소린지 못 알아먹겠다. -_- (2)번은 뭔 소린지는 중학교만 제대로 나온 사람이라면 다 알아먹을 수 있다. (2)번 완승. 문제는, (2)번이 (1)번만큼만 속도가 나와주면 좋은데, (1)번에 비해서 조홀라 느리다는 거다.

그래서 보통은 다음과 같이 한다. SSE 함수들은 VC에서 제공하는 헤더이므로, _WINDOWS_매크로 같은 걸로 감싸서 VC에서만 컴파일 되도록 만드는 거다.

vector3& operator+= ( const vector3& rhs )
{
#if !defined( _WINDOWS_ )
    x += rhs.x;
    y += rhs.y;
    z += rhs.z;
#else
    _mm_store_ps( &x, _mm_add_ps( _mm_load_ps(&x), _mm_load_ps(&v.x) ) )
#endif
    return *this;
}

 근데 한 함수 안에서 저렇게 코드를 갈라 놓는 건 심히 보기 좋지 못하다. 이건 2번 요구조건과 걸리는데, 함수야말로 가장 기본적인 프로그래밍 단위인데, 그걸 저렇게 내부에서 갈라 놓는건 아름답다고는 할 수 없는 코드이다 -_-

 

그래서 궁여지책으로 생각해 낸 게 템플릿의 명시적 특수화를 이용한 구현이다. 나는 보통 벡터는 템플릿으로 구현해 놓기 때문에, SSE가 빠르게 처리해 줄 수 있는 float 타입의 벡터만 SSE 내장 함수로 특수화하면 되는 문제.

#pragma pack( push, 16 )
template <typename scalar_t> struct vector3
{
    typedef scalar_t scalar_type;
    scalar_type x, y, z;

public:
    vector3() {}
    vector3( scalar_type a, scalar_type b, scalar_type c ) : x(a), y(b), z(c) {}

};

#if defined( _WINDOWS_ )|
#include <xmmintrin.h>

template<> struct vector3<float>
{
    typedef float scalar_type;
    scalar_type x, y, z;
private: scalar_type _; // 128비트 정렬을 위한 숨은 멤버.
                                // 사실 #pragma pack이 있으므로 없어도 된다.
public:
    vector3() {}
    vector3( scalar_type a, scalar_type b, scalar_type c )
    {
        _mm_store_ps( &x, _mm_set_ps( 0, c, b, a ) );
    }
};

#endif
#pragma pack( pop )

뭐 이런 식. 윈도우일때만 SSE 내장 함수를 사용하고, 원래의 직관적 의미도 유지할 수 있는 방안인 거 같다.

Posted by uhm