PGO·LTO로 C++ 컴파일러 최적화하기: -O2 vs -O3 차이와 측정법
[C++ 실전 가이드 #2-2] C++ 컴파일러 최적화 심화
”최적화 옵션을 올렸는데도 왜 느릴까?”
-O2나 -O3를 적용했는데도 프로그램이 기대만큼 빨라지지 않는 흔한 원인 하나는 컴파일러가 파일 경계를 넘어 최적화하지 못한다는 것입니다. 핫 루프 안에서 다른 .cpp에 정의된 작은 함수를 호출하면, 그 호출은 인라인되지 못하고 매번 실제 함수 호출로 남으며, 그 때문에 루프 벡터화도 막힙니다. 각 .cpp 파일은 독립적으로 컴파일되므로, 컴파일러는 다른 파일에 있는 함수의 본문을 볼 수 없습니다.
이 글에서는 -O2/-O3가 실제로 무엇을 다르게 하는지에서 시작해, LTO(Link Time Optimization, 링크 시점에 여러 목적 파일을 함께 보고 최적화하는 기법), PGO(Profile-Guided Optimization, 실제 실행 프로파일을 반영해 다시 컴파일하는 기법), -march 같은 옵션이 각각 무엇을 바꾸는지와 어떤 코드에서 효과가 크고 작은지를 살펴봅니다. 이런 기법은 -O2/-O3만으로는 얻을 수 없는 정보(다른 파일의 함수 본문, 실제 실행 경로, 대상 CPU의 명령어)를 컴파일러에 제공합니다. 효과는 코드 성격에 따라 거의 없는 수준부터 눈에 띄는 수준까지 폭이 넓으므로, 반드시 자기 워크로드로 측정해야 합니다.
이런 옵션들은 빌드 시간과 복잡도를 늘리므로, 먼저 알고리즘과 자료 구조를 점검한 뒤 컴파일러 최적화를 적용하는 순서가 안전합니다. PGO는 분기와 호출이 많은 코드에서, LTO는 여러 .cpp로 나뉜 프로젝트에서 효과가 크고, 둘 다 빌드 시간이 늘어나므로 보통 릴리스 빌드에만 적용합니다.
당장 코딩부터 하고 싶다면 #3 VS Code 개발 환경으로 넘어가도 됩니다.
고급 최적화 개요
언제 어떤 최적화를 쓸까?
| 상황 | 권장 옵션 | 이유 |
|---|---|---|
| 개발 중 | -O0 -g 또는 -Og -g | 빠른 컴파일, 쉬운 디버깅 |
| 일반 배포 | -O2 | 균형 잡힌 최적화, 대부분의 경우 충분 |
| 성능이 중요한 배포 | -O3 -flto, 대상 CPU에 맞춘 -march | 벤치마크로 효과를 확인한 경우 |
| 분기·호출이 많은 코드 | PGO 추가 | 실제 실행 패턴 반영 |
| 크기가 중요한 임베디드 | -Os | 코드 크기 최소화 |
최적화 적용 순서
알고리즘이 O(n²)인 상태에서 -O3를 올려도 근본적인 병목은 그대로입니다. 그래서 순서가 중요합니다.
- 알고리즘과 자료 구조를 점검해 불필요한 반복이나 맞지 않는 컨테이너 선택을 먼저 해결합니다.
- 기본 최적화(
-O2)를 적용합니다. 대부분의 경우 이것으로 충분합니다. -O3, LTO,-march같은 옵션은 벤치마크로 효과를 확인한 뒤 적용합니다.- PGO는 분기가 많거나 호출 경로가 복잡한 코드에서, 대표 워크로드를 확보한 경우에만 적용합니다.
기본 최적화 레벨 상세 (-O0/-O2/-O3)
최적화 레벨별 차이
| 레벨 | 컴파일 속도 | 실행 속도 | 디버깅 | 주요 내용 |
|---|---|---|---|---|
| -O0 | 가장 빠름 | 가장 느림 | 쉬움 | 최적화 없음, 모든 변수를 메모리에 둠 |
| -Og | 빠름 | 보통 | 쉬운 편 | 디버깅을 해치지 않는 최적화만 (GCC, Clang) |
| -O1 | 빠름 | 보통 | 보통 | 기본적인 최적화 |
| -O2 | 보통 | 빠름 | 어려움 | 대부분의 최적화 (권장) |
| -O3 | 느림 | 경우에 따라 더 빠름 | 어려움 | -O2에 루프 변환, 더 적극적인 벡터화·인라인 추가 |
| -Os | 보통 | 보통 | 어려움 | 코드 크기를 늘리는 최적화를 제외 |
GCC 기준으로 보면, GCC 12부터는 -O2도 비용이 아주 낮은 경우에 한해 루프 벡터화를 수행하고, -O3는 벡터화 비용 모델을 더 공격적으로 바꾸며 루프 분할·언스위칭·필링 같은 변환을 추가합니다. 흔히 “-O3는 루프 언롤링을 한다”고 알려져 있지만, GCC는 -O3에서도 일반적인 루프 언롤링(-funroll-loops)을 켜지 않습니다. 이 옵션은 직접 지정하거나 -fprofile-use를 쓸 때 켜집니다. 반면 Clang은 -O2에서도 루프 언롤링과 벡터화를 수행하고, -O2와 -O3의 차이가 GCC보다 작습니다.
-O0 vs -O2 vs -O3: 실행 시간 비교 예제
아래 코드는 1억 번 반복하며 정수를 누적합니다.
// benchmark_opt.cpp - g++ -O0/O2/O3로 각각 빌드해 비교
#include <chrono>
#include <iostream>
int main() {
volatile long long sum = 0; // volatile: 컴파일러가 루프를 제거하지 못하게
const int N = 100000000;
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < N; ++i) {
sum += i * 2;
}
auto end = std::chrono::steady_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "sum=" << sum << ", time=" << ms << "ms" << std::endl;
return 0;
}
# 각 최적화 레벨로 빌드 후 실행 시간 비교
g++ -O0 -std=c++17 benchmark_opt.cpp -o bench_o0 && time ./bench_o0
g++ -O2 -std=c++17 benchmark_opt.cpp -o bench_o2 && time ./bench_o2
g++ -O3 -std=c++17 benchmark_opt.cpp -o bench_o3 && time ./bench_o3
-O0는 i와 중간값까지 매번 메모리에 읽고 쓰므로 가장 느리고, -O2는 i를 레지스터에 두므로 빨라집니다. 반면 -O2와 -O3의 차이는 거의 없을 가능성이 큽니다. sum이 volatile이라 매 반복마다 메모리 읽기·쓰기가 강제되므로 벡터화나 누산 최적화가 불가능하기 때문입니다. volatile을 제거하면 -O2에서도 루프 전체가 상수 계산으로 바뀌거나 사라질 수 있습니다. 즉 이 예제는 “최적화 레벨의 효과”보다 “벤치마크가 측정하는 것이 무엇인지”를 보여 주며, 실제 벤치마크에서는 결과를 출력하거나 benchmark::DoNotOptimize 같은 장치를 쓰는 편이 낫습니다.
어셈블리로 확인하는 최적화 차이
-S 옵션으로 어셈블리 출력을 비교하면 최적화 효과를 직접 확인할 수 있습니다.
// simple.cpp
int add(int a, int b) {
return a + b;
}
int main() {
int x = add(1, 2);
return x;
}
# -O0: 함수 호출 그대로 유지
g++ -O0 -S simple.cpp -o simple_o0.s
# -O2: add()가 인라인되어 main에 통합, 상수 3으로 치환
g++ -O2 -S simple.cpp -o simple_o2.s
-O0에서는 call 명령으로 add를 호출하지만, -O2에서는 인라인과 상수 전파 후 main이 mov eax, 3(AT&T 문법에서는 movl $3, %eax)에 가까운 코드로 바뀝니다. Compiler Explorer를 쓰면 같은 비교를 브라우저에서 바로 할 수 있습니다.
인라인과 루프 언롤링
인라인 최적화
인라인은 함수 호출을 함수 본문으로 치환해 호출 오버헤드(인자 전달, 점프, 스택 프레임)를 없애는 최적화입니다. 더 중요한 효과는 호출 경계가 사라지면서 상수 전파나 벡터화 같은 다른 최적화가 가능해진다는 점입니다. 작은 함수가 자주 호출될 때 효과가 큽니다.
// inline_example.cpp - 작은 함수는 인라인 후보
inline int square(int x) {
return x * x;
}
int sum_of_squares(int n) {
int sum = 0;
for (int i = 0; i < n; ++i) {
sum += square(i); // -O2 이상에서 인라인됨
}
return sum;
}
# 인라인 결과 확인 (GCC): -fopt-info-inline은 표준 에러로 출력
g++ -O2 -c -fopt-info-inline inline_example.cpp 2>&1 | grep -i inline
C++에서 inline 키워드의 주된 의미는 “여러 번역 단위에 같은 정의가 있어도 된다”는 링크 규칙이고, 실제 인라인 여부는 컴파일러가 결정합니다. -O0에서는 always_inline 속성이 붙은 함수를 제외하면 인라인이 거의 일어나지 않습니다.
루프 언롤링 (Loop Unrolling)
루프 언롤링은 루프 본문을 여러 번 펼쳐 반복 횟수와 분기 횟수를 줄이는 최적화입니다.
// unroll_example.cpp - 루프 언롤링 예
void add_one(const int* src, int* dst, int n) {
for (int i = 0; i < n; ++i) {
dst[i] = src[i] + 1;
}
}
// 수동 언롤링 (컴파일러도 자동으로 할 수 있음)
void add_one_unrolled(const int* src, int* dst, int n) {
int i = 0;
for (; i + 3 < n; i += 4) {
dst[i] = src[i] + 1;
dst[i+1] = src[i+1] + 1;
dst[i+2] = src[i+2] + 1;
dst[i+3] = src[i+3] + 1;
}
for (; i < n; ++i) {
dst[i] = src[i] + 1;
}
}
# 벡터화 결과 확인 (GCC)
g++ -O3 -march=native -c -fopt-info-vec-optimized unroll_example.cpp
이런 단순한 루프는 수동 언롤링보다 컴파일러의 벡터화가 훨씬 큰 효과를 냅니다. 위 루프는 src와 dst가 겹칠 수 있으므로, 컴파일러는 실행 시점에 겹침을 검사하는 코드를 추가한 뒤 벡터화합니다. 수동 언롤링은 가독성을 해치므로, 컴파일러에 맡기고 최적화 리포트로 벡터화가 안 되는 이유를 확인한 다음에만 시도합니다.
인라인과 언롤링이 적용되지 않는 경우
함수가 커서 인라인하면 코드 크기가 크게 늘어나는 경우, 재귀 함수(꼬리 재귀는 반복문으로 바뀔 수 있음), 다른 번역 단위에 정의된 함수(LTO 없이), 대상이 컴파일 시점에 정해지지 않는 가상 함수 호출이 대표적입니다. 가상 함수는 컴파일러가 실제 타입을 증명할 수 있을 때(final 클래스 등) 탈가상화된 뒤 인라인될 수 있습니다.
GCC 고급 최적화
CPU 특화 최적화: -march 옵션
CPU 세대마다 지원하는 명령어 세트가 다릅니다. 최신 CPU는 한 명령으로 여러 값을 동시에 계산하는 SIMD 확장을 지원하며, -march로 대상 CPU를 지정하면 컴파일러가 그 명령어를 사용할 수 있습니다.
-march=native는 “지금 이 PC의 CPU가 지원하는 명령어를 모두 써도 된다”는 뜻입니다. -march=skylake는 Intel Skylake 세대의 명령어 세트(AVX2 포함, AVX-512 제외)를, -march=armv8-a는 64비트 ARM 기본 명령어 세트를 대상으로 합니다. -march=x86-64-v2, -v3, -v4는 x86-64 psABI가 정의한 공통 수준으로, v2는 SSE4.2와 POPCNT, v3는 AVX2와 FMA, v4는 AVX-512 일부까지 포함합니다.
# 현재 PC CPU에 맞춘 최적화 (이 PC에서만 실행할 때)
g++ -O3 -march=native -o opt main.cpp && ./opt
# 특정 대상 지정
g++ -O3 -march=skylake main.cpp # Intel Skylake 세대 명령어
g++ -O3 -march=x86-64-v2 main.cpp # SSE4.2/POPCNT를 지원하는 x86-64 CPU
g++ -O3 -march=x86-64-v3 main.cpp # AVX2/FMA를 지원하는 x86-64 CPU
aarch64-linux-gnu-g++ -O3 -march=armv8-a main.cpp # ARM64 (해당 아키텍처용 컴파일러 필요)
-march=native는 벡터화 가능한 수치 루프에서는 효과가 클 수 있지만, 분기나 포인터 추적이 많은 코드에서는 거의 차이가 없습니다.
x86 CPU의 SIMD 확장은 단계적으로 추가되어 왔습니다. SSE 계열은 128비트 레지스터로 float 4개를, AVX는 256비트 레지스터로 float 8개를 동시에 처리하고, AVX2는 256비트 정수 연산과 gather 명령을 추가했습니다. 일부 서버·데스크톱 CPU는 512비트 AVX-512도 지원합니다.
배포할 때 주의할 점은 분명합니다. 특정 서버에서만 실행할 프로그램이라면 -march=native로 그 환경의 성능을 최대한 끌어낼 수 있습니다. 하지만 다른 사람의 PC나 다양한 CPU에서 실행할 실행 파일이라면, 그 CPU에 없는 명령어(예: AVX2)를 만나는 순간 illegal instruction으로 종료됩니다. Docker 이미지나 패키지로 배포할 때는 대상 CPU 범위를 정해 -march=x86-64-v2처럼 명시하거나, 컴파일러 기본값을 쓰는 편이 안전합니다.
Link Time Optimization (LTO): 파일 간 최적화
일반적인 최적화는 각 소스 파일을 독립적으로 컴파일하면서 수행됩니다. LTO를 켜면 컴파일러는 목적 파일에 중간 표현(IR)을 담아 두고, 링크 단계에서 프로그램 전체의 IR을 함께 최적화합니다. 그래서 파일 경계를 넘는 인라인, 상수 전파, 사용하지 않는 함수 제거가 가능해집니다.
flowchart TB
subgraph without[LTO 없음]
A1[main.cpp] --> B1[main.o 기계어]
A2[utils.cpp] --> B2[utils.o 기계어]
B1 --> C1[링커]
B2 --> C1
C1 --> D1[실행 파일]
end
subgraph with[LTO 사용]
E1[main.cpp] --> F1[main.o IR]
E2[utils.cpp] --> F2[utils.o IR]
F1 --> G1[링크 시점 최적화]
F2 --> G1
G1 --> H1[인라인·제거·벡터화]
H1 --> I1[최적화된 실행 파일]
end
# 링크 시점 최적화 활성화 (컴파일과 링크 모두에 -flto 필요)
g++ -O3 -flto main.cpp utils.cpp -o myapp
LTO는 서로 다른 파일에 있는 함수들의 호출 관계를 분석해, 작은 함수를 호출하는 쪽에 인라인하고 사용되지 않는 함수와 변수를 제거합니다. 작은 함수가 여러 파일에 흩어져 핫 경로에서 호출되는 코드일수록 효과가 크고, 핫 루프가 이미 한 파일 안에 모여 있다면 차이가 작습니다.
비용도 있습니다. 링크 시간이 늘어나 큰 프로젝트에서는 수 분이 걸릴 수 있고, 링크 단계의 메모리 사용량이 커지며, 인라인이 늘어나 디버깅할 때 스택 트레이스가 원래 코드 구조와 덜 맞게 됩니다. 별도로 컴파일한 정적 라이브러리를 LTO에 포함하려면 GCC에서는 gcc-ar처럼 LTO 플러그인을 쓰는 아카이버로 만들어야 한다는 점도 자주 걸리는 부분입니다.
GCC 예제
main.cpp는 utils.h에 선언된 calculate를 호출하고, utils.cpp에 그 구현이 있습니다. 일반 빌드에서는 두 파일이 각각 컴파일된 뒤 링크되므로 calculate는 다른 번역 단위의 함수로 남고, 호출 오버헤드가 그대로 있습니다. LTO를 켜면 링크 단계에서 calculate가 main에 인라인될 수 있고, 인자 100이 상수로 전파되어 결과를 컴파일 시점에 계산할 수도 있습니다. 반대로 calculate가 크거나 루프 밖에서 가끔 호출된다면 인라인해도 얻을 것이 거의 없으므로, LTO의 효과는 이런 작은 함수가 핫 경로에 얼마나 있느냐에 달려 있습니다.
// main.cpp - calculate()를 호출하는 메인 프로그램
#include <iostream>
#include "utils.h"
int main() {
int result = calculate(100);
std::cout << "Result: " << result << std::endl;
return 0;
}
// utils.h - calculate 함수 선언
#pragma once
int calculate(int n);
// utils.cpp - calculate 함수 구현 (작은 함수 → LTO 시 인라인 후보)
#include "utils.h"
int calculate(int n) {
int sum = 0;
for (int i = 0; i < n; ++i) {
sum += i * i;
}
return sum;
}
# 기본 빌드 (파일 경계를 넘지 못함)
g++ -O2 main.cpp utils.cpp -o app1
# 같은 -O 수준에서 LTO만 추가
g++ -O2 -flto main.cpp utils.cpp -o app2
# 어셈블리에서 calculate 호출이 사라졌는지 확인
objdump -d --no-show-raw-insn app2 | grep -A20 '<main>:'
실행이 1ms도 걸리지 않는 프로그램은 time으로 차이를 구분할 수 없습니다. 이런 예제에서는 실행 시간보다 디스어셈블 결과에서 call calculate가 남아 있는지를 보는 편이 정확합니다. 실행 시간을 비교하려면 반복 횟수를 늘려 수백 ms 이상 걸리게 한 뒤 여러 번 재고, LTO만의 효과를 보려면 위처럼 같은 -O 수준에서 -flto 유무만 바꿔야 합니다.
CMake에서 LTO 설정
CMake는 INTERPROCEDURAL_OPTIMIZATION 속성으로 컴파일러별 LTO 플래그를 대신 붙여 줍니다. 지원 여부를 먼저 확인하는 것이 안전합니다.
# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(MyApp CXX)
set(CMAKE_CXX_STANDARD 17)
add_executable(myapp main.cpp utils.cpp)
include(CheckIPOSupported)
check_ipo_supported(RESULT ipo_ok OUTPUT ipo_msg)
if(ipo_ok)
# Release 구성에서만 LTO
set_property(TARGET myapp PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE)
else()
message(WARNING "LTO not supported: ${ipo_msg}")
endif()
# 배포 대상 CPU 범위를 명시 (GCC/Clang)
target_compile_options(myapp PRIVATE $<$<CONFIG:Release>:-march=x86-64-v2>)
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
CMake의 Release 구성은 GCC/Clang에서 이미 -O3 -DNDEBUG를 붙이므로, -O3를 다시 추가할 필요는 없습니다. 구성별 설정에 if(CMAKE_BUILD_TYPE STREQUAL "Release") 대신 _RELEASE 속성이나 생성기 표현식을 쓰면 Visual Studio 같은 다중 구성 생성기에서도 동작합니다.
Clang 고급 최적화
Profile-Guided Optimization (PGO): 실행 데이터 기반 최적화
PGO는 프로그램을 실제로 실행해 “어느 코드가 자주 실행되는지, 어떤 분기가 더 자주 선택되는지”를 수집한 뒤, 그 결과를 반영해 다시 컴파일하는 방식입니다. 컴파일러가 정적 휴리스틱으로 추측하던 부분을 실제 측정값으로 대체하므로, 어떤 함수를 인라인할지, 어떤 분기를 직선 경로에 둘지, 어떤 코드를 차가운 영역으로 분리할지를 더 정확히 결정할 수 있습니다.
flowchart TB
subgraph step1["1단계: 계측 빌드"]
A1[소스 코드] --> B1[-fprofile-generate]
B1 --> C1[계측된 실행 파일]
end
subgraph step2["2단계: 대표 워크로드 실행"]
C1 --> D1[실제 사용 시나리오 실행]
D1 --> E1[.profraw 파일 생성]
end
subgraph step3["3단계: 최적화 빌드"]
E1 --> F1[llvm-profdata merge]
F1 --> G1[default.profdata]
G1 --> H1[-fprofile-use]
H1 --> I1[최적화된 실행 파일]
end
PGO의 3단계
1단계에서는 프로파일을 수집할 수 있도록 계측 코드를 넣어 컴파일합니다.
clang++ -O2 -fprofile-generate main.cpp -o myapp_prof
이렇게 빌드한 프로그램은 실행하면서 각 분기와 함수 호출이 몇 번 실행되었는지를 기록합니다. 계측 코드 때문에 평소보다 느리게 실행됩니다.
2단계에서는 계측된 프로그램을 실제 작업 부하로 실행합니다.
./myapp_prof < typical_input.txt
# 현재 디렉터리에 default_<해시>.profraw 형태의 파일이 생성됨
Clang의 -fprofile-generate는 기본적으로 default_%m.profraw 패턴의 파일 이름을 쓰므로, 파일 이름이 정확히 default.profraw가 아닙니다. 위치와 이름을 고정하려면 -fprofile-generate=<디렉터리>를 쓰거나 실행 시 LLVM_PROFILE_FILE 환경 변수를 지정합니다. 여기서 쓰는 입력은 실제 운영 환경과 최대한 비슷해야 합니다. 테스트용 작은 입력으로 수집하면 드문 경로를 핫 경로로 잘못 판단할 수 있습니다.
3단계에서는 수집한 프로파일을 병합해 최종 빌드에 사용합니다.
# 여러 실행에서 나온 .profraw를 하나로 병합
llvm-profdata merge -output=default.profdata *.profraw
clang++ -O2 -fprofile-use=default.profdata main.cpp -o myapp_optimized
계측 빌드와 최종 빌드는 같은 소스와 같은 최적화 옵션을 써야 프로파일이 정확히 대응됩니다. GCC도 같은 -fprofile-generate/-fprofile-use 옵션을 쓰지만, 별도 병합 도구 없이 오브젝트 파일 옆에 .gcda 파일을 생성하고 -fprofile-use가 이를 바로 읽습니다.
PGO가 바꾸는 것
PGO는 자주 실행되는 분기를 직선 경로(fall-through)에 두고 드문 경로를 함수 끝이나 별도 영역으로 옮겨, 실행되는 점프 수를 줄이고 명령어 캐시를 효율적으로 씁니다. 자주 호출되는 호출 지점은 더 적극적으로 인라인하고, 거의 실행되지 않는 코드는 크기 위주로 최적화하며, 가상 함수 호출에서 자주 등장하는 대상을 직접 호출로 바꾸는 최적화(indirect call promotion)도 합니다.
효과는 분기가 많고 실행 경로가 한쪽으로 치우친 코드(파서, 인터프리터, 컴파일러, 서버의 요청 처리 경로)에서 크고, 단순한 수치 루프에서는 작습니다. 프로파일을 수집한 워크로드가 실제 운영과 다르면 오히려 나빠질 수도 있습니다.
PGO 예제
// sort_example.cpp - 데이터 분포가 치우친 정렬
#include <iostream>
#include <vector>
#include <algorithm>
int main() {
std::vector<int> data;
// 90%는 0~99 범위의 작은 값, 10%는 큰 값
for (int i = 0; i < 1000000; ++i) {
if (i < 900000) {
data.push_back(i % 100);
} else {
data.push_back(i);
}
}
std::sort(data.begin(), data.end());
std::cout << "Sorted: " << data.size() << " elements" << std::endl;
return 0;
}
# 일반 최적화
clang++ -O2 sort_example.cpp -o sort1
time ./sort1
# PGO (3단계)
clang++ -O2 -fprofile-generate sort_example.cpp -o sort_prof
./sort_prof
llvm-profdata merge -output=default.profdata *.profraw
clang++ -O2 -fprofile-use=default.profdata sort_example.cpp -o sort2
time ./sort2
이 예제에서는 차이가 크지 않을 가능성이 높습니다. 대부분의 시간이 std::sort 내부의 비교와 이동에 쓰이고, 이 코드는 이미 잘 최적화되어 있기 때문입니다. PGO의 효과를 제대로 보려면 실제 서비스처럼 분기와 함수 호출이 많은 프로그램에, 운영과 비슷한 입력으로 수집한 프로파일을 적용해야 합니다.
Thin LTO vs Full LTO
Clang은 Full LTO 외에 Thin LTO를 지원합니다. Full LTO는 모든 모듈을 하나로 합쳐 최적화하고, Thin LTO는 각 모듈의 요약 정보만 모아 어떤 함수를 어디로 가져와 인라인할지 결정한 뒤 모듈별 최적화는 병렬로 수행합니다.
# Thin LTO (병렬 처리, 링크가 빠름)
clang++ -O2 -flto=thin main.cpp utils.cpp -o myapp
# Full LTO (단일 모듈로 처리, 링크가 느림)
clang++ -O2 -flto main.cpp utils.cpp -o myapp
| 항목 | Thin LTO | Full LTO |
|---|---|---|
| 링크 시간 | 빠름 (병렬 처리, 증분 캐시 가능) | 느림 (단일 모듈로 합쳐 처리) |
| 최적화 수준 | LLVM 문서 기준으로 대부분의 경우 Full LTO에 근접 | 모듈 전체를 한 번에 봄 |
| 메모리 사용 | 적음 | 많음 |
| 권장 상황 | 대부분의 프로젝트 | 작은 프로젝트, 측정으로 이득이 확인된 경우 |
GCC에는 Thin LTO라는 이름의 모드가 없지만, 기본 LTO가 프로그램을 여러 파티션으로 나눠 병렬로 처리하는 방식(WHOPR)이며, -flto=auto나 -flto=8처럼 병렬 작업 수를 지정할 수 있습니다.
MSVC 고급 최적화
Link Time Code Generation (LTCG)
MSVC의 전체 프로그램 최적화는 컴파일 옵션 /GL과 링크 옵션 /LTCG로 켭니다. GCC/Clang의 LTO와 같은 역할입니다.
REM /Fe는 컴파일러 옵션이므로 /link 앞에 둠
cl /GL /O2 main.cpp utils.cpp /Fe:myapp.exe /link /LTCG
/GL로 만든 목적 파일은 기계어가 아니라 중간 표현을 담고 있으므로, 같은 버전의 MSVC로만 링크할 수 있습니다. 다른 버전의 컴파일러로 만든 /GL 정적 라이브러리를 링크하면 오류(C1047 등)가 나므로, 외부에 배포하는 정적 라이브러리에는 /GL을 쓰지 않는 것이 일반적입니다.
Profile-Guided Optimization
MSVC PGO도 3단계로 진행합니다. Visual Studio 2015부터는 /LTCG:PGI, /LTCG:PGO 대신 /GENPROFILE, /USEPROFILE 링커 옵션을 씁니다.
REM 1단계: 계측 빌드 (myapp.pgd 생성)
cl /GL /O2 main.cpp /Fe:myapp.exe /link /LTCG /GENPROFILE
REM 2단계: 대표 시나리오로 실행 (myapp!1.pgc 같은 파일 생성)
myapp.exe
REM 3단계: 프로파일 사용 빌드 (같은 출력 이름으로 myapp.pgd를 읽음)
cl /GL /O2 main.cpp /Fe:myapp.exe /link /LTCG /USEPROFILE
링커는 출력 파일 이름을 기준으로 .pgd 파일을 찾으므로, 3단계에서 출력 이름을 바꾸려면 /USEPROFILE:PGD=myapp.pgd처럼 경로를 지정해야 합니다.
MSVC 특화 옵션
REM AVX2 명령어 사용 허용 (AVX2를 지원하는 CPU에서만 실행 가능)
cl /O2 /arch:AVX2 main.cpp
REM 전체 프로그램 최적화 + 불필요 코드 제거
cl /GL /O2 main.cpp /link /LTCG /OPT:REF /OPT:ICF
REM /OPT:REF - 참조되지 않는 함수/데이터 제거
REM /OPT:ICF - 내용이 같은 함수 병합 (Identical COMDAT Folding)
/OPT:REF와 /OPT:ICF는 릴리스 링크의 기본값이지만, /DEBUG를 지정하면 기본값이 꺼지므로 디버그 정보를 포함한 릴리스 빌드에서는 명시하는 것이 좋습니다. /OPT:ICF는 서로 다른 함수가 같은 주소를 갖게 만들 수 있어, 함수 포인터 비교에 의존하는 코드가 있다면 주의해야 합니다.
Visual Studio에서 설정하기
- 프로젝트 속성 → C/C++ → 최적화 → 전체 프로그램 최적화: 예(
/GL) - 링커 → 최적화 → 참조: 예(
/OPT:REF), COMDAT 접기 사용: 예(/OPT:ICF) - PGO는 링커 → 최적화 → 링크 타임 코드 생성 항목에서 “프로필 기반 최적화 - 계측”과 “프로필 기반 최적화 - 최적화”를 단계에 맞게 선택합니다.
직접 재 볼 수 있는 벤치마크
아래 두 테스트는 옵션과 컴파일러에 따른 차이를 직접 재 볼 수 있는 코드입니다. 실행 시간은 CPU, 컴파일러 버전, OS에 따라 크게 달라지므로 결과표 대신 무엇을 보면 되는지를 적었습니다.
테스트 1: 정수 연산 (행렬 곱셈)
// matrix_multiply.cpp - N×N 행렬 곱셈 (O(N³))
const int N = 1000;
std::vector<std::vector<int>> A(N, std::vector<int>(N));
std::vector<std::vector<int>> B(N, std::vector<int>(N));
std::vector<std::vector<int>> C(N, std::vector<int>(N, 0));
for (int i = 0; i < N; ++i) {
for (int j = 0; j < N; ++j) {
for (int k = 0; k < N; ++k) {
C[i][j] += A[i][k] * B[k][j];
}
}
}
이 코드는 B[k][j]를 k 방향으로 읽어 매번 다른 행(다른 힙 블록)을 건드리므로, 캐시 미스가 연산보다 큰 병목입니다. 그래서 -O3나 -march=native로 명령어를 바꿔도 개선 폭이 제한적일 수 있고, 루프 순서를 i-k-j로 바꾸거나 연속된 1차원 배열을 쓰는 코드 수정이 옵션 변경보다 훨씬 큰 효과를 냅니다. 컴파일러 옵션의 효과를 보려면 이렇게 메모리 접근을 먼저 정리한 다음 비교하는 것이 공정합니다.
테스트 2: 문자열 처리
// string_sort.cpp - 100만 개 문자열 생성 및 정렬
std::vector<std::string> strings;
for (int i = 0; i < 1000000; ++i) {
strings.push_back("string_" + std::to_string(i));
}
std::sort(strings.begin(), strings.end());
이 테스트는 문자열 생성과 비교가 대부분을 차지하므로, 컴파일러 차이보다 표준 라이브러리 구현(libstdc++, libc++, MSVC STL)과 메모리 할당자 차이가 결과에 더 크게 반영됩니다. "string_" + std::to_string(i)는 최대 13자라 SSO(짧은 문자열 최적화) 범위(libstdc++와 MSVC는 15자, libc++는 64비트에서 22자) 안에 들어가 힙 할당이 일어나지 않습니다. 문자열을 더 길게 바꾸면 결과가 크게 달라지는 것도 확인해 볼 만합니다.
컴파일 속도 비교
컴파일 속도는 전통적으로 Clang이 GCC보다 빠르다는 평가가 많았지만, 버전, 최적화 수준, 템플릿 사용량에 따라 차이가 작거나 뒤집히기도 합니다. 자기 프로젝트에서 비교하려면 같은 옵션으로 클린 빌드를 한 뒤 -ftime-trace(Clang)나 -ftime-report(GCC)로 어떤 헤더와 템플릿 인스턴스화가 시간을 쓰는지까지 보는 것이 좋습니다. 컴파일러를 바꾸는 것보다 헤더 의존성을 줄이는 쪽이 효과가 큰 경우가 많습니다.
벡터화 확인하기
-O3나 -march를 썼을 때 실제로 벡터화가 적용되었는지는 컴파일러의 최적화 리포트로 확인합니다.
# GCC: 벡터화·인라인 리포트 (표준 에러를 파일로 저장)
g++ -O3 -march=native -fopt-info-vec-optimized -fopt-info-inline main.cpp -o myapp 2> opt_report.txt
# GCC: 벡터화에 실패한 루프와 이유
g++ -O3 -march=native -fopt-info-vec-missed main.cpp -o myapp
# Clang: 벡터화 성공/실패 리포트
clang++ -O3 -march=native -Rpass=loop-vectorize -Rpass-missed=loop-vectorize main.cpp -o myapp
GCC 리포트에 “loop vectorized”가 보이면 해당 루프에 SIMD가 적용된 것이고, Clang은 “vectorized loop” 메시지를 냅니다. 실패 리포트에는 루프 간 데이터 의존성, 포인터 별칭 가능성, 복잡한 제어 흐름 같은 이유가 함께 나오므로, 그 이유를 보고 코드를 고칩니다.
워크로드에 따른 선택
지연 시간에 민감하고 분기가 많은 서버(게임 서버, 요청 처리 서버)는 PGO의 효과를 보기 좋은 유형입니다. 실제 트래픽이나 플레이 시나리오를 재현한 입력으로 프로파일을 수집할 수 있다면 LTO와 PGO를 함께 적용해 볼 만합니다. 반대로 처리량 위주의 데이터 파이프라인처럼 시간이 몇몇 루프에 집중되는 코드는 PGO보다 벡터화와 대상 CPU에 맞춘 명령어(-march)의 영향이 더 큰 경우가 많습니다. 어느 쪽이든 서버를 직접 운영해 하드웨어가 정해져 있다면 그 CPU에 맞춘 -march를 쓸 수 있고, 하드웨어가 섞여 있다면 가장 오래된 CPU에 맞춰야 합니다.
컴파일 시간 단축
고급 최적화는 빌드 시간을 늘립니다. 개발 중에는 다음 기법으로 컴파일 시간을 줄일 수 있습니다.
Precompiled Headers (PCH)
자주 포함하는 헤더를 미리 컴파일해 두면 파일마다 같은 헤더를 반복 파싱하는 비용이 사라집니다. 표준 라이브러리, Boost, Qt처럼 무거운 헤더를 많은 파일이 공유할수록 효과가 큽니다.
// pch.h - 자주 사용하는 헤더 모음
#pragma once
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
#include <map>
#include <set>
GCC:
# PCH 생성 (pch.h.gch)
g++ -x c++-header -O2 -std=c++17 pch.h -o pch.h.gch
# PCH 사용: -include pch.h를 주면 같은 디렉터리의 pch.h.gch를 먼저 찾음
g++ -include pch.h -O2 -std=c++17 main.cpp -o myapp
GCC는 PCH를 만들 때와 사용할 때의 주요 옵션(-std, 최적화 수준 등)이 맞아야 .gch를 사용하고, 맞지 않으면 조용히 원래 헤더를 다시 파싱합니다. -Winvalid-pch를 주면 PCH가 사용되지 않은 이유를 경고로 알려 줍니다.
Clang:
# PCH 생성
clang++ -x c++-header -O2 -std=c++17 pch.h -o pch.h.pch
# PCH 사용
clang++ -include-pch pch.h.pch -O2 -std=c++17 main.cpp -o myapp
MSVC:
REM pch.cpp는 #include "pch.h" 한 줄만 포함
cl /c /Yc"pch.h" /Fp"pch.pch" /std:c++17 pch.cpp
REM 다른 소스는 첫 줄에 #include "pch.h"가 있어야 하며, 링크할 때 pch.obj도 포함
cl /Yu"pch.h" /Fp"pch.pch" /std:c++17 main.cpp pch.obj
Unity Builds (Jumbo Builds)
여러 소스 파일을 하나의 번역 단위로 합쳐 컴파일하면 공통 헤더를 번역 단위마다 반복 파싱하지 않아도 됩니다.
// unity.cpp - 여러 cpp를 하나의 번역 단위로
#include "file1.cpp"
#include "file2.cpp"
#include "file3.cpp"
#include "file4.cpp"
g++ -O2 unity.cpp -o myapp
클린 빌드 시간은 크게 줄 수 있지만, 파일 하나만 고쳐도 그 파일이 속한 묶음 전체를 다시 컴파일해야 하므로 증분 빌드에는 불리합니다. 또 원래는 파일마다 따로 있던 static 함수, 익명 네임스페이스 안의 이름, 매크로가 한 번역 단위에 모이면서 이름이 충돌할 수 있습니다. 익명 네임스페이스는 번역 단위 단위로 구분되므로 unity 빌드에서는 충돌을 막아 주지 못합니다. CMake 3.16 이상에서는 UNITY_BUILD 타깃 속성으로 묶음 크기를 조절하며 적용할 수 있습니다.
ccache 사용
ccache는 같은 소스와 같은 옵션으로 이미 컴파일한 결과를 캐시에서 재사용합니다.
# ccache 설치 (Ubuntu/Debian)
sudo apt install ccache
# 사용 방법 1: PATH에 ccache 래퍼 디렉터리 추가
export PATH=/usr/lib/ccache:$PATH
g++ -O2 main.cpp -o myapp
# 사용 방법 2: ccache를 앞에 붙여 실행
ccache g++ -O2 main.cpp -o myapp
# CMake와 함께 사용
cmake -B build -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
make clean 후 다시 빌드하거나, 브랜치를 오갔다가 돌아오거나, CI에서 캐시를 공유할 때 효과가 큽니다. 반면 한 파일만 고치고 다시 빌드하는 경우에는 빌드 시스템이 이미 그 파일만 다시 컴파일하므로 ccache가 더해 주는 이득은 거의 없습니다.
Ninja 빌드 시스템
# CMake + Ninja
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
Ninja는 기본적으로 CPU 코어 수에 맞춰 병렬로 빌드하고, 시작과 의존성 검사가 빨라 “바뀐 것이 거의 없는” 증분 빌드에서 Make보다 반응이 빠릅니다. 컴파일 자체의 속도는 같으므로, 클린 빌드 시간의 차이는 크지 않을 수 있습니다.
개발 중에는 -O0/-Og와 ccache, PCH로 빠른 피드백을 얻고, -O3나 LTO, PGO는 CI의 릴리스 빌드에서만 적용하는 방식이 흔히 쓰입니다.
자주 발생하는 문제와 해결법
문제 1: “illegal instruction” 또는 SIGILL
-march=native로 빌드한 실행 파일을 다른 PC에서 실행하면 바로 종료됩니다. 빌드한 PC의 CPU는 지원하지만(예: AVX2) 실행하는 PC의 CPU는 지원하지 않는 명령어가 들어 있기 때문입니다. Docker 이미지도 마찬가지로, 최신 CPU의 빌드 서버에서 만든 이미지를 오래된 서버에서 실행하면 같은 문제가 생깁니다.
# 배포용에 -march=native를 쓰지 않음
g++ -O3 -march=x86-64-v2 -o myapp main.cpp # SSE4.2/POPCNT 지원 CPU
g++ -O3 -march=x86-64 -o myapp main.cpp # 모든 x86-64 CPU
# 빌드 시 대상 CPU 범위를 명시
ARG CXXFLAGS="-O3 -march=x86-64-v2"
RUN g++ $CXXFLAGS -o myapp main.cpp
문제 2: LTO 링크 시 메모리 부족 또는 매우 느린 링크
LTO는 링크 단계에서 프로그램 전체의 IR을 다루므로, 대규모 프로젝트에서는 메모리 사용량과 링크 시간이 크게 늘어납니다.
# Clang: Thin LTO로 메모리와 링크 시간 감소
clang++ -O2 -flto=thin main.cpp utils.cpp -o myapp
# GCC: 병렬 LTRANS 작업 수를 제한해 동시 메모리 사용을 줄임
g++ -O2 -flto=4 main.cpp utils.cpp -o myapp
GCC의 -flto-partition=none은 프로그램 전체를 하나의 파티션으로 처리하므로 오히려 메모리를 더 쓰고 병렬화도 사라집니다. 메모리가 부족할 때는 병렬 작업 수를 줄이는 쪽이 맞습니다.
문제 3: PGO 프로파일이 반영되지 않음
-fprofile-use로 빌드했는데 성능 향상이 거의 없다면, 프로파일 수집에 쓴 입력이 실제 워크로드와 다르거나, 소스가 바뀐 뒤 프로파일을 다시 수집하지 않은 경우가 많습니다. 소스가 바뀌면 함수의 제어 흐름이 프로파일과 맞지 않아 컴파일러가 해당 함수의 프로파일을 버립니다. Clang은 -Wprofile-instr-out-of-date, GCC는 -Wcoverage-mismatch 같은 경고로 이런 불일치를 알려 줍니다. 계측 빌드와 최종 빌드의 옵션이 다를 때도 같은 문제가 생깁니다.
문제 4: MSVC에서 /GL 라이브러리 링크 실패
다른 버전의 MSVC로 /GL 컴파일한 정적 라이브러리를 링크하면 “object file was created with a different version of the compiler” 같은 오류가 납니다. /GL 목적 파일은 컴파일러 버전에 묶인 중간 표현을 담기 때문입니다. 같은 툴셋으로 라이브러리를 다시 빌드하거나, 배포용 라이브러리는 /GL 없이 빌드합니다.
문제 5: PCH를 수정했는데 반영되지 않음
pch.h를 고쳤는데 다시 빌드해도 반영되지 않는다면, 빌드 시스템이 PCH와 소스 사이의 의존성을 추적하지 못하는 것입니다. CMake의 target_precompile_headers를 쓰면 의존성이 자동으로 관리됩니다.
add_executable(myapp main.cpp)
target_precompile_headers(myapp PRIVATE pch.h)
문제 6: -O2에서 벤치마크 결과가 비현실적으로 빠름
루프가 0ms에 끝난다면, 컴파일러가 결과가 쓰이지 않는 계산을 통째로 제거했을 가능성이 큽니다.
// 나쁜 예: sum을 쓰지 않으면 루프 전체가 제거될 수 있음
long long sum = 0;
for (int i = 0; i < N; ++i) sum += i;
// 나은 예: 결과를 출력해 관찰 가능한 동작으로 만듦
long long sum = 0;
for (int i = 0; i < N; ++i) sum += i;
std::cout << sum << std::endl; // 또는 benchmark::DoNotOptimize(sum)
결과를 출력하더라도 컴파일러가 루프를 닫힌 공식(N*(N-1)/2)으로 바꿀 수 있으므로, 측정하려는 연산이 실제로 실행되는지 어셈블리로 확인하는 것이 좋습니다. volatile은 제거를 막지만 매 반복마다 메모리 접근을 강제해 측정 대상 자체를 바꿉니다.
문제 7: 디버그 빌드를 실행하고 있음
“최적화가 적용되지 않은 것 같다”는 경우의 상당수는 실수로 디버그 빌드를 실행하고 있는 경우입니다. IDE의 기본 구성이 Debug인 경우가 많습니다.
# CMake 단일 구성 생성기(Ninja, Make): 빌드 타입 명시
cmake -B build -DCMAKE_BUILD_TYPE=Release
# Visual Studio 같은 다중 구성 생성기: 빌드할 때 구성 지정
cmake --build build --config Release
프로덕션 빌드 패턴
CMake 구성별 설정
CMake는 구성별로 기본 플래그를 정해 둡니다. GCC/Clang 기준으로 Debug는 -g, Release는 -O3 -DNDEBUG, RelWithDebInfo는 -O2 -g -DNDEBUG입니다. 그래서 최적화 레벨을 다시 지정하기보다는 필요한 것만 구성별로 추가하는 편이 깔끔합니다.
# Release와 RelWithDebInfo에 배포 대상 CPU 지정, Release에만 LTO
# ($<CONFIG:A,B> 형태의 여러 구성 지정은 CMake 3.19 이상)
target_compile_options(myapp PRIVATE
$<$<CONFIG:Release,RelWithDebInfo>:-march=x86-64-v2>)
set_property(TARGET myapp PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE)
RelWithDebInfo는 최적화된 코드에 디버그 심볼을 포함하므로, 운영 환경에서 발생한 크래시 덤프를 분석해야 하는 서비스에 적합합니다.
CI/CD에서 릴리스 빌드
# GitHub Actions 예시
- name: Release Build
run: |
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release
env:
CXXFLAGS: "-march=x86-64-v2"
CXXFLAGS 환경 변수는 빌드 디렉터리를 처음 구성할 때만 읽혀 CMAKE_CXX_FLAGS의 초기값이 되며, Release 구성의 -O3 -DNDEBUG는 CMake가 따로 붙입니다.
PGO 파이프라인
# 1단계: 계측 빌드와 프로파일 수집
clang++ -O2 -fprofile-generate=./pgo main.cpp -o myapp_prof
./myapp_prof < production_like_input.txt
# 2단계: 병합 후 같은 옵션으로 최종 빌드
llvm-profdata merge -output=default.profdata ./pgo/*.profraw
clang++ -O2 -fprofile-use=default.profdata main.cpp -o myapp_release
프로파일 수집과 병합을 빌드 파이프라인에 넣어 두면, 코드가 바뀔 때마다 프로파일이 자동으로 다시 만들어져 오래된 프로파일 문제를 피할 수 있습니다.
ccache와 PCH로 개발 빌드 가속
find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM)
set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}")
endif()
target_precompile_headers(myapp PRIVATE <vector> <string> <iostream>)
ccache는 PCH를 사용하는 컴파일을 기본 설정에서는 캐시하지 않으므로, 둘을 함께 쓰려면 ccache의 sloppiness 설정에 pch_defines,time_macros를 추가해야 합니다.
최적화가 잘 안 되는 경우
옵션을 모두 켰는데도 느리다면, 병목이 컴파일러가 해결할 수 있는 영역 밖에 있을 가능성이 큽니다.
파일이나 네트워크 I/O가 대부분의 시간을 차지한다면 CPU 최적화의 효과는 제한적이며, 버퍼링, 배치 처리, 비동기 I/O를 검토해야 합니다. 알고리즘의 복잡도가 문제라면, 예를 들어 입력이 큰 상황에서 O(n²) 루프를 O(n log n)으로 바꾸는 것이 어떤 컴파일러 옵션보다 큰 차이를 만듭니다. 메모리 접근이 비연속적이면 캐시 미스가 지배적이므로 캐시 친화적 코드처럼 데이터 배치를 바꿔야 합니다. 락 경합이 병목이라면 임계 구역을 줄이거나 데이터를 스레드별로 나누는 설계 변경이 필요합니다.
어느 경우든 perf(Linux), VTune, Visual Studio 프로파일러 같은 도구로 시간이 실제로 어디에 쓰이는지 먼저 확인하는 것이 출발점입니다.
참고 자료
- GCC Optimization Options — 공식 문서
- Clang Profile Guided Optimization — Clang PGO 사용법
- ThinLTO — Clang Thin LTO 문서
- MSVC /O Options — MSVC 최적화 옵션
- MSVC Profile-guided optimizations — MSVC PGO 문서
같이 보면 좋은 글
- C++ 컴파일러 뭘 쓸까? GCC vs Clang vs MSVC 차이·선택 가이드
- Visual Studio C++ 빌드가 느릴 때: PCH·/MP 병렬 컴파일·증분 링크·Unity Build 설정
- C++ 컴파일러 비교: GCC vs Clang vs MSVC, 어떤 걸 써야 할까?
- C++ 멀티 컴파일러 전략과 CI/CD 파이프라인 구축
- C++ 개발 환경 구축
- VS Code C++ 설정 | IntelliSense·빌드·디버깅
다음으로 컴파일러 고급 활용(#2-3)을 읽어 보면 좋습니다.
자주 묻는 질문 (FAQ)
PGO 프로파일은 어떤 워크로드로 수집해야 하나요?
컴파일러는 수집된 실행 경로를 기준으로 인라인과 분기 배치를 정하므로, 실제 운영 트래픽과 비슷한 입력 분포로 프로그램을 돌려 프로파일을 모아야 합니다. 단위 테스트나 에지 케이스 위주로 수집하면 드문 경로를 핫 경로로 착각해 오히려 느려질 수 있습니다. 또 소스가 바뀌면 프로파일이 맞지 않아 본문 문제 3처럼 반영되지 않으므로, 대표 워크로드 실행과 프로파일 병합(Clang은 llvm-profdata merge)을 빌드 파이프라인에 포함해 주기적으로 다시 수집하는 것이 좋습니다.