최근 타일 기반 렌더링에 대해 질문을 받은 적이 있습니다. 그 과정에서 Unity 빌드 설정 중 Memoryless Framebuffer Depth가 이 주제와 관련 있다는 것이 떠올랐습니다.
문제는, 분명 예전에 공부했던 내용인데 어째 단 1도 기억이 나지 않았다는 점입니다. 최근 직무 중 렌더링을 직접 들여다볼 일이 많이 줄었다고는 해도, 꽤 부끄러웠습니다. 그래서 이번 기회에 다시 정리해두려 합니다.
이 글은 두 편으로 나누어 작성합니다.
1부에서는 모바일 GPU에서 자주 사용되는 타일 기반 렌더링이 무엇인지, 왜 이런 구조가 필요한지, 일반적인 데스크톱 GPU식 렌더링 사고방식과 무엇이 다른지를 살펴봅니다.
특히 메모리 대역폭, 온칩 타일 메모리, render pass 경계가 왜 중요한지를 중심으로 설명합니다.
2부에서는 이 개념을 Unity와 Metal 레벨로 내려가서 살펴볼 예정입니다.
Unity의 Memoryless Depth 설정, URP Store Actions, Render Graph의 렌더 패스 병합, 그리고 Metal의 loadAction, storeAction, storageModeMemoryless가 어떤 식으로 같은 문제를 다루는지 연결해서 설명하려 합니다.
이번 아티클은 다음 Arm의 기술 아티클을 많이 참고했습니다.
이번 아티클에서 다룰 내용의 핵심은 다음과 같습니다.
- 모바일 타일 기반 GPU에서는 얼마나 빠르게 계산하는가 만큼, 외부 메모리에 얼마나 덜 갔다 오는가가 중요합니다.
서로 다른 비용 모델
일반적으로 렌더링 성능을 이야기할 때는 다음과 같은 기준을 떠올립니다.
- 셰이더 연산량
- 드로우콜 수
- 삼각형 수
- 렌더 패스 수
- 파이프라인 상태 전환
- 메모리 대역폭
데스크톱 환경에서는 개발자가 CPU에서 발생하는 드로우콜 비용이나 GPU의 직접적인 연산 비용에 상대적으로 더 집중하는 경우가 많습니다. 물론 데스크톱에서도 메모리 대역폭은 중요합니다. 다만 데스크톱 GPU는 상대적으로 큰 전력 예산, 냉각 여유, 넓은 메모리 버스를 전제로 설계됩니다.
모바일 GPU는 조건이 다릅니다. 모바일 기기는 전력 예산이 작고, 발열 여유도 작습니다. GPU 코어가 아무리 빠르게 연산할 수 있어도, 매 픽셀마다 외부 메모리에서 color, depth, stencil 값을 읽고 다시 쓰면 금방 전력과 발열 한계에 부딪힙니다.
그래서 모바일 GPU에서는 “계산을 어떻게 빠르게 할 것인가”만큼, 외부 메모리에 얼마나 덜 갔다 올 것인가가 중요합니다. 타일 기반 렌더링은 이 문제의 해법 중 하나입니다.
왜 대역폭이 모바일에서 중요한가
모바일에서 대역폭은 곧 전력 문제로 이어집니다. 로우레벨에서 GPU 코어와 외부 DRAM 사이의 경로는 대략 이렇게 볼 수 있습니다.
- GPU Core > L2 Cache / System Cache > 컨트롤러 > 물리 인터페이스 > DRAM
GPU 코어 내부에 있는 레지스터나 온칩 캐시를 접근하는 것과, 칩 외부의 LPDDR 메모리에 접근하는 것은 비용이 다릅니다. 외부 메모리에 접근하려면 GPU 코어에서 DRAM까지 이어지는 회로의 수많은 신호선과 트랜지스터 상태가 바뀌어야 합니다.
물리적으로는 배선과 트랜지스터의 기생 커패시턴스를 반복적으로 충전하고 방전하는 과정입니다. 초당 더 많은 비트를 이동시킬수록 더 많은 회로가 스위칭하고, 그만큼 더 많은 에너지가 열로 변환됩니다.
즉 외부 메모리 접근이 비싼 이유는 다음과 같습니다.
- 더 많은 비트를 읽고 쓴다 > 더 많은 커패시턴스를 충전/방전한다 > 더 많은 전력을 소모한다 > 더 많은 열이 발생한다
머리말에 언급한 Arm의 Mali GPU 관련 기술 아티클에서, 외부 DRAM의 읽기/쓰기 전력은 시스템 설계에 따라 다르지만 1GB/s의 대역폭당 약 120mW 수준이 될 수 있으며, 내부 메모리 접근은 그보다 대략 한 자릿수 정도 낮은 에너지 비용이라고 설명합니다.
이 관점에서 보면 모바일 GPU가 외부 메모리 접근을 줄이려는 이유는 단순히 속도가 느려서가 아닙니다. 외부 메모리 접근은 전력을 먹고, 전력은 발열로 이어지며, 발열은 결국 클록 저하와 성능 저하로 이어집니다.
렌더링은 외부 메모리를 매우 자주 접근하는 작업
문제는 렌더링이 원래 메모리 접근이 많은 작업이라는 점입니다.
픽셀 하나를 그릴 때 GPU가 항상 color 하나만 쓰는 것은 아닙니다. 일반적인 fragment 처리 과정에서는 여러 attachment를 읽고 쓸 수 있습니다.
예를 들어 다음과 같은 작업이 일어납니다.
- depth buffer read
- depth test
- depth buffer write
- color buffer read // blending이 있으면
- blend
- color buffer write
- stencil read/write // stencil을 쓰면
즉 픽셀 하나를 그린다는 것은 단순히 “색 하나 계산해서 저장한다”가 아닙니다. 기존 depth를 읽고, 새 depth를 쓰고, blending이 있으면 기존 color도 읽습니다. stencil을 사용하면 stencil buffer도 건드립니다. MSAA가 켜져 있으면 sample 수만큼 attachment의 작업량이 커집니다. overdraw가 있으면 같은 픽셀 위치를 여러 번 처리합니다.
간단히 1920×1080 해상도에서 60fps로 렌더링한다고 가정해보겠습니다.
- 픽셀 수: 1920 × 1080 = 2,073,600 픽셀
- 60fps 기준: 2,073,600 × 60 = 124,416,000 픽셀/초
RGBA8 컬러 버퍼에 한 번 쓰기만 해도 다음 정도의 쓰기 대역폭이 필요합니다.
- 124,416,000 픽셀/초 × 4 bytes = 약 497 MB/s
이건 컬러 버퍼 하나를 한 번 쓰는 비용만 단순 계산한 것입니다.
여기에 depth read/write, color blending read/write, stencil, MSAA, overdraw, post-process용 intermediate render texture가 붙으면 몇 GB/s에서 10GB/s 이상의 대역폭도 어렵지 않게 발생할 수 있습니다.
만약 앞서 언급한 Arm의 예시처럼 1GB/s당 120mW라는 값을 매우 단순하게 적용하면, 10GB/s는 메모리 트래픽만으로도 1.2W 수준의 전력 소모를 만들 수 있습니다. 즉, 모바일 렌더링에서 대역폭은 성능 문제이면서 동시에 전력 문제입니다.
그래서 모바일 GPU는 외부 메모리를 계속 읽고 쓰는 방식으로 프레임을 처리하기 어렵습니다. 이 문제를 줄이기 위해 등장하는 구조가 타일 기반 렌더링입니다.
데스크톱 GPU식 사고방식
타일 기반 렌더링을 보기 전에, 먼저 일반적인 데스크톱 GPU식 사고방식을 단순화해서 보겠습니다(물론 현대 데스크톱 GPU도 캐시, 압축, hierarchical Z 등 많은 최적화를 합니다).
다만 API 사용자 관점에서는 보통 렌더 타겟을 화면 크기의 큰 2D 이미지처럼 생각합니다.
Color Buffer
Depth Buffer
Stencil Buffer
↓
GPU Memory / VRAM
프리미티브가 들어오면 rasterizer가 fragment를 만들고, fragment는 파이프라인의 다음 단계로 흘러갑니다.
각 fragment는 depth test를 수행하고, 필요하면 color blending을 하고, 결과를 render target에 기록합니다.
이 모델에서는 작업 단위는 메모리에 렌더타겟 전체 이미지로 보입니다. GPU는 그 이미지의 특정 위치를 읽고, 테스트하고, 다시 씁니다.
그래서 데스크톱 GPU식 직관에서는 다음 생각이 자연스럽습니다.
- 렌더 타겟에 그렸다면, 그 결과는 버퍼에 남아 있습니다.
이 직관은 많은 경우 유용합니다. 렌더 패스가 끝난 뒤 color buffer나 depth buffer가 메모리에 남아 있고, 다음 패스에서 이를 읽거나, 후처리에 쓰거나, 다른 텍스처로 복사할 수 있다고 생각하기 쉽습니다.
하지만 타일 기반 GPU에서는 이 직관이 절반만 맞습니다.
타일 기반 GPU에서는 렌더링 결과가 외부 메모리에 남을 수도 있습니다. 하지만 렌더링 중간의 작업 세트가 항상 외부 메모리에 있는 것은 아닙니다.
모바일 GPU식 사고방식
타일 기반 GPU는 화면 전체를 작은 영역으로 나눕니다. 이 작은 영역을 tile이라고 부릅니다.
흐름을 단순화하면 다음과 같습니다.
- 화면을 작은 tile들로 나눈다.
- 각 primitive가 어떤 tile에 영향을 주는지 분류한다.
- tile 하나를 처리할 때 필요한 color/depth/stencil working set을 on-chip tile memory에 올린다.
- 그 tile에 포함되는 fragment들을 처리한다.
- 필요한 결과만 외부 메모리로 저장한다.
이 방식은 tile의 color/depth/stencil 작업세트를 코어 가까이에 있는 빠른 메모리에 둘 수 있어, 처리 속도가 빠르며 전력 소모가 낮습니다.
Apple의 TBDR(Tile-Based Deferred Renderer) GPU도 비슷하게 설명할 수 있습니다
하나의 render pass 안에서 먼저 tiling phase를 통해 geometry를 처리하고 primitive를 tile 목록으로 분류한 뒤,
rendering phase에서 tile별로 load action, rasterization, visibility 계산, shading, store action을 수행합니다
이 구조의 핵심은 다음입니다.
-
화면 전체 framebuffer
- 너무 큼
- 외부 메모리에 존재
-
현재 tile의 color/depth/stencil working set
- 작음
- on-chip tile memory에 올려 처리
온칩 타일 메모리는 작습니다. 화면 전체 framebuffer를 담기 위한 공간이 아닙니다.
대신 현재 처리 중인 tile 하나, 또는 GPU 내부 병렬성에 따라 소수의 tile에 필요한 working set만 담는 고속 작업 공간입니다.
이 방식은 다음과 같이 요약 가능합니다
- 타일 안에서 만들고, 타일 안에서 쓰고, 필요한 것만 타일 밖으로 내보냅니다.
타일 안에서 depth test, stencil test, blending, MSAA sample 처리를 끝낼 수 있으면 외부 메모리 왕복을 줄일 수 있습니다.
반대로 타일 밖으로 데이터를 내보내고, 다음 단계에서 다시 읽어야 하면 비용이 커집니다.
Render pass 경계가 중요해진다
타일 기반 GPU를 이해할 때 가장 헷갈리는 부분은 실행 순서입니다.
애플리케이션 코드만 보면 렌더링은 보통 이런 식으로 보입니다.
Render Pass A
Draw 1
Draw 2
Draw 3
Render Pass B
Draw 4
Draw 5
즉, 먼저 Render Pass A를 수행하고, 그다음 Render Pass B를 수행하는 구조처럼 보입니다. 하지만 타일 기반 GPU 내부에서는 하나의 render pass 안에서 실행 순서가 조금 다르게 재구성됩니다.
Render Pass A 시작
Tiling phase
Draw 1, 2, 3의 primitive들이
어떤 tile에 영향을 주는지 분류한다.
Rendering phase
Tile #0
이 tile에 걸린 Draw 1, 2, 3 처리
depth test
stencil test
blending
필요한 결과 store
Tile #1
이 tile에 걸린 Draw 1, 2, 3 처리
depth test
stencil test
blending
필요한 결과 store
Tile #2
...
즉 API 레벨에서는 draw call을 순서대로 제출하지만, GPU 내부에서는 render pass 안의 primitive들을 tile 기준으로 모아 처리할 수 있습니다.
다르게 표현하면, 타일 기반 렌더링에서는 하나의 render pass 안에서 타일 단위로 최대한 많은 작업을 끝낼 수 있다면 성능이 좋아집니다.
같은 render pass 안에서는 tile memory가 유지된다
하나의 render pass 안에서 여러 draw가 실행된다고 해보겠습니다.
Render Pass
Draw A
Draw B
Draw C
현재 GPU가 Tile #12를 처리 중이라면, 이 tile의 color/depth/stencil working set은 tile memory 안에 있습니다.
Tile #12
Draw A
depth write
color write
Draw B
이전 depth와 비교
blending 시 이전 color 참조
Draw C
같은 tile memory 안의 값 재사용
이때 depth test, stencil test, blending은 외부 메모리에 계속 갔다 오지 않아도 됩니다.
현재 tile의 depth, stencil, color 값이 이미 tile memory 안에 있기 때문입니다.
같은 render pass 안
같은 tile의 attachment working set을
tile memory에서 재사용할 수 있다.
그래서 타일 안에서 만들고, 타일 안에서 읽고, 타일 안에서 갱신할 수 있는 작업은 상대적으로 쌉니다.
하지만 render pass가 끝나면 tile memory는 보존되지 않는다
문제는 render pass 경계입니다.
예를 들어 다음과 같은 구조를 생각해보겠습니다.
Render Pass A
scene color 생성
Render Pass B
scene color를 샘플링해서 post-process
Render Pass A가 끝났다는 것은, A에서 사용하던 tile memory의 lifetime이 끝났다는 뜻에 가깝습니다.
Render Pass B에서 A의 결과가 필요하다면, A의 결과는 외부 메모리에 저장되어 있어야 합니다.
즉 흐름은 보통 이렇게 됩니다.
Render Pass A
Tile #0 처리
Tile #1 처리
Tile #2 처리
...
결과를 외부 메모리에 store
Render Pass B
외부 메모리에 저장된 scene color를 sampling
Tile #0 처리
Tile #1 처리
Tile #2 처리
...
따라서 render pass를 나누면 다음 비용이 생길 수 있습니다.
Pass A의 결과
tile memory
↓ store
external memory
Pass B 시작
external memory
↓ load 또는 texture sampling
shader / tile memory
render pass 경계는 일반적으로 어태치먼트를 (외부)메모리에 로드/세이브할지 결정하는 순간입니다.
그런데 타일 기반 GPU에서는 여기서 더해, 타일 메모리 안에 있던 데이터를 외부 메모리로 내보낼지도 결정하는 타이밍이 되므로 더 성능에 민감한 것입니다.
왜 여러 render pass를 타일 단위로 자동 합쳐주지 않는가
겉으로 보기에는 이렇게 다수의 패스를 각 타일 아래에 병합하는 최적화가 가능해 보일 수 있습니다.
Tile #0
Pass A 수행
Pass B 수행
Tile #1
Pass A 수행
Pass B 수행
이렇게 하면 Pass A의 결과를 tile memory 안에 둔 채로 Pass B까지 이어서 처리할 수 있으니 좋아 보입니다.
하지만 일반적인 렌더 패스들 사이에서는 이런식으로 최적화를 자동으로 적용하기 어렵습니다.
다음 pass가 이전 pass의 결과를 어떻게 읽을지 GPU가 단순히 tile-local하다고 가정할 수 없기 때문입니다.
예를 들어 Pass B에서 bloom을 구현한다고 해보겠습니다.
bloom은 현재 픽셀 하나만 읽는 것이 아니라 주변 픽셀을 읽습니다.
blur, bilinear filtering, mip sampling, depth of field, SSAO, screen-space effect도 비슷합니다.
이런 경우 Tile #0을 처리할 때도 인접 tile의 결과가 필요할 수 있습니다.
그러면 Pass A의 전체 결과가 먼저 완성되어 있어야 합니다.
Pass A의 결과를 texture로 샘플링한다
↓
결과가 외부 메모리에 존재해야 한다
↓
Pass A는 store가 필요하다
↓
Pass B는 그 texture를 다시 읽는다
즉, 타일 메모리 안에 있던 데이터를 외부 메모리로 내보냈다가 다시 읽어야만 하는 구조를 가진 fullscreen blit, intermediate render texture, opaque texture, depth texture, post-process pass가 모바일 타일 기반 GPU에서 비용을 만들기 쉬운 이유입니다.
loadAction과 storeAction
Metal에서 attachment는 load/store action을 통해 tile memory 안팎으로 이동합니다.
그리고 이 load/store traffic이 시스템 메모리 대역폭의 큰 부분을 차지할 수 있으므로 줄이는 것이 중요합니다.
필요 없는 데이터는 load하지 말고, 필요한 데이터만 store하는 것이 좋습니다
render pass가 시작될 때 GPU는 현재 tile의 attachment 값을 어떻게 초기화할지 알아야 합니다.
loadAction의 질문은 이것입니다.
- 이 pass를 시작할 때 이전 attachment 내용이 필요한가?
선택지는 대략 다음과 같습니다.
load
- 이전 pass 또는 이전 frame에서 저장된 값을 외부 메모리에서 읽어와 tile memory에 채운다.
clear
- 외부 메모리에서 읽지 않고 지정한 clear 값으로 tile memory를 초기화한다.
dontCare
- 이전 값이 필요 없다고 선언한다. 초기 tile memory 값은 보장하지 않아도 된다.
필요하면 load입니다.
필요 없으면 clear 또는 dontCare입니다.
반대로 render pass가 끝날 때는 tile memory 안에 계산된 결과를 어떻게 할지 정해야 합니다.
storeAction의 질문은 이것입니다.
- 이 pass가 끝난 뒤 attachment 결과가 다시 필요한가?
필요하면 store입니다.
필요 없으면 dontCare입니다.
예를 들어 최종 화면 color는 화면에 표시해야 하므로 저장해야 합니다.
하지만 depth가 현재 pass의 depth test에만 쓰이고, 이후 shader에서 샘플링되지 않는다면 pass가 끝난 뒤 depth 값은 필요 없습니다.
여러 단계를 tile memory 안에서 이어가려면
정리하면 load와 store는 tile memory와 external memory 사이의 데이터 이동입니다.
load
external memory → tile memory
store
tile memory → external memory
좋은 경우는 이런 구조입니다.
Render Pass
Tile #0
depth test
stencil test
blending
final color만 store
Tile #1
depth test
stencil test
blending
final color만 store
중간 attachment를 pass 밖으로 내보내지 않습니다.
depth나 stencil이 pass 안에서만 필요하다면 저장하지 않습니다. 최종적으로 필요한 color만 외부 메모리에 store합니다.
반대로 비싼 경우는 이런 구조입니다.
Pass A
intermediate color store
Pass B
intermediate color sampling
another intermediate store
Pass C
previous result sampling
final color store
이 구조는 기능적으로는 자연스럽습니다.
하지만 타일 기반 GPU 입장에서는 tile memory 안에서 끝날 수 있었던 데이터를 여러 번 외부 메모리로 내보내고 다시 읽는 구조가 됩니다.
그래서 모바일에서는 render pass를 어떻게 나누는지가 중요합니다.
그렇다고 여러 단계를 tile 단위로 이어가는 최적화가 불가능한 것은 아닙니다.
다만 그 단계들이 일반적인 별도 render pass로 분리되어 있으면 어렵습니다.
tile memory 안에서 이어가고 싶다면, 가능한 한 그 작업을 하나의 render pass 내부로 표현해야 합니다.
개념적으로는 이런 구조입니다.
One Render Pass
Tile #0
중간 데이터 생성
visibility 처리
lighting 또는 resolve
final color만 store
Tile #1
중간 데이터 생성
visibility 처리
lighting 또는 resolve
final color만 store
이런 방식에서는 중간 attachment를 외부 메모리에 저장할 필요가 줄어듭니다.
Metal의 memoryless attachment, tile shader, imageblock 같은 기능이 의미를 갖는 지점도 여기에 있습니다.
Metal의 tile shading은 렌더링과 컴퓨트 작업을 하나의 렌더 패스 안에서 결합하고, imageblock data와 threadgroup memory를 공유할 수 있게 합니다.
Unity에서도 비슷한 방향의 최적화가 있습니다. URP Render Graph는 렌더 패스와 리소스 의존성을 그래프로 표현하고, 사용되지 않는 리소스나 pass를 제거하며, 여러 렌더 패스를 런타임에 하나의 렌더 패스로 병합합니다. 병합된 native render pass는 texture를 tile memory에 유지할 수 있어 메모리 대역폭과 렌더링 시간을 줄일 수 있습니다
다만 이 부분은 Unity 쪽 이야기가 더 깊게 들어가야 하므로 2부에서 따로 다루겠습니다.
정리하면 다음과 같습니다.
별도 render pass로 끊긴다
→ 중간 결과를 store/load할 가능성이 커진다.
하나의 native render pass 안으로 유지된다
→ tile memory 안에서 이어갈 가능성이 커진다.
Memoryless Depth 설정
이제 처음에 제가 타일 기반 렌더링을 과거에 공부하게 만든 Memoryless Framebuffer Depth가 왜 이 주제와 연결되는지 보입니다.
Memoryless Depth는 depth buffer를 쓰지 않는다는 뜻이 아닙니다.
Depth attachment는 렌더링 중에는 여전히 중요합니다.
하지만 그 depth 값이 render pass 이후에도 항상 필요한 것은 아닙니다.
후처리에서 depth를 샘플링하지 않는다면 depth는 현재 render pass 안에서만 의미가 있습니다.
이 경우 depth의 lifetime은 이렇게 됩니다.
Render Pass 시작
depth clear
Render Pass 중
depth test
depth write
Render Pass 종료
depth 결과 불필요
store하지 않음
그러면 depth attachment는 외부 메모리에 저장될 필요가 없습니다.
tile memory 안에서만 존재해도 충분합니다.
Unity에서 RenderTexture.memorylessMode를 사용할 경우 (memoryless) render texture가 렌더링 중 on-tile memory에 임시 저장되며 CPU/GPU 메모리에 저장되지 않습니다.
이 개념은 2부에서 Unity 설정과 Metal 코드로 다시 연결해보겠습니다.
정리
- 타일 기반 GPU는 화면 전체를 계속 외부 메모리에 두고 처리하는 것이 아니라, 현재 tile의 working set을 on-chip tile memory에 올려 처리합니다.
- 같은 render pass 안에서는 이 장점을 살릴 수 있지만, render pass 경계를 넘으면 store/load 비용이 발생할 수 있습니다.
- 따라서 모바일 렌더링에서는 draw call이나 shader instruction만 볼 것이 아니라, render pass 경계와 attachment lifetime을 같이 봐야 합니다.
타일 기반 GPU의 비용 모델은 다음 문장으로 요약할 수 있습니다.
외부 메모리에 갔다 오는 것이 비쌉니다. tile memory 안에서 끝나는 것이 쌉니다.
2부에서는 타일 기반 렌더링을 Unity와 Metal에서 살펴보겠습니다.