dev.log2014. 9. 10. 12:27

C++ 람다가 잘 이해가 안간다면, 다음과 같은 코드를 생각해 보면 된다.


void function()

{

struct functor

{

    ClassX& x;
    int& i;

    functor( ClassX& a, int& b ) : x(a), i(b) {}
    void operator() ( ValueType v ) {  ..... /* code using x & i */ }

};


ClassX x;

int i;

functor f( x, i );


ValueType a;

f(a);

}


위 코드와 '거의' 동등한 일을 다음 코드를 쓰면 컴파일러가 알아서 만들어 준다는 거다.


void function()

{

ClassX x;

int i;

auto f = [&x,&i](ValueType v){ .... /* code using x&i */ };


ValueType a;

f(a);

}


Posted by uhm
dev.log2013. 10. 21. 21:32

part 1에서, 언리얼 리소스 로딩은 재귀적 알고리듬으로 짜여 있다고 한 바 있다. 재귀적 알고리듬 자체는 문제가 아니지만, 내가 할 작업에 있어서는 저런 큰 덩어리의 작업이 재귀적으로 짜여 있으면 락을 큰 단위로만 걸어야 하므로 문제가 있다. 게다가 읽기 작업이 언제 어디서 몇회나 일어날 지 모르므로, 이에 대한 컨트롤을 위해서 재귀적 알고리듬을 반복적 알고리듬으로 바꿔야만 했다.


재귀적 알고리듬을 반복적 알고리듬으로 바꾸는 건, 대학교 2학년때 배우는 알고리듬 수업만 해도 충분하다. 펑션 파라미터를 모아서 스택을 구성하고, 재귀 호출이 일어날 때 스택에 파라미터를 push하고 루프를 돌리면 된다. 이 과정을 슈도 코드로 써보면 이렇게 된다.


load( name )

{

stack.push( name )

while !stack.empty()

{

o = create( stack.top );

for each property in o

read( property );       // 디스크 읽기!!!

if ( property is a object )

push( property.name ); // 재귀호출이 일어나는 곳은 스택으로 대체


if ( stack.top == o.name )

o.postload();

stack.pop();

}

}


그리고 이 수도코드에 크리티컬 섹션으로 락을 걸면 실제 프로젝트에 들어가 있는 코드와 대충 비슷하다. 이제 문제는 실제 읽기 작업을 크리티컬 섹션 밖에서 할 수 있도록 빼는 것이다. 이 작업의 핵심 아이디어는, 실제 파일에서 읽어들인 내용물을 메모리 버퍼에 담아놓고 오브젝트 로딩 과정에서는 디스크보다는 속도가 훨씬 빠른 메모리에서 값을 가져오도록 하는 것이다.
(load_buffer는 락 바깥에 있어야 한다는 점에 주의!!!)


load( name )

{

stack.push( name )

while !stack.empty()

{

buffer = load_buffer( stack.top ); // 디스크 읽기!!

lock();

o = create( stack.top );

for each property in o

buffer.read( property ); // 메모리 복사

if ( property is a object )

push( property.name );


if ( stack.top() == o.name )

o.postload();

stack.pop();

unlock();

}

}


이제 남은 문제는 버퍼를 어디에서 읽어야 하는지를 파고들어가서 그걸 내손으로 구현하는 일이다.


언리얼 엔진에서 쓰는 패키지 파일을 설계한 아저씨는 (아마도 팀 스위니겠지만) 여러 면에서 DLL에 대한 은유를 많이 사용했다. 각 패키지 안에 들어있는 모든 오브젝트는 외부 패키지에서 접근할 수 있도록 export 테이블에서 노출되고, 또한 다른 패키지에서 참조하는 오브젝트는 import 테이블을 통해 관리한다. 이들 오브젝트의 레퍼런스를 resolve해주는 과정을 link라고 부르는 것은 덤.


언리얼 패키지에서 오브젝트 하나를 로딩하는 절차는 UDN에 대충 나와 있긴 하고, 소스 코드를 보면 누구나 알 수 있긴 하지만, 정리해 보면,


(시작)

1> 오브젝트 이름에서 패키지 이름 추출

2> 패키지 summary 읽기 

3> 패키지 export 테이블 읽기 

4> 패키지 import 테이블 읽기

5> 패키지 export 테이블에서 오브젝트 검색하고 대상 오브젝트 생성

6> export 테이블에서 지정된 대로 오브젝트 읽기

7> 읽는 동안 패키지 내부의 오브젝트 참조가 발생하면 export 테이블에서 검색

8> 읽는 동안 외부 패키지의 오브젝트 참조가 발생나면 import 테이블에서 검색

9> 모든 참조가 resolve되면 종료.


part 1에서 생성 -> 읽기 -> 정리의 과정은 위에서 5~9 사이의 과정이라고 할 수 있다. 물론, 2~4 사이의 과정은 패키지가 이미 열려져 있는 상태라면 생략되도록 구성되어 있다. (에픽 아저씨들이 바보는 아니니까) 이 절차를 정리해 본 것은, 패키지 summary와 export/import  테이블은 언리얼 리소스 로딩 과정을 개조하기 위해 필수적으로 건드려야 하는 개념이기 때문이다.


summary에는 각 오브젝트의 정보보다는, 패키지 자체에 대한 정보가 있지만, 여기서 가장 중요한 것은 export/import 테이블의 옵셋과 사이즈를 알려준다는 점이다. export 테이블에는 패키지 내에 저장돼 있는 모든 오브젝트의 옵셋과 사이즈, 이름, 클래스 등에 대한 정보가 담겨 있다. 오브젝트를 로딩하기 위한 핵심 정보이다. import 테이블은 전체적으로는 export 테이블과 비슷한데, 차이점이라면 외부 패키지의 오브젝트에 대한 참조이기 때문에 패키지 이름이 추가돼 있다는 정도?


이제 읽어야 할 것에 대한 정보가 어디 들어있는지를 알았으므로, 이 지식을 코드에 반영하면 된다.


load_buffer( name )

{

if ( name is internal )

e = export.find( name )

else

e = import.find( name )


buffer = create( e.size );

read( buffer, e.offset, e.size );

return buffer;

}


여기까지 하고, load 펑션을 백그라운드 쓰레드로 돌리면, 디스크 읽기로 인해 메인 쓰레드가 멈추는 일 없이, 실제 공유자원인 전역 오브젝트 배열에 대한 락에 필요한 시간보다 크게 길지 않게 락을 잡고 작업을 병렬화 할 수 있을 것이고, 실제로 동작하긴 했다.


그런데 무수히 많은 크래쉬와 데드락이 일어났다.


- 계속



Posted by uhm
dev.log2013. 2. 22. 18:55

우연인지 몰라도 내가 다니는 회사마나 나한테 파일 및 리소스 로딩을 좀 손봐달라는 요구를 한다. 내가 파일을 잘 읽게 생겼나?  여튼, 언리얼 엔진에서만 두번째로 백그라운드 로딩을 만드는 일이 나한테 떨어졌다.


언리얼 리소스 로딩 시스템을 첫번째로 손봤던 것은 G사에 다닐 때의 일로, 그때는 MMORPG를 만들고 있었기 때문에, 요구사항이 그렇게 빡세지 않았다. "옆에서 캐릭터 스폰될때 막 1,2초씩 끊기지 않게 해주세요" 정도? 즉, Mutually Exclusive한 작업을 해야 할 때 그 1/10인 100ms 정도는 락을 잡고 있어도 무방하다는 뜻이고, 100ms정도면 많은 일을 할 수 있는 시간이다. 이 회사에서 언리얼 로딩 시스템을 손봄으로써 두번째 손을 대게 된 것인데, 이번 회사에서 만들고 있는 것은 FPS이고, 여기서의 요구사항은 "다른 플레이어가 난입할 때 프레임 저하가 안생기게 해주세요."였다. 이말인 즉슨, 90fps로 게임을 하는 것을 기준으로, 10ms 길이의 락도 길다는 것이다. OMG...


언리얼의 로딩 과정을 대략적으로 표현하면 이렇다.


load( name )

{

o = create( name );        // 생성

for each property in o

   read( property );         // 읽기

postload();                    // 정리

}


여기서 가장 오래 걸리는 작업은 대부분의 경우 (당연히) 읽기다. 시간에 따른 바의 길이로 표현하면 이런식.

 A생성

A읽기 

A정리 



그런데 언리얼의 리소스 패키지 관리 구조는 내가 "고구마줄기"라고 평하는 디펜던시 그래프에 기반한 구조다. 즉, 뭐 하나 로딩하려면 그놈이 의존하고 있는 것들을 따라가면서 줄줄이 로딩하는 구조다.

캐릭터를 하나 로딩한다고 치자. 당연히 캐릭터의 메쉬가 있어야 겠고, 메쉬에는 매터리얼이 붙어 있을 테고, 매터리얼에는 다수의 텍스쳐가 붙어 있고... 그뿐인가? 캐릭터 애니메이션도 불러 올라 치면 애니메이션에는 노티파이도 붙어 있고, 노티파이에서는 파티클도 뿌려대고, 사운드도 틀어주고, 파티클에는 메쉬를 뿌려주는 놈들이 있고 그 메쉬에는 또 매터리얼이 붙어있고.... 등등.

게다가 매터리얼에는 셰이더를 구성하는 매터리얼 표현식이, 애니메이션에는 각각의 애니메이션 시퀀스가, 사운드에는 개별 사운드 노드가 일일이 다 별개의 객체로 들어 있기 때문에 뭐 하나 로딩할라 치면 백여개의 서브 오브젝트가 생성되고, 로딩되어야 하는 실정.


그러니까, 이 고구마 줄기를 위의 슈도 코드에 반영하면, 이렇게 된다.


load( name )

{

o = create( name );

for each property in o

   read( property );

   if ( property is a object )

        load( property.name );

postload();

}


재귀적 구조 때문에 실제 타임라인은 이렇게 된다.

 A생성

A읽기 

A중단 

.... 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 B생성

B읽기 

B중단 

.... 

.... 

 B읽기

 B중단

.... 

 

 

 

 

.... 

 B읽기

 

 

 

 

C생성

C읽기 

C정리 

 

 D생성

 D읽기

D중단 

..... 

.... 

 D읽기

 D정리

 

 

 

 

 

 

 

 

 

 

 

 E생성

E읽기 

E정리 

 

 

 


이런 단계를 거쳐도 아직 A가 완료되려면 멀었다 ....


위 그림에서 '읽기' 단계의 길이가 좀 짧게 그려진 감이 있는데, 실제로는 모든 '읽기' 단계는 10~30배 가량 길다고 봐야 한다.  물론, 이런 오퍼레이션과 메인 쓰레드의 동작이 concurrent하게 진행될 수 있다면 아무 문제가 없지만, 불행히도 회사에서 쓰고 있는 언리얼 엔진은 모든 오브젝트를 하나의 거대한 전역 배열에 담아두고 사용하기 때문에, 그 배열에 변경을 가하고자 할 때에는 메인 쓰레드에 락을 걸고 붙잡고 있어야 한다. 게다가 이론상 '생성' 단계가 끝나면 전역 배열의 락은 풀어도 되지만, 우리 게임은 불행한 역사적 사건에 의해 '정리'단계가 끝날 때 까지 락을 잡고 있어야만 하는 상황이었다. 이래서는 뭐 하나 로딩하고자 할 때 필요한 시간이 10ms가 될지 100ms가 될지 아무런 보장도 할 수 없다.

G사에서 처음 로딩을 손볼 때에는 이런 구조를 그냥 놔 둬도 시간 제약이 좀 널럴한 편이어서 그냥저냥 쓸만 했는데, 시간 제약이 빡빡한 상황에서 그대로 쓸 수는 없다.

결국 시간을 잡아먹는 것은 '읽기' 단계인데, 이 '읽기' 단계에서 디스크에서 읽어올 내용을 모아서 한번에 처리해서 메모리에 담아 두었다가, 실제 '읽기' 단계에서 메모리 복사로 대체하면 필요한 락의 길이를 대폭 줄일 수 있다는 것이 내가 고친 내용의 핵심이다.

그런데 문제가 하나 있다. 디스크의 어디에서 무엇을 읽어올 지는 오브젝트를 하나하나 까보기 전에는 모른다는 점.

 - 계속

Posted by uhm
dev.log2011. 10. 25. 15:34
최근 1~2년간 내가 몸담고 있는 게임에 크래쉬가 부쩍 늘었는데, 그 속을 들여다보면 절반 이상이 메모리 부족이다. 이 문제는 시스템 사양을 올려도 해결되지가 않는다. 제아무리 시스템 메모리가 넘쳐나는 세상이라고 해도, 아직 32bit 윈도우를 쓰는 사람도 많다. 그래서 게임은 32bit 프로세스로 돌아가게 만들어야 하고, 그렇게 하면 프로세스가 할당받을 수 있는 유저 메모리는 2GB가 한계이기 때문이다. 실제로는 스택 영역도 있고, 단편화나 뭐 이런저런 문제로 진짜로 쓸 수 있는 메모리는 2기가가 안되기 마련.

문제는 게임의 수명이 길어질수록 컨텐츠가 늘어나게 되고 그에 따라 사용하는 리소스의 양도 많아진다는 것.

어느 게임이든 그럿지만, 개발 당시에는 어떻게든 출시하는게 목표였을 것이기 때문에, 일단 되는 방향으로 개발해야만 했을 거다(그당시의 상황을 나는 모른다). 이렇다할 컨텐츠가 많이 없었기 때문에 게임에 포함된 모든 리소스를 메모리에 올려놔도 사용하는 것만 메모리를 할당하는 것과 몇MB차이가 안났을 게다. 그래서 의도인지 아닌지는 모르겠으나, 게임에 쓰이는 모든 리소스가 게임 구동시에 메모리를 차지하도록 구성되어 있었다. (두둥)
아마도 짐작컨데, 이런 시나리오였을 거다. FPS게임이 있고, 출시 당시에 총기를 대략 20종 준비해 놓고 출시했다고 가정해 보자. 한 방에서 게임하는 플레이어가 대충 8:8이라고 치고 최악의 경우를 상정하면 16명이 16종의 총기를 골고루 들고 나오는 건데, 게임 구동시 모든 총기를 다 로딩해 놔도 기껏해야 4종의 총기 리소스에 해당하는 메모리만 추가 부담하면 된다. 총기 하나에 5~6MB를 차지한다고 치면 껏해야 20MB. 이마저도 게임에 플레이어가 들어오고 나가고를 몇번 하다 보면 어차피 다 로딩되어야 하는 경우도 심심치 않았을 거다. 게다가 게임 구동시에 로딩이 일어나므로 맵 로딩 시간 및 난입 랙이 줄어드는 순기능마저 있다. (lol)
그러다가 총기를 추가하는 업데이트를 한다. 한번에 한두개의 총기만 추가되므로 추가되는 메모리 부담 역시 10MB안쪽이다. 요즘 같은 세상에서는 납득할 만한 수치이다. 게다가 총기 한개 더 넣자고 기존의 리소스 관리를 뜯어고쳐서 새로 만드는 것 역시 수지가 안맞는 일이다. 이런저런 이유로 기존의 방식 대로 총기 리소스가 들어간다.
그런데 세월이 흘러 총기 한두개씩을 넣는 업데이트 30회를 거쳤다. 총기가 3배로 늘어났다. 그럼 총기가 60종이 되는 건데, 최악의 상황을 가정해도 16명이 16종의 총기를 들고 나오는 거다. 그런데 기존 스킴 대로 게임 구동시에 모든 총기의 리소스가 로딩된다면 게임 중 쓰일 리가 없는 총기 44종의 리소스는 말 그대로 메모리 도둑일 뿐이다. 게다가 같은 총기를 들고 오는 사람이 있다면 낭비는 더 심해진다.

물론 이런 일이 일어나서는 안된다. 그런데 그것이 실제로 일어났습니다.

이는 부분적으로 언리얼 엔진의 구조 때문이기도 한데, 언리얼 스크립트는 게임 진행에 필요한 리소스가 참조될 경우 스크립트 로딩과 동시에 해당 리소스를 로딩해 버린다. 신경써서 짜지 않으면 실행경로에 없는 리소스도 마구 로딩돼 메모리를 차지하게 된다.
그럼 방법은? 리소스가 로딩될 시점을 프로그래머가 제어해 줘야 하는데, 이 역시 난관이 있다. 개발 작업에 사용하는 애셋에는 일반에 공개되면 안되는 리소스도 많이 있다. 그래서 패키징 시에 공개될 버전에서는 참조되지 않는 리소스를 제외하고 패키징하는데, 리소스를 프로그래머가 동적으로 로딩하면 언리얼 스크립트에서 제공하는 리소스 참조 관리를 거치지 않으므로 참조되지 않는 것으로 처리하여 패키징에서 빠지는 불상사가 일어난다.
동적으로 로딩하는 것들을 별도로 관리하는 매커니즘을 만들면 되긴 하지만, (그리고 실제로도 만들었다) 문제는 지금까지 작성된 스크립트를 고칠 엄두가 안난다는 것이다. 작업량도 많거니와, 새 컨텐츠 만들 시간도 없는데 기존의 것 고치자고 노가다를 하기도 좀 그렇고, 게다가 고쳤는데 버그라도 일어난다면 누가 책임진단 말인가.

해서, 우리 게임의 메모리 사용량은 계속해서 계속 계속 계속 증가하기만 해왔고, 급기야 이제는 클라이언트 크래쉬의 주 원인으로 떠오른 지경. (후새드) 이거야말로 가랑비에 옷젖는 줄 모르는 대표적 사례.

결말은? 근 2주에 걸쳐 각종 총기과 무기, 캐릭터에 주렁주렁 달려 있는 리소스를 동적로딩으로 바꾸고 대략 200MB를 절감할 수 있었다는 것. (올레!)

 
Posted by uhm
dev.log2010. 5. 12. 17:56
오늘 섬군이 나한테 이렇게 물어봤다.
[섬] 엄아 엄아
[엄]  ??
[섬] 이게 웨 3바이트로 나올까.. ;ㅁ;
union RGB565
{
 struct
 {
  byte r : 5;
  byte g : 6;
  byte b : 5;
 };
 WORD rgb;
};
[엄]  음; 컴파일러의 최적화 덕분이겠지;
[섬] #pragma pack(1) 이거 해도?
[섬] 아.. 짜증나 .. 완전 삽질하고 있었네 -_-;
[엄] 음.. 유니언은 pack이 아니라 다른 걸로 할거 같은데...
[섬] 에잇! 다 다시 짜야쥐.ㅠ.ㅠ 망할
[섬] 유니온 따위 쓰지 말아야.. 겠.. -_-
[엄] 아아 니가 쓴 타입이 byte라 그래. WORD같은 걸로 바까바바
[섬] 흠.. WORD로? 따로 계산 ? 음..
[엄] 유니언은 선언된 타입의 경계를 벗어나는 멤버는 그 형에 맞춰서 재배열하게 돼 있슴
[엄] 5+5 = 10이니까.. g멤버가 1바이트 경계에 걸쳐지잖아
[엄] 그러니까 g멤버는 다음 바이트로 넘어가는 거지.
[섬] 호오 ... 그렇군
[섬] 크 ... 모든건 그 비모로글 유니언 때문이었군..ㅠㅠ
[엄] WORD로 바꾸면 별 문제 없이 잘 될듯;;

섬군이 모를 정도면 다른 사람들도 잘 모를거 같아서 포스팅.
Posted by uhm
dev.log2009. 11. 15. 01:13
몇년동안이나 책장에 덩그러니 꽂혀만 있던 D&EC++을 드디어 다 읽었다. 대략적인 느낌은 왜 지금의 C++이 이모냥밖에 못되었나에 대한 변명..이랄까... 이런 느낌인데, 뭐, (스타게이트 아틀란티스의) 닥터 로드니의 말을 따오자면 "완벽한 세상에서는" 이모냥밖에 안되지 않았겠지. 하지만 우리 세상은 완벽하지 않잖아? 안될거야 아마. -_-a

책을 보면, 각각의 언어 스펙에 대한 변천사가 개략적으로 기술되는데, 일부는 현재의 솔루션이 확실히 진보했다는 느낌이 들고, 일부는 호환성이나 기술적, 관습적 한계 때문에 쉽고 우아하고 효율적인 솔루션을 포기했다는 면이 안타깝다는 느낌.

현재의 상태라서 더 낫다고 생각하는 한가지 예로는 비야네가 '상속을 통한 제약조건'이라고 이름붙인 항목에서 기술한 것이다. 이는 템플릿이 어떤 종류의 매개변수로 스페셜라이제이션 될 수 있는가를 명시할 필요성이 있지 않겠느냐는 논의에서 비롯된다. 템플릿 매개변수가 만족해야 하는 조건을 명시할 필요성은 나도 느끼는 바이고, C++0x의 concept 개념이 매우 마음에 들었지만, 현재 C++0x 표준에서는 떨어졌다고 하는 걸 보면 만족스러운 해결책은 아직도 요원하다. 그런데 이러한 논의는 C++의 템플릿 명세를 처음 만들면서도 했다는 점이다. 비야네의 동료들이 제안한 방안은 템플릿 선언시 매개변수 선언부에서 특정한 클래스에서 상속받은 타입들로만 스페셜라이제이션할 수 있음을 명시하면 어떻겠느냐는 것이다.
template <class T>
class Comparable
{
    bool operator==(const T&, const T&);
};

template <class T : Comparable>
class XXX
{
  // .....
};
일견 타당해 보이기도 하지만, 비야네의 생각으로는 근본적으로 'T가 비교가능해야 한다'고 명시하는 대신에 'T가 Comparable에서 상속받아야 한다'라고 명시하는 것은 틀린 개념이라고 봤다고 한다. 여기엔 나 역시 동의한다. C++의 템플릿 매개변수가 저런 식으로 제약조건을 명시해야 했다면 활용도가 크게 떨어졌을 듯.

C++에서 빠져서 아쉬운 것중의 하나는, 비야네가 생각만 하고 있었다는 include 키워드(프리프로세서 명령이  아닌!).
#include 전처리기 명령은 매우 무식한 방법으로 작동하여, 스코프 룰을 완전히 무시하고, 순서의존성이 매우 크다. 윈도우에서 프로그래밍해본 사람이라면 std::max 템플릿을 쓰기 위해서는 windows.h 헤더에서 선언된 max 매크로를 요리조리 피해가야 했던 경험이 있을 것 같다. 비야네는 include라는 키워드를 도입하여 유일성을 자동으로 보장해주며,  include로 포함되는 헤더에서 선언된 매크로는 해당 헤더파일 안에서만 동작하고, 거꾸로 include로 포함된 헤더에서 선언되지 않은 매크로는 해당 헤더에서 동작하지 않도록 만들고 싶었다고 한다. 대략적으로 자바의 import와 비슷하게 동작할 수 있도록 만들고 싶었던 모냥. 이는 나도 적극 찬성인데, C++에 들어가지 못한 것은 매우 안타깝다.

책 전체에 걸쳐서 이건 이렇게 하려고 했었는데 이런이런 문제가 발생해서 결국 이렇게 되고야 말았다... 이런걸 구구절절하게 써놔서..... 난 무척 재미있었다. 아! 비야네도 불완전한 세상에 살수밖에 없는 엔지니어였구나. 흑_흑

비야네도 알고 있는, 인류에게 가장 도움이 되는 조언.

Posted by uhm
dev.log2009. 10. 27. 04:15
TDD는 코딩 전에 테스트를 정의하는 방법론이다. TDD가 말하는 바는, 작성하는 코드가 어떻게 동작하여야 하는지를 정의하고, 이 정의에 부합하도록 동작하는지, 그리고 이 정의에 부합하지 않는 쪽으로 동작하지 않는지를 검증하는 코드를 먼저 짜야 한다는 것이다. 내가 지금까지 코딩해왔던 방식이랑 비교하자면, 난 일단 특정 모듈의 행동양식을 정의하고, 사용처에서 사용하고자 하는 방식을 적어놓고 컴파일해본다. 컴파일 에러가 모두 사라지고 구현하면 동작하는 코드가 나오는 방식.
의식의 흐름 기법으로 정리해 보면.
  1. "파일에서 뭘 하나 읽어와야겠군"
    int XXX::open_file( const string& filename )
    {
    }
  2. "그럼 스트림을 추상화해야겠군"
    int XXX::open_file( const string& filename )
    {
        stream file( filename );
        file.read( this->buffer, this->size );
    }
  3. "컴파일 에러로군"
    class stream
    {
    public:
        stream( const string& name );
        int read( void* buffer, size_t size );
    };

    int XXX::open_file( const string& name )
    {
        stream file( name );
        return file.read( this->buffer, this->size );
    }
  4. "구현을 해야겠지?"
    class stream
    {
    public:
        stream( const string& name );
        int read( void* buffer, size_t size );
    };

    int XXX::open_file( const string& name )
    {
        stream file( name );
        return file.read( this->buffer, this->size );
    }

    stream::stream( const string& name )
    {
        // ...................
    }
    int stream::read( void* buffer, size_t size )
    {
        // ...................
    }
  5. "파일이 제대로 열리나?"
    class stream
    {
    public:
        stream( const string& name );
        int read( void* buffer, size_t size );
    };

    int XXX::open_file( const string& name )
    {
        stream file( name );
        return file.read( this->buffer, this->size );
    }

    stream::stream( const string& name )
    {
        // ...................
    }
    int stream::read( void* buffer, size_t size )
    {
        // ...................
    }

    int main()
    {
        // ...................
        XXX x;
        int result = x.open_file( ",,,,,,,,,,,,,,,," );
        assert( result != ERROR );
        assert( memcmp( x.buffer, dummy ) == 0 );
    }
  6. "버그 잡자"
내가 코딩하는 방식과 TDD에서 제시하는 방식의 차이는, 바라는 동작을 정의하는 부분이 워킹 코드이냐 테스트용 코드이냐 하는 점이다. 지금 내 방식의 단점은, 코드가 바뀌고 나면 동작의 일관성이 깨져도 그걸 체크할 방법이 바뀐 코드와 함께 유실돼 버린다는 것. TDD의 유용성이 느껴지는 부분이다.

TDD의 방법론은 실천 지침이 명확하다는 점에서 유용한 듯 하다. "코드보다 테스트코드 먼저". 이 얼마나 간단 명료한 지침인가. 내 생각으로는, 이 간단한 지침에 숨겨진 효용은 대충 이런 것 같다. 테스트를 작성하기 위해서는 내가 쓰고자 하는 코드가 어떤 식으로 동작해야 하는지를 정의해야 한다. 동작을 정의하기 위해서는 설계를 머리 속에 담고 있어야 하고, 이는 결국 코딩하기 전에 생각하라-는 오래된 금언을 실천하기 위한 매우 유용한 방법론인 것이다.
생각해 보면, 코딩하기 전에 생각하는 것이 습관화된 사람에게 TDD는 별달리 임팩트를 주진 않을 것 같다. TDD의 사용자에게 작용하는 궁극적이고 장기적인 영향은 코딩하기 전에 생각하는 습관을 들이게 하는 거라는 점에서 그렇다. (TDD의 다른 장점은 프로젝트에 작용하는 장점이지, 사람에게 작용하는 장점이 아니다) 하지만 그럼에도 장점은 있는데, 일단 남들에게 가르치기 쉽고, 또한 각 단계가 매우 짧고 보상이 정확하기 때문에 마치 게임을 하는 것처럼 코딩을 할 수 있다는 거다. "만렙까지 알아서 레벨 올리세요"와 "남쪽골짜기에서 토끼를 잡으면 레벨이 하나 올라요"의 차이랄까. 나에게 TDD란, 프로그래밍이란 게임의 규칙이란 느낌. 한 단계씩 레벨 올려서 만렙이 되면 프로그램이 완성되는 게임.

Posted by uhm
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.log2009. 7. 3. 17:17
행렬 클래스를 다음과 같이 구현해보려고 했었다.
struct matrix
{
    vector4 m[4];
    matrix( scalar m11, scalar m21, scalar m31, scalar m41,
               scalar m12, scalar m22, scalar m32, scalar m42,
               scalar m13, scalar m23, scalar m33, scalar m43,
               scalar m14, scalar m24, scalar m34, scalar m44 )
        : m[0]( m11, m21, m31, m41 ),
          m[1]( m12, m22, m32, m42 ),
          m[2]( m13, m23, m33, m43 ),
          m[3]( m14, m24, m34, m44 )
    {
    }
};

결론적으로 말하자면, 위와 같은 구문은 허용되지 않는다. 저런 식으로 접근해 보려고 1시간쯤 삽질했는데, 결과적으로는 안되는 거였다. 잠깐 생각해 보니 알 수 있었는데, 배열의 초기화는 {}로 묶인 배열의 초기화 목록으로만 가능하기 때문이다. 저런 구문이 작동 가능하려면 전역 배열도 다음과 같이 초기화할 수 있어야 한다.
[header]
extern int array[4];

[source]
int array[0](0);
int array[1](1);
int array[2](2);
int array[3](3);
배열의 이름/크기 선언과 별도로 배열의 내용물을 각기 따로 초기화할 수 있는 방법이 있다면 위의 구문이나 아래의 구문이나 내용면에서는 차이가 없는데, 아래와 같은 선언이 당연히 안될 거라는 것에 생각이 미치자 위의 것도 당연히 안된다는 걸 깨달았다. 기본 생성자에서 아무것도 안하게 하면 생성자 바디에서 초기화해도 오버헤드는 없으므로 상관은 없지만, 그래도 초기화는 초기화 리스트에서 해주는 것을 선호하는 지라, 약간 불만.

그리고 또 하나의 삽질은, 행렬의 SSE구현에서 다음과 같은 생성자를 만들려고 한 것이었다.
struct matrix
{
    matrix( __m128 v1, __m128 v2, __m128 v3, __m128 v4 )
    {
        //............
    }
};
__m128 구조체는 VC에서 선언한 SSE 내장함수용 16바이트 정렬 구조체인데, 위 생성자를 컴파일하면 2719 에러가 난다.
http://msdn.microsoft.com/ko-kr/library/373ak2y1.aspx
그냥 안되는 거였슴. 줵.

Posted by uhm
dev.log2009. 6. 2. 17:21
누구나 이와같은 실수를 할 수 있다. 이런 실수의 근원은 루프의 현재 값과 루프 카운터가 일치하지 않는다는 점. 이를테면, 현재 값을 배열로 dataset[i] 등으로 루프카운터를 직접 사용했다면 문제의 소지가 전혀 없었을 것이다. 하지만 루프카운터와 현재 값이 따로 변함으로써 버그가 된 케이스. 따라서 루프를 돌면 현재 값이 직접적으로 변하도록 루프를 구성하는 것이 버그를 미연에 방지할 수 있다.
for ( LIST* cur = header; cur != 0; cur = cur->next )
{
     some_function( *cur );
     // .........................
}

만약 현재 인덱스가 필요하다면 별도의 카운터를 두는 것이 안전.
int index = 0;
for ( LIST* cur = header; cur != 0; cur = cur->next, ++index )
{
     some_function( *cur );
     // .........................
}

사실 거의 모든 C/C++ 학습서가 for 루프의 루프카운터를 정수형 변수로 쓰는 예제들만 소개하고 있기 때문에 위와 같은 형태는 초심자에게는 익숙하지 않을 수도 있다. 하지만 루프 카운터는 어떠한 타입의 변수라도 상관이 없으므로, 루프의 불변값과 종료조건을 대표할 수 있는 것으로 정하는 것이 바람직하다.

위와 같은 일반적인 루프카운터의 개념을 캡슐화한 것이 이터레이터(iterator)라는 추상개념. C++ STL의 근간이 되는 개념이다. (이터레이터는 개념이라, C++0x에서는 iterator가 concept 로 선언된다.)
for( list::iterator cur = data.begin(); cur != data.end(); ++cur )
{
     some_function( *cur );
     // ...............
}

중요한 것은, 루프의 현재 값과 종료조건을 언어의 문법적 레벨에서 결합시켜 놔야 안전한 루프가 된다는 점.

Posted by uhm