Lobsters

Dirty Optimization Secrets (C for Playdate)

Playdate C 최적화의 비밀스러운 기법

Playdate에서 풀스피드 Game Boy 에뮬레이터를 만들며 발견한 저수준 최적화 기법을 정리합니다. 작은 명령어 캐시와 빠른 TCM을 고려해 코드 배치와 메모리 사용을 바꾸고, 캐시 정렬·ITCM 복사 같은 방법을 적용합니다.

AI 요약

Playdate용 풀스피드 Game Boy 에뮬레이터를 개발하며 발견한 최적화 기법을 소개합니다. 일반적인 게임 최적화 팁보다 에뮬레이터, 대형 시뮬레이션, 3D 렌더러, 코덱처럼 성능 요구가 큰 프로그램에 초점을 둡니다. 글의 출발점은 Playdate CPU가 연산은 빠르지만 메모리 접근, 특히 캐시에 없는 데이터 접근은 느리다는 점입니다.

명령어 캐시에 맞춰 코드 줄이기

최적화 옵션이 항상 빠른 실행으로 이어지지는 않습니다. Playdate Rev A의 명령어 캐시는 4KB이며 Rev B는 16KB로 알려져 있습니다. 자주 실행하는 핵심 코드가 캐시에 들어가지 않으면, 더 많은 연산을 하더라도 코드 크기가 작은 편이 빠를 수 있습니다. 작성자는 Game Boy 에뮬레이터 핵심부를 20KB에서 2KB로 줄였습니다. 큰 switch 테이블을 없애고 적은 분기문으로 다시 표현했으며, -O3 대신 -Os를 사용했습니다. 동작은 그대로였지만 실행 속도는 빨라졌다고 합니다.

드물게 실행되는 명령어나 시뮬레이션의 예외 처리는 핵심 코드 바깥에 둘 수 있습니다. 핵심 함수들을 연속된 주소에 배치하려면 C 함수에 __attribute__((section(".text.<이름>")))을 붙이고, 링크 스크립트에서 해당 섹션을 모읍니다. Playdate C_API/buildsupport의 link_map.ld를 프로젝트에 복사한 뒤 Makefile에서 override LDSCRIPT=./link_map.ld를 common.mk 포함 전에 지정하면 사용자 링크 스크립트를 쓸 수 있습니다. nm Source/pdex.elf | sort 명령으로 심볼 주소를 확인해 핵심 코드 크기와 배치를 점검합니다.

TCM과 스택 메모리 활용

Playdate에는 일반 메모리보다 빠른 tightly-coupled memory(TCM)가 있습니다. 스택은 TCM 영역에 있으므로 스택 할당 객체가 힙 객체나 정적 변수보다 빠를 수 있습니다. 전역 구조체를 오래 처리한다면 스택으로 복사해 작업한 뒤 되돌리는 방법도 있습니다. 다만 복사 두 번을 줄이려고 데이터를 스택에 계속 두려면 별도 주의가 필요합니다. 작성자는 eventHandler의 kEventInit에서 __builtin_frame_address(0)으로 스택의 높은 주소를 구하고, 그보다 10KB 미만 아래쪽을 지속 메모리 풀로 사용했습니다. 0x2180은 안전했다고 하지만, 이 영역은 스택 오버플로가 발생하면 손상될 수 있습니다. 풀 양끝에 canary를 두고 매 업데이트마다 확인하라고 권합니다. 프레임버퍼도 TCM에 있으나, 그곳에 데이터를 두면 화면에 나타날 수 있습니다.

코드를 ITCM으로 복사하기

데이터뿐 아니라 코드도 TCM에 둘 수 있습니다. 특히 Rev A에서는 Game Boy 에뮬레이션 성능에 효과가 있었다고 합니다. 함수에 __attribute__((section(".itcm")))과 __attribute__((short_call))을 붙이고, 링크 스크립트에서 __itcm_start와 __itcm_end 심볼을 정의해 섹션 경계를 얻습니다. 그 범위의 코드를 원하는 TCM 주소로 memcpy한 뒤 복사본의 함수 주소를 호출합니다. 복사 대상 주소는 원래 주소와 2의 나머지가 같아야 합니다. Cortex-M7은 함수 포인터의 최하위 비트를 Thumb 모드 표시로 쓰며, 함수 주소 자체도 함수 시작점보다 1바이트 뒤를 가리키기 때문입니다.

이 방식에는 주의할 점이 많습니다. -fPIC을 켜면 재배치 테이블 때문에 문제가 생깁니다. ITCM 함수끼리는 상대 점프가 필요하므로 short_call을 쓰고, ITCM 밖의 함수를 호출한다면 longcall로 절대 점프를 지정해야 합니다. 코드를 복사하거나 수정한 뒤에는 Playdate API의 명령어 캐시 무효화 함수를 호출합니다. 처음부터 큰 함수를 옮기지 말고, 숫자를 반환하는 작은 함수부터 시험하라고 설명합니다. shortcall로 연결된 함수가 원래 위치로 점프하면 충돌할 수 있습니다.

빌드마다 달라지는 성능 다루기

빌드 결과가 뚜렷한 패턴 없이 때로는 50%가량 빨라지거나 느려지는 현상도 다룹니다. 원인 후보로 32바이트 캐시 라인 정렬을 듭니다. 함수가 캐시 라인 경계를 걸치거나, 앞부분 함수 크기가 바뀌면서 뒤 함수 주소가 이동하면 캐시 적중 양상이 달라질 수 있습니다. C에서는 __attribute__((aligned(32)))을 쓰고, 링크 스크립트에서는 . = ALIGN(32);로 정렬합니다. 소스 파일이나 섹션 앞마다 정렬을 넣거나, -falign-loops=32로 루프 정렬을 시도할 수 있습니다. 이전에 성능이 좋았던 빌드의 nm 출력을 보고 정렬 뒤 오프셋을 조정하는 방법도 제안합니다.

Cortex-M7의 분기 예측 동작은 공개되지 않았다고 전제합니다. 작성자는 1024바이트 간격의 분기 명령이 예측 테이블을 공유할 가능성을 추정하고, 링크 스크립트에 . = ALIGN(1024);와 작은 오프셋을 드물게 넣어 봤습니다. 이 설명은 추측이며, 정렬 기법이 성능을 개선했지만 원인이 분기 예측이라고 단정하지는 않습니다. 프리페치는 직접 측정한 개선이 없었지만 간접 참조가 많은 코드에서는 효과가 있을 수 있다고 덧붙입니다. FPS 표시보다 고정밀 타이머로 프레임 시간을 재며 확인하라고 권합니다.

원문: Playdate Dev Forum / 번역·요약: Trawling