<aside> 🎯

한 줄 결론 — 컴파일러는 최적화할 때 "이 분기는 어디로 자주 가나 / 이 함수를 인라인할 값어치가 있나 / 어떤 코드를 서로 옆에 둘까"를 실행해보지 않고 추측한다. PGO(Profile-Guided Optimization, 프로파일 기반 최적화)는 그 추측을 실제 실행 기록으로 바꾼다.

옵션 하나를 켜는 게 아니라 계측 빌드 → 부하 걸기 → 재빌드라는 절차다. 성패는 "어떤 부하로 프로파일을 떴는가"에서 갈린다.

기대 이득은 마이크로소프트가 샘플 기반 PGO 기준 5~15%를 이야기한다. 우리 서버 숫자는 재봐야 안다.

</aside>

<aside> 📌

범위 — MSVC(Visual Studio) x64 네이티브 기준. 리눅스/clang은 8장에서 한 문단만 다룬다.

청자 — 게임서버 프로그래머. PGO를 들어본 적 없다고 가정한다.

</aside>

<aside> 📐

이 문서의 상태 — 절차와 근거는 완성됐다. 비어 있는 건 실측값이다. 4장은 before 스냅샷만 있고, 6장의 판정 임계값은 재빌드 노이즈를 재봐야 채워진다. 9장 순서대로 실행하면서 메운다.

</aside>


1. 우리가 안 보던 절반 — 명령어도 캐시를 쓴다

캐시 지역성, false sharing… 우리가 신경 써온 건 전부 데이터 쪽이었다. 그럼 코드는 누가 어디에 배치했나?

우리는 캐시를 늘 신경 써왔다. 구조체를 캐시 라인에 맞추고, 스레드마다 만지는 카운터를 떼어놓고(false sharing), 순회할 배열을 연속으로 깔았다. 전부 데이터를 어디에 둘 것인가의 문제였다.

그런데 CPU는 데이터만 캐시에 올리지 않는다. 실행할 명령어도 캐시에 올린다. L1 캐시는 애초에 둘로 나뉘어 있다 — 데이터용(L1d)과 명령어용(L1i).

이 문서의 테스트 PC로 실제로 확인해보면:

# Get-CimInstance Win32_Processor / Win32_CacheMemory
# (WMI의 Level 값은 L1=3, L2=4, L3=5로 어긋나 있으니 주의)
Name        : Intel(R) Core(TM) i9-10900 CPU @ 2.80GHz
NumberOfCores : 10

Purpose     Level  MaxCacheSize
L1 Cache      3        640      (KB, 10코어 합계)
L2 Cache      4       2560
L3 Cache      5      20480

L1 총 640KB를 코어 10개로 나누면 코어당 64KB. Skylake 계열 구성상 이 64KB는 데이터 32KB + 명령어 32KB로 반씩 갈린다.

코어 하나가 "지금 실행 중인 코드"를 담아둘 수 있는 공간이 32KB다. x64 명령어가 어림잡아 3~4바이트니 만 개 안팎의 명령어 분량.

핵심 질문은 이거다. 그 32KB에 무엇이 들어갈지는 누가 정하나?

답은 "함수가 바이너리 어디에 놓였는가"다. 그리고 그 배치를 정한 건 컴파일러와 링커이며, 근거는 — 추측이었다.

이게 왜 게임서버에서 문제인가

우리 서버의 한 틱은 대충 이렇게 흐른다.

수신 완료 처리 → 패킷 파싱 → 게임 로직 → AOI 계산 → 브로드캐스트 대상 수집 → 송신 큐 적재 → 송신 완료 처리

이 함수들이 바이너리에서 서로 멀리 떨어져 있으면 한 틱을 도는 동안 L1i를 여러 번 갈아치운다. 반대로 한 덩어리로 뭉쳐 있으면 한 번 올려둔 걸 계속 쓴다. 초당 수십 번 도는 루프에서 이 차이는 누적된다.

여기에 하나 더. 우리 코드에는 거의 안 도는 코드가 잔뜩 섞여 있다. 에러 처리, 예외 경로, 로그 출력, 유효성 검사 실패 분기. 이것들이 핫 함수 중간에 끼어 있으면 캐시는 라인 단위로 올라오므로 "절대 실행 안 될 명령어"까지 같이 끌고 온다. 32KB의 일부가 그냥 낭비된다.