IMPRESSER 이미지 생성 파이프라인 설계 및 개발(2)

heesan·2026년 1월 13일

IMPRESSER

목록 보기
2/5
post-thumbnail

CUDA 기반 이미지 생성

최대 “2GB BMP”를 GPU로 찍어내기 위한 C++/CUDA 연산 엔진 설계

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


1. 왜 CUDA가 필요했나

초대형 BMP는 단순히 “파일이 큰 것”을 넘어, 결국 픽셀을 채우는 연산량이 폭발합니다.

width × height 만큼 픽셀 연산 반복/ 채널(R/G/B) 패턴 규칙(간격/크기/오프셋) 계산 최종적으로 BGR 바이트 배열을 채움

CPU로도 가능하지만, 이 작업은 구조적으로 픽셀 단위 독립 연산(데이터 병렬) 이라 GPU에 매우 적합합니다.
그래서 연산 엔진에서 CUDA 커널로 “픽셀 채우기”를 병렬화했습니다.


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

해당 부분을 GPU가 병렬 연산을 수행하여 매우 효율적입니다.


2. C++ 엔진은 “HTTP 서버 + 작업 큐 + 워커” 구조

연산 엔진은 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 응답합니다.


3. GenerateJob: “패턴 파라미터 + 업로드 정보 + 인증”을 모두 가진 작업 단위

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;
};

4. 워커 스레드: 큐에서 job을 꺼내 “생성 → 업로드 → 콜백”까지 처리

연산 엔진 내부는 간단한 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()에서 실제 연산이 수행됩니다.


5. CUDA 커널: 픽셀 단위 병렬로 RGB 패턴 생성

커널 실행 구성 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;
}

6. 메모리 전략: pitch + pinned memory로 Host 복사 비용 최소화

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);

7. BMP 파일 생성: 헤더 직접 작성 + raw pixel dump

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 병목과 분리됩니다.


8. 멀티파트 업로드: presigned URL로 파트 병렬 PUT + ETag 수집

생성된 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);

즉, 연산 엔진은 “파일 생성”만 하는 게 아니라, 업로드까지 책임지는 완결형 워커입니다.


9. 완료 콜백: backend에 성공/실패를 알려 상태를 마무리

모든 작업이 끝나면 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 알림 흐름으로 자연스럽게 이어집니다.


10. 빌드/의존성: CMake로 CUDA + nvTIFF + curl + RabbitMQ까지 통합

프로젝트는 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로 성공/실패 콜백
profile
👩‍💻Backend Engineering

0개의 댓글