C++공부/그 외의 C++
Optimized C++ : 4 ~ 8장
아헿헿헿
2022. 7. 7. 03:09
Chapter 4. 문자열 최적화
std::string은 C++ 표준 라이브러리에서 많이 사용되는 기능 중 하나로 문자열 조작하는 코드는 자주 실행되며 최적화한다면 좋은 결과를 낼 수 있습니다
4.1 문자열이 왜 문제인가요
- 문자열에는 구현과 관계없이 메모리를 동적으로 할당하지만 표현식에서는 값처럼 작동하여 복사하기에 비용이 높은 함수가 많습니다.
- 먼저, 문자열은 메모리를 동적으로 할당합니다.
- 하지만 문자열은 대입문과 표현식에서 값처럼 작동하여, 임시 문자열의 값도 할당되어야 하기때문에, 메모리 관리자를 많이 호출합니다.
- 값처럼 작동하지만 복사 비용이 큰 것을 COW(copy-on-write)라고 하는데, 값을 공유하다가 변경할 때만 복사하여 사용하는 것인데 표준 라이브러리에서는 제공하지 않습니다.
- C++11 표준에서 우측값 참조와 이동 문법이 생기며 복사하기 보다는 이동시켜 비용이 낮게 작업이 수행 가능합니다.
4.2 문자열 최적화 첫번째 시도
- 먼저 문자열에 무언가를 더 더할 때 operator=를 사용하기 보다는 operator+=를 사용한다면 임시 객체가 사라지기에 효과적이게 됩니다.
- 만약 문자열의 길이를 사전에 어느 정도 예측이 가능하다면 reserve()로 할당하고 연산을 진행하는 것이 좋습니다.
- 변경하지 않는다면 인자로 받는 std::string은 const std::string&와 같이 참조자 형태로 받는 것이 좋습니다. 하지만 참조자라는 것은 컴파일러에서 포인터로서 해석이 되고, 이를 역참조한 포인터에 접근하는 것이기 때문에 성능이 저하될 수도 있습니다.
- 따라서 위와 같은 경우에는 포인터를 역참조하게 하는 것이 아닌, iterator를 소환하여 해결할 수 있습니다.
- 만약 결과값도 std::string의 형태라면, 지역 변수를 return 하기 보다는 밖에서 참조 형태의 인자를 받아 이에 할당하는 것도 괜찮지만, 잘 못 사용하게 되는 경우도 존재합니다.
- 성능을 최대한으로 하고 싶다면 C 스타일의 문자열 함수를 사용할 수 있습니다. 이는 여러 함수 호출과 캐시 지역성을 향상하기에 성능이 높아집니다.
4.3 문자열 최적화 두번째 시도
- 자체적으로 더 나은 알고리즘을 사용하는 것이 가장 좋은 방법으로 작동할 때도 있습니다.
- 더 좋은 컴파일러를 사용하면 더 좋은 결과를 낼 수도 있습니다.
- 더 좋은 문자열 라이브러리를 사용하면 더 많은 기능을 가져 선택의 폭도 넓어지고 성능이 좋아질 수 있습니다.
- std::stringstream은 데이터를 추가할 수 있는 엔티티처럼 크기를 동적으로 바꿀 수 있는 버퍼를 다른 방법으로 캡슐화하여 조금 더 효율적으로 코딩이 가능합니다.
- std::string_view, folly_fbstring과 같은 다른 다양한 라이브러리로 std::string을 대체하는 것도 하나의 방법입니다.
- 좀 더 좋은 할당자를 이용한다면, 효율이 올라갑니다.
4.4 문자열 변환 연산 제거하기
- c 문자열에서 std::string으로의 불필요한 변환 연산을 막는 것이 좋습니다.
- 리터럴 c 문자열과 UTF-8 문자열을 비교 하는 경우와 같이, 문자 인코딩 사이의 변환을 하나의 형식을 택해 모든 문자열을 선택한 형식으로 바꾸어 저장하는 것으로 막을 수 있습니다.
Chapter 5. 알고리즘 최적화
5.1 알고리즘의 시간 비용
- 시간 비용은 함수의 입력값에 따라 알고리즘의 비용이 얼마나 증가하는 지를 추상적으로 나타낸 수학 함수입니다.
- 성능을 고려하는 데에 있어, 순서에 따라 성능이 바뀐다면, 최악을 고려해야합니다.
- 상환 시간 비용은 입력값이 클 때 전체 시간 비용을 입력값으로 나눈 평균 시간 비용을 의미합니다.
5.2 검색과 정렬을 최적화하는 툴킷
- 평균적으로 시간이 적은 알고리즘을 선택하고, 데이터의 특징에 따라 더 빠른 특정한 알고리즘을 선택합니다.
5.3 효율적인 검색 알고리즘
- 선형 검색 - O(n)으로 가장 일반적이며 정렬되지 않은 테이블에서 사용가능합니다.
- 이진 검색 - O(logn)으로 성능은 뛰어나지만 가장 빠르진 않습니다. 입력 데이터가 키를 기준으로 정렬되어 있어야만 합니다.
- 보간 검색 - O(loglogn)으로 키의 부가적 정보를 사용하여 키가 균일하게 분포되어 있으면 성능이 매우 뛰어납니다.
- 해싱 검색 - O(1)으로 키를 해시 테이블의 배열 색인으로 변환하여 빠르게 찾습니다. 최악의 경우는 O(n)으로 검색할 레코드 보다 해시 테이블 항목이 많은 경우도 존재합니다.
5.4 효율적인 정렬 알고리즘
- 퀵 정렬이 항상 빠르지는 않고 최악에는 O(n^2)의 성능을 보이기에 입력 데이터에 대해서 알고나서 정렬을 선택하는 것이 좋습니다.
5.5 최적화 패턴
- 사전 계산 - 자주 사용하는 코드를 미리 계산하여 실행 횟수가 많은 부분의 계산량을 줄이는 최적화 기법입니다. 이는 컴파일 타임에 특정 인수를 갖는 템플릿 함수 호출도 계산이 가능합니다.
- 지연 계산 - 계산 코드를 실제로 필요한 부분과 최대한 가까운 곳으로 미뤄서 계산하는 방법으로, 모든 실행 경로에서 계산할 필요가 없다면 필요한 곳에서만 계산하도록 만듭니다. 예로 COW가 존재합니다.
- 배칭 - 여러 작업을 함께 처리하는 것으로, 반복된 함수 호출 코드와 한 번에 하나씩 처리할 떄 발생하는 계산 코드를 제거할 수 있습니다.
- 캐싱 - 필요할 때마다 결과를 다시 계산하지 않고 저장한 뒤 재사용해 계산량을 줄이는 최적화 기법입니다.
- 특수화 - 어떤 특정 상황에서 필요하지 않은 고비용 계산을 제거하는 방법입니다. std::swap을 특수화하는 것과 같습니다.
- 더 큰 조각 선택하기 - 반복 과정에서 생기는 오버헤드를 줄이고 반복 횟수를 줄이는 방법입니다. 예를 들어 커널로 보내는 함수의 호출 횟수를 줄이는 것입니다.
- 힌팅 - 힌트를 사용해 연산 비용과 계산량을 줄이는 최적화 기법입니다.
- 예상 경로 최적화 - if문을 무작위 순서로 배치했다면, 더 확률이 높은 구문을 앞으로 내보내는 방법입니다.
- 해싱 - 입력값이 큰 자료구조나 길이가 긴 문자열을 해시로 정수로 변환하여 사용하는 방법입니다.
- 이중 검사 - 비용이 크지 않은 검사로 몇몇 경우를 배제하고 필요하다면 비용이 큰 후속 검사로 다른 모든 경우를 배제하는 최적화 기법입니다.
Chapter 6. 동적 할당 변수 최적화
메모리 관리자의 호출 횟수를 줄여 최적화하는 방법입니다.
6.1 C++ 변수
- 모든 C++ 변수는 메모리에 고정된 레이아웃을 가지며, 그 크기는 컴파일 타임에 결정됩니다.
- 프로그램이 변수의 크기를 바이트 단위로 얻고 변수에 대한 포인터를 선언하는 것은 허용하지만 비트 단위는 불가합니다.
- 개발자는 구조체에서 변수의 순서와 레이아웃을 추론할 수 있는데 이는 C++의 공용체 타입을 사용 가능토록 합니다.
- 변수는 저장 기간을 가지며, 정적 변수는 해당 함수에 진입할 때 생성되며 고정 메모리 주소에서 고정 크기를 차지합니다. 정전 변수를 위한 저장 공간 생성에는 런타임 비용이 들지 않습니다.
- C++11부터 스레드 지역 저장(TLS) 기간을 갖는 변수를 선언할 수 있습니다. 스레드 지역 변수는 스레드에 진입할 때 생성되어 스레드가 끝날 때 파괴됩니다. 스레드 지역 변수는 환경에 따라 다르지만 정적 변수보다 접근 비용이 비쌉니다.
- 자동 저장 기간을 갖는 변수는 함수 호출 스택에서 컴파일러가 예약해둔 메모리에 상주하며 컴파일 타임에 크기가 결정되어 각 함수 호출 스택 포인터에서 고정된 오프셋 위치를 가지게 됩니다.
- 동적 저장 기간을 갖는 변수는 실행 중인 프로그램에서 요청한 메모리에 상주하며, 메모리 풀을 관리하는 자료구조로 구성된 메모리 관리자를 호출하여 프로그램이 저장 공간을 명시적으로 요청하여 변수를 동적 생성하며, 명시적으로 파괴할 수 있습니다.
- 값을 통해 의미를 갖는 변수는 값 객체, 프로그램에서의 역할에 따라 의미를 얻는 변수는 엔티티라고 합니다.
- 엔티티는 유일하며, 변경될 수는 있지만 복사될 수는 없고 비교할 수도 없습니다. ex) mutex, symbol table
- 값은 교환, 비교 및 복사는 가능하지만, 변경할 수는 없습니다.
6.2 C++ 동적 변수 API
- 스마트 포인터는 동적 변수의 소유권을 자동화합니다.
- 동적 변수의 소유권을 shared_ptr로 공유하면 비용이 더 커집니다.
- 동적 변수는 런타임 비용이 존재합니다.
6.3 동적 변수 사용 줄이기
- 클래스 인스턴스를 정적으로 만드는 것이 좋습니다.
- 클래스 멤버 변수를 정적으로 만드는 것이 좋습니다.
- std::vector이나 std::string 대신 std::array와 같은 정적 자료구조를 사용하는 것도 한 방법입니다.
- 또는 스택에 큰 버퍼를 만들거나, 연결 자료구조를 정적으로 만드는 방법도 존재합니다.
- new 대신 std::make_shared를 사용하면 할당을 한번에 할 수 있습니다.
- 소유권을 불필요하게 공유하지 않고 포인터를 내보내는 것이 좋습니다.
- 동적 변수를 소유하기 위한 스마트 포인터를 사용하는 것이 좋습니다.
6.4 동적 변수의 재할당 줄이기
- reserve와 같이 동적 변수를 미리 할당해 재할당을 방지하는 것이 좋습니다.
- 반복문 바깥에서 동적 변수를 만드는 것이 좋습니다.
6.5 불필요한 복사 제거하기
- 클래스 정의에서 원치 않는 복사 방지시켜야 합니다.
- 함수 호출에서 복사를 제거할 수도 있습니다.
- 함수 반환에서 반환값 최적화(RVO) 혹은 출력용 매개변수를 사용하여 복사 제거할 수 있습니다.
- 참조를 사용하여 라이브러리를 왔다갔다 거리는 복사 없는 라이브러리를 활용하는 방법도 존재합니다.
- COW를 구현하여 평소에는 얕은 복사를 수행하다 필요할 때만 깊은 복사를 수행토록 합니다.
6.6 이동 문법 구현하기
- 우측값을 이용하여 이동 문법을 구현하도록 합니다.
- 복사 생성자와 메모리 관리 함수에 시간 낭비가 많다면 이는 이동 문법을 통해 효율이 좋아집니다.
- 우측 값으로 반환하려고하면 RVO와 충돌하여 오히려 효율이 떨어지게 됩니다.
6.7 평평한 자료구조
- 자료구조의 요소들이 인접한 저장 공간에 함께 저장되었다면 자료구조가 평평하다고 합니다.
- 평평한 자료구조는 포인터로 연결된 자료구조 보다 메모리 관리자 호출이 적습니다.
- 노드 기반 자료구조에 비해 평평한 자료구조는 메모리를 적게 차지합니다.
Chapter 7. 문장 최적화
- 문장 수준에서 이뤄지는 최적화는 실행할 때 불필요한 명령어를 제거하는 과정으로 비유됩니다.
- 문장에서 이뤄지는 최적화의 문제점은 컴파일러에 따라 최적화의 효율성이 달라질 수 있다는 점입니다.
7.1 반복문에서 코드 제거하기
- 반복문의 종료값을 캐싱하십시오.
- 값을 증가하는 대신 감소하게 하여, 종료 조건에서의 비용을 줄이는게 좋습니다.
- 반복문에서 불변의 값들은 밖으로 옮기는 것이 좋습니다.
- 반복문에서 불필요한 함수 호출을 제거하는 것이 좋습니다.
- 반복문에 숨겨진 함수 호출을 제거하는게 좋습니다. 이는 클래스 타입 변수에서 많이 발생합니다.
- 반복문에서 비용이 크고 변화가 느린 호출을 제거하는게 좋습니다.
- 반복문을 함수 안에 넣어 호출 오버헤드를 줄이는게 좋습니다.
- 특정 행동을 하는 횟수를 줄이는 것이 좋습니다.
7.2 함수에서 코드 제거하기
- 함수 호출은 다음과 같이 이뤄집니다.
- 함수의 인수와 지역 변수를 저장하기 위해 호출 스택에 새 프레임을 삽입
- 각 인수 표현식을 계산하여 스택 프레임에 복사
- 실행 주소를 복사하여 스택 프레임에 반환 주소로 대입
- 실행 코드는 실행 주소를 함수 본문의 첫번째 문장으로 갱신
- 함수 본문의 명령어 실행
- 스택 프레임에 저장되어 있는 반환 주소를 명령어 주소에 복사
- 호출 스택에서 스택 프레임을 삭제
- 가상 함수의 경우는 vtable을 통해 호출되기에 비순차적 메모리를 두번 불러와야해서, 캐시 미스와 실행 파이프라인 스톨의 가능성이 높아지며, 인라인화하기 어려워집니다.
- 간단한 함수는 인라인으로 선언하여 함수 호출 비용을 줄입니다
- 함수를 처음 사용하기 전에 정의를 하여 사용합니다.
- 사용하지 않는 다형성은 제거하는 것이 좋습니다.
- 사용하지 않는 인터페이스는 버려 인스턴스를 생성하지 않도록 합니다.
- 템플릿으로 컴파일 타임에 구현을 선택하는 것이 좋습니다.
- PIMPL 관용구를 사용하는 코드를 제거하는 것이 좋습니다. 이는 예전에는 유의미한 컴파일 타임 감소를 가져왔지만, 현재는 컴파일이 빠르기에 사용하지 않아도 괜찮습니다.
- DLL을 호출하는 코드를 제거하고, 라이브러리를 하나의 실행 파일로 만드는 방법으로 성능을 향상시킬 수 있습니다.
- 멤버 함수 대신 정적 멤버 함수를 사용하면, 비용이 큰 멤버 함수 포인터 대신 일반 함수 포인터를 참조할 수 있습니다.
- 가상 소멸자를 기본 클래스로 옮기는 것으로 vtable 포인터가 기본 클래스에 포함되게 하는 것이 좋습니다.
7.3 표현식 최적화
- 표현식을 단순하게 만드는 것이 좋습니다.
- 상수끼리 계산은 컴파일 타임에 이루어지기에 상수는 모아서 표현하는 것이 좋습니다.
- 비용이 적은 연산자를 사용하는 것이 좋습니다.(비트 연산)
- 부동 소수점 연산 대신 정수 연산이 빠르므로 사용하는 것이 좋습니다.
- double이 float보다 빠를 수 있습니다.
- 반복 계산을 닫힌 형태로 바꾸는 것이 좋습니다.
7.4 제어 흐름 최적화
- if-elseif-else 대신 switch를 사용하는 것이 O(1)로 접근할 수 있습니다.
- switch나 if 대신 가상 함수를 사용하세요
- 비용이 들지 않는 예외 처리를 사용하세요
Chapter 8. 라이브러리 최적화
8.1 표준 라이브러리 최적화
- C++은 범용성을 갖추고자 간결한 표준 라이브러리를 제공합니다. 이는 대부분은 매우 효율적인 코드를 생성하는 템플릿 클래스와 함수로 구성되어 있습니다.
- 이는 여러 운영체제에서 매우 광범위하게 재사용할 수 있습니다.
- 표준 라이브러리의 구현이 C++ 표준을 준수하지 않을 수 있습니다.
- 표준 라이브러리의 개발자에게 가장 중요한 것은 성능이 아니라 범용성입니다.
- 라이브러리의 구현이 최적화 시도를 좌절시킬 수도 있습니다.
- C++ 표준 라이브러리의 모든 부분이 똑같이 유용하지는 않습니다. ex) vector<bool>
- 표준 라이브러리는 운영체제의 가장 좋은 네이티브 함수만큼 효율적이지 않습니다.
8.2 기존 라이브러리 최적화
- 가능한 한 적게 수정하는 것이 좋습니다.
- 기능을 변경하기 보다는 추가하는 것이 좋습니다.
8.3 최적화된 라이브러리 설계
- 급하게 설계한 라이브러리나 라이브러리 안에 독립적으로 작성된 함수 집합은 호출 및 규칙, 작동 및 효율성이 일치하지 않을 수도 있고, 라이브러리의 범용성을 해칠 수도 있습니다.
- 자원을 절약하도록 짜는 것도 중요합니다.
- 라이브러리 바깥에서 메모리 할당을 결정하는 것이 좋습니다.
- 확신이 서지 않는다면 속도를 위한 라이브러리 코드를 작성하는 것이 좋습니다.
- 함수가 프레임워크보다 최적화하기 쉽습니다.
- 상속 계층 구조를 평평하게 만드는 것이 좋고, 이는 대부분 3계층 이하인 것이 좋습니다.
- 호출 체인을 평평하게 만들어, 함수 호출의 중첩 횟수가 3회 이하로 만드는 것이 좋습니다.
- 계층화된 설계를 평평하게 만드는 것이 좋습니다.
- 동적 검색을 피하는 것이 좋습니다.