요청(JSON) → GenerateJob 큐잉 → CUDA 커널로 픽셀 생성 → BMP 파일 생성 → 멀티파트 업로드 → 완료 콜백

초대형 BMP는 단순히 “파일이 큰 것”을 넘어, 결국 픽셀을 채우는 연산량이 폭발합니다.
width × height 만큼 픽셀 연산 반복/ 채널(R/G/B) 패턴 규칙(간격/크기/오프셋) 계산 최종적으로 BGR 바이트 배열을 채움
CPU로도 가능하지만, 이 작업은 구조적으로 픽셀 단위 독립 연산(데이터 병렬) 이라 GPU에 매우 적합합니다.
그래서 연산 엔진에서 CUDA 커널로 “픽셀 채우기”를 병렬화했습니다.

CUDA는 HOST(CPU)에서 GPU에서 실행할 블록 및 그리드 크기 계산하고

해당 부분을 GPU가 병렬 연산을 수행하여 매우 효율적입니다.
연산 엔진은 httplib 기반의 간단한 HTTP 서버로 구동됩니다.
svr.Post("/generate", [](const httplib::Request& req, httplib::Response& res) {
json j = json::parse(req.body);
conv::GenerateJob job;
if (j.contains("partUploadUrls")) {
job.partUploadUrls = j.at("partUploadUrls").get<std::vector<std::string>>();
job.uploadId = j.at("uploadId").get<std::string>();
job.objectName = j.at("objectName").get<std::string>();
}
job.generationUuid = j.at("generationUuid").get<std::string>();
job.bmpWidth = j.at("bmpWidth").get<uint32_t>();
job.bmpHeight = j.at("bmpHeight").get<uint32_t>();
job.bmpVolume = j.at("bmpVolume").get<uint64_t>();
// RGB 패턴 파라미터들 생략...
conv::enqueueGenerateJob(std::move(job));
res.status = 202;
});
요청을 받으면 바로 계산하지 않고, GenerateJob로 변환해 큐에 넣고 즉시 202 응답합니다.
GenerateJob은 사실상 “GPU가 이미지를 만들기 위해 필요한 모든 입력”을 가진 DTO입니다.

struct GenerateJob {
std::vector<std::string> partUploadUrls;
std::string uploadId;
std::string objectName;
std::string generationUuid;
uint32_t bmpWidth;
uint32_t bmpHeight;
uint64_t bmpVolume;
int redCountX, redCountY, redSizeX, redSizeY, redGapX, redGapY;
int greenCountX, greenCountY, greenSizeX, greenSizeY, greenGapX, greenGapY;
int blueCountX, blueCountY, blueSizeX, blueSizeY, blueGapX, blueGapY;
int rgGapX, rgGapY, gbGapX, gbGapY;
std::string authScheme;
std::string accessToken;
};
연산 엔진 내부는 간단한 producer-consumer 구조입니다.
void conv::enqueueGenerateJob(GenerateJob&& job) {
std::lock_guard<std::mutex> lk(g_mtx);
g_queue.push(std::move(job));
g_cv.notify_one();
}
void conv::startGenerateWorkers(int count) {
for (int i = 0; i < count; ++i)
std::thread(generate_worker_loop).detach();
}
generate_worker_loop()에서 실제 연산이 수행됩니다.
커널 실행 구성 2D grid/block로 이미지 전체를 커버
block(x, y) 은 warp 활용과 메모리 접근을 고려한 구성
block(32,8): 한 블록 안에 GPU 스레드를 32×8 = 256개
grid(...): 그 블록을 가로/세로로 몇 개 깔아서 전체 이미지를 덮겠다
이미지 (width x height)
┌─────┬─────┬─────┐
│ blk │ blk │ blk │ ← grid.x
├─────┼─────┼─────┤
│ blk │ blk │ blk │
├─────┼─────┼─────┤
│ blk │ blk │ blk │
└─────┴─────┴─────┘
↑
grid.y
dim3 block(32, 8);
dim3 grid((width + block.x - 1) / block.x, (height + block.y - 1) / block.y);
fillCellPatternKernel<<<grid, block>>>(d_pixels, width, height, rowSize, cell);
cudaDeviceSynchronize();
핵심은 fillCellPatternKernel 입니다.
각 thread가 (x, y) 픽셀을 맡아, 해당 픽셀이 R/G/B 중 어떤 채널 블록에 속하는지 판단해 BGR 값을 기록합니다.
__global__ void fillCellPatternKernel(
unsigned char* d_pixels,
int width, int height,
size_t rowSize,
CellPattern cell)
{
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
if (x >= width || y >= height) return;
int yFlip = height - 1 - y;
int cellW = cell.cellWidth + cell.interGapX;
int cellH = cell.cellHeight + cell.interGapY;
int col = x / cellW;
int row = yFlip / cellH;
if (col >= cell.gridCols || row >= cell.gridRows) return;
int localX = x % cellW;
int localY = yFlip % cellH;
if (localX >= cell.cellWidth || localY >= cell.cellHeight) return;
int type = inChannelCell(cell, localX, localY);
unsigned char R = 0, G = 0, B = 0;
if (type == 1) R = 255;
if (type == 2) G = 255;
if (type == 3) B = 255;
size_t off = (size_t)y * rowSize + (size_t)x * 3;
d_pixels[off + 0] = B;
d_pixels[off + 1] = G;
d_pixels[off + 2] = R;
}
BMP는 row padding이 필요하기 때문에 rowSize를 4바이트 정렬로 맞춥니다.
static inline uint64_t rowSizePadded(int width) {
uint64_t row = (uint64_t)width * 3ull;
return (row + 3ull) & ~3ull;
}
GPU 메모리를 할당하고 커널로 채운 다음,
Host로 복사할 때는 pinned memory(cudaMallocHost)로 복사 속도를 개선합니다.
unsigned char* h_pixels = nullptr;
cudaMallocHost(&h_pixels, dataSize);
cudaMemcpy(h_pixels, d_pixels, dataSize, cudaMemcpyDeviceToHost);
cudaFree(d_pixels);
CUDA 결과는 단순 BGR byte array이므로, BMP 헤더를 직접 작성해 파일로 저장합니다.
std::ofstream f(bmpPath, std::ios::binary);
uint32_t fileSize = 54 + (uint32_t)dataSize;
f.put('B'); f.put('M');
f.write((char*)&fileSize, 4);
// ... BMP header 생략
f.write((char*)h_pixels, dataSize);
이 과정은 “CPU 연산”이 아니라 단순한 IO이므로, GPU 병목과 분리됩니다.
생성된 BMP는 partUploadUrls가 존재하면 멀티파트 업로드 경로로 들어갑니다.
각 URL에 대해 PUT 업로드
응답 헤더에서 ETag를 수집
이후 backend의 /s3/complete로 업로드 완료 요청
if (!io->uploadFileWithMultipartUrls(bmpPath.string(), job.partUploadUrls, parts)) {
throw std::runtime_error("multipart upload failed");
}
완료 요청은 다음과 같은 JSON으로 전달됩니다.
json body = {
{"uploadId", job.uploadId},
{"objectName", job.objectName},
{"parts", partArray} // partNumber, eTag
};
io->postJson(completeUrl, body.dump(), headers, &httpCode, &resp);
즉, 연산 엔진은 “파일 생성”만 하는 게 아니라, 업로드까지 책임지는 완결형 워커입니다.
모든 작업이 끝나면 backend 콜백으로 성공/실패를 전달합니다.
json body = {
{"isSuccess", true},
{"errorMessage", nullptr},
{"generationUuid", job.generationUuid},
{"bmpVolume", bmpVolume}
};
std::string cb = callbackBase + "/bmp/" + job.generationUuid + "/complete";
io->postJson(cb, body.dump(), headers, &code, &resp);
실패 시에도 동일 경로로 isSuccess=false, errorMessage를 채워 전달해
(1)편에서 설명한 DB 상태 갱신 + SSE 알림 흐름으로 자연스럽게 이어집니다.
프로젝트는 project(... LANGUAGES CXX CUDA)로 CUDA 빌드를 기본으로 하고,
nvTIFF, libcurl, Threads, NVML, libtiff, zlib, RabbitMQ 라이브러리까지 링크합니다.
project(image_converter LANGUAGES CXX CUDA)
find_package(CURL REQUIRED)
find_package(Threads REQUIRED)
find_package(TIFF REQUIRED)
find_package(ZLIB REQUIRED)
target_link_libraries(image_core PRIVATE
"${NVTIFF_LIBRARY}"
"${CUDART_LIBRARY}"
CURL::libcurl
Threads::Threads
${NVML_LIBRARY}
${TIFF_LIBRARIES}
${ZLIB_LIBRARY}
${RABBITMQ_LIB}
${SIMPLE_AMQP_CLIENT_LIB}
)
정리하며
(2)편에서 정리한 CUDA 기반 이미지 생성 엔진의 핵심은 다음입니다.
- /generate 요청을 받으면 GenerateJob으로 변환해 큐잉(202 응답)
- 워커가 job을 가져와 CUDA 커널로 픽셀을 병렬 생성
- pinned memory로 host 복사 → BMP 파일 저장
- presigned multipart URL로 병렬 업로드 + ETag 수집
- backend /s3/complete로 멀티파트 완료
- backend /bmp/{uuid}/complete로 성공/실패 콜백