C++

C/C++ 코드 최적화

kim선달 2020. 5. 26. 19:21
Select Language

C++ 코드 속도 향상 기법


제 주전공인 물리학과 특성상 시뮬레이션 같은걸 돌릴 경우 수 시간에서 수일, 크게는 몇개월 까지 돌리는 일이 많습니다.

이 때문에 교수님들이 포트란/C 시절의 극한의 코드 쥐어짜기를 은연중에 설파하시고, 저도 관련 자료들을 많이 찾아 봤습니다.

아래는 제가 경험적으로 알게 됬거나 구글링을 통해 찾아 본 방법이며, 하드웨어, 컴파일러마다 다른 결과가 나올 수 있습니다.


피드백은 언제나 환영합니다


코드 최적화.pptx

본 내용은 제가 예전에 발표한 코드 최적화 프레젠테이션을 문서화 한 것입니다. (아직 덜 옮겨적었습니다)

PPT 에 다 못한 얘기들을 더 추가할 예정입니다.


목차

1. 서론

2. 일반적인 방법

3. 하드웨어 동작 기반

4. 그 외


1. 서론


(프로파일링 등을 하여) 정확히 어떤 곳을 최적화 할지 알아야 합니다.
코드 최적화는 언제나 모든 코드가 작성 완료 된 이후 하여야 합니다.


코드 최적화를 하기에 앞서 가장 중요한 마음가짐입니다. 

괜히 가 ++i 이 빠르니  i++ 가 빠르니 일일이 테스트 해 보다가 시간 잡아먹는것 보다 일단 프로젝트에 대한 코드가 모두 작성되고 난 이후 하는게 효율적입니다. 

그 다음, 프로파일링을 통해 어떤 부분이 느린지 정확히 알아내고 난 이후에 그 부분을 순서대로 최적화 해야 합니다

예를 들어 코드 일부분의 속도를 10배 빠르게 최적화 시켜봤자, 정작 코드 전체에서 차지하는 비중이 1% 라면 실질적인 속도 향상은 거의 없다고 봐도 무방합니다. 

그 보다는 속도가 1.2배 밖에 빨라지지 않더라도 전체 코드에서 비중이 30%인 부분의 코드를 먼저 고치는게 더 효율적입니다.

자꾸 효율을 강조하는데, 이는 제 경험이기도 합니다.

아직 비중이 큰 코드를 최적화 하지 않은 상황에서, 비중이 적은 부분의 코드를 보다 더 빠르게 짜겠다고 몇시간을 쏟아붓는건 매우 비효율적인 일입니다. 

젤 처음에 말했듯이, 코드 최적화는 몇몇 너무 당연하면서(시간 복잡도가 바뀌는 등) 쉬운 최적화를 제외하면 이게 실질적으로 성능 향상이 있는지 없는지는 프로파일링을 하기 전까지 모릅니다.

솔직히 말해서 코드 대충 짜고 컴파일러 최적화 옵션을 최대치로 설정해 준 거나 10시간 들여서 최적화 한 코드나 성능상 별 차이가 없는 경우가 종종 있습니다.

그래서 본 글은 너무나 당연한 최적화 말고는 정말 극한의 성능을 뽑아내려고 하시는 분이거나, C/C++ 의 기법에 관심이 있으신 분들이 참고하셔서 사용하시는게 좋을 것 같습니다.

포스트의 아래로 갈수록 저수준에서의 최적화를 다루고 있습니다.



제가 테스트한 환경은 다음과 같습니다.

Apple MacBook Pro(16-inch, 2019)
2.6 GHz 6코어 Intel Core i7
16GB 2667 MHz DDR4

Apple clang version 11.0.3 (clang-1103.0.32.59)
Target: x86_64-apple-darwin19.4.0
Thread model: posix




2. 일반적인 방법

ㄱ. 알고리즘 개선

사실 보통 최적화라 하면 알고리즘 쪽을 말하는게 대부분입니다. 시간 복잡도가 바뀌기 때문에 성능 향상이 확실하기 때문이죠. 대부분 최적화를 안해도 알고리즘은 최적화된 방법을 쓰는게 보통입니다. 간단한 예시만 들어보겠습니다.


버블정렬입니다. 코드 자체는 매우 간단합니다. 하지만 시간 복잡도는 O(n²) 입니다.

template<typename T>
void sort_bubble(std::vector<T> &data) {
for (int i = data.size() - 1; i > 0; --i)
for (int j = 0; j < i; ++j)
if (data[j] > data[j + 1]) {
int t = data[j];
data[j] = data[j + 1];
data[j + 1] = t;
}
}


퀵 정렬을 코드 예시입니다. 버블 정렬보다는 많이 복잡해졌고, 여기서는 재귀함수를 이용해서 구현이 되어 있네요. 하지만 시간 복잡도는 O(nlog(n)) 으로, 버블정렬보다 훨씬 뛰어난 성능을 보여줍니다.

template<typename T>
void sort_quick(std::vector<T> &data, int start, int end) {
if (start >= end)
return;

int pivot = start;
int i = pivot + 1;
int j = end;
T temp;
while (i <= j) {
T d_pivot = data[pivot];
while (i <= end && data[i] <= d_pivot) i++;
while (j > start && data[j] >= d_pivot) j--;

if (i > j) {
temp = data[j];
data[j] = data[pivot];
data[pivot] = temp;
} else {
temp = data[j];
data[j] = data[i];
data[i] = temp;
}
}

sort_quick(data, start, j - 1);
sort_quick(data, j + 1, end);
}

이러한 알고리즘의 최적화는 성능향상이 무조건 보장되고(n 이 1, 2 같이 작거나, 메모리가 정말 작은 임베디드 시스템이 같은 경우가 아닌 이상에야..) , 저 알고리즘이 사용되는 부분만 코드를 수정하면 되니 메인 코드도 더러워지지 않고, 시간도 그닥 걸리지 않습니다.
메모리와 trade-off 가 이루어지는 버켓 정렬 같은 경우는 시스템에 따라서 고려 사항이 되겠지만, 이러한 최적화는 굳이 안 해줄 이유가 없습니다. 아니, 무조건 하는게 맞겠죠.
이 외에도 다양한 알고리즘이 있겠습니다만, 가장 간단한 


ㄴ. 병렬 계산

다음은 병렬 계산입니다. 병렬 계산은 CPU의 여러 스레드를 이용하는 멀티스레드와 GPU를 이용하는 방법이 있습니다. 이는 하드웨어에 따라서 또다른 장치가 있을 수도 있겠죠. 이 또한 해야 하는 작업이 독립적이라면 단일 스레드에서 돌리는 것 보다 확실한 성능 향상이 대부분 보장됩니다. GPU를 이용하여 병렬계산을 할 때는 각각의 작업이 너무 복잡하다면 오히려 더 느려질 수 있습니다.


func 라는 함수를 calc_count 횟수만큼 수행하는 코드 입니다. 이 작업이 독립적이라면, 즉 각각의 작업이 서로의 선행조건에 포함되지 않는다면 이는 병렬처리가 가능합니다.

for (int i = 0; i < calc_count; ++i)
func(args...);


std::thread 를 이용한 병렬 처리 코드 입니다. 여기서는 단순하게 calc_count 만큼 스레드를 만들어 주었습니다. 

std::vector<std::thread> threads;
threads.reserve(calc_count);

for (int i = 0; i < calc_count; ++i)
threads.emplace_back(func, args...);

for (auto &th : threads)
if (th.joinable()) th.join();

threads.clear();

실제로 calc_count 가 10이라고 10배 빨라지는건 아니고, 작업 앞 뒤로 스레드를 생성하는 부분과 기다리는 부분이 있기 때문에 실제로는 10배 보단 느리게 수행됩니다. 
단, 스레드가 너무 많으면 오히려 더 느려질 수 있습니다. (시스템에 따라 스레드를 만드는게 오히려 더 느려질 수 있습니다)
또한 작업들이 독립적이지 않으면 멀티스레드를 이용하는 이점이 없고, 부분적으로 독립적이라면 이 스레드들을 동기화 시키는 작업에는 시간과 수정이 많이 필요합니다. 대신, 알고리즘 다음으로 확실한 성능 향상이 보장되는 작업입니다. (스레드를 사용하는게 이득인 시스템에서)


. 메모리와 trade-off

만약 자주 호출되는 계산식이 있다면 어떨까요? 
sine 함수 일 수도 있고, 특정 함수를 특정 구간까지 적분하는 함수가 될 수도 있습니다.
게임같은 그래픽 계산이 이루어질 때는 삼각함수인 cosine 함수가 매우 자주 불리게 됩니다.
cosine 함수 같은 경우는, 입력값이 0 에서 2π 사이이고, 입력값이 조금 틀리더라도 출력값의 오차가 크지 않습니다(입력값의 오차가 0.1 일때, 출력값의 최대 오차도 0.1입니다)
그러면 0에서 360˚를 100 등분해서, 1˚ 마다 값들을 미리 계산해서 배열에 저장해 놓고, 
30.17˚ 가 들어오면 가까운 언저리인 30˚ 로 계산된 값을 꺼내주면 일일이 cosine 함수를 컴퓨터가 계산하는 것 보다 더 빠르지 않을까요?
이렇게 미리 계산해놓은 값들을 저장한 컨테이너를 가르켜 Lookup Table 이라고 부릅니다. 
계산하지 않고 저장된 값을 Table 에서 검색(lookup) 해서 가져온다는 것이죠.



C++ 에서 cosine 함수의 값을 얻는 방법입니다. 입력은 실수형으로 넣었습니다.

double sum = 0;
for (int i = 0; i < calc_size; ++i)
sum += std::cos((double) i * 0.001);



cosine 함수 특성상 cos360˚ = cos0˚ 이므로, 0˚, 1˚, 2˚, ...  359˚ 까지의 미리 계산된 값을 길이가 360인 배열에 미리 저장해 놓습니다.
저장된 값이 라디안이 아닌 각도 이므로, 이를 라디안으로 변환하는 π/180 을 곱한 뒤, 위 처럼 0.001 을 곱한 값을 정수형으로 반올림 하고 배열을 초과하지 않게 360의 나머지를 취한 값을 인덱스로 얻습니다.
이 인덱스를 통해 배열에 저장된 값을 바로 꺼내옵니다.

const float LUT_COSINE_360[360] = {
1.000000000000000, 0.999847695156391, 0.999390827019096, ... };


double sum = 0;

int deg;
for(int i=0; i<calc_size; ++i){

sum += LUT_COSINE_360[(int)(std::round((i * 0.057295779513082320)))%360];

}

굳이 배열이 아니더라도, 해쉬테이블 처럼 탐색 없이 바로 접근할 수 있는 테이블이라면 어느 방법을 사용하여도 무관합니다.
다만, 이런 계산식들은 실수 전체를 커버할 수 없기 때문에, 일부 값들만 계산되어 있어 반올림 등을 하면서 오차가 생기기 마련입니다.
오차를 줄이려면 1˚ 가 아닌 0.1˚ 로 쪼개어 3600개의 배열을 만들 수 있겠지만, 대신 메모리도 10배로 많이 먹습니다. 
또, 테이블이 너무 크면 캐싱이 잘 이루어 지지 않아 오히러 더 느려질 수도 있습니다.

calc_size 를 100만으로 설정하고, 1000회 반복 실시한 결과입니다. 컴파일러 최적화 옵션은 -O3 (최대), 루프 언롤링을 설정해 주었습니다.
Calculation
1000/ 1000 (100%): 8.44s (Average: 8.44ms)
827098.282
Lookup Table (size 360)
1000/ 1000 (100%): 3.76s (Average: 3.76ms)
827113.289

==============================

Calculation : Slowest ( 8.44ms )
Lookup Table (size 360) : 2.24x faster ( 3.76ms )

==============================

대략 2배 이상 차이가 납니다. 이는 컴파일러와 환경(CPU 성능 등) 에 따라 다를 수 있습니다. 하지만 일반적으로 cosine 함수는 Lookup Table 을 사용하는게 더 빠릅니다. 

+ Tip

최적화에 관심이 많으신 분들은 눈치채셨겠지만, 
값을 0.5˚, 1.5˚, 2.5˚, ...  359.5˚ 를 계산하여 저장해놓으면 std::round 없이 정수로 바로 casting 해서 사용할 수 있고,
몫연산도 곱과 비트연산으로 치환할 수 있습니다. 그 경우에는 지금보다 더(2배 이상) 빠른 속도를 보여줍니다. 
몫연산을 곱과 비트연산으로 대체하는건 아래에서 다루겠습니다.

+ Tip 2
일반적인 switch 구문도 실제로는 case 들의 값을 인덱스로 하는 배열(혹은 규칙에 알맞은 2차 배열도)을 만들어 그곳에 접근 히는 룩업 테이블의 형식을 취합니다.
다만 case 가 100000, 200000, 300000 같이 큰 경우에는 저만한 크기의 배열을 만드느니 그냥 if 구문을 만들어서 돌아가게 됩니다.
10만 20만은 예시로 든 거고, 실제로 컴파일러가 어떤 값 이상일때 if 로 대체하는지는 어셈블리어를 까보거나 프로파일링을 해야 알 수 있습니다. if 문으로 대체 해도 그냥 순서대로 if else-if else 쓰는게 아니라 최적화 해서 이진 탐색으로 만들어주긴 합니다(일반적으로). 
출처: 





3. 하드웨어 동작 기반

ㄱ. CPU Cache

현대의 컴퓨터에는 RAM 외에도 CPU Cache(이하 캐시) 라는 기억 장치가 대부분 탑재되어 있습니다. 
캐시는 메모리에 저장된 값들을 잠시 맡아주는 역할을 합니다.
CPU 에서 값을 불러올 때 캐시에서 불러오는게 주 메모리에서 불러오는것 보다 훨씬 빠르기 때문에, CPU 는 어떠한 주소값에 접근할 때, 캐시에서 먼저 탐색하고, 없으면 메모리에서 탐색을 하게 됩니다.
그러면 자연히 캐시에 원하는 주소값이 들어있을 확률이 높을 수록 더 빠르게 접근할 수 있게 되겠죠.
그런데 현대에도 고작 수 MB밖에 되지않는 캐시에 수 GB 가 넘는 메모리의 주솟값이 들어있을 확률은 얼마나 될까요? 
단순 계산해 봐도 0.1% 도 안되는 수치입니다.

그래서 공학자들이 CPU가 메모리에 접근하는 주소들의 순서를 찬찬히 살펴 보니까, 한번 어떤 주소에 접근하면, 그 인근의 주소에 다시 접근할 확률이 높은 것으로 나타났습니다.
(사실 코드에서 상당 수의 값들이 배열이나 구조체에 저장되어 있기 때문에 당연합니다)

그래서 CPU 는 메모리에서 어떤 값을 불러올 때, 그 주변 값들도 곧 사용될 것이라 예측하고 근처 값들과 주소도 같이 긁어와 캐시에 저장헤 놓습니다. (현대의 프로세서는 보통 64bit, 즉 8byte 만큼 가져옵니다 출처)
만약 다음번에 인근 주소가 캐시에 존재하는지 찾게 되면, 캐시에 존재하므로 훨씬 빠르게 값을 찾을수 있게 됩니다. 
이처럼 탐색했더니 캐시에 주소값이 존재하는 것을 Cache hit 이라고 하고, 탐색했는데 존재하지 않는 것을 Cache miss 라고 합니다.
다만, 캐시의 크기는 매우 작기 때문에 멀리 떨어진 주솟값이 계속 쓰이다 보면 이전에 존재하던 값이 금방금방 덮어씌워져서 다시 그 값을 찾으면 cache miss 가 나게 됩니다.
그래서 비슷한 주솟값에 접근하는 작업이 반복적으로 이루어 질 때  cache hit 이 잘 일어나게 됩니다.

그럼 예시를 한번 보겠습니다


임의의 숫자가 들어있는 배열이 있다고 해 봅시다.

int array[1000][1000] = { ... };


여기 들어있는 숫자의 총합을 구하는 것을 생각해 봅시다.
그런데 인덱스 순서를 다음과 같이 짰다고 해 봅시다.

for(int i=0; i<1000; ++i){
for(int j=0; j<1000; ++j){
sum += array[j][i];

}
}

















'C++' 카테고리의 다른 글

초보도 따라하는 모던 C++ 튜토리얼  (0) 2020.04.06