자료보관함‎ > ‎가마수트라‎ > ‎

Next-Gen 콘솔 아키텍처에 효율적인 게임코드 작성법


저작권 정보

현재 가마수트라가 번역허가를 내주고 있지 않은 관계로 원문의 링크만을 제공합니다. 하지만 영어에 도움이 필요하신 분들을 위해 아래에 해석을 달아놓았습니다. 원문을 읽으시다가 모르시는 부분이 있으시다면 비교하면서 읽어보시길 바랍니다.


원문 정보


소개


코드의 성능은 성공적인 게임 타이틀에 있어 치명적이며, 형편없는 성능은 출시일 연기나 컨텐츠의 삭제라는 결과를 초래하기도 합니다. 물론 값비싼 개발 프로세스가 증가하는 것은 말할 것도 없습니다. 따라서 프로젝트를 시작할 때부터 올바르게 코드를 작성하는 일을 시작해야 합니다. 다른 다른 요소만큼이나 게임 시스템의 아키텍처가 성능을 결정짓기 때문입니다. 아키텍처 설계를 어설프게 했다면 프로젝트를 실패작으로 만들 것입니다.

차세대(next-gen) 콘솔들은 다중 코어, 더욱 길어진 파이프라인, 레벨 2 캐시, 다중쓰레드 등을 도입한 이후부터 모든 일이 점점 복잡하게 꼬여가고만 있습니다. 대부분의 차세대 콘솔들은 대부분의 워크스테이션보다 많은 계산 장치들을 칩에 포함시키기로 결정했습니다. 이를 위해 치른 대가는 명령어의 이름 변경을 생략한 것과 대용량 캐시로 기존의 응용프로그램들을 빠르게 돌아가게 만들기 위한 것입니다.

이것의 장점은 차세대 콘솔들이 수백개의 기가플롭들을 처리할 수 있다는 것입니다. 이것은 제가 프로그래밍을 처음 배우던 시절인 1976년에 가장 강력한 슈퍼컴퓨터였던 Cray-1 약 1000대의 처리량입니다. 이 글은 차세대 콘솔에서 고성능 코드를 작성하기 위한 핵심요소 몇가지를 설명하는 글입니다.



메모리 접근


여러분이 하실 수 있는 가장 중요한 최적화는 데이터의 크기를 줄이는 것입니다. 보통의 게임들은 레벨 1 캐시와 레벨 2 캐시 미스(miss)를 기다리느라 상당한 양의 시간을 소비합니다. 레벨 1 캐시 미스도 나쁘지만 레벨 2 캐시 미스는 대재앙입니다.

현대의 프로세서에서 일어나는 일반적인 레벨 1 캐시 미스는 수십개의 사이클을 낭비하는 반면 레벨 2 미스는 수백개를 낭비합니다. 이것은 매우 중요하므로 게임상태가 레벨 2 캐시의 크기보다 작게 유지해야 합니다. 그렇지 않으면 캐시가 매 프레임마다 내용을 내다버릴 것이므로(thrash) 성능이 떨어질 것입니다. 이 성능의 하락은 너무나 갑작스럽고 설명할 방도도 없을 것입니다. 하지만 그 원인은 코드가 한 프레임에 너무 많은 데이터를 다루기 때문입니다. 만약 512k의 캐시를 가지고 있는 컴퓨터에서 한 프레임에서 511k의 레벨 2 캐시를 다룬다면 아무 문제가 없을 것입니다. 하지만 512k를 초과하는 순간부터 LRU 매커니즘이 모든 접근을 실패로 만들 것입니다. 이것은 레벨 1 캐시만 가지고 있어 캐시 미스에 대한 불이익이 낮았던 이전 세대의 콘솔에서는 존재하지 않았던 문제입니다.

비트 필드의 사용, 조심스럽게 설계된 접근 방법과 건실한 게임데이터의 사전처리는 게임상태의 크기를 현저히 줄여줄 것입니다. 충돌평면 정보나 AI 경로찾기 등과 같이 넓은 범위(scope)를 가지고 있는 메모리는 매 프레임마다 동일한 메모리 영역을 액세스하도록 되어야 합니다. 그 데이터의 크기가 꽤 클지라도 규칙적으로 액세스 하면 그 캐시내용이 비워지지 않을 것입니다.

메모리 안에서 참조(look-up) 테이블과 상수의 사용을 피하십시요. 이들은 캐시 미스를 초래하여 레벨 2 캐시 액세스를 비울 것입니다. GPU 또한 메모리 접근 문제점을 가지고 있습니다. 일반적으로 GPU는 동일한 규칙을 따르는 캐시를 하나 가지고 있을 것입니다. 정점 데이터로 byte와 short를 사용하는 것을 추천하고 밉맵 레벨(bias)을 조정하여 텍스쳐 LOD의 균형을 잡는 것이 중요한 도구입니다. 양의 LOD bias 대신에 이방성 필터를 사용하여 텍스처를 선명하게 만드십시요.

대부분의 훌륭한 게임 시스템은 아티스트의 원래 텍스쳐들을 서브샘플링하여 텍스처 이용량을 낮추는 방법을 가지고 있습니다. 상이한 장면들은 동일한 텍스처를 다른 방법으로 사용합니다. 따라서 플레이어가 결코 텍스쳐를 가까이에서 보지 않는 텍스처가 있다면 이것을 낮은 해상도로 읽어 둘 수도 있습니다. 마지막으로 텍스처 풀의 크기가 작을 수록 GPU 성능이 높아질 것입니다. 텍스처에 따라 장면을 정렬하면 GPU 캐시의 일관성을 유지하게 됩니다.

분기문을 피하자


다음으로 가장 큰 최적화 혜택을 볼 수 있는 방법은 분기문이 없도록 주요 함수들을 다시 짜는 것입니다. 여기에 포함되는 것들로는 가상 함수호출과 함수 포인터가 있습니다. 분기를 잘못 예측하는 것은 파이프라인을 쓸모없게 만들고 명령어 예측을 실패하게 만들어 이득보다 손실이 훨씬 크게 됩니다. 템플릿은 가상 함수호출을 피할 수 있는 변형 메서드를 만드는 훌륭한 방법이며, 인라인 함수에 const로 전달되는 함수 포인터들을 직접 호출로 바꿀 수 있는 경우가 종종 있습니다. 프로파일 주도적 분기 조율은 실행파일에 분기문에 대한 정보를 알려줄 수 있습니다. 분기문은 보통 "그럴 것이다"와 "아닐 것이다" 라는 두가지 형태를 가집니다. 한 쓰레드가 분기문을 처음으로 만나면 그 명령어 안에 코딩된 가능성에 따라 힌트를 받습니다. 프로필 주도적 최적화기가 분기 가능성을 선택하여 코드블락을 재정렬함으로써 흔히 사용되진 않는 블럭들을 레벨 1 캐시에 읽어오는 것을 피합니다.

다음과 같은 코드

if( x == 0 ) y = z;

를 아래와 같이 바꿔 분기문을 완전히 제거할 수 있습니다.

y = x == 0 ? z : y;

그리고 다음의 코드를

if( ptr != 0 ) ptr->next = prev;

아래처럼 바꿔도 마찬가지입니다.

*( ptr != 0 ? &ptr->next : &dummy ) = prev;

괜찮은 컴파일러를 가지고 있다면 이것이 "if" 형태의 코드가 만드는 분기문을 가진 코드보다 훨씬 빠르게 실행될 것입니다. Next-gen 콘솔은 깊은 파이프라인을 가지고 있기에 대규모의 인터럽트되지 않는 함수본체가 효과적으로 스케줄되어야만 합니다.

인라인 코드


분기문 피하기 외에도 인라인 코드를 사용하여 호출에 따르는 부하를 제거하고 함수의 몸체크기를 증가시킬 수 있습니다. 하지만 이 경우 레벨 2 캐시 용량을 좀더 많이 잡아먹게 되겠죠. SN 시스템 컴파일러(SNC) 등의 몇몇 컴파일러들은 요청이 있는 경우 자동으로 함수들을 인라인으로 만듭니다. 심지어는 구형 코드들도 함수의 범위를 "정적(static)"으로 제한함으로써 좀더 빠르게 만들 수 있습니다.

새롭게 디자인을 한다면 모든 함수들을 인라인으로 만들 수도 있습니다. 특히나 모든 함수들을 헤더 파일안에서 정의하고 프로그램의 큰 부분들을 하나의 단위로 컴파일하면 그렇지요. 이와 같은 코드는 실제로 다중 모듈 코드보다 빠르게 컴파일 및 링크됩니다. 컴파일러가 만드는 디버그 정보가 줄어들기 때문이라나요.

대부분의 컴파일러는 최적화에 대해 n 제곱의 의존성(dependencies)을 가지고 있습니다. 따라서 컴파일 시간이 길어진다면 큰 블럭들을 분리시키거나 낮은 컴파일러 레벨을 사용할 수 있을 것입니다. 인라인 함수의 또다른 장점은 컴파일러가 불필요한 부하와 저장을 제거하고 스케줄러가 사용할 수 있는 블럭들의 길이를 늘릴 수 있게 도와주는 별칭(alias) 분석의 영역입니다. 인라인 함수는 레지스터 사용의 제한을 제거합니다.

부동소수점 파이프라인 사용량


현재 세대의 프로세서들은 꽤나 긴 부동소수점 파이프라인을 가지고 있고 들어온 순서와 다르게 결과들이 나올 수도 있습니다. 한 연산이 여러 사이클에 걸쳐 진행되는 경우 이 파이프라인이 통과하는 게이트의 수가 다를 수 있기 때문에 이는 빠른 클럭속도를 허용합니다. 이를 위해 지불하는 대가는 수십 사이클을 기다려야 부동소수점의 결과를 얻을 수 있다는 것입니다. 따라서 최대한 부동소수점 결과를 사용하는 것을 피하고, 부동소수점 코드를 사용하는 함수 몸체를 분기문 없이 최대한 크게 만드는 것이 중요합니다.

특히 부동소수점 연산의 결과를 정수로 변환할 때 주의합시다. 프로세서는 파이프라인의 내용을 버려야 하므로 커다란 딜레이를 일으킵니다.

벡터 단위와 슈퍼벡터


이상적으로 모든 수학계산은 프로세서나 보조프로세서 상에서 벡터 단위를 사용하여 행해야 합니다. 불행히도 벡터 수학 라이브러리를 부동소수점 기반 코드로 껴 맞추는 것은 별로 효율적이지 않아 주요 함수의 경우 오직 1~2 프로의 성능향상만 보여줍니다. 벡터 수학 라이브러리에 스칼라 형을 포함시켜 스칼라를 벡터로 복사하는 것의 비용이 높은 경우 곳에 스칼라 형을 "float" 계산 대신에 널리 쓰도록 합시다.

즉 아래의 코드 대신에

void Add( vector &elem, vector a, vector b )
{
    elem = vec_add( a, b );
}


다음과 같이 함수를 작성합니다.

void Add( vector elem[], vector a[], vector b[] )
{
    elem[ 0 ] = vec_add( a[ 0 ], b[ 0 ] );
    elem[ 1 ] = vec_add( a[ 1 ], b[ 1 ] );
    elem[ 2 ] = vec_add( a[ 2 ], b[ 2 ] );
    elem[ 3 ] = vec_add( a[ 3 ], b[ 3 ] );
    elem[ 4 ] = vec_add( a[ 4 ], b[ 4 ] );
    elem[ 5 ] = vec_add( a[ 5 ], b[ 5 ] );
    elem[ 6 ] = vec_add( a[ 6 ], b[ 6 ] );
    elem[ 7 ] = vec_add( a[ 7 ], b[ 7 ] );
}


두번째 함수의 실행을 마치는 데 걸리는 시간은 첫번째 함수를 마치는 데 걸리는 그것과 거의 같습니다. FPU 지연(latency) 때문인데요 그래도 8배나 많은 일을 한답니다. 함수가 인라인으로 되어서 별칭 분석을 돕지 않는다면 __restrict 키워드를 사용해야 할 수도 있습니다. 이것은 중요한 수치 최적화입니다. 일반적으로 최고의 방법은 동일한 일을 여러번 하는 것입니다. 파이프라인은 파이프라인 연산 주기 동안 동일한 입력 명령어를 여러번 사용할 때 최고로 작동합니다.


Next-get 보조프로세서는 긴 지연시간(latency)를 가지고 있지만 레지스터의 수가 많아 "읽은 뒤 쓰기" 지연시간 문제를 많이 줄여줍니다. 배열의 구조체 방법을 사용하여 일반적인 산술연산을 벡터 단위에 적용할 수 있습니다. 여기서는 수퍼벡터가 스칼라처럼 취급됩니다. 배열의 구조체 방법을 사용하면 여러 객체들의 값이 벡터의 x, y, z 요소를 가진 배열들로 표현됩니다. 숫자 연산을 그룹지어 같이 연산할 수 있도록 하는 것이 중요합니다. 예를 들어 중력값을 속력에 더하는 파티클 시스템이 있고 같은 종류의 모든 파티클이 루프 하나 안에서 진화한다면 이 중력값을 한번만 읽어야 합니다.

GPU의 힘을 빌리는 것이 가능하다면 단순한 선형 작업들을 GPU상에서 실행시킬 수도 있습니다. 하지만 그 결과를 렌더링 용으로만 사용하지 않는다면 동기화의 문제가 발생할 수 있습니다. 다시 한 번 비슷한 작업들을 그룹짓는 것이 핵심입니다.

쓰레드 관리


임베디드 다중스레드 환경을 처음 접하는 사람들이 저지르는 실수는 너무 많은 스레드를 생성한다는 것입니다. 스레드를 실행시킬 수 있을만큼의 하드웨어 리소스만큼만 스레드를 만드는 것이 중요합니다. 그렇지 않으면 운영체제가 스레드를 메모리 밖으로 옮기면서 상당한 양의 과부하를 초래할 것입니다. 단일 코어 상에서도 여러개의 스레드를 만들 수는 있지만 이들은 리소스를 두고 다툴 것이므로 스레드를 사용하는 장점이 무색하게 만듭니다. 작업들을 여러 스레드로 나눠 스케줄을 할 때는 주의를 기울여야 합니다. 예를 들어 메모리를 많이 사용하는 작업과 계산을 많이하는 작업을 섞어 그 두 작업이 메모리 리소스를 두고 싸우지 않게 만드십시요. 게임 초기화 코드에서 스레드를 생성합시다. 스레드는 값비싼 연산이므로 게임 루프에서 스레드를 생성하려고 하는 것은 현명한 생각이 아닙니다. 개발기간 중 일찍 게임코드를 프로파일링하여 심각한 문제가 발생하기 전에 운영체제 과부하가 일어나는 곳을 미리 파악할 수 있습니다.

Next-gen 엔진은 작업들이 주어진 시간안에 일을 마쳐 한 작업의 출력이 다른 작업의 입력이 되는 실행계획을 가지고 있을 수 있습니다. 주어진 시간안에 일을 마치지 못하면 디버그 빌드에서 assert 실패가 생기도록 하면 문제가 생길 때 바로 고칠 수 있으므로 마지막 순간에 최적화를 할 때 난리가 나서 마감시간을 지키지 못하는 일을 막을 수 있습니다. 실행 계획을 XML 파일로 표현하여 Collada 장면 묘사언어와 같은 시스템에 그럴듯하게 연계시킬 수 있습니다. 장면이나 하위 장면의 실행계획은 반드시 주의 깊게 설계해야 합니다. 한 장면에서 사용가능한 리소스가 유한적이라면 초과할당이 없이 프레임속도를 보장하는 것도 가능합니다.


한 코어에서 여러 개의 스레드를 사용하면 명령어가 번갈아 가면서 실행되므로 효율적인 지연속도(latency)가 감소합니다. 괜찮은 컴파일러는 이것을 지원하는 최적화 옵션을 가지고 있을 것입니다.

직렬화와 사전 컴파일


게임에서 직렬화는 PC 게임에서 긴 로딩시간을 가져오는 주범입니다. 이 문제는 도구사슬의 게임 컴파일 단계에서 게임 상태 데이터와 상수들을 미리 직렬화시킴으로써 피할 수 있습니다. 구조체들의 이진 이미지들은 로딩 중에 코드를 실행시킬 필요가 없으므로 훨씬 빨리 로딩됩니다. 귀중한 CPU 사이클을 사용하지 않고도 장면에 필요한 코드와 데이터 리소스를 로딩할 수 있으므로 끊김없는 장면 변경이 가능합니다. 콘솔에서는 이것이 선호받는 방법입니다.

게임은 보통 DVD에서 읽어옵니다. DVD는 디스크 검색(seek)을 잘 다루지 못합니다. 대규모의 게임 프로젝트들은 라이트매핑, 충돌 데이터 구축, AI 경로찾기 테이블 구성과 같이 게임 특유의 프로세스들을 실행하는 서버들을 가지고 있는 것이 보통입니다. 개발자들이 대규모의 코드와 데이터 시스템을 구축할수 있도록 도와주는 분산 빌드 시스템들이 많이 있습니다.

예를 들어 DVD로부터 곧바로 몇초안에 읽어올 수 있는 수 메가바이트의 블럭인 게임 구조의 이진 이미지들로 게임 데이터를 변환하는 것이 가능합니다. DVD 효율성을 위해 동일한 데이터들을 여러 곳에 배치하여 디스크 검색을 피하는 것도 흔한 일입니다. DVD에 텍스처를 한번만 저장하는 것은 올바르지 못한 절약정신입니다. 이는 게임 로딩시간을 배로 증가시킬 것입니다.

메모리 관리


고성능 게임들은 스스로 메모리를 관리합니다. 게임 루프 안에서 malloc이나 디폴트 new를 호출하는 것은 무책임한 행동입니다. 메모리 단편화 현상이 콘솔 게임 크래시의 가장 큰 이유이며 PC 게임도 어느정도 플레이를 하면 느려지기 시작할 것입니다. 힙 관리 시스템에는 단순한 블럭 할당자부터 메모리의 위치에 따라 블럭 크기를 정렬하고 짧은 사이클안에 풀에서부터 메모리를 할당할 수 있는 시스템까지 포함됩니다. 보통 디버그 정보도 힙 관리의 일부분입니다. 어떤 시스템이 메모리 블럭을 할당하는지 살펴보는 것도 유용합니다.

랜덤 블럭을 피하는 것은 2개의 큰 메모리 블럭이 합쳐지는 것을 방지하므로 중요합니다. 장면은 스스로의 메모리 영역을 가지는 것이 좋습니다. 즉 장면을 마무리 지을 때 단편화의 걱정없이 그 전체 영역을 해제할 수 있습니다.



결론


허접한 프로그래밍 습관은 차세대 콘솔코드에 매우 치명적이며, 코드를 효율적으로 최적화하려면 분기문 없는 길고 멋진 함수몸체가 필요합니다. CPU, 보조프로세서, GPU는 캐시의 내용을 비우는 일이 없도록 주의깊은 코드설계를 요하지만 이들을 잘 다루면 PC 게임에서 이룰 수 있는 것 이상의 엄청난 성능향상을 꾀할 수 있습니다. 더이상 SIMD의 구현에 맞춰 4개의 float만을 사용할 필요가 없습니다. 16개 또는 32개의 float 슈퍼벡터가 느려터진 "읽은 뒤 쓰기" 의존성을 해결하는데 훨씬 유용합니다.

주의깊은 스레드 관리도 속도지연을 줄이고 리소스를 두고 다투는 일을 피하는데 필수적입니다. 실행계획을 사용하는 것이 시스템의 거시적인 구성요소들을 함께 묶는 현명한 생각입니다.

번역문 정보

현재상태

  • 초벌번역시작   (2006년 1월 2일)
  • 초벌번역완료   (2006년 1월 6일)
  • 재벌및감수완료 (2006년 1월 8일)

Comments