[LLMSO] LLM은 어떻게 한 토큰을 생성하는가

xgro·2026년 8월 8일

LLMSO

목록 보기
2/10
post-thumbnail

📌 Notice

LLM Serving and Optimization Study (=LLMSO)
직접 실습을 통해 LLM 서비스 제공 및 최적화에 대한 지식에 대한 학습 및 실습 내용을 정리한 블로그입니다.

CloudNet@에서 스터디를 진행하고 있습니다.

EKS 관련 이전 스터디 내용은 아래 링크를 통해 확인할 수 있습니다.



📌 Large Language Model Serving

들어가며

LLM API를 호출하면 완성된 문장이 돌아옵니다. 하지만 모델 내부에서는 문장을 한 번에 만드는 것이 아니라, 지금까지 입력된 Token을 바탕으로 다음 Token 하나를 선택하는 연산이 반복됩니다.

이 단순해 보이는 반복은 LLM Serving에서 여러 문제로 이어집니다.

  • 이전 Token을 매번 다시 계산하면 생성 비용이 빠르게 증가합니다.
  • 요청마다 Prompt와 출력 길이가 달라 고정 Batch를 유지하기 어렵습니다.
  • 생성이 끝날 때까지 기다리면 사용자가 느끼는 응답 시간이 길어집니다.
  • KV Cache를 사용하면 계산은 줄지만 GPU 메모리 관리가 중요해집니다.

이번 글에서는 LLM을 단순히 사용하는 관점에서 벗어나, 하나의 Token이 생성되고 사용자에게 전달되기까지의 구조를 단계별로 확인합니다.

Qwen2.5-0.5B-Instruct를 직접 실행하면서 다음 질문에 답해봅니다.

문자열은 어떻게 다음 Token의 확률분포가 될까?
Self-Attention은 어떤 정보를 계산할까?
generate()는 어떤 반복문을 숨기고 있을까?
KV Cache는 무엇을 저장하고 얼마나 빠르게 만들까?
길이가 다른 요청은 어떻게 Batch로 처리할까?
Streaming은 사용자가 느끼는 대기 시간을 얼마나 줄일까?

실습 환경

Model      Qwen/Qwen2.5-0.5B-Instruct
GPU        NVIDIA Tesla T4
Framework  PyTorch + Hugging Face Transformers
Dtype      FP16
Decoding   Greedy Decoding

실험 수치는 모델, GPU, Prompt 길이와 라이브러리 버전에 따라 달라질 수 있습니다. 따라서 절대적인 숫자보다 입력 길이에 따른 변화와 Cache 사용 전후의 추세에 초점을 맞췄습니다.

이번 글의 흐름은 다음과 같습니다.

Token 생성 원리
→ Qwen Decoder 구조
→ Self-Attention
→ Autoregressive Generation
→ KV Cache
→ Prefill / Decode 성능
→ Continuous Batching
→ Streaming

👉 Step 01. LLM은 다음 토큰을 어떻게 생성할까?

💡 핵심

Decoder-only LLM은 문장을 한 번에 완성하지 않는다. 지금까지 입력된 토큰을 바탕으로 다음 토큰의 확률분포를 계산하고, 하나를 선택해 입력 뒤에 추가하는 과정을 반복한다.

직접 확인할 질문

사용자가 입력한 문자열은 어떤 과정을 거쳐 다음 토큰 하나로 변환될까?

사용자 입력
   ↓
Tokenizer
   ↓
Token IDs
   ↓
Embedding + Position Information
   ↓
Decoder Block × N
(Self-Attention + MLP)
   ↓
Final Normalization
   ↓
LM Head
   ↓
Vocabulary Logits
   ↓
Sampling
   ↓
Next Token

사용자가 입력한 문장은 그대로 모델에 전달되지 않습니다. 먼저 Tokenizer가 문자열을 모델이 처리할 수 있는 정수 ID의 배열인 Token IDs로 변환합니다.

"LLM 서빙이란 무엇인가?"
→ [Token 1, Token 2, Token 3, ...]

각 Token ID는 Embedding Layer를 거쳐 벡터로 변환됩니다. 여기에 토큰의 순서 정보를 나타내는 위치 정보가 반영되고, 결과는 여러 개의 Decoder Block을 차례로 통과합니다.

각 Decoder Block은 크게 두 가지 계산을 수행합니다.

Self-Attention
→ 이전 토큰 중 어떤 정보를 참고할지 계산

MLP / Feed Forward Network
→ Attention이 모은 정보를 토큰별로 변환

이 과정이 모든 Decoder Layer에서 반복된 후, 마지막 토큰 위치의 출력 벡터가 LM Head로 전달됩니다.

LM Head는 이 벡터를 모델이 알고 있는 전체 Vocabulary 크기의 값으로 변환합니다. 이 값을 logits라고 합니다.

마지막 Hidden State
→ LM Head
→ 각 Token 후보의 Logits
→ 확률분포
→ 다음 Token 선택

코드로 다음 토큰 하나 확인하기

Hugging Face의 generate()는 이 과정을 내부에서 반복합니다. 먼저 generate()를 사용하지 않고 모델을 한 번만 실행해 다음 토큰 하나를 확인해보겠습니다.

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

model_id = "Qwen/Qwen2.5-0.5B-Instruct"

tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    device_map="auto",
)
model.eval()

prompt = "LLM 서빙이란 무엇인가?"

inputs = tokenizer(
    prompt,
    return_tensors="pt",
).to(model.device)

with torch.no_grad():
    outputs = model(**inputs)

print("logits shape:", outputs.logits.shape)

last_token_logits = outputs.logits[:, -1, :]
next_token_id = torch.argmax(
    last_token_logits,
    dim=-1,
    keepdim=True,
)

next_token = tokenizer.decode(
    next_token_id[0],
    skip_special_tokens=True,
)

print("next token id:", next_token_id.item())
print("next token:", repr(next_token))

outputs.logits의 Shape은 다음 의미를 가집니다.

[batch_size, sequence_length, vocabulary_size]

그중 아래 코드는 마지막 입력 위치에서 예측한 모든 Token 후보의 logits만 가져옵니다.

last_token_logits = outputs.logits[:, -1, :]

그리고 다음 코드는 가장 큰 logit을 가진 Token을 선택합니다.

next_token_id = torch.argmax(
    last_token_logits,
    dim=-1,
    keepdim=True,
)

여기서는 동작을 단순하게 확인하기 위해 가장 확률이 높은 토큰을 선택하는 Greedy Decoding을 사용했습니다. 실제 LLM 생성에서는 Temperature, Top-k, Top-p 등의 Sampling 설정에 따라 다른 토큰이 선택될 수 있습니다.

자기회귀적 생성

선택한 Token은 응답으로 끝나는 것이 아니라 기존 입력 뒤에 다시 추가됩니다.

input_ids = torch.cat(
    [inputs["input_ids"], next_token_id],
    dim=-1,
)

모델은 늘어난 input_ids를 다시 입력받아 그다음 Token을 예측합니다.

Prompt
→ Token A 생성

Prompt + Token A
→ Token B 생성

Prompt + Token A + Token B
→ Token C 생성

이 과정은 다음 조건 중 하나를 만족할 때까지 반복됩니다.

  • EOS Token이 생성됨
  • max_new_tokens에 도달함
  • Stop Sequence를 만남
  • 사용자가 생성을 중단함

📌 핵심

LLM 추론은 한 번의 모델 실행으로 전체 문장을 반환하는 작업이 아니다.
프롬프트를 처리한 뒤, 생성된 토큰을 다시 입력에 추가하면서 모델 실행을 반복하는 작업이다.

이 자기회귀적 특성 때문에 LLM 서빙은 일반적인 분류 모델 서빙과 다른 문제를 가집니다.

  • 출력 길이를 실행 전에 정확히 알 수 없음
  • 토큰을 생성할 때마다 모델 실행이 반복됨
  • 이전 토큰의 계산 결과를 계속 참고해야 함
  • 요청마다 입력과 출력 길이가 다름
  • 여러 요청을 고정된 Batch로 처리하기 어려움

다음 Step에서는 이 과정의 중심에 있는 Qwen의 model.config와 Decoder Block을 직접 열어보고, 하나의 Token이 어떤 Layer를 통과하는지 살펴봅니다.


👉 Step 02. Qwen의 설계도와 Decoder Block 열어보기

💡 핵심

model.config는 단순한 설정 모음이 아니다. 모델의 Layer 수, 벡터 크기, Attention Head 구조와 KV Cache 크기를 결정하는 설계도다.

Step 01에서 확인한 Embedding → Decoder Block → LM Head는 실제 Qwen 모델 안에서 어떤 모듈로 구현되어 있을까?

모델의 설계도 config

Hugging Face에서 모델을 불러올 때는 가중치뿐 아니라 모델 구조를 재구성하는 데 필요한 config.json도 함께 내려받습니다.

가중치를 내려받지 않고 Config만 확인할 수도 있습니다.

from transformers import AutoConfig

model_id = "Qwen/Qwen2.5-0.5B-Instruct"
config = AutoConfig.from_pretrained(model_id)

config_fields = [
    "vocab_size",
    "hidden_size",
    "intermediate_size",
    "num_hidden_layers",
    "num_attention_heads",
    "num_key_value_heads",
    "max_position_embeddings",
    "tie_word_embeddings",
    "use_cache",
]

for field in config_fields:
    print(f"{field:28}: {getattr(config, field)}")

Qwen2.5-0.5B-Instruct에서는 다음과 같은 설정을 확인할 수 있습니다.

vocab_size                 : 151936
hidden_size                : 896
intermediate_size          : 4864
num_hidden_layers          : 24
num_attention_heads        : 14
num_key_value_heads        : 2
max_position_embeddings    : 32768
tie_word_embeddings        : True
use_cache                  : True

이 값들은 공식 모델의 config.json을 기준으로 합니다.

주요 설정값의 의미

설정의미실제 구조에 미치는 영향
vocab_size모델이 구분하는 Token 후보 수Embedding과 LM Head 크기
hidden_sizeToken 하나를 표현하는 벡터 크기Decoder 내부의 기본 차원
intermediate_sizeMLP 내부 확장 차원MLP 연산량과 파라미터 수
num_hidden_layersDecoder Block 개수모델 깊이와 연산량
num_attention_headsQuery Head 개수Attention의 병렬 표현 수
num_key_value_headsKey/Value Head 개수KV Cache 크기
max_position_embeddings설정상 최대 위치 범위처리 가능한 Context 범위
tie_word_embeddings입력·출력 Embedding 공유 여부파라미터 중복 감소
use_cacheKV Cache 기본 사용 여부생성 시 과거 K/V 재사용

Config를 Tensor Shape으로 연결하기

hidden_size가 896이고 Query Head가 14개이므로 Head 하나의 차원은 다음과 같습니다.

head_dim
= hidden_size / num_attention_heads
= 896 / 14
= 64

따라서 Query Projection의 출력 크기는 다음과 같습니다.

14 Query Heads × 64
= 896

반면 Key/Value Head는 각각 2개입니다.

2 KV Heads × 64
= 128

이를 Tensor 관점에서 보면 다음과 같습니다.

입력 Hidden State
[batch, sequence, 896]

Query
[batch, 14, sequence, 64]

Key
[batch, 2, sequence, 64]

Value
[batch, 2, sequence, 64]

Query Head는 14개지만 Key와 Value Head는 각각 2개뿐입니다. 여러 Query Head가 Key/Value Head를 공유하는 구조이며, 이를 Grouped Query Attention, GQA라고 합니다.

Qwen 공식 모델 카드에서도 이 모델이 24개의 Layer와 14개의 Query Head, 2개의 KV Head를 사용한다고 설명합니다.

GQA가 KV Cache 메모리를 어떻게 줄이는지는 뒤의 Step에서 별도로 살펴보겠습니다.

실제 PyTorch 모델 구조 확인하기

Step 01에서 불러온 model을 사용해 주요 모듈을 확인해보겠습니다.

print("Model type:", type(model).__name__)
print("Embedding:", model.model.embed_tokens)
print("Number of layers:", len(model.model.layers))
print("Final norm:", model.model.norm)
print("LM head:", model.lm_head)

모델의 큰 구조는 다음과 같이 대응됩니다.

Qwen2ForCausalLM
├── model.embed_tokens
├── model.layers[0]
├── model.layers[1]
├── ...
├── model.layers[23]
├── model.norm
└── lm_head

이를 Step 01의 실행 흐름과 연결하면 다음과 같습니다.

Token IDs
   ↓
model.model.embed_tokens
   ↓
model.model.layers[0:24]
   ↓
model.model.norm
   ↓
model.lm_head
   ↓
Logits

첫 번째 Decoder Block 열어보기

24개의 Decoder Block은 같은 구조를 가지며 서로 다른 가중치를 학습합니다. 첫 번째 Layer를 확인해보겠습니다.

first_layer = model.model.layers[0]

print(first_layer)

더 구체적으로 Attention과 MLP를 나누어 볼 수 있습니다.

print("Self-Attention")
print(first_layer.self_attn)

print("\nMLP")
print(first_layer.mlp)

print("\nNormalization")
print(first_layer.input_layernorm)
print(first_layer.post_attention_layernorm)

구조를 단순화하면 다음과 같습니다.

Qwen2DecoderLayer
├── input_layernorm
├── self_attn
│   ├── q_proj
│   ├── k_proj
│   ├── v_proj
│   └── o_proj
├── post_attention_layernorm
└── mlp
    ├── gate_proj
    ├── up_proj
    ├── act_fn
    └── down_proj

Attention Projection 크기 확인하기

attention = first_layer.self_attn

print("q_proj:", attention.q_proj)
print("k_proj:", attention.k_proj)
print("v_proj:", attention.v_proj)
print("o_proj:", attention.o_proj)

Qwen2.5-0.5B의 설정값을 기준으로 Projection 크기는 다음처럼 연결됩니다.

q_proj: 896 → 896
k_proj: 896 → 128
v_proj: 896 → 128
o_proj: 896 → 896

q_projk_proj, v_proj의 출력 크기가 다를까요?

Query: 14 Heads × 64 = 896
Key:    2 Heads × 64 = 128
Value:  2 Heads × 64 = 128

이 차이가 Config에서 확인했던 GQA 구조가 실제 코드에 반영된 결과입니다.

MLP 구조 확인하기

mlp = first_layer.mlp

print("gate_proj:", mlp.gate_proj)
print("up_proj:", mlp.up_proj)
print("down_proj:", mlp.down_proj)

MLP에서는 Hidden State를 더 큰 중간 차원으로 확장한 뒤 다시 원래 크기로 줄입니다.

hidden_size
896
   ↓ gate_proj / up_proj
intermediate_size
4864
   ↓ down_proj
hidden_size
896

Attention이 Token 사이의 관계를 계산한다면, MLP는 각 Token의 표현을 더 풍부하게 변환하는 역할을 합니다.

입력 Embedding과 LM Head의 가중치 공유

Config에는 다음 설정도 있었습니다.

tie_word_embeddings = True

이는 입력 Token을 벡터로 변환하는 Embedding과, 마지막 Hidden State를 Vocabulary Logits로 변환하는 LM Head가 같은 가중치를 공유한다는 의미입니다.

실제로 같은 메모리를 가리키는지 확인할 수 있습니다.

input_embedding = model.get_input_embeddings().weight
output_embedding = model.get_output_embeddings().weight

print(
    "Weights are tied:",
    input_embedding.data_ptr() == output_embedding.data_ptr(),
)

Vocabulary가 151,936개이고 Hidden Size가 896이므로 Embedding 행렬만 해도 다음 크기를 가집니다.

151,936 × 896
= 136,134,656 parameters
≈ 136M parameters

가중치를 공유하지 않는다면 입력 Embedding과 출력 LM Head에 이 크기의 행렬이 각각 필요합니다. Weight Tying은 두 모듈이 하나의 Parameter를 공유하도록 해 파라미터 중복을 줄입니다.

Qwen은 위치 정보를 어디에서 반영할까?

Step 01에서는 전체 흐름을 단순화해 다음처럼 표현했습니다.

Embedding + Position Information

Qwen에서는 별도의 위치 벡터를 Token Embedding에 단순히 더하는 방식이 아니라, RoPE(Rotary Position Embedding)를 Self-Attention의 Query와 Key에 적용합니다.

Token Embedding
   ↓
Q, K, V Projection
   ↓
Q와 K에 RoPE 적용
   ↓
Attention 계산

따라서 위치 정보는 Embedding Layer 하나에서 끝나는 것이 아니라, 각 Decoder Layer의 Attention 계산에 반영됩니다.

📌 핵심

Config는 모델의 정적인 메타데이터가 아니라 실제 Tensor Shape과 연산 구조를 결정하는 설계도다. Qwen2.5-0.5B는 24개의 Decoder Layer를 가지며, 각 Layer에서 14개의 Query Head가 2개의 KV Head를 공유한다.

이번 Step에서 확인한 관계는 다음과 같습니다.

config.json
→ PyTorch Module 구조 결정
→ Projection Tensor Shape 결정
→ 연산량과 메모리 사용량 결정
→ 최종적으로 서빙 성능에 영향

다음 Step에서는 Decoder Block 내부의 Self-Attention을 확대해 Q·K·V가 어떻게 만들어지고, Causal Mask를 통해 이전 Token의 문맥을 계산하는지 살펴봅니다.


👉 Step 03. Self-Attention은 문맥을 어떻게 계산할까?

💡 핵심

Self-Attention은 현재 Token의 Query를 다른 Token의 Key와 비교해 참고할 비중을 계산하고, 그 비중만큼 Value를 모아 문맥이 반영된 새로운 Token 표현을 만든다.

Qwen의 Decoder Block에서 Q·K·V는 어떤 Shape으로 만들어지고, 현재 Token이 미래 Token을 미리 보지 못하도록 어떻게 제한할까?

Self-Attention이 필요한 이유

Embedding Layer를 통과한 직후에는 각 Token이 고정된 벡터로 표현됩니다. 하지만 같은 Token이라도 문장에 따라 의미가 달라질 수 있습니다.

은행에 돈을 맡겼다.
강의가 끝나서 강둑에 앉았다.

Token 자체의 Embedding만으로는 주변 문맥을 충분히 반영하기 어렵습니다. Self-Attention은 각 Token이 문장 안의 다른 Token을 참고해 문맥이 반영된 표현으로 바뀌도록 합니다.

고정된 Token Embedding
→ 주변 Token과의 관계 계산
→ 문맥화된 Hidden State

Query, Key, Value

각 Token의 Hidden State는 서로 다른 Linear Projection을 통과해 Query, Key, Value로 변환됩니다.

Hidden State X
├── q_proj → Query
├── k_proj → Key
└── v_proj → Value

직관적으로는 다음과 같이 이해할 수 있습니다.

요소질문역할
Query현재 Token은 무엇을 찾고 있는가?비교의 기준
Key각 Token은 어떤 특징을 가지고 있는가?Query와의 관련성 계산
Value실제로 전달할 정보는 무엇인가?Attention 결과에 반영할 내용

예를 들어 현재 Token이 어떤 대상을 수식하는지 파악하려면 Query는 앞선 Token들의 Key와 비교됩니다. 관련성이 높다고 판단된 Token의 Value가 더 큰 비중으로 반영됩니다.

Scaled Dot-Product Attention

Self-Attention의 핵심 계산은 다음과 같습니다.

Attention(Q,K,V)=softmax(QKTdk+M)V\text{Attention}(Q,K,V) = \text{softmax} \left( \frac{QK^T}{\sqrt{d_k}} + M \right)V

각 단계의 의미는 다음과 같습니다.

Q × Kᵀ
→ Token 사이의 관련성 점수 계산

÷ √dₖ
→ Dot Product 값의 크기 조정

+ Causal Mask
→ 미래 Token을 볼 수 없도록 차단

Softmax
→ 점수를 합이 1인 Attention Weight로 변환

Attention Weight × V
→ 참고할 정보의 가중합 계산

d_k는 Attention Head 하나의 차원입니다. Qwen2.5-0.5B에서는 다음과 같습니다.

d_k
= hidden_size / num_attention_heads
= 896 / 14
= 64

따라서 Scaling Factor는 다음과 같습니다.

1 / √64
= 1 / 8

Dot Product를 √d_k로 나누는 이유는 Head Dimension이 커질수록 내적 값이 지나치게 커져 Softmax가 특정 위치에 과도하게 집중되는 현상을 줄이기 위해서입니다.

Qwen의 Q·K·V Shape

Step 02에서 Qwen2.5-0.5B의 설정을 확인했습니다.

hidden_size         = 896
num_attention_heads = 14
num_key_value_heads = 2
head_dim             = 64

입력 Hidden State의 Shape이 다음과 같다고 가정하겠습니다.

X
[batch, sequence, 896]

Projection 직후의 Shape은 다음과 같습니다.

q_proj(X)
[batch, sequence, 896]

k_proj(X)
[batch, sequence, 128]

v_proj(X)
[batch, sequence, 128]

이를 Head 단위로 분리하면 다음과 같습니다.

Query
[batch, 14, sequence, 64]

Key
[batch, 2, sequence, 64]

Value
[batch, 2, sequence, 64]

Query Head는 14개지만 KV Head는 2개입니다.

14 Query Heads / 2 KV Heads
= KV Head 하나당 Query Head 7개

여러 Query Head가 하나의 Key/Value Head를 공유하는 이 구조가 Grouped Query Attention, GQA입니다.

KV Head 1
→ Query Head 1~7이 공유

KV Head 2
→ Query Head 8~14가 공유

실제 최적화된 구현에서는 Key와 Value를 물리적으로 여러 번 복사하지 않고, GQA를 지원하는 Attention Kernel이 Head Group을 처리할 수 있습니다.

RoPE로 위치 정보 반영하기

Qwen은 Token Embedding에 별도의 위치 벡터를 단순히 더하는 대신, Query와 Key에 RoPE를 적용합니다.

Hidden State
   ↓
Q, K, V Projection
   ↓
Query와 Key에 RoPE 적용
   ↓
Q × Kᵀ

RoPE는 Token의 위치에 따라 Query와 Key 벡터를 회전시킵니다. 따라서 같은 Token이라도 문장 안에서 등장한 위치에 따라 다른 Attention 관계를 만들 수 있습니다.

Value에는 RoPE가 직접 적용되지 않습니다.

Causal Mask

Decoder-only LLM은 다음 Token을 예측할 때 미래 Token을 미리 볼 수 없어야 합니다.

Token이 네 개 있다고 가정하면 허용되는 Attention 관계는 다음과 같습니다.

             참고할 Token
           t1   t2   t3   t4

현재 t1   [ O    X    X    X ]
현재 t2   [ O    O    X    X ]
현재 t3   [ O    O    O    X ]
현재 t4   [ O    O    O    O ]

각 위치는 자신과 이전 Token만 참고할 수 있습니다.

Attention Score에 적용되는 Mask는 개념적으로 다음과 같습니다.

[
  [0,   -∞,  -∞,  -∞],
  [0,    0,  -∞,  -∞],
  [0,    0,   0,  -∞],
  [0,    0,   0,   0]
]

미래 위치에는 -∞에 가까운 값을 더합니다. 이후 Softmax를 통과하면 해당 위치의 확률은 0이 됩니다.

Softmax(-∞) ≈ 0

PyTorch로 Q·K·V Shape 확인하기

Step 01에서 불러온 model, tokenizer를 그대로 사용합니다.

import torch

prompt = "LLM 서빙은 이전 토큰의 문맥을 사용한다."

inputs = tokenizer(
    prompt,
    return_tensors="pt",
).to(model.device)

input_ids = inputs["input_ids"]

with torch.no_grad():
    hidden_states = model.model.embed_tokens(input_ids)

    first_layer = model.model.layers[0]
    normalized = first_layer.input_layernorm(hidden_states)

    query = first_layer.self_attn.q_proj(normalized)
    key = first_layer.self_attn.k_proj(normalized)
    value = first_layer.self_attn.v_proj(normalized)

print("input_ids:", input_ids.shape)
print("hidden_states:", hidden_states.shape)
print("query projection:", query.shape)
print("key projection:", key.shape)
print("value projection:", value.shape)

입력 Token이 L개라면 다음과 같은 Shape을 확인할 수 있습니다.

input_ids
[1, L]

hidden_states
[1, L, 896]

query projection
[1, L, 896]

key projection
[1, L, 128]

value projection
[1, L, 128]

Projection 결과를 Head 단위로 분리해보겠습니다.

batch_size, sequence_length, _ = query.shape

num_query_heads = model.config.num_attention_heads
num_kv_heads = model.config.num_key_value_heads
head_dim = model.config.hidden_size // num_query_heads

query = query.view(
    batch_size,
    sequence_length,
    num_query_heads,
    head_dim,
).transpose(1, 2)

key = key.view(
    batch_size,
    sequence_length,
    num_kv_heads,
    head_dim,
).transpose(1, 2)

value = value.view(
    batch_size,
    sequence_length,
    num_kv_heads,
    head_dim,
).transpose(1, 2)

print("query heads:", query.shape)
print("key heads:", key.shape)
print("value heads:", value.shape)

결과는 다음 구조가 됩니다.

query heads
[1, 14, L, 64]

key heads
[1, 2, L, 64]

value heads
[1, 2, L, 64]

이 코드는 Tensor Shape을 확인하기 위한 단순화된 예제입니다. 실제 Qwen Forward에서는 Attention Score를 계산하기 전에 Query와 Key에 RoPE를 적용합니다.

Attention 계산을 단순화해 따라가기

다음 코드는 GQA의 Shape과 Causal Mask를 이해하기 위한 교육용 예제입니다.

실제 Qwen은 PyTorch SDPA나 FlashAttention과 같은 최적화된 Kernel을 사용할 수 있으며, Key와 Value를 아래 코드처럼 물리적으로 복제하지 않을 수 있습니다.

import math

query_groups = num_query_heads // num_kv_heads

# Shape 이해를 위한 개념적 확장
key_for_query_heads = key.repeat_interleave(
    query_groups,
    dim=1,
)

value_for_query_heads = value.repeat_interleave(
    query_groups,
    dim=1,
)

# [batch, 14, L, L]
attention_scores = (
    query @ key_for_query_heads.transpose(-2, -1)
) / math.sqrt(head_dim)

causal_mask = torch.triu(
    torch.ones(
        sequence_length,
        sequence_length,
        dtype=torch.bool,
        device=attention_scores.device,
    ),
    diagonal=1,
)

attention_scores = attention_scores.masked_fill(
    causal_mask,
    float("-inf"),
)

attention_weights = torch.softmax(
    attention_scores,
    dim=-1,
)

# [batch, 14, L, 64]
context = attention_weights @ value_for_query_heads

# [batch, L, 896]
context = context.transpose(1, 2).contiguous().view(
    batch_size,
    sequence_length,
    model.config.hidden_size,
)

attention_output = first_layer.self_attn.o_proj(context)

print("attention scores:", attention_scores.shape)
print("attention weights:", attention_weights.shape)
print("context:", context.shape)
print("attention output:", attention_output.shape)

이 코드의 Shape 변화는 다음과 같습니다.

Q × Kᵀ
[batch, 14, L, 64]
×
[batch, 14, 64, L]

→ Attention Scores
[batch, 14, L, L]

→ Softmax
[batch, 14, L, L]

× Value
[batch, 14, L, 64]

→ Context
[batch, 14, L, 64]

→ Head 결합
[batch, L, 896]

→ o_proj
[batch, L, 896]

Self-Attention이 비싼 이유

입력 Token 수를 L이라고 하면 각 Query Token은 허용된 모든 Key Token과 비교됩니다.

Query Token 수: L
Key Token 수: L

Attention Score 개수
≈ L × L

따라서 일반적인 Prefill Attention 계산은 Sequence Length에 대해 제곱으로 증가합니다.

L = 1,000  → 약 1,000,000개의 Token 관계
L = 2,000  → 약 4,000,000개의 Token 관계
L = 4,000  → 약 16,000,000개의 Token 관계

Causal Mask가 미래 위치를 가리더라도, 기본적인 Dense Attention의 계산 및 메모리 패턴이 긴 Sequence에서 부담이 된다는 점은 달라지지 않습니다.

이 문제는 이후 다음 최적화 기술로 연결됩니다.

Attention 계산과 메모리 이동 비용
→ FlashAttention

이전 Token의 K/V 반복 계산
→ KV Cache

KV Cache의 할당과 단편화
→ PagedAttention

📌 핵심

Self-Attention은 Query와 Key의 유사도로 Token 간 참고 비중을 구하고, 그 비중에 따라 Value를 모아 문맥화된 표현을 만든다. Decoder에서는 Causal Mask를 사용해 각 Token이 자신과 이전 Token만 참고하도록 제한한다.

이번 Step의 전체 흐름을 정리하면 다음과 같습니다.

Hidden State
→ Q, K, V Projection
→ Q와 K에 RoPE 적용
→ QKᵀ / √dₖ
→ Causal Mask
→ Softmax
→ Attention Weight × V
→ Head 결합
→ Output Projection
→ 문맥화된 Hidden State

다음 Step에서는 Hugging Face의 generate()가 숨기고 있는 반복문을 직접 작성해보고, KV Cache를 사용하지 않을 때 이전 Token이 매번 다시 계산되는 과정을 확인합니다.


👉 Step 04. generate() 없이 토큰 생성을 직접 반복해보기

앞에서는 모델이 입력을 받아 다음 Token의 확률 분포를 만드는 과정을 살펴봤습니다.

그런데 실제 문장 생성은 한 번의 추론으로 끝나지 않습니다. 모델이 선택한 Token을 기존 입력 뒤에 추가하고, 늘어난 Sequence를 다시 모델에 전달하는 과정을 반복해야 합니다.

Hugging Face의 generate()는 이 반복 과정을 내부에서 처리합니다.

Forward Pass
    ↓
마지막 위치의 Logits 추출
    ↓
다음 Token 선택
    ↓
기존 Sequence 뒤에 추가
    ↓
종료 조건까지 반복

이번에는 generate()를 사용하지 않고 이 과정을 직접 작성해봅니다.

핵심 질문
Token이 하나씩 생성될 때마다 모델의 입력 길이는 어떻게 변하며, use_cache=False에서는 무엇이 반복 계산될까?


1. Prompt를 Chat Template으로 변환하기

Google Colab의 Tesla T4 환경에서 Qwen2.5-0.5B-Instruct를 FP16으로 실행했습니다.

Qwen Instruct 모델은 대화 역할과 생성 시작 위치를 포함한 Chat Template을 사용하는 편이 적절합니다.

import time
import torch

messages = [
    {
        "role": "user",
        "content": "LLM 서빙을 한 문장으로 설명해줘.",
    }
]

inputs = tokenizer.apply_chat_template(
    messages,
    add_generation_prompt=True,
    tokenize=True,
    return_dict=True,
    return_tensors="pt",
).to(model.device)

generated_ids = inputs["input_ids"]
attention_mask = inputs["attention_mask"]

prompt_length = generated_ids.shape[-1]

print("Prompt Token 수:", prompt_length)
print("Input shape:", generated_ids.shape)

apply_chat_template()은 대화 내용을 단순 문자열이 아니라 모델이 학습할 때 사용한 형식의 Token Sequence로 변환합니다.


2. use_cache=False로 생성 반복문 작성하기

이번 실습에서는 생성 과정을 명확하게 관찰하기 위해 KV Cache를 비활성화합니다.

다음 Token을 선택할 때는 가장 높은 확률의 Token을 고르는 Greedy Decoding을 사용합니다.

max_new_tokens = 12
step_records = []

model.eval()

with torch.inference_mode():
    for step in range(max_new_tokens):
        input_length = generated_ids.shape[-1]

        # GPU 연산은 비동기적으로 실행되므로
        # 시간을 측정하기 전후에 동기화합니다.
        if model.device.type == "cuda":
            torch.cuda.synchronize()

        started_at = time.perf_counter()

        outputs = model(
            input_ids=generated_ids,
            attention_mask=attention_mask,
            use_cache=False,
        )

        if model.device.type == "cuda":
            torch.cuda.synchronize()

        elapsed_ms = (time.perf_counter() - started_at) * 1000

        # 마지막 위치에서 다음 Token의 Logits만 사용합니다.
        next_token_logits = outputs.logits[:, -1, :]

        # Greedy Decoding: 확률이 가장 높은 Token 선택
        next_token_id = torch.argmax(
            next_token_logits,
            dim=-1,
            keepdim=True,
        )

        next_token_text = tokenizer.decode(
            next_token_id[0],
            skip_special_tokens=False,
        )

        step_records.append(
            {
                "step": step + 1,
                "input_length": input_length,
                "token_id": next_token_id.item(),
                "token": repr(next_token_text),
                "elapsed_ms": elapsed_ms,
            }
        )

        print(
            f"Step {step + 1:02d} | "
            f"Input length: {input_length:3d} | "
            f"Token ID: {next_token_id.item():6d} | "
            f"Token: {next_token_text!r} | "
            f"{elapsed_ms:.2f} ms"
        )

        # 선택된 Token을 기존 Sequence 뒤에 추가합니다.
        generated_ids = torch.cat(
            [generated_ids, next_token_id],
            dim=-1,
        )

        # 새 Token에 대응하는 Attention Mask도 추가합니다.
        new_mask = torch.ones(
            (attention_mask.shape[0], 1),
            dtype=attention_mask.dtype,
            device=attention_mask.device,
        )

        attention_mask = torch.cat(
            [attention_mask, new_mask],
            dim=-1,
        )

        # EOS Token이 생성되면 종료합니다.
        if next_token_id.item() == tokenizer.eos_token_id:
            break

생성된 부분만 디코딩하면 다음과 같습니다.

new_token_ids = generated_ids[:, prompt_length:]

generated_text = tokenizer.decode(
    new_token_ids[0],
    skip_special_tokens=True,
)

print("\nGenerated text:")
print(generated_text)

생성 단계가 진행될수록 input_length가 42, 43, 44와 같이 한 Token씩 증가합니다.

실행할 때마다 다음과 같은 정보를 관찰할 수 있습니다.

Step 01 | Input length: L     | 새 Token: t1
Step 02 | Input length: L + 1 | 새 Token: t2
Step 03 | Input length: L + 2 | 새 Token: t3
...

정확한 Token과 실행 시간은 Prompt, 모델, GPU 및 실행 환경에 따라 달라집니다.


3. 입력 길이가 계속 증가하는 이유

초기 Prompt 길이를 L이라고 하면 각 Step의 입력은 다음과 같이 변합니다.

생성 단계모델에 전달되는 입력입력 길이
Step 1PromptL
Step 2Prompt + t1L + 1
Step 3Prompt + t1 + t2L + 2
Step 4Prompt + t1 + t2 + t3L + 3

use_cache=False에서는 각 Step마다 이 전체 Sequence가 다시 모델에 입력됩니다.

Step 1: [Prompt 전체]                       → t1
Step 2: [Prompt 전체, t1]                   → t2
Step 3: [Prompt 전체, t1, t2]               → t3
Step 4: [Prompt 전체, t1, t2, t3]           → t4

4. use_cache=False에서는 무엇이 다시 계산될까?

Cache를 사용하지 않으면 각 Decoder Layer는 매 Step마다 전체 Sequence에 대해 다음 연산을 다시 수행합니다.

Hidden States
    ↓
Q, K, V Projection
    ↓
RoPE 적용
    ↓
Attention Score 계산
    ↓
Causal Mask + Softmax
    ↓
Value 가중합

예를 들어 Step 3에서 입력이 다음과 같다고 가정해봅니다.

[Prompt Token들, t1, t2]

t3을 만들기 위해 새 Token인 t2만 계산하는 것이 아닙니다. Prompt와 t1을 포함한 전체 Sequence의 Hidden State와 K/V가 각 Layer에서 다시 계산됩니다.

단계전체 입력 길이새로 추가된 Token다시 처리되는 위치
Step 1L없음L개 전체
Step 2L + 11개L + 1개 전체
Step 3L + 21개L + 2개 전체

여기서 중요한 점은 다음과 같습니다.

Cache를 사용하지 않는다고 해서 모델이 이전 문맥을 잊는 것은 아닙니다.
이전 문맥을 매번 다시 입력하고 다시 계산하는 것입니다.

따라서 생성 길이가 길어질수록 이미 처리했던 Token에 대한 중복 연산도 함께 증가합니다.


5. generate()가 숨기고 있던 것

결국 generate()가 수행하는 핵심 절차는 직접 작성한 반복문과 같습니다.

while not finished:
    outputs = model(input_ids)

    next_token_logits = outputs.logits[:, -1, :]
    next_token_id = select(next_token_logits)

    input_ids = append(input_ids, next_token_id)

여기에 실제 generate()는 다음과 같은 기능을 추가로 처리합니다.

  • Greedy, Sampling, Beam Search 등의 Decoding 전략
  • EOS와 최대 생성 길이 같은 종료 조건
  • Logits Processor 및 Logits Warper
  • Padding과 Attention Mask
  • KV Cache 관리
  • Batch 단위의 생성 상태 관리

즉, generate()는 모델 자체의 연산이라기보다 Autoregressive Decoding을 제어하는 반복 실행기에 가깝습니다.

6. 직접 구현과 generate() 결과 비교

동일한 Greedy Decoding 조건을 적용해 직접 작성한 반복문과 Hugging Face의 generate() 결과를 비교했습니다.

Qwen의 기본 Generation 설정을 통일하자 두 방식에서 동일한 Token Sequence가 생성되었다.


7. 이번 Step에서 확인한 것

  • LLM은 한 번에 완성된 문장을 출력하지 않습니다.
  • 매 Step에서 마지막 위치의 Logits로 다음 Token을 선택합니다.
  • 선택된 Token은 기존 입력 뒤에 추가됩니다.
  • use_cache=False에서는 늘어난 전체 Sequence가 매번 다시 입력됩니다.
  • 이 과정에서 과거 Token의 K/V를 포함한 Layer 연산이 반복됩니다.
  • 생성 길이가 길어질수록 중복 계산도 증가합니다.

use_cache=False는 Autoregressive Generation의 동작을 이해하기 위한 좋은 Baseline입니다.
하지만 실제 LLM Serving에서는 이러한 중복 계산을 줄이기 위해 KV Cache를 사용합니다.

참고:

다음 Step에서는 past_key_values의 구조를 직접 확인하고, KV Cache를 사용했을 때 왜 새 Token만 모델에 입력할 수 있는지 살펴봅니다.


👉 Step 05. KV Cache는 무엇을 저장하는가?

Step 04에서는 use_cache=False로 생성하면서 매 단계마다 전체 Sequence가 다시 모델에 전달되는 것을 확인했습니다.

Step 1: Prompt 전체
Step 2: Prompt + t1 전체
Step 3: Prompt + t1 + t2 전체

하지만 Decoder-only Transformer에서 이미 처리한 과거 Token의 Key와 Value는 미래 Token이 추가되더라도 바뀌지 않습니다.

따라서 과거 Token의 K/V를 저장해두면 매번 다시 계산할 필요가 없습니다. 이 저장 공간을 KV Cache라고 합니다.

핵심 질문
KV Cache를 사용하면 모델의 입력과 Layer별 K/V Tensor Shape은 실제로 어떻게 바뀔까?


1. 왜 Query가 아니라 Key와 Value를 저장할까?

새로운 Token을 생성할 때 Attention은 현재 Token의 Query와 지금까지 축적된 Key를 비교합니다.

현재 Token의 Query
        ×
과거와 현재 Token의 Key
        ↓
Attention Weight
        ×
과거와 현재 Token의 Value
        ↓
현재 Token의 Context Vector

수식으로 표현하면 다음과 같습니다.

Attention(
    qₜ,
    [K₁, K₂, ..., Kₜ],
    [V₁, V₂, ..., Vₜ]
)

현재 시점의 Query는 이번 Attention 계산에 사용한 뒤 다시 필요하지 않습니다. 반면 과거 Token의 Key와 Value는 이후 생성되는 모든 Token이 참고합니다.

따라서 KV Cache에는 Query가 아니라 각 Decoder Layer의 Key와 Value가 저장됩니다.


2. Prefill에서 KV Cache 생성하기

42개 Prompt Token을 Prefill하자 24개 Layer에 각각 (1, 2, 42, 64) 형태의 Key와 Value가 저장되었다.

Prompt 전체를 처음 처리하는 단계를 Prefill이라고 합니다.

Prompt 전체 입력
    ↓
모든 Prompt Token의 K/V 계산
    ↓
Layer별 KV Cache 저장
    ↓
첫 번째 출력 Token 예측

Step 04에서 만든 inputs를 그대로 사용해 Cache를 생성합니다.

with torch.inference_mode():
    prefill_outputs = model(
        **inputs,
        use_cache=True,
    )

past_key_values = prefill_outputs.past_key_values

first_token_id = torch.argmax(
    prefill_outputs.logits[:, -1, :],
    dim=-1,
    keepdim=True,
)

print("Prompt input shape:", inputs["input_ids"].shape)
print("First token:", tokenizer.decode(first_token_id[0]))
print("Cache type:", type(past_key_values))

Transformers 버전에 따라 Cache 내부 접근 방식이 다를 수 있으므로 다음 함수를 사용합니다.

def get_layer_kv(cache, layer_idx=0):
    # 최신 DynamicCache 형식
    if hasattr(cache, "layers"):
        layer = cache.layers[layer_idx]
        return layer.keys, layer.values

    # 이전 DynamicCache 형식
    if hasattr(cache, "key_cache"):
        return (
            cache.key_cache[layer_idx],
            cache.value_cache[layer_idx],
        )

    # Legacy tuple 형식
    return cache[layer_idx]


def get_cache_layer_count(cache):
    if hasattr(cache, "layers"):
        return len(cache.layers)

    if hasattr(cache, "key_cache"):
        return len(cache.key_cache)

    return len(cache)

첫 번째 Decoder Layer의 Cache Shape을 확인합니다.

layer0_key, layer0_value = get_layer_kv(
    past_key_values,
    layer_idx=0,
)

prefill_key_shape = tuple(layer0_key.shape)
prefill_value_shape = tuple(layer0_value.shape)

print("Cache layer 수:", get_cache_layer_count(past_key_values))
print("Layer 0 Key shape:", prefill_key_shape)
print("Layer 0 Value shape:", prefill_value_shape)

Qwen2.5-0.5B-Instruct와 42개의 Prompt Token을 사용했다면 다음 구조를 확인할 수 있습니다.

Cache layer 수: 24
Layer 0 Key shape:   [1, 2, 42, 64]
Layer 0 Value shape: [1, 2, 42, 64]

각 차원의 의미는 다음과 같습니다.

차원의미
Batch1요청 수
KV Heads2num_key_value_heads
Sequence42Cache에 저장된 Token 수
Head Dimension64896 ÷ 14

Query Head는 14개지만 KV Head는 2개입니다. Qwen2.5-0.5B는 GQA를 사용하므로 여러 Query Head가 같은 K/V Head를 공유합니다.


3. Decode에서는 새 Token 하나만 입력하기

Prefill 이후 생성되는 각 Token을 처리하는 단계를 Decode라고 합니다.

Cache를 사용하면 과거 Token 전체를 다시 입력할 필요가 없습니다. 방금 생성한 Token 하나와 기존 past_key_values만 모델에 전달합니다.

prompt_length = inputs["input_ids"].shape[-1]

decode_attention_mask = torch.cat(
    [
        inputs["attention_mask"],
        torch.ones(
            (inputs["attention_mask"].shape[0], 1),
            dtype=inputs["attention_mask"].dtype,
            device=model.device,
        ),
    ],
    dim=-1,
)

cache_position = torch.tensor(
    [prompt_length],
    device=model.device,
)

with torch.inference_mode():
    decode_outputs = model(
        input_ids=first_token_id,
        attention_mask=decode_attention_mask,
        past_key_values=past_key_values,
        cache_position=cache_position,
        use_cache=True,
    )

print("Decode input shape:", first_token_id.shape)

결과는 다음과 같습니다.

Prompt input shape: [1, 42]
Decode input shape: [1, 1]

Step 04에서는 두 번째 Token을 생성할 때 43개 Token 전체를 다시 입력했습니다.

KV Cache를 사용한 이번 실습에서는 새로 생성된 1개 Token만 입력했습니다.

Cache 없음
[Prompt 42개 + 생성 Token 1개] → 모델 입력 43개

Cache 사용
[생성 Token 1개] + [과거 K/V Cache] → 모델 입력 1개

4. Cache의 Sequence 길이 증가 확인하기

Decode가 끝난 뒤 첫 번째 Layer의 KV Cache를 다시 확인합니다.

updated_cache = decode_outputs.past_key_values

updated_key, updated_value = get_layer_kv(
    updated_cache,
    layer_idx=0,
)

decode_key_shape = tuple(updated_key.shape)
decode_value_shape = tuple(updated_value.shape)

print("Prefill Key shape:", prefill_key_shape)
print("Decode Key shape:", decode_key_shape)

print("Prefill Value shape:", prefill_value_shape)
print("Decode Value shape:", decode_value_shape)

예상되는 변화는 다음과 같습니다.

Prefill Key shape: [1, 2, 42, 64]
Decode Key shape:  [1, 2, 43, 64]

새 Token 하나가 처리되면서 Sequence 차원만 42 → 43으로 증가했습니다.

K_cache ← concat(K_past, K_new)
V_cache ← concat(V_past, V_new)

Dynamic KV Cache는 Token이 생성될 때마다 Layer별 Key와 Value 뒤에 새로운 K/V를 추가합니다.


5. Cache 결과가 전체 재계산과 같은지 검증하기

KV Cache를 사용하자 Decode 입력은 1개 Token으로 줄었고, Cache 길이는 42에서 43으로 증가했다. 전체 재계산과 동일한 Token도 얻었다.

Cache를 사용해 계산한 두 번째 Token과 전체 Sequence를 다시 계산한 두 번째 Token을 비교합니다.

cached_second_token_id = torch.argmax(
    decode_outputs.logits[:, -1, :],
    dim=-1,
    keepdim=True,
)

full_input_ids = torch.cat(
    [inputs["input_ids"], first_token_id],
    dim=-1,
)

with torch.inference_mode():
    no_cache_outputs = model(
        input_ids=full_input_ids,
        attention_mask=decode_attention_mask,
        use_cache=False,
    )

no_cache_second_token_id = torch.argmax(
    no_cache_outputs.logits[:, -1, :],
    dim=-1,
    keepdim=True,
)

print(
    "Cache 사용:",
    tokenizer.decode(cached_second_token_id[0]),
)

print(
    "전체 재계산:",
    tokenizer.decode(no_cache_second_token_id[0]),
)

print(
    "Token IDs 일치:",
    torch.equal(
        cached_second_token_id,
        no_cache_second_token_id,
    ),
)

다음처럼 나오면 Cache를 사용한 계산이 정상적으로 동작한 것입니다.

Token IDs 일치: True

KV Cache는 모델의 결과를 근사하거나 생략하는 최적화가 아닙니다.
과거 Token에서 이미 계산한 K/V를 저장하고 재사용하는 최적화입니다.


6. KV Cache는 얼마나 많은 메모리를 사용할까?

Layer별 K/V Tensor를 합산한 실제 Cache 크기가 모델 Config로 계산한 이론값과 일치했다. KV Cache 메모리는 Sequence 길이에 비례해 증가한다.

KV Cache 크기는 다음 요소로 결정됩니다.

KV Cache Bytes
=
Batch
× Layer 수
× 2 (Key + Value)
× KV Head 수
× Sequence 길이
× Head Dimension
× Element 크기

Qwen2.5-0.5B-Instruct를 FP16으로 실행하는 현재 환경에서는 다음 값이 사용됩니다.

Layer 수       = 24
KV Head 수     = 2
Head Dimension = 64
FP16           = 2 Bytes

Batch 1에서 Token 하나당 KV Cache 크기는 다음과 같습니다.

24 × 2 × 2 × 64 × 2 Bytes
= 12,288 Bytes
= 12 KiB / Token

Sequence 길이에 따른 이론적인 Cache 크기는 다음과 같습니다.

Sequence 길이KV Cache 크기
42약 504 KiB
4,096약 48 MiB
32,768약 384 MiB

이는 Qwen2.5-0.5B처럼 KV Head가 2개뿐인 작은 GQA 모델의 Batch 1 기준입니다. 모델 크기, Layer 수, KV Head 수, Batch 크기와 동시 요청 수가 증가하면 Cache 메모리도 함께 증가합니다.

실제로 생성된 Cache Tensor가 사용하는 메모리는 다음 코드로 확인할 수 있습니다.

def get_kv_cache_bytes(cache):
    total_bytes = 0

    for layer_idx in range(get_cache_layer_count(cache)):
        key, value = get_layer_kv(cache, layer_idx)

        total_bytes += key.numel() * key.element_size()
        total_bytes += value.numel() * value.element_size()

    return total_bytes


cache_bytes = get_kv_cache_bytes(updated_cache)

print("KV Cache Bytes:", cache_bytes)
print("KV Cache KiB:", cache_bytes / 1024)
print("KV Cache MiB:", cache_bytes / (1024 ** 2))

이 계산은 PyTorch가 관리하는 부가 메모리나 메모리 할당 단편화가 아니라, Layer별 Key/Value Tensor 자체의 크기를 합산한 값입니다.


7. Prefill과 Decode의 차이

구분PrefillDecode
입력Prompt 전체새 Token 1개
주요 작업Prompt 전체의 K/V 생성현재 Token의 K/V 추가
병렬성여러 Prompt Token 병렬 처리Token을 순차적으로 생성
Cache 변화Prompt 길이만큼 생성Step마다 1씩 증가
Serving 관점많은 연산량반복 실행과 메모리 접근

KV Cache를 사용하더라도 현재 Query는 Cache에 저장된 모든 Key와 비교하고, Attention Weight를 통해 모든 Value를 읽어야 합니다.

따라서 과거 K/V의 재계산은 제거되지만, Context가 길어질수록 Cache의 메모리 사용량과 읽어야 하는 데이터는 계속 증가합니다.

이 지점에서 LLM Serving의 새로운 문제가 나타납니다.

KV Cache로 중복 연산 제거
        ↓
요청마다 가변적인 Cache 공간 필요
        ↓
GPU 메모리 할당과 단편화 문제
        ↓
PagedAttention과 Continuous Batching

8. 이번 Step에서 확인한 것

  • KV Cache는 각 Decoder Layer의 과거 Key와 Value를 저장합니다.
  • Qwen2.5-0.5B에서는 Layer마다 2개의 KV Head를 사용합니다.
  • Prefill에서는 Prompt 전체의 K/V가 Cache에 저장됩니다.
  • Decode에서는 전체 Sequence가 아니라 새 Token 하나만 모델에 입력됩니다.
  • 새 Token이 생성될 때마다 Cache의 Sequence 차원이 1씩 증가합니다.
  • Cache를 사용해도 전체 재계산과 같은 Token을 얻을 수 있습니다.
  • 중복 연산은 줄지만 Cache 메모리 사용량은 Sequence 길이에 비례해 증가합니다.

KV Cache는 LLM 생성 속도를 높이는 핵심 장치이지만, 동시에 LLM Serving에서 가장 중요한 GPU 메모리 관리 대상이 됩니다.

참고:

다음 Step에서는 Prefill과 Decode의 실행 시간을 분리해서 측정하고, KV Cache 사용 여부에 따라 Token 생성 비용이 어떻게 달라지는지 비교합니다.


👉 Step 06. KV Cache의 효과를 실행 시간으로 측정하기

Step 05에서는 KV Cache를 사용했을 때 Decode 입력이 전체 Sequence에서 새 Token 하나로 줄어드는 것을 확인했습니다.

Cache 없음: [Prompt + 생성된 Token 전체] 입력
Cache 사용: [새 Token 1개] + [저장된 K/V] 입력

이번에는 이것이 실제 실행 시간에 어떤 차이를 만드는지 측정합니다.

핵심 질문
Prompt 길이가 증가할수록 KV Cache 사용 여부에 따른 Decode 시간은 어떻게 달라질까?


1. 어떤 지표를 측정할까?

이번 실습에서는 다음 지표를 사용합니다.

지표의미
Prompt TokensPrefill에 입력되는 Token 수
Prefill / TTFTPrompt 전체를 처리하고 첫 Token을 선택하기까지의 시간
Decode Latency두 번째 Token부터 Token 하나를 생성하는 평균 시간
Decode Throughput1초 동안 생성할 수 있는 Token 수
Total Latency정해진 수의 Token을 모두 생성한 시간

Decode Throughput은 다음과 같이 계산합니다.

Decode Tokens/s
=
1,000
÷
평균 Decode Latency(ms/token)

여기서 측정하는 TTFT는 HTTP 요청, Queue, Tokenization과 Network 시간을 제외한 모델 Forward 기준입니다.

실제 Serving 시스템에서 측정하는 End-to-End TTFT와는 범위가 다릅니다.


2. 길이가 다른 Benchmark Prompt 준비하기

짧은 Prompt만 사용하면 Qwen2.5-0.5B처럼 작은 모델에서는 Cache 관리 비용과 측정 오차의 영향이 상대적으로 크게 나타날 수 있습니다.

따라서 64, 256, 512, 1,024 Tokens 길이에서 각각 측정합니다.

benchmark_text = (
    "LLM 서빙 시스템은 여러 사용자의 요청을 처리하면서 "
    "GPU 자원과 KV Cache를 효율적으로 관리해야 합니다. "
    * 300
)

token_pool = tokenizer(
    benchmark_text,
    add_special_tokens=False,
    return_tensors="pt",
)["input_ids"].to(model.device)

print("Token pool shape:", token_pool.shape)

길이에 맞는 입력을 만드는 함수를 작성합니다.

def make_benchmark_inputs(prompt_length):
    input_ids = token_pool[:, :prompt_length].clone()
    attention_mask = torch.ones_like(input_ids)

    return input_ids, attention_mask

3. Prefill과 Decode를 분리해 측정하기

GPU 연산은 CPU 코드와 비동기적으로 실행됩니다. 따라서 time.perf_counter()만 호출하면 실제 GPU 연산이 끝나기 전의 시간이 측정될 수 있습니다.

측정 전후에 torch.cuda.synchronize()를 호출합니다.

import time
import statistics
import pandas as pd
import torch

Cache 유무에 따라 동일한 Greedy Decoding을 실행하는 Benchmark 함수를 작성합니다.

def benchmark_generation(
    input_ids,
    attention_mask,
    use_cache,
    max_new_tokens=16,
):
    generated_ids = input_ids.clone()
    current_attention_mask = attention_mask.clone()

    # -------------------------
    # Prefill
    # -------------------------
    torch.cuda.synchronize()
    prefill_started = time.perf_counter()

    with torch.inference_mode():
        outputs = model(
            input_ids=generated_ids,
            attention_mask=current_attention_mask,
            use_cache=use_cache,
        )

    torch.cuda.synchronize()
    prefill_ms = (
        time.perf_counter() - prefill_started
    ) * 1000

    next_token_id = torch.argmax(
        outputs.logits[:, -1, :],
        dim=-1,
        keepdim=True,
    )

    generated_ids = torch.cat(
        [generated_ids, next_token_id],
        dim=-1,
    )

    current_attention_mask = torch.cat(
        [
            current_attention_mask,
            torch.ones(
                (current_attention_mask.shape[0], 1),
                dtype=current_attention_mask.dtype,
                device=current_attention_mask.device,
            ),
        ],
        dim=-1,
    )

    if use_cache:
        cache = outputs.past_key_values

    # -------------------------
    # Decode
    # -------------------------
    decode_token_count = max_new_tokens - 1

    torch.cuda.synchronize()
    decode_started = time.perf_counter()

    with torch.inference_mode():
        for _ in range(decode_token_count):
            if use_cache:
                cache_position = torch.tensor(
                    [current_attention_mask.shape[-1] - 1],
                    device=model.device,
                )

                outputs = model(
                    input_ids=next_token_id,
                    attention_mask=current_attention_mask,
                    past_key_values=cache,
                    cache_position=cache_position,
                    use_cache=True,
                )

                cache = outputs.past_key_values

            else:
                outputs = model(
                    input_ids=generated_ids,
                    attention_mask=current_attention_mask,
                    use_cache=False,
                )

            next_token_id = torch.argmax(
                outputs.logits[:, -1, :],
                dim=-1,
                keepdim=True,
            )

            generated_ids = torch.cat(
                [generated_ids, next_token_id],
                dim=-1,
            )

            current_attention_mask = torch.cat(
                [
                    current_attention_mask,
                    torch.ones(
                        (current_attention_mask.shape[0], 1),
                        dtype=current_attention_mask.dtype,
                        device=current_attention_mask.device,
                    ),
                ],
                dim=-1,
            )

    torch.cuda.synchronize()
    decode_total_ms = (
        time.perf_counter() - decode_started
    ) * 1000

    decode_ms_per_token = (
        decode_total_ms / decode_token_count
    )

    total_ms = prefill_ms + decode_total_ms

    return {
        "prefill_ms": prefill_ms,
        "decode_ms_per_token": decode_ms_per_token,
        "decode_tokens_per_second": (
            1000 / decode_ms_per_token
        ),
        "total_ms": total_ms,
        "generated_ids": generated_ids,
    }

4. Warm-up 후 반복 측정하기

첫 번째 실행에는 CUDA Kernel 초기화와 메모리 할당 등의 비용이 포함될 수 있습니다.

각 조건을 한 번 Warm-up하고, 이후 3회 측정한 중앙값을 사용합니다.

prompt_lengths = [64, 256, 512, 1024]
max_new_tokens = 16
repeats = 3

records = []
output_matches = {}

for prompt_length in prompt_lengths:
    input_ids, attention_mask = make_benchmark_inputs(
        prompt_length
    )

    mode_outputs = {}

    for use_cache in [False, True]:
        # Warm-up
        _ = benchmark_generation(
            input_ids=input_ids,
            attention_mask=attention_mask,
            use_cache=use_cache,
            max_new_tokens=4,
        )

        runs = []

        for _ in range(repeats):
            result = benchmark_generation(
                input_ids=input_ids,
                attention_mask=attention_mask,
                use_cache=use_cache,
                max_new_tokens=max_new_tokens,
            )

            runs.append(result)

        prefill_values = [
            run["prefill_ms"]
            for run in runs
        ]

        decode_values = [
            run["decode_ms_per_token"]
            for run in runs
        ]

        total_values = [
            run["total_ms"]
            for run in runs
        ]

        median_prefill = statistics.median(
            prefill_values
        )

        median_decode = statistics.median(
            decode_values
        )

        median_total = statistics.median(
            total_values
        )

        mode_name = (
            "KV Cache"
            if use_cache
            else "No Cache"
        )

        records.append(
            {
                "Prompt Tokens": prompt_length,
                "Mode": mode_name,
                "Prefill ms": median_prefill,
                "Decode ms/token": median_decode,
                "Decode tokens/s": 1000 / median_decode,
                "Total ms": median_total,
            }
        )

        mode_outputs[use_cache] = runs[-1][
            "generated_ids"
        ]

    output_matches[prompt_length] = torch.equal(
        mode_outputs[False],
        mode_outputs[True],
    )

5. 실제 측정 결과 확인하기

benchmark_df = pd.DataFrame(records)

numeric_columns = [
    "Prefill ms",
    "Decode ms/token",
    "Decode tokens/s",
    "Total ms",
]

benchmark_df[numeric_columns] = benchmark_df[
    numeric_columns
].round(2)

print(benchmark_df.to_string(index=False))

실험 결과가 동일한 Token Sequence를 생성했는지도 확인합니다.

print("\n=== Output Token IDs 검증 ===")

for prompt_length, matched in output_matches.items():
    print(
        f"{prompt_length:>4} Tokens: {matched}"
    )

동일한 Greedy Decoding 조건에서 Cache 유무와 관계없이 같은 Token Sequence가 생성되었는지 확인했다. 표의 구체적인 수치는 실행 환경에 따라 달라진다.


6. Cache 사용에 따른 Decode 속도 향상 계산하기

no_cache_df = (
    benchmark_df[
        benchmark_df["Mode"] == "No Cache"
    ]
    .set_index("Prompt Tokens")
)

cache_df = (
    benchmark_df[
        benchmark_df["Mode"] == "KV Cache"
    ]
    .set_index("Prompt Tokens")
)

comparison_df = pd.DataFrame(
    {
        "No Cache ms/token":
            no_cache_df["Decode ms/token"],

        "KV Cache ms/token":
            cache_df["Decode ms/token"],

        "Speedup":
            (
                no_cache_df["Decode ms/token"]
                / cache_df["Decode ms/token"]
            ),
    }
)

comparison_df["Speedup"] = (
    comparison_df["Speedup"]
    .round(2)
    .astype(str)
    + "x"
)

print(comparison_df.to_string())

Speedup은 Cache를 사용하지 않은 평균 Decode 시간과 KV Cache를 사용한 평균 Decode 시간의 비율이다.

수치가 다음과 같다면 2.0x는 Cache를 사용했을 때 Token 하나를 생성하는 시간이 절반 수준이라는 의미입니다.

Speedup
=
No Cache Decode Latency
÷
KV Cache Decode Latency

실제 배수는 모델, GPU, Prompt 길이, 생성 길이와 라이브러리 버전에 따라 달라집니다.


7. 측정 결과를 그래프로 표현하기

개념도 대신 실제 Benchmark 결과를 그래프로 표현합니다.

import matplotlib.pyplot as plt

plt.figure(figsize=(9, 5))

for mode in ["No Cache", "KV Cache"]:
    mode_df = benchmark_df[
        benchmark_df["Mode"] == mode
    ]

    plt.plot(
        mode_df["Prompt Tokens"],
        mode_df["Decode ms/token"],
        marker="o",
        linewidth=2,
        label=mode,
    )

plt.title(
    "Decode Latency by Prompt Length"
)
plt.xlabel("Prompt Tokens")
plt.ylabel("Decode Latency (ms/token)")
plt.grid(alpha=0.3)
plt.legend()
plt.tight_layout()
plt.show()

Prompt가 길어질 때 Cache를 사용하지 않은 Decode와 KV Cache를 사용한 Decode의 Latency가 어떻게 달라지는지 비교했다.


8. 결과를 어떻게 해석해야 할까?

결과표에서 다음 순서로 확인합니다.

첫째, 출력 Token이 같은가?

64 Tokens:   True
256 Tokens:  True
512 Tokens:  True
1024 Tokens: True

동일한 결과가 나왔다면 KV Cache는 모델의 출력을 바꾼 것이 아니라 중복 계산을 제거한 것입니다.

둘째, Prompt가 길어질수록 No Cache의 Decode 시간이 증가하는가?

Cache를 사용하지 않으면 매 생성 단계마다 Prompt와 이전 출력 Token 전체가 다시 모델을 통과합니다.

따라서 Context가 길수록 다시 계산해야 하는 Token 수도 증가합니다.

셋째, KV Cache의 Decode 시간은 어떻게 변하는가?

KV Cache를 사용하면 현재 Token의 Q/K/V만 새로 계산합니다. 하지만 현재 Query는 Cache에 저장된 과거 Key 및 Value를 읽어야 합니다.

따라서 Cache를 사용해도 Context 길이의 영향을 완전히 제거하는 것은 아닙니다.

넷째, Prefill 시간은 항상 Cache가 더 빠른가?

그렇지 않을 수 있습니다.

Prefill은 두 방식 모두 Prompt 전체를 처리합니다. use_cache=True에서는 이후 Decode를 위해 Layer별 K/V를 저장하는 작업도 수행합니다.

따라서 이번 실험에서 KV Cache의 핵심 효과는 Prefill보다 반복되는 Decode 구간에서 관찰해야 합니다.

짧은 Prompt나 작은 모델에서는 Cache 관리 비용과 측정 노이즈 때문에 차이가 작거나 일부 구간에서 예상과 다른 결과가 나올 수 있다. 따라서 한 번의 실행값보다 Warm-up 후 반복 측정한 중앙값과 Prompt 길이에 따른 추세를 확인해야 한다.


9. 이번 Step에서 확인한 것

  • Prefill과 Decode의 시간을 분리해 측정했습니다.
  • GPU 비동기 실행을 고려해 측정 전후에 동기화했습니다.
  • Warm-up 이후 반복 실행한 중앙값을 사용했습니다.
  • Cache 유무와 관계없이 동일한 Token Sequence가 생성되는지 검증했습니다.
  • Prompt 길이에 따른 Decode Latency 변화를 비교했습니다.
  • KV Cache의 효과는 Prompt 전체를 처리하는 Prefill보다 반복 Decode에서 두드러집니다.
  • KV Cache도 과거 K/V를 읽어야 하므로 Context 길이의 영향을 완전히 제거하지는 않습니다.

KV Cache가 줄이는 것은 과거 Token의 반복 계산이다.
그러나 Cache를 저장하고 읽는 메모리 비용은 새로운 Serving 문제로 남는다.

참고:

다음 Step에서는 요청마다 Prompt 및 출력 길이가 다른 상황을 만들고, 고정 Batch가 비효율적인 이유와 Continuous Batching이 필요한 이유를 실습으로 확인합니다.


👉 Step 07. 요청 길이가 다르면 고정 Batch는 왜 비효율적일까?

지금까지는 하나의 요청이 Token을 생성하는 과정을 살펴봤습니다.

실제 LLM Serving 시스템에는 Prompt 길이와 출력 길이가 서로 다른 요청이 동시에 들어옵니다.

요청 A → 짧은 응답
요청 B → 긴 응답
요청 C → 중간 길이 응답

고정 Batch에서는 처음 묶인 요청들이 같은 Batch를 유지합니다. 짧은 요청이 먼저 끝나더라도 해당 Slot에 새로운 요청을 바로 넣지 못하고, 가장 긴 요청이 끝날 때까지 기다립니다.

Continuous Batching은 매 Decode Step마다 완료된 요청을 제거하고 대기 중인 요청을 빈 Slot에 투입합니다.

핵심 질문
길이가 다른 요청이 들어올 때 고정 Batch와 Continuous Batching의 대기 시간과 Batch Slot 활용률은 얼마나 달라질까?


1. 실험할 요청 정의하기

이번 실습은 실제 GPU 시간이 아닌 Token 생성 Step을 기준으로 한 Scheduler Simulation입니다.

각 요청에는 도착 시점과 생성해야 하는 Token 수가 있습니다.

import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Patch

requests = [
    {"request": "A", "arrival": 0, "output_tokens": 3},
    {"request": "B", "arrival": 0, "output_tokens": 8},
    {"request": "C", "arrival": 0, "output_tokens": 5},
    {"request": "D", "arrival": 2, "output_tokens": 4},
    {"request": "E", "arrival": 4, "output_tokens": 3},
]

batch_capacity = 3

request_df = pd.DataFrame(requests)

print("=== Request Workload ===")
print(request_df.to_string(index=False))
print("\nBatch Capacity:", batch_capacity)

요청의 의미는 다음과 같습니다.

요청도착 Step출력 Token 수
A03
B08
C05
D24
E43

Batch에는 동시에 최대 3개의 요청만 들어갈 수 있다고 가정합니다.


2. 고정 Batch 시뮬레이션

고정 Batch는 요청을 최대 Batch 크기만큼 묶은 뒤, 그중 가장 긴 요청이 끝날 때까지 Batch 구성을 유지합니다.

def simulate_static_batching(requests, capacity):
    pending = [
        dict(request)
        for request in requests
    ]

    current_step = 0
    results = []

    while pending:
        available = [
            request
            for request in pending
            if request["arrival"] <= current_step
        ]

        if not available:
            current_step = min(
                request["arrival"]
                for request in pending
            )

            available = [
                request
                for request in pending
                if request["arrival"] <= current_step
            ]

        batch = available[:capacity]

        for request in batch:
            pending.remove(request)

        batch_duration = max(
            request["output_tokens"]
            for request in batch
        )

        batch_release = (
            current_step + batch_duration
        )

        for request in batch:
            finish_step = (
                current_step
                + request["output_tokens"]
            )

            results.append(
                {
                    "request": request["request"],
                    "arrival": request["arrival"],
                    "start": current_step,
                    "finish": finish_step,
                    "slot_release": batch_release,
                    "output_tokens":
                        request["output_tokens"],
                }
            )

        current_step = batch_release

    return pd.DataFrame(results).sort_values(
        "request"
    ).reset_index(drop=True)
static_df = simulate_static_batching(
    requests,
    batch_capacity,
)

print("=== Static Batching ===")
print(static_df.to_string(index=False))

요청 A는 3 Steps 만에 생성이 끝나지만, 같은 Batch의 요청 B가 8 Steps 동안 실행됩니다.

따라서 요청 A가 사용하던 Slot은 요청이 끝난 뒤에도 Batch가 해제될 때까지 새로운 요청을 받을 수 없습니다.


3. Continuous Batching 시뮬레이션

Continuous Batching에서는 매 Step이 끝날 때 완료된 요청을 제거합니다. 다음 Step을 시작하기 전에 대기 Queue의 요청을 빈 Slot에 추가합니다.

def simulate_continuous_batching(
    requests,
    capacity,
):
    pending = [
        {
            **request,
            "remaining": request["output_tokens"],
            "start": None,
        }
        for request in requests
    ]

    active = []
    completed = []
    current_step = 0

    while pending or active:
        available = [
            request
            for request in pending
            if request["arrival"] <= current_step
        ]

        while available and len(active) < capacity:
            request = available.pop(0)
            pending.remove(request)

            request["start"] = current_step
            active.append(request)

        if not active:
            current_step = min(
                request["arrival"]
                for request in pending
            )
            continue

        for request in active:
            request["remaining"] -= 1

        current_step += 1

        finished = [
            request
            for request in active
            if request["remaining"] == 0
        ]

        for request in finished:
            active.remove(request)

            completed.append(
                {
                    "request": request["request"],
                    "arrival": request["arrival"],
                    "start": request["start"],
                    "finish": current_step,
                    "slot_release": current_step,
                    "output_tokens":
                        request["output_tokens"],
                }
            )

    return pd.DataFrame(completed).sort_values(
        "request"
    ).reset_index(drop=True)
continuous_df = simulate_continuous_batching(
    requests,
    batch_capacity,
)

print("=== Continuous Batching ===")
print(continuous_df.to_string(index=False))

Continuous Batching에서는 A가 끝난 다음 Slot에 D가 들어가고, C가 끝난 다음 Slot에 E가 들어갑니다.

A 완료 → D 투입
C 완료 → E 투입

4. 대기 시간과 Batch Slot 활용률 비교하기

두 방식의 결과를 다음 지표로 비교합니다.

def summarize_schedule(
    schedule_df,
    strategy,
    capacity,
):
    useful_token_steps = int(
        schedule_df["output_tokens"].sum()
    )

    makespan = int(
        schedule_df["slot_release"].max()
    )

    available_slot_steps = (
        makespan * capacity
    )

    wasted_slot_steps = (
        available_slot_steps
        - useful_token_steps
    )

    average_wait = (
        schedule_df["start"]
        - schedule_df["arrival"]
    ).mean()

    average_latency = (
        schedule_df["finish"]
        - schedule_df["arrival"]
    ).mean()

    slot_utilization = (
        useful_token_steps
        / available_slot_steps
        * 100
    )

    token_throughput = (
        useful_token_steps
        / makespan
    )

    return {
        "Strategy": strategy,
        "Total Steps": makespan,
        "Average Wait": average_wait,
        "Average Latency": average_latency,
        "Useful Token Steps": useful_token_steps,
        "Wasted Slot Steps": wasted_slot_steps,
        "Slot Utilization (%)": slot_utilization,
        "Tokens / Step": token_throughput,
    }
summary_df = pd.DataFrame(
    [
        summarize_schedule(
            static_df,
            "Static",
            batch_capacity,
        ),
        summarize_schedule(
            continuous_df,
            "Continuous",
            batch_capacity,
        ),
    ]
)

numeric_columns = [
    "Average Wait",
    "Average Latency",
    "Slot Utilization (%)",
    "Tokens / Step",
]

summary_df[numeric_columns] = summary_df[
    numeric_columns
].round(2)

print("=== Scheduling Comparison ===")
print(summary_df.to_string(index=False))

여기서 Slot Utilization은 실제 GPU Utilization이 아닙니다.

유효한 Token을 생성한 Slot 수
÷
전체 시간 동안 사용할 수 있었던 Batch Slot 수

로 계산한 Scheduler 수준의 논리적 활용률입니다.


5. 요청별 Timeline 확인하기

요청이 기다린 구간, 실제 Decode된 구간과 Batch Slot을 해제하지 못한 구간을 그래프로 확인합니다.

def draw_schedule(
    axis,
    schedule_df,
    title,
):
    request_order = list(
        reversed(schedule_df["request"].tolist())
    )

    y_positions = {
        request: index
        for index, request
        in enumerate(request_order)
    }

    for _, row in schedule_df.iterrows():
        y = y_positions[row["request"]]

        wait_duration = (
            row["start"] - row["arrival"]
        )

        if wait_duration > 0:
            axis.barh(
                y,
                wait_duration,
                left=row["arrival"],
                color="#CBD5E1",
                edgecolor="white",
            )

        decode_duration = (
            row["finish"] - row["start"]
        )

        axis.barh(
            y,
            decode_duration,
            left=row["start"],
            color="#7C3AED",
            edgecolor="white",
        )

        blocked_duration = (
            row["slot_release"]
            - row["finish"]
        )

        if blocked_duration > 0:
            axis.barh(
                y,
                blocked_duration,
                left=row["finish"],
                color="#FCA5A5",
                edgecolor="white",
                hatch="//",
            )

        axis.text(
            row["finish"] + 0.08,
            y,
            f'{row["finish"]}',
            va="center",
            fontsize=9,
        )

    axis.set_yticks(
        range(len(request_order))
    )

    axis.set_yticklabels(request_order)
    axis.set_xlabel("Generation Step")
    axis.set_title(title)
    axis.grid(
        axis="x",
        alpha=0.25,
    )
figure, axes = plt.subplots(
    1,
    2,
    figsize=(13, 5),
    sharey=True,
)

draw_schedule(
    axes[0],
    static_df,
    "Static Batching",
)

draw_schedule(
    axes[1],
    continuous_df,
    "Continuous Batching",
)

legend = [
    Patch(
        facecolor="#CBD5E1",
        label="Queue Wait",
    ),
    Patch(
        facecolor="#7C3AED",
        label="Decode",
    ),
    Patch(
        facecolor="#FCA5A5",
        hatch="//",
        label="Finished but Slot Blocked",
    ),
]

figure.legend(
    handles=legend,
    loc="lower center",
    ncol=3,
)

figure.suptitle(
    "Variable-Length Request Scheduling"
)

figure.tight_layout(
    rect=[0, 0.1, 1, 0.95]
)

plt.show()

Static Batching 그래프에서는 A와 C가 먼저 끝났지만, Batch가 해제되는 시점까지 Slot을 반환하지 못합니다.

Continuous Batching에서는 완료된 요청이 즉시 제거되고 대기 중인 D와 E가 실행됩니다.


6. 요청별 Latency 비교하기

latency_comparison = pd.DataFrame(
    {
        "Request": static_df["request"],

        "Static Wait":
            static_df["start"]
            - static_df["arrival"],

        "Continuous Wait":
            continuous_df["start"]
            - continuous_df["arrival"],

        "Static Latency":
            static_df["finish"]
            - static_df["arrival"],

        "Continuous Latency":
            continuous_df["finish"]
            - continuous_df["arrival"],
    }
)

print("=== Per-Request Latency ===")
print(
    latency_comparison.to_string(
        index=False
    )
)

특히 Batch가 실행되는 도중 도착한 D와 E를 확인합니다.

고정 Batch에서는 앞의 Batch가 완전히 해제될 때까지 기다리지만, Continuous Batching에서는 Slot이 비는 다음 Step에 실행될 수 있습니다.


7. 실험 결과 해석하기

이번 실험에서 확인해야 할 것은 단순히 전체 실행 Step이 줄었다는 사실만이 아닙니다.

완료된 요청의 Slot이 즉시 반환되는가?

Static Batching에서는 짧은 요청이 끝나더라도 Batch 구성이 유지됩니다.

Continuous Batching에서는 완료된 요청을 제거하고 Queue의 다음 요청으로 교체합니다.

뒤늦게 도착한 요청은 언제 시작하는가?

요청 D와 E는 첫 번째 Batch가 만들어진 뒤 도착합니다.

Static Batching에서는 기존 Batch 전체가 끝날 때까지 기다리지만, Continuous Batching에서는 빈 Slot이 생기면 바로 들어갈 수 있습니다.

무엇이 개선되는가?

빈 Slot과 Padding 감소
→ Batch Slot 활용률 증가
→ Queue 대기 시간 감소
→ 전체 처리량 증가

다만 이번 결과는 Scheduler 동작을 분리해서 보기 위한 Token Step Simulation입니다. 실제 GPU 처리량과 Latency는 다음 요소에도 영향을 받습니다.

  • Prefill과 Decode의 연산량 차이
  • Prompt 길이
  • KV Cache 메모리 여유
  • 한 Step의 Token Budget
  • Attention Backend
  • GPU Kernel 실행 비용
  • Scheduler의 우선순위 정책

8. 이번 Step에서 확인한 것

  • 요청마다 Prompt와 출력 길이가 다르기 때문에 종료 시점도 서로 다릅니다.
  • 고정 Batch는 가장 긴 요청이 끝날 때까지 구성을 유지합니다.
  • 먼저 끝난 요청의 Slot에는 낭비가 발생할 수 있습니다.
  • Continuous Batching은 매 Step마다 완료 요청을 제거합니다.
  • 빈 Slot에는 Queue에서 대기 중인 요청이 들어갑니다.
  • 이를 통해 평균 대기 시간과 전체 처리 Step을 줄일 수 있습니다.
  • Paged KV Cache는 동적으로 Batch에 들어오고 나가는 요청의 Cache를 관리하는 기반이 됩니다.

Continuous Batching의 핵심은 Batch 크기를 단순히 크게 만드는 것이 아니다.
요청의 생명주기에 맞춰 Batch 구성을 매 Step 다시 결정하는 것이다.

참고:

다음 Step에서는 생성된 Token을 응답이 끝날 때까지 기다리지 않고 즉시 사용자에게 전달하는 Streaming Generation을 실습합니다.


👉 Step 08. Streaming은 응답을 얼마나 빨리 보여줄까?

일반적인 generate() 호출은 전체 응답 생성이 끝날 때까지 호출자를 기다리게 합니다.

요청
→ 전체 Token 생성
→ 전체 응답 반환

Streaming에서는 생성 작업을 실행하는 동안 출력 가능한 텍스트 조각을 즉시 전달합니다.

요청
→ 첫 번째 Text Chunk 전달
→ 다음 Text Chunk 전달
→ 반복
→ 생성 완료

이번 실습에서는 동일한 모델과 Prompt를 사용해 다음 두 방식을 비교합니다.

  • Blocking Generation
  • Streaming Generation

핵심 질문
전체 생성 시간은 비슷하더라도 사용자가 첫 번째 텍스트를 보는 시간은 얼마나 달라질까?


1. 동일한 생성 조건 준비하기

Step 04부터 사용한 inputs와 Qwen2.5-0.5B-Instruct 모델을 그대로 사용합니다.

import time
import pandas as pd
import matplotlib.pyplot as plt

from threading import Thread
from transformers import TextIteratorStreamer
stream_inputs = {
    key: value.clone()
    for key, value in inputs.items()
}

stream_prompt_length = (
    stream_inputs["input_ids"].shape[-1]
)

stream_max_new_tokens = 64

common_generation_kwargs = {
    **stream_inputs,
    "max_new_tokens": stream_max_new_tokens,
    "do_sample": False,
    "use_cache": True,
    "repetition_penalty": 1.0,
}

print("Prompt Tokens:", stream_prompt_length)
print("Max New Tokens:", stream_max_new_tokens)
print("Model:", model_id)
print("GPU:", torch.cuda.get_device_name(0))

두 방식은 같은 Prompt, Greedy Decoding, KV Cache와 최대 출력 길이를 사용합니다.


2. Blocking Generation 측정하기

먼저 일반적인 generate()를 실행합니다.

초기 실행 비용의 영향을 줄이기 위해 짧은 Warm-up을 먼저 수행합니다.

with torch.inference_mode():
    _ = model.generate(
        **stream_inputs,
        max_new_tokens=8,
        do_sample=False,
        use_cache=True,
        repetition_penalty=1.0,
    )

Blocking 방식의 전체 생성 시간을 측정합니다.

torch.cuda.synchronize()
blocking_started = time.perf_counter()

with torch.inference_mode():
    blocking_ids = model.generate(
        **common_generation_kwargs,
    )

torch.cuda.synchronize()

blocking_total_ms = (
    time.perf_counter() - blocking_started
) * 1000

blocking_new_ids = blocking_ids[
    :,
    stream_prompt_length:,
]

blocking_text = tokenizer.decode(
    blocking_new_ids[0],
    skip_special_tokens=True,
)

print("=== Blocking Generation ===")
print(f"First visible output: {blocking_total_ms:.2f} ms")
print(f"Total generation: {blocking_total_ms:.2f} ms")
print(f"Generated Tokens: {blocking_new_ids.shape[-1]}")

print("\nGenerated Text:")
print(blocking_text)

Blocking API에서는 첫 Token이 내부에서 이미 생성됐더라도 generate()가 반환되기 전에는 호출자가 결과를 받을 수 없습니다.

따라서 이번 실험에서 Blocking 방식의 첫 출력 시점은 전체 생성 시간과 같습니다.


3. TextIteratorStreamer로 Streaming 실행하기

TextIteratorStreamer는 생성된 Token을 출력 가능한 텍스트 단위로 디코딩해 Queue에 넣습니다.

생성은 별도 Thread에서 실행하고, 메인 Thread는 Streamer를 순회하며 Text Chunk를 받습니다.

streamer = TextIteratorStreamer(
    tokenizer,
    skip_prompt=True,
    skip_special_tokens=True,
    timeout=60.0,
)

stream_generation_kwargs = {
    **common_generation_kwargs,
    "streamer": streamer,
}

stream_result = {}
stream_error = []
def run_streaming_generation():
    try:
        with torch.inference_mode():
            stream_result["ids"] = model.generate(
                **stream_generation_kwargs,
            )

    except Exception as error:
        stream_error.append(error)
        streamer.end()

Streaming을 실행하면서 각 Text Chunk가 도착한 시간을 기록합니다.

torch.cuda.synchronize()
stream_started = time.perf_counter()

generation_thread = Thread(
    target=run_streaming_generation,
    daemon=True,
)

generation_thread.start()

chunk_records = []
streamed_text = ""
first_chunk_ms = None
chunk_index = 0

for text_chunk in streamer:
    if not text_chunk:
        continue

    elapsed_ms = (
        time.perf_counter() - stream_started
    ) * 1000

    if first_chunk_ms is None:
        first_chunk_ms = elapsed_ms

    streamed_text += text_chunk
    chunk_index += 1

    chunk_records.append(
        {
            "Chunk": chunk_index,
            "Elapsed ms": elapsed_ms,
            "Text": repr(text_chunk),
            "Cumulative Characters":
                len(streamed_text),
        }
    )

    print(
        f"[{elapsed_ms:8.2f} ms] "
        f"{text_chunk!r}"
    )

generation_thread.join()
torch.cuda.synchronize()

stream_total_ms = (
    time.perf_counter() - stream_started
) * 1000

if stream_error:
    raise stream_error[0]

TextIteratorStreamer가 전달하는 단위는 모델의 Token 하나와 정확히 같지 않을 수 있습니다. Tokenizer가 화면에 출력 가능한 문자열로 조합한 Text Chunk 단위입니다.


4. Text Chunk 도착 시간 확인하기

chunk_df = pd.DataFrame(chunk_records)

chunk_df["Elapsed ms"] = (
    chunk_df["Elapsed ms"].round(2)
)

print("=== Streaming Chunk Timeline ===")

print(
    chunk_df.head(15).to_string(
        index=False
    )
)

이 표에서 첫 번째 Chunk가 전체 생성이 끝나기 전에 도착했는지 확인합니다.


5. Blocking과 Streaming 결과 비교하기

Streaming에서도 실제로 생성된 Token ID를 가져와 Blocking 결과와 비교합니다.

streaming_ids = stream_result["ids"]

streaming_new_ids = streaming_ids[
    :,
    stream_prompt_length:,
]

token_ids_match = torch.equal(
    blocking_new_ids,
    streaming_new_ids,
)

text_match = (
    blocking_text.strip()
    == streamed_text.strip()
)
comparison_df = pd.DataFrame(
    [
        {
            "Mode": "Blocking",
            "First visible output ms":
                blocking_total_ms,
            "Total generation ms":
                blocking_total_ms,
            "Progressive output": False,
        },
        {
            "Mode": "Streaming",
            "First visible output ms":
                first_chunk_ms,
            "Total generation ms":
                stream_total_ms,
            "Progressive output": True,
        },
    ]
)

comparison_df[
    [
        "First visible output ms",
        "Total generation ms",
    ]
] = comparison_df[
    [
        "First visible output ms",
        "Total generation ms",
    ]
].round(2)

visibility_gain_ms = (
    blocking_total_ms - first_chunk_ms
)

visibility_gain_percent = (
    visibility_gain_ms
    / blocking_total_ms
    * 100
)

print("=== Blocking vs Streaming ===")
print(comparison_df.to_string(index=False))

print("\n=== Correctness ===")
print("Token IDs Match:", token_ids_match)
print("Decoded Text Match:", text_match)

print("\n=== Perceived Latency ===")
print(
    f"First output advanced by:"
    f" {visibility_gain_ms:.2f} ms"
)

print(
    f"Perceived wait reduction:"
    f" {visibility_gain_percent:.2f}%"
)

두 방식의 Token ID가 같다면 Streaming은 다른 답변을 생성한 것이 아닙니다. 같은 생성 결과를 완료 전에 나누어 전달한 것입니다.


6. 텍스트가 전달되는 과정을 그래프로 확인하기

Text Chunk가 도착할 때마다 사용자가 볼 수 있는 누적 글자 수를 그래프로 표현합니다.

plt.figure(figsize=(10, 5))

plt.step(
    chunk_df["Elapsed ms"],
    chunk_df["Cumulative Characters"],
    where="post",
    linewidth=2,
    color="#7C3AED",
    label="Streaming visible text",
)

plt.axvline(
    blocking_total_ms,
    color="#DC2626",
    linestyle="--",
    linewidth=2,
    label="Blocking response returned",
)

plt.scatter(
    chunk_df["Elapsed ms"],
    chunk_df["Cumulative Characters"],
    color="#7C3AED",
    s=25,
)

plt.title(
    "When Does Generated Text Become Visible?"
)

plt.xlabel("Elapsed Time (ms)")
plt.ylabel("Visible Characters")
plt.grid(alpha=0.3)
plt.legend()
plt.tight_layout()
plt.show()

Blocking에서는 붉은 점선 시점에 전체 텍스트가 한 번에 보입니다.

Streaming에서는 그보다 앞선 시점부터 누적된 Text Chunk가 단계적으로 사용자에게 전달됩니다.


7. Streaming이 줄이는 것은 무엇일까?

Streaming이 반드시 모델의 총 생성 시간을 단축하는 것은 아닙니다.

별도 Thread, Queue와 Token Decoding 비용이 추가되기 때문에 Streaming의 총 시간은 Blocking과 비슷하거나 조금 길어질 수도 있습니다.

Streaming이 직접 줄이는 것은 다음 시간입니다.

사용자 요청
→ 첫 번째 화면 출력까지 기다리는 시간

사용자 관점에서는 전체 응답이 끝날 때까지 빈 화면을 보는 대신, 모델이 생성 중인 내용을 바로 읽기 시작할 수 있습니다.

따라서 다음 지표를 구분해야 합니다.

지표의미
TTFT첫 Token 또는 첫 Chunk가 전달되기까지의 시간
ITL이후 Token 사이의 시간
Total Latency전체 생성이 완료되기까지의 시간
Throughput단위 시간 동안 처리한 전체 Token 수

이번 실습의 First visible output은 Streamer가 전달한 첫 번째 비어 있지 않은 Text Chunk 기준입니다. Serving Engine 내부에서 측정하는 첫 Token 시각과는 차이가 날 수 있습니다.


8. Blocking과 Streaming은 계산 방식이 다른가?

두 방식 모두 내부적으로 같은 Autoregressive Generation을 수행합니다.

Prefill
→ Decode Token 1
→ Decode Token 2
→ Decode Token 3
→ ...

차이는 결과 전달 방식입니다.

Blocking
모든 Token 생성 완료
→ 전체 응답 반환

Streaming
Token 생성
→ 출력 가능한 Text Chunk 전달
→ 다음 Token 생성
→ 다음 Text Chunk 전달

즉, Streaming은 모델 구조를 변경하는 최적화가 아니라 생성 결과를 소비자에게 전달하는 Serving 방식입니다.


9. 이번 Step에서 확인한 것

  • TextIteratorStreamer를 사용해 생성 결과를 Iterator로 전달했습니다.
  • 생성 작업은 별도 Thread에서 실행했습니다.
  • 각 Text Chunk의 도착 시간을 기록했습니다.
  • Streaming과 Blocking이 동일한 Token Sequence를 생성하는지 검증했습니다.
  • Streaming은 전체 응답 완료 전부터 텍스트를 사용자에게 보여줍니다.
  • 총 생성 시간은 반드시 줄어들지 않습니다.
  • Streaming의 핵심 효과는 사용자가 느끼는 첫 출력 대기 시간을 줄이는 것입니다.

Streaming은 모델을 더 빠르게 계산하게 만드는 기술이 아니다.
이미 생성된 Token을 기다리지 않고 즉시 전달해 체감 Latency를 줄이는 Serving 전략이다.

참고:

실험 결과 Blocking 방식은 전체 생성이 끝난 4,385.90ms 후에 결과를 반환했습니다. 반면 Streaming에서는 요청 후 402.77ms에 첫 번째 Text Chunk가 전달됐습니다.

Blocking First Visible   4,385.90ms
Streaming First Visible    402.77ms
차이                     3,983.13ms
체감 대기 감소               90.82%

Streaming의 전체 생성 시간은 5,993.22ms로 Blocking보다 길었습니다. 별도 Thread, Queue 처리와 Text Decoding 같은 부가 비용이 포함됐기 때문입니다.

그러나 두 방식의 Token IDs MatchDecoded Text Match는 모두 True였습니다. 즉, Streaming은 다른 결과를 생성하거나 모델 계산을 생략한 것이 아니라, 동일한 결과를 완성 전에 나누어 전달했습니다.


📌 Conclusion: 하나의 Token에서 LLM Serving까지

이번 글은 “LLM은 어떻게 다음 Token 하나를 만드는가?”라는 질문에서 시작했습니다.

Decoder-only LLM의 생성 과정을 다시 연결하면 다음과 같습니다.

사용자 입력
→ Tokenizer
→ Token IDs
→ Embedding
→ Decoder Block × N
→ Self-Attention + MLP
→ Final Normalization
→ LM Head
→ Vocabulary Logits
→ 다음 Token 선택
→ 기존 입력 뒤에 추가
→ 반복

모델의 관점에서는 다음 Token을 예측하는 연산의 반복이지만, Serving 시스템의 관점에서는 여기에 상태와 스케줄링, 메모리와 응답 전달 문제가 추가됩니다.

모델 계산
├─ Self-Attention
├─ Prefill
└─ Decode

상태 관리
└─ KV Cache

요청 스케줄링
└─ Continuous Batching

응답 전달
└─ Streaming

직접 확인한 결과

실습확인한 결과
Qwen 구조24 Decoder Layers, 14 Query Heads, 2 KV Heads
KV Cache ShapeLayer마다 (1, 2, 42, 64)
KV Cache 증가Prompt 처리 후 Sequence가 42 → 43으로 증가
Cache 정확성전체 재계산과 Cache 사용 결과의 Token ID 일치
KV Cache 메모리43 Tokens에서 실제값과 이론값 모두 528,384 Bytes
Decode 성능1,024 Prompt Tokens에서 199.26 → 71.05ms/token
Decode Speedup1,024 Tokens 기준 2.80x
Batching Simulation전체 처리 12 → 8 Steps
Slot 활용률63.89% → 95.83%
평균 Queue 대기2.0 → 0.4 Steps
Streaming 첫 출력4,385.90 → 402.77ms
출력 정확성Blocking과 Streaming의 Token ID 및 Text 일치

Step 07의 Batching 결과는 실제 GPU Utilization 측정이 아니라, Token Step × Batch Slot을 기준으로 계산한 Scheduler Simulation입니다. Step 08의 첫 출력 시간도 Serving Engine 내부의 첫 Token이 아니라, TextIteratorStreamer가 전달한 첫 번째 Text Chunk 기준입니다.

KV Cache가 해결한 것과 남긴 것

KV Cache를 사용하지 않으면 생성 단계마다 이전 Token 전체의 K/V가 다시 계산됩니다.

Cache 없음
Prompt + 지금까지 생성된 Token 전체 재계산

KV Cache를 사용하면 과거 K/V를 저장하고 새 Token의 K/V만 추가합니다.

Cache 사용
새 Token 1개 계산
+
과거 K/V 재사용

실험에서도 Prompt 길이가 길어질수록 Cache의 효과가 커졌습니다.

64 Tokens      1.04x
256 Tokens     1.43x
512 Tokens     2.44x
1,024 Tokens   2.80x

하지만 KV Cache는 무료가 아닙니다. Sequence와 동시 요청 수가 증가할수록 GPU 메모리를 더 많이 사용하고, 요청마다 길이가 달라 메모리 할당과 회수가 복잡해집니다.

이 문제는 이후 PagedAttention과 vLLM 같은 Serving Engine의 설계로 연결됩니다.

Continuous Batching이 필요한 이유

길이가 다른 요청을 하나의 고정 Batch로 처리하면 짧은 요청이 끝나도 가장 긴 요청이 끝날 때까지 Slot이 막힐 수 있습니다.

Continuous Batching은 매 Decode Step마다 완료된 요청을 제거하고, 빈 Slot에 대기 중인 요청을 투입합니다.

이번 Simulation에서는 다음 변화를 확인했습니다.

전체 처리 Step     12 → 8
평균 대기          2.0 → 0.4
Slot 활용률       63.89% → 95.83%
처리량             1.92 → 2.88 Tokens/Step

중요한 점은 Batch 크기를 단순히 키우는 것이 아닙니다. 각 요청의 생명주기에 맞춰 Batch 구성을 계속 바꾸는 것이 핵심입니다.

Streaming이 줄이는 시간

Streaming은 모델을 더 빠르게 계산하는 기술이 아닙니다.

Blocking
전체 생성 완료 → 응답 반환

Streaming
Text Chunk 생성 → 즉시 전달 → 다음 Chunk 생성

이번 실험에서는 Streaming의 전체 생성 시간이 더 길었지만, 첫 화면 출력은 약 3,983ms 앞당겨졌습니다.

따라서 Serving 성능을 볼 때는 하나의 Latency만 사용하면 안 됩니다.

TTFT          첫 출력까지의 시간
ITL           이후 Token 사이의 시간
Total Latency 전체 생성 완료 시간
Throughput    단위 시간당 처리 Token 수

이번 챕터에서 얻은 결론

LLM Serving 최적화는 단순히 모델 Forward를 빠르게 만드는 문제가 아니다.

좋은 Serving 시스템은 다음 문제를 함께 해결해야 합니다.

  • 반복 계산을 KV Cache로 줄입니다.
  • 가변 길이 요청을 Continuous Batching으로 스케줄링합니다.
  • KV Cache를 제한된 GPU 메모리에 효율적으로 배치합니다.
  • 첫 결과를 Streaming으로 빠르게 전달합니다.
  • TTFT, ITL, Throughput과 Memory를 함께 관찰합니다.

처음에는 LLM이 완성된 문장을 한 번에 반환한다고 생각했습니다.

내부 구조를 따라가 보니 실제로는 다음 Token 하나를 계산하는 작은 반복이 수많은 요청과 GPU 메모리 위에서 스케줄링되는 시스템이었습니다.

하나의 Token이 만들어지는 과정을 이해하는 것에서 시작했지만, 결국 LLM Serving은 모델의 연산만으로 설명할 수 없다는 것을 알게 되었습니다. 반복 계산을 줄이는 KV Cache, 서로 다른 요청을 다루는 Continuous Batching, 생성 결과를 빠르게 전달하는 Streaming까지 함께 동작해야 비로소 하나의 응답이 사용자에게 도착합니다.

이번 학습을 통해 LLM Serving을 단순히 모델을 GPU에 올려 API로 제공하는 일이 아니라, 제한된 자원 안에서 계산과 메모리, 요청과 응답의 흐름을 조율하는 시스템으로 바라보게 되었습니다.

profile
안녕하세요! DevOps 엔지니어 이재찬입니다. 블로그에 대한 피드백은 언제나 환영합니다! 기술, 개발, 운영에 관한 다양한 주제로 함께 나누며, 더 나은 협업과 효율적인 개발 환경을 만드는 과정에 대해 인사이트를 나누고 싶습니다. 함께 여행하는 기분으로, 즐겁게 읽어주시면 감사하겠습니다! 🚀

0개의 댓글