사전 렌더 패스 데이터 레이스
문제

렌더 패스 데이터 레이스

gabury1고친 사람 github-actions[bot]

렌더 패스 데이터 레이스는 그래픽 카드에서 한 명령이 이미지를 다 그리기 전에 다른 명령이 그 이미지를 읽어 가는 고장입니다. 그래픽 카드는 순서를 따로 정해 주지 않은 명령들을 겹쳐서 돌리기 때문에 생깁니다. 대개는 맞게 그려지다가 가끔 화면 일부가 깨지므로 원인을 찾기 어렵습니다.

쉽고 빠른 이해

그리기 명령 둘이 같은 이미지를 두고 앞뒤가 엉키는 고장입니다. 그림자를 먼저 그려 두고 다음 단계에서 그 그림자를 읽는다고 해 봅시다. 그림자가 반만 그려진 채로 읽히면 화면에 얼룩이 생깁니다.

그래픽 카드는 빨리 끝내려고 명령을 겹쳐서 처리합니다. 순서가 중요한 곳을 프로그램이 알려 주지 않으면 그래픽 카드는 그 순서를 모릅니다.

어떻게 생기나:

  1. 앞 명령이 이미지에 그리기 시작합니다
  2. 둘 사이에 「앞이 끝나면 시작하라」는 표시가 없습니다
  3. 뒤 명령이 먼저 출발해 덜 그려진 이미지를 읽습니다

막을 때 치르는 값 — 표시를 넣으면 뒤 명령이 앞 명령을 기다립니다. 표시를 넓게 걸수록 그래픽 카드가 쉬는 시간이 늘어납니다.

언제 만나나 — 그래픽 카드에 명령을 직접 넘기는 엔진·라이브러리 코드에서 생깁니다. 엔진 위에서 장면만 꾸미는 코드는 대개 만나지 않습니다.

상세

이 절은 두 명령이 한 이미지를 두고 엇갈리는 경로 둘(렌더 패스 사이 · 한 렌더 패스 안)과 재현 조건 · 증상 · 막는 수단을 봅니다. 출발점은 그래픽 카드가 명령을 겹쳐 돌리는 방식입니다.

명령을 겹쳐 돌리는 그래픽 카드

그래픽 카드는 앞 명령이 끝나기 전에 뒤 명령을 시작합니다. 이 소절은 명령이 그래픽 카드로 넘어가는 길부터 그 구조를 차례로 봅니다.

그래픽 카드에서 계산을 맡는 칩이 GPU(Graphics Processing Unit, 그래픽 처리 장치)입니다. 프로그램은 GPU 를 직접 만지지 않습니다. 그래픽스 API(Application Programming Interface, 응용 프로그램 인터페이스)를 거쳐 명령을 넘깁니다.

프로그램은 그리기 명령을 여러 개 모아 한꺼번에 넘깁니다. 이 명령 모음을 담는 그릇이 커맨드 버퍼입니다. 한 번에 여러 개를 넘기면 명령마다 GPU 와 주고받는 수고가 줄어듭니다.

GPU 가 그리는 결과는 이미지입니다. 이미지를 이루는 점 하나가 픽셀입니다. 화면에 보이는 그림 한 장도 픽셀 수백만 개로 이루어집니다.

한 명령은 여러 처리 단계를 차례로 거칩니다. 꼭짓점의 위치를 계산하는 단계, 도형을 픽셀로 쪼개는 단계, 픽셀마다 색을 정하는 단계가 차례로 옵니다. 이 단계들의 줄이 그래픽스 파이프라인입니다.

GPU 는 앞 명령을 다 끝낸 뒤에 다음 명령을 시작하지 않습니다. 앞 명령이 마지막 단계에 있을 때 뒤 명령의 첫 단계를 돌립니다. 그래서 넘긴 순서와 끝나는 순서가 같다는 보장이 없습니다.

이렇게 겹쳐 돌리는 까닭은 GPU 의 계산 장치를 놀리지 않으려는 것입니다. GPU 에는 같은 계산을 나란히 하는 장치가 아주 많습니다. 명령마다 앞 명령이 끝나기를 기다리면 끝나 갈 무렵 대부분의 장치가 쉽니다.

렌더 패스와 어태치먼트

렌더 패스는 그리기 명령을 「이 이미지에 그린다」는 묶음으로 여닫는 단위입니다. 여는 명령에 그릴 이미지를 적습니다. 닫는 명령까지 들어온 그리기는 전부 그 이미지에 그려집니다.

렌더 패스에 걸어 두는 그릴 이미지가 어태치먼트입니다. 렌더 패스를 열 때 적는 이미지가 바로 이것입니다. 한 패스에 어태치먼트를 여럿 걸면 한 번의 그리기로 여러 값을 함께 남깁니다.

어태치먼트의 대표는 픽셀마다 색을 받는 렌더 타깃입니다. 픽셀마다 카메라에서 얼마나 먼지를 담는 깊이 버퍼도 어태치먼트로 겁니다. 깊이 버퍼가 있어야 가까운 물체가 먼 물체를 가립니다.

셰이더는 GPU 에서 도는 작은 프로그램입니다. 픽셀 하나의 색을 계산하는 일 같은 것을 맡습니다.

셰이더는 계산하는 도중에 이미지를 읽어 오기도 합니다. 이렇게 셰이더가 읽는 이미지가 텍스처입니다. 벽돌 무늬 사진을 벽에 입히는 것이 흔한 예입니다.

한 패스가 그린 어태치먼트를 다음 패스의 셰이더가 텍스처로 읽는 일이 흔합니다. 앞 패스의 결과를 뒤 패스에 넘기는 방법이기 때문입니다. 이 넘김이 이 편의 고장이 나는 길목입니다.

데이터 레이스라는 이름

데이터 레이스는 두 실행 흐름이 같은 메모리를 순서 없이 만지는 일입니다. 둘 중 적어도 하나가 쓰기일 때를 가리킵니다. 둘 사이에 「누가 먼저」를 정하는 장치도 없어야 합니다. 결과는 어느 쪽이 먼저 닿았느냐에 따라 달라집니다.

백엔드 코드에서는 두 스레드가 락 없이 같은 변수를 고칠 때가 이 경우입니다. 렌더 패스 데이터 레이스는 스레드 대신 GPU 명령 둘이, 변수 대신 이미지 하나를 두고 엇갈리는 것입니다.

순서가 엇갈리는 모양은 셋으로 나뉩니다. 앞 명령과 뒤 명령이 각각 읽는지 쓰는지로 가릅니다.

앞 명령 뒤 명령 부르는 이름 무엇이 틀어지나
쓴다 읽는다 쓰기 뒤 읽기 뒤 명령이 덜 쓰인 값을 읽는다
읽는다 쓴다 읽기 뒤 쓰기 앞 명령이 읽기 전에 뒤 명령이 값을 덮어쓴다
쓴다 쓴다 쓰기 뒤 쓰기 어느 쪽 값이 마지막에 남을지 정해지지 않는다

이 셋을 묶은 이름이 데이터 해저드(data hazard)입니다. 영어로는 read-after-write · write-after-read · write-after-write 라고 적습니다. 이 편이 주로 다루는 것은 첫째 줄입니다.

패스 사이에서 엇갈리는 경로

그림자 매핑을 예로 듭니다. 화면에 그림자를 드리우는 흔한 방법입니다. 첫 패스는 빛이 있는 쪽에서 장면을 그려 깊이 버퍼를 남깁니다. 픽셀마다 빛에서 얼마나 먼지가 담긴 이미지입니다.

둘째 패스는 카메라 쪽에서 장면을 그리며 그 깊이 버퍼를 텍스처로 읽습니다. 빛에서 떨어진 거리가 텍스처에 적힌 값보다 먼 픽셀이 그림자 진 픽셀이 됩니다.

둘 사이에 순서를 정해 두지 않으면 GPU 는 첫 패스가 끝나기 전에 둘째 패스를 시작할 수 있습니다. 둘째 패스는 첫 패스가 아직 안 쓴 픽셀에서 거기 남아 있던 옛 값을 읽습니다.

sequenceDiagram
    participant 프로그램
    participant GPU
    participant 깊이 as 깊이 버퍼
    프로그램->>GPU: 첫 패스와 둘째 패스를 넘긴다
    GPU->>깊이: 첫 패스가 쓰기 시작한다
    GPU->>깊이: 둘째 패스가 읽기 시작한다
    Note over GPU,깊이: 첫 패스가 아직 안 쓴 픽셀에는 옛 값이 있다
    깊이-->>GPU: 옛 값과 새 값이 섞여 나온다
    GPU->>깊이: 첫 패스가 쓰기를 마친다

그림에서 둘째 패스의 읽기는 첫 패스의 쓰기가 끝나기 전에 시작합니다. 그래서 둘째 패스가 받는 깊이 버퍼는 일부 픽셀만 새 값입니다. 화면에는 그림자가 반쯤만 드리우거나 없던 곳에 그림자가 생깁니다.

쓰기가 끝나도 안 보이는 값

GPU 는 메모리 앞에 캐시를 여럿 둡니다. 캐시는 자주 쓰는 값을 계산 장치 가까이 잠시 두는 작은 메모리입니다. 메모리까지 매번 오가면 느리기 때문에 둡니다.

어태치먼트에 그린 값은 한동안 그리기를 맡은 쪽 캐시에 머물 수 있습니다. 텍스처를 읽는 쪽은 다른 캐시를 거쳐 메모리를 봅니다. 앞 명령이 끝났어도 값이 메모리까지 내려가지 않았으면 뒤 명령은 옛 값을 읽습니다.

그래서 순서만 맞추면 끝나지 않습니다. 앞 명령의 쓰기를 메모리로 내려보내야 합니다. 뒤 명령이 읽을 캐시에 남은 옛 값도 버려야 합니다.

막는 표시는 이 두 가지 일을 함께 맡습니다. 뒤 명령을 앞 명령이 끝날 때까지 세워 두는 일과, 앞 명령의 쓰기가 뒤 명령에 보이게 하는 일입니다. 이 표시가 파이프라인 배리어입니다. 줄여서 배리어라고 부릅니다.

배리어에는 앞 명령의 어느 단계를 기다리고 뒤 명령의 어느 단계를 세울지 적습니다. 여기서 단계는 그래픽스 파이프라인의 단계입니다. 앞 명령이 픽셀에 색을 다 쓰는 단계를 마쳐야 뒤 명령이 텍스처를 읽는 단계로 들어가게 적는 식입니다.

레이아웃 전환이 끼는 경우

어떤 API 에서는 배리어가 이미지를 메모리에 늘어놓는 방식도 함께 바꿉니다. 그래서 그런 API 에서 배리어가 빠지면 고장이 하나 더 붙습니다.

이미지 레이아웃은 이미지를 메모리에 늘어놓는 방식입니다. 픽셀을 한 줄씩 차례로 늘어놓을 수도 있습니다. 이웃한 픽셀끼리 작은 사각형으로 묶어 늘어놓을 수도 있습니다.

어떤 GPU 는 그리기에 맞춘 레이아웃과 텍스처로 읽기에 맞춘 레이아웃을 따로 둡니다. 쓰임에 맞는 레이아웃으로 두면 메모리를 덜 오갑니다.

Vulkan 은 GPU 를 가까이서 다루게 하는 그래픽스 API 입니다. 명령 사이의 순서를 프로그램이 직접 적게 합니다.

Vulkan 에서는 배리어에 「옛 레이아웃 → 새 레이아웃」을 함께 적어 레이아웃을 바꿉니다. 이 바꿈이 레이아웃 전환입니다.

레이아웃 전환은 그 자체가 이미지를 한 번 고쳐 쓰는 일입니다. 배리어가 빠지면 전환도 같이 빠집니다. 그러면 뒤 패스는 그리기용 레이아웃으로 놓인 이미지를 읽기용 레이아웃으로 알고 읽습니다.

배리어를 걸었어도 기다릴 단계를 잘못 적으면 전환이 앞 패스의 쓰기와 겹칩니다. 어느 쪽이든 읽힌 값은 뜻 없는 값이 됩니다.

한 패스 안에서 엇갈리는 경로

한 렌더 패스 안에서도 같은 일이 생깁니다. 지금 그리고 있는 어태치먼트를 같은 패스의 셰이더가 텍스처로 읽을 때입니다. 이런 꼴이 피드백 루프입니다.

한 패스 안의 그리기는 수많은 픽셀을 한꺼번에 칠합니다. 어떤 픽셀은 이미 새 값이고 어떤 픽셀은 아직 옛 값인 채로 읽힙니다. 한 픽셀을 쓰는 중에 같은 픽셀을 읽으면 어느 값이 나올지 정해지지 않습니다.

대개는 패스를 나눠서 풉니다. 읽을 이미지를 앞 패스에서 다 그려 두고, 패스를 닫은 뒤 다음 패스에서 텍스처로 읽습니다.

같은 픽셀의 값만 읽으면 되는 경우에는 한 패스 안에서 푸는 길도 있습니다. 한 렌더 패스를 다시 잘게 나눈 단계가 서브패스입니다. 패스를 닫지 않고도 앞 단계와 뒤 단계를 가를 수 있습니다.

앞 서브패스가 쓴 값을 뒤 서브패스가 같은 픽셀에서 읽게 하는 어태치먼트가 입력 어태치먼트입니다. 같은 픽셀만 읽을 수 있는 까닭은 다른 픽셀을 앞 서브패스가 아직 안 칠했을 수 있기 때문입니다.

재현되는 조건

넷이 다 서면 렌더 패스 데이터 레이스가 납니다.

  1. 두 명령이 같은 이미지를 만집니다
  2. 둘 중 적어도 하나가 쓰기입니다
  3. 둘 사이에 배리어도 서브패스 의존성도 없습니다
  4. GPU 가 두 명령을 겹쳐 돌립니다

셋째 줄의 서브패스 의존성은 서브패스 사이에 거는 배리어입니다. 렌더 패스를 만들 때 「이 서브패스는 저 서브패스의 쓰기를 기다린다」고 미리 적어 둡니다.

셋째 조건까지 서면 결과가 정해지지 않은 상태입니다. 넷째 조건이 서야 틀린 값이 화면에 나옵니다.

넷째 조건은 프로그램이 정하지 않습니다. GPU 가 얼마나 바쁜지, 어느 제조사의 GPU 인지, 디바이스 드라이버가 명령을 어떻게 나눠 넣는지에 따라 겹치기도 하고 안 겹치기도 합니다. 그래서 같은 코드가 한 기계에서는 멀쩡하고 다른 기계에서는 깨집니다.

드러나는 증상

이 고장은 값이 틀리는 것이지 프로그램이 멈추는 것이 아닙니다. 오류 메시지 없이 화면만 달라집니다. 흔히 보이는 모습은 이렇습니다.

  • 화면 일부가 프레임마다 깜빡이거나 얼룩이 생겼다 사라집니다
  • 그림자처럼 앞 패스의 결과를 읽는 효과만 가끔 틀립니다
  • 그릴 것이 늘거나 줄어 GPU 가 얼마나 바쁜지가 바뀌면 나타나거나 사라집니다
  • GPU 나 드라이버를 바꾸면 나타나거나 사라집니다

재현이 들쭉날쭉해서 원인을 찾기 어렵습니다. 고쳤다고 여긴 뒤에도 넷째 조건이 우연히 안 서서 안 보일 뿐일 수 있습니다.

막는 수단과 치르는 값

막는 수단은 어디까지 기다리게 하느냐로 값이 갈립니다. 앞에서 본 대로 배리어에는 기다릴 단계와 세울 단계를 적습니다. 넓게 적을수록 놓치는 경우가 줄어듭니다. 대신 뒤 명령이 필요 이상으로 오래 섭니다.

막는 수단을 하는 일과 치르는 값으로 견주면 이렇습니다.

수단 하는 일 치르는 값
파이프라인 배리어 뒤 명령을 세우고 앞 명령의 쓰기를 보이게 한다 넓게 걸면 GPU 가 쉬는 틈이 생긴다
서브패스 의존성 한 렌더 패스 안의 서브패스 사이에 같은 일을 한다 렌더 패스를 만들 때 미리 적어야 한다
패스 나누기 읽을 이미지를 앞 패스에서 끝내 둔다 패스를 여닫을 때마다 이미지를 메모리로 내보내고 다시 읽는다
API 가 넣는 배리어 드라이버가 이미지 쓰임을 보고 순서를 맞춘다 드라이버가 넉넉하게 걸고 프로그램은 그 폭을 못 줄인다

표의 넷째 줄은 OpenGL · Metal · WebGPU 가 대개 택한 길입니다. Vulkan 과 Direct3D 12 는 배리어를 프로그램이 직접 적게 합니다. 직접 적는 API 에서는 하나를 빠뜨리는 순간 이 고장이 납니다.

엔진은 이 일을 사람 손에서 떼어 내기도 합니다. 패스마다 무엇을 읽고 쓰는지 적게 한 뒤 배리어를 계산해 넣습니다. 이런 구조가 렌더 그래프입니다.

찾는 도구

Vulkan 에는 검증 레이어가 있습니다. 프로그램과 드라이버 사이에 끼어 API 호출이 규칙에 맞는지 검사하는 레이어입니다. 여기에 동기화를 검사하는 기능이 있어서, 배리어 없이 같은 이미지를 쓰고 읽는 명령 쌍을 오류로 알려 줍니다.

WebGPU 는 한 패스 안의 피드백 루프를 아예 받지 않습니다. 같은 텍스처를 한 패스에서 어태치먼트와 읽기용으로 함께 쓰면 그 패스를 오류로 거부합니다.

그래픽스 디버거로 한 프레임의 명령을 차례로 펼쳐 보는 방법도 씁니다. 어느 패스가 어느 이미지를 쓰고 읽는지를 보며 배리어가 빠진 곳을 찾습니다.

신경 써야 하는 코드와 아닌 코드

이 고장은 그래픽스 API 를 직접 부르는 코드에서 생깁니다. 게임 엔진이나 렌더링 라이브러리를 만드는 쪽입니다. 엔진 위에서 장면만 꾸미는 코드는 엔진이 배리어를 대신 넣으므로 대개 이 고장을 직접 만나지 않습니다.

GPU 로 그림 대신 계산만 하는 코드도 같은 조건을 겪습니다. 컴퓨트 셰이더는 그리기 없이 계산만 하는 셰이더입니다. 한 컴퓨트 셰이더가 쓴 버퍼를 다음 컴퓨트 셰이더가 읽으려면 둘 사이에 배리어가 있어야 합니다. 렌더 패스라는 이름만 빠질 뿐 조건 넷은 같습니다.

관련 항목

렌더 패스 데이터 레이스가 일어나는 GPU 명령과 이미지

GPU · 커맨드 버퍼 · 커맨드 큐 · 그래픽스 파이프라인 · 렌더 패스 · 서브패스 · 어태치먼트 · 렌더 타깃 · 깊이 버퍼 · 텍스처 · 이미지 레이아웃 · 캐시 · 디바이스 드라이버 · 컴퓨트 셰이더

렌더 패스 데이터 레이스를 막는 동기화 수단

파이프라인 배리어 · 메모리 배리어 · 이미지 메모리 배리어 · 서브패스 의존성 · 레이아웃 전환 · 입력 어태치먼트 · 세마포어 · 펜스 · 렌더 그래프

렌더 패스 데이터 레이스와 같은 뿌리의 동시성 고장

데이터 레이스 · 데이터 해저드 · 경쟁 상태 · 피드백 루프 · 정의되지 않은 동작 · torn read

렌더 패스 데이터 레이스를 잡아내는 도구

검증 레이어 · 동기화 검증 · 그래픽스 디버거 · RenderDoc

배리어를 누가 적느냐로 갈리는 그래픽스 API

Vulkan · Direct3D 12 · Metal · WebGPU · OpenGL

렌더 패스 데이터 레이스가 터지는 렌더링 기법

그림자 매핑 · 지연 셰이딩 · 후처리 · G-버퍼 · 멀티패스 렌더링

렌더 패스 데이터 레이스가 속하는 상위 분류

그래픽스 · 렌더링 · 그래픽스 API · 동기화 · 동시성

다른 이름: render pass data race · 렌더 패스 동기화 해저드