[OpenCL 입문 시리즈 5편] C++20/23로 깔끔하게 래핑하기

안녕하세요! 지난 글들에서 OpenCL의 기본 사용법을 어느 정도 파악하셨다면, 이제는 조금 더 현대적인 C++ 문법과 래퍼(wrapper) 라이브러리를 활용해 코드를 깔끔하게 정리하는 방법을 살펴보려고 해요. OpenCL 기본 API는 C 스타일 함수들로 구성되어 있고, 초기화 및 에러 처리 로직이 장황해지는 경우가 많은데, C++20/23 기능을 적극 활용하면 가독성과 유지보수성을 크게 높일 수 있습니다.

이번 글에서는 다음 내용을 다룹니다.

  • OpenCL C++ Wrapper(CL.hpp) 라이브러리 소개
  • 스마트 포인터, 범위 기반 for문, 구조적 바인딩 등 C++20/23 기능 활용
  • 에러 처리와 RAII(Resource Acquisition Is Initialization) 패턴 적용
  • CUDA와 비교: CUDA는 이미 C++ API가 익숙한데, OpenCL도 비슷한 수준으로 정돈 가능
  • 참고할만한 유튜브 자료(유효한 링크로 검증)

1. OpenCL C++ Wrapper (CL.hpp) 소개

Khronos 그룹에서는 cl.hpp라는 C++용 OpenCL 래퍼를 제공하고 있습니다. 이 래퍼는 다음과 같은 장점을 갖습니다.

  • C 스타일 함수 호출을 C++ 클래스/메서드 형태로 감싸 가독성 향상
  • 예외 처리를 지원하여 에러 코드 확인 코드가 줄어듦
  • 컨텍스트, 커맨드 큐, 프로그램, 커널 등을 객체로 다룰 수 있어 RAII 활용 가능

해당 래퍼는 Khronos 공식 GitHub 리포지토리에서 관리되고 있습니다.

다운로드하거나 패키지 관리자(예: Ubuntu의 경우 sudo apt install ocl-icd-opencl-dev로 설치되는 경우도 있으나, 별도로 header-only 라이브러리를 추가하는 것이 좋음)를 통해 프로젝트에 포함할 수 있습니다.

2. C++20/23 기능으로 깔끔하게 다듬기

C++20/23에는 코드 가독성을 높일 수 있는 유용한 기능이 많습니다. 예를 들어:

  • 구조적 바인딩(Structured Binding): 플랫폼, 디바이스 정보를 받아올 때 튜플처럼 반환하여 편리하게 다룰 수 있습니다.
  • std::optional, std::expected (C++23에 근접한 기능 or Boost 라이브러리 사용): 에러 처리를 좀 더 깔끔하게 표현할 수 있습니다.
  • 범위 기반 for문: 디바이스 목록 순회 시 더 직관적입니다.
  • 스마트 포인터: 혹은 전용 RAII 클래스를 이용하여 clRelease* 호출을 자동화할 수 있습니다.

예를 들어, 기존 코드에서는 플랫폼, 디바이스를 나열할 때 clGetPlatformIDs, clGetDeviceIDs를 호출하고, std::vector를 이용해 데이터를 담았지만, 래퍼를 사용하면 다음과 같이 단순화할 수 있습니다.

3. 예제 코드: OpenCL C++ Wrapper 활용

아래 예제는 단순히 벡터 덧셈을 하는 커널을 OpenCL C++ Wrapper로 정리한 예시입니다. 지난 글에서 다뤘던 벡터 덧셈 예제를 래핑 버전으로 바꿔본다고 생각하시면 좋아요.

#include <CL/cl2.hpp> // 또는 cl.hpp. cl2.hpp는 OpenCL 2.0+에 대응
#include <iostream>
#include <vector>

int main() {
    try {
        // 모든 플랫폼 얻기
        std::vector<cl::Platform> platforms;
        cl::Platform::get(&platforms);

        if (platforms.empty()) {
            std::cerr << "플랫폼을 찾지 못했습니다.\n";
            return 1;
        }

        // 첫 번째 플랫폼 선택 (데모용)
        auto platform = platforms.front();

        // 플랫폼에서 GPU 디바이스 검색
        std::vector<cl::Device> devices;
        platform.getDevices(CL_DEVICE_TYPE_GPU, &devices);

        if (devices.empty()) {
            // GPU가 없다면 CPU라도 시도
            platform.getDevices(CL_DEVICE_TYPE_CPU, &devices);
            if (devices.empty()) {
                std::cerr << "사용 가능한 디바이스가 없습니다.\n";
                return 1;
            }
        }

        auto device = devices.front();

        // 컨텍스트와 큐 생성
        cl::Context context({device});
        cl::CommandQueue queue(context, device, 0);

        // 커널 소스
        const char* kernelSrc = R"CLC(
            __kernel void vector_add(__global const float* A,
                                     __global const float* B,
                                     __global float* C,
                                     const int n) {
                int gid = get_global_id(0);
                if (gid < n) {
                    C[gid] = A[gid] + B[gid];
                }
            }
        )CLC";

        cl::Program program(context, kernelSrc);
        program.build({device});

        cl::Kernel kernel(program, "vector_add");

        int n = 1000;
        std::vector<float> A(n, 1.0f), B(n, 2.0f), C(n, 0.0f);

        cl::Buffer bufA(context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR, sizeof(float)*n, A.data());
        cl::Buffer bufB(context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR, sizeof(float)*n, B.data());
        cl::Buffer bufC(context, CL_MEM_WRITE_ONLY, sizeof(float)*n);

        // 인자 설정
        kernel.setArg(0, bufA);
        kernel.setArg(1, bufB);
        kernel.setArg(2, bufC);
        kernel.setArg(3, n);

        queue.enqueueNDRangeKernel(kernel, cl::NullRange, cl::NDRange(n));
        queue.enqueueReadBuffer(bufC, CL_TRUE, 0, sizeof(float)*n, C.data());

        std::cout << "C[0] = " << C[0] << ", C[1] = " << C[1] << "\n";
    } catch (cl::Error &e) {
        std::cerr << "OpenCL 에러: " << e.what() << " (" << e.err() << ")\n";
        return 1;
    }

    return 0;
}

위 예제에서는 cl::Platform::get, cl::Program::build, kernel.setArg 등 직관적인 메서드 호출로 API가 정돈되어 있음을 볼 수 있습니다. 에러 발생 시 cl::Error 예외를 통해 처리할 수도 있구요. CUDA 코드와 비교하면, 이제 OpenCL도 객체 지향적인 느낌으로 깔끔해졌다는 것을 체감하실 수 있을 거예요.

4. CUDA와 비교하기

CUDA는 원래부터 C++로 작성된 API 형태를 제공하고 있어서 코드가 상대적으로 간단하고 에러 처리가 명시적이죠. OpenCL은 C API를 바탕으로 했기 때문에 초창기에는 코드가 장황했습니다. 하지만 위와 같이 C++ 래퍼를 쓰면 CUDA에 가까운 개발 경험을 얻을 수 있어요.

  • CUDA: NVCC를 통한 사전 컴파일, C++ API, RAII 패턴 적용 가능
  • OpenCL + C++ 래퍼: 런타임 빌드 방식을 유지하되 C++객체로 감싸 유지보수성 향상, 에러 처리 간소화

5. 마무리

이번 글에서는 C++20/23 기능과 OpenCL C++ Wrapper를 활용하여 기존의 장황한 OpenCL 코드를 어떻게 깔끔하게 만들 수 있는지 살펴봤습니다. RAII, 예외 처리, 구조적 바인딩, 람다 등을 적절히 활용하면 OpenCL 코드도 CUDA 못지않게 세련되게 유지할 수 있습니다.

 

다음 글에서는 이렇게 정돈된 코드 위에서 간단한 이미지 처리 예제를 다뤄보며, 실제 애플리케이션에서도 OpenCL을 어떻게 활용할 수 있는지 좀 더 재미있는 예제를 보여드리겠습니다.

유용한 링크 & 리소스

반응형