Featured image of post [Optimization(C++) 18] ABI·링크 경계와 극한 성능 (전문)

[Optimization(C++) 18] ABI·링크 경계와 극한 성능 (전문)

전문 난이도 장으로 ODR·ABI·가시성·심볼 경계가 인라이닝·LTO·동적 링크에 주는 제약을 정리합니다. semantic interposition이 공유 라이브러리 최적화를 막는 원리, 2-TU LTO 재현, Tr.03·챕터 12·19와 연계한 판단 기준과 실무 체크리스트를 제공합니다.

본 장은 전문 난이도입니다. ABI(Application Binary Interface)와 링크 경계는 “코드를 조금 바꿨는데 왜 최적화가 사라졌는가”를 설명할 때 자주 등장합니다. 인라이닝·LTO·PGO·멀티버저닝은 Tr.03에서 다루고, 여기서는 언어·링크 모델이 만들어내는 벽이 성능에 어떤 의미를 갖는지 정리합니다.

이 장을 읽기 전에

완전한 초보자? 이 장은 전문 난이도로, 03장: 추상화 비용 분석·12장: 인라이닝 유도 기법·08장: 템플릿/constexpr을 먼저 읽었다고 가정합니다. 번역 단위(TU)·링크·동적 라이브러리가 무엇인지 알고 있어야 따라가기 쉽습니다.

이 장의 깊이: 이 장은 전문에 집중합니다. ODR·ABI·가시성 같은 경계가 왜 최적화를 막는지부터 시작해, LTO가 고치는 것/못 고치는 것, 인라이닝과 코드 크기, 버전 업그레이드·ABI 안정성까지 다룹니다. 다루지 않는 것: 인라이닝·LTO·PGO·멀티버저닝의 도구 사용법은 Tr.03 컴파일러 트랙에서, SIMD·asm 같은 명령 선택은 Tr.08 극한 최적화 트랙에서, 코루틴은 10장, 타입 소거의 세부는 19장에서 다룹니다.

당신의 수준에 맞는 경로

수준읽을 부분핵심 목표
중급자“ABI와 링크 경계가 성능에 닿는 이유” ~ “가시성과 심볼(visibility)”경계가 최적화를 막는 이유 이해
전문가“LTO가 고치는 것·못 고치는 것” ~ “인라이닝과 코드 크기 (다시)”LTO 한계와 코드 크기 트레이드오프 파악
아키텍트“Tr.03·Tr.08과의 역할 나누기” ~ “판단 기준: 언제 이 장을 깊게 읽을까”ABI 안정성·경계 설계 판단

ABI와 링크 경계가 성능에 닿는 이유

컴파일러는 번역 단위(TU) 단위로 최적화합니다. 다른 TU에 정의된 함수가 가시성이 열려 있고 ABI상 호출 규약이 고정되어 있으면, 링크 전에는 구현을 모르는 경우가 많아 인라이닝·특화가 제한됩니다. LTO(ThinLTO 포함)는 링크 시점에 경계를 넘겨 일부를 되살리지만, 동적 라이브러리 경계·C ABI·플러그인 인터페이스처럼 고정된 경계는 여전히 남습니다.

ODR과 템플릿·인라인

ODR(One Definition Rule)은 프로그램 전체에서 특정 엔티티의 정의 규칙을 제한합니다. 위반은 미정의 동작으로 이어질 수 있고, 진단이 어렵습니다. 인라인 함수와 템플릿은 ODR 예외가 있어 헤더에 정의를 두는 패턴이 흔합니다. 이 패턴은 헤더 의존성 폭발과 빌드 시간 비용을 키울 수 있어, 성능과 엔지니어링 비용이 동시에 트레이드오프가 됩니다.

ODR 위반은 항상 즉시 크래시로 드러나지 않습니다. 서로 다른 TU에서 동일 심볼 이름으로 서로 다른 정의가 링크되면, 어떤 빌드에서는 우연히 한 정의만 쓰이다가 LTO·링커 순서가 바뀌며 다른 정의가 선택될 수 있습니다. 증상은 “릴리즈만 틀리다”, “한 번 빌드하면 괜찮다”처럼 재현이 어려운 성능·정확성 버그로 나타나기도 합니다. 전문 튜닝에서 헤더/소스를 복제하거나 매크로로 갈라진 정의를 두면, 나노초를 아끼려다 계약을 깨기 쉬우므로 코드 리뷰에서 정의 단일성을 별도 체크합니다.

가시성과 심볼(visibility)

GCC/Clang 계열의 기본 가시성과 MSVC의 dllexport/import 등은 심볼이 동적 링커에 얼마나 노출되는지를 바꿉니다. 과도한 노출은 동적 링크 비용과 전역 링커 작업을 키울 수 있고, 반대로 과도한 숨김은 테스트 더블 삽입과 충돌할 수 있습니다. “전부 숨긴다”가 항상 최선은 아니며, 핫 심볼만 전략적으로 다루는 편이 실무에 맞습니다.

가시성이 최적화에 닿는 핵심 메커니즘은 ELF의 심볼 인터포지션(symbol interposition)입니다. ELF 동적 링커는 공유 라이브러리가 내보낸 심볼을 실행 파일이나 먼저 로드된 다른 라이브러리의 같은 이름 심볼로 바꿔치기할 수 있게 허용합니다(LD_PRELOAD로 malloc을 교체하는 것이 대표적인 활용입니다). 그러면 -fPIC로 공유 라이브러리를 컴파일하는 GCC는, 같은 라이브러리 안의 다른 함수가 기본 가시성으로 내보낸 함수를 호출할 때도 “런타임에 다른 구현으로 바뀔 수 있다"고 가정해야 합니다. GCC 매뉴얼은 이 경우 컴파일러가 프로시저 간 전파, 인라이닝 같은 최적화를 수행할 수 없다고 명시합니다. 같은 라이브러리 안의 호출조차 PLT를 거치는 간접 호출로 남을 수 있는 이유입니다.

이 제약을 푸는 방법은 두 가지입니다. 첫째, 심볼을 -fvisibility=hidden이나 visibility("hidden")으로 숨기면 외부에서 인터포즈할 수 없으므로 컴파일러가 마음 놓고 인라인합니다. 둘째, GCC 5부터 제공되는 -fno-semantic-interposition을 주면 “인터포즈가 일어나더라도 대체 함수는 의미가 같다"고 가정하고 최적화합니다(-Ofast는 이 옵션을 자동으로 켭니다). 명시적으로 inline으로 선언된 함수는 처음부터 인터포즈로 의미가 바뀌는 것이 허용되지 않으므로 이 제약을 받지 않습니다. 실제 노출 심볼을 줄이는 예는 아래와 같습니다.

가시성: visibility("default") vs -fvisibility=hidden

ELF 계열에서 -fvisibility=hidden은 기본 노출 심볼 수를 줄여 동적 심볼 테이블과 링크·로딩 비용을 낮춥니다. 공개해야 하는 심볼만 __attribute__((visibility("default")))로 되살리는 패턴이 일반적입니다. 아래를 공유 라이브러리로 빌드해 노출 심볼을 비교합니다.

1
2
3
4
// lib.cpp
__attribute__((visibility("default"))) int public_api(int x) { return x + 1; }

int internal_helper(int x) { return x * 2; }  // 숨김 대상

이 소스를 -fvisibility=hidden으로 공유 라이브러리로 빌드한 뒤, 동적 심볼 테이블에 실제로 무엇이 노출되는지 nm -D로 확인합니다.

1
2
g++ -O2 -fvisibility=hidden -fPIC -shared lib.cpp -o liblib.so
nm -D --defined-only liblib.so | c++filt

nm -D(동적 심볼만)에는 public_api만 보이고 internal_helper는 빠집니다 — 즉 숨긴 심볼은 동적 링커가 이름으로 찾을 수 없으므로, 플러그인 로더나 테스트가 dlsym으로 그 이름을 찾는다면 깨질 수 있습니다. “숨기기”는 성능 도구이자 API 계약 변경임을 기억합니다.

LTO가 고치는 것·못 고치는 것

LTO는 TU 경계를 넘는 죽은 코드 제거, 인라이닝, 상수 전파 등을 강화할 수 있습니다. 그러나 동적 로딩, dlsym, 플러그인 ABI, 외부에서만 알 수 있는 입력은 여전히 정보 부족으로 최적화를 막습니다. 또한 LTO는 링크 시간·메모리를 크게 쓰므로, CI와 개발자 루프 비용을 Tr.03와 함께 봐야 합니다. 동일 소스라도 스태틱 링크 vs 동적 링크는 코드 배치와 GOT/PLT 비용이 달라지고, C++20 모듈은 인터페이스를 미리 컴파일한 BMI(Binary Module Interface)를 가져다 쓰므로 헤더 재파싱 비용을 줄이는 대신, BMI를 먼저 만들어야 하는 직렬 의존이 빌드 그래프에 생겨 병렬성이 줄 수 있습니다. 이런 빌드 구조 변화는 런타임 성능과 별개이므로 “빌드 시간”과 “런타임”을 분리 기록해야 합니다.

2-TU 최소 재현: LTO 없이 vs -flto

두 번역 단위로 나누면 TU 경계가 인라이닝을 어떻게 막는지 눈으로 볼 수 있습니다. cold.cpp에 정의된 scale을 hot.cpp의 루프에서 호출할 때, LTO가 없으면 hot.cpp를 컴파일하는 컴파일러가 scale 본문을 보지 못해 out-of-line call이 남고, -flto를 켜면 링크 시점에 다시 실행되는 최적화기가 두 TU의 본문을 함께 보고 인라인할 수 있습니다.

1
2
// cold.cpp — 다른 번역 단위에 정의
int scale(int x) { return x * 3 + 1; }

hot.cpp는 scale의 선언만 보고 컴파일됩니다 — 본문이 다른 TU(cold.cpp)에 있으므로, LTO 없이는 컴파일러가 이 시점에 scale을 인라인할 근거(본문)를 갖지 못합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// hot.cpp — 핫 루프에서 다른 TU의 함수를 호출
int scale(int x);             // 선언만 보임(본문은 cold.cpp)

__attribute__((noinline)) int hot_sum(int n) {   // main에 흡수되지 않도록 고정
    int acc = 0;
    for (int i = 0; i < n; ++i) acc += scale(i);  // TU 경계 호출
    return acc;
}

int main(int argc, char**) {
    return hot_sum(argc * 1000) & 0xff;   // 실행 시점 값이라 루프가 상수로 접히지 않는다
}

아래 두 빌드는 같은 소스를 LTO 없이/있이 각각 빌드해, 인라인 여부가 실제로 갈리는지 비교합니다.

1
2
3
4
5
6
7
8
9
# LTO 없음: scale 호출이 out-of-line으로 남는다
g++ -O2 -c cold.cpp -o cold.o
g++ -O2 -c hot.cpp  -o hot.o
g++ -O2 cold.o hot.o -o app_nolto

# LTO 켬: 링크 시점에 scale 본문을 보고 인라인 가능
g++ -O2 -flto -c cold.cpp -o cold_lto.o
g++ -O2 -flto -c hot.cpp  -o hot_lto.o
g++ -O2 -flto cold_lto.o hot_lto.o -o app_lto

확인은 간단합니다. hot_sum에 noinline을 붙인 이유는, 한 번만 호출되는 함수가 main에 흡수되어 objdump로 찾을 심볼이 사라지는 것을 막기 위해서입니다. objdump -d app_nolto | grep -A12 hot_sum에는 루프 안에 scale을 부르는 직접 call 명령이 남고, app_lto에서는 같은 루프가 lea/imul류 산술로 펼쳐져 call 자체가 사라지는 경우가 많습니다. nm app_lto에서 scale이 더 이상 별도 심볼로 남지 않는 것도 인라인의 방증입니다.

인라이닝과 코드 크기 (다시)

전문 튜닝에서는 핫 함수를 무리하게 헤더에 두어 전 TU에 인라인시키기도 합니다. 이때 I-cache 미스와 빌드 캐시 무효가 동시에 올라갈 수 있습니다. “한 함수의 나노초”와 “전체 바이너리의 캐시”를 같은 벤치로 묶어 보지 않으면 잘못된 승리를 할 수 있습니다.

flowchart LR
  subgraph compile [컴파일 타임]
    A["TU 경계"]
    B["가시성"]
  end
  subgraph link [링크 타임]
    C["LTO"]
    D["심볼 해상도"]
  end
  subgraph run [런타임]
    E["호출 규약"]
    F["동적 로딩"]
  end
  A --> C
  B --> D
  C --> E
  D --> E
  D --> F

Tr.03·Tr.08과의 역할 나누기

Tr.03는 플래그·리포트·PGO 워크플로를 다룹니다. 본 장은 “왜 리포트가 이렇게 말하는가”를 ABI·링크 관점에서 해석합니다. Tr.08은 SIMD·asm 등 명령 선택으로 넘어가며, 그때도 외부 심볼 경계는 남습니다. 세 트랙을 오갈 때 질문을 바꿉니다. “인라인이 안 된 이유가 컴파일러 한계인가, 링크 모델인가, 명령 부족인가?”

사례 유형별 메모

사례 1 — 작은 헬퍼가 TU 밖에만 정의됨: 헤더에 inline 정의를 두거나, LTO로 링커가 본문을 볼 수 있게 구성하는지 검토합니다. 팀 정책상 헤더 노출이 싫다면 내부 네임스페이스·내부 헤더로 옮기는 타협이 흔합니다.

사례 2 — C API 경계: extern "C"는 C 링키지로 이름 맹글링을 하지 않게 하지만, 호출 규약과 객체 레이아웃 제약이 남습니다. C++ 쪽에서 RAII 객체를 건네면 ABI가 깨지기 쉽습니다.

사례 3 — 예외·RTTI 넘나드는 경계: 동적 라이브러리 간 예외 전파는 플랫폼·빌드 플래그에 민감합니다. “성능” 이전에 정확성 이슈가 될 수 있어, 전문 튜닝 전에 빌드 매트릭스를 고정합니다.

표준 레이아웃과 최적화

특정 타입의 메모리 레이아웃은 ABI에 의해 고정되는 경우가 많습니다. 레이아웃을 바꾸는 “최적화”는 외부 모듈과의 계약을 깨뜨릴 수 있습니다. 이는 Tr.04의 레이아웃 튜닝과 충돌할 수 있으므로, 내부 타입에만 적용하는 규칙을 두는 것이 일반적입니다.

버전 업그레이드와 재현성

컴파일러·표준 라이브러리·OS를 올리면 동일 소스라도 생성 코드가 달라질 수 있습니다. ABI 안정성을 전제로 한 바이너리 플러그인은 특히 취약합니다. 전문 튜닝 결과는 도구 체인 버전과 함께 기록해야 재현됩니다.

type erasure와 경계 (챕터 19 연계)

std::function처럼 타입 소거된 객체를 DLL 경계로 넘기면, 할당자·vtable·소멸이 어느 모듈에 속하는지가 ABI와 맞물립니다. 챕터 19에서 다루듯 소거는 간접 호출을 동반하기 쉬운데, 경계까지 겹치면 한 번의 호출이 아니라 프로세스 전체 계약 문제가 됩니다. 내부는 구체 타입, 경계는 명시적 C API나 안정된 팩토리로 좁히는 패턴이 안전합니다.

표: 증상별·플랫폼별 진단 매핑

먼저 증상에서 출발해 무엇을 확인할지 좁히고, 이어서 같은 주제가 ELF/Linux와 Windows에서 어떻게 다른지 확인합니다.

증상먼저 볼 것
인라인 실패-fopt-info-inline, 가시성, ODR
심볼 과다visibility, export 표, 불필요한 API
LTO 이득 없음동적 로딩, 플러그인, C API
PGO 이득 불안정프로파일 대표성, 빌드 플래그 일치
주제ELF/Linux 흐름에서Windows에서
심볼 기본 노출visibility, version scriptdllexport 누락 주의
동적 로딩dlsym, RTLDLoadLibrary/GetProcAddress
예외 경계libstdc++/libc++ 일치DLL 경계·/EH 플래그

세부는 플랫폼 문서가 정답이며, 본 장은 “경계가 있으면 최적화 정보가 끊긴다”는 틀만 제공합니다.

흔한 오해

“LTO를 켜면 공유 라이브러리 안의 호출도 모두 인라인된다”는 오해입니다. LTO는 함께 링크되는 오브젝트의 본문을 보여 줄 뿐, 앞에서 본 심볼 인터포지션 제약은 그대로 남습니다. -fPIC 공유 라이브러리에서 기본 가시성으로 내보낸 함수는 LTO 아래에서도 “런타임에 바뀔 수 있는 함수"이므로, 가시성을 숨기거나 -fno-semantic-interposition을 주지 않으면 같은 라이브러리 안의 호출도 펼쳐지지 않을 수 있습니다.

"extern "C"로 감싸면 ABI 문제가 끝난다”도 오해입니다. extern "C"는 C 언어 링키지를 지정해 이름 맹글링을 하지 않게 할 뿐, 넘기는 타입의 레이아웃이나 예외 전파 규칙까지 고정하지 않습니다. extern "C" 함수의 인자로 std::string이나 std::vector를 넘기면 양쪽 모듈이 서로 다른 표준 라이브러리·빌드 플래그로 컴파일됐을 때 레이아웃이 어긋날 수 있으므로, 경계에는 POD 구조체와 포인터+길이처럼 레이아웃이 고정된 타입만 둡니다.

비판적 시각

ABI를 핑계로 모듈화를 깨는 선택을 정당화하기 쉽습니다. 전문 튜닝은 회귀 비용이 크므로 Tr.12 성능 게이트·코드 리뷰(Tr.11)와 묶지 않으면 팀 전체 속도가 떨어질 수 있습니다. 또한 플랫폼마다 ABI 세부가 다르므로, 이식성을 명시적으로 포기할 때만 “전문 장치”를 켜는 것이 안전합니다.

판단 기준: 언제 이 장을 깊게 읽을까

사용해도 되는 경우피하거나 늦추는 경우
인라인·LTO 리포트가 TU 경계·가시성을 이유로 자주 막힐 때아직 챕터 03·12·Tr.03를 읽지 않아 “왜 간접 호출이 남는지” 기준이 없을 때
동적 라이브러리·플러그인·C API로 모듈 경계가 많을 때단일 실행 파일·헤더만 쓰는 팀이고 링크 이슈가 없을 때
컴파일러·링커 업그레이드 후 성능 회귀가 “같은 소스”에서 발생할 때ABI를 건드리지 않는 순수 알고리즘 변경만 할 때

한 줄 요약: “코드는 그대로인데 빌드 설정·경계만 바꿨는데 속도가 변했다”면 이 장의 어휘로 질문을 세분화하고, 그렇지 않다면 먼저 언어·컴파일러 본편 챕터를 읽는 편이 효율적입니다.

평가 기준 (이 장을 읽은 후)

이 장을 마치면 TU 경계, LTO, 가시성, 인터포지션을 각각 구분해 “왜 이 호출만 인라인되지 않는가"를 설명할 수 있어야 합니다. 다음 질문에 답할 수 있는지 확인합니다.

  • TU 경계가 인라이닝에 미치는 영향을 예로 설명할 수 있는가?
  • LTO가 해결하지 못하는 경계를 두 가지 이상 말할 수 있는가?
  • 가시성 변경이 링크·로딩에 미칠 수 있는 부작용을 말할 수 있는가?
  • 심볼 인터포지션이 -fPIC 공유 라이브러리 내부 인라이닝을 막는 이유와 해제 방법을 설명할 수 있는가?

실무 체크리스트

  • 핫 심볼 목록과 노출 정책이 문서화되어 있는가? — 무엇을 숨겨도 되는지 모르면 가시성 최적화가 API 계약을 깬다.
  • LTO on/off·PGO on/off에서 동일 벤치로 비교했는가? — 링크 단계 최적화의 이득은 코드가 아니라 빌드 설정에서 나온다.
  • 공유 라이브러리 내부 핫 호출이 인터포지션 때문에 PLT를 거치지 않는가? — 숨김 가시성이나 -fno-semantic-interposition으로 해제할 수 있다.
  • 예외·RTTI·std::function 같은 소거 객체가 모듈 경계를 넘는가? — 레이아웃·할당자·소멸 책임이 모듈 사이에 걸린다.
  • new/delete가 크로스 모듈에서 짝이 맞는가? — 모듈마다 다른 할당자가 링크되면 다른 힙에 해제하게 된다.
  • 여러 컴파일러·표준 라이브러리 버전 지원 시 ABI 변경 롤백 플랜이 있는가? — 같은 소스도 도구 체인이 바뀌면 바이너리 계약이 바뀐다.

더 읽을 거리

  • Itanium C++ ABI — 이 장에서 다룬 이름 맹글링·객체 레이아웃·가상 테이블 등 ABI 규칙의 1차 사양 문서입니다.
  • GCC Wiki: Visibility — -fvisibility=hidden·visibility("default") 속성이 동적 심볼 노출과 링크·로딩 비용에 미치는 영향을 정리한 GCC 공식 가이드입니다.
  • GCC 매뉴얼: Optimize Options — -fsemantic-interposition이 인터포즈 가능한 심볼의 프로시저 간 최적화·인라이닝을 막는다는 설명과 해제 옵션의 1차 문서입니다.
  • cppreference: Definitions and ODR — One Definition Rule의 정확한 범위(함수·클래스·템플릿에 대한 예외 포함)를 정리한 레퍼런스로, “ODR과 템플릿·인라인” 절과 함께 보면 좋습니다.

다음 장에서는

collection_order 기준으로 이전 장은 Parameter Passing 전략 (챕터 17)입니다. 인라이닝·TU 경계를 바로 복습하려면 인라이닝 유도 기법 (챕터 12)을 함께 열어 두면 좋고, 숫자·리포트의 출처는 Tr.03 컴파일러·빌드 최적화에서 확인합니다.

트랙의 마지막 주제인 Type Erasure 비용 패턴으로 넘어갑니다. std::function을 포함한 타입 소거가 간접 호출·SBO·할당을 어떻게 조합하는지, 그리고 이 장에서 본 ABI·경계 논의가 소거 객체에 어떻게 얽히는지 정리합니다.

→ Type Erasure 비용 패턴 (챕터 19)