[C++] AVX2 벡터확장.
AVX2 벡터확장 (프로그램 속도최적화).
서론
오늘은 프로그램의 속도최적화와 관련된 이야기를 해볼까 한다.
앞선 포스팅에서 OpenMP 관련 내용도 게시했는데, 멀티스레드와는 결이 다른 방식이다.
요즘은 중국 우한에서 TIANMA 라는 국영기업 공장으로 출근하고 있다.
해당 공장에서 Open Metal Mask 라고 불리는 제품의 외관검사 프로젝트를 진행하고 있다.
제품의 크기가 크고 라인스캔 카메라로 초당 많은 양의 이미지가 들어오기 때문에,
실시간 검사속도가 아주 중요한 프로젝트이다.
따라서 불량검출을 위한 이미지 전처리 속도도 아주 중요했는데…
이와 관련해 어떤 방식으로 속도를 높였는지 얘기해볼까 한다.
AVX2 벡터확장이란?
bit depth 8 의 흑백이미지에서 영상처리를 할 때 32번의 연산을 해야할 일이 생겼다면
일반적인 CPU는 32 회 연산을 수행 할 것이다.
하지만 AVX2 벡터확장을 이용하면 CPU는 32byte 크기의 레지스터를 활용해 연산을 수행한다.
따라서 1회만 연산을 수행한다.
적용 방법
프로젝트 설정 > 구성 속성 > C/C++ > 코드 생성 > 고급 명령 집합 사용 > 고급 벡터 확장 2(/arch:AVX2)
프로젝트 설정 > 구성 속성 > C/C++ > 전처리기 > 전처리기 정의 > “__AVX2__” 추가
사용 예시
원본 이미지를 이진화하는 AVX2 사용 예시코드이다.
cv::Mat VisionProcess::CInspectAvi::Binarize(const cv::Mat& src, int dist, int threshold, int minCount, bool findDarker, int blurSize) {
// ── 전처리 (블러) ─────────────────────────────────────────
static thread_local cv::Mat processed;
if (blurSize > 0) {
cv::GaussianBlur(src, processed, cv::Size(blurSize, blurSize), 0);
}
else {
processed = src.clone();
}
// ── 기본 정보 ─────────────────────────────────────────────
const int rows = processed.rows;
const int cols = processed.cols;
const size_t step = processed.step;
cv::Mat dst(rows, cols, CV_8UC1, cv::Scalar(0));
const uchar* pBase = processed.data;
uchar* pDstBase = dst.data;
const size_t dstStep = dst.step;
// ── 8방향 1D 메모리 오프셋 ────────────────────────────────
const long offsets[8] = {
-static_cast<long>(step * dist), // 상
static_cast<long>(step * dist), // 하
-static_cast<long>(dist), // 좌
static_cast<long>(dist), // 우
-static_cast<long>(step * dist) - dist, // 좌상
-static_cast<long>(step * dist) + dist, // 우상
static_cast<long>(step * dist) - dist, // 좌하
static_cast<long>(step * dist) + dist, // 우하
};
// ── x/y 방향 오프셋 (스칼라 경계 체크용) ─────────────────
const int xOffsets[8] = { 0, 0, -dist, dist, -dist, dist, -dist, dist };
const int yOffsets[8] = { -dist, dist, 0, 0, -dist, -dist, dist, dist };
// ── AVX2 처리 범위 ────────────────────────────────────────
const int avxStart = dist; // AVX 시작 x (이 이전은 스칼라)
const int avxEnd = cols - 2 * dist - 32; // AVX 종료 x (이 이후는 스칼라)
// ── 스칼라 처리 람다 (경계 skip 포함) ────────────────────
auto ScalarProcess = [&](int y, int x) {
const uchar* pSrc = pBase + y * step;
uchar* pDst = pDstBase + y * dstStep;
const uchar center = pSrc[x];
int matchCount = 0;
for (int i = 0; i < 8; ++i) {
const int nx = x + xOffsets[i];
const int ny = y + yOffsets[i];
// 이미지 범위 밖이면 skip
if (nx < 0 || nx >= cols || ny < 0 || ny >= rows) continue;
const uchar neighbor = *(pSrc + x + offsets[i]);
const int diff = findDarker
? std::max(0, static_cast<int>(neighbor) - static_cast<int>(center))
: std::max(0, static_cast<int>(center) - static_cast<int>(neighbor));
if (diff > static_cast<uchar>(threshold) && ++matchCount >= minCount) {
pDst[x] = 255;
break;
}
}
};
// ── 타일 크기: L2 캐시(512KB) 기준 동적 계산 ─────────────
const int TILE_ROWS = std::max(4, std::min(64, (512 * 1024) / std::max(1, cols)));
const int tileCount = (rows + TILE_ROWS - 1) / TILE_ROWS; // 전체 rows로 확장
for (int t = 0; t < tileCount; ++t) {
const int yStart = t * TILE_ROWS;
const int yEnd = std::min(yStart + TILE_ROWS, rows);
for (int y = yStart; y < yEnd; ++y) {
const bool bYBorder = (y < dist || y >= rows - dist); // 상단/하단 경계 여부
#ifdef __AVX2__
const __m256i vThr = _mm256_set1_epi8(static_cast<char>(std::min(threshold, 255)));
const __m256i vMinCount = _mm256_set1_epi8(static_cast<char>(minCount - 1));
const __m256i vSign = _mm256_set1_epi8(static_cast<char>(INT8_MIN));
const __m256i vOne = _mm256_set1_epi8(1);
#endif
// 1. 좌측 경계: x = 0 ~ avxStart-1 (스칼라, skip 방식)
for (int x = 0; x < avxStart && x < cols; ++x) {
ScalarProcess(y, x);
}
// 2. AVX2 내부 구간: x = avxStart ~ avxEnd
// 단, y가 상단/하단 경계이면 AVX 스킵하고 스칼라로 처리
#ifdef __AVX2__
if (!bYBorder && avxEnd >= avxStart) {
const uchar* pSrc = pBase + y * step;
uchar* pDst = pDstBase + y * dstStep;
for (int x = avxStart; x <= avxEnd; x += 32) {
const __m256i vCenter = _mm256_loadu_si256((const __m256i*)(pSrc + x));
__m256i vMatch = _mm256_setzero_si256();
for (int i = 0; i < 8; ++i) {
const __m256i vNeighbor = _mm256_loadu_si256((const __m256i*)(pSrc + x + offsets[i]));
const __m256i vDiff = findDarker
? _mm256_subs_epu8(vNeighbor, vCenter)
: _mm256_subs_epu8(vCenter, vNeighbor);
const __m256i vCond = _mm256_cmpgt_epi8(
_mm256_xor_si256(vDiff, vSign),
_mm256_xor_si256(vThr, vSign));
vMatch = _mm256_add_epi8(vMatch, _mm256_and_si256(vCond, vOne));
}
const __m256i vResult = _mm256_cmpgt_epi8(
_mm256_xor_si256(vMatch, vSign),
_mm256_xor_si256(vMinCount, vSign));
_mm256_storeu_si256((__m256i*)(pDst + x), vResult);
}
// avxEnd 이후 ~ cols-dist-1: 스칼라 (기존 우측 나머지)
for (int x = avxEnd + 32; x < cols - dist; ++x) {
ScalarProcess(y, x);
}
}
else {
// y가 경계 구간이면 avxStart ~ cols-dist-1 전체를 스칼라로
for (int x = avxStart; x < cols - dist; ++x) {
ScalarProcess(y, x);
}
}
#else
// AVX2 없는 환경: avxStart ~ cols-dist-1 전체 스칼라
for (int x = avxStart; x < cols - dist; ++x) {
ScalarProcess(y, x);
}
#endif
// 3. 우측 경계: x = cols-dist ~ cols-1 (스칼라, skip 방식)
for (int x = std::max(avxStart, cols - dist); x < cols; ++x) {
ScalarProcess(y, x);
}
}
}
return dst;
}
AVX2 전처리 정의 외부에 유사한 코드가 작성되어 있는 것을 볼 수 있다.
주요 차이점은 x의 값이 32개씩 증가하는지 1씩 증가하는지 이다.
AVX2는 32개의 픽셀씩 작업을 수행하기 때문에 총 작업량이 32의 배수와 다르다면, AVX2가 처리할 수 없는 영역이므로 예외처리가 필요한 것이다.
이를 벡터영역과 스칼라영역이라고 명칭한다.
AVX2는 알겠는데 AVX1은 무엇이었을까??
| 이름 | AVX | AVX2 |
|---|---|---|
| 출시 | 2011 | 2013 |
| 레지스터 크기 (Bit) | 256 | 256 |
| 주력 연산 대상 | Float, Double | ALL |
| 정수 처리량 | 128 | 256 |
이 외에도 FMA3(고속 곱셈-누산)을 지원하는지, Gather(데이터 섞기)를 지원하는지 등의 차이도 있지만 지금 내가 잘 모르는 내용이기도 하고 위 예시 코드에서 사용되지도 않았으니 생략하겠다.
나와 같은 상황, 큰 이미지에서 단순 비교를 통해 이미지 이진화를 해야하는 상황에선 AVX2 가 더 올바른 선택이었고. 굳이 그렇지 않더라도 상위호환이기 때문에 그냥 AVX2 를 쓰면 된다는 뜻이다.
다만 사용하는 CPU가 2013년 이전에 출시했다면 AVX1 으로 타협해야 할 것이다.
결론
AVX2 (벡터확장 가속) 기술은 CPU 코어의 효율을 높여주는 것이기 때문에 병렬처리, 멀티스레드와는 다른 개념이다. 둘 다 사용하면 좋다.
끝.
댓글남기기