Skip to content

Latest commit

 

History

History
445 lines (345 loc) · 30 KB

cpp-backend.md

File metadata and controls

445 lines (345 loc) · 30 KB

C++ 백엔드 검토

생성 대상을 C에서 C++로 바꿀지 검토한 내용과 실험 결과를 모았다. 결정은 아직 보류 상태다.

  • 측정 환경: clang 23.1.1(Homebrew LLVM, libc++ 포함), 32코어, Linux(glibc 동적 링크).
  • 시스템에는 libstdc++ 개발 파일이 없어서, C++ 표준 라이브러리는 brew LLVM의 libc++로 실험했다.

1. 배경

  • TurboPython(https://tpy-lang.org): 타입을 명시한 Python 문법을 C++로 변환해 네이티브 바이너리를 만드는 프로젝트다.
    • 정수 기본 32비트, 넘치면 실행 중단, GC 없음, CPython 패키지 비호환, @native로 C/C++ 연결 등 우리와 방향이 비슷하다.
    • 메모리 모델은 소유권과 빌림(Own[T])이고, RAII, move, 템플릿과 1:1로 대응해서 C++을 고른 것으로 보인다.
  • 처음 걱정은 "C++은 C의 추상화라 이중 백엔드가 아닌가"였다. 실제로는 C와 C++ 모두 clang 프런트엔드에서 LLVM IR로 바로 내려가므로 백엔드는 하나다. 차이는 프런트엔드 비용과 언어 기능에 있다.

2. 컴파일 시간

2.1 작은 TU와 큰 TU (C++ 코드를 손으로 따로 작성한 비교)

측정 C C++
작은 TU 하나, -O1 54.8ms 55.7ms
큰 TU(모듈 300개 분량), -O1 491ms 538ms (+10%)
같은 TU, -fsyntax-only 83ms 96ms (+16%)

이 비교는 언어 차이와 코드 차이가 섞여 있었다. 그래서 아래에서 같은 소스로 다시 쟀다.

2.2 같은 소스를 C 모드와 C++ 모드로 (모듈 900개 분량, 약 44k줄)

C23 정수 접미사(uwb)만 바꿔서 같은 파일을 두 모드로 컴파일했다.

최적화 C C++ 차이
-O0 358ms 442ms +84ms (+23.5%)
-O1 1419ms 1504ms +85ms (+6.0%)
-O2 1177ms 1271ms +95ms (+8.1%)
  • 늘어나는 절대 시간(85~95ms)은 최적화 수준과 무관하게 거의 일정하다. 전부 프런트엔드 비용이다.
  • -ftime-trace 결과, 최적화와 기계어 생성 시간은 같고 차이는 함수 본문 해석(ParseFunctionDefinition, 96ms → 133ms)에 몰려 있다.
  • 규모별(syntax-only, 프로세스 시작 약 46ms 제외)로 보면, C++의 파싱·의미 분석 작업량이 C의 약 1.5배다.
  • 외부 측정(Clang 19)도 -O0 약 +11%, -O2 약 +3%로 "거의 항상 C가 빠르다"는 결론이다. 우리는 생성 코드가 선언과 호출 위주라 더 크게 나온다.

2.3 원인 (구문 6만 개짜리 마이크로 벤치마크, 파싱 작업 대비 C++ 추가 비용)

구문 추가 비용
함수 프로토타입 int f(int x); +33%
static 함수 정의 +27%
함수 호출 a = f(a) +28%
지역 변수 선언(초기화 유무 무관) +19%
대입 b = a +8%
산술 a = a + 1 +6%
  • 선언 하나하나를 처리하는 규칙(재선언 조회, 링키지, 오버로드 가능성 검사)이 C보다 많다.
  • 한정자 없는 호출은 ADL 때문에 호출마다 UnresolvedLookupExpr가 생긴다(-print-stats로 확인, 메모리 +31%). ::f(a)처럼 한정하면 이 노드가 사라지고 차이가 약 1/3 줄어든다.
  • 효과 없음: -fno-exceptions -fno-rtti, -fno-spell-checking, C 스타일 캐스트 제거.
  • AST는 C와 C++이 거의 같다. 차이는 의미 분석(Sema) 과정의 작업량이다.

2.4 대형 프로젝트 벤치마크

구성:

  • 파일 200개, 파일당 약 2만 줄. 생성 코드 총량은 C 410만 줄, C++ 440만 줄이다.
  • 각 파일은 다른 파일 3개를 import해서 공개 함수를 호출한다.
  • C: 지금 lower가 생성한 실제 코드를 복제했다.
  • C++: 전환 후 형태다. 파일마다 모듈 인터페이스(공개 선언만)와 구현을 두고, prelude를 모듈로 빌드하고, namespace와 템플릿을 쓴다.
  • 32코어 병렬 빌드, 결과 바이너리 실행으로 정상 동작을 확인했다(exit 0).
빌드 C C++ 차이
Release -O1 -flto=thin, 파일마다 프로세스(인터페이스 BMI와 오브젝트를 따로) 9.79s 12.58s +28%
Release, 인터페이스를 BMI+오브젝트 한 번에(-fmodule-output) 9.79s 11.77s +20%
Release, 프로세스당 여러 파일 묶음 9.45s 10.61s +12%
Debug -O0, 파일마다 프로세스 3.90s 5.58s +43%
Debug, 프로세스당 여러 파일 묶음 3.35s 4.48s +34%
  • 처음 차이의 상당 부분은 인터페이스용 clang 프로세스 200~400개를 더 띄우는 빌드 구성에서 왔다. 인터페이스 하나는 48ms(빈 C 파일 46ms)지만, 동시에 많이 띄우면 경합으로 느려진다. 묶어서 컴파일하면 0.97s → 0.28s.
  • 구성을 맞춘 뒤 남는 순수 차이는 컴파일 단계 Release +9.5%, Debug +23%로, 2.2의 언어 비용과 같다.
  • TypeScript 7(Go 포팅, tsc/internal 555개 파일, 약 32만 줄) 규모로 환산하면 차이는 Release와 Debug 모두 약 0.1초다.
  • 참고: 포크 tsc를 Go로 빌드하면 캐시 없이 35.5초(CPU 238초), 캐시 있으면 0.95초다.

2.5 대형 프로젝트 빌드 속도를 좌우하는 것

  • TU당 고정 비용(clang 프로세스 시작 약 46ms)이 C++ 추가 비용보다 훨씬 크다. 파일 1000개면 시작 비용만 약 46초(CPU)다.
  • 지금 빌드 매니저는 TU를 순차로 컴파일한다(build.ts의 반복문). 병렬 컴파일과 증분 캐시가 C/C++ 선택보다 큰 차이를 만든다.
  • 생성 TU에 std 헤더를 텍스트로 include하면 TU마다 수백 ms가 추가된다(2.6). 언어 차이보다 훨씬 크다.

2.6 std 헤더 비용 (TU 하나, libc++, -O1)

8개 헤더: string, vector, unordered_map, memory, algorithm, optional, variant, functional.

방식 시간 std 없는 TU 대비
std 없음 49.0ms -
텍스트 #include 314.4ms +265ms
#include + -fmodules 캐시 60.6ms +12ms
import std; (미리 빌드한 BMI) 50.1ms +1ms
import std + 실제 사용(템플릿 인스턴스화) 147.3ms +98ms
  • 헤더를 읽는 비용은 모듈 캐시나 import std로 거의 없어진다. 남는 비용은 실제로 쓰는 템플릿의 인스턴스화다.
  • 무거운 std 사용은 런타임(한 번만 컴파일)에 두고, 생성 코드는 런타임의 구체 함수를 부르게 한다.
  • brew LLVM에 include/c++/v1/module.modulemap과 share/libc++/v1/std.cppm이 있다.

2.7 증분 빌드

C++ 언어 자체에는 증분 빌드 기능이 없다. 빌드 시스템(우리는 ssc 빌드 매니저)이 맡는다. 최근 clang의 모듈 기능이 이를 돕는다.

  • Reduced BMI (clang 22부터 기본값, 우리 clang 23도 해당)
    • BMI에 inline이 아닌 함수 본문, inline이 아닌 변수 정의, 쓰이지 않는 전역 모듈 영역 선언을 넣지 않는다.
    • 함수 본문만 바꾸면 BMI가 바뀌지 않아, import하는 쪽을 다시 컴파일할 필요가 없다.
    • 플래그는 -fmodules-reduced-bmi, --precompile-reduced-bmi이고, 되돌릴 때는 -fno-modules-reduced-bmi.
  • 연쇄되지 않는 변경 (clang 19부터 실험적)
    • clang은 BMI 안에 영향을 줄 수 있는 BMI들의 해시를 기록한다.
    • 빌드 시스템은 직접 import한 모듈의 BMI가 실제로 바뀌었을 때만 다시 컴파일한다. 간접 의존이 바뀌어도 직접 의존의 BMI 내용이 같으면 넘어간다("바뀐 경우에만 덮어쓰기").
    • 컴파일러 버그 가능성 때문에 끌 수 있는 옵션으로 두라고 권장한다.
  • 모듈 의존성 스캔(P1689): CMake 4.0과 Ninja가 컴파일러에게 의존성을 묻는다. 우리는 import 그래프를 이미 알아서 필요 없다.
  • 1단계(-fmodule-output)와 2단계(--precompile → 오브젝트): 2단계가 병렬성이 높고, 1단계가 구현이 단순하다.
  • ThinLTO 캐시: Release는 링크 단계에서 기계어를 만든다. -Wl,--thinlto-cache-dir=...로 바뀌지 않은 모듈의 결과를 재사용한다. C와 C++ 모두 해당한다.

우리 구조(인터페이스는 선언만, 구현은 본문)에 적용하면 다음과 같다.

변경 다시 컴파일할 것
함수 본문만 수정 그 파일의 구현 TU 하나
비공개 함수 추가/수정 그 파일의 구현 TU 하나
공개 함수 시그니처 수정 그 파일의 인터페이스 + 그 모듈을 직접 import하는 구현 TU들
바뀌지 않은 파일 없음(생성 코드 해시가 같으면 건너뜀)

3. 제네릭과 템플릿

3.1 인스턴스화 전략 (제네릭 컨테이너, 메서드 30개, 타입 10개, 100개 TU가 모두 사용)

방식 Release Debug
(a) 클래스 밖 정의, 암묵 인스턴스화(일반 C++) 5.12s (컴파일 2.23) 0.86s
(e) 클래스 안 정의, 암묵 인스턴스화 5.17s (컴파일 2.24) 0.84s
(d) 클래스 안 정의 + extern template 5.26s (컴파일 2.30) 0.66s
(b) 클래스 밖 정의 + 한 TU 명시 인스턴스화 + extern template 4.45s (컴파일 0.54) 0.64s
(c) 직접 단형화(템플릿 없이 타입별 함수, C 방식 기준선) 4.27s (컴파일 0.54) 0.61s
  • 일반 C++은 TU마다 같은 인스턴스를 만들고 링커가 중복을 버린다. 컴파일 작업이 TU 수만큼 반복된다.
  • ssc는 TS checker로 프로그램 전체의 인스턴스 집합을 안다. 그래서 제네릭을 정의한 모듈의 구현 TU에서 명시 인스턴스화하고, 사용하는 쪽에는 extern template만 내면 직접 단형화와 같은 효율이 된다.
  • 조건: 메서드 본문은 클래스 밖(최상위)에 정의한다. 클래스 안에서 정의하면 자동으로 inline이 되어, 최적화 빌드에서 clang이 인라인하려고 TU마다 다시 인스턴스화한다((d)의 Release 결과).

3.2 다른 언어의 제네릭 컴파일

언어 방식
C++ (일반) TU마다 암묵 인스턴스화, 링커가 중복 제거
Rust 크레이트 단위 단형화, share-generics로 재사용
Go GC shape별로만 인스턴스화 + 딕셔너리
Swift 제네릭 코드 하나 + 필요한 곳만 특수화
syscript (제안) 프로그램 전체를 보고 인스턴스마다 한 번만 생성

4. 모듈 격리 (C++20 named module)

판단 기준은 "JS 모듈이 허용하는 동작을 그대로 표현할 수 있는가"다. JS에서도 안 되는 것은 문제로 보지 않는다.

4.1 실험

# 내용 결과
E1 두 모듈의 같은 이름 비공개 함수 helper ✅ 충돌 없음. 심볼 _ZW1a6helperi / _ZW1b6helperi
E5 두 모듈의 같은 이름 전역 변수 ✅ _ZW1a5total / _ZW1b5total
E2 같은 이름 공개 함수를 가진 두 모듈을 import만 함 ✅ 에러 없음
E2 두 모듈을 import하고 한정자 없이 run 사용 에러. JS에서 같은 이름을 두 번 가져오는 것과 같음
E2c 모듈 + export namespace m_a로 m_a::run, m_b::run ✅ JS의 as 별칭에 대응
E3 전역 모듈 영역과 모듈 본문의 매크로 ✅ import한 쪽으로 새지 않음
E6 export extern "C" ✅ 맹글링 없는 심볼로 C에서 호출 가능
E7a 전역 모듈 영역에 C 헤더 include ✅ 사용 가능, 매크로 비노출
E7b restrict 같은 C 전용 문법 헤더 ❌ C++ TU에서 컴파일 에러. C++ 백엔드면 연동 대상이 C++이므로 지시문을 C++ 통합으로 바꾸면 된다
E4 모듈끼리 또는 파티션끼리 순환 import ❌ 서로의 BMI가 먼저 필요해서 빌드 불가
W1 순환 함수 호출: 인터페이스(선언만)와 구현을 분리, 구현에서만 import ✅ C의 전방 선언과 같은 방식
W2 순환 클래스 참조: 한 모듈 + 파일마다 파티션 + 서로 전방 선언 ✅ C의 구조체 전방 선언과 같은 방식
E8 모듈 단위 컴파일 시간 ✅ 일반 TU 149.2ms, 모듈 148.9ms. BMI만 약 60ms

4.2 정리

  • 지금 C에서 직접 하는 모듈 맹글링, 전역 이름 격리, 매크로 누출 방지는 모듈 + namespace가 컴파일러 차원에서 대신한다.
  • 순환 import는 C와 같은 문제이고, 같은 방식(선언과 정의 분리)으로 푼다. 차이는 C++ 모듈에서는 다른 모듈의 이름을 그 모듈 밖에서 선언할 수 없어서, 선언을 인터페이스 유닛 한 곳에 모아야 한다는 점이다.
  • 생성 구조: 소스 파일마다 인터페이스 유닛(공개 선언만, import 없음)과 구현 유닛(본문, 다른 모듈 import)을 둔다. 빌드는 인터페이스 BMI → 구현 2단계다.
  • C++ 키워드가 C보다 많다(template, typename, namespace, operator, friend, virtual, explicit, mutable, concept, requires 등). 식별자 충돌 처리는 계속 필요하다.

4.3 이름 맹글링 비판에 대해

  • "맹글링이 형편없다"는 불만은 읽기 어려움, 다른 언어에서 호출하기 어려움, 컴파일러마다 다름, 템플릿 이름이 길어짐에 대한 것이다.
  • clang만 쓰고 매번 전체를 생성하는 우리에게는 영향이 작다. 사용자에게 내보내는 함수만 extern "C"로 두면 된다.
  • 우리의 ssc__module_<id>_fn__name 규칙도 사실상 직접 만든 맹글링이다.

5. 라이브러리 통합 (ssc:include를 C++로)

5.1 생태계

  • 고수준 기능(자료구조, 문자열·유니코드, 숫자 변환, 정규식, JSON, 병렬 처리)은 C++이 압도적이다.
    • 예: simdutf, fast_float, dragonbox, ryu, re2, abseil, fmt.
  • 시스템 라이브러리(OS API, zlib, OpenSSL, SQLite, curl, libuv, SDL)는 C ABI 중심이고, C++에서도 그대로 쓴다.
  • 그래서 사용 가능한 범위로 보면 C++ 생태계는 C를 포함한다.
  • 약점은 패키지 관리다. 공식 표준이 없고(vcpkg, Conan 등), 빌드 방식도 제각각이다. 런타임에 넣을 라이브러리는 직접 골라 소스째 가져오는 쪽이 현실적이다.

5.2 .d.ts로 표현해야 하는 C++ 개념

C++ 개념 .d.ts 난이도
자유 함수, namespace 지금 C와 같음, declare namespace 쉬움
오버로드 TS 오버로드 시그니처, 선택은 C++ 컴파일러에 맡김 쉬움
클래스, 메서드, 생성자 declare class 중간
템플릿 클래스 TS 제네릭 중간
참조 인자, 연산자 오버로드, std::string 규칙과 변환 필요 중간

메모리 모델과 맞출 것:

  • 소멸자가 있는 C++ 객체는 아레나에 finalizer(해제할 때 실행할 소멸자 목록)로 등록한다. V8 Zone, protobuf Arena와 같은 방식이다.
  • 라이브러리 내부 할당은 막지 않는다(C 라이브러리와 같다).
  • 라이브러리 예외는 예외를 켠 얇은 shim에서 try/catch로 감싸 우리 에러로 바꾼다.

선례: Nim importcpp 패턴({.importcpp: "#.push_back(@)".}), Swift(clang 내장), D extern(C++), Rust cxx/bindgen.

5.3 이름 충돌

충돌 C 백엔드 C++ 백엔드
우리 생성 코드 ↔ 라이브러리 직접 맹글링 namespace + 모듈로 해결
라이브러리 매크로가 다른 모듈로 퍼짐 TU 안에서 보임 헤더를 쓰는 모듈 안에만 머묾
C++ 라이브러리끼리 C++ 라이브러리 사용 불가 namespace로 대부분 해결
한 TU 안 두 헤더의 선언 충돌 컴파일 에러 같음. 라이브러리별 래퍼 모듈로 분리 가능
C 라이브러리끼리 같은 전역 심볼 링크 충돌 같음
  • 바이너리 심볼 이름 바꾸기(objcopy --redefine-syms)는 dlsym, 어셈블리 참조, weak/별칭/버전 심볼 때문에 호환을 보장하지 못해 채택하지 않는다.
  • 라이브러리를 고치지 않고 Linux에서 같은 이름의 전역 심볼을 살리는 방법은 런타임 격리(dlopen(RTLD_LOCAL), dlmopen)뿐이다. LTO와 인라인이 막힌다.
  • macOS의 2단계 이름 공간과 Windows DLL 가져오기 표는 이 문제가 없다. ELF 정적 링크에는 이런 장치가 없다.

5.4 C++20 모듈 라이브러리끼리의 충돌 (실험)

  • 이름 있는 모듈에 속한 선언은 심볼에 모듈 이름이 붙는다(strong ownership, Itanium ABI 확장, clang과 GCC 구현).
경우 결과
libA, libB가 똑같이 util::hash와 전역 state를 가짐 심볼 util::hash@libA, util::hash@libB, state@libA, state@libB로 분리
모듈 x는 libA를, 모듈 y는 libB를 사용(인터페이스/구현 분리) ✅ 둘 다 링크되고 각자 올바른 함수 호출(-O1, -O1 -flto=thin)
x, y의 인터페이스에 함수 본문이 있음 main이 x, y만 import해도 libA, libB 선언이 끌려와 에러
한 TU에서 libA와 libB를 import하고 util::hash 사용 에러(다른 모듈에 붙은 같은 선언). JS에서 같은 이름을 두 번 가져오는 것과 같음

6. 숨은 할당

원칙: 명시하지 않은 할당이 생성 코드와 런타임에 있어서는 안 된다.

6.1 숨은 할당이 생기는 곳 (오브젝트의 외부 참조 심볼로 확인)

기능 할당
람다(캡처 포함), RAII 소멸자, 클래스/상속/가상 함수 없음
optional, variant, array, span, string_view, sort 없음
throw 있음(__cxa_allocate_exception)
코루틴(프레임이 밖으로 새는 경우) 있음(operator new). 새지 않으면 LLVM CoroElide가 없앰
vector, string 등 컨테이너 있음(operator new)
stable_sort 있음(operator new(nothrow), 임시 버퍼)

언어 자체의 숨은 할당은 예외와 코루틴 두 곳이고, 나머지는 표준 라이브러리다.

6.2 막는 방법

대상 방법 결과
예외 -fno-exceptions throw가 컴파일 에러 ✅
코루틴 프레임 promise 타입에 operator new 정의 → 아레나 할당 외부 참조가 _Znwm → ssc_arena_alloc ✅
표준 라이브러리 할당 빌드 후 오브젝트 심볼 검사(_Znwm, _Znam, malloc, calloc, realloc, __cxa_allocate_exception 등이 있으면 실패). path-leak 검사와 같은 방식 ✅
operator new에 [[gnu::error]] 컴파일 에러로 막으려 함 ❌ 컴파일러가 미리 선언하는 함수라 속성을 붙일 수 없음

사용자 코드는 lower를 거치므로 숨은 할당을 직접 만들 수 없다. 위험은 우리가 작성하는 코드(생성 형태, 런타임)에만 있고, 빌드 검사가 잡는다.

6.3 컨테이너와 아레나 통합

코드 외부 참조(할당 관련)
기본 std::vector<int> _Znwm, _ZdlPvm
std::vector<int, ArenaAllocator<int>> ssc_arena_alloc만
std::pmr::vector<std::pmr::string> + 아레나 리소스 할당 함수 참조 없음(가상 호출로 위임)
  • 할당자 템플릿 인자: 호출이 정적으로 결정돼 인라인이 잘 된다.
  • std::pmr: 요소(pmr::string)까지 같은 아레나를 자동으로 쓴다. 아레나가 달라도 타입이 같아 인스턴스가 늘지 않는다. 할당마다 가상 호출 하나.
  • 직접 만든 컨테이너(LLVM SmallVector, V8 ZoneVector 방식): 마지막 할당이면 제자리 확장, 인라인 저장 등 아레나에 맞출 수 있다.
  • 주의할 점
    • 늘어날 때 옛 공간이 아레나에 남는다(두 배씩 늘리면 최악의 경우 약 2배 사용).
    • 소멸자 없는 요소 타입이면 소멸자를 부르지 않아도 된다.
    • 참조 요소의 write barrier는 요소 타입(우리 참조 래퍼)의 대입 연산자에 넣는다.
  • 추천
    • 런타임 내부에는 std::pmr를 쓴다.
    • JS Array처럼 사용자에게 보이는 타입은 직접 만든 컨테이너로 한다(8바이트 참조, 제자리 확장, barrier).

6.4 클래스와 메모리 위치

C++ 클래스는 힙을 전제로 하지 않는다. 스택, 정적 영역, 아레나 메모리 위(placement new, 또는 C++20 implicit-lifetime 규칙) 어디에나 둘 수 있다. 탈출하지 않는 값은 스택에, 탈출하는 값은 아레나에 두는 원래 계획이 그대로 된다.

7. 표준 라이브러리와 링크

  • libc(glibc)는 지금처럼 동적 링크한다.
  • libc++(C++ 표준 라이브러리)는 정적 링크로 바이너리에 넣는다(brew LLVM에 libc++.a, libc++abi.a).
  • 그래서 실행에 필요한 것은 지금과 같이 libc 하나다.
  • 완전한 정적 바이너리는 백엔드와 무관하게 musl을 쓸지의 문제다. glibc 정적 라이브러리(libc.a)는 이 시스템에 없고, glibc는 NSS/dlopen 때문에 정적 링크와 잘 맞지 않는다.
  • libc++가 제공하는 것
    • 파일 I/O(<fstream>, <print>), <filesystem>, 스레드/원자 연산, <chrono>
  • libc++가 제공하지 않는 것
    • 네트워크(Networking TS 미채택, C++26에도 없음), 프로세스 실행, 비동기 I/O
    • 대안: POSIX 소켓, asio, libuv

8. C++ 표준 버전과 clang 지원

  • 최신 릴리스는 C++26이다. WG21이 2026년 3월 런던 회의에서 기술적으로 확정했고, ISO 발행은 진행 중(DIS)이다.
  • 마지막으로 ISO 발행까지 끝난 버전은 C++23(ISO/IEC 14882:2024)이다.
  • clang은 -std=c++2c, __cplusplus = 202400L.
C++26 기능 로컬 clang 23
팩 인덱싱 T...[0] ✅
#embed ✅ (공식 현황 페이지 요약과 다름. 직접 확인 필요)
이름 없는 변수 _ ✅
static_assert 사용자 메시지 ✅
= delete("이유") ✅
constexpr placement new ✅ (libc++ 헤더 필요)
구조적 바인딩 팩 ✅
정적 리플렉션 ^^ ⚠️ 구현 중. -Xclang -freflection으로 파싱은 되나 상수 평가 미지원
contracts ❌

전환한다면 -std=c++2c로 두고 확인된 기능만 쓴다.

9. LLVM 최적화

모든 C++ 기능이 LLVM IR로 내려가서 최적화 대상이 된다.

분류 기능 최적화
컴파일 타임에 사라짐 템플릿, constexpr/consteval, 팩 인덱싱, 구조적 바인딩 런타임 비용 없음
평범한 코드가 됨 클래스, 멤버 함수, 연산자 오버로딩, 참조, 람다, RAII C와 같은 수준으로 인라인·최적화
제한될 수 있음 가상 함수 탈가상화는 LTO + -fwhole-program-vtables
std::function 타입 소거로 인라인 어려움
예외 정상 경로 비용 0, 일부 최적화가 보수적
코루틴 CoroSplit/CoroElide 패스, 새는 프레임은 아레나 할당으로
RTTI 끄거나 우리 타입 id로 대체

10. 전환했을 때 얼마나 단순해지나

10.1 이미 구현된 기능

기능 C++에서
모듈 격리, 이름 맹글링 모듈 + namespace. 접두사가 사라짐
정수 연산, 넘침 검사 템플릿 하나, 타입 접미사 제거
숫자 생성자 템플릿 또는 inline 함수
char enum class 같은 별도 타입 필요(오버로딩에서 u32와 구분)
union any::of<T>, is<T>(), get<T>(). prelude 매크로가 크게 줄어듦
문자열 같은 메모리 배치에 연산자. lower는 a == b 그대로 생성
문자열 리터럴 문자열을 템플릿 인자로 받는 inline 변수 템플릿 → 링커가 프로그램 전체 중복 제거(TU 간 병합 todo 해결)
TDZ 검사가 들어간 래퍼 타입
모듈 초기화 순서 순환 import 때문에 지금처럼 명시 초기화 함수 유지
아레나 할당기 그대로 사용
빌드 인터페이스 BMI → 구현 2단계 빌드, 묶음 컴파일. 새로 작업 필요

바뀌는 범위는 lower, c-builder, prelude, 빌드 매니저다. TS 포크, 파서, 동작 테스트는 그대로 쓴다. snapshot 테스트만 다시 만든다.

10.2 앞으로 할 기능

기능 C C++
아레나 스코프(함수, 반복문, using) 모든 탈출 경로에 정리 코드 생성 RAII 가드
아레나 간 RC + write barrier 모든 포인터 저장에 barrier 호출 생성 참조 타입 대입 연산자에 barrier
클로저, 콜백 환경 구조체 + 함수 포인터 생성 람다(캡처 메모리는 아레나용 클로저 타입 필요)
제네릭 타입별 인스턴스 생성 템플릿(3장의 인스턴스화 제어)
클래스, 메서드, 상속 구조체 + vtable 직접 생성 C++ 클래스
예외 반환값 전파 + 정리 코드 C++ 예외 + RAII(단, 숨은 할당과 런타임 의존성 검토)
for..of, 제너레이터 상태 기계 변환 직접 구현 C++20 코루틴, std::generator
async/await 상태 기계 + 이벤트 루프 코루틴 + 이벤트 루프(libuv 등)
Map, Set 직접 구현 abseil, std::pmr
문자열 연산 직접 구현 simdutf, fast_float, dragonbox(JS Number → 문자열의 최단 표현)

11. 다른 생성 대상 후보

대상 장점 걸리는 점
LLVM IR 직접 최대 통제, 프런트엔드 비용 0 C ABI 호출 규약, 디버그 정보 등 전부 직접 구현
Zig 숨은 할당 없음, defer, comptime, C 헤더 직접 가져오기 1.0 전 불안정, C++ 생태계 사용 불가, 클로저 없음, 프로그램 전체가 한 컴파일 단위
D (-betterC, LDC) 템플릿, 모듈, 람다, scope(exit), C++ 일부 연동 생태계 작음
C3 모듈, 제네릭 모듈, defer 매우 젊음
Rust 기능 풍부, 생태계 큼 생성 코드가 대부분 unsafe, 컴파일 느림
MLIR 다단계 IR 언어 하나에 과한 인프라

생태계를 원하면 C++ 외 대안이 없다. 철학(숨은 비용 없음)만 보면 Zig가 가장 가깝다.

12. 전환한다면 지킬 원칙

  1. 생성 TU에는 std 헤더를 텍스트로 include하지 않는다. 필요하면 import std 또는 모듈 캐시를 쓴다.
  2. 무거운 템플릿과 std 사용은 런타임에 두고, 생성 코드는 런타임의 구체 함수를 호출한다.
  3. 사용자 제네릭은 템플릿으로 생성하되, 인스턴스화는 정의한 모듈의 구현 TU에서 명시적으로 하고 사용하는 쪽은 extern template. 메서드 본문은 클래스 밖에 정의한다.
  4. 소스 파일마다 인터페이스 유닛(공개 선언만, import 없음)과 구현 유닛으로 나눈다. 순환 import와 라이브러리 충돌 격리가 이 구조로 해결된다.
  5. 인터페이스는 묶어서 컴파일하고, 구현은 병렬로 컴파일한다.
  6. 호출은 완전히 한정(::ssc::f(...))해서 ADL 비용을 뺀다. 전방 선언은 필요한 것만 낸다.
  7. -fno-exceptions, 코루틴 프레임은 아레나 할당, 빌드 후 할당 심볼 검사로 숨은 할당을 막는다.
  8. libc++는 정적 링크한다.
  9. 증분 빌드: 생성 코드 해시로 바뀐 TU만 재컴파일하고, 인터페이스 BMI가 바뀌었을 때만 import하는 쪽을 재컴파일하고, ThinLTO 캐시를 켠다.

13. 재구현 순서 (전환 시)

  1. prelude를 C++ 모듈로 옮기고 2단계 빌드를 구성한다.
  2. 이름 규칙(모듈 + namespace)을 바꾼다.
  3. 숫자, 생성자를 옮긴다.
  4. union을 옮긴다.
  5. 문자열과 리터럴을 옮긴다.
  6. TDZ를 옮긴다.

각 단계는 기존 동작 테스트가 그대로 통과하는지로 확인한다.

14. 남은 판단

  • 숨은 동작(예외, 코루틴, std 할당)을 규칙과 빌드 검사로 막는 방식이 충분히 신뢰할 만한가.
  • 예외를 쓸지(RAII + 예외), 반환값 기반 에러로 갈지. 아레나 스코프 설계 때 같이 정한다.
  • Debug 빌드에서 C++ 프런트엔드 비용(같은 코드 +23~34%)을 받아들일 수 있는가.

15. 실험 재현

실험 파일은 세션 scratchpad에 있었고, 영구 보관하지 않는다. 재현 방법은 다음과 같다.

  • 같은 소스 비교: lower가 생성한 C에서 uwb/wb 접미사를 ull/ll로 바꾸고 clang -std=gnu23 / clang -x c++ -std=gnu++23로 각각 컴파일한다.
  • 대형 프로젝트: 대표 프로그램(정수 반복문, char, union, 문자열 비교, 생성자)의 생성 C와 대응 C++을 블록으로 복제해 파일 200개 × 블록 500개를 만든다. 32개 스레드로 병렬 빌드한다.
  • 제네릭: 메서드 30개짜리 템플릿 컨테이너를 타입 10개, TU 100개에서 사용한다. (a)~(e) 변형을 빌드한다.
  • 모듈 격리: --precompile로 BMI를 만들고 -fmodule-file= 또는 -fprebuilt-module-path=로 import한다. nm으로 심볼을 확인한다.
  • 숨은 할당: 기능별 작은 TU를 -O1로 컴파일하고 nm -u로 할당 함수 참조를 확인한다.