하드웨어 기초 (2) - 메모리 계층 구조 - soo:bak
작성일 :
연산 속도와 메모리 속도의 격차
하드웨어 기초 (1) - CPU 아키텍처와 파이프라인에서는 CPU가 명령어를 어떻게 끊기지 않고 처리하려 하는지 살펴보았습니다. 하지만 명령어를 실행하려면 연산할 데이터도 필요합니다.
CPU 내부의 연산은 매우 빠르게 끝날 수 있지만, 필요한 데이터가 메인 메모리인 DRAM에 있다면 그 데이터를 가져오는 시간이 문제가 됩니다. 뒤의 연산이 그 데이터를 필요로 한다면 CPU는 값을 받을 때까지 기다려야 하고, 그동안 파이프라인에도 빈 시간이 생깁니다.
이 문제를 줄이기 위해 컴퓨터는 메모리를 하나의 큰 저장 공간으로만 두지 않습니다. CPU 가까이에는 빠르지만 작은 저장 공간을 두고, 멀리에는 느리지만 큰 저장 공간을 둡니다. 이렇게 속도와 용량이 다른 저장 공간을 여러 층으로 배치한 구조가 메모리 계층 구조입니다.
이 글에서는 메모리 계층 구조가 왜 필요한지, 캐시가 어떤 원리로 동작하는지, 그리고 게임 코드의 데이터 접근 순서가 성능에 어떤 영향을 주는지 살펴봅니다.
메모리 계층 피라미드
메모리 계층은 속도와 용량의 균형을 단계적으로 나눈 구조입니다. CPU에 가까운 저장 공간일수록 빠르지만 작고, 멀리 있는 저장 공간일수록 느리지만 더 많은 데이터를 담을 수 있습니다. 이 관계를 한눈에 보기 위해 보통 피라미드 형태로 나타냅니다.
CPU 입장에서는 데이터가 어느 계층에 있는지가 매우 중요합니다. 레지스터나 L1 캐시에 있는 데이터는 거의 바로 사용할 수 있지만, DRAM에 있는 데이터는 훨씬 오래 기다려야 합니다. 같은 연산이라도 필요한 데이터가 가까운 계층에 있으면 빠르게 진행되고, 먼 계층에 있으면 파이프라인이 대기할 수 있습니다.
빠른 저장 공간을 크게 만들기 어려운 이유는 물리적인 비용 때문입니다. 캐시에 주로 쓰이는 SRAM(Static RAM)은 빠르게 읽고 쓸 수 있지만, 같은 면적에 담을 수 있는 데이터가 적습니다. 반대로 DRAM은 훨씬 큰 용량을 만들기 좋지만, 데이터를 읽고 유지하는 과정이 더 복잡해 접근 시간이 길어집니다.
따라서 모든 데이터를 CPU 바로 옆의 빠른 저장 공간에 둘 수는 없습니다. 자주 쓰는 데이터는 작고 빠른 계층에 올려 두고, 큰 데이터는 느리지만 용량이 큰 계층에 보관하는 방식이 필요합니다. 메모리 계층 구조는 이 절충을 시스템 전체에 적용한 형태입니다.
레지스터
레지스터는 CPU 코어 안에 있는 가장 가까운 저장 공간입니다. ALU(산술 논리 연산 장치)가 값을 더하거나 비교할 때, 입력값을 레지스터에서 읽고 결과도 다시 레지스터에 기록합니다.
메모리 계층에서 레지스터는 가장 빠른 대신 가장 작은 계층입니다. 일반적인 데이터 저장소라기보다, CPU가 지금 당장 계산에 사용할 값을 잠시 올려 두는 작업 공간에 가깝습니다.
레지스터가 항상 계산할 숫자만 담는 것은 아닙니다. CPU가 프로그램의 어느 위치를 실행 중인지 관리하는 값도 레지스터에 들어갑니다. 예를 들어 프로그램 카운터(PC)는 다음에 가져올 명령어의 주소를 저장합니다. 스택 포인터(SP)는 함수 호출과 지역 변수를 관리하는 스택 영역의 현재 위치를 가리킵니다. 이런 레지스터들은 연산 자체보다 CPU의 실행 흐름을 유지하는 데 사용됩니다.
레지스터가 빠른 이유는 ALU와 매우 가까운 위치에 있고, CPU가 한 명령어를 처리하는 동안 바로 읽고 쓸 수 있도록 설계되어 있기 때문입니다. 좌표 계산, 물리 연산, AI 판단처럼 게임 코드에서 발생하는 중간값도 실제 실행 순간에는 레지스터에 잠시 올라갑니다.
다만 레지스터는 CPU가 지금 처리 중인 값만 담을 수 있을 정도로 작습니다. 캐릭터 목록, 컴포넌트 데이터, 애니메이션 상태, 경로 탐색 데이터처럼 게임에 필요한 대부분의 데이터는 레지스터에 머물 수 없습니다. 이런 데이터는 캐시와 DRAM에 저장되고, CPU는 필요할 때마다 계층을 따라 값을 가져옵니다.
L1, L2, L3 캐시
캐시(Cache)는 CPU와 DRAM 사이의 속도 차이를 줄이기 위한 작은 고속 저장 공간입니다.
CPU는 필요한 데이터를 매번 DRAM에서 직접 가져오지 않습니다. 곧 다시 사용할 가능성이 높은 데이터의 복사본을 캐시에 두고, 같은 데이터나 가까운 위치의 데이터를 다시 읽을 때 캐시에서 먼저 찾습니다. 캐시에 데이터가 있으면 DRAM까지 가지 않아도 되므로 대기 시간이 크게 줄어듭니다.
L1 캐시
L1 캐시는 CPU 코어에 가장 가까운 캐시입니다. 보통 코어마다 따로 붙어 있으며, 다른 코어와 공유하지 않습니다. 용량은 작지만 접근 속도가 가장 빠르기 때문에, 현재 실행 중인 코드가 바로 사용할 데이터가 L1에 있으면 파이프라인이 거의 기다리지 않고 진행할 수 있습니다.
L1 캐시는 보통 명령어 캐시와 데이터 캐시로 나뉩니다. 명령어 캐시는 CPU가 실행할 명령어를 보관하고, 데이터 캐시는 연산에 사용할 값을 보관합니다. 이렇게 나누어 두면 명령어를 가져오는 작업과 데이터를 읽고 쓰는 작업이 서로 덜 방해받습니다.
게임 코드에서는 반복문 안에서 순차적으로 처리하는 작은 데이터 구간이 L1 캐시에 올라오는 경우가 많습니다. 현재 처리 중인 배열 구간이나 구조체 일부가 L1에 들어와 있으면, CPU는 같은 데이터를 매우 빠르게 반복해서 사용할 수 있습니다.
L2 캐시
L2 캐시는 L1보다 크지만 조금 더 느린 계층입니다. L1에 원하는 데이터가 없을 때, CPU는 바로 DRAM으로 가지 않고 먼저 L2를 확인합니다.
L2는 L1보다 더 넓은 작업 구간을 담을 수 있습니다. L1에서 밀려난 데이터라도 L2에는 남아 있을 수 있으므로, DRAM까지 내려가지 않고 다시 가져올 기회를 제공합니다. 반복문이 다루는 데이터가 L1에는 다 들어가지 않더라도 L2에 머물 수 있다면, 메인 메모리 접근을 크게 줄일 수 있습니다.
L3 캐시
L3 캐시는 여러 코어가 함께 사용하는 더 큰 캐시입니다. L1과 L2가 코어 가까이에 붙어 있는 전용 공간이라면, L3는 코어들 사이에서 공유되는 완충 공간에 가깝습니다.
한 코어가 가져온 데이터가 L3에 남아 있으면, 다른 코어가 같은 데이터를 필요로 할 때 DRAM까지 내려가지 않고 L3에서 찾을 수 있습니다. 물리 시뮬레이션이나 잡 시스템처럼 여러 코어가 관련 데이터를 함께 다루는 경우, L3 캐시는 코어 사이의 데이터 재사용 비용을 줄이는 데 도움이 됩니다.
캐시 조회 순서
CPU가 데이터를 필요로 하면 가장 가까운 L1 캐시부터 확인합니다. L1에 없으면 L2, L3 순서로 내려가고, 어느 캐시에도 없을 때 DRAM에서 데이터를 가져옵니다.
캐시에 원하는 데이터가 있는 경우를 캐시 히트(Cache Hit)라고 하고, 없어서 더 아래 계층을 찾아야 하는 경우를 캐시 미스(Cache Miss)라고 합니다.
캐시에서 데이터를 찾으면 CPU는 비교적 짧은 대기 후 연산을 이어갈 수 있습니다. 반대로 모든 캐시에서 미스가 발생해 DRAM까지 내려가면 대기 시간이 크게 늘어납니다.
이 차이가 누적되면 같은 코드라도 실행 시간이 크게 달라집니다. 특히 반복문 안에서 매번 DRAM까지 내려가는 접근 패턴이 생기면, CPU는 연산보다 데이터를 기다리는 데 더 많은 시간을 쓰게 됩니다.
캐시의 동작 원리
CPU가 메모리에서 데이터를 가져올 때는 요청한 값 하나만 따로 가져오지 않습니다. 그 값이 들어 있는 주변 메모리까지 묶어서 캐시에 올립니다.
이때 캐시에 들어오는 고정 크기의 데이터 블록을 캐시 라인(Cache Line)이라고 합니다. 많은 CPU에서 캐시 라인은 64바이트 단위로 사용됩니다. 즉, 어떤 주소의 4바이트 정수 하나를 읽더라도, 캐시에는 그 주변 데이터까지 함께 들어올 수 있습니다.
캐시는 메모리를 일정한 크기의 블록으로 나누어 다룹니다. CPU가 어떤 값을 요청하면, 캐시는 그 값만 따로 가져오는 것이 아니라 그 값이 들어 있는 블록 전체를 가져옵니다.
따라서 요청한 값이 블록의 앞쪽에 있든 중간에 있든, 캐시에 올라오는 범위는 같습니다. 캐시가 데이터를 관리하는 기본 단위가 개별 변수나 바이트가 아니라 캐시 라인이기 때문입니다.
캐시가 블록 단위로 데이터를 가져오는 이유는 메모리 접근이 대개 한 지점에서 끝나지 않기 때문입니다. 배열처럼 연속된 데이터를 처리할 때는 하나의 값을 읽은 뒤 바로 옆의 값을 이어서 읽는 경우가 많습니다. 이때 주변 데이터가 이미 캐시에 올라와 있으면 다음 접근은 DRAM까지 내려가지 않아도 됩니다.
캐시 라인이 너무 작으면 근처 데이터를 충분히 활용하지 못해 캐시 미스가 자주 발생합니다. 반대로 너무 크면 실제로 쓰지 않을 데이터까지 많이 가져와 캐시 공간을 차지합니다. 많은 CPU에서 사용하는 64바이트 캐시 라인은 이 두 부담 사이에서 정해진 크기에 가깝습니다.
이 장점이 잘 드러나는 예가 배열 순차 접근입니다. 배열의 첫 번째 요소를 읽을 때 해당 캐시 라인이 함께 올라오면, 같은 캐시 라인에 들어 있는 다음 요소들은 DRAM까지 내려가지 않고 캐시에서 읽을 수 있습니다.
순차 접근에서는 처음 요소를 읽을 때 캐시 미스가 나더라도, 같은 캐시 라인에 들어 있는 다음 요소들은 캐시에서 바로 읽을 수 있습니다. 배열을 앞에서부터 차례대로 처리하는 코드가 캐시에 유리한 이유입니다.
공간적 지역성과 시간적 지역성
캐시가 성능에 도움이 되려면, 한 번 가져온 데이터가 곧 다시 쓰이거나 그 주변 데이터가 이어서 쓰여야 합니다. 이런 접근 경향을 지역성(Locality)이라고 합니다.
지역성은 보통 두 가지로 나누어 설명합니다. 가까운 주소의 데이터를 이어서 사용하는 공간적 지역성(Spatial Locality)과, 같은 데이터를 짧은 시간 안에 다시 사용하는 시간적 지역성(Temporal Locality)입니다.
공간적 지역성
공간적 지역성은 가까운 주소에 있는 데이터를 이어서 사용하는 경향입니다. 배열을 앞에서부터 차례대로 읽는 코드가 대표적입니다.
예를 들어 적 캐릭터의 위치가 positions 배열에 연속으로 저장되어 있고, 매 프레임 앞에서부터 순서대로 갱신된다고 하겠습니다. 첫 번째 위치 값을 읽을 때 그 주변 위치 값들도 같은 캐시 라인에 함께 올라올 수 있습니다.
이후 다음 위치 값을 읽을 때는 이미 캐시에 들어 있는 데이터를 사용할 가능성이 높습니다. 그래서 연속된 배열을 순차적으로 처리하는 코드는 DRAM 접근을 줄이기 쉽습니다.
시간적 지역성
시간적 지역성은 같은 데이터를 짧은 시간 안에 다시 사용하는 경향입니다.
예를 들어 플레이어 위치 값은 한 프레임 안에서도 여러 번 필요할 수 있습니다. 이동 처리에서 값을 갱신한 뒤, 충돌 판정이나 카메라 추적에서도 같은 위치 값을 다시 읽을 수 있습니다.
처음 접근할 때 캐시에 올라온 데이터가 잠시 뒤에도 남아 있다면, 다음 접근은 DRAM까지 내려가지 않고 캐시에서 처리됩니다. 같은 값을 반복해서 읽는 코드가 캐시에 유리한 이유입니다.
캐시는 이런 지역성이 있는 코드에서 가장 효과적으로 동작합니다. 연속된 데이터를 차례대로 읽거나, 방금 읽은 데이터를 곧 다시 사용하면 캐시 히트가 늘어나고 DRAM 접근은 줄어듭니다.
반대로 접근 위치가 계속 흩어지거나 한 번 읽은 데이터를 다시 사용하지 않는다면, 캐시에 올린 데이터가 충분히 활용되지 못합니다. 이런 상황에서는 캐시에서 원하는 데이터를 찾지 못하는 캐시 미스(Cache Miss)가 자주 발생합니다.
캐시 미스의 종류와 비용
CPU가 필요한 데이터를 캐시에서 찾지 못하는 상황을 캐시 미스(Cache Miss)라고 합니다.
캐시 미스가 발생하면 CPU는 더 아래의 메모리 계층에서 데이터를 가져와야 합니다. L1에 없으면 L2를 확인하고, L2에도 없으면 L3나 DRAM까지 내려갑니다. 그동안 해당 값을 필요로 하는 명령어는 계속 진행할 수 없습니다.
캐시 미스는 왜 원하는 데이터가 캐시에 없었는지에 따라 몇 가지 유형으로 나누어 볼 수 있습니다.
Cold Miss (콜드 미스)
콜드 미스는 프로그램이 어떤 데이터를 처음 접근할 때 발생합니다. 아직 그 데이터가 캐시에 올라온 적이 없으므로, 캐시가 아무리 잘 동작해도 첫 접근에서는 미스가 날 수밖에 없습니다.
그래서 콜드 미스는 강제 미스(Compulsory Miss)라고도 부릅니다. 처음 읽는 데이터는 하위 계층에서 가져와야 하고, 그 과정에서 해당 캐시 라인이 캐시에 채워집니다.
게임에서는 새 씬을 로드한 직후나 첫 프레임에서 이런 미스가 몰릴 수 있습니다. 텍스처, 메시, 컴포넌트 데이터처럼 아직 접근하지 않았던 데이터가 처음 사용되기 때문입니다. 다만 같은 데이터에 대한 콜드 미스는 최초 접근에서만 발생합니다.
Capacity Miss (용량 미스)
용량 미스는 작업 중에 사용하는 데이터가 캐시에 담을 수 있는 양보다 많을 때 발생합니다.
캐시는 공간이 부족해지면 기존에 들어 있던 캐시 라인 일부를 내보내고 새 데이터를 채웁니다. 이 과정을 축출(eviction)이라고 합니다.
문제는 내보낸 데이터를 다시 필요로 할 때입니다. 한 번 캐시에 올라왔던 데이터라도 이미 밀려난 뒤라면, CPU는 그 데이터를 하위 계층에서 다시 가져와야 합니다.
작업 대상이 많고 각 대상의 데이터가 크면 이런 상황이 쉽게 생깁니다. 예를 들어 많은 적 캐릭터의 상태를 순회하는 동안, 앞쪽 캐릭터의 데이터가 캐시에 올라왔다가 뒤쪽 캐릭터를 처리하는 사이 밀려날 수 있습니다. 이후 앞쪽 데이터를 다시 참조하면 캐시에 남아 있지 않아 용량 미스가 발생합니다.
Conflict Miss (충돌 미스)
충돌 미스는 캐시에 빈 공간이 남아 있는데도 특정 데이터가 밀려나는 경우입니다. 캐시 라인이 캐시 안의 아무 위치에나 들어갈 수 있는 것은 아니기 때문입니다.
캐시는 메모리 주소를 기준으로 각 캐시 라인이 들어갈 구역을 정합니다. 이 구역을 set이라고 합니다. 같은 set으로 배정된 캐시 라인들은 서로 같은 공간을 나누어 써야 합니다.
set 하나가 동시에 담을 수 있는 캐시 라인 수는 제한되어 있습니다. 이 개수를 way라고 부릅니다. 예를 들어 8-way 캐시에서는 같은 set 안에 캐시 라인 8개까지 보관할 수 있습니다.
문제는 서로 다른 데이터가 계속 같은 set으로 배정될 때 생깁니다. 캐시 전체에는 여유가 있어도 해당 set이 이미 가득 차 있다면, 새 캐시 라인을 넣기 위해 기존 캐시 라인을 밀어내야 합니다. 이후 밀려난 데이터를 다시 읽으면 충돌 미스가 발생합니다.
캐시 미스의 비용
캐시 미스가 발생하면 CPU는 더 느린 계층에서 데이터가 도착할 때까지 기다려야 합니다. 해당 데이터가 필요한 명령어는 값을 받기 전까지 실행을 마칠 수 없습니다.
이 대기는 이전 글에서 다룬 파이프라인 스톨(stall)로 이어질 수 있습니다. 데이터 접근이 빠르게 끝나면 파이프라인은 곧바로 이어지지만, DRAM까지 내려가야 하면 그만큼 빈 시간이 길어집니다.
CPU의 연산 장치가 충분히 빠르더라도, 필요한 데이터가 제때 도착하지 않으면 다음 계산을 진행할 수 없습니다. 캐시 미스가 성능에 큰 영향을 주는 이유는 연산 자체보다 데이터를 기다리는 시간이 길어질 수 있기 때문입니다.
out-of-order 실행을 지원하는 CPU는 이 손실을 어느 정도 줄일 수 있습니다. 어떤 데이터가 도착하기를 기다리는 동안, 그 데이터에 의존하지 않는 다른 명령어를 먼저 실행할 수 있기 때문입니다.
하지만 캐시 미스가 계속 이어지면 이 방식에도 한계가 있습니다. 기다리는 명령어가 쌓이고, 독립적으로 먼저 처리할 수 있는 명령어가 부족해지면 CPU는 결국 데이터가 도착할 때까지 멈춰 서게 됩니다.
메모리 접근 패턴이 성능에 미치는 영향
캐시 미스를 줄이려면 어떤 데이터를 읽는지뿐 아니라, 어떤 순서로 읽는지도 중요합니다. 처리하는 데이터의 양이 같아도 접근 순서가 달라지면 캐시 히트율이 크게 달라질 수 있습니다.
차이가 가장 잘 드러나는 예가 순차 접근과 랜덤 접근입니다.
순차 접근 vs 랜덤 접근
순차 접근은 메모리에 연속으로 배치된 데이터를 차례대로 읽는 방식입니다. 배열을 앞에서부터 끝까지 순서대로 순회하는 경우가 대표적입니다.
랜덤 접근은 다음에 읽을 데이터가 현재 위치 근처에 있다고 기대하기 어려운 접근 방식입니다. 연결 리스트(Linked List)를 포인터를 따라 순회하는 경우가 대표적입니다.
연결 리스트의 각 노드는 다음 노드의 주소를 따로 가지고 있습니다. 노드들이 메모리상에서 서로 떨어진 위치에 할당되어 있으면, 현재 노드를 읽어도 다음 노드가 같은 캐시 라인이나 가까운 주소에 있을 가능성이 낮습니다.
순차 접근은 한 번 가져온 캐시 라인 안의 데이터를 이어서 활용하기 쉽습니다. 그래서 첫 접근에서 미스가 발생하더라도, 같은 캐시 라인에 있는 다음 데이터는 캐시에서 읽을 가능성이 높습니다.
반대로 랜덤 접근은 매번 다른 위치로 이동하기 쉽습니다. 이전 접근에서 가져온 캐시 라인의 주변 데이터가 다음 접근에 도움이 되지 않으면, 캐시 라인을 새로 가져오는 일이 반복됩니다.
결국 같은 양의 데이터를 처리하더라도 순차 접근은 캐시 히트가 늘어나고, 랜덤 접근은 캐시 미스가 늘어날 수 있습니다. 이 차이가 반복문 안에서 누적되면 CPU 시간 차이로 나타납니다.
Array of Structs vs Struct of Arrays
캐시 효율은 자료구조의 종류만으로 결정되지 않습니다. 배열을 사용하더라도, 실제로 필요한 데이터가 메모리에 어떻게 섞여 있는지에 따라 캐시 라인의 활용도가 달라집니다.
게임 코드에서 이 차이가 자주 드러나는 지점이 AoS(Array of Structs)와 SoA(Struct of Arrays) 데이터 배치입니다.
먼저 AoS는 한 개체가 가진 여러 값을 하나의 구조체에 모으고, 그 구조체를 배열로 저장하는 방식입니다. 적 캐릭터의 위치, 속도, 체력, 상태를 함께 저장하면 다음과 같은 형태가 됩니다.
1
2
3
4
5
6
7
8
struct Enemy {
Vector3 position;
Vector3 velocity;
float health;
int state;
}
Enemy[] enemies = new Enemy[1000];
SoA는 개체별로 데이터를 묶지 않고, 같은 종류의 값끼리 별도의 배열로 분리해 저장하는 방식입니다. 위치만 갱신하는 코드라면 positions 배열만 순차적으로 읽을 수 있습니다.
1
2
3
4
Vector3[] positions = new Vector3[1000];
Vector3[] velocities = new Vector3[1000];
float[] healths = new float[1000];
int[] states = new int[1000];
적의 위치를 갱신하는 루프에서는 보통 position과 velocity만 필요합니다. health나 state는 같은 적 데이터에 들어 있어도, 이 작업에서는 사용하지 않습니다.
AoS 방식에서는 위치 갱신에 필요하지 않은 health, state도 같은 구조체에 들어 있으므로 캐시 라인에 함께 올라올 수 있습니다. 반면 SoA 방식에서는 positions와 velocities처럼 필요한 배열만 순차적으로 접근할 수 있습니다.
따라서 특정 필드만 대량으로 처리하는 루프에서는 SoA가 캐시 라인을 더 효율적으로 사용할 수 있습니다. 단, 항상 SoA가 더 좋은 것은 아닙니다. 한 개체의 여러 필드를 한꺼번에 자주 읽는 코드라면 AoS가 더 단순하고 접근 패턴도 나쁘지 않을 수 있습니다.
Unity의 DOTS(Data-Oriented Technology Stack)와 ECS(Entity Component System)도 이 관점에서 이해할 수 있습니다.
ECS는 데이터를 오브젝트 단위로 흩어 두기보다, 같은 조합의 컴포넌트를 가진 엔티티들을 청크(Chunk) 단위로 모아 저장합니다. 시스템은 자신에게 필요한 컴포넌트 집합만 순회하므로, 접근 패턴이 연속적인 데이터 처리에 가까워집니다.
예를 들어 위치 갱신 시스템은 위치와 속도에 해당하는 컴포넌트를 중심으로 순회합니다. 이 구조에서는 작업에 필요하지 않은 데이터가 캐시 라인에 섞이는 일을 줄이고, 같은 종류의 데이터를 연속적으로 처리하기 쉬워집니다.
대역폭과 지연의 차이
캐시 히트가 늘어나면 느린 메모리 접근을 줄일 수 있습니다. 하지만 메모리 성능은 캐시 히트율만으로 설명되지 않습니다.
캐시에 없는 데이터를 읽거나 큰 데이터를 연속으로 옮길 때는 메모리 자체의 성능이 중요해집니다. 이 성능은 데이터를 요청한 뒤 도착하기까지의 시간인 지연(Latency)과, 일정 시간 동안 옮길 수 있는 데이터의 양인 대역폭(Bandwidth)으로 나누어 볼 수 있습니다.
지연은 메모리 요청 하나가 완료되기까지 걸리는 시간입니다. 포인터를 따라 다음 노드로 이동하는 코드처럼, 현재 데이터를 읽어야 다음 주소를 알 수 있는 경우에는 요청을 겹치기 어렵습니다. 이런 코드는 데이터가 도착할 때까지 다음 단계로 진행하기 어렵기 때문에 지연의 영향을 크게 받습니다.
대역폭은 일정 시간 동안 옮길 수 있는 데이터의 양입니다. 큰 배열을 순차적으로 처리하거나 정점, 텍스처처럼 큰 데이터를 전송하는 작업에서는 개별 요청의 대기 시간보다 전체 데이터 전송량이 더 중요해질 수 있습니다.
따라서 지연 병목은 다음 데이터가 도착하기를 기다리는 문제에 가깝고, 대역폭 병목은 옮겨야 할 데이터량이 전송 능력을 넘어서는 문제에 가깝습니다. 게임 코드에서는 접근 패턴에 따라 어느 쪽이 더 크게 드러나는지가 달라집니다.
프리페치(Prefetch)
순차 접근이 지연의 영향을 덜 받는 이유 중 하나는 프리페치(Prefetch)입니다.
CPU의 하드웨어 프리페처(Hardware Prefetcher)는 메모리 접근이 일정한 방향으로 이어지는지 감지합니다. 예를 들어 배열을 앞에서부터 차례대로 읽고 있다면, CPU는 다음에도 그 뒤쪽 주소를 읽을 가능성이 높다고 판단할 수 있습니다.
이때 프리페처는 현재 캐시 라인을 처리하는 동안 다음 캐시 라인을 미리 가져오려고 합니다. 예측이 맞으면 다음 캐시 라인이 필요해지는 시점에는 이미 캐시에 들어와 있으므로, CPU가 메모리 응답을 기다리는 시간이 줄어듭니다.
프리페치가 효과를 내려면 접근 패턴이 어느 정도 예측 가능해야 합니다. 배열을 순서대로 읽는 코드는 다음 주소를 예상하기 쉽기 때문에 프리페치가 잘 맞는 편입니다.
반대로 포인터를 따라 이동하거나 접근 위치가 계속 바뀌는 코드는 다음 주소를 미리 알기 어렵습니다. 이런 경우에는 프리페치가 충분히 앞서 움직이기 어렵고, 캐시 미스의 지연이 그대로 드러나기 쉽습니다.
따라서 순차 접근이 빠른 이유에는 캐시 라인 재사용뿐 아니라, 다음 데이터를 미리 가져올 수 있는 프리페치의 효과도 포함됩니다.
모바일과 GPU에서의 대역폭 제약
프리페치가 잘 맞아도, 옮겨야 할 데이터의 양 자체가 많으면 대역폭이 병목이 됩니다. 이 문제는 GPU 작업과 모바일 환경에서 특히 중요합니다.
GPU는 매 프레임 텍스처, 정점, 렌더 타깃 데이터를 계속 읽고 씁니다. 화면 해상도가 높고 후처리 단계가 많을수록 메모리에서 오가는 데이터도 늘어납니다.
모바일에서는 이 대역폭을 더 조심해서 써야 합니다. 모바일 SoC는 CPU와 GPU가 같은 시스템 메모리를 공유하는 경우가 많고, 배터리와 발열 제약 때문에 메모리 대역폭을 무작정 넓히기 어렵습니다. GPU가 많은 텍스처 샘플링이나 렌더 타깃 쓰기를 요구하면, 같은 메모리를 쓰는 CPU 작업에도 영향이 갈 수 있습니다.
그래서 모바일 그래픽스에서는 대역폭을 줄이는 전략이 중요합니다. 텍스처 압축, 적절한 렌더 타깃 포맷, 불필요한 후처리 감소, 오버드로우 감소는 모두 메모리에서 오가는 데이터량을 줄이는 데 연결됩니다.
모바일 GPU에 널리 쓰이는 TBDR(Tile-Based Deferred Rendering) 구조도 같은 맥락에서 이해할 수 있습니다. 화면을 작은 타일로 나누고, 타일 안의 중간 데이터를 온칩 메모리에서 처리하면 DRAM을 오가는 횟수를 줄일 수 있습니다.
TBDR의 구체적인 구조는 GPU 아키텍처 (1)에서 더 자세히 다룹니다.
마무리
이번 글에서는 CPU가 데이터를 가져오는 과정이 왜 성능에 영향을 주는지, 그리고 캐시가 어떤 접근 패턴에서 효과적으로 동작하는지 살펴보았습니다.
- CPU와 DRAM 사이의 속도 차이를 줄이기 위해 레지스터와 여러 단계의 캐시가 계층적으로 배치됩니다.
- 캐시는 값을 하나씩 가져오기보다 캐시 라인 단위로 주변 데이터를 함께 가져오며, 이 구조는 공간적 지역성을 활용합니다.
- 같은 데이터를 짧은 시간 안에 다시 쓰는 시간적 지역성도 캐시 히트율을 높이는 중요한 조건입니다.
- 캐시 미스는 처음 접근, 용량 부족, set 충돌처럼 서로 다른 이유로 발생하며, 데이터가 도착할 때까지 파이프라인을 기다리게 만들 수 있습니다.
- 순차 접근과 필요한 데이터만 모아 처리하는 배치는 캐시 라인과 프리페치를 더 잘 활용하게 해 줍니다.
- 지연은 데이터 하나를 기다리는 비용에 가깝고, 대역폭은 많은 데이터를 옮길 때의 전송 한계에 가깝습니다. 모바일에서는 CPU와 GPU가 메모리 대역폭을 공유하므로 이 제약이 더 중요해집니다.
결국 메모리 최적화는 데이터를 덜 읽는 것만이 아니라, CPU가 기다리지 않도록 읽는 순서와 배치를 정리하는 작업입니다. 배열을 순차적으로 접근하고, 함께 처리할 데이터를 가까이 두고, 사용하지 않는 데이터를 캐시 라인에 덜 섞는 이유가 여기에 있습니다.
CPU가 데이터를 효율적으로 가져와 게임 상태를 계산하더라도, 화면을 구성하는 픽셀은 GPU가 처리합니다. 다음 글에서는 이 그래픽 연산을 전담하기 위해 GPU가 어떤 구조로 발전했는지로 이어집니다.
하드웨어 기초 (3) - GPU의 탄생과 발전에서는 CPU와 다른 방식으로 대량의 그래픽 연산을 처리하는 GPU의 배경과 구조를 다룹니다.
관련 글
전체 시리즈
- 하드웨어 기초 (1) - CPU 아키텍처와 파이프라인
- 하드웨어 기초 (2) - 메모리 계층 구조 (현재 글)
- 하드웨어 기초 (3) - GPU의 탄생과 발전
- 하드웨어 기초 (4) - 모바일 SoC
- 그래픽스 수학 (1) - 벡터와 벡터 연산
- 그래픽스 수학 (2) - 행렬과 변환
- 그래픽스 수학 (3) - 좌표 공간의 전환
- 그래픽스 수학 (4) - 투영
- C# 런타임 기초 (1) - 값 타입과 참조 타입
- C# 런타임 기초 (2) - .NET 런타임과 IL2CPP
- C# 런타임 기초 (3) - 가비지 컬렉션의 기초
- C# 런타임 기초 (4) - 스레딩과 비동기
- 색과 빛 (1) - 빛의 물리적 원리
- 색과 빛 (2) - 색 표현과 색공간
- 색과 빛 (3) - 셰이딩 모델
- 래스터화 파이프라인 (1) - 삼각형에서 프래그먼트까지
- 래스터화 파이프라인 (2) - 버퍼 시스템
- 래스터화 파이프라인 (3) - 디스플레이와 안티앨리어싱
- Unity 엔진 핵심 (1) - GameObject와 Component
- Unity 엔진 핵심 (2) - Transform 계층과 씬 그래프
- Unity 엔진 핵심 (3) - Unity 실행 순서
- Unity 엔진 핵심 (4) - Unity의 스레딩 모델
- Unity 에셋 시스템 (1) - Asset Import Pipeline
- Unity 에셋 시스템 (2) - Serialization과 Instantiation
- Unity 에셋 시스템 (3) - Scene Management
- Unity 렌더링 (1) - Camera와 Rendering Layer
- Unity 렌더링 (2) - Render Target과 Frame Buffer
- Unity 렌더링 (3) - Render Pipeline 개요