퇴근하고 아이들 재우고 노트북 열어보는 시간이 사실상 저의 휴식이에요. 와이프랑 TV 보는 시간을 빼면 하루에 겨우 1~2시간. 그 시간을 회사 일이 아니라 사이드 프로젝트에 쓰거든요. 머리 식히기 좋아요. 코드는 거짓말을 안 하니까요.
근데 그 사이드 프로젝트가 1주일 동안 답을 못 찾으면 머리가 오히려 더 무거워져요. 이번 주가 그랬어요. “이렇게 하면 더 빨라지겠지” 라고 시작한 코드가 결과를 측정해보니 2.9배 더 느려졌어요. 며칠 동안 가설 두 개를 세웠는데 둘 다 측정으로 부정. 결국 한 층 더 아래에서 진짜 원인을 찾았어요.
저도 이번에 정리하면서 새로 알게 된 게 많은데요, 이 글은 사이드 프로젝트로 만들고 있는 LLM 추론 컴파일러 “Lumen”의 v0.5 두 번째 묶음 회고예요. AI 모델을 더 빠르게 돌리는 코드를 직접 짜는 거고, 이번엔 “여러 입력을 한 번에 처리하는 prefill batching”이 답일 거라고 가설을 세웠다가 진짜 호되게 당한 얘기예요. 진짜 솔직하게 풀어볼게요.
한눈에 보기
- 5/18에 발행한 글에 이어 v0.5 두 번째 시도. 격차 1.3배 (Lumen 65 tok/s vs ggml 87 tok/s)
- 가설: prefill batching으로 5~10배 가속 가능 — ggml은 prefill에서 decode보다 8.4배 빠름
- 측정: pp32 = 22.65 tok/s. tg32(65 tok/s) 대비 token당 2.9배 더 느림 (회귀)
- 가설 1 (attention이 원인) → 50줄 단위 측정으로 부정. 실제 비중 0.66%
- 가설 2 (kernel codegen 비효율) → 진짜 원인 확정. dispatcher 변경으로 2.4배 회복

컴파일러 사이드 프로젝트 뭔지 다시 짧게
5/18 글에서 한 번 설명했지만 이번 글부터 보시는 분도 있을 테니 짧게 다시 정리해요. 저는 “Lumen”이라는 사이드 프로젝트를 만들고 있어요. 한 줄로 하면 ChatGPT 같은 AI 모델이 답을 만들어 낼 때 그 계산을 더 빠르게 해주는 코드예요.
비유로 풀면 이래요. AI 모델은 사실 거대한 곱셈표예요. 100만 개 단어 중에 다음에 올 단어 1개를 고르려면, 모델 안에 저장된 수십억 개 숫자를 매번 곱하고 더해야 해요. 이 곱셈을 얼마나 빠르게 하느냐가 “AI가 얼마나 빠르게 답하느냐”를 결정해요.
전 세계 사람들이 쓰는 llama.cpp(ggml)이라는 도구가 표준인데, 저는 그걸 처음부터 다시 만들어보고 싶어서 6주 전부터 짜고 있어요. 결과는 명확해요. 빠르거나 느리거나. 정답이거나 틀리거나. 이게 컴파일러 일의 매력이거든요.
5/18 시점에서 제 코드는 ggml의 1.3배 느렸어요. 같은 모델로 같은 답을 만들지만 시간이 1.3배 걸렸다는 뜻이에요. 같은 CPU(AMD Zen 4)에서요. 솔직히 잘 따라가고 있는 편이긴 해요. 이전 phase까지의 결론은 “단일 코어 성능은 우리가 ggml보다 빠르고 멀티스레드 활용도가 ggml의 72%만 되니까 메모리 대역폭 효율이 격차의 정체”였어요.
그래서 이번 v0.5 두 번째 묶음의 질문은 단순했어요. “메모리 대역폭이 정체라면, 같은 weight를 더 여러 번 재사용하는 방식으로 가면 격차 좁힐 수 있겠지?”
왜 prefill batching이 답일 거라 생각했나
AI 모델로 답을 만들 때는 두 단계가 있어요.
1단계 prefill (프롬프트 처리): “오늘 날씨 어때?” 같은 입력 문장을 모델에 한 번 통과시키는 단계예요. 여러 단어를 동시에 처리할 수 있어요.
2단계 decode (답 생성): “오늘은”, “날씨가”, “맑아요” 이렇게 한 단어씩 만드는 단계예요. 한 단어 만들고 다음 단어 만들고를 반복해요.
여기서 핵심 차이가 있어요. decode는 1개씩 처리하니까 매번 모델 전체를 메모리에서 다시 읽어야 해요. 모델 크기가 670MB라면 단어 32개 만들 때 670MB × 32 = 약 21GB를 메모리에서 스트리밍해야 해요. 우리 CPU 메모리 대역폭이 80GB/s 정도니까 산술적으로 이 부분만 0.27초 걸려요.
반면 prefill은 여러 단어를 한 번에 처리해서 모델 weight를 1번만 읽어요. 32개 단어를 한 번에 prefill하면 weight read는 670MB × 1 = 670MB만 됨. 같은 32개 단어 처리하는데 메모리 트래픽이 1/32로 줄어요. 이론적으로는요.
이거 알고 좀 흥분했어요. ggml 데이터로 봤거든요.
| 구성 | 처리 속도 | 차이 |
|---|---|---|
| ggml decode (tg128) | 87 tok/s | 1.0× |
| ggml prefill (pp128) | 739 tok/s | 8.4× 빠름 |
| Lumen decode (tg32) | 65 tok/s | — |
| Lumen prefill | 없음 (decode와 동일) | — |
ggml은 prefill에서 decode보다 8.4배 빠른데 우리는 prefill 미지원. 그러면 prefill만 붙이면 자동으로 5~10배 가속이 따라올 거다. 이게 제 가설이었어요. 1주일 동안 코드 짜는 동안 이 가설 검증을 한 번도 안 했어요. 그게 첫 번째 실수였어요.

코드 1주일 짰는데 결과는 회귀
구현은 단계별로 진행했어요. 솔직히 단계마다 통과 통과 통과라 분위기 좋았거든요.
먼저 매트멀 커널이 N>1 (여러 입력 동시 처리) 정답성을 보장하는지 검증했어요. 정수형 8비트 양자화(Q8) 매트멀에서 prompt 길이 8, 16, 32, 64 각각에서 단순 참조 구현과 비트 단위 동일한 결과 나오는지. 6개 형상 다 통과. 좋아요.
그 다음 한 layer 한 token이 아니라 한 layer 여러 token 처리하는 함수 (forward_layer_prefill_jit) 추가. 단위 테스트로 같은 입력에 단순 참조와 동일 결과 확인. 통과.
마지막 모델 전체에서 prompt 처리 후 마지막 token의 logits만 반환하는 함수 (forward_prefill_jit) 추가. 정답성 검증. 통과.
여기까지 1주일 걸렸어요. 토큰 출력이 v0.4와 비트 단위 동일했고 모든 단위 테스트가 통과했고, 매번 commit + push를 했어요. 그리고 마침내 측정. 결과를 보고 그날 처음으로 자조 섞인 한숨이 나왔어요.
| 측정 | 결과 | 의미 |
|---|---|---|
| tg32 (기존 decode 32 토큰) | 65 tok/s | baseline |
| pp32 (새 prefill, 32 prompt + 1 decode) | 22.65 tok/s | token당 2.9배 더 느림 |
잠깐만요. prefill이 decode보다 빨라야 하는 거 아니었어요? ggml은 8.4배 빨라지는데 우리는 2.9배 느려졌다? 이걸 어떻게 설명해요. 진짜 막막했어요.
첫 가설 — 어텐션이 원인일까
제 첫 추측은 어텐션이었어요. 어텐션은 AI 모델의 핵심 부품 중 하나인데요, 32개 단어를 한 번에 처리하면 “각 단어가 다른 모든 단어를 얼마나 보는지”를 계산해야 해요. 32 × 32 = 1024번 계산. decode는 1단어씩이라 32번만. 즉 prefill의 어텐션은 32배 더 일이 많아요.
그래서 commit message에 “attention O(seq²) 때문에 prefill 회귀”라고 자신 있게 적었어요. 사실 측정은 안 했어요. 그냥 그게 맞을 거라 생각했죠. 이게 두 번째 실수였어요.
다음 날 50줄짜리 단위 측정을 만들었어요. 어텐션 함수만 입력 길이 1, 8, 16, 32, 64로 호출하면서 시간 측정.
| 입력 길이 (seq) | 어텐션 시간 (µs) |
|---|---|
| 1 | 1.18 |
| 8 | 26.5 |
| 16 | 114 |
| 32 | 389 |
| 64 | 1442 |
seq² 형태로 정확히 증가. 가설 맞는 것 같죠. 근데 숫자를 전체 시간에 대비해보니 충격이었어요.
pp32 전체 시간은 1410밀리초였어요. 어텐션 시간은 layer 24개 × 389µs = 9.3밀리초. 비중 0.66%예요. 1%도 안 돼요.
제가 commit message에 “어텐션이 원인”이라고 적었던 게 그날로 부정됐어요. 진짜 허탈했어요. 하루 만에 제 가설을 제가 50줄로 부정한 셈이에요.
이게 맞는 건지 저도 100% 확신은 못 했어요. 측정 자체가 토이 환경이라 진짜 forward 호출에서는 cache miss 같은 게 다를 수도 있거든요. 그래도 0.66%면 너무 작은 비중이라 무시할 수 없었어요. 가설 1 폐기.
진짜 원인을 찾은 50줄 측정
다시 처음으로. 어텐션이 원인이 아니라면 어디서 prefill이 느려진 거지. 그 외 후보는 세 개였어요.
- 매트멀 dispatch 자체 (단순 plumbing)
- 입출력 transpose 비용 (메모리 복사)
- 매트멀 커널 자체가 N>1에서 비효율
저는 솔직히 3번이 의심스러웠어요. 왜냐면 N=1 (한 token씩 처리)용 커널은 Phase 7.G에서 정말 정밀하게 튜닝했거든요. 4개 sub-accumulator로 FMA 의존성 체인 끊고, 8개 weight를 한 명령으로 로딩하고. 근데 N>1 (여러 token 동시 처리)용 커널은 그 튜닝이 안 들어가 있었어요.
또 50줄 측정. 이번엔 매트멀 커널만 직접 호출. transpose 같은 부수 비용 다 제외하고 raw 커널만 비교.
비교 방식: 같은 work양에 대해 (a) N=32 커널 1번 호출 vs (b) N=1 커널 32번 호출.
| 매트멀 형상 | N=32 1번 | N=1 × 32번 | 차이 |
|---|---|---|---|
| qkv (Q): 896×896 | 2925µs | 1156µs | N=32이 2.5배 느림 |
| qkv (KV): 128×896 | 417µs | 160µs | 2.6배 느림 |
| wo: 896×896 | 2876µs | 1181µs | 2.4배 느림 |
| gate/up: 4864×896 | 15566µs | 6803µs | 2.3배 느림 |
| down: 896×4864 | 15915µs | 6454µs | 2.5배 느림 |
모든 매트멀 형상에서 N=32 커널이 N=1 커널을 32번 호출하는 것보다 2.3~2.6배 느려요. 같은 work인데요. 같은 곱셈 횟수인데 2.5배 더 느리다는 건 N>1 커널이 마이크로 튜닝이 안 들어갔다는 명확한 증거예요.
이거 보고 진짜 화나기도 하고 후련하기도 했어요. 1주일 동안 짠 plumbing이 다 잘 됐는데 그 아래 커널이 안 따라줬던 거예요. 예를 들면 한식당에서 회 코스를 새로 짰는데 정작 회 뜨는 칼이 무뎠던 거. plumbing은 정답인데 커널이 발목 잡은 거예요.

임시 fix로 2.4배 회복
진짜 답은 N>1 커널 자체를 재작성하는 거예요. 4-accumulator 패턴을 N>1 path로 포팅. 근데 이건 큰 작업이에요. 컴파일러 코드 수백 줄. 1~2주 더 필요해요.
그래서 임시 fix를 시도했어요. 측정 결과 N=1 커널이 N=32 커널보다 2.5배 빠르니까, prefill에서 N=1 커널을 N번 호출하면 어떨까. 비유로 풀면 무딘 칼 1자루로 32번 자르는 게 아니라 빠른 칼 1자루로 32번 자르는 거. 같은 work인데 칼이 빠른 쪽 쓰기.
코드 변경은 작아요. dispatcher에서 N>1 커널 호출 대신 N=1 커널을 row마다 호출. transpose도 필요 없어졌어요(N=1 커널은 활성화 row를 그대로 받으니까).
측정 결과:
| pp32 (LUMEN_PREFILL=1) | 속도 | baseline 대비 |
|---|---|---|
| 처음 (N>1 커널 직접 호출) | 22.65 tok/s | 회귀 2.9배 |
| N=1 fan-out 임시 fix | 54.15 tok/s | +2.39배 회복 |
| tg32 baseline (참고) | 65 tok/s | 여전히 17% 느림 |
2.39배 회복. 회귀에서 빠져나왔어요. 근데 여전히 baseline보다 17% 느려요. 진짜 prefill 가속(ggml의 8.4배)에는 멀어요.
여담인데, 이렇게 임시 fix로 정상으로 돌아오는 패턴이 사실 우리 사이드 프로젝트 6주 동안 자주 나왔어요. 큰 변경 시도하다가 회귀 → 인프라는 살리되 활성화 정책만 끄기. 이번엔 회복까지 한 단계 더 추가됐어요. 직접 짜본 사람만 알 수 있는 패턴이에요.
commit 메시지에 추측 적지 말 것
여기는 짧게 끊을게요. 사실 이 부분은 패스해도 돼요. 그냥 제 자신한테 하는 다짐이에요.
이번 phase의 진짜 교훈은 commit message에 추측을 적은 게 잘못이었다는 거예요. “attention이 원인”이라고 적었을 때 측정 안 한 추측이었어요. 다음 날 50줄로 부정. 만약 commit 전에 50줄 측정 먼저 했으면 1주일 더 일찍 진짜 원인 발견했을 거예요.
이 한 패턴 강화. “측정 안 한 추측은 commit message에 적지 말 것”.
대신 질문 해드릴게요
이거 일반 사용자가 쓸 수 있어요?
아직 사이드 프로젝트라 production-ready는 아니에요. 0.5B 작은 모델 위주로 정답성 보장하면서 빌드 중이고요. 만약 직접 빌드해서 돌리고 싶으면 GitHub에서 redchupa/lumen 검색하시면 코드 다 있어요. Apache-2.0 라이선스라 가져다 쓰셔도 돼요. 다만 production은 llama.cpp 쓰시는 게 안전해요.
왜 ggml 안 쓰고 직접 만드는 거에요
이게 가장 자주 받는 질문이에요. 솔직히 효율로만 따지면 ggml 쓰는 게 맞아요. 근데 직접 짜보면 CPU가 어떻게 동작하는지, 컴파일러가 어떻게 코드를 만드는지 진짜로 이해하게 돼요. 책 100번 읽는 거랑 한 번 직접 짜보는 거랑 차이가 너무 커요. 그리고 결과가 명확해요 — 빠르거나 느리거나. 학습 피드백이 빨라요.
회사 일하면서 사이드 프로젝트 어떻게 시간 내요
퇴근 후 아이들 재우고 1~2시간이 전부예요. 주말에 둘째 분유 타다가 노트북 잠깐 보는 정도. 한 번에 큰 진척은 못 해요. 그래서 작은 단위로 commit하고 push하고 측정. 5분만 짜도 다음에 5분 더 짜기 좋아요. 1주일 단위로 보면 무난한 진척이에요.
측정-주도 의사결정 진짜 매번 하는 거에요
네. 사이드 프로젝트 6주 동안 가설 → 측정 → 부정/확정 패턴을 10번쯤 반복했어요. 이번 phase까지 합치면 11번. 가설 부정이 더 많아요. 직관은 자주 틀려요. 이게 좀 짜증날 수도 있는데, 측정 없이 직관만으로 갔으면 지금 코드가 절반은 잘못된 상태였을 거예요.
이 프로젝트 끝나면 뭐 해요
제일 가까운 다음은 N>1 커널 codegen 재작성이에요. 1~2주 더 걸려요. 그게 끝나면 ARM64 백엔드(Apple Silicon, 라즈베리파이용) 또는 다른 quantization(Q4 4비트) 추가 가능. 그건 또 1~2주씩. 진짜 production-ready까지는 한참이에요. 근데 그 과정이 즐거워요. 결과가 명확하니까.
코드 회고를 블로그에 쓰는 게 도움이 되나요
네 진짜 도움 돼요. 글로 풀어 쓰면서 제 가설이 어디서 틀렸는지 다시 정리하게 되거든요. 이번 글 쓰는 동안에도 “어, commit message 추측 패턴” 같은 메타 패턴을 발견했어요. 글 안 썼으면 그냥 다음 phase로 넘어가버렸을 거예요. 사이드 프로젝트 하시는 분들께 글쓰기 같이 하시는 거 추천해요.
레추의 총평
1주일 동안 짠 코드가 -2.9배 회귀로 끝났고, 하루 후 측정으로 추측 부정, 다시 하루 후 진짜 원인 발견. 임시 fix로 2.39배 회복하고 진짜 fix는 다음 1~2주로 미루기. 이게 이번 phase 한 줄 요약이에요.
혹시 사이드 프로젝트 진행하시는 분들께 정리하면:
- 측정 인프라를 가장 먼저 단단히 만들어 두기. step별 시간 측정 가능해야 가설 부정 가능
- commit message에 측정 없는 추측 절대 적지 말기. 다음 phase에 따라잡힘
- 큰 변경 시도하기 전에 50줄 단위 측정으로 가설 먼저 검증. 1주일 절약 가능
- 가설 부정 후에도 인프라는 살리기. 다른 호스트나 다른 조건에서 살아날 수 있음
- 회고 블로그 한 편씩 쓰기. 메타 패턴 발견에 진짜 도움
코드 궁금하신 분들은 github.com/redchupa/lumen에서 redchupa/lumen 검색하시면 돼요. 이번 회고 글의 코드 커밋 8개는 f44face부터 16166fd까지 main 브랜치에 다 있어요. 질문 있으면 댓글로 남겨주세요.
이 글과 함께 많이 보는 상품
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.