C++ 최적화최고 성능을 구현하는 10가지 검증된 기법
커트 건서로스, 2019.07.05.
1. 최적화란
- 90/10 법칙 : 일반적으로 코드의 10% 만이 성능에 결정적인 영향을 줌.
1. C++ 코드 최적화 전략 요약
1.5.1 더 좋은 컴파일러를 사용
- 컴파일러의 최적화 옵션을 사용
- 최신 gcc, intel compiler 를 사용
1.5.2 더 좋은 알고리즘을 사용
- 최적 알고리즘 선택이 최적화 효과의 거의 대부분
- 그 외의 최적화는 많은 노력을 들여야 30~50% 개선
1.5.3 더 좋은 라이브러리를 사용
- C++ 표준 라이브러리보다 Boost 프로젝트, 구글 코드 등에 공개된 라이브러리 많으며 더 향상된 성능과 추가 기능 제공
1.5.4 메모리 할당 복사 줄이기
- 메모리 관리자(동적 변수 할당) 호출 횟수 줄이기, 복사 횟수 줄이기는 매우 효과적인 최적화 방법. 메모리 관리자 호출 비용은 명령이 수천 줄에 달함.
1.5.5 계산 제거하기
- 단일 문의 비용은 크게 중요하지 않으나, 수백만번 실행되는 코드의 최적화는 중요 함. 단 최근 C++ 컴파일러는 자동으로 수행해 주기도 함.
2. 컴퓨터 하드웨어와 최적화
3. 성능 측정
- 프로파일러, timer class 를 활용. timer 호출 비용은 수십 ns 수준임. ms 이하의 resolution 이 필요하지 않다면 사용해도 상관 없음.
- 최적화는 기본적으로 실험 평가임. 항상 성능 측정 실험을 해야 함.
- ...
4. 문자열 최적화
5. 알고리즘 최적화
6. 동적 할당 변수 최적화
- 메모리 관리자를 불필요하게 호출해 성능 저하 요인을 제거 (ex :
new, delete
)
6.2.1 스마트 포인터
unique_ptr
은 비용 패널티가 없어서 최적화에 좋음
shared_ptr
은 소유권 공유가 가능하고, atomic operation 이 가능해서 thread safe 하나 비용이 unique_ptr
보다 높음.
6.3 동적 변수 사용 줄이기
- 동적 변수는 런타임 비용이 매우 높음. 동적 변수의 메모리 할당을 위해서는 수천 번의 메모리 접근이 필요. 메모리 관리자 호출 횟수를 한번만 줄여도 함수 성능이 크게 향상 됨.
6.3.1 클래스 인스턴스를 정적으로 만들기
- 포인터, 스마트 포인터를 사용하기 보다 클래스 인스턴스 정적 생성이 비용 적음.
6.3.2 정적 자료구조를 사용
std::String
, std::vector
는 저장 공간이 가득 차면 새로운 저장 공간 재할당. std::map
, std::list
는 추가되는 항목마다 새로운 노드 할당. 이런 컨테이너는 사용이 편리하나 비용이 클 때도 있음.
std::vector
대신 std::array
가 최적화에 좋음.
- 연결 자료구조를 정적으로, 이진 트리를 배열로, 데큐 대신 원형 버퍼를 사용.
6.3.3 new 대신 std::make_shared 를 사용
shared_ptr
은 실제로 2개의 포인터를 포함 (참조된 객체 잠조, 동일 객체 참조하는 모든 shared_ptr
의 참조 수를 저장하는 동적 변수를 참조)
shared_ptr
사용이 필요하다면 new
대신 std::make_shared
를 활용
6.3.4 소유권을 불필요하게 공유하지 말기
shared:ptr
사용 시 소유권 공유 시 비용이 큼.
6.3.5 동적 변수를 소유하기 위한 소유 포인트
를 활용
- shared_ptr
은 비용이 높으므로 unique_ptr
사용이 최적화에 좋음.
6.4 동적 변수에 공간 재할당 줄이기
- 저장 공간이 가득 차서, 새로운 저장 공간 할당 및 복사는 비용이 큼.
reserve()
함수를 사용하여 미리 저장 공간 확보하면 좋음
6.4.2 반복문 바깥에 동적 변수 생성
- 동적 변수의 선언과 할당을 for 문 바깥에 만들기
- string, vector 등 동적 크기 조절이 되는 자료구조에 활용 시 좋음.
// Before
for (auto& filename : namelist) {
std::string config;
ReadFileXML(filename, config);
ProcessXML(config);
}
// After
std::string config;
for (auto& filename : namelist) {
config.clear();
ReadFileXML(filename, config);
ProcessXML(config);
}
6.5 불필요한 복사 제거하기
- primitive type 이 아닌 클래스의 경우 복사 시 초기화(생성자 호출), 대입 (대입 연산자 호출), 함수 (이동 생성자 or 복사 생성자 호출) 등 비용이 클 수 있음.
6.5.1 원치 않는 복사 일어나지 않도록 클래스 만들기
- 복사 생성자, 대입 연산자 선언에 delete 키워드 추가 시, 복사가 일어 날 경우 컴파일 오류가 나도록 할 수 있음
class BigClass {
public:
BigClass(BigClass const&) = delete;
BigClass& operator=(BigClass const&) = delete;
...
};
6.5.2 함수 호출에서 복사 제거하기
**- 함수로 인수 전달 시 메모리 복사, 복사 생성자 호출되어 비용 발생.
6.5.3 함수 반환에서 복사 제거하기
6.5.4 복사 없는(copy free) 라이브러리
COW (Copy On Write)구현하기
- COW :
- 복사 비용이 큰 동적 변수를 포함하는 클래스 인스턴스를 효율적으로 복사 할 때 사용하는 프로그래밍 용어
- 객체를 복사 시 기존 객체에 수정이 생기기 전까지는 shallow copy, 수정이 생길 때 Deep copy 실행
- Deep copy : 동적 변수를 소유하는 객체를 복사 시, 동적 변수의 복사본을 새로 만듦
- Shallow copy : 동적 변수를 소유하지 않은 포인터를 복사 시, 변수가 아닌 포인터를 복사
이동 문법 (move semantics) 구현하기
R-value 참조, 복사 생략, 이동 생성자, move semantics 먼저 알아보기!
- L-value (Left Value, Locator Value) : 식별자를 가지고 있는 객체. 특정 메모리의 위치를 가르키는 값. 참조 연산자(&)를 통해 참조가 가능.
- R-value (Right Value) : 식별자가 없는 객체, 상수 객체
- r-value(x-value) 참조 :
- ex :
int &&rrNum = 10;
- 상수 객체의 주소값을 이용.
- 이름 없는 임시 객체를 생성.
- 복사 생략 (Copy Elision)
A c(A(2));
와 같이 일반 생성자 호출, 복사 생성자 호출이 일어나야 할 것 같은 코드에서 컴파일러가 불필요한 복사를 생략하고, 일반 생성자만 한번 호출하는 기능. 임시로 만들어진 A(2)
자체를 바로 c 로 만들어서 복사를 수행하지 않음!
- 단 일반 Operator (
+,-,*,/
) 활용 시에는 따로 이동 생성자를 만들어 줘야 복사 생략이 가능!
- 이동 생성자
- R-value 를 인자로 받는 생성자를 활용하여, 복사 대신에 메모리 주소 값을 받도록 하여 복사 생략을 구현!
- ex:
class MyString {
...
public:
...
MyString(MyString&& str); // 이동생성자
...
}
MyString::MyString(MyString&& str) {
std::cout << "이동 생성자 호출\n";
string_length = str.string_content;
string_content = str.string_content;
memory_capacity = str.memory_capacity;
// 임시 객체 메모리 소멸 방지
str.string_content = nullptr;
}
- Move semantics (move 연산)
std::move(x)
- 인자로 들어오는 값을 R-value 로 만들어 주어서, 이동 생성자
- 임시 객체의
출처 :
7. 문장 최적화
- 문장 수준의 최적화는 불필요한 명령어를 제거하는 과정. 다음과 같은 경우가 아니라면 들인 노력보다 성능 개선 결과가 미약
- 프로그램 가장 안쪽에 있는 반복문
- 자주 호출되는 라이브러리 함수
- 프로그램 전체에서 사용되는 관용구
- 인터프리터 프로그램
- 컴파일러에 따라 최적화 했을 때의 효울성이 달라질 수 있음. 하나의 컴파일러에서는 성능 개선이 되도, 다른 컴파일러에서는 오히려 성능 감소 할 수도 있음.
- 데스크톱 프로세서의 경우 명령어 캐싱, 동시성이 있어서 문장 수준의 최적화보다 할당,복사 최적화의 이득이 더 큼.
7.1 반복문에서 코드 제거하기
7.2 함수에서 코드 제거하기
7.2.1 함수 호출 비용
- 함수 호출 시 다음 작업을 수행
- 함수 인수와 지역 변수 저장을 위해 호출 스택에 새 프레임 삽입
- 각 인수 표현식을 계산한 뒤 스택 프레임에 복사
- 현재 실행 주소를 복사해서 스택 프레임에 반환 주소로 넣음
- 실행 주소 (프래그램 카운터?) 를 함수 본문 첫 번째 문장으로 갱신
- 함수 본문 실행
- 프로그램 카운터? 를 원래 실행 주소로 돌아감
- 스택 프레임 삭제
7.2.2 인라인 선언 사용
- 함수 인라이닝은 코드 최적화 방법 중 가장 강력한 방법 중 한가지
- 그러나 현대 컴파일러는 자동으로 함수를 인라인하는데 매우 뛰어나서,
inline
키워드를 사용 할 필요가 거의 없음
- TODO : 그래서 인라인을 어떻게 하지?
7.3 표현식 최적화
7.3.1 표현식을 단순하게 만들기
- 호너의 규칙 (다항식 계산 규칙을 단순하게)
- y=ax^3 + bx^2 + cx + d 계산 의 경우
- before :
y = a*x*x*x + b*x*x + c*x + d;
-> 곱셈 6번, 덧셈 3번
- after :
y = (((a*x + b)*x + d;
-> 곱셈 3번, 덧셈 3번
7.3.2 상수 계산
constexpr
을 사용하여 컴파일 시간에 값을 결정
7.3.3 비용이 적은 연산자 사용
- 프로세서는 시프트 연산, 덧셈 연산은 한 사이클에 하지만 곱셈의 경우 덧셈을 반복해서 수행.
- 곱셈 대신에 시프트 연산 수행 (ex:
x*9 -> x*8 + x*1 -> (x<<3)+x
)
7.3.4 부동 소수점 연산 대신 정수 연산 사용
- 일반적으로 부동 소수점 연산보다 정수 연산이 최소 10배 더 빠름.
7.4 제어 흐름 최적화
7.4.1 if-elseif-else 대신 switch 를 사용
if-then-else if
는 O(n) 번 비교. switch 문은 O(1)
8. 라이브러리 최적화
8.1.1 C++ 표준 라이브러리의 철학
- 철학적으로 C++ 표준 라이브러리에 포함된 함수와 클래스는 다른 방법으로 제공 할 수 없거나 여러 운영체제에서 매우 광범위하게 재사용 할 수 있기에 채택 되었음.
8.1.2 C++ 표준 라이브러리의 사용에 관한 사안
- 표준 라이브러리 구현에도 버그는 존재 함.
- 표준 라이브러리 구현이 C++ 표준을 준수하지 않을 수 있음. 라이브러리와 컴파일러는 다른 일정으로 각각 관리됨.
...
## 9. 검색 및 정렬 최적화
- ...
10. 자료구조 최적화
10.1 표준 라이브러리 컨테이너 알아보기
- 표준 라이브러리 컨테이너가 가지는 공통된 속성
- 삽입 및 삭제 비용의 big-O 성능 보장이 필요
- 시퀀스 컨테이너에 항목을 추가하는 분할 상환 비용이 상수 시간이여야 함
- 컨테이너로 할당된 동적 메모리를 미세하게 제어하는 능력이 있어야 함
- ...
10.2 std::vector 와 std::string
std::vector
와 std::string
의 특성은 다음과 같음
- 시퀀스 컨테이너
- 삽입 시간 : 뒤에 삽입 시 O(1), 아니면 O(n)
- 색인 시간 : 위치로 색인 시 O(1)
- 정렬 시간 : O(nlog2n)
- 검색 시간 : 정렬되어 있으면 O(log2n), 아니면 O(n)
- 내부 배열 재할당하면 반복자와 참조가 무효
- 반복자는 양방향(앞에서 뒤, 뒤에서 앞) 으로 항목을 방문
- 크기와 관계 없이 할당된 용량을 합리적으로 제어
10.2.1 재할당의 성능 결과
- vector 를 resize 하는 비용은 매우 큼.
void reserve(size_t n)
을 호출해 용량을 예약하면, 불필요한 재할당과 복사 작업을 하지 않아도 됨.
10.2.2 std::vector 에서 삽입/삭제하기
- 벡터에 데이터를 삽입하는 가장 빠른 방법은 벡터를 대입하는 것. 이유는 복사하는 벡터의 크기를 알고 있으므로, 대입 할 벡터의 내부 버퍼를 만들기 위해 메모리 관리자를 한 번만 호출하면 되기 때문. 100,000 개 벡터 복사 시 0.445 ms 소모
std::vector::insert()
사용 시 0.696 ms 소모
std::vector::push_back()
와 for 구문 사용 시 반복자는 2.26ms, at
은 2.05ms, []
은 1.99 ms 소모. 하나씩 삽입하기 때문에 벡터 내부 버퍼를 여러 반 재할당 하기 때문에 느림. reserve
를 사용 한 다음 for 구문 사용하면 0.674ms 소모
10.3 std::deque
10.4 std::list
10.5 std:forward_list
10.6 std::map 과 std::multimap
10.7 std::set 과 std::multiset
10.8 std::unordered_map 과 std::unordered_multimap
11. 입출력 최적화
12. 동시성 최적화
13. 메모리 관리 최적화
KISTI 성능최적화 교육 정리
Compiler optimization
-O 2
적용 시 Data locality 를 제외한 Issue (아래 Hand tuning 의 모든 경우) 은 대부분 컴파일러가 스스로 적용 함.
-O 3
는 개선 효과가 없을 경우 많으며, Multi threading computing 을 적용 할 경우 비정상 결과 나올때가 있음
Hand Tuning
- 전역변수는 지역변수 대비 Data locality 가 떨어짐
- Pointer 참조는 메모리 참조가 2번 발생
클러터 (Clutter)
- 결과에 기여 없이 실행시간 소비 부분.
- 클러터 종류
- 함수 호출
- 간접 메모리 참조
- 루프 안의 IF 문
- 형 변환
Loop Optimization
- Loop 문 안에 IF 문 제거
- Code Motion
Unrolling (Unwinding)
- 슈퍼 스칼라 프로세서에서 파이프라이닝 및 병렬처리 가능
for i in N
a[i] = a[i] + b[i]
for i in 1,N,4
a[i] = a[i] + b[i]
a[i+1] = a[i+1] + b[i+1]
a[i+2] = a[i+2] + b[i+2]
a[i+3] = a[i+3] + b[i+3]
Loop interchange
- Array 를 활용하는 Nested loop 에서 Data locality 가 개선되도록 loop 의 순서를 바꿈
- *C언어의 경우 Array 의 마지막 Index 순서대로 메모리에 data 가 저장 됨.
TO-DO
TO-DO
Loop blocking
- Loop 에서 Cache size (page table size??) 만큼 increment size 를 키우면 cache 활용률이 최대화 됨
TO-DO
TO-DO
Vectorization
- Compile option 으로
-qopt-report=5
부여 시 Vectorization report 파일 생성 됨.
- Vectorization 은 Data locality 가 좋아야 사용 가능.
-O 2
활용 시 Vectorization 도 알아서 적용 됨
- Intel compiler option
- 멀티코어 프로세서 (Xeon) :
-xCORE-AVX512
- 매니코어 프로세서 (KNL) :
-xMIC-AVX512
- 멀티코어, 매니코어 :
-xCOMMON-AVX512
- PIC (Position Independent Code) 생성 (??):
-fPIC
, -fpic
- OpenMP :
-qopenmp
- GNU 컴파일러 옵션
- 멀티코어 프로세서 (Xeon) :
-march=skylake-avx512
- 매니코어 프로세서 (KNL) :
-march=knl
- 멀티코어, 매니코어 :
-xCOMMON-AVX512
- ffast-math 매크로 (?) :
-Ofast
- OpenMP :
-fopenmp
- PIC (Position Independent Code) 생성 (??):
-fPIC
, -fpic
- 벡터화가 가능한 루프
- 벡터화가 가능해야지만 컴파일러가 알아서 Vectorization 을 진행 함. (정확히는 할 수도 있고 안 할수도 있음) 벡터화가 가능하도록 code 를 짜는 것은 사람의 몫!
- 함수,
if
, switch
문이 없는 경우
- Memory 가 연속적으로 배치 된 경우 (cache miss X)
- Loop 에 Data dependency 가 있는 경우
- Data dependency
- Flow Dependence
- RAW (Read After Write) 가 발생하는 경우
- 반드시 구문을 순서대로 진행해야 함. 의존성을 없앨 수 없음.
- ex
A = 3
B = A
C = B
- TO-DO...
AoS (Array of Structure)
대신에 SoA (Structure of Array)
사용하기. AoS
는 Vectorization 불가.
- 나눗셈 사용 지양하기 (Intel Skylake CPU 기준 10cycle, 덧셈은 1cycle)
- 시프트 연산 활용하기
- 2 의 지수로 곱셈, 나눗셈 할 때
- 일반 곱셈, 나눗셈을 2 의 지수로 쪼개기 (x9 = x8 + x*1)
- ex:
- 대수학 활용하기
// Before
for (i = 0; i < n; i++)
sum += i;
// After
sum = n(n+1) * 0.5
- 함수 디자인
- 함수 호출 시 argument 가 integer compatible (char, short, int) 거나 4 words 이하의 자료형이라면 register 에 저장 됨. 단 argument 가 4개 초과되면 그 이상부터는 stack 에 저장 됨. (메모리 접근 발생)
- 인자가 커진다면(class,struct) 포인터를 넘기기
- if 문
- 사용 지양하기 (분기 예측 실패하여 파이프라이닝 실패 시, 20 Cycle)
- if-else if 는 조건문이 여러개이므로, 조건문이 한번인 switch 를 사용
- for 문
- 숫자를 감소 시키는 방향으로 for 문 사용하기 : 0 인지 아닌지 비교하는데 시간 소비가 더 적음.
// Before
for (i = 0; i < 10; i++) {...}
// After
for (i = 10; i; i--) {...}
// Or
for (i = 10; i! = 0; i--) {...}
- register 변수 사용
- 변수 선언 시
register
키워드 사용하면 메모리 대신 CPU 레지스터를 활용.
- ex:
register int i, j;
for (i = 0; i < N; ++i){
for (j = 0; j < N; ++j){
...
}
}
- 자료형 선택
int
보다 unsigned int
연산이 더 빠름
- 부동 소수점 (
float, double
) 연산은 매우 느림. 소수점이 필요하다면 모든 값에 x100 을 해서 int
사용이 나음.
- 룩업 테이블 (Look Up Table, LUT) 활용하기
- 특정 데이터에서 다른 데이터로 변환 할 때 사용하는 테이블
- ex : sin1~90 까지의 계산을 미리 테이블로 만들어 두기.
- Cache hit ratio 올리기
// Before
ullong array[1000][1000] = {...};
ullong sum = 0;
for (int r=0; r < 1000; ++r){
for (int c=0; c < 1000; ++c){
sum += array[c][r];
}
}
return sum;
// After
ullong array[1000][1000] = {...};
ullong sum = 0;
for (int r=0; r < 1000; ++r){
for (int c=0; c < 1000; ++c){
sum += array[r][c];
}
}
return sum;
- Vectorization
- TODO: 더 알아 보기.. SIMD 랑은 무슨 차이??
- ex:
// Before
int A[64] = {201, 112, ...};
int B64] = {489, 972, ...};
llong sum = 0;
for (int i = 0; i < 64; ++i)
sum += A[i] + B[i]
// After
int A[64] = {201, 112, ...};
int B64] = {489, 972, ...};
int SUM[64] = {0};
llong sum = 0;
/** Compile with vectorize flag**/
for (int i = 0; i < 64; ++i)
SUM[i] = A[i] + B[i]
for (int i=0; i < 64; ++i)
sum += SUM[i];
10가지 방법
https://hackernoon.com/c-performance-optimization-best-practices
출처:
컴퓨터의 시간
- 1 cycle = fetch + decode + execute
- CPU Clock 이 3GHz 라면 3cycle 은 대략 1nanoceconds
Latencies for Typical Modern Processor
Central Processing Unit (CPU):
- Integer add: 1 cycle
- 32-bit integer multiply: 10 cycles
- 64-bit integer multiply: 20 cycles
- 32-bit integer divide: 69 cycles
- 64-bit integer divide: 133 cycles
On-chip Floating Point Unit (FPU):
- Floating point add: 4 cycles
- Floating point multiply: 7 cycles
- Double precision multiply: 8 cycles
- Floating point divide: 23 cycles
- Double precision divide: 36 cycles
Memory Hierarchy and latency
Level | Size | Latency | Physical | Location |
---|
L1 | cache | 32 KB | 4 cycles | inside each core |
L2 | cache | 256 KB | 12 cycles | beside each core |
L3 cache | 6 MB | ~21 cycles | shared between all cores | |
L4 E-cache 128 MB ~58 cycles separate eDRAM chip
RAM 4+ GB ~117 cycles SDRAM DIMMs on motherboard
Swap 100+ GB 10,000+ cycles hard disk or SSD
Parallel computing API
OpenMP
- fork-join latency
num_thredas 1/2/4/6 : 0.43/0.83/0.97/1.24 us
실제 경험
- Inner most loop 에서
- 산술 연산 감소는 생각보다 효과가 크다! (중복 연산 변수화, 안 해도 되는 연산 줄이기) ~7%
- 반복적인 if 문 대신 multiplication 으로 logic 구현 하면 더 빠르다! ~5%
- 짱구를 굴려서 Inner most loop 의 case 를 나누면 좋다! ~7%
- 명시적 인라인화는 효과가 거의 없다!
- 다중 If 문, 함수 호출 후 안의 If 문 등은 제거 할 수록 좋다!! 효과가 가장 크다!
출처 :
https://www.quora.com/How-long-does-a-computer-take-to-add-two-numbers
https://www.lighterra.com/papers/modernmicroprocessors/
https://stackoverflow.com/questions/71077917/measuring-openmp-fork-join-latency
https://hackernoon.com/c-performance-optimization-best-practices