2020년 6월 10일 수요일

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

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

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

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


C++ 변수

- C++ 변수는 고정된 레이아웃을 가지고 컴파일 타임에 크기가 결정됨
- 변수는 저장 기간(수명)을 가짐
- 정적 저장 기간
 . 컴파일러가 예약해둔 메모리에 상주
 . 메모리 주소는 컴파일 타임에 결정
 . 전역 정적 변수는 main()함수 진입 전에 생성, main()함수를 떠난뒤 파괴
 . 함수에서 선언한 정적 변수는 함수에 처음 진입하기 전에 생성
 
- 스레드 지역 저장 기간
 . C++ 11 이후 thread-local storage(TLS) 변수를 선언 가능
 . 스레드 진입 시 생성, 스레드가 끝나면 파괴
 . thread_local 키워드로 선언된 변수는 스레드 지역 저장 기간을 가짐

- 자동 저장 기간
 . 함수 호출 스텍에서 컴파일러가 예약해둔 메모리 상주
 . 컴파일 타임에 메모리 크기 결정
 . 함수 호출 스택 포인터에서 고정된 오프셋 위치를 가짐
 . 코드 블록 {} 안에 존재하므로 {} 진입 시 생성, 빠져나오면 파괴

- 동적 저장 기간
 . 실행 중인 프로그램에서 요청한 메모리에 상주
 . 프로그램에서 new로 명시적으로 저장 공간을 요청하고 delete로 파괴
 . 메모리 관리자가 메모리 관리
 . 자동 변수와 마찬가지로 주소는 런타임에 결정


- 변수의 소유권( 변수의 생성, 파괴를 결정하는 주체를 설명하기 위한 개념인듯)
 : 전역 소유권
  . 프로그램이 가지며 main()함수 진입 전 생성되고 main()함수를 떠난 뒤 파괴 됨.
 : 유효 범위가 지정된 소유권 
  . 중괄호로 둘러 싸인 코드블록의 유효 범위가 소유권을 가짐. 
  . main() 함수의 {} 도 해당 되며 main()함수에서 선언된 자동 변수는 정적 변수와 동일한 수명을 가짐
 : 멤버 소유권 
  . 클래스 인스턴스가 가짐. 
  . 클래스 인스턴스가 생성될 때 생성되며 인스턴스가 파괴될 때 파괴됨.
 : 동적변수의 소유권 
  . 정해져 있지 않고 프로그램에서 명시적으로 관리해야 함. 
  . new로 만들어진 포인터를 프로그램이 관리하고 더이상 사용하지 않는 경우 delete로 제거해야 함. (포인터 날로 쓰는게 가장 싫었음. 가능하면 동적 변수는 스마트 포인터로 관리하는게 맞는 방법임.)


- C++11의 nullptr이라는 특정한 값은 유효한 메모리를 가지키지 않는다. 
  : 0을 nullptr로 변환할 수 있지만 0 != nullptr 이다.



POCO(Plain Old C++ 개체)에 대한 포인터를 캡슐화하는 데 가장 먼저 스마트 포인터를 사용합니다.

  • unique_ptr
    기본 포인터로 한 명의 소유자만 허용합니다. shared_ptr이 필요하다는 점을 확실히 알 경우 POCO의 기본 선택으로 사용합니다. 새 소유자로 이동할 수 있지만 복사하거나 공유할 수 없습니다. 사용하지 않는 auto_ptr을 대체합니다.boost::scoped_ptr과 비교합니다. unique_ptr작고 효율적입니다. 크기는 하나의 포인터이며 C++ 표준 라이브러리 컬렉션에서 빠른 삽입 및 검색을 위한 rvalue 참조를 지원합니다. 헤더 파일: <memory>. 자세한 내용은 unique_ptr 인스턴스 및 unique_ptr 클래스를 만드는 방법( 사용 방법)을 참조하십시오.

  • shared_ptr
    참조 횟수가 계산되는 스마트 포인터입니다. 원시 포인터 하나를 여러 소유자에게 할당하려고 할 경우 사용합니다(예: 컨테이너에서 포인터 복사본을 반환할 때 원본을 유지하고 싶을 경우). 원시 포인터는 모든 shared_ptr 소유자가 범위를 벗어나거나 소유권을 포기할 때까지 삭제되지 않습니다. 크기는 2개의 포인터입니다. 하나는 개체용이고, 다른 하나는 참조 횟수가 포함된 공유 제어 블록용입니다.헤더 파일: <memory>. 자세한 내용은 인스턴스 및 shared_ptr 클래스를shared_ptr 만드는 방법: 인스턴스 만들기 및 사용 방법을 참조하십시오.

  • weak_ptr
    shared_ptr과 함께 사용할 수 있는 특별한 경우의 스마트 포인터입니다.weak_ptr은 하나 이상의 shared_ptr 인스턴스가 소유하는 개체에 대한 액세스를 제공하지만, 참조 수 계산에 참가하지 않습니다. 개체를 관찰하는 동시에 해당 개체를 활성 상태로 유지하지 않으려는 경우 사용합니다. shared_ptr 인스턴스 사이의 순환 참조를 차단하기 위해 필요한 경우도 있습니다. 헤더 파일: <memory>. 자세한 내용은 weak_ptr 인스턴스 및 weak_ptr 클래스를 만드는 방법: 인스턴스 만들기 및 사용 방법을 참조하십시오.



스마트 포인터의 삭제 조건
- break, continue를 만났을 때, 함수에서 값을 리턴할 때, 예외가 발생하는 등 선언문을 둘러싸고 이쓴 범위를 빠져 나갈 때 소유한 동적 변수 삭제
- 클래스 멤버로 선언된 스마트 포인터는 클래스 인스턴스가 파괴될 때 소유한 동적 변수를 삭제함.
- 스레드 지역 저장 기간으로 선언된 스마트 포인터 인스턴스는 스레드가 정상 종료될 때 소유한 동적 변수를 삭제
 : 운영체제가 스레드를 종료시킨 경우 일반적으로 삭제되지 않는 다고 함.
- 정적 저장 기간으로 선언된 스마트 포인터의 인스턴스는 프로그램이 종료될 때 소유한 동적 변수 삭제

- 동적 변수를 한 소유자가 관리하는 경우 
 : std::unique_ptr 사용 추천. 비용 패널티도 거의 없음.

스마트 포인터는 메모리 및 성능 관점에서 최대한 효율적으로 사용할 수 있도록 설계되었습니다. 예를 들어, unique_ptr의 유일한 데이터 멤버는 캡슐화된 포인터입니다. 즉, unique_ptr은 해당 포인터와 정확히 동일한 크기(4바이트 또는 8바이트)입니다. 오버로드된 스마트 포인터 * 및 -> 연산자(s)를 사용하여 캡슐화된 포인터에 액세스하는 것은 원시 포인터에 직접 액세스하는 것보다 훨씬 느리지 않습니다.


- 동적 변수를 여려명이 공유하는 경우, 두개 이상의 포인터가 동적변수를 관리하는 경우 접근 시 유효성을 확인하기 어렵고 삭제 같은 관리가 어려움. 
 : std::shared_ptr를 사용하는 것을 추천
 : 단 shared_ptr에 대입할 때 new 표현식에서 리턴되는 것을 바로 넣고 std::make_shared를 사용하는 것을 추천

- 동적 변수는 런타임 비용이 있음.
- 동적 변수의 메모리를 할당하기 위해서는 수천번의 메모리 접근이 필요할 때도 있음.
 : 동적 변수 생성을 위해 메모리 할당 함수는 빈 메모리 블럭을 찾음.
  . 빈 메모리 블록을 찾으면 블록을 제거한 뒤 제공
  . 필요한 것보다 큰 블록을 찾으면 분할한 뒤 일부를 반환
 : 이용 가능한 메모리 블록이 없다면 할당 함수는 큰 메모리 블럭을 추가로 얻기 위해 가용 메모리 시스템 풀에서 운영체제 커널을 고비용으로 호출
  . 커널에서 사용하는 메모리는 RAM에 캐시되어 있을 수도 없을 수도 있음. 캐시되어 있지 않다면 지연 될 수 있음
 : 빈 메모리 블록의 컬렉션은 프로그램의 모든 스레드가 공유하는 자원이며 메모리 블록 변경 사항은 스레드 세이프(thread-safe) 함
  . 할당, 해제 함수를 자주 호출하면 하나 외 다른 스레드들이 대기하게 됨
 : 동적 변수의 할당된 메모리를 해제할 때 반환된 블럭을 빈 메모리 컬렉션에 넣게 되지만 복잡함.
  . 할당, 해제로 블록들의 크기가 작아지는 것을 방지하기 위해 해제된 메모리 블록을 인접한 메모리 블록과 합치려고 시도함. 


동적 변수 사용 줄이기

- 클래스 인스턴스를 정적으로 만들어라
  ex) YourClass yourInstance("hahaha",111);

- 클래스 멤버 변수를 정적으로 만들어라
 : 클래스 멤버변수가 클래스 인스턴스라면 클래스 생성 시 멤버변수도 정적으로 생성되어 할당하는 비용을 줄일 수 있음.
 : 클래스 생성 시 멤버 변수를 생성하기 위한 자원이 없어 동적으로 생성해야 하는 경우 두 단계초기화(two-part initialization)을 사용하라.

- 배열의 크기가 정해져 있다면 std::vector 대신 std::array를 사용하라.
 : std::array는 복사 생성할 수 있고 operator[]로 임의 접근 반복자와 첨자 지정을 제공함.

- 저장공간이 커지면서 발생하는 재할당 비용을 줄이기 위해 스택에 큰 버퍼를 만들어라

- 정적으로 초기화된 데이터가 연결 자료 구조라면 정적으로 구성하라.

- 이진트리를 배열로 만들어라

- std::deque 대신 원형 버퍼(http://bit.ly/b-buffer)를 사용하라.

- 변수 할당 비용을 줄이기 위해 new 대신 std::make_shared를 사용하라.
  ex) std::shared_ptr p = std::make_shared("hahah",111);
        auto p = std::make_shared("hahah",111);

- 동적 변수를 사용한다면 std::unique_ptr, std::shared_ptr을 사용하라.


동적 변수의 재할당 줄이기

- 동적 변수를 미리 할당하여 재할당을 방지하라.
 : 메모리할당 비용을 줄이기 위해 std::string, std::vector의 reserve(size_t n) 같은 함수로 적당히 공간을 확보하라.
- 반복문 바깥에서 동적 변수를 만들어라
 : 반복문내의 변수는 할당/해제를 반복하므로 외부에 두고 clear()같은 함수를 사용하여 초기화 후 사용하라.


불필요한 복사 제거하기

- 클래스의 대입 시(ClassA = ClassB)의 대입 연산자(Assignment operator)가 호출 됨. 이 때 클래스 멤버에 따라서 대입하는 비용이 상당히 커질 수 있음.
- 선언과 동시에 초기화 하는 문장(Foo a = b)의 경우 Foo가 클래스라면 복사생성자(Copy constructor)가 호출 됨. 
 : 대입 연산자와 복사 생성자는 하는일이 거의 동일하고 비용이 클 수 있어 최적화 시 다음상황에서 발생가능한지 확인해야 함.

- 초기화 (생성자 호출)
- 대입 (대입 연산자 호출)
- 함수 인수 (함수의 인자/형식 인수로 전달되면서 이동 생성이나 복사 생성됨)
- 함수 반환 (이동 생성자나 복사 생성자를 호출)
- 표준 라이브러리 컨테이너에 항목을 삽입 (항목은 이동 생성이나 복사 생성됨)
- 벡터에 항목을 삽입 (벡터가 재할당 될 경우 모든 항목은 이동 생성이나 복사 생성됨)

- 클래스 정의에서 원치 않는 복사 방지하기
 : 클래스 인스턴스를 복사하는 비용이 많거나 복사를 원하지 않는다면 복사를 금지할 수 있음.
 : 단 아래와 같이 복사를 금지한다면 표준 라이브러리 컨테이너 클래스 값으로 사용하지 못함.

// C++ 11 이전 방법
class BigClass {
private :
   BigClass(BigClass const&);
   BigClass& operator=(BigClass const&);
public : 
...
};

// C++11 이후
class BigClass {
public 
   BigClass(BigClass const&) = delete;
   BigClass& operator=(BigClass const&) = delete;
   ...
};


- 함수 호출에서 복사 제거하기, 
 : 함수 인자로 넘겨지는 클래스 인스턴스의 경우 레퍼런스로 받아라. 
 : 내부에서 수정이 필요 없으면 const &, 수정이 필요하면 &
 : 하지만 함수 내에서 레퍼런스값을 여러번 참조한다면 역참조(dereferencing)로 인한 비용이 더 커질 수 있음.

포인터나 레퍼런스에서 값을 확인할 때를 역참조라 표현


- 함수 반환에서 복사 제거하기
 : 함수에서 클래스 인스턴스를 리턴 시 반환값으로 복사 생성됨.
 : 하지만 C++ 컴파일러에서는 복사 생략(copy elision), 반환값 최적화(return value optimization, RVO) 방법을 사용하지만 구체적인 조건에서만 가능함.
   . 함수는 내부에서 생성된 객체를 반환해야 함.
   . 함수에서 선언했던 반환 타입과 똑같은 타입이어야 함.
   . 함수가 간단하고 제어 경로가 하나뿐이라면 컴파일러는 RVO를 수행할 가능성이 높음
 : 그 외 방법으로는 출력용 매개변수(out parameter)를 사용하여 값을 반환하는 것도 방법임.
  ex) vector scalar_product(std::vector const& v, int c) 
        =>  void scalar_product(std::vector const& v, int c, vector& result)
 

RVO(Return Value Optimization)
 Output (note that -fno-elide-constructors disables RVO in clang):
$ clang++ -std=c++11 main.cpp && ./a.out
c'tor
d'tor

$ clang++ -std=c++11 -fno-elide-constructors main.cpp && ./a.out
c'tor
move c'tor
d'tor
move c'tor
d'tor
d'tor
RVO 적용과 미적용의 차이를 확실히 알 수 있다.


- COW(Copy On Write) 구현하기
 : COW의 개념은 원본 객체와 복사한 객체 중 하나가 수정되기 전까지 객체가 동일하다는 것
    즉 초기에는 얕은 복사(shallow copy)를 하고 객체가 수정될 때까지 깊은 복사(deep copy)를 지연 시키는 것


COW(Copy On Write)
예시를 보여주고 있지만 주의점도 함께 설명하고 있다.

n a multi-threaded environemnt (which is most of them nowadays) CoW is frequently a huge performance hit rather than a gain. And with careful use of const references, it's not much of a performance gain even in a single threaded environment. 
Additionally, as other people have pointed out, CoW strings are really tricky to implement, and it's easy to make mistakes. That coupled with their poor performance in threading situations makes me really question their usefulness in general. This becomes even more true once you start using C++11 move construction and move assignment. 
답변의 내용이 생각보다 부정적인 반응임. 구현하기 까다롭고 성능 개선도 크지 않을 수 있다고 함.


이동 문법 구현하기
책의 내용을 참고하자. 어렵지만 이 책(https://www.oreilly.com/library/view/effective-modern-c/9781491908419/)도 참고하면 좋다.


평평한(flat) 자료구조
- 자료 구조 요소들이 인접한 저장 공간에 저장되었다면 자료구조가 평평하다고 표현함.
- 평평한 자료구조는 생성 시 포인터로 연결된 자료구조보다 메모리 관리자를 호출하는 횟수가 적음
- list, deque, map, unordered_map 자료구조는 동적 객체를 많이 만드는 반면 vector는 이보다 적게 만듬. 같은 big O 성능을 갖는다고 하더라도 평평한 자료구조가 가지는 장점이 많음.
- std::array, std::vector와 같은 평평한 자료구조는 list, map, unordered_map과 같은 노드 기반의 자료구조보다 메모리를 적게 차지함.
- 평평한 자료구조를 사용하면 스마트 포인터와 스마트 포인터가 가리키는 객체를 할당하는 런타임 비용을 없앨 수 있음.

댓글 없음:

댓글 쓰기