3배 이상한 숫자를 쫓다가, 범인이 내 자였다 — prefix caching 측정기
prefix caching 성능을 재다 3배 어긋난 값을 발견하고, 가설 세 개를 세워 둘을 실험으로 죽이고, 마지막에 원인이 내 측정 방식이었다는 걸 알게 된 기록.
이 글의 독자: 1편을 읽었거나, LLM 서빙 성능을 처음 재보는 분. 용어는 처음 나올 때 다시 설명합니다.
결론을 먼저: 3배 어긋난 값의 원인은 llama.cpp도, 발열도 아니고 제가 길이를 잘못 세고 있었던 것이었습니다. 그걸 알아내기까지 네 번의 측정과 두 번의 잘못된 결론이 있었고, 이 글은 그 순서를 그대로 적은 것입니다.
0. 1편에서 여기까지
1편의 결론은 이랬습니다. 맥미니 M4에서 Qwen2.5 7B를 돌리면 생성 속도는 21.3 tok/s로 메모리 대역폭 한계의 83%까지 나오지만, 3,500 토큰짜리 프롬프트는 첫 글자가 뜨기까지 18초가 걸립니다.
그리고 그 18초는 튜닝으로 줄일 수 없습니다. GPU가 53조 번의 연산을 하는 시간이니까요. 남은 방법은 하나뿐입니다.
prefill(프롬프트 처리)은 연산 문제라서, 유일한 해결책은 “그 일을 안 하는 것”입니다.
그 “안 하는 방법”이 prefix caching입니다. 그리고 1편에서 저는 이걸 벤치마크 버그로 우연히 발견했습니다. 같은 프롬프트를 반복해서 보냈더니 3,473 토큰이 58ms에 처리됐거든요. 물리적으로 불가능한 속도였고, 알고 보니 서버가 이전 요청의 계산 결과를 그대로 재사용하고 있었습니다.
17,846 ms → 58 ms. 약 300배.
2편은 그 300배가 실제 서비스에서 얼마나 남는지를 제대로 재보려던 기록입니다.
1. prefix caching이 뭔지 다시
LLM 서버는 요청이 오면 이전 요청의 계산 결과(KV cache)와 앞부분이 얼마나 같은지를 비교합니다. 앞에서부터 일치하는 토큰은 다시 계산하지 않고 그대로 씁니다.
중요한 건 이게 prefix(앞부분) 일치라는 점입니다.
| 상황 | 재사용되는 양 |
|---|---|
| 앞 2,000토큰이 같고 2,001번째가 다름 | 2,000 토큰 재사용 |
| 첫 토큰이 다르고 나머지가 전부 같음 | 0 토큰. 전부 다시 계산 |
변하는 내용이 프롬프트의 어디에 있느냐가 전부를 결정합니다. 맨 앞에 timestamp 하나만 붙여도 캐시가 통째로 날아갑니다.
RAG는 이게 특히 중요합니다. RAG 프롬프트는 보통 이런 구조입니다.
1
2
3
4
[시스템 프롬프트] ← 항상 같음
[few-shot 예제] ← 항상 같음
[검색된 문서 청크] ← 비슷한 질문이면 자주 같음
[사용자 질문] ← 매번 다름
앞부분이 안정적이라면 캐시가 잘 먹습니다. 그래서 “실제로 몇 %가 같아야 얼마나 빨라지는가”를 알아야 아키텍처를 설계할 수 있습니다.
2. 1차 측정 — 그리고 3배 어긋난 값
실험 설계는 단순합니다. 프롬프트 전체 길이를 4,096 토큰으로 고정하고, 앞부분 몇 %를 매 요청 똑같이 유지할지를 바꿔가며 잽니다.
예상 비용은 이렇게 계산했습니다. 1편에서 얻은 prefill 비용식에서 캐시된 부분을 빼는 겁니다.
\[\text{cost} \approx 4.50\,(T-P) + 1.96 \times 10^{-4}\,(T^{2} - P^{2})\]T는 전체 토큰 수, P는 캐시된 앞부분 길이입니다. 뒷항이 2차식인 이유는 attention 때문입니다. 살아남은 뒷부분 토큰들은 앞의 캐시된 부분 전체를 다시 쳐다봐야 하거든요.
결과입니다.
| 같은 비율 | 새로 처리할 토큰 | 실측 TTFT | 식의 예측 | 비율 |
|---|---|---|---|---|
| 0% | 4,367 | 24,398 ms | 23,389 | 1.04× |
| 25% | 3,238 | 18,487 ms | 17,926 | 1.03× |
| 50% | 2,112 | 13,188 ms | 12,074 | 1.09× |
| 75% | 986 | 6,886 ms | 5,815 | 1.18× |
| 95% | 82 | 1,568 ms | 495 | 3.17× |
0~50%는 식과 거의 맞는데, 95%에서 3배 넘게 어긋납니다.
여기서 저는 잘못된 결론을 하나 썼습니다. “캐시가 이론만큼 안 먹는다. 적중률이 높을수록 손해가 커진다.” 그럴듯했고, 설명도 붙였습니다. 나중에 이게 틀렸다는 걸 알게 됩니다.
3. 가설 세우기 — 서버가 실제로 어떤 옵션으로 떴는지 보기
원인을 찾으려면 서버가 실제로 어떻게 동작하는지 알아야 합니다. 문서 말고 실행 중인 프로세스를 보는 게 빠릅니다.
1
pgrep -fl ollama
나온 실행 옵션이 이랬습니다.
1
2
llama-server --cache-type-k q8_0 --cache-type-v q8_0
--flash-attn on -b 1024 -ub 1024 -np 1 --context-shift
여기서 두 가지를 알게 됐습니다.
하나. KV cache가 FP16이 아니라 q8_0(8bit)입니다. 1편에서 저는 FP16을 가정하고 메모리를 계산했는데, 실제로는 절반입니다. Qwen2.5 7B가 토큰당 56KB가 아니라 30KB, 동시 처리 가능 요청이 70건이 아니라 135건이었습니다. 제가 메모리를 2배 비관적으로 잡고 있었던 겁니다.
둘. -ub 1024. prefill을 1,024 토큰씩 잘라서 처리한다는 뜻입니다. 만약 이 덩어리(micro-batch)마다 고정 비용이 붙는다면, 새로 처리할 토큰이 82개뿐이어도 덩어리 하나 값을 다 낼 수 있습니다. 3배 차이의 유력한 용의자로 보였습니다.
런타임이 실제로 어떤 플래그로 떴는지 확인하세요. 저는 문서의 기본값을 믿었다가 메모리 계산을 2배 틀렸습니다.
4. 가설 1 검증 — 그리고 기각
micro-batch가 원인이라면, 새로 처리할 토큰이 1,024개를 넘는 순간 비용이 계단처럼 뛰어야 합니다. 1덩어리에서 2덩어리가 되니까요.
그래서 캐시 비율을 쓸어보는 대신, 새 토큰 개수를 1024 경계 위아래로 직접 바꿔가며 쟀습니다. 이쪽이 훨씬 빠르고 답이 명확합니다.
1덩어리(파란 원)와 2덩어리(초록 마름모) 사이에 단차가 없다.
| 새 토큰 | micro-batch 수 | prefill | 토큰당 |
|---|---|---|---|
| 512 | 1 | 1,630 ms | 3.18 ms |
| 1,024 | 1 | 3,071 ms | 3.00 ms |
| 1,152 | 2 | 3,512 ms | 3.05 ms |
| 2,048 | 2 | 6,143 ms | 3.00 ms |
계단이 없습니다. 1덩어리에서 2덩어리로 넘어가도 3.00 → 3.05 ms/토큰, 오차 범위입니다. 직선의 기울기는 3.000 ms/토큰, y절편은 −1 ms. 가설 1 기각.
5. 예상 못 한 발견 — 이 기계는 2분이면 느려진다
이 실험에는 대조군(control point)을 넣었습니다. 똑같은 작업을 실험 처음과 끝에 한 번씩 재는 겁니다. 값이 달라지면 그 실험은 오염된 겁니다.
| prefill | |
|---|---|
| 대조군 (시작) | 10,685 ms |
| 대조군 (끝) | 11,439 ms |
| 차이 | +7.1% |
144초 만에 7% 느려졌습니다. 그것도 작은 3B 모델로요.
prefill은 GPU를 100% 쓰는 작업입니다. 맥미니는 작은 상자고요. 나중에 실제로 케이스를 만져보니 90°C였습니다.
이게 왜 중요하냐면 — 1편의 측정값 상당수가 5분씩 쉬지 않고 돌린 배치의 뒷부분에서 나왔기 때문입니다. 즉 그 숫자들에도 같은 크기의 오차가 섞여 있습니다.
대조군은 샘플 두 번 값입니다. 그리고 그 실험이 진짜인지 아닌지를 알려줍니다. 이후 모든 측정에 넣기로 했습니다.
재미있는 건, macOS는 이걸 전혀 알려주지 않았다는 점입니다. pmset -g therm은 90°C에서도 “No thermal warning level has been recorded”였습니다. 온도계가 경고하기 한참 전부터 성능은 이미 떨어지고 있었습니다. 그래서 온도보다 대조군이 더 나은 계측기입니다.
6. 불가능한 숫자
가설 2는 발열이었습니다. 확인하려면 차가운 상태에서 다시 재면 됩니다. 10분 식히고 다시 돌렸습니다.
| 같은 비율 | 목표 P | 실제 토큰 | 새 토큰 |
|---|---|---|---|
| 90% | 3,686 | 3,962 | 276 |
| 95% | 3,891 | 3,935 | 44 |
| 98% | 4,014 | 3,917 | −97 |
새 토큰이 −97개. 음수입니다.
프롬프트 전체가, 제가 캐시했다고 믿은 앞부분보다 더 짧게 토큰화된 겁니다. 산술적으로 불가능한 값이고, 여기서 진짜 원인이 드러납니다.
7. 범인 — 나는 길이를 세지 않고 추측하고 있었다
제 코드는 새 토큰 수를 이렇게 계산했습니다.
1
새 토큰 = 전체 토큰 − P
문제는 P입니다. 이건 제가 측정한 값이 아니라 “목표값”이었습니다. 프롬프트를 만들 때 “단어 하나당 대략 1.14 토큰이니까, 3,891 토큰을 원하면 3,407 단어를 넣자” 하는 식으로 추정한 숫자였습니다.
그 추정은 몇 % 어긋납니다. 그리고 여기가 핵심입니다.
| 캐시 비율 | 뒷부분 크기 | 추정 오차의 영향 |
|---|---|---|
| 낮음 (0~50%) | 큼 (1,000~4,000 토큰) | 오차가 묻힘 |
| 높음 (95%+) | 작음 (수십 토큰) | 오차가 값 자체보다 큼 |
새 토큰이 실제로 130개인데 44개로 계산하면, 예측 비용은 3배 작게 나옵니다. 그러면 실측 ÷ 예측 비율이 3배로 뜁니다.
3배 어긋난 값은 llama.cpp의 성질이 아니라 제 계산 실수였습니다.
잴 수 있는 값을 추정하지 마세요. 서버는 매 응답에
prompt_eval_count로 정확한 토큰 수를 돌려줍니다. 한 번만 물어봤으면 이틀치 삽질을 안 했을 겁니다.
8. 고친 방법
추정을 없애면 됩니다.
- 앞부분(head)을 하나로 고정합니다. 실험 내내 절대 안 바뀝니다.
- 그 head만 한 번 서버에 보내서 정확한 토큰 수를 받아둡니다.
- 이후에는 뒷부분만 바꿔가며 잽니다.
이러면 새 토큰 = 전체 − head 토큰이 정확합니다. 추정이 한 군데도 없습니다.
head가 정확히 3,613 토큰으로 측정됐고, 결과는 이랬습니다.
| 새 토큰 (정확) | prefill | 식의 예측 | 비율 | 잔차 |
|---|---|---|---|---|
| 43 | 352 ms | 255 | 1.38× | +98 ms |
| 62 | 566 ms | 368 | 1.54× | +199 ms |
| 115 | 749 ms | 683 | 1.10× | +66 ms |
| 237 | 1,498 ms | 1,413 | 1.06× | +85 ms |
| 544 | 3,444 ms | 3,276 | 1.05× | +168 ms |
잔차가 +98, +199, +66, +85, +168 ms — 새 토큰 수와 무관하게 거의 일정합니다. 비례하지 않습니다.
즉 이건 요청당 약 100ms의 고정 비용이지, 캐시 메커니즘의 결함이 아닙니다. 새 토큰이 100개를 넘으면 식이 5% 이내로 맞습니다.
3배 잔차는 처음부터 없었습니다. 제가 쫓던 가설 두 개는 존재하지 않는 현상에 대한 설명이었습니다.
덤으로, 캐시를 전혀 안 쓴 대조군은 1,144 토큰을 5,462 ms에 처리했고 식의 예측은 5,404 ms였습니다. 1.01×. 식 자체는 튼튼합니다.
9. 마지막 자책골 — 측정 도구가 실험을 바꾼다
이제 곡선 전체를 정확한 길이로 다시 그리면 됩니다. 그런데 한 점이 이상하게 나왔습니다.
| 같은 비율 | prefill |
|---|---|
| 71.8% | 3,141 ms |
| 91.6% | 4,304 ms |
캐시를 더 많이 했는데 더 느립니다. 불가능합니다.
원인은 이번에도 제 코드였습니다. 각 head의 정확한 토큰 수를 알아내려고, head들을 긴 것부터 짧은 것 순서로 서버에 보냈습니다. 그러면 마지막에 캐시에 남는 건 가장 짧은 head(537 토큰)입니다. 그 상태에서 첫 측정을 시작하니, 1,933 토큰이 캐시돼 있어야 할 시점에 537 토큰밖에 없었던 겁니다.
순서를 바꿔서 가장 긴 head를 먼저 넣어두자 4,304 ms → 997 ms가 됐습니다.
시스템을 건드리는 계측은 그 시스템의 일부입니다. 토큰 수를 “읽기만” 했다고 생각했지만, 그 읽기가 캐시 상태를 바꿔서 첫 측정을 조용히 망가뜨렸습니다.
10. 최종 결과
정확한 앞부분 길이, 대조군 드리프트 −1.4%(깨끗함).
왼쪽: 실제로 새로 처리한 토큰 수 대비 비용. 오른쪽: 같은 데이터를 “몇 % 같은가” 축으로 본 것.
| 같은 비율 (정확) | head 토큰 | 전체 토큰 | 새 토큰 | prefill | 식의 예측 | 비율 |
|---|---|---|---|---|---|---|
| 0.0% | 0 | 2,372 | 2,372 | 11,912 ms | 11,777 | 1.01× |
| 23.3% | 537 | 2,302 | 1,765 | 8,933 ms | 8,925 | 1.00× |
| 46.9% | 1,045 | 2,230 | 1,185 | 5,972 ms | 6,093 | 0.98× |
| 71.8% | 1,553 | 2,164 | 611 | 3,141 ms | 3,195 | 0.98× |
| 85.1% | 1,809 | 2,126 | 317 | 1,779 ms | 1,671 | 1.06× |
| 91.6% | 1,933 | 2,111 | 178 | 997 ms | 942 | 1.06× |
전 구간에서 0.98~1.06배. 이상 현상은 어디에도 없습니다.
실무적으로 쓸 숫자는 이겁니다. 약 2,100 토큰 프롬프트에서 앞부분 91.6%가 고정되면 prefill이 11.9초 → 1.0초가 됩니다.
11. 그래서 RAG를 어떻게 설계해야 하나
측정에서 나오는 설계 규칙은 이렇습니다.
- 프롬프트를 “잘 안 변하는 것부터” 배치한다. 시스템 프롬프트 → few-shot → 검색 청크 → 사용자 질문. 질문을 앞에 두면 뒤쪽 캐시가 전부 무효가 됩니다.
- timestamp, request ID, session token을 앞쪽에 절대 넣지 않는다. 10번째 토큰 하나가 바뀌면 캐시 전체가 버려집니다. 캐싱을 켜놓고 적중률 0%를 얻는 가장 쉬운 방법입니다.
- 검색 결과를 결정론적으로 만든다. 같은 질문에 같은 청크가 같은 순서로 나와야 합니다. 보기 좋으라고 순서를 섞는 reranking은 재사용을 파괴합니다.
- 작은 청크 여러 개보다 큰 청크 적은 개수가 캐시에 유리합니다. 경계가 적을수록 흔들릴 여지가 적습니다.
12. 타임라인 요약
| # | 한 일 | 결과 | 얻은 것 |
|---|---|---|---|
| 1 | 캐시 곡선 1차 측정 | 95%에서 3배 어긋남 | 조사할 대상 |
| 2 | pgrep으로 실제 실행 옵션 확인 | KV cache가 q8_0, -ub 1024 | 메모리 계산 2배 수정 |
| 3 | 가설 1: micro-batch 경계 | 계단 없음 | 기각 |
| 4 | 대조군 도입 | 144초에 +7.1% 드리프트 | 이 기계는 2분이면 느려진다 |
| 5 | 차가운 상태로 재측정 | 새 토큰 −97개 | 불가능한 값 = 단서 |
| 6 | 원인 규명 | P가 측정값이 아니라 추정값 | 범인은 내 자 |
| 7 | head 고정 + 정확 측정 | 잔차 일정 ~100ms | 3배 잔차는 없었다 |
| 8 | 곡선 재측정 | 91.6%가 71.8%보다 느림 | 탐색 순서가 캐시를 오염 |
| 9 | 순서 수정 | 4,304 → 997 ms | 곡선 완성, 전 구간 0.98~1.06× |
13. 처음 재보는 분들께
이 글에서 가져갈 게 딱 네 개입니다.
- 잴 수 있는 값을 추정하지 마세요. 이 이야기 전체가, 서버에 한 번 물어보면 알 수 있는 값을 제가 계산으로 때웠기 때문에 벌어졌습니다.
- 대조군을 넣으세요. 실험 처음과 끝에 같은 작업을 한 번씩. 값이 5% 넘게 달라지면 그 데이터는 버립니다. 샘플 두 번 값으로 “이 실험이 진짜인가”를 알 수 있습니다.
- 불가능한 값은 선물입니다. −97개가 아니었다면 95%의 3배 차이는 그냥 “신기한 현상”으로 남았을 겁니다. 이상한 값이 나오면 반갑게 파세요.
- 계측은 실험의 일부입니다. “읽기만 했다”고 생각한 코드가 캐시 상태를 바꿔 측정을 망쳤습니다.
그리고 하나 더 — 틀린 결론을 두 번 썼고, 두 번 다 데이터를 다시 보고 고쳤습니다. 기록에 남겨두는 이유는, 이게 이 일의 정상적인 모습이기 때문입니다. 처음부터 맞히는 게 실력이 아니라, 틀린 걸 알아채는 장치를 미리 심어두는 게 실력입니다.
다음 편
여기까지는 전부 사용자 1명 기준입니다. 다음은 진짜 백엔드 이야기입니다.
- FastAPI + SSE 스트리밍을 앞에 붙이고, 그 앞에 Spring Boot
- k6로 동시 요청을 올려가며 latency 곡선이 수직으로 꺾이는 지점 찾기
- Prometheus + Grafana로 TTFT, TPOT, throughput, P50/P99 관측
1편의 메모리 계산은 4K 문맥에서 동시 135건을 예측했습니다. 그게 맞는지 재보는 게 다음 편입니다.
모든 숫자는 Mac mini M4(32GB) / Ollama / Qwen2.5 7B Q4_K_M(일부 3B) 환경에서 2026-08-31 ~ 09-01에 측정했습니다. 각 실험의 대조군 드리프트를 함께 표기했으며, 5%를 넘긴 실험은 본문에서 오염된 것으로 명시했습니다.