CPU에는 3가지 핵심 부품이 존재한다.
다음 작업 지시(명령어)를 가져오고(Fetch), 그게 무슨 뜻인지 해석(Decode)한다.
그리고 그 작업을 위해 어떤 기계를 돌려야 할지 지시하고 조율한다.
CU의 지시를 받아 실제 작업을 수행한다.
CPU가 지금 당장 사용하는 데이터를 임시로 보관하는, CPU 칩 내부에 존재하는 가장 빠른 저장 공간이다.
단일 작업 지시, 혹은 함수 실행 중 아주 짧은 순간 동안만 유지된다.
CPU가 작업을 하기 위해 RAM까지 가서 레지스터로 가져오는 것은 굉장히 시간이 많이 걸리는 작업이다.
따라서 레지스터와 RAM 사이에 L1, L2, L3 '캐시'라는 저장 공간이 존재한다.
| 이름 | 비유 | 속도 (접근 시간) | 크기 |
|---|---|---|---|
| 레지스터 | 작업자 손 (작업대) | 즉시 (0.2 ns) | 극도로 작음 (KB) |
| L1 캐시 | 작은 공구함 (개인) | 매우 빠름 (1 ns) | 매우 작음 (e.g., 128KB) |
| L2 캐시 | 중간 부품 선반 (개인) | 빠름 (4 ns) | 작음 (e.g., 1MB) |
| L3 캐시 | 공장 임시 창고 (공유) | 조금 느림 (15 ns) | 중간 (e.g., 32MB) |
| RAM (메인) | 대형 부품 창고 (외부) | 매우 느림 (100 ns) | 매우 큼 (GB) |
CPU는 두 가지 '경험 법칙'에 의존해 캐시를 채워둔다.
방금 사용한 데이터는 곧 다시 사용할 확률이 매우 높다
데이터가 한 번 사용되면(RAM -> 캐시로 로드), CPU는 이 데이터가 '쓸모있다'고 판단하여 캐시에서 이 데이터를 버리지 않고 최대한 오래 보관하려고 한다.
예를 들어 다음 같은 코드가 있다고 가정해보자.
int TotalGold = 0;
TArray<FMonsterData> Monsters; // (1000개짜리 배열)
for (int i = 0; i < 1000; i++)
{
TotalGold += Monsters[i].Gold;
}
TotalGold를 RAM에서 읽어옴 -> 더함 -> TotalGold를 RAM에 씀(RAM 왕복)TotalGold를 RAM에서 읽어옴 -> 더함 -> TotalGold를 RAM에 씀(RAM 왕복)즉, RAM을 2000번이나 왕복해야한다.
TotalGold를 RAM에서 읽어와 캐시로 가져옴 -> 더함 -> 결과를 다시 캐시에 씀TotalGold를 확인(매우 빠름) -> 더함 -> 결과를 다시 캐시에 씀즉, RAM 접근은 1번으로 줄고, 나머지 접근은 RAM보다 빠른 L1/L2 캐시에서 처리된다.
방금 사용한 데이터의 바로 옆에 있던 데이터들도 곧 사용할 확률이 매우 높다
RAM에 데이터를 가지러 갔을 때, 메모리 컨트롤러는 원하는 데이터 딱 하나만 주지 않는다.
CPU가 int 하나를 요청해도, RAM은 그 데이터가 포함된 64바이트 한 상자(Cache Line)를 통째로 캐시에 쏘아준다.
이 공간 지역성은 TArray 같은 연속 메모리 구조가 왜 그렇게 빠른지, 포인터 배열이 왜 그렇게 느린지를 증명한다.
다음과 같은 코드가 있다고 가정해보자.
TArray<int> MyScores; // (1000개 가정)
void CalculateTotalScore()
{
int Total = 0;
for (int i = 0; i < 1000; i++)
{
Total += MyScores[i];
}
}
루프가 실행될 때 캐시의 움직임은 다음과 같다.
MyScores[0] 필요해" -> 캐시 미스MyScores[0]의 주소를 요청MyScores[0]이 포함된 64바이트 '한 상자'를 보냄MyScores[0]부터 MyScores[15]까지 (총 16개의 int)가 한꺼번에 로드MyScores[1] 필요해"MyScores[0] 가져올 때 같이 가져옴)MyScores[2]...[15]" 모두 캐시 히트MyScores[16] 필요해" -> 캐시 미스 (첫 번째 상자에는 없음)MyScores[16]이 포함된 두 번째 64바이트 상자 보냄"MyScores[16]부터 MyScores[31]까지가 로드MyScores에 1000번 접근했지만, 실제 RAM 접근은 1000 / 16번 밖에 발생하지 않는다.
AActor 객체들은 힙(Heap) 영역에 new(언리얼에서는 SpawnActor)를 호출할 때마다 여기저기 흩어져 생성된다.
다음 예시를 보면 왜 포인터 배열이 느린지 알 수 있다.
// 1. 1000명의 적(Enemy)이 Heap 영역 곳곳에 흩어져 스폰됐다고 가정
// (데이터 위치: 0x1000A000, 0x3000C000, 0x5000F000, ...)
TArray<AEnemyCharacter*> EnemyActors; // '포인터'(데이터 위치 주소)의 배열
void UpdateAllEnemies_Bad(float DeltaTime)
{
for (AEnemyCharacter* Enemy : EnemyActors)
{
// 'Enemy' 포인터(주소) 자체를 읽는 것은 빠름. (TArray는 연속적이므로)
// 하지만 그 주소가 가리키는 데이터에 접근할 때
// 1. Enemy 1 (0x1000A000)의 Health에 접근 -> 캐시 미스
// (RAM에서 0x1000A000 근처 64바이트(한 상자)를 가져옴)
// 2. Enemy 2 (0x3000C000)의 Health에 접근 -> 캐시 미스
// (아까 L1에 가져온 64바이트는 0x1000A000 근처 데이터라 아무 쓸모가 없음)
// 3. Enemy 3 (0x5000F000)의 Health에 접근 -> 캐시 미스
// ... (최악의 경우 캐시 미스 1000번 발생)
if (Enemy->Health < 0) { }
}
}
TArray<int>와 비교했을 때 EnemyActor 순회는 1000 / 16번이 아니라 1000번의 캐시 미스를 유발할 수도 있다.
하지만 AActor는 언리얼 엔진에서 사용하지 않을 수가 없다.
다형성을 사용하기 위해서는 반드시 포인터로 다뤄야만 하기 때문이다.
따라서 공간 지역성은 AActor처럼 월드에 존재하는 유일한 개체가 아닌, 순수한 데이터 묶음을 다룰 때 의미가 있다.
예를 들어 게임에 아이템 데이터가 필요하고, 이 아이템은 스탯 버프 3개와 이름을 가진다고 가정해보자.
UStatBuff를 UObject로, UItemData도 UObject로 만든다.
UCLASS()
class UStatBuff : public UObject { /* ... */ };
UCLASS()
class UItemData : public UObject
{
public:
UPROPERTY() FString Name;
UPROPERTY() TObjectPtr<UStatBuff> Buff1; // 힙 어딘가에 생성 (e.g., 0x1000)
UPROPERTY() TObjectPtr<UStatBuff> Buff2; // 힙 어딘가에 생성 (e.g., 0x3000)
UPROPERTY() TObjectPtr<UStatBuff> Buff3; // 힙 어딘가에 생성 (e.g., 0x5000)
};
UPROPERTY()
TArray<TObjectPtr<UItemData>> Inventory;
위의 경우
Inventory[i]에 접근 -> 캐시 미스 -> Inventory[i]->Buff1에 접근 -> 캐시 미스 -> ...->Buff2 -> 또 캐시 미스
같은 형태로 작동할 것이다.
장점으로는 UStatBuff를 상속받는 다형성 구현이 쉽고, GC가 알아서 관리해준다.
단점으로는 데이터가 메모리에 흩어져서 캐시 효율이 0에 수렴한다.
FStatBuff와 FItemData를 USTRUCT로 만든다.
USTRUCT(BlueprintType)
struct FStatBuff { /* ... */ };
USTRUCT(BlueprintType)
struct FItemData
{
GENERATED_BODY()
public:
UPROPERTY() FString Name;
UPROPERTY() FStatBuff Buff1; // FItemData 메모리 안에 '포함됨'
UPROPERTY() FStatBuff Buff2; // FItemData 메모리 안에 '포함됨'
UPROPERTY() FStatBuff Buff3; // FItemData 메모리 안에 '포함됨'
};
UPROPERTY()
TArray<FItemData> Inventory;
위의 경우
Inventory[i]에 접근 -> 캐시 미스
하지만 FItemData 구조체(Name, Buff1, Buff2, Buff3)가 '한 덩어리'이므로, 캐시 라인(64바이트 상자)에 통째로 같이 딸려온다.
즉, Inventory[i]->Buff1에 접근 -> 캐시 히트 -> ...->Buff2 -> 캐시 히트
같은 형태로 작동할 것이다.
장점으로는 완벽한 공간 지역성을 가지기에 많은 아이템을 순회해도 빠르게 작동한다.
단점으로는 FStatBuff는 상속(다형성)이 불가능하기에 유연성이 떨어진다.
AActor처럼 월드에 존재하는 유일한 개체나 다형성이 필요하면 포인터 배열을 사용해야 한다.
하지만 아이템 데이터나 스탯 값처럼 순수한 데이터 묶음을 다룰 때는, UObject 대신 USTRUCT를 사용하는 것이 캐시 성능에 압도적으로 유리하다.
CPU의 코어는 간단히 말하자면 일꾼이다.
예전에는 'CPU 1개 = 일꾼 1명'이었지만, 지금은 하나의 CPU 안에 여러 개의 코어가 존재하는 형태이다.
비유를 하자면 다음과 같다.
CU + ALU + 레지스터 세트를 가짐 (보통 L1, L2 캐시도 코어마다 전용으로 가짐)여러 개의 작업을 동시에 처리할 수 있어서 마냥 좋은 것 같지만, 위험성도 존재한다.
만약 코어들이 공유 데이터를 동시에 건드린다면 문제가 발생한다.
G_TotalScore라는 전역 변수가 있다고 가정 후 다음 예시를 살펴보자.
G_TotalScore = 100 일 때
(작업자 1 [코어 1]) | (작업자 2 [코어 2])
---------------------+----------------------
1. TotalScore 읽음 (100) |
| 2. TotalScore 읽음 (100)
3. 100 + 10 = 110 계산 |
| 4. 100 + 20 = 120 계산
5. TotalScore에 110 저장! |
| 6. TotalScore에 120 저장!
결과: 120 (작업자 1의 +10 연산이 '증발')
이것을 경쟁 상태혹은 동시성 이슈라고 한다.
언리얼에서는 이런 문제를 FCriticalSection(Mutex, 락)이나 TAtomic(원자적 연산)을 사용해 데이터를 잠그는 방식으로 해결하지만, 이 잠금 자체가 성능 저하를 유발할 수 있다.
따라서 개발자는 가급적 G_TotalScore 같은 공유 데이터를 만들지 말고, 스택 변수, 객체 멤버 변수를 사용하도록 코드를 설계하는 것이 좋다.
비유: 작업장 안에서 일하는 실제 일꾼들
특징:
언리얼 엔진의 스레드 구조:
Tick() 함수, 게임 로직, 액터 관리 담당다음과 같은 상황을 가정해보자.
CPU에 8개의 코어가 있고, 처리해야할 스레드가 500개(게임 스레드, 렌더 스레드, 윈도우 시스템 스레드 등)이 대기 중이다.
8개의 코어는 동시에 딱 8개의 스레드만 처리할 수 있다.
이때 OS의 역할이 중요한데, OS의 작업 분배 행위를 CPU 스케줄링이라고 한다.
OS는 우선순위 결정, Time Slice와 Context Switch 2가지 역할을 하며 스케줄링을 한다.
아주 짧은 시간(Time Slice) 동안 여러 스레드를 번갈아 가며 코어를 넣었다 뺐다 하는 것을 시분할(Time Slicing)이라고 한다.
그리고 스레드를 교체하는 이 비싼 행위(저장하고 복구하는 과정)를 '문맥 교환(Context Switch)'이라고 한다.
스케줄링에는 여러 알고리즘이 있는데 3가지 알고리즘을 살펴보겠다.
OS가 스레드를 교체하는 Context Switch는 공짜가 아니다.
하던 일을 정리하고, 새 일을 준비하는 Overhead가 존재한다.
그런데 이 비용은 '누구와 교대하느냐'에 따라 엄청난 차이가 있다.
MyGame 프로세스 안에서 Game Thread와 Physics Thread를 교체MyGame 프로세스 전체가 작업을 멈추고, Chrome 프로세스가 들어옴MyGame이 캐시에 가져다 놓은 데이터는 Chrome 입장에서는 완전한 쓰레기Chrome이 실행되면서 캐시를 자기 데이터로 OverrideMyGame이 돌아오면 다시 RAM에서 데이터를 가져오느라 엄청난 렉(Stall)이 발생