2020년 7월 7일 화요일

C++ 최적화 책, 7장 문장 최적화

C++ 최적화 책을 보게 되었고 읽으며 기억해야 할 내용을 간략히 정리 함.
C++ 언어를 사용하시거나 더 자세한 내용을 보시려면 아래 책을 꼭 읽어보세요. 오랜만에 읽는 유익한 C++ 책이네요.

C++ 최적화 최고 성능을 구현하는 10가지 검증된 기법
커트 건서로스 저/옥찬호 역/박수현 감수 | 한빛미디어

C++ 최적화 책, 4장 문자열 최적화
http://charlie0301.blogspot.com/2020/05/c-4.html

C++ 최적화 책, 6장 동적 할당 변수 최적화


반복문에서 코드 제거하기


- 반복문에서 종료값을 캐싱하라

char s[] = "This string is toooooooo long ";

for(size_t i=0; i< strlens(s);++i)
    if(s[i] == ' ')
        s[i] = '*';

위의 경우 문자열을 탐색하며 개수를 세는 strlen() 때문에 O(n) - O(n^2)의 비용이다.
이런 경우 값을 저장해 두고 사용하면 된다.

for(size_t i=0, len=strlen(s); i< len;++i)
    if(s[i] == ' ')
        s[i] = '*';

- 효율적인 반복문을 사용하라. 
다른 책에서도 do while()을 사용하는 것을 추천하던데 이유가 제대로 설명되어 있지 않았는데 이 책에서는 아래와 같이 설명

for(초기화 표현식; 조건식; 증감문) 반복해서 실행할 코드
는 다음과 같이 컴파일 된다고 함
        초기화 표현식;
L1: if (!조건식) goto L2;
        반복해서 실행할 코드;
        증감문;
        goto L1;
L2:

for 문은  조건식 결과가 false일 경우 두번 점프해야 함.
그에 반해 do while문은 다음과 같다.

do 반복해서 실행할 코드 while (조건식);
은 다음과 같이 컴파일 된다.
L1: 반복해서 실행할 코드
       if(조건식) goto L1;


- 반복문에서 불변 코드를 제거하라.
 : 반복문 진행 중에 변경되지 않는 코드는 컴파일러가 자동으로 반복문 바깥으로 옮기지만 함수가 복잡하거나 함수의 본문이 다른 컴파일 단위에 있다면 컴파일러가 자동으로 처리할 수 없으니 직접 확인 해서 반복문 바깥으로 옮겨라.


- 반복문에서 불필요한 함수 호출을 제거하라.
 : 반복문내의 제공하는 값이 항상 동일하거나 반복문과 상관 없는 함수는 제거하거나 외부로 옮기는 것이 맞다.


 순수함수(pure function)

아래 wikipedia를 보면 간단하게 말하면 함수에서 local static 변수, non-local 변수, parameter를 변경하거나 I/O 기기의 값을 참조하지 않아 같은 입력에 대해서 같은 출력을 제공하는 함수를 말한다. 

In computer programming, a pure function is a function that has the following properties:[1][2]

  1. Its return value is the same for the same arguments (no variation with local static variablesnon-local variables, mutable reference arguments or input streams from I/O devices).
  2. Its evaluation has no side effects (no mutation of local static variables, non-local variables, mutable reference arguments or I/O streams).


아래 글을 참고하면 pure function의 경우 컴파일러가 최적화 대상으로 인식하여 최적화 하게 된다.





- 반복문에서 숨겨진 함수 호출을 제거하세요.
 : 일반적으로 함수의 이름을 쓰고 괄호로 인수를 적어 호출하는 함수 외 클래스 타입 변수를 취급할 경우 다음 함수들이 호출 될 수 있다.
  . 클래스 인스턴스의 선언 (생성자를 호출)
  . 클래스 인스턴스의 초기화 (생성자를 호출)
  . 클래스 인스턴스의 대입 (대입 연산자를 호출)
  . 클래스 인스턴스를 포함하는 산술 표현식 (연산자 멤버 함수를 호출)
  . 범위를 빠져나갈 때 (범위에서 선언된 클래스 인스턴스의 소멸자를 호출)
  . 함수 인수 (각 인수 표현식은 형식 인수로 복사 생성됨)
  . 클래스 인스턴스의 함수 반환 (아마도 복사 생성자를 두 번 호출)
  . 표준 라이브러리 컨테이너에 항목을 삽입 (항목이 이동 또는 복사 생성됨)
  . 벡터에 항목을 삽입 (벡터가 재할당될 경우 모든 항목이 이동 또는 복사 생성됨)
 : 클래스의 불필요한 생성자, 연산자를 제거하거나 반복문 내에서 클래스 인스턴스 생성, 삭제, 복사를 최소한으로 하는 것이 방법

- 반복문을 함수 안에 넣어 호출 오버헤드를 줄이세요.
 : 문자열, 배열, 다른 자료구조를 반복하며 함수를 호출한다면 반복문 뒤집기(loop inversion) 기법을 사용하여 함수 호출 횟수를 줄여 성능 향상이 가능하다.



loop inversion

이것이 저자가 말하는 기법인지 모르겠으나 설명에 의하면 
while loop을 do..while loop을 가지는 if문으로 변경하는 방법으로
적절히 사용되면 cpu instruction pipelining에 의해 성능을 향상할 수 있다고 함.

위 링크의 어셈블리를 예를 확인하면  while loop 보다 if do..while loop이 불필요한 실행이 감소된 것을 확인할 수 있다.

https://en.m.wikipedia.org/wiki/Loop_optimization 에서도 하나의 기법으로 소개하고 있음.


* 책의 저자는 여러 저수준 최적화 기법이 있지만 생각만큼 효과가 없을 수 있다고 말하며 오히려 이미 C++ 컴파일러가 훌륭히 처리하고 있다고 함.


함수에서 코드 제거하기

함수가 호출 되면
컴퓨터는 현재 실행 중인 코드의 위치를 저장하고,
함수 본문으로 실행 흐름을 바꾼 다음,
함수 호출이 끝나고 이전에 실행하던 명령어의 다음 위치로 복귀하는 방법으로
실행 흐름에 함수 본문을 효율적으로 집어 넣음.

1. 실행 코드는 함수의 인수와 지역 변수를 저장하기 위해 호출 스택에 새 프레임을 삽입
2. 각 인수 표현식을 계산한 뒤 스택 프레임에 복사
3. 현재 실행 주소를 복사해서 스택 프레임에 반환 주소로 넣음.
4. 실행 코드는 실행 주소를 (함수를 호출한 후의 다음 문장 대신) 함수 본문의 첫 번째 문장으로 갱신
5. 함수 본문에 있는 명령어들을 실행
6. 스택 프레임에 저장되어 있는 반환 주소를 명령어 주소에 복사. 그리고 함수를 호출한 후의 문장으로 제어권을 넘김
7. 호출 스택에서 스택 프레임을 삭제

함수 호출의 기본 비용
- 함수 인수
 : 인수 표현식을 계산하는 비용, 인수값을 메모리 스택에 복사하는 비용 필요
 : 인수 몇개는 레지스터를 통해 전달 가능하지만 많으면 스택으로 전달함.

- 멤버 함수 호출(vs 함수 호출)
 : 멤버 함수를 호출하는 모든 코드에는 자신을 가리키는 this 포인터가 숨겨져 있다.

가상함수의 비용
 - 가상 멤버 함수가 있는 클래스의 각 인스턴스는 vtable이라는 테이블을 가리키는 이름 없는 포인터를 포함
 : vtable은 가상 함수들의 시그니처와 연관된 본문을 가리킴
 : vtable 포인터는 역참조 비용을 줄이기 위해 클래스 인스턴스의 첫번째 필드로 만듬.
 : 가상 함수를 호출하는 코드는 vtable을 가리키는 포인터를 얻고자 클래스 인스턴스를 가리키는 포인터를 역참조함. 즉 호출 시 비 순차적인 메모리를 추가로 두번 불러와야 함.

파생 클래스에서의 멤버 함수 호출의 비용도 상당하다.

함수를 가리키는 포인터의 비용
- 함수 포인터 (함수와 정적 멤버 함수를 가리키는 포인터)
 : 코드는 함수의 실행 주소를 얻기 위해 포인터를 역참조함. 컴파일러는 인라인할 수 없음.
- 멤버 함수 포인터
 : 함수 시그니처와 클래스를 모두 식별해야 하며 최악의 경우에 해당하는 성능을 갖는다고 가정해도 무리는 아님

- 인수가 없는 C 스타일의 void 함수는 호출 비용이 적음.
 : 함수를 인라인할 수 있다면 비용이 들지 않고 메모리 접근과 실행 비용이 전부
- 가상 다중 상속 클래스를 포함하지만 가상 함수가 없는 기본 클래스에서 파생된 클래스의 가상 함수를 호출하는 것이 최악의 경우
 : 코드는 클래스 인스턴스 포인터에 더할 오프셋을 결정하기 위해 클래스 인스턴스 테이블을 역참조
 : 가상 다중 상속된 함수의 인스턴스 포인터를 형성하고 vtable을 얻기 위해 인스턴스를 역참조한 뒤 함수의 실행 주소를 얻기 위해 vtable을 인덱싱

- 간단한 함수는 인라인으로 선언
- 함수를 처음 사용하기 전에 정의
 : 정의가 호출 전에 있다면 컴파일러가 함수를 호출하는 코드를 최적화 가능
- 사용하지 않는 다형성을 제거
 : 런타임 다형성을 구현하기 위해 가상 멤버 함수를 자주 사용함.

- 사용하지 않는 인터페이스를 버려라
- 템플릿으로 컴파일 타임에 구현을 선택하라
- PIMPL 관용구를 사용하는 코드를 제거하라
- 멤버 함수 대신 정적 멤버 함수를 사용하라
- 가상 소멸자를 기본 클래스로 옮겨라



표현식 최적화

- 표현식을 단순하게 만들어라
y = a*x*x*x + b*x*x +c*x + d;
=> y = (((a*x + b)*x) + c)*x + d;
로 바꿔 단순화 할 수 있다.

- 상수를 함께 모아라.
seconds = 24 * 60 * 60 * days;
seconds = days * (24 * 60 * 60);
이런 문장을 컴파일러에서는
seconds = 86400 * days;
바꿀 수 있지만 
seconds = 24 * days * 60 * 60;
인 경우 컴파일러는 곱셈을 런타임에 계산해야 한다고 함.

- 비용이 적은 연산자를 사용하라.
- 부동 소수점 연산 대신 정수 연산을 사용하라.
- double이 float보다 빠를 수 있다.
- 반복 계산을 닫힌 형태라 바꿔라


제어 흐름 최적화

- if-elseif-else 대신 switch를 사용하라.
 : if-else if-else 문은 선형 제어 흐름이고 O(n)번 비교함.
 : switch문은 색인 작업을 한 번 수행하고 테이블에 있는 주소로 점프함.
  . 비교하는데 O(1)이지만 색인에 사용되는 상수의 간격이 크다면 컴파일러는 상수를 정렬하여 이진 검색을 수행하는 코드를 생성함. 그래도 O(log2n)이다.

- swtich나 if 대신 가상 함수를 사용하라.
- 비용이 들지 않는 예외 처리를 사용하라.
  : 예외 사양을 사용하지 마라.
  : 예외 사양에는 개발자가 호출한 함수 라이브러리의 함수에서 어떤 예외를 던질 수 있는지 알아내기가 어렵다.
  : 예외 사양은 성능에 부정적인 영향을 미친다.
  : C++11에서는 더이상 예외 사양이 사용되지 않고 noexcept라는 새로운 예외 사양이 도입됨.
   . 함수를 noexcept로 선언하면 함수가 예외를 던질 수 없다고 알려주는 것임.
   . 컴파일러가 이동 문법을 구현하기 위해 특정 이동 생성자와 이동 대입 연산자를 noexcept로 선언해야 한다고 요구함. 이는 예외 안정성 보장 보다 이동 문법이 중요하다는 선언과 같음.


 
noexcept 및 C++에서의 예외처리 기본 지침

C++11부터 정의된 keyword로 함수에서 예외를 throw 할 수 있는지를 지정할 때 사용됨.


noexcept(true)의 별칭인 throw()를 제외하고는 C++17에서 모두 제거됨.
각각은 아래와 같은 의미를 가짐 

예외 사양의미
noexcept
noexcept(true)
throw()
이 함수는 예외를 throw하지 않습니다. /Std: c + + 14 모드 (기본값)에서는 noexcept 및 noexcept(true)가 동일 합니다. noexcept 또는 noexcept(true)선언 된 함수에서 예외가 throw 되는 경우 std:: terminate 가 호출 됩니다. /Std: c + + 14 모드에서 throw()로 선언 된 함수에서 예외가 throw 되는 경우 결과는 정의 되지 않은 동작입니다. 특정 함수가 호출 되지 않습니다. 이는 컴파일러에서 std::를 호출 하는 데 필요한 c + + 14 표준과의 차이입니다.
Visual Studio 2017 버전 15.5 이상/std: c + + 17 모드에서 noexceptnoexcept(true)및 throw() 모두 동일 합니다. /Std: c + + 17 모드에서 throw()는 noexcept(true)에 대 한 별칭입니다. /Std: c + + 17 모드에서 이러한 사양 중 하나를 사용 하 여 선언 된 함수에서 예외가 throw 되는 경우 std:: Terminate 는 c + + 17 표준에 필요한 대로 호출 됩니다.
noexcept(false)
throw(...)
사양 없음
함수는 모든 형식의 예외를 throw 할 수 있습니다.
throw(type)(C + + 14 및 이전) 함수는 type형식의 예외를 throw 할 수 있습니다. 컴파일러는 구문을 허용 하지만 noexcept(false)로 해석 합니다. /Std: c + + 17 모드에서 컴파일러는 경고 될 때 c5043를 발생 시킵니다.



기본 지침

강력한 오류 처리는 모든 프로그래밍 언어에서 어렵습니다. 예외가 좋은 오류 처리를 지원하는 여러 기능을 제공하기는 하지만 사용자를 대신해 모든 작업을 수행할 수는 없습니다. 예외 메커니즘의 혜택을 누리려면 코드를 디자인할 때 예뢰를 염두에 둡니다.

  • 절대 발생하지 않아야 하는 오류를 확인하려면 어설션을 사용합니다. 예외를 사용하여 발생할 수 있는 오류, 예를 들어 공용 함수의 매개 변수에 대한 입력 유효성 검증 오류를 점검하세요. 자세한 내용은 예외 및 어설션이라는 섹션을 참조 하십시오.

  • 오류를 처리하는 코드가 하나 이상의 개입 함수 호출로 오류를 감지하는 코드와 분리될 수 있는 경우 예외를 사용합니다. 오류를 처리하는 코드가 오류를 감지하는 코드에 밀접하게 연결되어 있으면 성능이 중요한 루프에서 오류 코드를 대신 사용할지 고려합니다.

  • 예외를 throw 하거나 전파 하는 모든 함수에 대해, 강력한 보장, 기본 보장 또는 nothrow (noexcept) 보장의 세 가지 예외 보장 중 하나를 제공 합니다. 자세한 내용은 방법: 예외 안전성을 위한 디자인을 참조 하세요.

  • 값에 따라 예외를 발생(throw)시키고 참조로 포착(catch)합니다. 처리할 수 없는 것은 포착(catch)하지 않도록 주의합니다.

  • C++11에서 더이상 사용되지 않는 예외 사양을 사용하지 마세요. 자세한 내용은 예외 사양 및 noexcept섹션을 참조 하세요.

  • 가능하다면 표준 라이브러리 예외 형식을 사용합니다. 예외 클래스 계층 구조에서 사용자 지정 예외 형식을 파생 시킵니다.

  • 소멸자 또는 메모리 할당 해제 함수에서 예외가 빠져나오는 것을 허용하지 마세요.