TECH 으로 돌아가기
TECH HACKER NEWS 오늘 9분 읽기 25 READS

플레이데이트 풀스피드 에뮬레이터가 밝혀낸 '더러운' 최적화 비법

플레이데이트 풀스피드 에뮬레이터가 밝혀낸 '더러운' 최적화 비법
SOURCE IMAGE · HACKER NEWS

임베디드급 하드웨어에서 성능을 쥐어짜 본 개발자라면 "CPU는 빠른데 왜 느리지?"라는 질문과 마주한 적이 있을 것이다. 소형 게임기 플레이데이트(Playdate)에서 풀스피드 게임보이 에뮬레이터를 개발 중인 한 개발자 팀이 그 과정에서 얻은 고급 최적화 기법을 공개했다. 이들은 흔히 알려진 게임 최적화 상식과는 결이 다른, 에뮬레이터·대규모 시뮬레이션(팩토리오류)·3D 렌더러·코덱처럼 극한의 연산 성능이 필요한 경우에 유효한 저수준 기법들이다.

출발점은 CPU에 대한 올바른 심성 모형이다. 플레이데이트의 CPU는 매우 빠르지만 메모리 접근, 특히 캐시에 없는 메모리 접근이 느리다. 따라서 코드가 주로 레지스터 위에서 돌아가고 많은 데이터를 로드하지 않는다면, 루프가 많고 분기 예측이 자주 틀리더라도 놀라울 만큼 빠르게 동작한다. 성능의 병목이 연산량이 아니라 메모리와 캐시에 있다는 전제가 이후 모든 기법을 관통한다.

명령어 캐시 안에 핵심 코드를 욱여넣기

가장 반직관적인 조언은 최적화 플래그 -O3가 항상 빠르지는 않으며, 오히려 코드 크기를 줄이는 -Os가 훨씬 나을 수 있다는 것이다. 플레이데이트의 명령어 캐시는 매우 작아서 Rev A 기준 4KB(Rev B는 약 16KB로 추정)에 불과하다. 에뮬레이터의 인터프리터 루프나 프래그먼트 렌더러, 물리 엔진 같은 핵심 코드가 이 용량을 넘기면 성능이 떨어진다. 실제로 저자는 20KB짜리 에뮬레이터 코어에서 거대한 스위치 테이블을 걷어내고 몇 개의 분기로 표현을 압축해 2KB로 줄였다. 동작은 완전히 동일했지만 CPU 연산 횟수가 늘어났음에도 훨씬 빨라졌다. 거의 발생하지 않는 옵코드나 희귀한 예외 처리는 핵심 코드 바깥으로 빼두면 된다.

핵심 코드를 캐시에 잘 들어가게 하려면 링커가 이들을 연속된 주소에 배치해야 한다. 각 핵심 함수에 __attribute__((section(".text.")))을 붙이면 같은 섹션으로 모을 수 있고, C_API/buildsupport 폴더의 link_map.ld를 복사해 커스텀 링커 스크립트를 쓰면 더 정교하게 제어할 수 있다. nm으로 심볼과 주소 목록을 뽑으면 코어 코드가 실제로 몇 KB인지, 캐시에 들어가는지 쉽게 확인할 수 있다.

스택과 TCM이라는 빠른 메모리

플레이데이트에는 일반 메모리보다 빠른 밀착 결합 메모리(TCM) 영역이 있고, 스택이 바로 여기에 위치한다. 그래서 스택에 할당한 객체는 힙이나 정적 변수보다 대체로 빠르다. 어떤 구조체를 한동안 집중적으로 다룬다면 memcpy로 스택에 복사해 작업한 뒤 되돌려 쓰는 것만으로도 큰 성능 향상을 볼 수 있는데, @RPDev가 PlayGB에서 이 방식으로 이득을 봤다고 한다.

더 나아가 두 번의 복사조차 없애려는 시도도 소개된다. main을 직접 제어할 수 없어 스택에 데이터를 영구히 둘 수는 없지만, 평소 건드리지 않는 스택의 반대편 끝(낮은 주소)에 데이터를 두는 우회법이 있다. kEventInit 이벤트에서 __builtin_frame_address(0)로 스택의 높은 주소 끝을 구한 뒤 10KB가 채 안 되는 값(저자는 0x2180이 안전했다고 밝힘)을 빼면 낮은 주소 영역, 즉 저자가 'dtcm_mempool'이라 부르는 공간에 도달한다. 여기 둔 데이터는 업데이트 간에도 유지된다. 다만 스택 오버플로로 손상될 위험이 있으므로 양 끝에 카나리 값을 두고 매 업데이트마다 확인하라고 권한다. 화면 프레임버퍼 역시 TCM 영역이라 데이터 저장소로 쓸 수 있지만, 그 내용이 실제로 화면에 표시된다는 점은 감수해야 한다.

데이터뿐 아니라 코드도 TCM(ITCM)에 올릴 수 있는데, Rev A 게임보이 에뮬레이션에서는 이것이 결정적인 비법이었다고 한다. 다만 자동으로 올라가지 않으므로 .text에서 수동으로 복사해야 한다. 여기엔 ARM 특유의 함정이 있다. Cortex-M7은 함수 포인터의 최하위 비트로 Thumb/ARM 여부를 구분하기 때문에, 복사 대상 주소는 원본과 2로 나눈 나머지가 같아야 하고, 함수 주소에서 직접 복사하면 실제로는 함수 시작보다 1바이트 뒤를 가리키게 된다. 처음엔 숫자 하나만 반환하는 단순 함수부터 복사해 보며 단계적으로 늘리라는 조언이 따른다. 큰 함수는 다른 함수를 쇼트콜로 호출하다 거의 틀림없이 크래시가 나기 때문이다.

'성능 복권'을 다스리는 정렬 기법

코드를 수정하다 보면 뚜렷한 이유 없이 어떤 빌드는 느리고 어떤 빌드는 50%까지 빨라지는 현상이 나타난다. 저자는 두 가지 원인을 지목한다. 첫째는 캐시 정렬이다. 캐시 라인은 32바이트 단위라, 32로 나누어떨어지지 않는 함수 하나를 코드 앞쪽에 추가하면 이후 모든 함수의 주소가 밀려 캐시 라인 경계를 걸치게 될 수 있다. 해법은 __attribute__((aligned(32)))나 링커의 . = ALIGN(32);로 주기적으로 32바이트 정렬을 강제하고, 마지막으로 빨랐던 빌드의 nm 출력을 참고해 수동 오프셋을 주는 것이다. -falign-loops=32로 루프를 정렬하는 방법도 있다.

둘째는 분기 예측 테이블이다. Cortex-M7의 분기 예측 동작은 공개되어 있지 않지만, 저자는 1024개 명령어 주소에 대한 예측값 테이블이 있을 것이라 추정한다. 이 가정대로라면 두 if문이 1024바이트의 배수만큼 떨어져 있으면 예측기가 둘을 동일하게 취급해 오예측이 늘어난다. 링커 맵에 . = ALIGN(1024); . += n;을 n값을 바꿔가며 몇 번 끼워 넣으면 완화되는데, 한 번에 4KB 이상을 낭비하므로 남발은 금물이다. 저자는 이 추정이 틀렸을 수 있다고 인정하면서도, 이런 기법들을 적용한 뒤로 성능 복권 현상이 거의 사라졌다고 전한다.

댓글의 실무 경험담도 참고할 만하다. 한 개발자는 Lua에서 호출하지도 않는 C 함수를 추가했다가 SAT 충돌 코드에서 20~30% 성능 저하를 겪었는데, 결국 명령어 캐시의 정렬과 크기 문제였다. 또 다른 개발자는 렌더링 루프에서 정수 나눗셈이 느려 이를 float 덧셈으로 바꾸자 크게 빨라졌다고 했다. 이에 대해 저자는 정수 나눗셈이 2~12사이클, float 나눗셈이 14사이클이라 그 자체로는 설명이 어렵고, ARM의 정수 레지스터가 열 몇 개뿐이라 일부 변수를 별도 레지스터를 쓰는 float로 옮기면서 레지스터 여유가 생긴 효과일 수 있다고 분석했다. 프리페치의 경우 저자 본인은 효과를 못 봤으나 @FReDs72는 도움이 된 사례를 보고했고, 효과가 미세하니 FPS 게이지 대신 고정밀 타이머로 프레임 시간을 측정하라고 덧붙였다.

이 글에 담긴 기법들은 어디까지나 특정 하드웨어의 좁은 특성에 기댄 것이고, 상당수는 저자 스스로 추정임을 밝히고 있어 일반 게임 개발에 그대로 적용하긴 어렵다. 그럼에도 '빠른 연산, 느린 메모리'라는 모형 아래 캐시 용량·정렬·메모리 계층을 코드 배치 수준에서 통제한다는 발상은, 플랫폼을 가리지 않고 성능 한계에 부딪힌 저수준 개발자에게 생각의 틀을 넓혀 준다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://devforum.play.date/t/dirty-optimization-secrets-c-fo...
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기 →
처리 중...