프로젝트 개요
AI에 대해서는 예전부터 관심이 있었고 C++과 rust같은 로우레벨 프로그래밍 언어도 개인적으로 좋아합니다.
그래서 이번 프로젝트는 C++와 CUDA를 활용해 외부 라이브러리 없이 테트리스 게임을 직접 구현하고, 이 게임을 스스로 플레이하며 최고 점수를 노리는 강화학습 에이전트를 처음부터 만들어보는 과정을 기록하는 것을 목표로 할 것입니다.
로우레벨 언어와 인공지능, 그리고 GPU 프로그래밍에 관심이 많은 학부생으로서, 이미 잘 만들어진 라이브러리나 프레임워크에 의존하지 않고 처음부터 모든 것을 직접 설계하고 구현해보는 경험을 통해, 진짜로 시스템이 어떻게 돌아가는지 깊이 이해하고 싶습니다.
또한, GPU의 병렬 연산 능력을 실제로 활용해보며, 이론으로만 배웠던 개념들이 실제 코드와 하드웨어에서 어떻게 동작하는지 체험하고자 합니다.
이 프로젝트를 통해 배우고자 하는 가장 큰 목표는, 강화학습의 핵심 원리와 GPU 프로그래밍의 실전 기술을 내 손으로 직접 구현하며 익히는 것입니다.
테트리스라는 익숙한 게임을 스스로 만들고, 그 위에서 동작하는 에이전트를 설계하면서, 상태 공간과 행동 집합, 보상 함수 설계의 중요성을 느낄 것입니다.
또한, CUDA를 활용해 대량의 시뮬레이션을 병렬로 처리하는 과정에서, GPU 메모리 관리나 커널 최적화와 같은 실전적인 문제들을 직접 해결해보고자 합니다.
라이브러리 없이 신경망이나 알고리즘을 처음부터 구현하는 과정에서, 평소에는 잘 느끼지 못했던 컴퓨팅 자원의 한계나, 병렬 연산의 어려움도 경험할 수 있을 것이라 기대하고 있습니다.
어려움도 많겠지만 이런 난관들을 직접 부딪히고 해결해가는 과정에서, 단순히 결과만 얻는 것이 아니라, 문제를 분석하고 해결책을 찾아가는 과정 자체가 큰 성장의 기회가 될 것이라고 생각합니다.
오늘 한 것
강화학습 알고리즘 선택
클래스 재설계
내용
DQN(Deep Q-Network)
DQN을 이해하려면 먼저 강화학습을 알아야합니다.
강화학습은 사람이 무언가 학습하는 과정과 유사합니다.
어떤 행동을 하고 그에 대해 깨달음을 얻는 것처럼, 강화학습의 에이전트(Agent)는 주어진 환경(Environment)안에서 다양한 행동(Action)을 시도합니다.
각 행동의 결과로 환경은 새로운 상태(State)로 변하고 ,에이전트는 그 행동이 얼마나 좋았는지 혹은 나빴는지를 나타내는 보상(Reward)을 받습니다.
에이전트의 궁극적인 목표는 단순히 즉각적인 보상이 아니라, 장기적으로 누적될 총 보상을 최대화하는 최적의 행동 전략, 즉 정책(Policy)을 학습하는 것입니다.
테트리스에 이 개념을 적용해 보면, 에이전트는 떨어지는 블록을 조작하는 AI 프로그램이 됩니다.
환경은 테트리스 게임 보드와 규칙 그 자체이며, 상태는 현재 보드 판에 블록이 쌓인 모습, 다음에 나올 블록의 종류 등을 포함할 수 있습니다.
AI가 취할 수 있는 행동은 블록을 왼쪽이나 오른쪽으로 옮기거나, 회전시키거나, 아래로 빠르게 내리는 것들이죠. 그리고 라인을 한 줄 없앨 때마다 높은 점수(긍정적 보상)를, 게임이 끝나면 큰 감점(부정적 보상)을, 혹은 블록을 하나 놓을 때마다 작은 감점을 주는 식으로 보상 체계를 설계할 수 있습니다.
이 때 AI는 좋은 행동과 나쁜 행동을 판단하기 위해 Q-러닝(Q-Learning)과 Q 함수입니다.
Q 함수, 는 현재 상태 에서 특정 행동 를 취했을 때, 그 이후부터 게임이 끝날 때까지 받을 것으로 기대되는 총 보상을 수치화한 것입니다.
만약 우리가 모든 가능한 상태와 행동 조합에 대한 최적의 Q 값, 즉 최적 Q 함수, 를 알 수만 있다면, AI는 매 순간 Q*(s, a)를 가장 크게 만드는 행동을 선택함으로써 완벽한 플레이를 펼칠 수 있을 것입니다.
이 최적 Q 함수는 벨만 최적 방정식(Bellman Optimality Equation)이라는 재귀적 관계로 표현됩니다.
벨만 방정식
처음에 보상은 에이전트가 한 행동에 대한 환경의 피드백이라고 했습니다.
이 때 상태에서 행동을 했을 때 보상을 함수로 나타내면 아래와 같다고 할 수 있습니다.
보상함수는 시간, 현재 상태 에서 행동 를 했을 때 얻을 수 있는 보상 의 기댓값이라고 말합니다.
그러면 어떤 행동을 했을 때 최종적으로 얻는 보상을 나타내면 다음과 같습니다.
하지만 이러한 식은 다음과 같은 단점이 있습니다.
- 현재 받는 보상과 미래의 받는 보상을 구분하지 못하여 현재 이익이 큰 상태로 가는 행동을 취하지 못함
- 한 번에 받는 보상과 여러번 나눠 받는 보상을 구분하지 못함
- 시간이 무한대가 될 경우 보상의 합을 수치적으로 구분하지 못함
이러한 문제점을 해결하기위해 미래에 받은 보상을 현재의 시점에서 고려할 때 감가하는 비율을 말하는 감가율을 각 항에 뒤로 갈수록 차수를 높여가며 곱해줍니다.
이제 이 를 반환값이라고 합니다.
우리는 에이전트가 특정 상태에서 행동을 선택하는 기준인 가치함수를 이제 거의 다 찾았습니다.
이제 가치함수는 반환값의 기댓값으로 정의됩니다. (아직 원리를 이해못해서 추후에 추가예정)
위에서 설명한 가치함수는 상태의 가치를 판단하는 ‘상태 가치함수’입니다.
상태 가치함수를 통해 다음 상태들의 가치를 판단할 수 있고, 이를 바탕으로 더 높은 가치를 가지고 있는 다음 상태로 가기 위한 행동을 선택하여 상태를 이동시킬 것입니다.
하지만 그러기 위해선 다음 상태에 대한 정보를 알아야하고, 그 상태로 가기위한 행동을 선택해도 그 상태에서 더 못가는 상태가 될 수도 있습니다.
따라서 상태말고 행동에 대한 가치 함수도 구할 수 있어야 합니다.
이것이 Q함수입니다.
Q함수는 다음과 같이 정의되고 특정 상태 s에서 특정 행동 a를 취했을 때 받을 반환값에 대한 기댓값이라 합니다.
이제 벨만 방정식의 2개중 1개인 벨만 기대 방정식을 서술할 수 있습니다.
벨만 기대 방정식은 가치함수식에서 유도되는 것인데 과정은 다음과 같습니다.
Q함수 또한 변형하면 다음과 같은 식이 유도됩니다.
이 수식들을 살펴보면 다음 상태의 가치함수를 통해 현재 상태의 가치함수를 도출한다는 걸 알 수 있습니다.
위에서 설명한 가치함수로부터 기댓값을 계산하려면 앞으로 받을 모든 보상에 대해 고려해야 하므로 비효율적입니다.
따라서 하나의 수식으로 풀어내는 이 방법보단 여러 번의 연속적인 계산으로 가치함수의 참 값을 알아내는 방법이 효율적입니다.
이 방법에 사용하는 수식이 바로 벨만 기대 방정식입니다.
그럼 두번째 벨만 방정식인 벨만 최적 방정식에 대해 설명하기위해 최적 가치함수를 설명하겠습니다.
최적 가치함수란 현재 상태에서 앞으로 가장 많은 보상을 받을 정책을 따랐을 때의 가치함수입니다.
즉, 현재 환경에서 취할 수 있는 가장 높은 값의 보상 총합입니다.
벨만 최적 방정식은 위에서 설명한 최적 가치함수를 벨만 기대 방정식처럼 현재 상태의 최적 가치함수와 다음 상태의 최적 가치함수 사이의 관계로 나타낸 식입니다.
다시 DQN
Q-러닝은 이 벨만 방정식을 바탕으로 Q 값을 점진적으로 업데이트해 나갑니다.
AI가 행동 를 취해 상태 에서 로 이동하고 보상 을 받으면, 값을 “실제로 받은 보상 과 다음 상태 s’에서의 예상되는 최대 미래 가치 합”에 더 가깝도록 조금씩 수정하는 것입니다.
이 과정을 수없이 반복하면 는 점차 에 수렴하게 됩니다.
초기 Q-러닝은 이러한 Q 값들을 거대한 표(테이블)에 저장하고 관리하는 테이블 방식을 사용했습니다.
상태와 행동의 종류가 적다면 이 방식도 효과적이지만, 테트리스처럼 가능한 보드 상태가 거의 무한대에 가까운 게임에서는 테이블 방식은 현실적으로 불가능합니다.
테이블 방식 Q-러닝의 명확한 한계, 즉 방대한 상태 공간 문제를 해결하기 위해 등장한 것이 바로 DQN(Deep Q-Network)입니다.
DQN의 핵심 아이디어는 Q 함수 자체를 거대한 표 대신, 신경망(Neural Network)을 사용하여 근사하는 것입니다.
즉, 와 같이, 신경망이 상태 s를 입력으로 받아 각 행동 에 대한 Q 값을 출력하도록 학습시키는 것입니다.
여기서 는 신경망을 구성하는 수많은 연결 가중치와 편향 같은 학습 가능한 파라미터들을 의미합니다.
이 때 신경망은 손실함수를 이용해 경사하강법과 역전파 알고리즘으로 Q함수를 근사해나가게 됩니다.
단순히 신경망으로 Q 함수를 근사하는 것만으로는 안정적인 학습이 어렵습니다. DQN이 획기적인 성공을 거둘 수 있었던 배경에는 다음과 같은 핵심적인 기법들이 자리 잡고 있습니다.
경험 리플레이 (Experience Replay): 사람이 과거의 경험을 곱씹으며 배우듯, DQN도 에이전트가 겪었던 과거의 경험, 즉 (현재 상태, 했던 행동, 받은 보상, 다음 상태)의 묶음을 리플레이 메모리라는 곳에 차곡차곡 쌓아둡니다. 신경망을 학습시킬 때는 이 메모리에서 무작위로 여러 개의 경험을 꺼내와 한 묶음(미니배치)으로 만들어 사용합니다. 이렇게 하면 몇 가지 중요한 이점이 생깁니다. 첫째, 하나의 경험을 여러 번 학습에 재사용할 수 있어 데이터 효율성이 크게 높아집니다. 둘째, 시간 순서대로 경험을 학습할 때 발생하는 데이터 간의 높은 상관관계를 줄여 학습을 더 안정적으로 만듭니다. 마치 교과서의 문제를 순서대로 풀기보다 여러 단원의 문제를 섞어서 푸는 것이 더 효과적인 것과 비슷합니다.
타겟 네트워크 분리 (Fixed Q-Targets / Target Network): DQN 학습에서 손실을 계산할 때 사용되는 ‘목표 Q 값’ 역시 학습 중인 신경망 자신을 통해 계산된다면, 목표가 계속 흔들리면서 학습이 불안정해질 수 있습니다. 축구에서 움직이는 골대에 공을 차 넣으려는 것과 비슷하죠. 이 문제를 해결하기 위해 DQN은 두 개의 신경망을 사용합니다. 하나는 실제 행동을 결정하고 주로 학습되는 주 네트워크(main network)이고, 다른 하나는 목표 Q 값을 계산하는 데만 사용되는 타겟 네트워크(target network)입니다. 타겟 네트워크의 가중치는 주 네트워크의 가중치로 주기적으로 (예: 몇천 번의 학습 스텝마다) 한 번씩 업데이트(복사)됩니다. 이렇게 타겟 값을 일정 기간 동안 고정시킴으로써 학습 과정을 훨씬 안정적으로 만들 수 있습니다.
이 외에도 오차가 너무 클 때 학습이 불안정해지는 것을 막기 위해 오차 클리핑(Error Clipping)을 사용하거나, 다양한 게임 환경에 유연하게 대응하기 위해 보상 클리핑(Reward Clipping) 같은 기법들이 사용되기도 합니다.
이 모든 요소들이 어우러져 DQN의 학습 과정은 다음과 같이 진행됩니다.
먼저, 주 네트워크와 타겟 네트워크를 (보통 무작위 값으로) 초기화하고, 경험 리플레이 메모리도 비어있는 상태로 시작합니다.
AI 에이전트는 테트리스 게임을 시작하여 현재 상태를 관찰하고, Epsilon-Greedy 전략에 따라 행동을 선택합니다. 선택한 행동을 수행하면 게임 환경으로부터 보상과 다음 상태를 전달받고, 이 한 번의 경험 (상태, 행동, 보상, 다음 상태)을 리플레이 메모리에 저장합니다. 리플레이 메모리에 충분한 경험이 쌓이면, 여기서 무작위로 미니배치만큼의 경험들을 꺼내옵니다.
각 경험에 대해 타겟 네트워크를 사용하여 목표 Q 값을 계산하고, 주 네트워크가 예측한 Q 값과의 차이(MSE 손실)를 구합니다.
이 손실을 줄이기 위해 주 네트워크의 가중치를 역전파 알고리즘으로 업데이트합니다.
그리고 일정 주기마다 주 네트워크의 가중치를 타겟 네트워크로 복사합니다.
텐서와 레이어 클래스
ushionn::Tensor 클래스
- 주요 멤버 변수:
- std::vector<int64_t> shape_: 텐서의 각 차원 크기를 저장 (예: {배치, 채널, 높이, 너비}).
- mutable std::vector<int64_t> strides_: NCHW 또는 NHWC 같은 메모리 레이아웃에 따른 각 차원의 스트라이드 값.
- get_strides() 호출 시 필요에 따라 계산 후 캐싱.
- cudnn_frontend::DataType_t data_type_: 텐서 데이터의 타입 (예: FLOAT, HALF, INT8).
- int64_t uid_: UshioNN 라이브러리 내에서 각 텐서를 고유하게 식별하는 ID.
- std::string name_: 디버깅 및 로깅을 위한 텐서의 이름 (선택적).
- bool is_virtual_: cuDNN 연산 그래프 API 사용 시, 그래프 내 중간 결과물로 실제 메모리 할당이 없을 수 있는 가상 텐서 여부. (DQN 구현 시 개별 연산 위주라면 항상 false일 수 있음).
- std::unique_ptr<char[], HostDeleter> h_data_ptr_: CPU 호스트 메모리에 대한 스마트 포인터. char[]로 관리하여 다양한 데이터 타입을 바이트 단위로 다룰 수 있게 하고, HostDeleter는 delete[] 호출.
- std::unique_ptr<void, CudaDeleter> d_data_ptr_: GPU 디바이스 메모리에 대한 스마트 포인터. void*로 일반성을 확보하고, CudaDeleter는 cudaFree 호출.
- DataLocation current_location_: enum class DataLocation { NONE, HOST, DEVICE } 중 하나의 값을 가지며, 현재 데이터가 유일하게 존재하는 위치를 명시.
- bool strides_dirty_: 스트라이드 재계산 필요 여부 플래그.
- size_t size_in_bytes_cache_: 텐서 데이터의 총 바이트 크기 (계산 후 캐싱).
- 주요 public 메소드:
- Tensor(const std::vector<int64_t>& shape, …): 다양한 생성자 (shape만, CPU 데이터로 초기화 등).
- const std::vector<int64_t>& get_shape() const: 텐서의 shape 반환.
- const std::vector<int64_t>& get_strides() const: 텐서의 strides 반환 (내부적으로 계산).
- cudnn_frontend::DataType_t get_data_type() const: 데이터 타입 반환.
- int64_t get_uid() const: UID 반환.
- size_t get_num_elements() const: 총 원소 개수 반환.
- size_t get_size_in_bytes() const: 총 바이트 크기 반환.
- DataLocation get_data_location() const: 현재 데이터 위치 반환.
- bool is_on_host() const: 데이터가 CPU에 있는지 확인.
- bool is_on_device() const: 데이터가 GPU에 있는지 확인.
- void* get_mutable_host_ptr(): CPU 데이터에 대한 수정 가능한 포인터 반환. (데이터가 CPU에 없으면 에러).
- const void* get_host_ptr() const: CPU 데이터에 대한 읽기 전용 포인터 반환. (데이터가 CPU에 없으면 에러).
- void* get_mutable_device_ptr(): GPU 데이터에 대한 수정 가능한 포인터 반환. (데이터가 GPU에 없으면 에러).
- const void* get_device_ptr() const: GPU 데이터에 대한 읽기 전용 포인터 반환. (데이터가 GPU에 없으면 에러).
- void to_device(cudaStream_t stream = nullptr): CPU에 있는 데이터를 GPU로 “이동”. 기존 CPU 메모리는 해제되고 current_location_은 DEVICE로 변경. 이미 GPU에 있다면 아무 작업 안 함.
- void to_host(cudaStream_t stream = nullptr): GPU에 있는 데이터를 CPU로 “이동”. 기존 GPU 메모리는 해제되고 current_location_은 HOST로 변경. 이미 CPU에 있다면 아무 작업 안 함.
- void fill_from_host(const void* host_data_ptr, size_t num_bytes): 외부 CPU 데이터로 텐서 내용을 채움. 데이터는 CPU에 위치하게 되며, 만약 이전에 GPU에 있었다면 GPU 메모리는 해제.
- void print_meta_info(const std::string& header = "") const: 텐서의 메타 정보(shape, data type, location 등) 출력 (디버깅용).
- std::shared_ptr<cudnn_frontend::graph::Tensor> create_graph_tensor_attributes(…)
ushionn::Layer (추상 베이스 클래스)
- 주요 멤버 변수 (protected):
- std::string name_: 레이어의 이름 (디버깅 및 식별용).
- bool trainable_: 이 레이어가 학습 가능한 파라미터를 가졌는지 여부.
- 주요 순수 가상 메소드 (public):
- virtual Tensor forward(const Tensor& input) = 0;: 순전파 연산을 수행. 입력 Tensor를 받아 처리한 후 출력 Tensor를 반환. 모든 데이터 연산은 GPU에서 수행되는 것을 목표로 함.
- virtual Tensor backward(const Tensor& output_gradient) = 0;: 역전파 연산을 수행. 출력층으로부터 전달된 그래디언트(output_gradient)를 받아 입력층으로 전달할 그래디언트를 계산하고, 내부 파라미터가 있다면 파라미터에 대한 그래디언트도 계산하여 저장.
- virtual std::vector<Tensor*> get_parameters(): 학습 가능한 파라미터 Tensor들의 포인터 리스트 반환 (예: 가중치, 편향). 파라미터가 없으면 빈 리스트 반환.
- virtual std::vector<Tensor*> get_gradients(): get_parameters()에 대응되는 그래디언트 Tensor들의 포인터 리스트 반환.
- virtual void initialize_parameters(unsigned long long seed = 0): 학습 가능한 파라미터 초기화 (예: Xavier, He 초기화).
회고 및 앞으로 할 일
아직 이해못한 부분도 많고 부족한 부분도 많은데 계속 열심히 해나가야겠습니다. 이 클래스는 추후 언제든 설계가 바뀔 수 있습니다.
