[AutoMine++] #1. GPGPU 프로그래밍 첫걸음: 행렬 덧셈 구현

장준수·2026년 2월 15일

AutoMine++

목록 보기
2/9

도입

이전 포스트에서 언급했듯, 필자는 GPGPU는커녕 로우 레벨(Low-level) 프로그래밍에 완전히 문외한이다. C언어로 프로그래밍에 입문하긴 했지만, 문법을 뗀 뒤 C로 한 것이라곤 알고리즘 풀이밖에 없다.

그나마 대학 진학 후 1학년 2학기 '객체지향 프로그래밍' 수업에서 C++을 배우긴 했지만, 말 그대로 '객체지향' 개념 그 자체를 배웠을 뿐, 꼭 C++이어야만 했던 이유는 없는 수업이었다.

『Effective C++』에 따르면, C++은 다음 4가지의 얼굴을 가진 연합체 언어로 보아야 한다고 한다.

  1. C: 블록, 문장, 선행처리기, 내장 데이터 타입 등.
  2. 객체지향 C++: 클래스, 캡슐화, 상속, 다형성 등.
  3. 템플릿 C++: 일반화 프로그래밍 (Generic Programming).
  4. STL: 템플릿 라이브러리 (컨테이너, 반복자, 알고리즘 등).

즉, C++은 사용하는 사람의 의도에 따라 전혀 다른 사용법을 가질 수 있는 언어라는 뜻이다. 내가 배운 부분은 정말 빙산의 일각이었고, 유명한 말을 빌려 표현하자면 "나는 전혀 C++ 하고 있지 않았다."

따라서 거창한 프로젝트를 시작하기에 앞서, 다음 세 가지 기초를 먼저 다지기로 했다.

  1. 모던 C++ 익숙해지기: 람다(Lambda), 템플릿, STL 등.
  2. SYCL 문법 적응하기: 이기종 컴퓨팅을 위한 표준 문법.
  3. 메모리 계층 구조 이해하기: 하드웨어 레벨의 메모리 동작 원리.

1번과 2번은 관련 서적과 LLM의 도움을 받아 직접 코드를 작성하고 부딪히며 배우는(Head-on) 식으로 돌파할 생각이다. 완벽한 이론을 먼저 세우고 코드로 옮기는 것보다, 직접 문제를 해결해 나가며 필요한 지식을 그때그때 습득하는 방식이 나에게 더 잘 맞기 때문이다.

3번은 맨땅에 헤딩하기엔 한계가 있으므로, 『CS:APP (Computer Systems: A Programmer's Perspective)』 책을 꾸준히 읽어나갈 계획이다. GPGPU와 관련된 내용은 주로 6장(메모리 계층 구조)에 몰려 있으니, 그 부분을 집중적으로 정독하면 해결되지 않을까 낙관하고 있다.


GPGPU의 Hello World, 'Vector Add' 구현

인텔 oneAPI를 설치하고 Visual Studio에서 DPC++ 프로젝트를 처음 생성하면, 템플릿 코드로 '벡터 덧셈(Vector Add)' 예제가 포함되어 있다. 해당 예제의 주석에는 다음과 같이 적혀 있다.

Vector Add is the equivalent of a Hello, World! sample for data parallel programs.
(Vector Add는 데이터 병렬 프로그램계의 Hello, World! 와 같습니다.)

그리고 그 아래에는 무언가 굉장히 긴 코드가 주르륵 나열되어 있는데, 솔직히 처음 봤을 땐 그 방대한 양에 압도되었다. '이게 고작 Hello, World 수준이라고?'

하지만 칼을 뽑았으면 무라도 썰어야 하는 법. 용기를 내어 핵심적인 부분부터 분석을 시작했다. 우선 SYCL에서 주목해야 하는 객체들은 다음과 같이 파악해 보았다.

1. Queue (큐)

모든 작업의 지휘관인 호스트(CPU)와 그 작업자인 디바이스(GPU 등)가 소통하기 위한 창구다. CPU와 GPU 사이의 연락망 역할을 하며, queue.submit()을 통해 Handler(작업 명세서)를 전달한다.

2. Handler (핸들러)

CPU가 디바이스에게 일을 시키기 위해 작성하는 작업 명세서다. 이 명세서 안에는 Kernel(실제 수행할 작업)Accessor(작업에 필요한 자원)가 포함된다.

3. Buffer (버퍼)

데이터의 위치를 총괄하는 자원 관리 담당자다. 호스트와 디바이스 사이에서 데이터를 적재적소로 이동시키며 관리한다. 프로세서는 원본 데이터를 직접 쓰는 대신 항상 이 Buffer를 통해 접근해야 한다.

4. Accessor (접근자)

디바이스가 자원에 접근할 수 있도록 돕는 데이터 접근 출입증이다. Buffer를 통해 데이터의 위치와 권한(Read/Write)을 할당받으며, 마치 원본 배열을 사용하는 것처럼 직관적인 인덱스 접근을 지원한다.

5. Kernel (커널)

디바이스가 수행해야 하는 로직 그 자체다. handler.parallel_for() 함수에 람다 형태로 전달되어 실행된다.


이러한 이해를 바탕으로 예외 처리나 함수화 등 부수적인 부분을 걷어내고, 예제 코드를 내 방식대로 재구성한 코드는 다음과 같다.

#include<sycl/sycl.hpp>
#include<iostream>
#include<vector>

using namespace sycl;

int main()
{
	// 내가 알던 C++, 우선 벡터를 초기화한다

	std::vector<int> a, b, sum;
	for (int i = 0; i < 10; ++i)
	{
		a.push_back(i);
		b.push_back(i);
	}
	sum.resize(a.size());

	// 여기부터 신비의 영역 시작.

	// 우선 CPU와 GPU 사이의 소통창구(queue)를 뚫어준다.
	queue q(gpu_selector_v);
    // RAII를 위한 중괄호. 스코프를 생성하여 종료 시점에 객체가 알아서 소멸자를 호출하도록 한다.
	{
		// 그 다음 각각의 raw data를 관리할 관리자들을 생성한다.
		// 이제 a, b, sum은 그들 자신을 필요로 하는 프로세서의 메모리에 존재하는 것이 보장된다.
		buffer a_buf(a), b_buf(b), sum_buf(sum);

		q.submit([&](handler &h){
			// accessor의 선언 = buffer가 일할 시간!
			// a, b, sum은 이 시점에 VRAM(만약 있다면)으로 복사된다.
			accessor a_acc(a_buf, h, read_only);
			accessor b_acc(b_buf, h, read_only);
			accessor sum_acc(sum_buf, h, write_only);

			// kernel(=해야하는 작업)을 람다로 넘겨준다.
			// 이 때, [&]를 사용하지 않는 이유는 GPU는 시스템 메모리를 "참조"할 수 없기 때문이다.
			h.parallel_for(range<1>(10), [=](id<1> i){
				sum_acc[i] = a_acc[i] + b_acc[i];
			});
		});
	}
    // 스코프 종료!
    // 1. Buffer 소멸자가 호출된다.
    // 2. GPU(Device)에 있던 계산 결과가 CPU(Host)의 sum 벡터로 자동 복사 된다.
    // 3. 즉, 이 시점부터 CPU에서 sum을 안전하게 읽을 수 있다.

	// 일을 제대로 처리하는지? CPU가 일했을때와 비교.
	bool passed = true;
	for (int i = 0; i < 10; ++i) {
		if (sum[i] != a[i] + b[i]) {
			passed = false;
			std::cout << "Error at " << i << "\n";
		}
	}

	if (passed) std::cout << "SUCCESS! (0+0 ... 9+9 calculated correctly)\n";

	return 0;
}

돌려보니 매우 잘 작동한다. 물론 매우 간단한 버전이라 각종 예외 처리 등은 추가적으로 공부해야 한다.


성능 비교 (CPU vs iGPU)

사실 크기 10짜리 벡터 더하기는 CPU나 GPU나 순식간에 끝나는 작업이고, 오히려 메모리 관리 오버헤드 때문에 CPU가 더 빠를 확률이 높다.

그러나 필자가 사용하는 내장 그래픽(iGPU)은 별도의 VRAM 대신 시스템 메모리를 공유하므로, 데이터 복사 비용이 적어 이득을 보지 않을까 하는 호기심에 성능 비교를 시도해 보았다.

int main()
{
	for (int i = 0; i < SIZE; ++i)
	{
		A.push_back(i);
		B.push_back(i);
	}
	Sum.resize(SIZE);

	auto start = std::chrono::high_resolution_clock::now();
	sum_by_cpu(Sum, A, B);
	auto end = std::chrono::high_resolution_clock::now();
	std::chrono::duration<double, std::milli> elapsed = end - start;
	std::cout << "CPU: " << elapsed.count() << " ms\n";

	start = std::chrono::high_resolution_clock::now();
	sum_by_gpu(Sum, A, B);
	end = std::chrono::high_resolution_clock::now();
	elapsed = end - start;
	std::cout << device_name << ": " << elapsed.count() << " ms\n";
	
	return 0;
}

결과는 다음과 같다.

?

결과는 충격적이었다. CPU가 0.0003ms 걸린 작업에 iGPU는 596ms가 걸렸다.

솔직히 예상을 뛰어넘은 결과라 잠깐 얼었지만, 침착하게 왜 그런지 파악해보았다. 조사 결과, 가장 큰 원인은 큐 생성 오버헤드JIT(Just-In-Time) 컴파일이었다. SYCL은 실행 시점에 파트너 장치를 파악하고 기계어로 번역하는 과정을 거치는데, 이 초기화 비용이 상상을 초월했던 것이다.

그래서 이번에는 '기울어진 운동장'을 만들어 재도전했다.
1. 데이터 크기를 1천만 개까지 늘린다.
2. 큐를 미리 생성하고, 워밍업을 통해 JIT 컴파일 시간을 측정에서 제외한다.

#include <sycl/sycl.hpp>
#include <iostream>
#include <vector>
#include <chrono>

using namespace sycl;

// SIZE를 1,000만 개로 늘림 (약 40MB)
const int SIZE = 10000000;

// Queue를 인자로 받아서 재사용함
void sum_by_gpu(queue& q, std::vector<int>& sum, std::vector<int>& a, std::vector<int>& b)
{
    // 버퍼 생성
    buffer a_buf(a), b_buf(b), sum_buf(sum);

    q.submit([&](handler& h) {
        // Accessor 생성
        accessor a_acc(a_buf, h, read_only);
        accessor b_acc(b_buf, h, read_only);
        // no_init: 기존 sum 데이터를 GPU로 복사해갈 필요 없다고 알려줘서 쓸데없는 복사를 막는다
        accessor sum_acc(sum_buf, h, write_only, no_init);

        h.parallel_for(range<1>(SIZE), [=](id<1> i) {
            sum_acc[i] = a_acc[i] + b_acc[i];
            });
        });

    q.wait(); // 계산 완료 대기
} // 여기서 버퍼 소멸 -> 데이터 복사(VRAM -> RAM)

void sum_by_cpu(std::vector<int>& sum, std::vector<int>& a, std::vector<int>& b)
{
    for (int i = 0; i < SIZE; ++i) {
        sum[i] = a[i] + b[i];
    }
    return;
}

int main()
{
    // 데이터 초기화 시간 단축용으로 그냥 1, 2, 0으로 통일
    // 어차피 결과는 하등 상관없음
    std::vector<int> A(SIZE, 1);
    std::vector<int> B(SIZE, 2);
    std::vector<int> Sum(SIZE, 0);

    // CPU 측정
    auto start = std::chrono::high_resolution_clock::now();
    sum_by_cpu(Sum, A, B);
    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double, std::milli> cpu_time = end - start;
    std::cout << "CPU: " << cpu_time.count() << " ms\n";

    // Queue 미리 생성
    queue q(gpu_selector_v);

    // 워밍업 단계: JIT 컴파일 시간을 빼기 위해 한 번 그냥 돌림
    sum_by_gpu(q, Sum, A, B);

    // GPU 실제 측정
    start = std::chrono::high_resolution_clock::now();
    sum_by_gpu(q, Sum, A, B);
    end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double, std::milli> gpu_time = end - start;

    std::cout << q.get_device().get_info<info::device::name>()<<": " << gpu_time.count() << " ms\n";
    return 0;
}


?

하지만 운동장을 아무리 기울여도 CPU가 무난하게 압도했다. 대체 왜 그럴까?


왜??????

조사 끝에 얻은 결론은, 필자가 CPU를 너무 얕보고 있었다는 점이다. 지금까지 나의 인식은 이랬다.

CPU는 다재다능한 박사님 8명이 모인 팀이고, GPU는 중학생 1,000명이 모인 팀이다. 덧셈 1천만 번을 시키면 당연히 쪽수가 많은 중학생들이 이기겠지?

하지만 결정적인 변수를 간과했다. 바로 각각의 박사님들 곁을 24시간 지키는 유능한 대학원생(캐시 메모리)들의 존재다.

  1. 박사님(CPU) 팀: 각 박사님 옆에 전담 대학원생(캐시)이 상주하며 문제를 빛의 속도로 전달한다. 덧셈처럼 쉬운 문제는 박사님이 손만 까딱해도 해결되며, 대학원생들이 다음 문제를 미리 준비해놓기에 쉴 틈이 없다.
  2. 중학생(GPU) 팀: 1,000명의 중학생은 쪽수는 많지만, 문제를 받으려면 교실 밖 저 멀리 있는 창고(RAM)에서 오는 유치원생(메모리 대역폭)을 하염없이 기다려야 한다. 유치원생이 아장아장 걸어와 교실 앞 공용 책상(로컬 메모리)에 문제를 놓아주면, 그제야 중학생들이 문제를 풀 수 있다. 그리고 그 문제라는것도 그냥 단순 숫자 두 개 더하기이니 문제가 들어오는 족족 풀리는데, 문제가 한 번에 1000개 들어와도 부족할 판에 수십 개밖에 안 들어오니 대부분의 시간동안 중학생들은 빈 책상을 멍하니 바라보게 된다.

문제를 푸는 속도는 중학생들이 빠를지 몰라도, 문제를 받는 속도 자체가 유치원생의 걸음걸이에 맞춰져 있으니 전체 속도가 느려질 수밖에 없었다.


마무리

이번 실험을 통해 얻은 교훈은 명확하다.

  1. 단순 벡터 덧셈은 CPU가 압도한다. 아무리 프로세서가 많아도 데이터 전달 속도가 병목이 되기 때문이다.

  2. GPU의 진가는 '우려먹기'에 있다. 적당한 크기의 데이터를 한 번에 가져와서 대량의 계산을 거듭 수행할 때 비로소 GPU가 빛을 발한다. 대표적인 것이 딥러닝에서 사용하는 행렬 곱셈이다.

GPU 내부에는 코어들이 다 같이 사용할 수 있는 로컬 메모리라는 아주 빠른 공용 책상이 존재한다고 한다. 따라서 위의 비유를 그대로 살릴 경우, 이 중학생 팀이 가장 활약하는 순간은 멀리 있는 창고에서 한 번 힘들게 가져온 데이터를 이 공용 책상에 올려두고, 팀원들끼리 돌려보며 마르고 닳도록 우려먹을 때다. 즉, 데이터의 체류 시간이 길고 연산량이 많은 문제가 주어질 때 비로소 1,000명의 중학생들이 단 1ms도 놀지 않고 연산에 몰두하며 물량의 힘을 제대로 발휘할 수 있다.

그런데 로컬 메모리라는 게 빠르긴 한데 용량이 수십에서 수백 KB 정도로 애매해서, 정말 큰 MB 단위의 행렬을 연산할 때는 그것을 통으로 올려놓고 처리할 수가 없다. 그렇다면 결국 이 거대한 행렬을 책상 크기에 맞춰 적절히 조각내서 연산하는 과정이 매우 중요해지지 않을까?

그런 의미에서 다음 포스트는 행렬 곱셈을 다뤄볼 것 같다. 솔직히 내 예상보다 행렬 곱셈까지는 금방 왔는데, 이제 여기부터가 본 게임이지 않을까?

profile
ㅇㅁㅇ;;

0개의 댓글