코드 최적화 기법
병목 지점을 찾았다면, 해당 코드를 어떻게 개선하여 성능을 향상시킬 수 있을까요?
이번 장에서는 C++ 프로그램의 성능을 최적화하기 위한 다양한 코드 최적화 기법들을 살펴보겠습니다.
여기서 다룰 기법들은 특정 도메인에 국한되지 않는 일반적인 원칙과 패턴입니다.
실제 적용 시에는 항상 프로파일링을 통해 효과를 검증해야 함을 다시 한번 강조합니다.
컴파일러 최적화 활용
가장 쉽고 기본적인 최적화는 컴파일러의 최적화 기능을 적극적으로 활용하는 것입니다.
현대 C++ 컴파일러(GCC, Clang, MSVC)는 매우 정교한 최적화 기술을 내장하고 있습니다.
그래서 개발자가 명시적으로 최적화하지 않아도 상당한 성능 향상을 가져다줍니다.
-
최적화 플래그
-O2
,-O3
(GCC/Clang): 최적화 수준을 지정합니다.-O2
는 대부분의 경우 좋은 균형을 제공하며,-O3
는 더 공격적인 최적화를 시도하지만, 컴파일 시간이 길어지고 때로는 디버깅을 어렵게 만들 수 있습니다./O2
,/Ox
(MSVC): MSVC의 최적화 플래그입니다.- LTO (Link Time Optimization) / WPO (Whole Program Optimization): 프로그램 전체를 대상으로 최적화를 수행하여, 개별 소스 파일 컴파일 시에는 불가능했던 최적화를 가능하게 합니다. (예: GCC/Clang의
-flto
, MSVC의/GL
)
-
인라인화 (Inlining)
- 컴파일러는 작은 함수를 호출하는 대신, 호출 지점에 함수의 본문을 직접 삽입(인라인화)하여 함수 호출 오버헤드를 줄일 수 있습니다.
inline
키워드는 컴파일러에게 인라인화를 "요청"하는 힌트일 뿐, 실제 인라인화 여부는 컴파일러가 결정합니다.[[noinline]]
(C++11) 속성으로 인라인화를 명시적으로 비활성화할 수도 있습니다.
-
Dead Code Elimination (불필요 코드 제거)
- 컴파일러는 실행되지 않거나 결과에 영향을 미치지 않는 코드를 자동으로 제거합니다.
-
Loop Unrolling (루프 언롤링)
- 루프 반복 횟수를 줄이기 위해 루프 본문을 여러 번 복제합니다. 루프 제어 변수의 오버헤드를 줄일 수 있습니다.
-
Vectorization (벡터화 / SIMD)
- 컴파일러는 SIMD(Single Instruction Multiple Data) 명령어를 사용하여 여러 데이터에 대해 단일 명령어를 동시에 실행하도록 코드를 변환할 수 있습니다. 이는 특히 벡터/행렬 연산에서 큰 성능 향상을 가져옵니다.
중요: 디버깅 빌드(-O0
/ /Od
)는 최적화를 거의 수행하지 않으므로, 성능 측정은 항상 최적화가 적용된 릴리스 빌드에서 수행해야 합니다.
알고리즘과 자료구조 선택
아무리 코드를 잘 작성해도, 비효율적인 알고리즘이나 자료구조를 사용한다면 성능 한계에 부딪힐 수밖에 없습니다.
이는 가장 중요하고 기본적인 최적화 원칙입니다.
-
시간 복잡도 (Time Complexity):
- $O(N^2)$ 알고리즘 대신 $O(N \log N)$ 또는 $O(N)$ 알고리즘을 사용하면 N이 커질수록 압도적인 성능 차이를 보입니다.
- 예: 정렬 시 버블 정렬 ($O(N^2)$) 대신
std::sort
(인트로 정렬, $O(N \log N)$ 평균) 사용.
-
공간 복잡도 (Space Complexity):
- 메모리 사용량을 줄이는 것은 캐시 효율성에도 영향을 미쳐 성능에 간접적인 영향을 미칩니다.
-
자료구조 선택:
std::vector
vsstd::list
vsstd::set
vsstd::unordered_map
등: 각 자료구조는 접근, 삽입, 삭제, 검색 등의 연산에 대한 시간 복잡도와 메모리 사용량 특성이 다릅니다.- 예: 빈번한 중간 삽입/삭제가 필요하면
std::list
, 빠른 검색이 필요하면std::unordered_map
(해시맵), 캐시 친화적인 순차 접근이 중요하면std::vector
.
캐시 효율성 (Cache Efficiency)
현대 CPU는 메인 메모리보다 훨씬 빠른 캐시 메모리(L1, L2, L3)를 사용하여 데이터 접근 속도를 높입니다.
캐시 친화적인(cache-friendly) 코드를 작성하는 것은 매우 중요합니다.
-
데이터 지역성 (Locality of Reference)
- 공간 지역성 (Spatial Locality): 참조된 데이터 주변의 데이터도 곧 참조될 가능성이 높습니다. 연속된 메모리 공간에 데이터를 배치하고 순차적으로 접근하면 캐시 히트(cache hit)율이 높아집니다.
std::vector
가std::list
보다 일반적으로 빠른 이유 중 하나입니다. - 시간 지역성 (Temporal Locality): 최근에 참조된 데이터는 곧 다시 참조될 가능성이 높습니다. 자주 사용하는 데이터를 캐시 내에 유지하도록 합니다.
- 공간 지역성 (Spatial Locality): 참조된 데이터 주변의 데이터도 곧 참조될 가능성이 높습니다. 연속된 메모리 공간에 데이터를 배치하고 순차적으로 접근하면 캐시 히트(cache hit)율이 높아집니다.
-
데이터 구조 패딩 (Padding)
- 구조체 멤버 변수의 순서를 조정하거나, 필요에 따라 패딩을 추가하여 캐시 라인(cache line)의 효율적인 사용을 유도할 수 있습니다. (고급 기법)
-
false sharing
방지- 멀티스레드 환경에서, 서로 다른 스레드가 캐시 라인을 공유하는 독립적인 데이터에 접근하면 불필요한 캐시 동기화가 발생하여 성능이 저하될 수 있습니다.
std::hardware_destructive_interference_size
(C++17)를 이용하여 패딩을 추가하거나 데이터를 재배치하여 이를 방지할 수 있습니다.
- 멀티스레드 환경에서, 서로 다른 스레드가 캐시 라인을 공유하는 독립적인 데이터에 접근하면 불필요한 캐시 동기화가 발생하여 성능이 저하될 수 있습니다.
불필요한 연산 제거 및 단순화
-
루프 불변 코드 이동 (Loop Invariant Code Motion)
- 루프 내에서 매번 동일한 값을 계산하는 코드가 있다면, 루프 밖으로 빼내어 한 번만 계산하도록 합니다. 컴파일러가 자동으로 수행하기도 하지만, 명시적으로 작성하는 것이 좋습니다.
-
// before for (int i = 0; i < N; ++i) { result[i] = i * func(param); // func(param)은 루프 내에서 변하지 않음 } // after const int fixed_val = func(param); for (int i = 0; i < N; ++i) { result[i] = i * fixed_val; }
-
지연 초기화 (Lazy Initialization)
- 객체나 자원이 실제로 필요할 때까지 초기화를 지연합니다. 항상 사용되지 않는 자원을 미리 할당하거나 계산하는 것을 방지합니다.
-
불필요한 함수 호출/객체 생성 줄이기
- 특히 루프 내에서 과도한 함수 호출이나 임시 객체 생성을 피합니다.
- RVO/NRVO (Return Value Optimization / Named Return Value Optimization): C++ 컴파일러가 반환값을 복사하지 않고 바로 목적지에 생성하도록 최적화합니다. C++17부터는 보장되는 경우가 많습니다.
-
const
및noexcept
활용const
는 컴파일러에게 값을 변경하지 않음을 알려주어 최적화 힌트를 제공합니다.noexcept
는 함수가 예외를 던지지 않음을 알려주어 컴파일러가 예외 처리 메커니즘과 관련된 오버헤드를 제거할 수 있게 합니다. 특히 이동 생성자/대입 연산자에noexcept
를 사용하는 것이 중요합니다.
메모리 관리 최적화
-
동적 할당 최소화
new
/delete
또는malloc
/free
는 시스템 호출을 포함하여 상당한 오버헤드를 가집니다.- 빈번한 작은 객체 할당을 피하고, 가능하면 스택에 할당하거나 큰 청크(chunk)로 미리 할당하여 재사용하는 메모리 풀(Memory Pool) 기법을 고려합니다.
std::vector
와 같이 연속적인 메모리 할당을 사용하는 컨테이너는 동적 할당의 오버헤드를 줄이는 데 효과적입니다. 미리reserve()
를 사용하여 재할당을 피하는 것도 중요합니다.
-
이동 시맨틱 (
std::move
) 활용- 값비싼 객체의 복사 대신 이동을 통해 자원 소유권만 이전하여 오버헤드를 줄입니다.
- 특히
std::unique_ptr
,std::vector
등의move-only
타입에서 그 효과가 두드러집니다.
병렬 처리 (Parallel Processing)
멀티코어 CPU의 시대에 병렬 처리는 성능 최적화의 핵심입니다.
-
멀티스레딩 (Multithreading)
- CPU 집약적인 작업을 여러 스레드로 분할하여 동시에 실행합니다. (15장 참조)
std::thread
,std::async
등을 활용합니다.- 경쟁 조건, 데드락 등의 문제에 대한 동기화가 필수적입니다 (
std::mutex
,std::condition_variable
).
-
OpenMP / TBB (Threading Building Blocks) / C++ Concurrency TS
- 더 복잡하고 세밀한 병렬 처리를 위해 OpenMP (컴파일러 지시어 기반 병렬화), Intel TBB (템플릿 라이브러리), 또는 C++ 표준의 동시성 TS(Technical Specification)를 사용할 수 있습니다.
기타 고려 사항
-
정밀한 부동소수점 연산 vs 빠른 연산
- 부동소수점 연산은 성능에 큰 영향을 미칠 수 있습니다. 필요에 따라
float
대신double
을 사용하거나, 컴파일러 플래그로 부동소수점 연산의 정밀도를 조절하여 속도를 조절할 수 있습니다 (예:-ffast-math
for GCC/Clang). 이는 수치적 정확성에 영향을 미칠 수 있으므로 신중해야 합니다.
- 부동소수점 연산은 성능에 큰 영향을 미칠 수 있습니다. 필요에 따라
-
시스템 호출 (System Calls) 최소화
- 운영체제 커널 모드와의 전환은 오버헤드가 발생합니다. 파일 I/O, 네트워크 통신 등 시스템 호출을 하는 작업의 횟수를 최소화하고 데이터를 버퍼링하여 한 번에 처리하는 것이 효율적입니다.
-
로그 및 디버깅 코드 제거
- 릴리스 빌드에서는 불필요한 로그 출력이나 디버깅을 위한 어설션(assertion) 등을 컴파일하지 않도록 매크로(
NDEBUG
등)를 사용합니다.
- 릴리스 빌드에서는 불필요한 로그 출력이나 디버깅을 위한 어설션(assertion) 등을 컴파일하지 않도록 매크로(