OpenCL 기초 (3)

OpenCL 을 이용한 병렬 프로그래밍

2019년 08월 03일

프랙탈 (Fractal)

프랙탈은 자기 유사성을 가지는 기하학적 구조입니다. 자기 유사성은 같은 패턴에 대해 재귀 또는 반복을 이용해 구현합니다. 아래 그림은 처음 삼각형의 한변에 대해 13\frac{1}{3}, 23\frac{2}{3} 지점에서 새로운 삼각형이 만들어지는 반복적인 패턴을 보여주고 있습니다.

이번 글에서 다룰 프랙탈은 망델브로(Mandelbrot) 집합입니다. 망델브로 집합은 이미지 버퍼의 각각의 픽셀을 복소 평면의 좌표로 변환 후 반복연산을 수행해 그릴 수 있습니다. 망델브로 집합은 픽셀마다 같은 연산을 수행하므로 병렬 프로그래밍을 적용하기에 적합한 예제입니다.

각각의 픽셀에서는 아래와 같이 복소수 znz_ncc 에 대한 수열 연산이 수행됩니다.

z0=czn+1=zn2+cz_0 = c \\ z_{n + 1} = z_n^2 + c

위 식의 상수 cc 의 값으로서 각각의 이미지 버퍼 상 픽셀에 대응하는 복소수를 대입하게 됩니다. 상수 cc 를 점화식의 초기값으로 시작해 znz_n 의 절댓값이 2보다 크면 수열은 발산한다고 할 수 있습니다. 이 때 2라는 값은 발산하는 수열의 계산을 미리 막아 주는 역할을 하며, 경계값이라고 부릅니다. 수열이 발산하면 cc 는 집합에 속하지 않습니다. 반면에 znz_n 의 절댓값이 2보다 작을 경우 계산이 무한히 반복되므로 반복 횟수에 제한을 둬야 합니다. 이를 반영한 식은 병렬 연산을 수행할 OpenCL 의 커널에 작성하게 됩니다.



병렬 연산 코드

0. 복소수 구조체 정의

typedef struct		s_complex
{
	float r;
	float i;
}					t_complex;

구조체 멤버 r 은 실수부, i 는 허수부를 뜻합니다.



1. 복소 평면의 범위 설정

이미지 버퍼가 띄워질 윈도우의 가로 세로 크기를 고려해 복소 평면의 범위를 설정합니다.

# define COORD_WIDTH 5.0f // 복소 평면의 실수부 넓이
# define WINDOW_WIDTH 1000 // 윈도우 넓이
# define WINDOW_HEIGHT 800 // 윈도우 높이

...

t_complex init_entry_point(void)
{
	t_complex point;
	float coord_height;
	float window_width = WINDOW_WIDTH;

	point.r = -1.0f * COORD_WIDTH / 2.0f;
	coord_height = COORD_WIDTH * WINDOW_HEIGHT / window_width;
	point.i = -1.0f * coord_height / 2.0f;
	return (point);
}

float init_delta(void)
{
	return (COORD_WIDTH / WINDOW_WIDTH);
}

함수 init_entry_point 는 매크로 상수로 정의된 윈도우 넓이, 높이와 복소 평면의 실수부 넓이를 바탕으로 복소평면의 가장 왼쪽, 위의 복소수 좌표를 반환합니다. 이는 이미지 버퍼의 가장 왼쪽, 위 픽셀과 대응됩니다. init_delta 는 윈도우 넓이와 복소평면의 넓이의 비율을 이용해 픽셀 사이 간격의 복소 평면 상 비율을 반환합니다. 두 함수의 반환값과 OpenCL 연산 유닛의 global 인덱스를 이용해 각 픽셀에 대응하는 복소수를 구할 수 있습니다.



2. OpenCL 오브젝트 생성

오브젝트 생성은 OpenCL 기초 (1)OpenCL 기초 (2) 에서 연습용 예제를 통해 작성한 코드와 크게 다르지 않습니다. 다만 이미지 버퍼를 윈도우에 출력하는 과정은 이 글에서 생략하겠습니다 (필자의 경우 외부에서 제공하고 있는 그래픽 라이브러리를 사용). 병렬 연산의 결과인 이미지 버퍼가 반환된 후엔 OpenGL 또는 Vulkan 과 같은 익숙한 그래픽 라이브러리를 사용해 윈도우에 출력하시면 됩니다.

이어지는 글에서 다루게 될 OpenCL API 의 자세한 설명은 이전 포스트공식문서를 참고하시기 바랍니다.



오브젝트 생성을 위한 첫 단계로 OpenCL API 를 이용해 플랫폼과 디바이스 ID 를 얻습니다. 이번 예제는 GPU 디바이스 하나만 사용합니다.

cl_platform_id platform;
cl_device_id device;

clGetPlatformIDs(1, &platform, NULL);
clGetDeviceIDs(platform, CL_DEVICE_TYPE_GPU, 1, &device, NULL);

다음으로 OpenCL 에서 수행되는 커널과 메모리를 관리하는 context 와 실행될 커맨드를 담을 커맨드 큐 구조체를 생성합니다.

int ret;
cl_context context;
cl_command_queue command_queue;

context = clCreateContext(NULL, 1, &device, NULL, NULL, &ret);
command_queue = clCreateCommandQueue(context, device, 0, &ret);

그리고 다음 섹션에서 작성하게 될 커널을 사용할 수 있도록 프로그램과 커널 구조체를 생성합니다.

int ret;
cl_program program;
cl_kernel kernel;
char *source_str;

program = clCreateProgramWithSource(context, 1, (const char **)&source_str, NULL, &ret);
clBuildProgram(program, 1, &device, NULL, NULL, NULL);
kernel = clCreateKernel(program, "fractal", &ret);

이 때 커널 소스를 문자열 변수 source_str 에 저장하는 과정은 이전 포스트와 마찬가지로 생략하겠습니다. 파일 디스크립터, 파일 포인터를 이용하거나 문자열 상수로서 커널 소스를 작성하셔도 무방합니다. clCreateKernel(program, "fractal", &ret); 에서 "fractal" 은 커널의 이름이고 다음 섹션에서 작성하게 됩니다.

커널의 매개변수이자 병렬 연산의 결과인 메모리 오브젝트를 생성하겠습니다.

int *host_buf = /* 동적 할당 또는 그래픽 라이브러리의 이미지 버퍼 */;
cl_mem dev_mem_obj;
size_t global_work_size = WINDOW_WIDTH * WINDOW_HEIGHT;

dev_mem_obj = clCreateBuffer(context, CL_MEM_WRITE_ONLY,\
    sizeof(*host_buf) * global_work_size, NULL, &ret);

위 코드로 생성된 메모리 오브젝트는 연산이 끝난 뒤 호스트의 이미지 버퍼 변수 host_buf 에 복사됩니다. global_work_size 는 global 계층의 연산 유닛의 개수를 의미하고 만델브로 집합에 대한 병렬 연산은 각 픽셀에 대해 똑같이 수행되므로 모든 픽셀의 개수인 윈도우의 넓이 ×\times 높이를 할당합니다.



3. 커널

메모리 오브젝트의 생성까지 병렬 연산의 준비 과정을 마쳤습니다. 이제 만델브로 집합의 복소수 연산 커널을 작성해 보겠습니다.

// fractal.cl

#define MAX_ITERATION 100
#define WHITE 0xFFFFFF
#define BLACK 0x000000

typedef struct      s_complex
{
    float r;
    float i;
}                   t_complex;

커널 소스에 매크로 상수와 구조체를 정의했습니다. OpenCL 커널 코드를 작성하기 위한 언어인 OpenCL C 는 C99 기반의 언어이고 C 에서 지원하는 문법 및 개념을 OpenCL 에서도 사용할 수 있습니다. 구조체 s_complex 는 호스트에서 정의한 s_complex 와 크기, 멤버가 같습니다. 이는 호스트에서 구한 가장 왼쪽 위 픽셀의 복소수 좌표를 데이터의 손실없이 디바이스에 전달하기 위함입니다.

// fractal.cl

...

int iteration(t_complex z, t_complex c)
{
    int i;
    float aa;
    float bb;
    float two_ab;

    i = 0;
    while (i < MAX_ITERATION)
    {
        aa = z.r * z.r;
        bb = z.i * z.i;
        two_ab = 2 * z.r * z.i;
        if (aa + bb > 4)
            return (WHITE);
        z.r = aa - bb + c.r;
        z.i = two_ab + c.i;
        i++;
    }
    return (BLACK);
}

__kernel void fractal(
    __global int *out, // 이미지 버퍼로 쓰일 메모리 오브젝트
    t_complex entry_point, // 이미지 버퍼 상 가장 왼쪽 위 픽셀에 대응하는 복소수
    float delta, // 픽셀 사이 간격의 복소 평면에 대한 비율
    int window_width // 윈도우 넓이
)
{
    int idx = get_global_id(0);
    int r = idx % window_width;
    int i = idx / window_width;
    t_complex point;

    point.r = entry_point.r + delta * r;
    point.i = entry_point.i + delta * i;
    out[idx] = iteration(point, point);
}

위 코드는 복소수의 만델브로 집합 포함 여부를 판단할 로직을 보여주고 있습니다. 연산 유닛의 개수는 이미지 버퍼의 픽셀 개수와 같습니다. 커널 fractal 에서는 현재 연산 유닛의 global 계층 상 인덱스(int idx = get_global_id(0);) 와 호스트에서 매개 변수로 전달한 윈도우의 넓이(int window_width) 를 이용해 현재 연산 유닛의 복소수, 즉 이미지 버퍼에 대응하는 픽셀의 복소 평면 상 좌표를 구합니다. point.r = ...point.i = ... 코드 구문이 이를 보여주고 있습니다.

함수 iteration 은 커널 fractal 에서 호출하는 함수입니다. OpenCL 에서는 커널이 아닌 이와 같은 함수를 보조함수 (auxiliary function) 라 부릅니다. iteration 은 복소수 연산 zn+1=zn2+cz_{n + 1} = z_n^2 + cMAX_ITERATION 번 수행하고 발산하는 수열의 경계값인 2를 이용해 만델브로 집합에 속하지 않는 c 에 대해 흰색 RGB 값을 반환합니다. 조건문 if (aa + bb > 4) 가 이를 보여주고 있습니다. MAX_ITERATION 동안 zn\|z_n\| 가 경계값보다 작을 경우 검은색 RGB 값을 반환합니다.



4. 커널 실행

다시 호스트 파트로 돌아와 커널에 필요한 매개변수를 전달할 코드를 작성하겠습니다.

t_complex entry_point = init_entry_point();
float delta = init_delta();
int window_width = WINDOW_WIDTH;

clSetKernelArg(kernel, 0, sizeof(dev_mem_obj), &dev_mem_obj);
clSetKernelArg(kernel, 1, sizeof(entry_point), &entry_point);
clSetKernelArg(kernel, 2, sizeof(delta), &delta);
clSetKernelArg(kernel, 3, sizeof(window_width), &window_width);

그리고 커맨드 큐에 커널과 결과 메모리를 읽을 커맨드를 추가합니다.

size_t global_work_size = WINDOW_WIDTH * WINDOW_HEIGHT;
size_t local_work_size = 1;

clEnqueueNDRangeKernel(command_queue, kernel, 1, NULL,\
	&global_work_size, &local_work_size, 0, NULL, NULL);
clEnqueueReadBuffer(command_queue, dev_mem_obj, CL_TRUE, 0,\
	sizeof(*host_buf) * global_work_size, host_buf, 0, NULL, NULL);

커맨드 큐에 추가된 모든 커맨드를 실행합니다.

clFlush(command_queue);
clFinish(command_queue);

병렬 연산이 완료된 메모리 오브젝트의 데이터가 이미지 버퍼 host_buf 에 복사되었습니다. 사용하시는 그래픽 라이브러리를 이용해 host_buf 를 윈도우에 출력해보시기 바랍니다. 출력 결과는 아래와 같습니다.



5. 각각의 구조체 해제

마지막으로 각각의 구조체의 메모리를 해제합니다.

clReleaseKernel(kernel);
clReleaseProgram(program);
clReleaseMemObject(dev_mem_obj);
clReleaseCommandQueue(command_queue);
clReleaseContext(context);



아래는 지금까지의 과정을 정리한 코드입니다.

t_complex init_entry_point(void)
{
	t_complex	point;
	float		coord_height;
	float		window_width = WINDOW_WIDTH;

	point.r = -1.0f * COORD_WIDTH / 2.0f;
	coord_height = COORD_WIDTH * WINDOW_HEIGHT / window_width;
	point.i = -1.0f * coord_height / 2.0f;
	return (point);
}

float init_delta(void)
{
	return (COORD_WIDTH / WINDOW_WIDTH);
}

void example_fractal(void)
{
	int fd;
	int *host_buf;
	char *source_str;
	cl_int ret;
	cl_platform_id platform;
	cl_device_id device;
	cl_context context;
	cl_command_queue command_queue;
	cl_program program;
	cl_kernel kernel;
	cl_mem dev_mem_obj;
	size_t global_work_size = WINDOW_WIDTH * WINDOW_HEIGHT;
	size_t local_work_size = 1;
	t_complex entry_point;
	float delta;
	int window_width = WINDOW_WIDTH;

	host_buf = /* 동적 할당 또는 그래픽 라이브러리에서 제공하는 버퍼 할당 */;
	/*
	** example
	** host_buf = (int *)malloc(sizeof(int) * global_work_size);
	*/

	entry_point = init_entry_point();
	delta = init_delta();

	/* 병렬 연산을 수행할 디바이스 ID 쿼리 */
	clGetPlatformIDs(1, &platform, NULL);
	clGetDeviceIDs(platform, CL_DEVICE_TYPE_GPU, 1, &device, NULL);

	/* context 와 커맨드 큐 구조체 생성 */
	context = clCreateContext(NULL, 1, &device, NULL, NULL, &ret);
	command_queue = clCreateCommandQueue(context, device, 0, &ret);

	/*
	** 아래는 .cl 파일의 소스를 문자열로 복사하는 코드입니다.
	** get_file_content 와 같은 기능을 하는 함수는 어렵지 않게 만드실 수 있습니다.
	*/
	fd = open("kernels/fractal.cl", O_RDONLY);
	source_str = get_file_content(fd);
	close(fd);

	/* 프로그램 구조체 생성 */
	program = clCreateProgramWithSource(context, 1, (const char **)&source_str, NULL, &ret);
	clBuildProgram(program, 1, &device, NULL, NULL, NULL);

	/* 커널 구조체 생성 */
	kernel = clCreateKernel(program, "fractal", &ret);

	/* 메모리 오브젝트 생성 */
	dev_mem_obj = clCreateBuffer(context, CL_MEM_WRITE_ONLY,\
		sizeof(*host_buf) * global_work_size, NULL, &ret);

	/* 커널 매개변수 설정 */
	clSetKernelArg(kernel, 0, sizeof(dev_mem_obj), &dev_mem_obj);
	clSetKernelArg(kernel, 1, sizeof(entry_point), &entry_point);
	clSetKernelArg(kernel, 2, sizeof(delta), &delta);
	clSetKernelArg(kernel, 3, sizeof(window_width), &window_width);

	/* 커맨드 큐에 커널과 메모리 읽는 커맨드 추가 */
	clEnqueueNDRangeKernel(command_queue, kernel, 1, NULL,\
		&global_work_size, &local_work_size, 0, NULL, NULL);
	clEnqueueReadBuffer(command_queue, dev_mem_obj, CL_TRUE, 0,\
		sizeof(*host_buf) * global_work_size, host_buf, 0, NULL, NULL);

	/* 커맨드 큐에 추가된 커맨드 실행 */
	clFlush(command_queue);
	clFinish(command_queue);

	/* 윈도우에 host_buf 출력 */

	/* 필요한 구조체 메모리 해제 */
	clReleaseKernel(kernel);
	clReleaseProgram(program);
	clReleaseMemObject(dev_mem_obj);
	clReleaseCommandQueue(command_queue);
	clReleaseContext(context);
}



kernels/fractal.cl

#define MAX_ITERATION 100
#define WHITE 0xFFFFFF
#define BLACK 0x000000

typedef struct      s_complex
{
    float r;
    float i;
}                   t_complex;

int iteration(t_complex z, t_complex c)
{
    int i;
    float aa;
    float bb;
    float two_ab;

    i = 0;
    while (i < MAX_ITERATION)
    {
        aa = z.r * z.r;
        bb = z.i * z.i;
        two_ab = 2 * z.r * z.i;
        if (aa + bb > 4)
            return (WHITE);
        z.r = aa - bb + c.r;
        z.i = two_ab + c.i;
        i++;
    }
    return (BLACK);
}

__kernel void fractal(
    __global int *out, // 이미지 버퍼로 쓰일 메모리 오브젝트
    t_complex entry_point, // 이미지 버퍼 상 가장 왼쪽 위 픽셀에 대응하는 복소수
    float delta, // 각 픽셀 간의 복소 평면에 대응하는 간격
    int window_width // 윈도우 넓이
)
{
    int idx = get_global_id(0);
    int r = idx % window_width;
    int i = idx / window_width;
    t_complex point;

    point.r = entry_point.r + delta * r;
    point.i = entry_point.i + delta * i;
    out[idx] = iteration(point, point);
}





참고