토큰 하나가 나오기까지
안에서 벌어지는 일.
Large Language Model Serving — Transformer 안을 열어보고, 토큰 생성 루프를 직접 돌려보고, KV Cache · PagedAttention · streaming · batching이 왜 그렇게 생겼는지까지.
수학은 거의 안 나옵니다. 나오는 것은 전부 접는 상자 안에 넣었고, 본문은 곱셈과 제곱만으로 따라갈 수 있게 썼습니다.
① 전문 용어는 영어 그대로. prefill을 억지로 번역하지 않습니다. 대신 뜻을 한국어로 풀어둡니다.
② 용어는 나오기 전에 뜻풀이. 모르는 단어로 설명하지 않습니다.
③ 그림 12개 전부 조작 가능. 슬라이더를 끝까지 밀어보는 게 이 자료의 핵심입니다.
④ 장마다 세 줄 요약. 거기만 읽어도 넘어갈 수 있게.
들어가며
CH1에서 "LLM 서빙에는 Transformer에 대한 이해가 사실상 전제 조건"이라는 말이 여러 번 나왔습니다.
그때는 KV Cache, attention 비용 같은 단어를 이름만 던지고 넘어갔습니다.
이 글이 그 빚을 갚는 자리입니다.
Transformer 설명 자료는 많습니다. 그런데 대부분 학습(training) 관점이라, "그래서 서버에서 요청 하나가 들어오면 뭐가 느린가"에는 답을 안 해줍니다. 이 글은 실행(inference) 관점만 다룹니다. 수식을 증명하지 않고, 어디서 시간이 새는지만 봅니다.
다루는 범위는 Transformer 내부 구조 · 토큰 생성 루프 · KV Cache · prefill/decode · vLLM · PagedAttention · streaming · batching입니다. 반대로 모델 학습 방법, 어텐션 수식의 유도, 파인튜닝, 웹 서비스로 감싸는 방법은 다루지 않습니다. 마지막 것은 스터디 CH3의 몫입니다.
- LLM은 토큰을 한 개씩 뱉습니다. 요청 하나가 forward pass 한 번이 아니라 수십~수백 번입니다. 이게 모든 이야기의 출발점입니다.
- self-attention은 시퀀스 길이의 제곱으로 비싸집니다. KV Cache가 그걸 선형으로 낮춥니다 — 대신 GPU 메모리를 씁니다.
- prefill은 compute-bound, decode는 memory-bound. 이 구분이 PagedAttention · continuous batching · streaming 등 이후 모든 최적화의 설계 근거입니다.
읽는 방법
· 굵은 글씨만 따라가도 흐름이 이어집니다.
· 회색으로 접힌 상자(▸ 코드로도 보고 싶다면)는 안 열어도 됩니다. 열지 않아도 본문은 완결됩니다.
· 그림은 전부 슬라이더를 밀고 버튼을 눌러보는 용도입니다. 특히 끝까지 밀었을 때 무슨 일이 나는지가 이 자료에서 제일 중요합니다.
· 각 장 끝의 세 줄 요약만 읽고 넘어가도 괜찮습니다.
① Decoder-only Transformer는 프롬프트 하나를 받아 다음 토큰 하나를 만들기까지 내부적으로 어떤 단계를 거치는가? (1~4장)
② self-attention은 왜 이렇게 비싸며, 그 비용을 KV Cache가 어떻게 줄이는가? (5~6장)
③ prefill과 decode는 왜 서로 다른 병목을 가지며, 그것이 실제 서빙 최적화 전략을 어떻게 결정하는가? (7~11장)
— Part 1. Inside the Mind of a Transformer — 먼저 모델 안을 열어봅니다 —
01LLM은 어떻게 여기까지 왔나
한 줄 요약: RNN은 순서대로만 처리할 수 있어서 GPU를 못 채웠습니다. Transformer는 그 족쇄를 풀었습니다.
역사를 아는 건 교양 때문이 아닙니다. 지금 구조가 왜 이렇게 생겼는지가 거기 들어 있고, 그게 곧 어디를 최적화할 수 있는지를 알려주기 때문입니다.
이 장에서는 RNN이 왜 막혔고 Transformer가 무엇을 풀었는지, 그리고 그 해결책이 대신 어떤 비용을 만들어냈는지를 알게 됩니다. 그 비용이 이 글 전체의 주제입니다.
- Token (토큰)
- 모델이 글을 다루는 최소 단위. 단어 조각 하나 정도입니다. 영어 단어 하나가 1~3 토큰, 한국어는 한 글자에 1~2 토큰쯤 됩니다.
- Embedding (임베딩)
- 토큰을 숫자 벡터로 바꾼 것. 뜻이 비슷한 단어가 비슷한 숫자 뭉치가 되도록 학습됩니다.
- RNN · LSTM · GRU
- Transformer 이전의 순차 처리 모델. 앞 토큰을 처리해야 다음 토큰을 처리할 수 있는 구조입니다.
- 병렬화 (parallelization)
- 여러 계산을 동시에 돌리는 것. GPU는 이걸 잘하라고 만든 물건입니다.
- Self-Attention
- 각 토큰이 다른 모든 토큰을 둘러보고 누가 중요한지 스스로 가중치를 매기는 장치. 4장에서 자세히 봅니다.
- Positional Encoding
- 순서를 안 보고 한꺼번에 처리하니까, "이 토큰이 몇 번째인지"를 따로 숫자로 넣어주는 장치.
- Pre-training · Fine-tuning
- 대규모 텍스트로 먼저 크게 학습해두고, 나중에 특정 작업에 맞춰 다듬는 2단계 방식.
- Zero-shot · Few-shot
- 예시를 하나도 안 주고 지시만으로 / 몇 개만 주고 새 작업을 해내는 능력. 가중치는 안 바뀝니다.
비유 먼저 — 릴레이 주자와 회의실
RNN은 릴레이 경주입니다. 1번 주자가 뛰어야 2번이 출발할 수 있습니다. 주자를 100명으로 늘려도 동시에 뛸 수는 없습니다. 트랙이 넓어져도 소용이 없습니다. GPU라는 넓은 트랙을 사놓고 한 명씩만 뛰게 하는 셈이었습니다.
Transformer는 회의실입니다. 참석자 전원이 동시에 자리에 앉아 서로를 둘러봅니다. 누가 누구 말을 얼마나 참고할지는 각자 정합니다(self-attention). 대신 대가가 있습니다 — 참석자가 늘면 서로 눈을 마주치는 쌍의 수가 제곱으로 늘어납니다.
이 "제곱"이 이 글 전체를 관통하는 문제입니다. 5장에서 숫자로 만나게 됩니다.
작은 숫자로 한 번
토큰 8개짜리 문장을 처리한다고 해봅시다.
1번 → 2번 → … → 8번
순서대로만 가능 = 8 스텝 // GPU가 넓어도 못 줄임
── Transformer ──
8개를 한 번에 = 1 스텝
대신 토큰 쌍의 수 = 8 × 8 = 64개 계산
// 토큰이 100개면? 100 스텝 vs 1 스텝 (하지만 10,000개 계산)
여기서 중요한 건 총 계산량이 아니라 "동시에 할 수 있느냐"입니다. GPU는 계산이 많은 건 견디지만, 순서를 기다리는 건 못 견딥니다.
RNN vs Transformer — GPU를 채울 수 있느냐
↔ 슬라이더여기까지 온 길
| 시기 | 무엇 | 무엇을 풀었나 / 무엇이 막혔나 |
|---|---|---|
| 2013 | Word2Vec | 단어를 밀집 벡터(dense embedding)로 표현해 의미 관계를 잡아냄 |
| 2013 | RNN | 언어의 순차성을 모델링. 감성 분석·텍스트 생성에 쓰임 |
| 2014 | LSTM / GRU | 게이트와 메모리 셀로 장기 의존성을 개선. 그래도 순차 처리는 그대로 |
| 2017 | Transformer Attention Is All You Need | 순환 레이어를 self-attention + positional encoding으로 대체 → 병렬 처리 가능, 장거리 의존성도 잘 잡음 |
| 이후 | BERT vs GPT | BERT는 양방향 인코더(문맥 이해에 강함), GPT는 단방향 디코더(생성에 강함). 이 글은 GPT 계열만 다룹니다 |
모델과 데이터를 키우자 학습 없이도 새 작업을 해내는 능력이 나타났습니다.
Zero-shot — 예시 없이 지시만으로. "이 문장을 프랑스어로 번역해줘"라고만 해도 번역합니다.
Few-shot — 프롬프트 안에 예시 몇 개를 넣으면 패턴을 참고합니다. "사과 → apple, 바나나 → banana, 포도 → ?"
두 경우 모두 모델의 weight는 바뀌지 않습니다. 프롬프트 문맥 안에서만 참고합니다.
파라미터 수의 변화가 이 흐름을 보여줍니다 — GPT-1: 1.17억 → GPT-3: 1,750억 → DeepSeek R1: 6,710억. "대규모 언어 모델(LLM)"이라는 말이 여기서 나왔습니다.
파라미터 수만으로 LLM을 정의하기는 어렵습니다. 긴 context 처리, instruction following, reasoning, multimodal, agentic workflow 같은 기능적 특성까지 함께 보는 쪽이 실제에 가깝습니다.
- 정의 — Transformer는 순환 구조를 self-attention + positional encoding으로 대체한 아키텍처입니다.
- 성질 — 이겼던 이유는 정확도만이 아니라 병렬 처리가 가능해 GPU를 꽉 채울 수 있었다는 것입니다. 대신 토큰 쌍이 제곱으로 늘어나는 비용을 떠안았습니다.
- 쓰임 — 생성형 LLM은 GPT 계열의 decoder-only 구조입니다. 이 글은 그것만 다룹니다.
— 여기까지: 병렬로 처리할 수 있게 됐다. 다음은 그럼에도 생성만은 순차라는 이야기 —
02자기회귀 — 토큰을 한 개씩 뱉는다는 것
한 줄 요약: 요청 하나는 forward pass 한 번이 아니라 수십~수백 번입니다. 서빙에서 이게 전부입니다.
아닙니다. 입력을 읽는 건 병렬이지만, 답을 만드는 건 여전히 한 글자씩입니다. 그리고 이 사실 하나가 LLM 서빙을 일반 API 서빙과 완전히 다른 문제로 만듭니다.
이 장에서는 자기회귀(autoregressive)가 정확히 무슨 뜻인지, 그리고 그게 GPU 점유 시간에 무엇을 의미하는지 알게 됩니다.
- Autoregressive (자기회귀)
- 자기가 방금 만든 출력을 다시 자기 입력으로 넣는 방식. LLM이 문장을 만드는 방법입니다.
- Forward pass
- 입력을 넣어 모델 전체를 한 번 통과시키는 것. 이걸 한 번 하면 토큰이 하나 나옵니다.
- Append (이어붙이기)
- 새로 만든 토큰을 입력 시퀀스 끝에 붙이는 동작. 그래서 입력이 매 스텝 길어집니다.
- Stop token · EOS
- "여기서 끝"을 뜻하는 특수 토큰. 이게 나오면 생성 루프가 멈춥니다.
- Context (문맥)
- 지금까지의 입력 + 생성된 것 전부. 다음 토큰은 이걸 통째로 조건으로 예측됩니다.
비유 먼저 — 끝말잇기
끝말잇기를 할 때 우리는 지금까지 나온 말 전부를 머릿속에 두고 다음 말을 고릅니다. 한 번에 열 단어를 미리 정해두지 않습니다. 하나 말하고, 그걸 포함해서 다시 생각합니다.
LLM도 똑같습니다. 다른 점은 매번 처음부터 다시 읽는다는 것뿐입니다. 사람이라면 "아까 뭐라고 했지"를 기억하고 있지만, 모델은 매 스텝 전체 시퀀스를 다시 입력받습니다. 이 낭비를 없애는 게 6장의 KV Cache입니다.
작은 숫자로 한 번
"미국 수도에 대한 짧은 소개를 써줘"라는 프롬프트가 들어왔다고 해봅시다.
입력: [프롬프트 11토큰] → 출력: Washington
── 스텝 2 ──
입력: [프롬프트 11토큰 + Washington] → D.C.
── 스텝 3 ──
입력: [… + Washington + D.C.] → is
── 스텝 4 ──
입력: [… + is] → the
──────────────────
토큰 100개를 만들려면 → forward pass 100번
읽은 토큰 총량 = 11 + 12 + 13 + … + 110 = 6,050개
토큰 100개를 얻으려고 6,050개를 읽습니다. 60배입니다. 이 숫자가 이 글에서 계속 커집니다.
자기회귀 루프 — 입력이 매 스텝 길어진다
↔ 재생 + 슬라이더"읽은 토큰 총량"이 곧 GPU가 한 일의 양입니다. 생성 토큰 수와 전혀 비례하지 않습니다.
운영 관점에서 이게 왜 중요한가
- 요청 하나가 GPU를 오래 붙잡습니다 일반 API는 요청 하나에 함수 한 번입니다. LLM은 요청 하나에 수백 번의 forward pass입니다.
- 생성 길이를 미리 알 수 없습니다 10토큰에서 끝날지 2,000토큰까지 갈지 모릅니다. 그래서 자원을 미리 잡아두기가 어렵습니다 — 9장 PagedAttention이 푸는 문제입니다.
- 문맥 일관성은 이 방식 덕분입니다 매 토큰이 앞의 전부를 조건으로 나오기 때문에 말이 이어집니다. 사람이 말을 만드는 방식과 닮았습니다.
- 정의 — LLM은 autoregressive합니다. 토큰을 하나 만들면 그걸 입력에 append하고 다시 돌립니다.
- 성질 — 요청 하나 = forward pass 여러 번. 그리고 매 스텝 입력이 길어져서 읽는 총량이 생성량보다 훨씬 큽니다.
- 쓰임 — 생성 길이가 길수록 GPU 점유 시간이 길어집니다. 서빙 용량 계산의 출발점입니다.
— 여기까지: 한 개씩 뱉는다. 다음은 그 한 개를 만드는 내부 3단 구조 —
03Decoder-Only Transformer의 3단 구조
한 줄 요약: 토크나이저·임베딩 → 디코더 블록 여러 개 → LM head. 이게 전부입니다.
파라미터 수는 마법이 아니라 덧셈입니다. 어떤 부품이 몇 개씩 있는지 알면 계산됩니다. 그리고 그 계산이 곧 GPU 메모리를 얼마나 먹는지입니다.
이 장에서는 실제 모델(Qwen2.5-0.5B)의 설정값을 보면서 구조 → 파라미터 수 → 메모리가 어떻게 이어지는지를 직접 맞춰봅니다.
- Tokenizer (토크나이저)
- 글자를 토큰으로 쪼개고 숫자 ID로 바꾸는 도구. 모델마다 규칙이 다릅니다.
- Hidden size (
hidden_size) - 토큰 하나를 표현하는 벡터의 길이. Qwen2.5-0.5B는 896입니다. 모든 레이어를 이 크기로 흘러갑니다.
- Hidden state
- 디코더 블록을 통과한 문맥이 반영된 토큰 표현. 모양은 [토큰 수 × hidden size]입니다.
- Decoder block
- self-attention + FFN 한 세트. 이게 여러 층(예: 24개) 쌓입니다.
- FFN (Feed-Forward Network)
- 어텐션이 모은 문맥을 토큰 단위로 한 번 더 다듬는 층. 파라미터를 가장 많이 먹는 부분입니다.
- Intermediate size
- FFN 안쪽의 넓어지는 폭. Qwen2.5-0.5B는 4864로, hidden size의 약 5.4배입니다.
- LM head
- 마지막 hidden state를 어휘 전체에 대한 점수로 바꾸는 층.
- Logits
- 어휘 하나하나에 대한 날것의 점수. 확률로 바꾸려면 softmax를 씌웁니다.
- Vocabulary size
- 모델이 아는 토큰의 총 개수. Qwen2.5는 151,936개(다국어라 큼), 영어 전용 모델은 보통 5만 개 내외입니다.
bfloat16/float16- 숫자 하나를 16비트로 저장하는 형식. 32비트 대비 메모리가 절반입니다. GPU 서빙의 표준입니다.
비유 먼저 — 번역 사무실
1단계 접수 창구가 원문을 문장 조각으로 쪼개고 사내 코드번호를 붙입니다(토크나이저 → 임베딩).
2단계 검토실 24개를 차례로 통과합니다. 각 방에서 담당자들이 서로 문서를 돌려보고(self-attention), 각자 맡은 부분을 다듬습니다(FFN). 방을 나올 때마다 문서가 조금씩 정교해집니다.
3단계 최종 판정에서 "다음에 올 단어 후보"에 점수를 매깁니다(LM head). 여기서는 맨 마지막 토큰의 결과만 씁니다. 나머지는 버립니다 — 이 낭비가 6장의 주제가 됩니다.
작은 숫자로 한 번 — 파라미터 4.94억은 어디서 나오나
Qwen2.5-0.5B의 실제 설정값으로 직접 더해보겠습니다.
── 임베딩 ──
151,936 × 896 = 136,134,656
── 디코더 블록 1개 ──
attention (q·k·v·o) ≈ 1,835,008
FFN (gate·up·down) ≈ 13,074,432
합계 ≈ 14,909,440
── 24층 ──
14,909,440 × 24 = 357,826,560
──────────────────
총합 ≈ 493,961,216 // 실제 494,032,768 (bias 등 제외해 근사)
두 가지가 눈에 띕니다. FFN이 attention보다 7배 무겁습니다. 그리고 임베딩만으로 전체의 28%입니다 — 어휘가 15만 개라서 그렇습니다.
모델 설정에서 파라미터 수와 메모리까지
↔ 슬라이더 4개Qwen2.5-0.5B의 실제 설정값
모델을 로드하고 model.config를 찍으면 이 값들이 나옵니다.
서빙 전에 이걸 먼저 봐야 합니다. GPU 메모리 추정, 양자화 여부, 배치 크기 결정이 전부 여기서 시작합니다.
| 설정 | 값 | 뜻 |
|---|---|---|
hidden_size | 896 | 토큰 하나를 표현하는 벡터 길이 |
num_hidden_layers | 24 | 디코더 블록이 24번 반복. 깊을수록 표현력이 좋지만 지연시간도 증가 |
num_attention_heads | 14 | 어텐션을 14개로 나눠 병렬 계산. 헤드당 차원 = 896 ÷ 14 = 64 |
num_key_value_heads | 2 | GQA — K/V는 2개만 씁니다. KV Cache를 크게 아낍니다 (4장) |
intermediate_size | 4864 | FFN 안쪽 폭 |
vocab_size | 151,936 | 다국어 지원으로 매우 큼. 영어 전용 모델은 보통 5만 내외 |
max_position_embeddings | 32,768 | 한 번에 다룰 수 있는 최대 토큰 수(context window) |
dtype | bfloat16 | 16비트 저장 — 메모리 절반 |
use_cache | True | KV Cache를 쓰겠다는 설정. 6장의 주인공 |
tie_word_embeddings | True | 입력 임베딩과 LM head가 가중치를 공유. 작은 모델에서 흔한 절약 기법 |
| 총 파라미터 | 494,032,768 | 임베딩 1.36억 + 24개 레이어 3.58억 |
코드로도 보고 싶다면
config = model.config
print(config.hidden_size, config.num_hidden_layers)
print(sum(p.numel() for p in model.parameters()))
읽는 법 — 1줄에서 모델을 받고, 2줄의 config가 위 표의 값들을 전부 갖고 있습니다.
4줄은 모든 파라미터의 개수를 세는 것입니다. 이 값이 494,032,768이 나옵니다.
모델 구조를 직접 출력하면 이런 모양입니다.
embed_tokens: Embedding # 토큰 ID → 벡터
layers: ModuleList # 0 ~ 23
0: Qwen2DecoderLayer
self_attn: q_proj / k_proj / v_proj / o_proj
mlp: gate_proj / up_proj / down_proj
input_layernorm / post_attention_layernorm
… 23까지 같은 구조 반복
norm / rotary_emb
lm_head: Linear # hidden → vocab logits
레이어 24개는 구조가 완전히 같고 학습된 숫자만 다릅니다.
norm, rotary_emb, lm_head는 레이어 밖에 한 번씩만 있습니다 —
모든 레이어가 같은 위치 인코딩 로직(RoPE)을 공유한다는 뜻입니다.
mlp가 gate_proj+up_proj→down_proj 3개인 것은
Llama·Qwen 계열이 쓰는 게이트형 FFN(SwiGLU) 구조입니다. 옛 GPT류의 단순 2층 FFN이 아닙니다.
- 정의 — Decoder-only Transformer는 tokenizer/embedding → decoder block × N → LM head 세 단으로 되어 있습니다.
- 성질 — 파라미터 수는 hidden size의 제곱과 layer 수가 지배합니다. FFN이 attention보다 훨씬 무겁습니다.
- 쓰임 — 서빙 전에
config를 먼저 봅니다. 여기서 GPU 메모리 추정과 배치 전략이 결정됩니다.
— 여기까지: 3단 구조. 다음은 그 가운데 있는 self-attention이 정확히 무엇을 하는가 —
04Self-Attention — 문맥을 어떻게 잡아내나
한 줄 요약: 각 토큰이 다른 모든 토큰을 둘러봅니다. 그래서 토큰 쌍의 수만큼 계산이 듭니다.
문맥이 없으면 알 수 없습니다. self-attention은 모델이 이 판단을 하게 해주는 장치입니다.
서빙하는 입장에서 필요한 이해는 딱 두 가지입니다. 어텐션은 연산량이 매우 크다는 것, 그리고 그 비용이 입력 길이에 따라 커진다는 것. 이 장에서는 그 두 가지가 왜 그런지만 봅니다.
- Query (Q) · Key (K) · Value (V)
- 토큰마다 만드는 세 개의 벡터. Q는 "내가 찾는 것", K는 "내가 가진 표식", V는 "내가 실제로 넘겨줄 내용"입니다.
- 내적 (dot product)
- 두 벡터를 짝지어 곱해서 다 더하는 연산. 결과가 클수록 두 벡터가 비슷하다는 뜻입니다.
- Softmax
- 여러 점수를 합이 1인 비율로 바꾸는 함수. 점수를 "몇 퍼센트 참고할지"로 바꿔줍니다.
- Attention weight
- "이 토큰이 저 토큰을 얼마나 볼지"의 비율. 토큰 쌍마다 하나씩 생깁니다.
- Multi-Head Attention
- 어텐션을 여러 개(head) 병렬로 계산하는 것. 문법·위치·의미 등 다른 종류의 관계를 동시에 잡습니다.
- Causal mask
- 각 토큰이 자기보다 뒤는 못 보게 가리는 장치. 생성 모델에는 필수입니다 — 미래를 보면 안 되니까요.
- GQA (Grouped Query Attention)
- Q는 헤드를 다 갖되 K/V는 몇 개만 만들어 나눠 쓰는 방식. KV Cache 크기를 줄이려고 씁니다.
- MHA / MQA
- K/V 헤드를 Q와 똑같이 다 갖는 것(MHA)과 딱 하나만 갖는 것(MQA). GQA는 그 사이입니다.
- O(L²D)
- "L의 제곱에 D를 곱한 만큼 계산이 든다"는 규모 표기. L은 토큰 수, D는 벡터 길이입니다.
비유 먼저 — 회의실의 눈맞춤
1장의 회의실로 돌아가 봅시다. 참석자 한 명이 발언하려면 모든 참석자를 한 번씩 쳐다보고 "이 사람 말을 얼마나 반영할까"를 정합니다. 그게 attention weight입니다.
참석자가 8명이면 눈맞춤은 8×8 = 64번. 100명이면 10,000번. 참석자가 12배 늘면 눈맞춤은 156배 늘어납니다. 이게 제곱의 무서움입니다.
그리고 생성 모델에는 규칙이 하나 더 있습니다. 자기보다 나중에 앉은 사람은 볼 수 없습니다(causal mask). 아직 만들지 않은 미래 토큰을 참고하면 안 되니까요.
어텐션은 네 단계입니다
- Q와 K를 내적 어떤 토큰의 Query와 모든 토큰의 Key를 짝지어 곱합니다. 결과는 "얼마나 관련 있는지" 점수입니다.
- 스케일링
점수를
1/√head_dim으로 나눕니다. Qwen2.5-0.5B는 head_dim이 64라 0.125를 곱합니다. - Softmax 점수를 합이 1인 비율로 바꿉니다. 이게 attention weight입니다.
- Value의 가중합 그 비율로 Value 벡터들을 섞습니다. 결과가 그 토큰의 새 표현입니다.
예를 들어 "Write a short introduction about the US capital city"에서 capital을 처리할 때, self-attention은 US를 참조해 "국가의 수도"임을 파악하고(금융 용어가 아니라), introduction을 참조해 간결한 톤임을 알고, Write를 참조해 지시문임을 파악합니다.
Attention 행렬 — 제곱으로 커지는 것과 GQA가 아끼는 것
↔ 클릭 + 슬라이더 + 토글GQA — 서빙 관점에서 가장 중요한 설계 선택
Qwen2.5-0.5B의 어텐션 층을 뜯어보면 Q/K/V의 크기가 다릅니다.
k_proj [128, 896] // 2개 헤드 × 64 = 128
v_proj [128, 896] // 2개 헤드 × 64 = 128
o_proj [896, 896]
──────────────────
num_key_value_groups = 14 ÷ 2 = 7
// Query 헤드 7개가 K/V 헤드 하나를 공유
즉 Query는 14개 헤드를 온전히 갖지만, Key/Value는 2개만 만듭니다. 일반적인 MHA였다면 K/V도 14개를 다 저장해야 합니다.
추론 중에 계속 들고 있어야 하는 게 KV Cache입니다(6장). GQA는 그 크기를 약 7배 줄입니다. 같은 GPU에 7배 많은 동시 요청을 담을 수 있다는 뜻입니다. 학습 성능을 조금 양보하고 서빙 비용을 크게 아낀 설계 선택입니다.
LLM 서빙 엔지니어에게 필요한 건 수식이 아니라 이 두 문장입니다.
① 어텐션은 연산량이 매우 크다 — 특히 프롬프트 전체를 한 번에 처리하는 prefill에서.
② 메모리·지연시간 비용이 입력 길이에 따라 커진다.
이 정도면 추론 성능 튜닝, KV Cache 관리, GPU 간 분산을 다루는 데 충분합니다.
계산 복잡도를 숫자로 보고 싶다면
읽는 법 — L은 시퀀스(토큰) 길이, D는 벡터 차원입니다. Query 하나와 Key 하나의 내적에 D번의 곱셈이 듭니다. 그 내적을 모든 Q × 모든 K = L × L 번 해야 하니 총 L² × D가 됩니다.
L이 2배가 되면 계산은 4배가 됩니다. 그런데 이 큰 계산으로 얻는 건 다음 토큰 하나뿐입니다. 그리고 다음 스텝에서 L+1 길이로 이걸 또 합니다.
이 낭비를 없애는 게 6장의 KV Cache이고, 그때 복잡도는 O(L · D)로 떨어집니다.
- 정의 — self-attention은 토큰마다 Q·K·V를 만들고, Q와 K의 내적 → softmax → V의 가중합으로 문맥을 반영합니다.
- 성질 — 토큰 쌍마다 계산하므로 비용이 길이의 제곱으로 늘어납니다. causal mask 때문에 각 토큰은 앞쪽만 봅니다.
- 쓰임 — GQA는 K/V 헤드만 줄여 KV Cache를 약 7배 아낍니다. 서빙 비용에 직접 영향을 주는 설계 선택입니다.
— Part 2. Executing LLM Generation — 이제 실제로 돌려보고, 어디서 시간이 새는지 봅니다 —
05생성 루프를 직접 돌려보기 — 왜 점점 느려지나
한 줄 요약: 토큰을 만들수록 한 개당 시간이 늘어납니다. 매번 처음부터 다시 읽기 때문입니다.
모델은 그대로이고 하는 일도 같아 보이는데, 실제로 재보면 뒤로 갈수록 느려집니다. 이 관찰 하나가 LLM 서빙 최적화의 출발점입니다.
이 장에서는 pipeline() 같은 편의 함수를 걷어내고
생성 루프를 한 줄씩 직접 돌립니다. 그러면 낭비가 어디에 있는지 눈에 보입니다.
pipeline()- Hugging Face의 고수준 편의 함수. 토크나이징·로딩·생성·디코딩을 다 감춰줍니다. 빠른 실험에는 좋지만 내부가 안 보입니다.
AutoModelForCausalLM- 다음 토큰을 예측하는 모델을 직접 다루는 클래스. 루프를 우리가 씁니다.
- Sampling (샘플링)
- 확률분포에서 실제로 토큰 하나를 뽑는 행위. 가장 높은 것만 고르지 않고 무작위로 뽑기도 합니다 — 그래서 같은 프롬프트도 결과가 매번 다릅니다.
torch.multinomial- 확률에 비례해 무작위로 하나 뽑는 함수.
- 중복 계산 (redundant computation)
- 이미 한 계산을 또 하는 것. 이 장의 주인공입니다.
비유 먼저 — 매번 처음부터 다시 읽는 사람
책을 읽으면서 요약을 한 줄씩 쓰는 사람이 있습니다. 그런데 이 사람은 한 줄 쓸 때마다 책을 1페이지부터 다시 읽습니다.
10번째 줄을 쓸 때는 10페이지를 다시 읽고, 90번째 줄을 쓸 때는 90페이지를 다시 읽습니다. 쓰는 양은 똑같은데 걸리는 시간은 계속 늘어납니다.
2장에서 본 자기회귀 루프가 정확히 이 모양입니다.
매 스텝 idx_cond = idx — 즉 지금까지의 전체 시퀀스를 통째로 모델에 다시 넣습니다.
작은 숫자로 한 번
프롬프트가 20토큰이고 100개를 생성한다고 해봅시다. 4장의 O(L²D)를 그대로 적용하면 —
20 × 20 = 400 쌍
── 스텝 50 (L=69) ──
69 × 69 = 4,761 쌍 // 12배
── 스텝 100 (L=119) ──
119 × 119 = 14,161 쌍 // 35배
──────────────────
100 스텝 누적 = 약 495,000 쌍
// 이 중 대부분은 앞 스텝에서 이미 계산한 것
마지막 토큰 하나를 만드느라 첫 토큰보다 35배 많은 계산을 합니다. 그리고 그 계산의 대부분은 바로 전 스텝에서 이미 했던 것입니다.
토큰별 생성 시간 — 뒤로 갈수록 느려지는 그래프
↔ 슬라이더 + 재생막대가 우상향하면 캐시가 없는 것입니다. 6장을 적용하면 평평해집니다.
실제로 재보면
책의 실습에서 Qwen2.5-0.5B로 100토큰을 생성했을 때 총 12.87초, 토큰당 평균 약 0.12초가 나왔습니다. 그리고 첫 토큰을 빼고 보면 뒤로 갈수록 시간이 늘어나는 패턴이 뚜렷하게 관찰됐습니다.
"이미 계산해둔 이전 토큰들의 결과를 재사용하면 되지 않을까? 새로 추가된 토큰에 대해서만 계산하면 되는 것 아닌가?"
이 질문의 답이 다음 장입니다.
루프 코드로도 보고 싶다면
idx_cond = idx # (A) 지금까지 전체를 컨텍스트로
outputs = model(idx_cond) # (B) forward pass
logits = outputs.logits[:, -1, :] # (C) 마지막 토큰의 점수만
probas = torch.softmax(logits, -1)
idx_next = torch.multinomial(probas, 1)
idx = torch.cat((idx, idx_next), dim=1) # (D) 이어붙이기
if idx_next.item() == tokenizer.eos_token_id: break # (E)
읽는 법 — (A)가 이 장의 범인입니다. idx 전체를 매번 다시 넣습니다.
(C)에서 [:, -1, :]로 마지막 토큰의 결과만 쓰는 것도 눈여겨보세요 —
앞쪽 토큰들에 대해 계산한 출력은 전부 버립니다.
torch.multinomial은 확률에 비례해 무작위로 뽑습니다.
그래서 같은 프롬프트라도 실행할 때마다 결과가 달라집니다. 항상 최고 확률만 고르고 싶다면 argmax를 씁니다.
- 정의 — 생성 루프는 전체 시퀀스 입력 → forward → 마지막 토큰의 logits → 샘플링 → 이어붙이기의 반복입니다.
- 성질 — 매 스텝 전체를 다시 처리하므로 토큰당 시간이 뒤로 갈수록 늘어납니다. 계산의 대부분은 중복입니다.
- 쓰임 — 토큰별 생성 시간 그래프가 우상향이면 캐시가 없다는 신호입니다.
— 여기까지: 중복 계산을 발견했다. 다음은 그걸 없애는 방법 —
06KV Cache — 무엇을 저장하고 무엇을 아끼나
한 줄 요약: 이전 토큰들의 Key와 Value만 저장해두면, 매 스텝 마지막 토큰 하나만 계산해도 결과가 같습니다.
"캐시니까 결과를 저장하나 보다" 정도로 넘어가면 6~9장이 전부 흐려집니다. K와 V만 저장하고 Q는 저장하지 않는 이유를 알면, PagedAttention이 왜 필요한지까지 자연스럽게 이어집니다.
이 장에서는 KV Cache가 정확히 무엇을 저장하고, 어떤 중복을 피하고, 대신 무엇을 지불하는지를 봅니다.
- Cache (캐시)
- 이미 계산한 걸 또 쓰려고 저장해두는 것. 개발자에게 익숙한 그 개념 그대로입니다.
- KV Cache
- 어텐션의 Key와 Value 시퀀스를 저장해두는 것. Q는 저장하지 않습니다.
past_key_values- PyTorch/Transformers에서 KV Cache를 담는 변수 이름. 매 스텝 갱신해 다음 스텝에 넘깁니다.
use_cache=True- 캐시를 쓰고 만들라는 설정. 3장의
config에 이미 나왔습니다. - Incremental 계산
- 전체를 다시 하지 않고 새로 늘어난 부분만 계산하는 것.
- O(LD)
- 길이에 비례하는 계산량. 제곱이 아니라 선형입니다.
비유 먼저 — 회의록을 남기는 사람
5장의 "매번 처음부터 다시 읽는 사람"에게 회의록을 쥐여줍시다.
이제 이 사람은 새 발언이 나올 때마다 그 발언만 회의록에 한 줄 추가하고, 요약할 때는 회의록을 참고합니다. 책을 처음부터 다시 읽지 않습니다.
여기서 중요한 건 회의록에 무엇을 적느냐입니다. "누가 무슨 말을 했는지"(Key와 Value)는 적어두지만, "지금 내가 뭘 궁금해하는지"(Query)는 적지 않습니다 — 그건 매번 새로 생기는 것이니까요.
왜 Q는 저장하지 않아도 되나 — 세 가지 사실
- Query는 서로 의존하지 않습니다 토큰 A의 어텐션 결과를 계산하는 데 토큰 B의 Query는 필요 없습니다. Query끼리는 완전히 독립입니다. 그래서 마지막 Query 하나만 넣어도 결과가 같습니다.
- 출력은 마지막 것만 씁니다
5장에서 본
logits[:, -1, :]그대로입니다. 앞쪽 토큰의 출력은 어차피 버립니다. - Key와 Value는 전체가 필요합니다 softmax와 가중합이 시퀀스 전체에 걸쳐 이뤄지기 때문입니다. 하지만 이건 직전 스텝에서 이미 계산한 것이라 저장해두면 됩니다.
이 셋이 맞아떨어져서, 마지막 토큰 하나만 입력해도 똑같은 다음 토큰이 나옵니다.
작은 숫자로 한 번
스텝마다 Q도 L개, K도 L개
내적 횟수 = L × L → O(L² D)
── 캐시 있음 ──
Q는 1개 (마지막 것만), K는 캐시에서 L개
내적 횟수 = 1 × L → O(L D)
──────────────────
L = 120 일 때
14,400 → 120 // 120배 절약
캐시 유무 — 누적 시간과 KV Cache 메모리
↔ 슬라이더 + 토글실측 결과
| 구분 | 100토큰 생성 | 토큰별 시간 패턴 |
|---|---|---|
| KV Cache 없음 | 9.12초 | 뒤로 갈수록 계속 느려짐 (매번 전체 재처리) |
| KV Cache 사용 | 3.14초 | 첫 토큰 이후 일정하게 유지 |
약 3배입니다. 모델도 GPU도 그대로이고, 바꾼 건 설정 두 줄입니다.
KV Cache는 추론에서만 쓸 수 있습니다. 학습 단계에서는 모든 위치의 출력이 다 필요하기 때문에(전부 손실 계산에 들어감) 마지막 토큰만 계산할 수 없습니다.
KV Cache는 계산을 메모리와 맞바꾼 것입니다. 캐시 자체가 GPU 메모리를 먹고, 시퀀스가 길어질수록 계속 커집니다.
그래서 서빙에서는 KV Cache 메모리 관리가 throughput · latency · 동시 처리 수를 결정하는 핵심 요소가 됩니다. 이 문제를 정면으로 다루는 것이 9장의 PagedAttention입니다.
코드로도 보고 싶다면
for _ in range(n):
outputs = model(input_ids=input_ids,
past_key_values=past_key_values, # 이전 캐시 전달
use_cache=True)
past_key_values = outputs.past_key_values # 캐시 갱신
…
input_ids = generated_token_id # 새 토큰 하나만 입력!
읽는 법 — 5장 코드와의 차이는 딱 세 곳입니다.
① 입력이 idx 전체가 아니라 방금 만든 토큰 하나입니다.
② 모델 호출 때 past_key_values를 같이 넘깁니다.
③ 매 스텝 캐시를 받아 갱신합니다.
실제 서빙 프레임워크(vLLM 등)에서는 이 관리가 훨씬 정교하게 내장되어 있어서 우리가 직접 이 코드를 쓸 일은 거의 없습니다. 하지만 안에서 이 일이 일어난다는 것은 알아야 합니다.
- 정의 — KV Cache는 이전 토큰들의 Key와 Value 시퀀스를 저장해두고 다음 스텝에서 재사용하는 것입니다.
- 성질 — Query는 서로 독립이고 출력은 마지막 것만 쓰기 때문에, 마지막 토큰 하나만 입력해도 결과가 같습니다. 복잡도가 O(L²D) → O(LD)로 떨어집니다.
- 쓰임 — 실측 9.12초 → 3.14초. 대신 GPU 메모리를 계속 먹으므로, 서빙에서는 KV Cache 메모리 관리가 핵심 과제가 됩니다.
— 여기까지: 캐시로 반복을 줄였다. 다음은 남은 두 구간의 성격이 완전히 다르다는 이야기 —
07Prefill과 Decode — 병목이 서로 다른 두 단계
한 줄 요약: prefill은 GPU 연산이 모자라고, decode는 메모리 대역폭이 모자랍니다. 처방이 정반대입니다.
GPU 사용률이 낮다고 GPU가 남는 게 아닙니다. 메모리 대역폭이 먼저 포화되면 연산 유닛은 놀면서도 전체는 느려집니다.
이 장에서는 LLM 실행이 성격이 전혀 다른 두 구간으로 나뉜다는 것, 그리고 이 구분이 이후 모든 최적화의 설계 근거라는 것을 알게 됩니다.
- Prefill (프리필)
- 입력 프롬프트 전체를 한 번에 처리해 첫 토큰을 만들고 KV Cache를 채우는 단계.
- Decode (디코드)
- 그 뒤로 토큰을 하나씩 만들어내는 단계. 2·5·6장에서 본 그 루프입니다.
- Compute-bound
- GPU의 계산 유닛이 병목인 상태. 연산이 많아서 느린 것.
- Memory-bound
- GPU 메모리에서 데이터를 실어 나르는 통로(대역폭)가 병목인 상태. 계산할 게 적은데도 느린 것.
- HBM
- GPU에 붙어 있는 고대역폭 메모리. 모델 weight와 KV Cache가 여기 삽니다.
- TTFT (Time To First Token)
- 첫 글자가 뜨기까지의 시간. 거의 prefill 시간입니다.
- ITL (Inter-Token Latency)
- 토큰과 토큰 사이의 간격. decode 속도가 결정합니다.
- P/D Disaggregation
- prefill과 decode를 아예 다른 장비에서 돌리는 서빙 구조.
비유 먼저 — 식당의 두 종류 바쁨
Prefill은 재료 손질입니다. 주문서를 받자마자 채소를 다 썰어야 합니다. 손이 많이 가고 일감 자체가 큽니다. 주방 인원(GPU 연산 유닛)이 병목입니다.
Decode는 접시 나르기입니다. 한 접시씩 계속 왔다 갔다 합니다. 힘든 일은 아닌데 동선(대역폭)이 좁으면 아무리 요리사가 많아도 못 냅니다.
두 바쁨은 처방이 다릅니다. 앞은 사람을 더 뽑아야 하고, 뒤는 복도를 넓혀야 합니다. 그런데 같은 주방에 두면 서로를 막습니다 — 재료를 썰고 있으면 접시가 안 나갑니다.
작은 숫자로 한 번
── prefill ──
500토큰을 한 번에 → 500 × 500 = 250,000 쌍
forward pass 1번, 하지만 아주 무거움
── decode ──
토큰 하나당 1 × (501…600) 쌍
forward pass 100번, 각각은 아주 가벼움
──────────────────
계산량은 prefill이 크지만
매번 모델 weight 전체를 메모리에서 읽어야 하는 건 decode
여기가 핵심입니다. decode는 계산할 게 거의 없는데도 매 스텝 모델 weight 전부와 KV Cache 전부를 메모리에서 끌어와야 합니다. 그래서 연산 유닛은 놀고 대역폭만 꽉 찹니다.
두 요청이 한 GPU에서 만나면 — colocated vs disaggregated
↔ 슬라이더 + 토글prefill 한 건이 뒤에 있는 모든 decode를 밀어냅니다. p95·p99 지연시간이 여기서 나빠집니다.
둘의 성격 비교
| 구분 | Prefill | Decode |
|---|---|---|
| 하는 일 | 프롬프트 전체를 한 번에 처리, KV Cache 채움 | 토큰을 하나씩 생성, KV Cache 읽고 쓰기 반복 |
| 병목 | Compute-bound — GPU 연산 유닛 | Memory-bound — HBM 대역폭 |
| 복잡도 | 시퀀스 길이에 대해 제곱 | 토큰당 선형 (캐시 덕분) |
| 영향 주는 지표 | TTFT | ITL · token throughput |
| 실행 횟수 | 요청당 1번 | 요청당 수십~수백 번 |
| 처방 | 연산 성능 · 병렬화 | 대역폭 · KV Cache 관리 · 배치 |
그림 5·6에서 본 첫 막대만 유독 긴 그래프가 바로 이것입니다. 첫 막대 = prefill, 나머지 = decode.
긴 프롬프트(500페이지 PDF 요약 등) → prefill이 병목. 프롬프트 처리 속도를 최적화해야 합니다.
짧은 프롬프트 + 긴 생성(챗봇 답변, 스토리 생성) → decode가 병목. 토큰 생성 속도와 메모리 관리가 중요합니다.
prefill과 decode를 서로 다른 하드웨어에서 돌리는 서빙 구조입니다.
전용 자원 할당 — 각각 독립적으로 스케줄링·확장할 수 있습니다.
프롬프트가 많이 겹치는 워크로드(멀티턴 대화, 에이전트)라면 KV Cache를 크게 재사용할 수 있어 prefill 몫을 줄이고 decode에 더 줄 수 있습니다.
병렬 실행 — 서로 간섭하지 않아 동시성과 처리량이 오릅니다. tail latency(p95·p99)도 개선됩니다.
독립 튜닝 — TTFT 목표와 ITL 목표에 각각 다른 최적화(텐서 병렬, 파이프라인 병렬 등)를 적용할 수 있습니다.
다만 만능은 아닙니다. 장비를 나누는 만큼 KV Cache를 네트워크로 옮겨야 하고, 운영 복잡도도 올라갑니다. vLLM · SGLang · NVIDIA Dynamo · llm-d 등이 이 구조를 지원합니다.
memory bandwidth가 병목이면 연산 유닛이 놀아도 전체 성능은 제한됩니다. 모니터링에서 GPU utilization만 보고 "여유 있네"라고 판단하면 틀립니다.
- 정의 — prefill은 프롬프트 전체를 한 번에 처리해 KV Cache를 채우고, decode는 토큰을 하나씩 생성합니다.
- 성질 — prefill은 compute-bound(TTFT 결정), decode는 memory-bound(ITL 결정). 같은 GPU에 두면 서로를 막습니다.
- 쓰임 — 이 구분이 PagedAttention · continuous batching · P/D disaggregation 등 이후 모든 최적화의 설계 근거입니다.
— 여기까지: 직접 구현으로 원리를 봤다. 다음은 이걸 대신 해주는 도구 —
08서빙 프레임워크로 넘어가기 — vLLM은 왜 17배 빠른가
한 줄 요약: 같은 GPU, 같은 모델, 같은 프롬프트인데 19.58초와 1.12초가 나옵니다. 엔진 구조가 다르기 때문입니다.
직접 짠 루프는 이해에는 최고지만 운영에는 못 씁니다. 실제 서비스에는 요청 스케줄링, 동시 처리, 스트리밍, 취소, 메모리 관리가 다 필요합니다.
이 장에서는 서빙 프레임워크가 무엇을 대신 해주는지, 그리고 17배라는 숫자가 어디서 나오는지를 요인별로 뜯어봅니다.
- Serving framework
- 학습된 모델을 로드해 API로 노출하고 추론을 처리하도록 만든 전용 시스템. vLLM, SGLang 등이 있습니다.
- CUDA Graph
- GPU에 보낼 명령 순서를 미리 통째로 녹화해두는 기능. 매번 명령을 새로 만드는 오버헤드를 없앱니다.
- Eager mode
- 명령을 그때그때 하나씩 보내는 방식. 유연하지만 오버헤드가 큽니다.
- FlashAttention
- 어텐션을 GPU 안쪽의 아주 빠른 작은 메모리(SRAM)에서 몰아 계산하는 최적화 커널. HBM 왕복을 줄입니다.
- SRAM / HBM
- GPU 안의 아주 빠르지만 작은 메모리 / 느리지만 큰 메모리. 데이터를 HBM에서 SRAM으로 옮기는 비용이 큽니다.
- Kernel (커널)
- GPU에서 실제로 도는 작은 계산 프로그램. "최적화된 커널"은 이걸 잘 짰다는 뜻입니다.
- Continuous batching
- 요청이 끝나면 그 자리에 새 요청을 바로 끼워 넣는 배치 방식. 11장에서 자세히 봅니다.
SamplingParams- vLLM에서 생성 방식을 정하는 설정 묶음.
temperature,top_p,max_tokens등.
비유 먼저 — 직접 만든 자전거와 양산 자전거
직접 조립한 자전거는 어디에 무엇이 붙어 있는지 다 압니다. 배우는 데는 이만한 게 없습니다.
그런데 매일 출퇴근에 쓰려면 이야기가 다릅니다. 베어링 정밀도, 프레임 강성, 변속 세팅 — 수천 시간의 최적화가 들어간 양산품을 이길 수 없습니다.
vLLM이 그 양산품입니다. 그리고 우리가 6장에서 직접 짠 KV Cache 관리가 그 안에 이미 들어 있습니다. 훨씬 정교한 형태로요.
프레임워크가 대신 해주는 것
- KV Cache 재사용 6장에서 직접 짠 그것. 프레임워크 안에 최적화된 형태로 내장되어 있습니다.
- 요청 스케줄링 여러 요청을 언제 어떻게 묶을지 결정합니다(배치·마이크로배치). 11장의 주제입니다.
- 동시 처리(concurrency) 여러 사용자의 요청을 동시에 처리합니다. 직접 짜면 이것만으로도 큰 작업입니다.
- 스트리밍과 취소 토큰을 즉시 흘려보내고, 중간에 요청을 중단할 수 있습니다. 10장의 주제입니다.
실제로 띄우면 이렇습니다
llm = LLM(model="Qwen/Qwen2.5-0.5B", dtype="float16")
outputs = llm.generate([prompt], SamplingParams(
temperature=0.8, top_p=0.95, max_tokens=128))
# 5·6장에서 우리가 직접 짠 것
# 토크나이징 → 루프 → logits → softmax → 샘플링
# → append → 캐시 갱신 → EOS 확인 … 전부 이 두 줄 안에
17배의 정체
책의 실습에서 같은 GPU · 같은 Qwen2.5-0.5B · 같은 프롬프트로 재봤을 때
Hugging Face pipeline()은 19.58초, vLLM은 1.12초가 나왔습니다.
17배는 어디서 나오나 — 요인을 하나씩 켜보기
↔ 버튼 5개요인별 기여도 배분은 제가 임의로 나눈 대략적인 수치입니다. 총합(19.58초 → 1.12초)만 실측값입니다.
다섯 가지 이유
| 요인 | vLLM | HF pipeline() |
|---|---|---|
| PagedAttention | KV Cache를 페이지 단위로 관리해 낭비 없이 재사용 (9장) | 시퀀스마다 연속된 메모리를 통째로 할당 — 비효율 |
| CUDA Graph | 초기화 때 실행 그래프를 미리 캡처해 커널 실행 오버헤드 제거 | 매 토큰마다 Python 레벨 eager 모드로 순회 |
| 최적화된 커널 | FlashAttention 등 저수준 커널 직접 사용 | 쓸 수는 있지만 기본 설정은 튜닝이 안 된 경우가 많음 |
| pipeline 오버헤드 | 없음 | 전·후처리, 텐서 변환 등 편의 레이어 비용이 매 호출마다 |
| Continuous batching | 요청을 동적으로 배치에 끼워 넣어 GPU를 계속 바쁘게 | 요청 단위 순차 처리 — 동시 요청이 늘수록 격차가 벌어짐 |
7장에서 decode가 memory-bound라고 했습니다. FlashAttention은 그 문제를 정면으로 겨냥합니다.
보통은 어텐션 중간 결과를 HBM(크고 느린 메모리)에 썼다가 다시 읽습니다. FlashAttention은 계산을 잘게 쪼개서 SRAM(작고 아주 빠른 메모리) 안에서 끝내고, HBM에는 최종 결과만 씁니다. 계산량은 그대로인데 데이터 왕복이 줄어듭니다.
단순하게 시작한 뒤 병목을 재면서 최적화합니다. 처음부터 분산 서빙을 설계하지 말고, 단일 모델 · 단일 GPU에서 기준 성능을 잡은 다음 KV Cache · batching · precision · parallelism을 하나씩 조정하세요.
고급 설정을 통째로 보고 싶다면
model="Qwen/Qwen-7B",
swap_space=16, # CPU로 밀어둘 공간 (GB)
max_model_len=4096,
block_size=16, # PagedAttention 블록 크기 (9장)
enable_prefix_caching=True, # 공통 앞부분 재사용
enable_chunked_prefill=True, # 긴 prefill을 쪼개서 (7장)
enable_cuda_graph=True,
)
읽는 법 — block_size가 9장에서 볼 페이지 하나에 담는 토큰 수입니다.
enable_chunked_prefill은 긴 prefill을 잘게 쪼개서 decode 사이에 끼워 넣는 옵션으로,
7장에서 본 "prefill이 decode를 막는 문제"를 완화합니다.
서버 로그를 보면 이런 줄이 나옵니다 —
the rest of the memory reserved for KV Cache is 10.63GiB,
Maximum concurrency for 32768 tokens per request: 28.35x.
6장에서 본 KV Cache 메모리가 곧 동시 처리 가능 요청 수가 된다는 걸 그대로 보여줍니다.
- 정의 — vLLM·SGLang 같은 serving framework는 모델을 로드해 API로 노출하고, KV Cache·스케줄링·동시성·스트리밍을 대신 처리합니다.
- 성질 — 같은 조건에서 HF
pipeline()대비 19.58초 → 1.12초. PagedAttention · CUDA Graph · FlashAttention · 오버헤드 제거 · continuous batching이 겹친 결과입니다. - 쓰임 — 직접 구현은 이해용, 운영은 프레임워크. 단일 GPU에서 기준을 잡고 하나씩 튜닝합니다.
— 여기까지: 프레임워크가 빠른 이유 다섯 개. 다음은 그중 첫 번째를 자세히 —
09PagedAttention — KV Cache의 메모리 낭비를 없애기
한 줄 요약: 운영체제가 40년 전에 푼 문제였습니다. 페이지 방식을 그대로 가져왔습니다.
vLLM 팀이 재봤더니 확보한 KV Cache 공간의 20~38.2%만 실제로 쓰이고 있었습니다. 나머지 60~80%는 예약만 해두고 비어 있었습니다.
이 장에서는 그 낭비가 왜 생기는지, 그리고 페이지 방식이 어떻게 없애는지를 직접 조작해 보며 확인합니다.
- 내부 단편화 (internal fragmentation)
- 넉넉하게 잡아둔 칸에서 실제로 안 쓰고 남은 공간. 4096칸을 잡고 200칸만 쓰면 3,896칸이 낭비입니다.
- 외부 단편화 (external fragmentation)
- 빈 공간의 총량은 충분한데 연속된 덩어리가 없어서 새 요청을 못 넣는 상태.
- Block / Page
- KV Cache를 고정 크기(보통 16 또는 32토큰)로 잘라놓은 단위.
- Block table
- "논리적으로 몇 번째 블록이 물리적으로 어디에 있는지" 적어둔 표. 운영체제의 페이지 테이블과 같은 개념입니다.
- PagedAttention
- KV Cache를 블록 단위로 흩어 저장하고 블록 테이블로 찾아가는 방식. 2023년 vLLM 논문에서 나왔습니다.
- Beam search
- 한 프롬프트에 대해 여러 후보 답변을 동시에 탐색하는 생성 전략.
- Copy-on-write
- 같은 블록을 여럿이 공유하다가, 누가 쓰려고 할 때만 복사하는 기법.
비유 먼저 — 주차장
예전 방식은 통째로 예약하는 주차장입니다. 손님이 오면 "혹시 리무진일지 모르니" 하고 가장 긴 차가 들어갈 만큼의 연속된 자리를 잡아둡니다. 실제로 온 건 경차인데, 나머지는 손님이 나갈 때까지 비어 있습니다 — 내부 단편화.
게다가 예약 구간 사이사이에 어정쩡하게 남은 틈들이 생깁니다. 다 합치면 자리가 충분한데, 연속된 자리가 없어서 새 손님을 못 받습니다 — 외부 단편화.
페이지 방식은 칸 단위로 쪼갠 주차장입니다. 자리를 한 칸씩만 내주고, 차가 길면 흩어진 칸 여러 개를 배정한 뒤 "당신 차는 3번·17번·8번 칸" 이라고 표(block table)에 적어둡니다. 칸 크기가 다 같으니 남는 틈이 안 생깁니다.
작은 숫자로 한 번
요청 하나당 예약 = 4,096 토큰
실제 사용 = 200 토큰
낭비 = 3,896 토큰 = 95%
── 페이지 방식 (블록 16토큰) ──
필요한 블록 = 200 ÷ 16 = 12.5 → 13개
할당 = 13 × 16 = 208 토큰
낭비 = 8 토큰 = 4%
// 내부 단편화가 "요청당 최대 블록 하나"로 줄어듦
// 외부 단편화는 크기가 다 같아서 아예 사라짐
메모리 단편화 — 예약 방식 vs 페이지 방식
↔ 토글 + 슬라이더왜 예약할 수밖에 없었나
서빙 시스템은 응답이 얼마나 길어질지 미리 알 수 없습니다. 2장에서 본 자기회귀의 성질 때문입니다 — EOS 토큰이 언제 나올지 모릅니다.
그래서 2023년 이전 시스템들은 안전한 방식을 택했습니다. 요청마다 도달 가능한 가장 긴 시퀀스만큼 HBM의 연속된 영역을 예약했습니다. 안전하지만, 실측 사용률이 20~38.2%였습니다.
① 내부 단편화 — 요청당 최대 부분 블록 하나로 줄었습니다.
② 외부 단편화 — 블록 크기가 전부 같아서 완전히 사라졌습니다.
③ 처리량 — 확보된 메모리만큼 동시 요청을 더 담을 수 있어,
같은 지연시간에서 기존 시스템 대비 2~4배 높은 처리량이 나옵니다.
어텐션 커널이 하는 계산은 똑같습니다. 달라진 건 바이트가 어디에 저장되는지와 블록 테이블을 한 번 거쳐 찾아간다는 것뿐입니다.
한 프롬프트에서 여러 후보 답을 동시에 탐색하는 beam search를 생각해 봅시다. 후보들은 앞부분을 공유합니다.
블록 테이블 방식이면 여러 후보의 표가 같은 물리 블록을 가리키면 됩니다. 실제로 쓰기가 일어나는 순간에만 복사합니다(copy-on-write). beam search에서 공유되는 비율이 캐시의 최대 55%에 달했고, 성능은 basic 1.3배 · beam search 2.3배 향상으로 나타났습니다.
같은 발상이 prefix caching(여러 요청의 공통 앞부분 공유)으로도 이어집니다. 시스템 프롬프트가 긴 서비스라면 이게 아주 큽니다.
- 정의 — PagedAttention은 KV Cache를 고정 크기 블록으로 쪼개고 block table로 찾아가는 방식입니다. 운영체제의 페이징 그대로입니다.
- 성질 — 내부 단편화는 요청당 블록 하나로 줄고 외부 단편화는 사라집니다. 실측 사용률 20~38% → 대폭 개선, 처리량 2~4배.
- 쓰임 — 블록 공유로 beam search·prefix caching까지 열립니다. vLLM에서 기본으로 켜져 있습니다.
— Part 3. LLM Streaming Serving Basics — 여기서부터는 사용자에게 어떻게 내보낼 것인가 —
10Streaming — 만들어지는 대로 흘려보내기
한 줄 요약: 총 시간은 그대로인데 체감은 완전히 달라집니다. 대신 서버 부담은 줄지 않습니다.
8장에서 쓴 llm.generate()는 동기·블로킹입니다.
모델은 안에서 토큰을 하나씩 만들고 있는데(2장), API는 전부 끝날 때까지 기다렸다가 한 번에 돌려줍니다.
이 장에서는 그 사이를 여는 방법과, 스트리밍이 실제로 무엇을 개선하고 무엇은 개선하지 않는지를 구분합니다.
- 동기(sync) · 블로킹(blocking)
- 결과가 전부 준비될 때까지 기다렸다가 한 번에 돌려주는 방식.
- Streaming (스트리밍)
- 토큰이 만들어지는 즉시 하나씩(또는 작은 덩어리로) 보내는 방식.
AsyncLLMEngine- vLLM에서 스트리밍용으로 쓰는 엔진 클래스. 일반
LLM대신 이걸 씁니다. async for- 파이썬에서 비동기로 하나씩 흘러오는 것을 받는 반복문.
request_id- 생성 요청마다 붙는 고유 식별자. 이게 있어야 중간에 취소할 수 있습니다.
abort()- 진행 중인 생성을 중간에 끊는 함수. GPU 시간을 아낍니다.
- Capacity planning
- 서버가 동시에 몇 건을 감당할 수 있는지 계산해 장비를 준비하는 일.
비유 먼저 — 코스 요리와 도시락
도시락은 다 만들어질 때까지 아무것도 안 나옵니다. 20분 뒤에 한꺼번에 받습니다.
코스 요리는 준비된 접시부터 나옵니다. 총 조리 시간은 똑같지만 3분 뒤부터 뭔가 먹고 있습니다. 체감이 전혀 다릅니다.
그런데 주방이 하는 일의 양은 똑같습니다. 코스로 낸다고 요리사가 덜 일하지 않습니다. 스트리밍도 그렇습니다 — 프런트엔드 체감은 좋아지지만 백엔드 부담은 그대로입니다.
작은 숫자로 한 번
──────────────────
전체 생성 시간 = 0.9 + 59 × 0.03 = 2.67초
── 비스트리밍 ──
사용자가 처음 뭔가 보는 시점 = 2.67초
── 스트리밍 ──
사용자가 처음 뭔가 보는 시점 = 0.90초 // TTFT
전체 완료는 여전히 2.67초
// GPU가 한 일은 두 경우가 완전히 동일
같은 시간, 다른 체감 — 그리고 중간에 끊기
↔ 재생 + 취소 버튼vLLM에서는 클래스 하나만 바꿉니다
llm = LLM(model="Qwen/Qwen2.5-0.5B")
outputs = llm.generate([prompt], params) # 다 끝나야 반환
# 스트리밍
engine = AsyncLLMEngine.from_engine_args(args)
gen = engine.generate(prompt, params, request_id="req-1")
async for out in gen: # 토큰이 나올 때마다 한 번씩
print(out.outputs[0].text, end="", flush=True)
실행하면 콘솔에 이렇게 쌓입니다 —
The → The capital → The capital of → … → The capital of the United States is Washington, D.C.
request_id를 들고 있으면 engine.abort(request_id)로 생성을 중간에 끊을 수 있습니다.
출력이 엉뚱한 방향으로 가고 있을 때 끊으면 사용자 경험도 좋아지고 GPU 시간도 아낍니다. 비용 관리가 중요한 운영 환경에서 특히 의미가 큽니다.
스트리밍은 TTFT를 낮게 "느끼게" 만들 뿐, 백엔드의 총 decode 비용은 그대로입니다.
그래서 프런트엔드 UX와 백엔드 capacity planning은 분리해서 봐야 합니다. "스트리밍을 켰으니 서버를 덜 사도 되겠지"는 틀린 결론입니다.
- 정의 — streaming은 토큰이 만들어지는 즉시 클라이언트로 보내는 방식입니다. vLLM에서는
AsyncLLMEngine+async for를 씁니다. - 성질 — 사용자가 처음 뭔가를 보는 시점이 전체 시간 → TTFT로 당겨집니다. 총 생성 시간과 GPU 부하는 변하지 않습니다.
- 쓰임 —
request_id로 중간 취소가 가능해 GPU 시간을 아낍니다. 다만 용량 계획은 스트리밍과 별개로 세워야 합니다.
— Part 4. LLM Batch Serving Basics — 마지막은 GPU를 놀리지 않는 법 —
11Batching — GPU를 놀리지 않기
한 줄 요약: 요청을 묶으면 처리량이 오릅니다. 어떻게 묶느냐가 static → dynamic → continuous의 역사입니다.
7장에서 decode가 memory-bound라고 했습니다. 요청 하나만 돌리면 모델 weight를 통째로 읽어와서 토큰 하나를 만듭니다. 지독하게 비효율적입니다.
이 장에서는 배치가 왜 효과적인지, 그리고 static · dynamic · continuous가 각각 무엇을 고쳤는지를 봅니다.
- Batching (배칭)
- 여러 요청을 묶어서 모델의 forward pass 한 번에 같이 통과시키는 것.
- Static batching
- 배치가 꽉 찰 때까지 기다렸다가 시작하는 방식. 먼저 온 요청이 오래 기다립니다.
- Dynamic batching
- 꽉 차거나 정해진 시간(timeout)이 지나면 시작하는 방식. static의 대기 문제를 시간으로 잘라 완화합니다.
- Continuous batching
- 계속 돌리면서 슬롯이 비면 즉시 새 요청을 끼워 넣는 방식. 토큰 단위로 배칭합니다. In-flight batching이라고도 부릅니다.
- Padding (패딩)
- 길이가 다른 입력을 묶을 때 짧은 쪽을 빈 값으로 채우는 것. 채운 만큼은 낭비입니다.
- Concurrency target
- 인스턴스 하나가 목표로 삼는 동시 처리 요청 수. 오토스케일링 기준이 됩니다.
- Fairness (공정성)
- 특정 요청만 오래 기다리지 않게 하는 것. 큰 배치는 이걸 해칠 수 있습니다.
비유 먼저 — 엘리베이터
Static batching은 정원을 다 채워야 출발하는 엘리베이터입니다. 10명이 다 모일 때까지 문을 안 닫습니다. 먼저 탄 사람은 하염없이 기다립니다.
Dynamic batching은 "30초 지나면 그냥 출발"을 추가한 것입니다. 최악의 대기는 막힙니다.
Continuous batching은 계속 움직이는 엘리베이터입니다. 누가 내리면 그 층에서 바로 다음 사람을 태웁니다. 정원이 빌 일이 없습니다.
LLM에서 이게 특히 잘 통하는 이유는 사람마다 내리는 층이 다르기 때문입니다 — 어떤 요청은 토큰 10개, 어떤 요청은 500개에서 끝납니다.
왜 Transformer는 배치가 특히 효과적인가
- 행렬곱과 어텐션은 병렬화가 잘 됩니다 1장에서 본 그 성질 그대로입니다. GPU는 이걸 하라고 만든 물건입니다.
- 모델 weight는 모두가 공유합니다 배치 크기를 늘려도 weight를 다시 읽어올 필요가 없습니다. 7장의 memory-bound 문제를 정면으로 완화합니다.
- GPU 코어를 꽉 채웁니다 요청 하나로는 연산 유닛이 대부분 놉니다. 여러 개를 묶으면 같은 시간에 훨씬 많은 일을 합니다.
작은 숫자로 한 번
책의 실습에서 프롬프트 4개를 vLLM으로 돌렸을 때 —
배치 처리 (generate 1번) = 1.38초
──────────────────
약 2.2배 향상
// 완벽히 병렬이면 4배여야 하는데 왜 2.2배?
// ① 프롬프트 길이가 서로 달라 패딩/스케줄링 비용
// ② 출력 길이도 달라서 먼저 끝난 자리가 논다
// ③ 4개는 GPU를 포화시키기엔 작은 배치
②가 핵심입니다. 먼저 끝난 요청의 자리가 비어 있는 채로 배치 전체를 기다립니다. 이걸 고친 게 continuous batching입니다.
세 가지 배칭 — 슬롯이 비는 걸 눈으로 보기
↔ 버튼 + 재생출력 길이 편차가 클수록 continuous의 이득이 커집니다. 실제 LLM 요청은 편차가 아주 큽니다.
세 가지 배칭 비교
| 방식 | 언제 시작하나 | 문제 / 이득 |
|---|---|---|
| Static | 배치가 꽉 찰 때까지 대기 | 먼저 온 요청이 오래 기다림. 짧은 응답이 먼저 끝나도 슬롯이 비어 있는 채로 배치 전체를 기다림 |
| Dynamic | 꽉 차거나 timeout 도달 | 최악의 대기시간은 막지만, 빈 슬롯 문제는 그대로 |
| Continuous | 계속 실행, 슬롯이 비면 즉시 투입 | GPU가 쉬지 않음. Anyscale 2023년 연구에서 최대 23배 처리량 향상과 p50 지연시간 대폭 감소 보고 |
vLLM · SGLang · TensorRT-LLM(여기서는 in-flight batching이라고 부릅니다)이 continuous batching을 구현하고 있습니다. 8장에서 본 vLLM의 다섯 번째 이유가 이것입니다.
배치를 키우면 처리량은 오르지만 개별 요청의 지연시간은 나빠집니다. 한 요청이 다른 요청들과 묶여 처리되기 때문입니다.
정답은 없습니다. 모델 · 인스턴스 · 지연시간 목표 · 예산에 맞춰 여러 배치 크기로 실제 성능 테스트를 해서 적정선을 찾아야 합니다. CH1 4장에서 본 latency와 throughput의 저울질이 여기서 다시 나옵니다.
트래픽 기반 오토스케일링을 하려면 인스턴스 하나가 동시에 몇 건을 감당하는지를 알아야 합니다. 이 값이 두 군데에 나타납니다.
① 오토스케일링 설정 — concurrency target
② 인스턴스 설정 — batch size
이 둘은 서로 맞춰야 합니다.
판단 로직은 단순합니다. 활성 레플리카가 전부 최대 동시성에 도달 → 늘릴 때. 레플리카들이 계속 절반만 찬 배치로 돌고 있음 → 줄일 때.
- 정의 — batching은 여러 요청을 묶어 forward pass 한 번에 함께 통과시키는 것입니다.
- 성질 — 모델 weight를 공유하므로 배치를 키워도 메모리 읽기가 늘지 않습니다. static → dynamic → continuous로 오면서 빈 슬롯 낭비가 사라졌습니다.
- 쓰임 — 실습 기준 순차 대비 2.2배, continuous batching은 최대 23배 보고. 대신 개별 지연시간과 공정성을 같이 봐야 합니다.
— 여기까지: 최적화 기법 네 가지를 다 봤습니다. 마지막으로 전체를 한 장에 —
12전체 정리 — 최적화는 어떻게 쌓이는가
한 줄 요약: 이론 → 직접 구현 → 프레임워크 → 성능 전략. CH2는 이 순서로 왔습니다.
KV Cache · PagedAttention · continuous batching · streaming은 서로 다른 문제를 풉니다. 그래서 겹쳐 쓸 수 있고, 겹칠수록 효과가 쌓입니다.
이 장에서는 그 쌓임을 한 화면에서 확인하고, 다음 장(CH3)으로 넘어갈 준비를 합니다.
- Radix Attention
- 여러 요청이 공유하는 프롬프트 앞부분을 트리 구조로 나눠 쓰게 하는 기법. SGLang이 씁니다. 발상은 9장 PagedAttention의 블록 공유와 같은 계열입니다.
- Speculative decoding
- 작은 모델이 여러 토큰을 미리 찍고 큰 모델이 한꺼번에 검증하는 기법. decode의 memory-bound 문제를 다른 각도에서 공격합니다.
- Chunked prefill
- 긴 prefill을 잘게 쪼개 decode 사이에 끼워 넣는 것. 7장의 "prefill이 decode를 막는 문제"를 완화합니다.
CH2가 걸어온 길
- 이론 — Transformer 아키텍처 decoder-only 구조, self-attention, autoregressive 생성. 병렬화를 얻은 대신 제곱 비용을 떠안았습니다. (1~4장)
- 직접 구현 — 스크래치 추론 생성 루프를 한 줄씩 돌려보며 중복 계산을 발견하고, KV Cache로 없앴습니다. 그리고 prefill / decode의 성격 차이를 확인했습니다. (5~7장)
- 프레임워크 — vLLM 직접 구현을 프로덕션 도구로 교체했습니다. PagedAttention이 메모리 낭비를 없앴습니다. (8~9장)
- 성능 전략 — streaming과 batching streaming은 체감 지연시간을, batching은 처리량을 개선합니다. 서로 다른 축입니다. (10~11장)
최적화 스택 — 하나씩 쌓아보기
↔ 토글 4개 + 슬라이더이 스택의 배수(2.9배, 2.5배 …)는 이 글에서 인용한 실측값들을 제가 곱셈 모델로 단순화한 것입니다. 실제로는 기법들이 서로 간섭하고, 워크로드에 따라 값이 크게 달라집니다. "쌓으면 쌓인다"는 감각을 얻는 용도로만 쓰세요. 실제 결정은 본인 워크로드로 측정해야 합니다.
기법별로 무엇을 풀었나
| 기법 | 푼 문제 | 대가 |
|---|---|---|
| KV Cache 6장 | 같은 계산을 매 스텝 반복 (O(L²D) → O(LD)) | GPU 메모리를 계속 먹음 |
| GQA 4장 | KV Cache가 너무 큼 | 표현력을 약간 양보 |
| PagedAttention 9장 | KV Cache 메모리의 내부·외부 단편화 | 블록 테이블 간접 참조 한 단계 |
| Continuous batching 11장 | 배치 슬롯이 비어 있는 낭비 | 개별 요청 지연시간·공정성 |
| Streaming 10장 | 사용자가 아무것도 못 보는 침묵 | 없음 (다만 서버 부담도 안 줄어듦) |
| P/D disaggregation 7장 | prefill이 decode를 막음 | KV Cache 전송·운영 복잡도 |
LLM 서빙 기술의 발전은 전부 "LLM을 실행할 때 생기는 병목을 어떻게 없앨까"에서 나왔습니다. 그래서 실행 구조를 이해하면 왜 그 기법이 생겼는지가 자연스럽게 보입니다. 반대로 구조를 모르면 옵션 이름만 외우게 됩니다.
- 정의 — CH2는 이론 → 직접 구현 → 프레임워크 → 성능 전략 순서로 LLM 실행의 멘탈 모델을 세웠습니다.
- 성질 — 기법들은 서로 다른 병목을 풉니다. KV Cache는 계산을, PagedAttention은 메모리를, batching은 GPU 유휴를, streaming은 체감을 다룹니다.
- 쓰임 — 새 최적화 기법을 만나면 "이건 어느 병목을 겨냥한 건가"부터 물어보면 됩니다.
마치며
이번 챕터에서는 LLM이 토큰을 만들어내는 과정을 안에서부터 따라가 봤습니다. Transformer가 병렬 처리를 얻은 대가로 제곱의 계산 비용을 떠안았고, 생성만은 여전히 한 토큰씩이라는 점, 그리고 그 두 사실이 겹치면서 중복 계산이라는 낭비가 생긴다는 것을 확인했습니다.
KV Cache는 그 낭비를 계산에서 메모리로 옮겼고, PagedAttention은 그 메모리를 다시 아꼈으며, batching은 남는 GPU 시간을 채웠고, streaming은 사용자가 기다리는 체감을 줄였습니다. 기법 하나하나가 앞 기법이 만들어낸 새 문제를 푸는 방식으로 이어져 있다는 점, 그래서 옵션 이름을 외우기보다 실행 구조를 이해하는 편이 훨씬 오래간다는 점을 배웠습니다.
댓글을 달아주세요. 제가 확인 후 수정하겠습니다. CH2는 CloudNet@ 『CH2. Large Language Model Serving』 스터디 자료를 바탕으로 재구성했습니다.
'AI > [LLMSO] Hands-On LLM 서빙과 최적화 스터디' 카테고리의 다른 글
| [1주차-LLMSO]LLM 기초 동작 원리 (2026.08.02) (0) | 2026.08.08 |
|---|---|
| [1주차-LLMSO]CH1. Introduction to Model Serving and Optimization (2026.08.02) (0) | 2026.08.08 |
| [1주차-LLMSO]트랜스포머 모델을 이해하기 위한 기초 수학 정리 (0) | 2026.08.07 |