최근 uled 검사 장비 개발 프로젝트를 진행하면서 극한의 성능 요구사항에 부딪혔습니다.
시스템에는 90GB에 달하는 초대형 Raw 이미지가 메모리에 로드되어 있습니다. 제 미션은 이 거대한 이미지 전체를 건드리는 것이 아니라, 검사가 필요한 4,000개 이상의 특정 영역(ROI)만 빠르게 잘라내어 어파인 변환(Affine Transform)을 수행하는 것이었습니다.
처음에는 "GPU가 빠르니까 금방 하겠지"라고 생각했지만, 현실은 달랐습니다. 90GB라는 거대한 바다에서 작은 조각 4,000개를 건져 올리는 과정에서 PCIe 통신 병목(Latency)이 발목을 잡았기 때문입니다.
오늘은 이 문제를 해결하기 위해 적용한 대용량 메모리 핀(Pin) 등록과 필요한 부분만 처리하는 GPU 배치 조립(Batch Assembly) 기법을 공유합니다.

1. 문제의 핵심: "4,000번의 왕복 달리기"
일반적인 OpenCV CUDA 방식으로 4,000개의 칩(Die)을 검사한다고 가정해 봅시다.
- 90GB 원본에서 1번 칩 위치 Crop
- GPU로 upload
- warpAffine (회전/보정)
- CPU로 download
- ... (이걸 4,000번 반복)
아무리 작은 이미지라도, CPU와 GPU 사이를 4,000번이나 왔다 갔다 하면 데이터를 처리하는 시간보다 데이터를 옮기려고 대기하는 시간(Overhead)이 훨씬 더 커집니다. 배달 기사님이 피자 4,000판을 배달하는데, 한 번에 한 판씩만 들고 4,000번을 왕복하는 꼴입니다.
2. 해결책 1: 90GB 전체를 '준비 태세'로 (Memory Registration)
우선 90GB 원본 데이터가 OS의 간섭 없이 언제든 접근 가능해야 합니다. 윈도우 환경에서 이렇게 큰 메모리를 페이징(Swap) 없이 고정(Pinning)하려면, 물리 메모리 사용 한도을 강제로 늘려줘야 합니다.
// [ImsGpuEngine.cpp 발췌]
// 1. 프로세스의 Working Set 크기를 90GB+a로 강제 증액
// 이것이 없으면 대용량 메모리 등록 시 'Out Of Memory' 발생
SetProcessWorkingSetSize(hProcess, requestSize, requestSize);
// 2. OpenCV Mat의 크기 한계 극복 (Reshaping)
// 90GB를 1D로 등록할 수 없어, Width=64로 고정하고 Height를 늘려서 등록
cv::Mat wrapper((int)heightCheck, width, CV_8UC1, pBuffer);
// 3. CUDA에 Pinned Memory로 등록 (Zero-Copy 활성화)
// 이제 90GB 중 '어디든' GPU가 즉시 접근할 수 있는 상태가 됨
cv::cuda::registerPageLocked(wrapper);
이 과정은 90GB를 복사하는 게 아니라, "이 메모리는 건드리지 마(Lock)"라고 OS에 신고만 하는 것이므로 부팅 시 딱 한 번만 수행하면 됩니다.
3. 해결책 2: 필요한 것만 담는 GPU 조립 라인 (Batch Assembly)
이제 90GB 버퍼에서 4,000개의 ROI를 효율적으로 가져와야 합니다. 저는 "4,000번의 왕복"을 "단 1번"으로 줄이는 전략을 세웠습니다.
이름하여 GPU Atlas Assembly 기법입니다.
- Partial Upload (부분 업로드): 90GB 전체를 GPU에 올리는 건 미친 짓입니다. 4,000개 ROI가 포함된 '필요한 영역'만 스마트하게 추려서 GPU로 보냅니다. (Zero-Copy라 빠릅니다.)
- GPU Assembly (내부 조립): 8개의 CUDA Stream을 돌려서 병렬로 warpAffine을 수행합니다. 이때 결과를 CPU로 바로 보내지 않고, **GPU 메모리 내부에 미리 할당해 둔 결과용 버퍼(Atlas)**에 차곡차곡 쌓습니다.
- Single Download (일괄 전송): 4,000개 처리가 다 끝나면, 조립이 완료된 결과물 컨테이너를 단 한 번의 다운로드로 가져옵니다.
// [배치 처리 핵심 로직]
for (size_t i = 0; i < requests.size(); i++)
{
// GPU 메모리 내의 결과 버퍼(Atlas) 위치 계산
unsigned char* pGpuPtr = m_dBatchResultGpu.data + currentOffset;
// *핵심*: 결과를 CPU로 보내지 않고, GPU 메모리 안에서 그리기만 함!
// srcView: 90GB 중 필요한 부분만 업로드된 GPU 메모리
cv::cuda::warpAffine(srcView, dstGpuRoi, M, ... , curStream);
currentOffset += frameBytes;
}
// 모든 조립이 끝나면 '한 번에' 다운로드
m_dBatchResultGpu.download(m_hBatchPinnedBuffer);
4. 결과: PCIe 병목 해소와 초고속 처리
이 아키텍처의 핵심은 "GPU는 90GB 전체를 알 필요가 없다"와 "통행료(PCIe Latency)는 한 번만 낸다"입니다.
- Before: ROI 1개마다 통신 -> 4,000번 통신 대기 발생 -> 매우 느림
- After: 필요한 데이터만 묶어서 업로드 -> GPU 내부에서 지지고 볶고 조립 -> 결과만 한 번에 다운로드 -> PCIe 효율 극대화
결과적으로 사용자는 pOutputMat 포인터만 확인하면, 복사 비용 없이(Zero-Copy) 변환이 완료된 4,000장의 이미지를 즉시 사용할 수 있게 되었습니다.
5. 마무리하며
대용량 데이터를 다룰 때 가장 큰 적은 '연산량'이 아니라 '이동 비용'입니다.
90GB라는 거대한 데이터에서 필요한 정보만 핀셋처럼 뽑아내어(Crop), 병목 없이 GPU 가속을 태우는 이 구조(ImsGpuEngine)는 앞으로 고속 웨이퍼 검사 모듈의 핵심 엔진이 될 것입니다. 비슷한 대용량 데이터 처리로 고민하시는 분들께 힌트가 되었으면 좋겠네요.
'Vision & Inspection' 카테고리의 다른 글
| [Vision/C++] 반복 패턴 이미지에서의 Grid Center 검출 알고리즘 구현 (0) | 2025.12.21 |
|---|---|
| [Insight] 이미지를 돌릴까, 마스크를 돌릴까? (Image vs Mask Rotation) (0) | 2025.12.07 |