DirectX

73_RawBuffer

devRiripong 2024. 3. 21.
반응형

컴퓨트 쉐이더(CPU가 아닌 GPU에 일을 넘기는 방식)에 대해 알아볼 것이다.

 

지금까지 작업한 WinMain의 코드들은 CPU에서 돌아가는 코드들이었다.

Engine 프로젝트 쪽에 있는 DirectX로 처리되는 게 GPU에 의해 처리되는 것이다.

렌더링 파이프라인을 타서 GPU에서 이용되는 거였다.

 

GPU한테 일을 시키기 위해 Shader를 만들어서 이런저런 걸 기술해서 GPU에게 시킬 수 있었다.

 

역사적인 부분을 봐야 한다.

CPU는 코어 개수가 많지 않지만 개개인이 똑똑한 직원들이다

GPU는 몸값은 낮지만 숫자가 많다고 볼 수 있다.

 

GPU, CPU 양쪽을 바쁘게 하는 게 최종 목표다.

CPU는 할게 많으니 언제든지 찾을 수 있는 반면

GPU는 상대적으로 Rendering 부분 외에는 아직까지 사용하지 않으니까 일부 CPU에서 연산량이 많이 들어가지만 연산이 어렵지 않은 경우 그걸 GPU에게 넘겨서 시켜 보는 걸 생각할 수 있을 것이다.

 

이런 이유로 GPU가 발전할 때는 그래픽스를 위해 발전했지만 언제부턴가 코어를 낭비하는 게 아깝다는 생각이 들기 시작한다.

대표적으로 암호학, 가상화폐 채굴, AI 같은 경우도 CPU가 모두 하기보다는 GPU에 넘기면 이득을 볼 수 있다. 업무끼리 독립적인 경우 장점이 있다.

업무가 복잡하고 데이터가 얽혀 있다면 병렬로 시킬 수 없어서 CPU를 쓴다.

 

병렬로 처리하는 단순 작업을 GPU에게 외주로 하면 좋다.

GPGPU(General Purpose GPU)라고 하는데 그 기술들이 CUDA부터 시작해서 발전을 해와서 요즘에는 일반적인 연산에서도 GPU에서 하는 기법을 알아야 한다.

 

DX 쪽에서도 ComputeShader를 이용해서 GPU에 단순 연산 시킬 수 있는데

연산을 던저서 결과 얻어서 동작하고, 결과를 취합해서 뭔가를 할 수 있다.

 

대표적으로 컨텐츠나 클라 만들 때 텍스쳐 다루는 부분을 조작해서 다른 파일을 만들고 싶다거나, 영상처리나 합성에도 장점이 있을 거고, 하나의 스트림을 압축하는 경우, 애니메이션의 경우 보간작업하는 경우, 순간적인 상태의 애니메이션을 알고 싶을 때 등에 사용할 수 있다.

어떤 컨텐츠를 넘길지는 나중의 문제고 어떤 식으로 외주를 시키고 돌려받는지가 주제다.

 

CPU에 비해 작업방식은 복잡하다.

CPU는 일반적 코드를 적고, 공용 정보는 락을 잡고 멀티 스레드에서 작업을 할 수 있었지만 GPU에서 하는 건 더 복잡하다.

한번 연습해서 만들면 GPU를 사용할 수 있게 된다.

 

작업을 시작한다.

 

 

GPU에게 어떤 식으로 일을 분배할지가 핵심이다.

 

GPU에다가 데이터를 넘기고 다시 받기 위해서는 Reosource가 필요하다.

여러 방식 중 RowBuffer라는 걸 이용해서 만들어 보도록 한다.

 

raw buffer 혹은 byte address buffer라고도 불리는데

첫 번째로 테스트할 것은 Byte Address Buffer고 나중에 다른 방식도 알아볼 것인데 이 방식은 dx11에서 처음 생겼다고 한다.

https://learn.microsoft.com/en-us/windows/win32/direct3d11/direct3d-11-advanced-stages-cs-resources#byte-address-buffer

주소를 이용해서 직접적으로 주소값을 연산해서 접근하는 형태라는 걸 알 수 있는데 C++의 기반으로 표현하면 일종의 byte형 포인터를 다루는 느낌이라고 생각할 수 있다.

raw buffer라는 건 날것의 것으로 어떻게 가공해서 사용할지는 우리가 정하면 된다고 볼 수 있다.

 

실습을 통해서 해보자.

첫 번째 버퍼 타입을 만들 것이다.

리소스를 만들 건데 지금까지 어떻게 했는지 보면

버퍼를 만들어 준 다음에 그것을 묘사하는 뷰라는 걸 만들어서 GPU와 통신할 때는 뷰라는 묘사한 정보를 건네주어서 작업을 했었다. 선수 작업이 필요하다

 

1. RawBuffer 클래스 만들기 

Engine에 05. ComputeShader라는 필터를 임시로 만들고 어디에 위치시킬지는 나중에 고민을 해본다.

그 안에 Buffer 필터를 추가하고 그 안에 RawBuffer라는 클래스를 만든다.

이 아이를 통해 입력을 받아줄 것이고, 입력을 받아준 다음에 Compute Shader에다 연산을 요청하고, 그 연산이 취합이 되면 그 결과를 다시 받아주는 역할을 하게 된다.

input과 output을 동시에 담당을 한다.

리소스를 따로 만들어도 되지만 이런 식으로 리소스를 필요한 애들을 한 번에 묶어서 하나의 클래스로 파는 것도 편리하기 때문에 묶어서 관리하는 방식으로 만들어 본다.

 

버퍼를 3개 만들어 볼 것이다.

 

RawBuffer.h

#pragma once
class RawBuffer
{
public: 
	RawBuffer(void* inputData, uint32 inputByte, uint32 outputByte); 
	~RawBuffer(); 

public:
	void CreateBuffer(); 
	void CopyToInput(void* data); // CPU 쪽 메모리에서 데이터를 만들어줘서 그것을 건내주세요하면 _input Buffer에 저장이 되어서 건내주는 형태
	void CopyFromOutput(void* data); // _result Buffer에 들어가 있는 것을 다시 긁어 오는 형태로 작업

private:
	void CreateInput(); 
	void CreateSRV(); 
	void CreateOutput(); 
	void CreateUAV(); 
	void CreateResult();

private:
	ComPtr<ID3D11Buffer> _input; 
	ComPtr<ID3D11ShaderResourceView> _srv; 
	ComPtr<ID3D11Buffer> _output; 
	ComPtr<ID3D11UnorderedAccessView> _uav; 
	ComPtr<ID3D11Buffer> _result; 

private: 
	void* _inputData; // 입력받을 데이터
	uint32 _inputByte = 0; // 크기 
	uint32 _outputByte = 0; // 리턴해야 할 데이터 크기	 
};

 

RawBuffer.cpp

#include "pch.h"
#include "RawBuffer.h"

RawBuffer::RawBuffer(void* inputData, uint32 inputByte, uint32 outputByte)
	: _inputData(inputData), _inputByte(inputByte), _outputByte(outputByte)
{
	CreateBuffer(); 
}

RawBuffer::~RawBuffer()
{
}

void RawBuffer::CreateBuffer()
{
	CreateInput();
	CreateSRV();
	CreateOutput();
	CreateUAV();
	CreateResult();
}

//CPU 메모리에서 GPU로 복사하는 형태
void RawBuffer::CopyToInput(void* data)
{
	D3D11_MAPPED_SUBRESOURCE subResource;
	DC->Map(_input.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &subResource);
	{
		memcpy(subResource.pData, data, _inputByte);
	}
	DC->Unmap(_input.Get(), 0);
}

// GPU쪽의 데이터를 꺼내 오는 거
void RawBuffer::CopyFromOutput(void* data)
{	
	// 출력 데이터 -> result에 복사
	DC->CopyResource(_result.Get(), _output.Get());

	D3D11_MAPPED_SUBRESOURCE subResource;
	DC->Map(_result.Get(), 0, D3D11_MAP_READ, 0, &subResource);
	{
		memcpy(data, subResource.pData, _outputByte);
	}
	DC->Unmap(_result.Get(), 0);
}

void RawBuffer::CreateInput()
{
	if (_inputByte == 0)
		return;

	D3D11_BUFFER_DESC desc;
	ZeroMemory(&desc, sizeof(desc));
	desc.ByteWidth = _inputByte;
	desc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
	desc.MiscFlags = D3D11_RESOURCE_MISC_BUFFER_ALLOW_RAW_VIEWS; // RAW_BUFFER
	desc.Usage = D3D11_USAGE_DYNAMIC; // CPU-WRITE, GPU-READ
	desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;

	D3D11_SUBRESOURCE_DATA subResource = { 0 };
	subResource.pSysMem = _inputData;

	if (_inputData != nullptr)
		CHECK(DEVICE->CreateBuffer(&desc, &subResource, _input.GetAddressOf()));
	else
		CHECK(DEVICE->CreateBuffer(&desc, nullptr, _input.GetAddressOf()));
}

void RawBuffer::CreateSRV()
{
	if (_inputByte == 0)
		return;

	D3D11_BUFFER_DESC desc;
	_input->GetDesc(&desc);

	D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc;
	ZeroMemory(&srvDesc, sizeof(srvDesc));
	srvDesc.Format = DXGI_FORMAT_R32_TYPELESS; // 쉐이더에서 알아서 하세요
	srvDesc.ViewDimension = D3D11_SRV_DIMENSION_BUFFEREX; // SRV_FLAG_RAW
	srvDesc.BufferEx.Flags = D3D11_BUFFEREX_SRV_FLAG_RAW;
	srvDesc.BufferEx.NumElements = desc.ByteWidth / 4; // 전체 데이터 개수

	CHECK(DEVICE->CreateShaderResourceView(_input.Get(), &srvDesc, _srv.GetAddressOf()));
}

void RawBuffer::CreateOutput()
{
	D3D11_BUFFER_DESC desc;
	ZeroMemory(&desc, sizeof(desc));
	desc.ByteWidth = _outputByte;
	desc.BindFlags = D3D11_BIND_UNORDERED_ACCESS;
	desc.MiscFlags = D3D11_RESOURCE_MISC_BUFFER_ALLOW_RAW_VIEWS;

	CHECK(DEVICE->CreateBuffer(&desc, NULL, _output.GetAddressOf()));
}

void RawBuffer::CreateUAV()
{
	D3D11_BUFFER_DESC desc;
	_output->GetDesc(&desc);

	D3D11_UNORDERED_ACCESS_VIEW_DESC uavDesc;
	ZeroMemory(&uavDesc, sizeof(uavDesc));
	uavDesc.Format = DXGI_FORMAT_R32_TYPELESS;
	uavDesc.ViewDimension = D3D11_UAV_DIMENSION_BUFFER;
	uavDesc.Buffer.Flags = D3D11_BUFFER_UAV_FLAG_RAW;
	uavDesc.Buffer.NumElements = desc.ByteWidth / 4;

	CHECK(DEVICE->CreateUnorderedAccessView(_output.Get(), &uavDesc, _uav.GetAddressOf()));
}

void RawBuffer::CreateResult()
{
	D3D11_BUFFER_DESC desc;
	_output->GetDesc(&desc);

	desc.Usage = D3D11_USAGE_STAGING;
	desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
	desc.BindFlags = D3D11_USAGE_DEFAULT; // UAV가 연결되려면, USAGE는 DEFAULT여야 함.
	desc.MiscFlags = 0;

	CHECK(DEVICE->CreateBuffer(&desc, nullptr, _result.GetAddressOf()));
}

 

Engine 프로젝트를 빌드 한다.

 

 

2. BufferDemo 클래스를 생성하고 세팅하기

Client로 가서 간단한 예제를 살펴본다.

Client/Game필터에서 SceneDemo클래스만 두고 나머지는 삭제한다.

그리고 SceneDemo 클래스를 복붙하고 이름을 RawBufferDemo라고 한다.

코드를 수정한다.

#pragma once
class RawBufferDemo : public IExecute
{
public:
	void Init() override;
	void Update() override;
	void Render() override;

private:
	shared_ptr<Shader> _shader;

private:
};
#include "pch.h"
#include "RawBufferDemo.h"

void RawBufferDemo::Init()
{
	_shader = make_shared<Shader>(L"24. RawBufferDemo.fx");

}

void RawBufferDemo::Update()
{

}

void RawBufferDemo::Render()
{
}

Client/Shaders 필터에 Week4 필터를 만들어 준다.

 

23. RenderDemo.fx를 복붙 해서 이름을 24. RawBufferDemo.fx로 하고 Week4 필터에 넣는다.

 

Main으로 가서

#include "pch.h"
#include "Main.h"
#include "Engine/Game.h"
#include "SceneDemo.h"
#include "RawBufferDemo.h"

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)
{
	GameDesc desc; 
	desc.appName = L"GameCoding";
	desc.hInstance = hInstance; 
	desc.vsync = false;
	desc.hWnd = NULL; 
	desc.width = 800; 
	desc.height = 600; 
	desc.clearColor = Color(0.f, 0.f, 0.f, 0.f);
	desc.app = make_shared<RawBufferDemo>(); // 실행 단위

	GAME->Run(desc); 

	return 0; 
}

이렇게 시작을 해본다.

 

Client를 빌드한다.

 

3. 24. RawBufferDemo.fx 쉐이더 만들기

먼저 24. RawBufferDemo.fx부터 작업을 한다.

// 연산을 하는 용도로 GPU를 사용하는 방식을 Compute Shader라고 보면 된다.

RWByteAddressBuffer Output; // UAV

struct ComputeInput
{ 
    uint3 groupID : SV_GroupID; 
    uint3 groupThreadID : SV_GroupThreadID; 
    uint3 dispatchThreadID : SV_DispatchThreadID; 
    uint groupIndex : SV_GroupIndex;    
};

[numthreads(10, 8, 3)] // thread의 갯수 의미 240개 고용하겠다는 의미
void CS(ComputeInput input)   
{
    uint index = input.groupIndex; 
    uint outAddress = index * 10 * 4; // ComputeInput이 4바이트가 10개 있는 거랑 마찬가지다. 주소의 offset 계산
    
    Output.Store3(outAddress + 0, input.groupID); 
    Output.Store3(outAddress + 12, input.groupThreadID); 
    Output.Store3(outAddress + 24, input.dispatchThreadID);
    Output.Store(outAddress + 36, input.groupIndex);
}

technique11 T0
{
    pass P0
    {
        SetVertexShader(NULL); 
        SetPixelShader(NULL); 
        SetComputeShader(CompileShader(cs_5_0, CS()));         
    }

};

40바이트씩을 기입을 해서 자기의 정보를 건네줄 것인데 이 결과물이 최종적으로 완성이 되면 어떤 식으로 출력이 되는지를 로그를 찍어서 그걸 살펴보면서 분석을 해 볼 것이다.

그게 핵심이다. 이걸 왜 강조하는지를 살펴본다.

 

4. RawBufferDemo에서 Shader에 버퍼를 만들어서 UAV로 Shader로 보낸 뒤 나온 결과를 로그로 출력하기

RawBufferDemo.h에

	struct Output
	{
		uint32 groupID[3];
		uint32 groupThreadID[3];
		uint32 dispatchThreadID[3];
		uint32 groupIndex; 
	};

를 추가해 준다. 이게 24. RawBufferDemo.fx에서 넣어준

struct ComputeInput
{ 
    uint3 groupID : SV_GroupID; 
    uint3 groupThreadID : SV_GroupThreadID; 
    uint3 dispatchThreadID : SV_DispatchThreadID; 
    uint groupIndex : SV_GroupIndex;    
};

이걸 받아주기 위해 맞춰줬다.

 

output buffer에서는 출력을 얼마짜리인지 크기를 만들어 줘야 한다.

 

RawBufferDemo.cpp에 가서

void RawBufferDemo::Init()
{
	_shader = make_shared<Shader>(L"24. RawBufferDemo.fx");

	// 하나의 쓰레드 그룹 내에서 운형할 쓰레드 개수 
	uint32 count = 10 * 8 * 3; // MSN 공식 문서에 이렇게 되어 있어서 

	shared_ptr<RawBuffer> rawBuffer = make_shared<RawBuffer>(nullptr, 0, sizeof(Output) *count); 
}

이렇게 데이터 크기를 집어주게 되면 이 데이터의 크기에 맞는 버퍼가 만들어질 것이고,

그걸 uav로 만들어 준 다음에

실질적으로 결과물을 받아오는 용도로 활용할 수 있게 된다.

 

RawBuffer.h로 가서

public:
	ComPtr<ID3D11ShaderResourceView> GetSRV() { return _srv; }
	ComPtr<ID3D11UnorderedAccessView> GetUAV() { return _uav; }

를 추가한다.

Engine 프로젝트를 빌드하고,

 

void RawBufferDemo::Init()
{
	_shader = make_shared<Shader>(L"24. RawBufferDemo.fx");

	// 하나의 쓰레드 그룹 내에서 운영할 쓰레드 개수 
	uint32 count = 10 * 8 * 3; // MSN 공식 문서에 이렇게 되어 있어서 

	shared_ptr<RawBuffer> rawBuffer = make_shared<RawBuffer>(nullptr, 0, sizeof(Output) *count); 

	_shader->GetUAV("Output")->SetUnorderedAccessView(rawBuffer->GetUAV().Get()); 

	// 원래는 draw 계열 함수를 썼는데 얘는 그냥 CompeuteShader를 실행하세요가 Dispatch로 실행된다
	// x, y, z 쓰레드 그룹 지정
	_shader->Dispatch(0, 0, 1, 1, 1); // 수치들이 ComputInput 여기의 값들과 연관이 있다. 

	// 각각 어떤 값들을 리턴 했는지 로그를 찍어 본다. 
	vector<Output> outputs(count); 
	rawBuffer->CopyFromOutput(outputs.data()); 

	// 엑셀로 만들어서 살펴보는 코드
	FILE* file;
	::fopen_s(&file, "../RawBuffer.csv", "w");

	::fprintf
	(
		file,
		"GroupID(X),GroupID(Y),GroupID(Z),GroupThreadID(X),GroupThreadID(Y),GroupThreadID(Z),DispatchThreadID(X),DispatchThreadID(Y),DispatchThreadID(Z),GroupIndex\\n"
	);

	for (uint32 i = 0; i < count; i++)
	{
		const Output& temp = outputs[i];

		::fprintf
		(
			file,
			"%d,%d,%d,	%d,%d,%d,	%d,%d,%d,	%d\\n",
			temp.groupID[0], temp.groupID[1], temp.groupID[2],
			temp.groupThreadID[0], temp.groupThreadID[1], temp.groupThreadID[2],
			temp.dispatchThreadID[0], temp.dispatchThreadID[1], temp.dispatchThreadID[2],
			temp.groupIndex
		);
	}

	::fclose(file);
}

 

실행을 하면

 

빈 화면이 보인다.

 

5 결과 분석하기

결과 sln 파일이 있는 폴더에 RawBuffer.csv라는 엑셀파일이 만들어져 있다.

여러 수치들이 나와 있는데 규칙성을 찾아본다.

얘기하고 있는 게 어떤 의미인지 분석을 해보면 된다.

엑셀의 수치들이 뭘 의미하는지 이해가 안 갈 수 있다.

코드를 실행할 때 스레드들을 열심히 고용해서 스레드들한테 일을 시키는 형태가 된다. 그러면 CPU에서는 스레드를 관리해서 만든 다음에 공용데이터를 이용하거나 여기서부터 여기까지 처리해 주세요라고 일감을 만들어서 떠넘기는 방식이다. GPU로 할 때는 워낙 스레드들을 많이 고용하다 보니까 그런 부분들을 우리가 일일이 지정하기보다는 어떤 규칙성을 갖고 하는 게 합리적이라고 생각할 수 있는데

struct ComputeInput
{ 
    uint3 groupID : SV_GroupID; 
    uint3 groupThreadID : SV_GroupThreadID; 
    uint3 dispatchThreadID : SV_DispatchThreadID; 
    uint groupIndex : SV_GroupIndex;    
};

이런 식으로 고유한 넘버링이 있다고 볼 수 있다.

이 넘버링이 렌덤 하게 선택되는 게 아니라

고용한 개수와 일관적인 규칙으로 넘버링이 붙는다는 걸 볼 수 있다.

 

그 정보를 이용해서 그룹 인덱스는 0부터 0,1,2,3,4,5,6,7,8 이렇게 순차적으로 증가한다고 볼 수 있는 거니까

하나의 스레드마다 실행되는 일감을 어떻게 구분하느냐, 고유한 정보들을 이용해 가지고 내가 어떤 정보를 가지고 조작을 하고, 내가 완료해서 가공한 정보를 몇 번째 칸에다가 넣어줘야 되는지를 같이 연산할 필요가 있다.

[numthreads(10, 8, 3)] // thread의 갯수 의미 240개 고용하겠다는 의미
void CS(ComputeInput input)   
{
    uint index = input.groupIndex; 
    uint outAddress = index * 10 * 4; // ComputeInput이 4바이트가 10개 있는 거랑 마찬가지다. 주소의 offset 계산
    
    Output.Store3(outAddress + 0, input.groupID); 
    Output.Store3(outAddress + 12, input.groupThreadID); 
    Output.Store3(outAddress + 24, input.dispatchThreadID);
    Output.Store(outAddress + 36, input.groupIndex);
}

서로 안 건드리고 작업할 수 있는 이유가 이런 식으로 작업해야 할 영역을 집어 가지고 그곳에다가 데이터를 넣어주기 때문에 깔끔하게 들어간다.

 

지금 중요한 건 240개의 스레드를 운영하고 있고,

uint32 count = 10 * 8 * 3;

 

스레드 그룹은

_shader->Dispatch(0, 0, 1, 1, 1); 

1개만 운영하고 있다.

 

240개를 하나의 팀이라 할 수 있는데 그 팀을 한 개만 고용을 한 것이다.

Dispatch를 통해 실행을 하면 자기 자신이 몇 번째인지 알고 있기 때문에 모든 정보들을 원하는 칸에다가 넣어주었다고 보면 된다.

그 정보를 다시 로그로 찍어서 csv 파일로 만들어서 살펴보는 상황이다.

 

다음시간에 정보들이 무슨 의미인지, Dispatch에서 넣어주는 x, y, z와 count에서 넣어주는 10, 8, 3은 무엇을 의미하는지를 살펴본다.

반응형

'DirectX' 카테고리의 다른 글

75_TextureBuffer  (0) 2024.03.22
74_System Value 분석  (0) 2024.03.22
72_Quaternion  (0) 2024.03.20
71_인스턴싱_Scene 구조 정리  (0) 2024.03.20
70_인스턴싱_인스턴싱 통합  (0) 2024.03.20

댓글