C 성능·Makefile·lldb: 캐시 순서 실측과 크래시 추적
C 강좌 6강. 행/열 순회 54배 차이, 구조체 배열 대 배열 구조체, 헤더 의존성을 챙기는 Makefile과 함정 세 가지, lldb의 bt·frame variable로 크래시 원인 찾기까지 실제 실행.
시리즈: C 강좌 6/6 (고급). C를 올바르게 쓸 줄 알고, 이제 빠르게 만들고, 여러 파일 프로젝트로 빌드하고, 남의 컴퓨터에서 죽었을 때 디버깅해야 하는 분을 위한 글입니다. 다 읽고 나면 같은 일을 하는 두 루프 중 어느 쪽이 빠를지 예측하고 재 볼 수 있고, 바뀐 파일만 정확히 다시 빌드하는 Makefile을 쓰고, lldb로 크래시의 호출 스택과 지역 변수를 꺼낼 수 있습니다.
학교에서 C를 Visual Studio로 배운 분은 F5 한 번에 빌드와 디버거가 같이 돌았으니, 회사에서 터미널과 Makefile, lldb(또는 gdb)를 처음 만나면 당황하기 쉽습니다. 게다가 실무 C에는 세 가지 일상적인 문제가 있습니다. 같은 합을 구하는 두 루프가 50배 차이가 나고, 프로젝트가 오래된 오브젝트 파일로 "성공적으로" 빌드되어 엉터리 값을 찍고, 크래시 보고가 Segmentation fault: 11 한 줄뿐입니다. 시리즈 마지막 강의에서 셋 다 직접 돌린 프로그램으로 다룹니다. 숫자는 실측, 에러 메시지는 복사, lldb 세션도 실제입니다.
실행 환경: Apple clang 21.0.0, GNU Make 3.81(macOS 기본), lldb-2103, Apple Silicon Mac(M1 Pro), macOS. 시간은 clock_gettime(CLOCK_MONOTONIC)으로 한 실행 안에서 5회 중 최솟값이고, 전체 실행을 네 번 반복해 마지막 값을 인용했습니다. 경로는 ~/demo/…로 줄였습니다. 전체 학습 순서는 프로그래밍 학습 로드맵에 있습니다.
메모리 순서가 중요한 이유: 캐시 라인 한 문단 요약
CPU 코어는 RAM에서 바이트 하나씩 읽지 않습니다. 캐시 라인(대부분의 x86-64는 64바이트, 이 M1 Pro는 sysctl에서 hw.cachelinesize: 128) 단위로 작고 빠른 캐시에 가져오고, 앞으로 걸어가는 패턴을 보면 다음 라인을 미리 가져옵니다(프리페치). 그래서 주소 순서대로 읽으면 첫 접근 뒤로는 거의 공짜이고, 여기저기 뛰면 매번 느린 단계까지 다녀와야 합니다. C에서는 이게 그대로 드러납니다. 2차원 배열 m[i][j]는 행 단위로 저장되므로 m[i][j]와 m[i][j+1]은 이웃이고, m[i][j]와 m[i+1][j]는 한 행만큼 떨어져 있습니다.
실측: 행 순서 대 열 순서, 54배
static int64_t sum_rows(const int *m) { /* m[i][j], j가 안쪽: 순차 접근 */
int64_t s = 0;
for (int i = 0; i < N; i++) for (int j = 0; j < N; j++) s += m[(size_t)i * N + j];
return s;
}
static int64_t sum_cols(const int *m) { /* m[i][j], i가 안쪽: 16KiB씩 점프 */
int64_t s = 0;
for (int j = 0; j < N; j++) for (int i = 0; i < N; i++) s += m[(size_t)i * N + j];
return s;
}
N = 4096이면 행렬은 int 1,680만 개, 64MiB로 칩의 어떤 캐시보다 큽니다. 두 함수는 같은 수를 다른 순서로 더합니다.
rows (i outer) 1.13 ms (result 8583959407)
columns (j outer) 61.58 ms (result 8583959407)
결과는 같고 시간은 54배. 행 루프는 64MiB를 대략 59GB/s로 흘려 읽고, clang이 벡터화(명령 하나로 int 여러 개를 더함)까지 했습니다. 열 루프는 한 번에 4096 × 4 = 16KiB씩 건너뛰니 캐시 라인 하나에서 쓸모 있는 int는 하나뿐이고, 이웃이 필요해지기 전에 라인이 밀려납니다. 네 번의 전체 실행에서 행은 1.13~1.48ms, 열은 60.5~63.0ms 사이였습니다.
흔한 실수: 수식에 "각 열 j에 대해, 각 행 i에 대해"라고 쓰여 있는 걸 그대로 이중 루프로 옮기기. 열 우선으로 저장하는 Fortran이나 MATLAB 코드를 옮길 때 특히 자주 생깁니다. 실제로 일어나는 일: 결과는 맞는데 조용히 50배 느립니다. 해결: 마지막 인덱스를 가장 안쪽 루프로. 열 순회가 핫 패스라면 데이터를 한 번 전치해 둡니다.
구조체 배열(AoS) 대 배열 구조체(SoA)
레코드에도 같은 생각이 적용됩니다. 입자 1,000만 개를 두 가지로 저장합니다.
struct Particle { double x, y, z, vx, vy, vz, mass; int64_t id; }; /* 64바이트 */
struct Particles { double *x, *y, *z, *vx, *vy, *vz, *mass; int64_t *id; };
첫째는 구조체의 배열(Array of Structs, AoS)로 p[i].x 뒤에 p[i].y, 그다음 입자 i의 나머지가 이어집니다. 둘째는 배열의 구조체(Struct of Arrays, SoA)로 x 값들이 연속으로 붙어 있습니다. x만 합산하면:
AoS sum of x 10.87 ms (result 4995000000)
SoA sum of x 9.35 ms (result 4995000000)
AoS sum of x, 4 acc 10.67 ms (result 4995000000)
SoA sum of x, 4 acc 2.35 ms (result 4995000000)
처음 두 줄이 솔직한 의외의 결과입니다. SoA가 겨우 14% 빨랐습니다. 단순한 두 루프 모두 메모리가 아닌 다른 것에 막혀 있었습니다. double 합 s += …는 사슬이라 덧셈마다 앞 덧셈이 끝나기를 기다려야 하고(부동소수점 덧셈은 몇 사이클 걸림), 부동소수점 덧셈은 결합 법칙이 성립하지 않아 순서를 바꾸면 결과가 달라질 수 있으니 컴파일러가 마음대로 재배치하지 못합니다. 그래서 두 루프 모두 "덧셈 지연 시간당 덧셈 하나" 속도로 돌았습니다.
사슬을 독립된 합 네 개(s0 += x[i]; s1 += x[i+1]; …)로 쪼개면 그 제한이 풀리고 이제 메모리가 속도를 정합니다. SoA는 2.35ms로 떨어졌고, AoS는 쓰는 8바이트마다 64바이트짜리 입자 전체를 캐시로 끌고 오기 때문에(한 바퀴에 640MB 대 80MB) 10.67ms에 머물렀습니다. 데이터 배치는 루프가 메모리를 기다릴 만큼 빨라진 다음에야 효과가 난다. 코드베이스를 SoA로 뜯어고치기 전에 먼저 재 보세요. (여기서는 값이 정수라 네 갈래 합도 결과가 완전히 같습니다. 임의의 double이면 마지막 비트가 다를 수 있습니다.)
측정 프로그램 전체(cache.c, 파일 하나)는 영어판에 있습니다. 전체 할당이 약 1.3GB이니 메모리가 작은 기계에서는 P를 줄이세요.
바뀐 파일만 다시 빌드하는 Makefile
.c 파일이 여러 개가 되면 매번 손으로 전부 컴파일하는 건 느리고 실수하기 쉽습니다. make는 Makefile의 규칙("타깃: 선행 조건" 다음 줄에 타깃을 만드는 명령, 즉 레시피)을 읽고, 선행 조건이 더 새로울 때만 타깃을 다시 만듭니다. 파일 세 개짜리 프로젝트(main.c, stats.c, parse.c, 그리고 stats.h):
CC = clang
CFLAGS = -std=c17 -Wall -Wextra -O2 -g -MMD -MP
OBJS = main.o stats.o parse.o
app: $(OBJS)
$(CC) $(CFLAGS) -o $@ $(OBJS)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
-include $(OBJS:.o=.d)
.PHONY: clean
clean:
rm -f app $(OBJS) $(OBJS:.o=.d)
$@는 타깃, $<는 첫 선행 조건, %.o: %.c는 모든 오브젝트에 대한 패턴 규칙입니다. 핵심은 -MMD -MP입니다. clang이 컴파일하면서 main.c가 포함한 헤더 목록을 main.d에 따로 써 주고, -include가 그 목록을 Makefile로 끌어옵니다. 만들어진 파일:
main.o: \
…/MacOSX.sdk/SDKSettings.json \
main.c stats.h
…/MacOSX.sdk/SDKSettings.json:
stats.h:
(SDK의 SDKSettings.json이 의존성에 들어가는 건 Apple clang만의 특징입니다. Xcode를 업데이트하면 다시 빌드된다는 뜻이고, 그게 맞는 동작입니다.) 첫 빌드, 아무것도 안 바뀐 빌드, 파일 하나 수정 후 빌드:
$ make
clang -std=c17 -Wall -Wextra -O2 -g -MMD -MP -c main.c -o main.o
clang -std=c17 -Wall -Wextra -O2 -g -MMD -MP -c stats.c -o stats.o
clang -std=c17 -Wall -Wextra -O2 -g -MMD -MP -c parse.c -o parse.o
clang -std=c17 -Wall -Wextra -O2 -g -MMD -MP -o app main.o stats.o parse.o
$ ./app
count=3 mean=3.000
$ make
make: `app' is up to date.
$ touch parse.c && make
clang -std=c17 -Wall -Wextra -O2 -g -MMD -MP -c parse.c -o parse.o
clang -std=c17 -Wall -Wextra -O2 -g -MMD -MP -o app main.o stats.o parse.o
parse.o와 링크만 다시 돌았습니다. make -j4는 서로 독립인 컴파일을 병렬로 돌립니다.
Makefile 함정 세 가지, 직접 재현
함정 1: TAB 대신 공백. 레시피 줄은 진짜 TAB으로 시작해야 합니다. 편집기가 공백을 넣는 경우가 많습니다. GNU Make 3.81의 메시지:
Makefile.spaces:6: *** missing separator. Stop.
6번째 줄이 첫 레시피 줄입니다. cat -t Makefile로 보면 진짜 탭은 ^I로 보이고, 공백이 보이면 그게 버그입니다. VS Code 같은 편집기에서 Makefile은 탭을 유지하도록 설정하세요.
함정 2: 헤더 의존성 없음. 아무것도 실패하지 않아서 가장 위험합니다. 같은 Makefile에서 -MMD -MP와 -include를 뺀 버전으로 빌드한 뒤, stats.h의 구조체 맨 앞에 필드를 하나 추가합니다.
struct stats { const char *label; long count; double sum; }; /* 원래: { long count; double sum; } */
$ make -f Makefile.naive
make: `app' is up to date.
make는 stats.h가 중요한지 모르니 아무것도 다시 빌드하지 않습니다. 다음으로 할 법한 일, main.c를 수정합니다(여기서는 touch):
$ make -f Makefile.naive
clang -std=c17 -Wall -Wextra -O2 -g -c main.c -o main.o
clang -std=c17 -Wall -Wextra -O2 -g -o app main.o stats.o parse.o
$ ./app
count=4621256167635550208 mean=3.000
exit=0
이제 main.o는 새 배치, stats.o는 옛 배치를 씁니다. stats_add(옛 배치)는 count를 오프셋 0에, sum을 오프셋 8에 썼고, main(새 배치)은 count를 오프셋 8에서 읽었습니다. 거기엔 double 9.0(1.5 + 2.5 + 5.0)의 비트가 있습니다. 0x4022000000000000 = 4621256167635550208. 경고도 크래시도 없이 exit 0. 의존성을 챙기는 Makefile로 make clean && make 하자 다시 count=3 mean=3.000. 메모리 오염과 똑같아 보이는 빌드 시스템 버그이고, "클린 빌드하니까 버그가 사라졌다"가 바로 이걸 의심할 때입니다.
함정 3: 같은 초 안의 수정. 빌드 직후 곧바로 parse.c를 touch했습니다.
make: `app' is up to date.
parse.c 1794494800.980412000
parse.o 1794494800.929907233
parse.c가 parse.o보다 0.05초 새롭지만 macOS의 Make 3.81은 초 단위로 비교해서 같다고 봤습니다. 1초 뒤 touch parse.c && make는 다시 빌드했습니다. 평소 편집에서는 거의 안 걸리지만, 파일을 생성하고 곧바로 make를 부르는 스크립트나 빠른 CI 단계에서는 걸릴 수 있습니다. GNU Make는 지원하도록 빌드되면 초 미만 시각도 비교하지만, 여기 들어 있는 3.81은 그렇지 않았습니다(다른 빌드는 시험하지 않음).
lldb로 크래시 추적하기
표에서 설정을 읽는 작은 프로그램입니다. 항목 하나에 값이 없습니다.
#include <stdio.h>
#include <string.h>
struct entry { const char *key; const char *value; };
static const struct entry table[] = {
{ "host", "example.com" }, { "port", "8080" }, { "mode", NULL }, /* mode는 값이 없다 */
};
static const char *lookup(const char *key) {
for (size_t i = 0; i < sizeof table / sizeof table[0]; i++)
if (strcmp(table[i].key, key) == 0) return table[i].value;
return NULL;
}
static size_t value_len(const char *key) {
const char *v = lookup(key);
return strlen(v); /* v가 NULL이면 죽는다 */
}
int main(void) {
const char *keys[] = { "host", "port", "mode" };
for (int i = 0; i < 3; i++)
printf("%s -> %zu chars\n", keys[i], value_len(keys[i]));
return 0;
}
아무 도움 없이 얻는 정보는 이게 전부입니다(파이프로 넘기면 앞 두 줄조차 안 보입니다).
bash: line 1: 77904 Segmentation fault: 11 ./crash
exit=139
종료 코드 139는 128 + 11, 즉 시그널 11번 SIGSEGV입니다. -O0 -g(디버그 정보, 최적화 없음)로 빌드하고 lldb에서 실행합니다. -b는 배치 모드, -o는 명령 실행, -k는 프로세스가 죽었을 때만 실행할 명령입니다.
clang -std=c17 -Wall -Wextra -O0 -g crash.c -o crash
lldb -b -o run -k bt -k "frame select 1" -k "frame variable" -k "frame select 2" -k "frame variable" -k kill ./crash < /dev/null
(lldb) run
host -> 11 chars
port -> 4 chars
Process 78194 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
frame #0: 0x0000000188df6ac4 libsystem_platform.dylib`_platform_strlen + 4
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
* frame #0: 0x0000000188df6ac4 libsystem_platform.dylib`_platform_strlen + 4
frame #1: 0x00000001000005a0 crash`value_len(key="mode") at crash.c:18:12
frame #2: 0x000000010000051c crash`main at crash.c:24:46
frame #3: 0x0000000188a244e4 dyld`start + 6992
(lldb) frame select 1
frame #1: 0x00000001000005a0 crash`value_len(key="mode") at crash.c:18:12
-> 18 return strlen(v); /* crashes when v is NULL */
(lldb) frame variable
(const char *) key = 0x000000010000067a "mode"
(const char *) v = 0x0000000000000000
(lldb) frame select 2
(lldb) frame variable
(const char *[3]) keys = ([0] = "host", [1] = "port", [2] = "mode")
(int) i = 2
순서대로 읽습니다. EXC_BAD_ACCESS (code=1, address=0x0)은 주소 0 읽기, 즉 NULL 포인터입니다. 프레임 0은 C 라이브러리(strlen) 안이고, 버그가 거기 있는 경우는 없습니다. 프레임 1이 우리 코드입니다. key="mode"인 value_len, frame variable에 v = 0x0. 프레임 2는 루프 세 번째(i = 2)였다고 말해 줍니다. printf 하나 안 넣고 화면 한 장으로 진단 끝. (배치 모드 메모: 처음에 -k up을 쓰고 표준 입력을 열어 둔 채 실행했더니 끝나지 않아서 직접 종료해야 했습니다. < /dev/null로 입력을 닫고 -k kill로 끝내자 그 뒤로는 매번 깔끔하게 끝났습니다.)
-O2 -g로 빌드하면 같은 세션이 덜 친절합니다.
frame #1: 0x00000001000004b0 crash2`value_len(key=<unavailable>) at crash.c:18:12 [opt] [inlined]
warning: crash2 was compiled with optimization - stepping may behave oddly; variables may not be available.
(lldb) frame variable
(const char *) key = <no location, value may have been optimized out>
(const char *) v = 0x0000000000000000
[opt] [inlined]: 함수가 main에 합쳐져 key는 어느 레지스터에도 남아 있지 않습니다. 가능하면 -O0에서 디버깅하고, 배포는 -O2 -g로 해서 사용자 크래시 보고에도 줄 번호가 남게 하세요.
조건부 브레이크포인트를 쓰면 나쁜 경우에만, 크래시 직전에 멈춥니다.
(lldb) breakpoint set -f crash.c -l 18 -c 'v == 0'
Breakpoint 1: where = crash`value_len + 28 at crash.c:18:19, address = 0x0000000100000598
(lldb) run
host -> 11 chars
port -> 4 chars
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x0000000100000598 crash`value_len(key="mode") at crash.c:18:19
(lldb) frame variable
(const char *) key = 0x000000010000067a "mode"
(const char *) v = 0x0000000000000000
비교하자면 -fsanitize=address는 같은 크래시를 디버거 없이 보고서로 바꿉니다. ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 … Hint: address points to the zero page. 다음에 #1 … in value_len crash.c:18, #2 … in main crash.c:24가 나옵니다. Visual Studio 디버거의 "호출 스택" 창과 "지역" 창이 각각 bt와 frame variable에 해당한다고 보면 됩니다.
결정표: 증상별 도구
| 증상 | 도구 | 볼 곳 |
|---|---|---|
| 결과는 맞는데 배열 루프가 느림 | 루프 순서 두 가지를 -O2로 측정 |
가장 안쪽 루프가 연속 주소를 걷는가 |
| 구조체 다수에서 필드 하나만 쓰는 루프가 느림 | SoA와 독립 누산기를 둘 다 시도 | 메모리에 막혔나, 지연 시간에 막혔나 |
Segmentation fault: 11 / exit 139 |
-O0 -g 빌드로 lldb -b -o run -k bt -k "frame variable" |
내 코드인 첫 프레임 |
| 크래시 없이 값이 망가짐 | -fsanitize=address,undefined (3~5강) |
마지막이 아니라 첫 보고 |
make clean 후 버그가 사라짐 |
헤더 의존성(-MMD -MP) |
.d 파일이 있고 include되는가 |
*** missing separator. Stop. |
cat -t Makefile |
레시피 줄이 ^I로 시작하는가 |
한계와 버전 메모
- 시간은 모두 Apple Silicon Mac(M1 Pro) 한 대, Apple clang 21.0.0
-O2, 실행 안에서 5회 최솟값, 전체 네 번 실행입니다. 절대값은 메모리 시스템에 따라 다르고, 54배라는 비율도 CPU마다 다르겠지만 방향은 같습니다. - 첫 절의 캐시 라인 크기는
sysctl값과 일반 지식이고, 우리가 직접 잰 건 루프 전체 시간뿐입니다. - GNU Make 3.81은 2006년 버전이고 macOS가 아직 이걸 넣어 둡니다. 최신 GNU Make(4.x)에는
.RECIPEPREFIX(TAB 대신 다른 문자 사용)나 "did you mean TAB instead of 8 spaces?" 힌트 같은 기능이 있습니다. 설치하거나 실행하지 않았습니다. -MMD -MP는 make가 아니라 GCC/clang의 플래그입니다. 이 맥에는 GDB가 없고, lldb 명령은 GDB의bt,frame N,info locals에 해당합니다(실행 안 함).- lldb 출력은 다듬었고(멈춘 뒤의 디스어셈블 줄 삭제) 경로는 줄였습니다. PID와 주소는 우리 실행의 값이라 여러분 환경에서는 다릅니다.
연습 문제
- 동료의 이미지 필터가
float in[H][W]에 대해for (x = 0; x < W; x++) for (y = 0; y < H; y++) out[y][x] = f(in[y][x]);로 돕니다. 무엇을 바꾸고, 빨라졌다는 걸 어떻게 증명할까요? -MMD없이Makefile.naive에 최소한 무엇을 추가하면stats.h가 바뀔 때 오브젝트 세 개가 모두 다시 빌드될까요?
정답
- 마지막 인덱스
x가 가장 안쪽이 되도록 루프를 바꿉니다:for (y…) for (x…). 증명은cache.c같은 최솟값 측정 틀로 같은 고정 시드 입력에 대해-O2로 두 버전의 시간을 재고, 출력(또는 그 체크섬)이 같은지 확인하는 것입니다. - 한 줄 추가:
$(OBJS): stats.h. Make 3.81로 확인했습니다.touch stats.h후make가main.o,stats.o,parse.o를 다시 컴파일하고 링크했습니다. 단점은 이 목록을 손으로 관리해야 한다는 것이고, 그래서-MMD -MP가 낫습니다.
C 시리즈는 여기까지입니다. 이 도구들을 실제 자료구조에 써 보려면 정렬 알고리즘 글을, 막 시작하는 동료에게 시리즈를 권하려면 1강을 보세요.
이전: 5강: 정의되지 않은 동작과 최적화 · 다음: C 이후의 길은 프로그래밍 학습 로드맵에서
자주 묻는 질문
2차원 배열을 열 방향으로 돌면 왜 그렇게 느린가요?
C는 2차원 배열을 행 단위로 저장합니다. 행을 따라가면 연속된 바이트를 읽어 캐시 라인을 통째로 쓰고 벡터화도 됩니다. 4096x4096 int 행렬에서 열을 따라가면 한 걸음에 16KiB씩 건너뛰어 거의 매번 새 캐시 라인이 필요합니다. 같은 데이터로 행 순서 1.13ms, 열 순서 61.58ms, 약 54배 차이였습니다.
'Makefile: *** missing separator. Stop.'은 무슨 뜻인가요?
레시피 줄(타깃 아래의 명령)은 반드시 TAB 문자로 시작해야 하는데 공백으로 시작했다는 뜻입니다. 보통 편집기가 탭을 공백으로 바꾼 경우입니다. macOS에 들어 있는 GNU Make 3.81은 'Makefile.spaces:6: *** missing separator. Stop.'처럼 줄 번호를 알려 줍니다. cat -t로 보면 진짜 탭은 ^I로 보입니다.
printf를 넣지 않고 C 프로그램이 어디서 죽었는지 보려면?
-g(가능하면 -O0)로 빌드해 lldb에서 실행하고, 멈추면 bt로 호출 스택을, frame variable로 선택한 프레임의 지역 변수를 봅니다. 배치 모드로는 lldb -b -o run -k bt -k 'frame variable' ./app. 실제 크래시에서 value_len(key="mode")의 v = 0x0000000000000000이 crash.c:18에서 보였습니다.