DevOps CI/CD Docker GitHub Actions

C++/CUDA 빌드 측정 — '빠르다'와 '통과했다'를 증거로 바꾸는 법

빌드를 다 켜서 빨라져도 무엇이 효과였는지 모르고, CI가 통과해도 GPU에서 도는지는 알 수 없습니다. 모호한 지시를 직교 축으로 나누고 축마다 증거를 설계하는 방법을 C++/CUDA 빌드 속도와 GPU 정합성 두 사례로 풉니다.

빌드가 느려서 ccache·Ninja·mold·unity build·PCH를 한꺼번에 켭니다. 4분이 1분으로 줄어듭니다. “3배 빨라졌네” 하고 넘어갑니다 — 그런데 어느 PR에서 그 1분이 다시 4분이 됩니다. 무엇이 효과였고 무엇이 우연이었는지 모르기 때문입니다.

CUDA 빌드도 비슷합니다. CI가 초록불을 띄웁니다. 배포하면 GPU 노드에서 커널이 엉뚱한 값을 냅니다 — CI는 커널이 컴파일된다까지만 봤지, GPU에서 올바르게 도는지는 본 적이 없습니다.

두 상황의 공통점은 하나입니다. 보고 있는 숫자(빨라진 시간)나 초록불(통과)이 정작 알고 싶은 것을 증명하지 않는다는 것. 이 글은 그 간극을 메우는 한 가지 방법을 두 사례로 풉니다 — 모호한 지시를 직교하는 축으로 나누고, 축마다 무엇을 측정·검증할지 정하고, 무엇을 증명하지 못했는지까지 적습니다.

빌드 속도가 첫 번째 사례, GPU 정합성이 두 번째입니다. 같은 방법이 두 번 반복됩니다.


지시 ① — “빌드를 빠르게 해라”

빌드가 느리다고 하면 처방이 한꺼번에 쏟아집니다 — 멀티스테이지로 나눠라, 레이어 캐시를 써라, ccache를 붙여라, 링커를 mold로 바꿔라, 이미지를 슬림하게 만들어라. 이걸 다 켜고 “N배 빨라졌다”고 적으면 깔끔해 보이지만, 그 숫자는 당신 빌드에 무엇이 듣는지 알려주지 않습니다. “빠르게 해라”는 한 지시 안에 사실 서로 다른 문제가 섞여 있기 때문입니다.

직교 분해 — 한 문제가 아니라 세 층

먼저 “느린 빌드”라는 한 덩어리를 쪼갭니다. 처방들이 서로 다른 문제를 풀기 때문입니다.

무엇이 느린가처방
A. 이미지 레이어매 빌드가 apt·CUDA 의존성 설치를 다시 실행레이어 순서 · multi-stage · 캐시 마운트
B. 컴파일소스 → 오브젝트 재컴파일 + 링크ccache · Ninja · mold · unity build · PCH
C. 이미지 크기빌드 툴체인이 최종 이미지에 그대로 잔류cuda:develcuda:runtime 분리

A는 “명령을 다시 실행하지 않기”, B는 “컴파일 자체를 빠르게”, C는 “결과물을 가볍게”입니다. 서로 보완할 뿐, 하나가 나머지를 대체하지 않습니다. 한 처방으로 다 풀린다고 믿는 순간 — 예를 들어 multi-stage로 빌드 단계만 나누고 산출물을 가벼운 런타임 이미지로 옮기는 COPY --from=build를 빠뜨리면 — 빌드는 빨라졌는데 최종 이미지는 여전히 수 GB입니다. multi-stage는 속도(B)와 크기(C) 두 축에 걸쳐 있는데 한쪽만 손댄 겁니다.

Layer A·C — 측정이 아니라 구조 선택

A와 C는 “재서 고르는” 게 아니라 “Dockerfile을 어떻게 쓰느냐”의 문제입니다.

Layer A. Docker는 레이어 단위로 캐시하고, 어떤 레이어의 입력이 바뀌면 그 아래 전부가 다시 빌드됩니다. 그래서 흔한 안티패턴이 이겁니다.

COPY . . # 소스 전체를 먼저 복사
RUN apt-get install ... # 그다음 의존성 설치

소스 한 줄만 바꿔도 COPY . .가 무효화되고 그 아래 apt-get install까지 다시 돕니다. CUDA 의존성 설치는 수십 초에서 분 단위인데, 빌드 로직과 무관한 한 줄 수정에 매번 따라붙습니다. 처방은 변하는 것을 변하지 않는 것 뒤에 두는 것입니다.

COPY apt-packages.txt . # 거의 안 바뀌는 의존성 목록을 먼저
RUN apt-get install $(cat ...) # → 소스가 바뀌어도 이 레이어는 캐시 적중
COPY . . # 자주 바뀌는 소스는 마지막

여기에 BuildKit 캐시 마운트(RUN --mount=type=cache)를 더하면 apt 캐시가 빌드 사이에 살아남습니다.

Layer C. nvidia/cuda:*-devel 이미지에는 nvcc·CUDA SDK·컴파일러가 들어 있습니다 — 빌드엔 필요하지만 실행엔 필요 없는 수 GB의 툴체인입니다. multi-stage로 *-devel에서 컴파일하고 산출 바이너리만 *-runtime으로 옮깁니다.

FROM nvidia/cuda:12.2.2-devel-ubuntu22.04 AS build
# ... 컴파일 ...
FROM nvidia/cuda:12.2.2-runtime-ubuntu22.04
COPY --from=build /app/cbor /app/cbor # 산출물만 가져온다

A와 C는 측정 대상이 아니라 구조 선택이라, 아래의 측정은 컴파일(B) 에 집중합니다.

Layer B — 컴파일: 두 단계 × 두 상황

C++엔 Maven·Gradle처럼 빌드를 통째로 맡는 한 도구가 없습니다. 빌드는 두 단계로 나뉘고, 단계마다 다른 도구가 붙습니다.

  • 컴파일 — 소스 파일(.cpp)을 하나씩 독립적으로 오브젝트 파일(.o)로 바꿉니다. 파일마다 따로라 코어 수만큼 병렬로 돕니다.
  • 링크 — 링커가 모든 .o를 모아 실행파일 하나로 합칩니다. 전부를 한꺼번에 봐야 해서 보통 직렬입니다 — 코어를 늘려도 빨라지지 않습니다.

빌드 상황도 둘로 나뉩니다 — 깨끗한 상태에서 전부 컴파일하는 전체 빌드(CI가 매번 새 컨테이너로 시작), 파일 몇 개만 고쳐 그것만 다시 컴파일하는 증분 빌드(로컬 코딩). 기법들을 이 위에 얹으면 정리됩니다.

기법손대는 단계하는 일
ccache컴파일전에 만든 .o를 재사용 — 같은 소스를 다시 컴파일하지 않음
PCH컴파일공통 헤더(<vector> 등)를 미리 한 번만 컴파일
unity build컴파일여러 .cpp를 묶어 컴파일 호출 수 자체를 줄임
Ninja컴파일 조율컴파일러를 효율적으로 병렬 실행 (make의 대안)
mold링크빠른 링커 (ld의 대안)
split-DWARF링크디버그 정보를 .o에서 빼내 링커가 처리할 양을 줄임

축별 증거 설계 — 격리하고, 두 기준점으로 재고, 노이즈를 판정한다

기법 목록을 다 켜고 “N배”를 외치는 대신, 각 기법이 언제 무엇을 줄이는지를 증거로 만듭니다. 의도적으로 무겁게 만든 C++/CUDA 프로젝트(컴파일 단위 96개)에 기법을 분리해서 쟀습니다.

측정 구간 격리 — cmake --build

docker build 전체를 재면 Layer A·C 시간이 섞여 컴파일 기법의 효과가 희석됩니다. 그래서 빌드 시간이 아니라 컴파일 구간(cmake --build)만 격리해 쟀습니다. 재려는 게 컴파일인데 측정값에 의존성 설치·이미지 복사가 섞이지 않게 하는 겁니다.

하나씩 켜 가며 재기 — 한 기준점

baseline에 기법을 하나씩 누적해 켜며 잰 표입니다. 각 셀에 측정 범위(괄호 안 lo–hi)를 같이 적습니다 — 단발값이 아니라 회차 간 흔들림까지 봐야 차이를 효과라 부를 수 있기 때문입니다.

누적해서 켠 것전체 빌드 (lo–hi)증분 빌드 (lo–hi)판정
아무것도 안 켬268.2 (266.3–269.8)269.8 (265.1–274.3)기준점
+ Ninja265.2 (261.5–266.1)272.1 (265.3–274.5)기준점과 구간 겹침 → 효과라 부를 수 없음
+ ccache274.3 (273.5–277.3)4.0 (3.9–4.1)증분에서 큰 효과 ●
+ PCH195.5 (195.1–199.2)11.1 (4.7–12.6) ◐전체에서 효과 ●
+ unity build21.6 (21.6–21.7)10.6 (6.6–10.7) ◐전체에서 가장 큰 효과 ● — 여기가 최저점
+ split-DWARF31.3 (30.8–32.0)15.7 (10.0–15.8) ◐직전보다 9.7초 느려짐(회귀)
+ mold29.5 (29.3–29.6)14.8 (9.4–15.0) ◐직전과 구간 겹침 → 효과라 부를 수 없음

단위는 초(컴파일 구간). 괄호는 측정 범위(lo–hi). ◐ = 증분 빌드 측정 범위가 중앙값의 15%를 초과해 흔들리는 셀(차이를 단정하기 어려움). “느려짐/회귀”는 직전 줄보다 시간이 늘었다는 뜻.

여기서 두 가지가 바로 보입니다.

기법마다 효과가 나타나는 구간이 다릅니다. ccache는 전체 빌드(274초)엔 효과가 없습니다 — 처음 빌드엔 캐시가 비어 있으니 당연합니다 — 대신 증분 빌드(270→4초)를 통째로 줄입니다. 반대로 unity build와 PCH는 전체 컴파일량을 줄이는 기법이라 전체 빌드(274→196→22초)에서 일하고 증분엔 거의 영향이 없습니다. “다 켜면 빨라진다”가 틀린 말은 아니지만, 내 빌드가 전체 빌드 위주냐 증분 빌드 위주냐에 따라 켜야 할 기법이 갈립니다.

그리고 “다 켜기”가 최적이 아닙니다. 전체 빌드 최저점은 unity build까지 켠 21.6초인데, split-DWARF와 mold를 더하면 29.5초로 되돌아갑니다. split-DWARF는 이 워크로드에서 9.7초를 오히려 까먹습니다. Ninja·split-DWARF·mold는 측정 범위가 앞 줄과 겹쳐 — 이 빌드에선 효과를 주장할 수 없습니다. 노이즈를 효과로 적지 않으려면, 차이가 측정 범위를 넘는지를 봐야 합니다.

하나씩 빼 가며 재기 — 두 번째 기준점

“켜 가며 재기”에는 함정이 있습니다 — 각 줄은 앞에서 켠 것들이 깔린 상태에서의 효과라, 순서에 휘둘립니다. 그래서 반대로도 쟀습니다: 다 켠 상태에서 하나씩 빼 가며.

기법켜 가며 잰 효과빼 가며 잰 효과괴리가 뜻하는 것
ccache (증분)268초 단축16초 단축unity와 충돌 — unity가 96개 파일을 7묶음으로 합쳐 ccache의 캐시 단위가 거칠어짐
PCH (전체)79초 단축0초PCH와 unity가 *같은 낭비(헤더 재파싱)*를 공격 — 한쪽이 일하면 다른 쪽은 할 일이 없음

“켜 가며” 효과는 양수=켰을 때 줄어든 시간, “빼 가며” 효과는 양수=뺐을 때 늘어난 시간입니다. 같은 기법인데 두 값이 크게 다르면, 그 기법은 다른 기법과 강하게 상호작용한다는 뜻입니다.

PCH를 “켜 가며” 재면 79초짜리 일꾼처럼 보이지만, 다 켠 상태에서 빼 보면 있으나 마나 입니다. unity build가 이미 같은 낭비(헤더 재파싱)를 없앴기 때문입니다. 한 기준점만 봤다면 PCH에 79초를 잘못 귀속시켰을 겁니다. ccache도 대칭입니다 — 켜 가며 보면 268초를 줄이지만, unity가 켜진 상태에서 빼 보면 16초에 그칩니다. 그래서 두 방향이 필요합니다. 누적 표 한 줄을 “그 기법의 단독 효과”로 읽으면, 순서가 만든 착시에 갇힙니다.

mold의 구조 의존성 — 링크가 병목일 때만

위 표에서 mold는 효과가 없었습니다. 이 프로젝트는 실행파일 하나 = 링크 1회 구조라, 빠른 링커를 넣어도 줄일 표면이 없기 때문입니다. 같은 코드를 라이브러리로 쪼개 구조를 바꿔 다시 재 봤습니다.

빌드 구조mold 끔mold 켬
실행파일 1개29.7 s28.8 s
STATIC 라이브러리 16개71.4 s73.1 s
SHARED 라이브러리 16개73.8 s72.1 s

±2초, 측정 범위 안입니다. 이 빌드는 컴파일 약 68초·링크 약 3초라(라이브러리 16개로 쪼갠 구조의 분리 측정값), 링크를 통째로 없애도 3초입니다. mold는 링크가 빌드 시간을 실제로 잡아먹는 프로젝트 — 거대한 단일 바이너리, 잦은 풀 링크 — 에서 의미가 있습니다. “mold가 효과 없다”가 아니라 “이 구조에선 효과가 없다”가 정확한 결론이고, 그 경계를 구조를 바꿔 보였습니다.

baseline과 optimized는 같은 프로젝트의 두 상태

숫자가 268초와 29.6초로 오가는데, 헷갈리지 않게 짚습니다 — 이 둘은 다른 프로젝트가 아니라 같은 96개 컴파일 단위 프로젝트의 두 상태입니다. 268초는 아무 기법도 안 켠 baseline, 29.6초는 기법을 다 켠 optimized(단일 실행파일)입니다.

빌드 캐시 — 같은 프로젝트가 러너 경계를 넘으면

매 빌드가 새 러너에서 도는 CI에선 어떤 캐시를 쓰느냐가 결과를 가릅니다. 같은 optimized 프로젝트를 두고:

시나리오컴파일 구간캐시 적중
ccache 로컬, 같은 잡에서 다시 빌드15.9 s64 %
ccache 로컬, 새 잡(새 러너)29.6 s0 %
sccache + 공유 캐시, 새 잡(새 러너)6.0 s적중

ccache는 캐시를 빌드 머신의 로컬 디스크에 둡니다. CI 러너는 잡마다 새 머신이라 잡이 끝나면 그 디스크가 사라지고, 다음 빌드의 새 러너에서 적중률 0% — baseline과 똑같은 29.6초가 나옵니다. sccache는 캐시를 원격 공유 저장소에 둬서, 직전 잡이 채운 캐시를 다음 잡의 새 러너가 그대로 읽습니다(6.0초). CI에 빌드 캐시를 적용한다는 건 곧 러너 경계를 넘는 캐시를 쓴다는 뜻입니다. 29.6→6.0은 마법이 아니라, 같은 프로젝트가 캐시를 건너 옮겨간 상태 전환입니다.


지시 ② — “빌드가 GPU에서 도는지 보증해라”

앞은 “빠르다”는 숫자였고, 이번엔 “통과했다”는 초록불입니다. 같은 방법을 한 번 더 적용합니다 — “통과”라는 한 신호가 정작 무엇을 증명하고 무엇을 증명하지 않는지부터 가릅니다.

직교 분해 — 컴파일됨 ≠ 올바르게 돎

CI는 보통 CPU 러너에서 돕니다. 컴파일러는 GPU 없이도 CUDA 커널을 컴파일하므로, 초록불은 커널이 컴파일된다는 것까지만 증명합니다 — 올바르게 도는지가 아니라. “빌드 통과”라는 한 신호 안에 사실 두 축이 섞여 있습니다: 컴파일됐는가, 그리고 GPU에서 올바른 결과를 내는가. 앞의 것이 초록불이어도 뒤의 것은 미지수입니다.

축별 증거 설계 — GPU job, CPU 기준값 대조, 두 실패의 구분

증거를 설계하면 이렇게 됩니다: 빌드한 이미지를 GPU 노드에 단발 Job으로 띄워, 커널 결과를 CPU로 계산한 기준값과 대조합니다. 이때 핵심은 실패를 한 갈래로 삼키지 않는 것입니다 — “GPU가 없음”과 “GPU는 있는데 드라이버가 깨짐”을 로그가 구분해야 합니다. 둘을 뭉뚱그리면 드라이버 불일치가 “디바이스 없음”으로 위장돼, 검증이 통과한 척하며 무력화됩니다.

# GPU job: 빌드 산출 이미지를 GPU 노드에 단발 Job으로 띄운다
count, status = cudaGetDeviceCount()
if status != success:
log("[gpu] CUDA error (not an absent device): " + status) # 드라이버 깨짐
exit graceful_skip # "디바이스 없음"과 구분
if count == 0:
log("[gpu] no CUDA device present — CPU-only run") # 디바이스 없음
exit graceful_skip
cpu_ref = run_on_cpu(inputs) # CPU 기준값
gpu_out = run_kernels_on_gpu(inputs) # 커널 실행 (vector_add, saxpy, matmul)
assert checksum(gpu_out) == checksum(cpu_ref) # 일치해야 "GPU에서 올바르게 돎"
log("[ok] CPU and GPU results agree")

홈랩 GPU 노드(RTX 3060, compute capability 8.6, CUDA 12.2)에서 이 검증을 돌리면 커널 세 개가 각각 실행되고 [ok] CPU and GPU results agree로 CPU↔GPU 결과 일치를 확인합니다. 같은 노드· 이미지에 GPU 런타임만 뺀 대조 Job은 “no usable CUDA device — CPU-only run”으로 정확히 분류돼, 디바이스 없음과 드라이버 깨짐이 로그에서 갈립니다. (CUDA 베이스 이미지 버전을 하나로 고정하는 이유이기도 합니다 — 드라이버와 런타임 버전이 어긋나면 빌드는 통과해도 런타임에 디바이스 초기화 에러가 납니다.)

GPU 정합성엔 속도 같은 ablation 표가 없습니다. 측정할 토글이 아니라 검증 설계의 문제이기 때문입니다. 그래도 방법은 같습니다 — “통과”라는 초록불이 증명하지 않는 축(컴파일됨 vs 돎)을 가르고, 각 축의 증거(컴파일 로그 vs GPU 결과 대조)를 설계합니다.


마무리 — 초록불을 증거로 바꾸기

두 사례는 같은 방법의 두 적용이었습니다. 마지막으로 그 방법의 핵심 — 정직 — 을 짚습니다.

무엇을 증명했고, 무엇을 안 했나

측정값이 빌드 설정 차이에서만 왔다고 말하려면 나머지를 전부 고정해야 합니다. 러너 종류, CUDA 이미지(12.2.2 하나), 소스, 코어 수를 고정하고 각 설정을 여러 회차 재 중앙값과 측정 범위를 함께 봤습니다. 전체 빌드를 잴 때마다 빌드 디렉터리와 캐시를 비워, 직전 회차의 따뜻한 캐시가 전체 빌드 칸으로 새어 들지 않게 했습니다. 이렇게 통제가 강한 만큼 — 한 가지는 정직하게 적어야 합니다.

이 96개 파일은 생성기로 찍어낸 합성 프로젝트입니다. 변수 통제엔 유리하지만, 표의 숫자 비율을 실제 프로젝트에 그대로 옮길 수는 없습니다. 통제는 강하되 일반화는 제한됩니다. 가져갈 것은 숫자가 아니라 규칙입니다. 그리고 효과가 측정 범위 안에서 흔들린 기법 (Ninja·split-DWARF·mold)은 “효과 없음”이 아니라 “이 워크로드·이 구조에선 효과를 단정할 수 없음”입니다 — 작다는 사실 자체가 측정 결과라, 빼지도 부풀리지도 않았습니다. 측정 설계와 데이터, 세 층을 다루는 Dockerfile은 레퍼런스 레포에 코드와 함께 정리해 두었습니다.

내 빌드 진단하기

“N배 빨라졌다”도 “빌드 통과”도, 그 숫자·초록불은 당신 빌드의 어디가 아픈지 알려주지 않습니다. 방법을 질문으로 줄이면:

  • Dockerfile의 COPY 순서 — 소스가 의존성보다 먼저면 Layer A 미적용. 매 빌드가 apt를 다시 깝니다.
  • 전체 빌드 위주냐, 증분 빌드 위주냐 — 전체면 unity build·PCH, 증분이면 ccache.
  • ccache와 unity build를 둘 다 켤 생각인가 — 둘은 상충합니다. 효과가 더해지지 않습니다.
  • CI에 빌드 캐시를 적용하나 — 적용하면 풀 빌드가 크게 줄지만(29.6→6.0초), CI 러너는 매번 새 머신이라 sccache 같은 공유 캐시라야 효과가 납니다. ccache 같은 로컬 캐시는 새 러너에서 비워집니다.
  • 링크가 빌드 시간을 지배하나 — 아니면 mold는 깔아도 체감이 없습니다.
  • 최종 이미지에 nvcc가 있나 — 있으면 Layer C 미적용. 툴체인이 런타임에 잔류합니다.
  • CUDA 코드인가 — 컴파일 통과가 GPU 동작을 보증하지 않습니다. GPU 노드에서 결과를 CPU 기준값과 대조하고, “디바이스 없음”과 “드라이버 깨짐”을 로그로 구분하세요.

초록불은 출발점이지 증거가 아닙니다. 그걸 증거로 바꾸는 건 — 축을 가르고, 각 축을 따로 재고, 무엇을 증명하지 못했는지 적는 — 그 방법입니다.

관련 콘텐츠

댓글