이 실습에서는 그레이스케일1 이미지를 이진2 이미지로 변환하는, 오래되었지만 여전히 훌륭한 오츠 이진화(Otsu's thresholding)3 기법을 다룹니다. 이 알고리즘의 핵심 부분 중 하나는 그레이스케일 이미지의 히스토그램을 계산하는 것, 즉 이미지에서 특정 색상이 몇 번 나타나는지 계산하는 것입니다. 입력 이미지가 8비트 그레이스케일이므로 서로 다른 색상은 256가지뿐입니다.
std::array<uint32_t, 256> hist;
hist.fill(0);
for (int i = 0; i < image.width * image.height; ++i)
hist[image.data[i]]++;히스토그램 알고리즘의 구현은 매우 간단하지만 한 가지 골치 아픈 특성이 있습니다. 이미지의 각 픽셀마다 1) 해당 픽셀 색상의 현재 값을 읽고, 2) 그 값을 증가시킨 다음, 3) 다시 저장해야 합니다.
히스토그램에서 같은 색상에 대한 갱신이 비교적 높은 빈도로 발생하면,
프로세서가 픽셀 i+1의 처리를 시작하기 전에 픽셀
i의 갱신을 완료하지 못했을 수 있습니다. 이런 경우
프로세서는 i+1 갱신을 위해 로드되는 값이 메모리에서 올지,
아니면 i의 저장에서 올지를 예측합니다. 메모리에서 온다면 두
갱신을 병렬로 수행할 수 있지만, 그렇지 않다면 프로세서는 갱신을
직렬화해야 합니다.
간단한 예시: 이미지에 다음과 같은 픽셀이 있다면,
0xFF 0xFF 0xFF 0xFF 0xFF 0xFF ...
hist[0xFF]에 대한 모든 갱신이 직렬화됩니다.
이 문제를 어떻게 우회할 수 있을지 생각해 보십시오. 힌트: 추가 메모리를 사용할 수 있습니다.
보너스 연습 문제 1: 원래 구현과 여러분의 해법에서 최악의 경우와 최선의 경우는 각각 무엇입니까?
입력 이미지는 다음에서 가져왔습니다: https://people.sc.fsu.edu/~jburkardt/data/pgmb/pgmb.html
힌트
힌트 1:
분석해야 할 배열 채우기 패턴은 다음 코드 조각입니다.
for (int i = 0; i < image.width * image.height; ++i)
hist[image.data[i]]++;image.data의 원소가 동일한 긴 연속 구간을 만나면 문제가
발생한다는 점을 알 수 있습니다. 예를 들어, image.data
배열에 서로 인접한 원소 열 개로 이루어진 블록이 있고, 이들이 모두
100과 같다고 가정해 봅시다. CPU가 hist에
필요한 수정을 비순차적으로 수행할 수 있습니까?
힌트 2:
image.data의 원소가 동일한 긴 연속 구간이 있으면, 증가
연산에는 이전 값을 알아야 하므로(즉, 앞선 모든 증가 연산이 완료되어야
하므로) CPU는 hist에 대한 이 명령어들을 순서대로 처리할
수밖에 없습니다.
힌트 3:
긴 파이프라인을 더 작은 청크로 나누어, image.data의
연속된 동일 원소 때문에 순차 실행이 강제될 가능성을 줄여 보십시오.
풀이:
배경:
벤치마크 대상 함수는 다음과 같습니다.
std::array<uint32_t, 256> computeHistogram(const GrayscaleImage& image) {
std::array<uint32_t, 256> hist;
hist.fill(0);
for (int i = 0; i < image.width * image.height; ++i)
hist[image.data[i]]++;
return hist;
}여기서 발생할 수 있는 성능 문제를 이해하려면 CPU 파이프라인이 어떻게 동작하는지, 그리고 현재 코드가 어떻게 문제를 일으킬 수 있는지 이해해야 합니다.
최신 CPU에서 명령어가 파이프라인에서 처리되는 방식을 단순화하여 보면 다음과 같습니다.
- Instruction Fetch - CPU가 다음에 실행할 명령어를 가져옵니다
- Instruction Decode - 이어서 CPU가 이 명령어를 자신이 이해할 수 있는 형태로 디코딩합니다
- Execute - 명령어가 실행됩니다
- Memory Access(메모리 접근) - 메모리에 접근합니다
- Write Back - 명령어의 결과가 확정되고 커밋(retired)됩니다
현대 CPU는 순차(In-Order) 실행과 비순차(Out-of-Order, OOO) 실행을 모두 지원합니다. 전자에서는 명령어가 엄격하게 프로그램 순서대로 실행되는 반면, 비순차 실행에서는 CPU가 (프로그램 순서상) 앞선 명령어가 완료되기 전에 후속 명령어의 파이프라이닝과 실행을 시작할 수 있습니다. OOO 실행은 뒤에 오는 연산을 미리 시작해 먼저 끝냄으로써 눈에 띄는 성능 향상을 가져올 수 있습니다.
그러나 OOO 실행에는 제약이 있습니다. CPU는 OOO로 실행하는 동안에도 그렇지 않았을 때와 정확히 같은 결과를 내야 합니다. 따라서 어떤 명령어의 실행이 앞선 명령어의 결과에 대한 의존성으로 제약을 받는다면, 그 명령어는 진행되어서는 안 됩니다. 이는 또한 CPU가 언제 실행했는지와 관계없이 모든 연산이 프로그램 순서대로 retire되어야 한다는 뜻이기도 합니다.
이러한 제약을 파이프라인 해저드(pipeline hazards)라고 합니다. 여기서 풀고 있는 이 실습과 관련된 해저드는 데이터 해저드(data hazards)라고 합니다. 이는 명령어 사이에 읽기 및 쓰기 의존성이 있는 경우를 말합니다.
위 코드 조각의 배열 채우기 패턴으로 돌아가 보면 다음과 같습니다.
for (int i = 0; i < image.width * image.height; ++i)
hist[image.data[i]]++;image.data의 요소들이 동일한 값으로 길게 연속되는 구간을
만나면 문제가 생긴다는 것을 알 수 있습니다. 예를 들어,
image.data 배열에 서로 인접한 열 개의 요소로 이루어진
블록이 있고 그 값이 모두 100이라고 가정해 봅시다. CPU가
hist에 필요한 수정을 비순차적으로 수행할 수 있겠습니까?
답은 '아니요, 할 수 없습니다'입니다. "hist[100]의 메모리
값을 증가시키는" 명령어가 여러 개 있습니다. 루프의 한 반복에서
hist[100]에 수행되는 쓰기 연산은 hist[100]의
어떤 값을 증가시켜야 하는지 알아야 하는데, 이전 반복이 retire된
이후에야 그 값을 알 수 있습니다. 결국 이는 순차 CPU 명령어
파이프라인에 가까운 형태로 퇴화하며, 후속 반복의 명령어들은 앞선
명령어들이 retire될 때까지 기다려야 합니다.
이론을 정리했으니 이제 성능 분석으로 돌아갈 수 있습니다. 다음은 원래
코드에 대한 perf 출력의 예입니다.
-----------------------------------------------------
Benchmark Time CPU Iterations
-----------------------------------------------------
bird/0 85.5 us 85.5 us 32819
coins/1 39.1 us 39.1 us 72406
pepper/2 32.4 us 32.4 us 86228
pixabay/3 14.6 ms 14.6 ms 193
해결책
hist[x]의 증가 연산을 더 작고 독립적인 하위 배열로
나누고, 그 중간값들을 마지막에 단순히 합산하는 방식으로 이 파이프라인
해저드의 영향을 줄일 수 있습니다. 코드는 다음과 같습니다.
std::array<uint32_t, 256> computeHistogram(const GrayscaleImage& image) {
std::array<uint32_t, 256> hist1{};
std::array<uint32_t, 256> hist2{};
std::array<uint32_t, 256> hist3{};
std::array<uint32_t, 256> hist4{};
int i = 0;
for (; i + 3 < image.width * image.height; i+=4) {
hist1[image.data[i]]++;
hist2[image.data[i+1]]++;
hist3[image.data[i+2]]++;
hist4[image.data[i+3]]++;
}
for (; i < image.width * image.height; i++) {
hist1[image.data[i]]++;
}
for (int j = 0; j < hist1.size(); ++j) {
hist1[j] += hist2[j] + hist3[j] + hist4[j];
}
return hist1;
}루프를 네 개 단위의 청크로 나누고, 네 개의 히스토그램에서 각각
해당하는 인덱스를 증가시킵니다. 이 배열들은 스택 기반 배열이므로
메모리가 매우 가깝고 그만큼 빠르기 때문에, hist 요소에
무작위로 접근해도 괜찮다는 점에 유의하십시오.
앞의 네 개 단위 블록에서 처리되지 않고 남은 요소가 있다면,
hist1에 대해 최대 세 번의 증가 연산을 추가로 수행하면
됩니다.
마지막으로 결과를 합산합니다.
생성된 어셈블리 코드를 분석해 보면(-O3 최적화로 컴파일),
마지막 루프에서 매우 좋은 점을 발견할 수 있습니다.
루프:
for (int j = 0; j < hist1.size(); ++j) {
hist1[j] += hist2[j] + hist3[j] + hist4[j];
}어셈블리:
.L8:
vmovdqu (%rax,%rbx), %ymm0
vpaddd (%rcx,%rax), %ymm0, %ymm0
vpaddd (%r12,%rax), %ymm0, %ymm0
vpaddd 0(%r13,%rax), %ymm0, %ymm0
addq $32, %rax
vmovdqu %ymm0, -32(%rax,%rbx)
cmpq $1024, %rax
jne .L8컴파일러가 우리의 루프를 자동 벡터화했습니다! YMM 레지스터(256비트)를
사용하여 hist[i] 배열에 저장하는 uint32_t
객체에 대해 256 / 32 = 8개의 덧셈을 동시에 수행할 수 있으므로, SIMD를
통해 큰 성능 이점을 얻을 수 있습니다.
벤치마크 결과, (반복 횟수가 더 많은 경우에도) 실행 시간이 줄어들어 성능이 개선되었음을 확인할 수 있습니다:
-----------------------------------------------------
Benchmark Time CPU Iterations
-----------------------------------------------------
bird/0 67.2 us 67.2 us 41713
coins/1 32.2 us 32.2 us 86758
pepper/2 28.3 us 28.3 us 98598
pixabay/3 12.9 ms 12.9 ms 218