Skip to content

[Summary] Vulkan Buffer 메모리 전략 #63

Description

@Winteradio

Vulkan Buffer 메모리 전략 (eBufferType / eBufferTransfer / eDataAccess ↔ Vulkan Flags)

작성자: Winteradio
날짜: 2026-08-09
주제: VulkanSystem::InitializeBuffereBufferType/eBufferTransfer/eDataAccess를 Vulkan VkBufferUsageFlags/VkMemoryPropertyFlags로 매핑하는 설계
상태: 🟡 설계 확정, 구현 진행 중


배경

VulkanSystem::InitializeBuffer()는 세 개의 독립적인 축을 조합해서 최종 Vulkan 값을 만듦.

  • eBufferType — 이 버퍼가 어느 GPU 파이프라인 단계에 바인딩되는가 → VkBufferUsageFlags의 역할 비트
  • eBufferTransfer — 이 버퍼가 copy 명령의 원본/목적지로 쓰일 수 있는가 → VkBufferUsageFlags의 Transfer 비트
  • eDataAccess + eBufferTransfer — 이 버퍼가 물리적으로 어디에 있어야 하는가 → VkMemoryPropertyFlags

eBufferType — 역할 비트

어느 GPU 파이프라인 단계(고정 기능 유닛 또는 셰이더 바인딩)가 이 버퍼를 읽는지(또는 storage의 경우 읽고 쓰는지)를 나타냄.

eBufferType VkBufferUsageFlags 읽는(또는 읽고 쓰는) 주체
eVertex VERTEX_BUFFER_BIT vertex-input-assembly 단계
eIndex INDEX_BUFFER_BIT index-fetch 단계
eConst UNIFORM_BUFFER_BIT 셰이더 스테이지(uniform 바인딩)
eStorage STORAGE_BUFFER_BIT 셰이더 스테이지(SSBO 바인딩, 읽기/쓰기 모두 가능)
eIndirect INDIRECT_BUFFER_BIT command processor(draw/dispatch 인자로 사용)
eTexel UNIFORM_TEXEL_BUFFER_BIT | STORAGE_TEXEL_BUFFER_BIT texel buffer view를 통한 접근
eNone (역할 비트 없음) draw/dispatch에 전혀 바인딩되지 않음 — 순수 전송 버퍼

eBufferTransfer — Transfer 비트 (copy 엔진 전용, 독립된 축)

vkCmdCopyBuffer 같은 copy 명령은 vertex-fetch나 셰이더 코어와는 별개의 전송(DMA) 엔진이 처리함.
그래서 eBufferType의 역할 비트가 있다고 해서 자동으로 copy 명령에 참여할 수 있는 건 아님 —
draw call에서 vertex로 읽히는 것과 copy 명령에서 원본/목적지로 쓰이는 건 서로 무관한 별개의 용도라
각각 명시적으로 비트를 켜야 함.

eBufferTransfer VkBufferUsageFlags 의미
eSource TRANSFER_SRC_BIT 이 버퍼가 copy 명령의 srcBuffer가 될 수 있음 — 전송 엔진이 여기서 읽어감
eDestination TRANSFER_DST_BIT 이 버퍼가 copy 명령의 dstBuffer가 될 수 있음 — 전송 엔진이 여기로 써줌

비트플래그라 eSource | eDestination 동시 설정도 가능함(스크래치/중간 버퍼 등).

결합 예시

VkBufferUsageFlags = GetBufferType(bufferType) | GetBufferTransfer(bufferTransfer)

eBufferType eBufferTransfer 결과 전형적 케이스
eNone eSource TRANSFER_SRC_BIT 업로드용 staging 버퍼
eNone eDestination TRANSFER_DST_BIT 리드백 버퍼
eVertex eNone VERTEX_BUFFER_BIT eStatic — 업로드 후 다시 안 건드림
eVertex eDestination VERTEX_BUFFER_BIT | TRANSFER_DST_BIT eDynamic/eStream(큰 경우) — copy로 재-staging
eVertex eSource VERTEX_BUFFER_BIT | TRANSFER_SRC_BIT 디버그/툴링용으로 이 버퍼 내용을 읽어가야 할 때

eDataAccess + eBufferTransferVkMemoryPropertyFlags (GetBufferProperty)

eDataAccess::eStaging는 "GPU 바인딩 역할이 없는 순수 전송 버퍼"를 뜻함(주석 참고: GPU Binding Datas는
eStatic/eDynamic/eStream, GPU Transfer Datas는 eStaging). 이 값과 eBufferTransfer의 방향을
조합하면 업로드용인지 리드백용인지가 구분됨 — 별도로 eReadBack 값을 추가하지 않아도 됨.

eDataAccess eBufferTransfer VkMemoryPropertyFlags 설명
eStatic (무관) DEVICE_LOCAL GPU 전용, staging으로 최초 1회만 채움
eDynamic (무관) DEVICE_LOCAL|HOST_VISIBLE|HOST_COHERENT 매핑 공유 우선 시도
eStream (무관) DEVICE_LOCAL|HOST_VISIBLE|HOST_COHERENT 매핑 공유 우선 시도(큰 경우 재-staging 전략은 InitializeBuffer가 별도 처리)
eStaging eSource HOST_VISIBLE|HOST_COHERENT 업로드 버퍼 — CPU가 쓰고 GPU가 복사해감
eStaging eDestination HOST_VISIBLE|HOST_CACHED 리드백 버퍼 — GPU가 복사로 써주고 CPU가 읽음

eStatic/eDynamic/eStream은 항상 GPU에 바인딩되는 "진짜" 리소스라 eBufferTransfer 방향과 무관하게
값이 고정됨. eStaging일 때만 방향을 봐서 COHERENT(업로드)냐 CACHED(리드백)냐를 가름.

eDynamic/eStream의 "매핑 공유 우선 시도"는 DEVICE_LOCAL|HOST_VISIBLE|HOST_COHERENT 조합이 용량
제한으로 실패할 수 있다는 뜻 — 그때는 HOST_VISIBLE|HOST_COHERENT만으로 재시도해야 함. 이 폴백은
GetBufferProperty 하나의 반환값으로 표현할 수 없는 실제 할당 시도 로직이라 InitializeBuffer
m_memory->Allocate() 결과를 보고 처리함.


왜 생성 시점에 별도의 매핑 의도 플래그가 없는가

Unreal Engine의 EBufferUsageFlagsBUF_Static/BUF_Dynamic/BUF_Volatile을 생성 시점에 한 번 정하고,
그 값 자체가 곧 매핑 가능 여부를 결정함 — 별도로 "나중에 매핑할 거야?"를 또 묻지 않음.

같은 이유로 eDataAccess::eDynamic/eStream이라는 값 자체가 이미 "CPU가 이후에 이 버퍼를 건드릴 것"이라는
의도를 담고 있어서, 생성 시점에 별도의 매핑 의도 신호가 필요 없음. CPU가 생성 후 전혀 안 건드릴 거면
그건 애초에 eStatic이었어야 함. (참고로 eMapAccessRHIBufferUpdateDesc에만 있고, 이건 매 Lock/Map
호출마다 정확히 뭘 할 건지를 나타내는 별개의 용도 — GL의 glMapBufferRange access 파라미터와 같은 자리.)


VkBufferCreateInfo 필드

typedef struct VkBufferCreateInfo {
    VkStructureType        sType;
    const void*            pNext;
    VkBufferCreateFlags    flags;
    VkDeviceSize           size;
    VkBufferUsageFlags     usage;
    VkSharingMode          sharingMode;
    uint32_t               queueFamilyIndexCount;
    const uint32_t*        pQueueFamilyIndices;
} VkBufferCreateInfo;
필드 비고
sType VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO 고정
pNext nullptr 확장 구조체 체이닝용, 지금은 불필요
flags 0 sparse binding 등 특수 케이스 전용, 일반 버퍼는 불필요
size 버퍼 바이트 크기 실제 바인딩될 메모리 크기(VkMemoryRequirements::size)는 정렬 때문에 이보다 커질 수 있음
usage GetBufferType(bufferType) | GetBufferTransfer(bufferTransfer) 위 표들의 결합 결과
sharingMode VK_SHARING_MODE_EXCLUSIVE VulkanDevice가 GRAPHICS|COMPUTE|TRANSFER를 만족하는 큐 패밀리 하나만 쓰므로, 여러 큐 패밀리가 나눠 쓰는 상황 자체가 없음
queueFamilyIndexCount 0 EXCLUSIVE일 때 무시됨
pQueueFamilyIndices nullptr EXCLUSIVE일 때 무시됨

메모리 속성 유형 — 하드웨어 데이터 전송 경로별 분류

eStatic/eDynamic/eStream/eStaging에 적용되는 다섯 가지 전송 경로.

유형 플래그 데이터 전송 경로
GPU 전용 메모리 DEVICE_LOCAL GPU 코어 ↔ VRAM 직접 접근. CPU는 접근 불가, staging 버퍼를 거쳐 복사로만 채움. 가장 빠름
CPU→GPU 논캐시 전송 HOST_VISIBLE | HOST_COHERENT CPU가 캐시를 거치지 않고(Write-Combining) System RAM에 직접 씀. flush 불필요. GPU는 매 접근마다 PCIe를 거쳐 읽음
GPU→CPU 캐시 리드백 HOST_VISIBLE | HOST_CACHED GPU가 쓴 데이터를 CPU가 자신의 캐시를 거쳐 반복해서 빠르게 읽음. 읽기 전 invalidate 필요
GPU-CPU 매핑 공유(용량 제한) DEVICE_LOCAL | HOST_VISIBLE | HOST_COHERENT VRAM 중 CPU가 직접 매핑 가능한 좁은 영역(ReBAR 등). GPU는 VRAM 속도, CPU도 직접 접근. 용량이 작아서 못 들어가면 위의 "CPU→GPU 논캐시 전송"으로 폴백
통합 메모리 공유 HOST_VISIBLE | HOST_COHERENT | HOST_CACHED CPU/GPU가 물리적으로 같은 RAM과 캐시 일관성 하드웨어를 공유하는 구조(통합 메모리 아키텍처) 전용. 본 설계 대상 아님

핵심 규칙 두 가지

1. HOST_COHERENTHOST_VISIBLE 없이 단독으로 쓰지 않음.
COHERENT는 CPU가 매핑해서 접근할 때의 캐시 동작을 설명하는 플래그임.
매핑 자체가 안 되는 메모리엔 적용 대상이 없음.

2. DEVICE_LOCALHOST_VISIBLE은 공존 가능하지만 용량이 제한적임(GPU-CPU 매핑 공유).
그래서 "작으면 CPU 쪽, 크면 GPU 쪽"이 아니라 반대에 가까움.
작은 버퍼가 오히려 이 좁은 풀에 들어가서 GPU 속도와 CPU 접근을 동시에 얻고,
큰 버퍼는 이 풀에 못 들어가서 순수 HOST_VISIBLE로 떨어짐.
구현상으로는 "매핑 공유를 먼저 시도하고, 실패하면 논캐시 전송으로 폴백"하는 2단계 로직임.


Write-Combining(WC) — 왜 CPU 쓰기 전용 데이터는 캐시가 없는 편이 더 빠른가

한 번 쓰고 CPU가 다시 읽지 않는 데이터(vertex/uniform buffer 업로드)라면,
일반 캐시를 거치는 경로가 오히려 더 비쌈.

[캐시 있음] Read-For-Ownership(RFO) -> 쓰기 -> 나중에 write-back
[캐시 없음, WC] 쓰기(작은 버퍼에 모음) -> 한 번에 flush

RFO가 낭비인 이유

일반 write-back 캐시는 특정 캐시라인에 쓰기를 하려면,
그 라인을 캐시에 온전히 올려야 dirty 표시를 할 수 있음.
아직 캐시에 없는 라인이면 CPU는 먼저 그 라인을 메모리에서 읽어와서(RFO) 캐시에 올린 다음
그 위에 덮어씀.

문제는 어차피 전부 새로 쓸 데이터라, 이 읽기 자체가 필요 없는 낭비라는 점임.
게다가 나중에 그 dirty 라인이 캐시에서 밀려날 때 또 한 번 메모리로 write-back 됨.
결국 RFO 읽기 1번 + 나중 write-back 1번, 총 두 번의 메모리 트랜잭션이 발생함.

WC는 이 읽기를 아예 생략함

HOST_VISIBLE만 있고 HOST_CACHED는 없는 메모리("CPU→GPU 논캐시 전송" 유형)는
보통 CPU 쪽에서 Write-Combining(WC) 메모리 타입으로 매핑됨.

WC는 코어당 몇 개 안 되는(보통 4~10개) 작은 전용 버퍼를 씀.
각 버퍼 엔트리는 캐시라인 하나(보통 64바이트) 크기의 목표 주소를 담당하고,
그 주소에 들어오는 여러 개의 작은 쓰기를 모았다가(combining)
꽉 차거나 다른 주소로 넘어가는 시점에 한 번의 큰 버스트 쓰기로 메모리에 내보냄.

RFO 없이 곧바로 쓰기만 하니 트랜잭션이 하나 줄고,
쓰고 다시 안 읽을 데이터로 캐시를 오염시켜서 다른 유용한 데이터를 밀어내는 부작용도 없음.

이게 "CPU→GPU 논캐시 전송"이 업로드에 적합하고,
"GPU→CPU 캐시 리드백"은 반대 방향(GPU가 쓰고 CPU가 반복해서 읽는 경우)에만
필요한 이유임.

WC 버퍼가 부족해지는 경우

WC 버퍼 엔트리는 코어당 소수(4~10개)뿐인 고정 하드웨어 자원임.
그래서 동시에 활성화해야 하는 서로 다른 목표 주소의 개수가 이 엔트리 수를 넘으면,
기존 엔트리 중 하나를 (아직 다 안 채워졌어도) 강제로 지금 상태로 flush해서 자리를 비워야 함.

이후 코드가 그 주소로 다시 돌아와 나머지를 쓰려고 하면,
이미 엔트리가 사라진 상태라 완전히 새 엔트리를 배정받고,
같은 캐시라인 주소에 두 번째의 더 작은 버스트 전송을 하게 됨.
원래 한 번의 깔끔한 버스트로 나갔어야 할 게 쪼개져서 나가는 셈이라,
WC의 combining 효과가 무너짐.

구체적 예시: 씬에 오브젝트가 여러 개 있고, 각자의 유니폼 버퍼(MVP 행렬 등)가
VulkanMemory의 TLSF arena 안에서 서로 떨어진 offset에 서브 할당돼 있는 상황.
매 프레임 오브젝트를 순회하며 갱신할 때, 그 순회 순서가 메모리 주소 순서와 일치할 이유가
없어서 여러 개의 멀리 떨어진 영역을 번갈아 쓰게 됨 — WC 엔트리를 스래싱시키는 전형적 패턴임.

이 현상은 캐시 스래싱(작업 집합이 캐시 용량을 넘어서 계속 밀려나는 것)과 같은 종류의 문제고,
메모리 할당기(allocator)의 파편화와는 다른 개념임 — 다만 arena의 서브 할당이
흩어져 있는 것(할당기 파편화)이 원인이 되어, 결과적으로 WC 스래싱을 유발한다는 점에서
인과관계로 이어져 있음.


다이어그램

1. 세 축의 조합 구조

flowchart TD
    BT["eBufferType<br/>(역할)"] -->|"GetBufferType()"| U1["VkBufferUsageFlags<br/>역할 비트"]
    BTr["eBufferTransfer<br/>(Source/Destination)"] -->|"GetBufferTransfer()"| U2["VkBufferUsageFlags<br/>Transfer 비트"]
    U1 --> U["최종 VkBufferUsageFlags<br/>(OR 결합)"]
    U2 --> U

    DA["eDataAccess<br/>(Static/Dynamic/Stream/Staging)"] -->|"GetBufferProperty()"| P["VkMemoryPropertyFlags"]
    BTr -->|"방향(eStaging일 때만 사용)"| P
Loading

2. 전송 경로별 하드웨어 배치

flowchart LR
    Cache["CPU Cache<br/>L1/L2/L3"]
    RAM["System RAM"]
    VramFull["VRAM<br/>GPU 전용 메모리"]
    VramSmall["VRAM<br/>GPU-CPU 매핑 공유(용량 제한)"]
    GPU["GPU Cores"]

    Cache -->|"GPU→CPU 캐시 리드백<br/>invalidate 필요"| RAM
    RAM -.->|"staging 경유 복사"| VramFull
    RAM -->|"CPU→GPU 논캐시 전송<br/>GPU가 PCIe로 읽음"| VramFull
    VramSmall -->|"GPU-CPU 매핑 공유<br/>용량 제한"| Cache
    VramFull --> GPU
Loading

통합 메모리 공유(CPU/GPU가 같은 RAM과 캐시 일관성 하드웨어를 공유)는
별도 아키텍처 전용이라 위 다이어그램엔 없음.

3. WC 엔트리 스래싱 (파편화된 오브젝트 유니폼 버퍼 갱신)

sequenceDiagram
    participant CPU
    participant WC as WC 버퍼 풀(엔트리 소수)
    participant MEM as 메모리

    CPU->>WC: Object1 유니폼 쓰기 (영역 A)
    WC->>WC: 엔트리 #1을 A에 배정
    CPU->>WC: Object2 유니폼 쓰기 (영역 B, A와 멀리 떨어짐)
    WC->>WC: 엔트리 #2를 B에 배정
    CPU->>WC: ObjectN+1 유니폼 쓰기 (영역 N+1)
    Note over WC: 모든 엔트리 사용 중, 빈 슬롯 없음
    WC->>MEM: 가장 오래된 엔트리(#1, 영역 A) 강제 flush<br/>(다 안 채워졌을 수 있음)
    WC->>WC: 엔트리 #1을 영역 N+1에 재배정
    CPU->>WC: Object1 유니폼 나머지 쓰기 (영역 A로 복귀)
    Note over WC: 영역 A용 엔트리가 이미 사라짐
    WC->>WC: 영역 A에 새 엔트리 배정
    WC->>MEM: 같은 주소로 두 번째의 더 작은 버스트 전송<br/>(combining 효과 상실)
Loading

4. GetBufferProperty 결정 흐름

flowchart TD
    Start{"eDataAccess?"}

    Start -->|eStatic| A["DEVICE_LOCAL"]
    Start -->|eDynamic| B["DEVICE_LOCAL|HOST_VISIBLE|HOST_COHERENT<br/>매핑 공유 우선"]
    Start -->|eStream| C["DEVICE_LOCAL|HOST_VISIBLE|HOST_COHERENT<br/>매핑 공유 우선<br/>(큰 경우 InitializeBuffer가 재-staging 전략으로 대체)"]
    Start -->|eStaging| Dir{"eBufferTransfer?"}
    Dir -->|eSource| D1["HOST_VISIBLE|HOST_COHERENT<br/>(업로드)"]
    Dir -->|eDestination| D2["HOST_VISIBLE|HOST_CACHED<br/>(리드백)"]
Loading

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

documentationImprovements or additions to documentation

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions