📌 Notice
LLM Serving and Optimization Study (=LLMSO)
직접 실습을 통해 LLM 서비스 제공 및 최적화에 대한 지식에 대한 학습 및 실습 내용을 정리한 블로그입니다.
CloudNet@에서 스터디를 진행하고 있습니다.
EKS 관련 이전 스터디 내용은 아래 링크를 통해 확인할 수 있습니다.
CH05에서는 LLM Serving이 느려지는 이유를 Compute, Memory와 Data Movement로 나누어 봤습니다.
CH06에서는 그 진단을 실제 최적화 수단과 연결합니다.
처음 읽는 동안에는 하나의 Serving 상황을 계속 떠올리면 좋습니다.
긴 공통 문서를 읽고 답해야 하는 요청 A와 짧은 채팅 요청 B·C가 같은 Model Replica로 들어오고,
각 요청은 답변을 Streaming으로 돌려받는 상황입니다.
Model이 Prompt를 읽으며 모든 입력 위치의 상태를 만드는 구간을 Prefill이라고 합니다.
그 상태를 이용해 다음 Token을 하나씩 반복 생성하는 구간은 Decode입니다.
Prefill은 입력 Token을 병렬로 처리해 계산 자원을 많이 사용하고,
Decode는 Token을 하나 만들 때마다 Model Weight와 이전 Token의 KV Cache를 반복해서 읽습니다.
같은 GPU를 쓰더라도 두 단계의 병목과 좋은 Scheduling 정책은 같지 않습니다.
사용자가 요청을 보낸 뒤 첫 Token을 받을 때까지의 시간이 TTFT(Time to First Token)이고,
첫 Token 이후 Token 사이의 간격이 ITL(Inter-Token Latency)입니다.
한 요청의 체감 속도만 볼 때는 이 두 값이 중요하고,
서버 전체가 일정 시간에 처리한 요청이나 Token의 양을 볼 때는 Throughput이 중요합니다.
최적화는 이 셋을 동시에 좋아지게 할 수도 있지만 한쪽을 위해 다른 쪽을 양보하게 만들기도 합니다.
이번에 살펴볼 방법은 여섯 묶음입니다.
이 순서는 기법을 임의로 나열한 것이 아닙니다.
먼저 A·B·C를 함께 실행해 GPU 사용률을 높이고,
긴 A의 Prefill이 B·C의 Decode를 막으면 실행 단위를 더 작게 나눕니다.
동시 요청이 늘어 KV Cache가 부족해지면 Attention 구조와 Memory Manager를 구분해 보고,
Weight 자체가 크면 Quantization 같은 Compression을 검토합니다.
마지막으로 A와 같은 공통 문서가 반복된다면 이미 계산한 Prefix를 재사용하고 따뜻한 Replica로 요청을 보냅니다.
어떤 기법도 무료로 성능을 주지는 않습니다.
Throughput을 높이면 Queue와 Tail Latency가 달라질 수 있고,
Memory를 줄이면 품질이나 Kernel 호환성을 다시 검증해야 합니다.
Cache Hit를 높이려다 부하 균형이나 Tenant Isolation을 놓칠 수도 있습니다.
따라서 이 글의 중심 질문은 “무엇을 켤까?”가 아닙니다.
지금 관찰한 병목을 어느 계층에서 줄이고, 그 대가를 어떤 지표로 확인할까?에 가깝습니다.
각 블록에서는 먼저 문제가 요청 흐름의 어디에서 생기는지 확인하고,
작은 숫자나 요청 예제로 기법이 바꾸는 대상을 계산합니다.
CUDA GPU가 있어야 확인할 수 있는 Kernel·Serving 성능과 M1 Air에서도 가능한 개념·산술 검증은 구분해서 적습니다.
Offline Inference는 처리할 입력을 미리 알고 있으므로 비슷한 길이의 요청을 큰 Batch로 만들기 쉽습니다.
Online Serving은 요청이 언제 도착할지 모르고, 사용자마다 Prompt와 출력 길이도 다릅니다.
요청을 오래 모으면 GPU는 효율적으로 일할 수 있지만 먼저 온 사용자는 그만큼 기다립니다.
여기서 Batch는 Model Forward Pass 한 번에 함께 계산하는 입력 묶음입니다.
이미 모인 데이터 전체를 처리하는 Offline 작업은 길이가 비슷한 입력끼리 정렬할 수 있지만,
사용자 요청을 즉시 받는 Online Serving은 미래에 올 요청과 생성 길이를 미리 알 수 없습니다.
따라서 Batch 크기는 단순한 숫자가 아니라 GPU 효율과 대기 시간 사이의 정책이 됩니다.

Dynamic Batching은 일정 개수의 요청이 모이거나 짧은 대기 시간이 끝나면 Batch를 실행합니다.
일반적인 ML Inference에서는 효과적이지만, 생성 길이가 제각각인 LLM에서는 먼저 끝난 요청의 자리가 Batch 종료까지 비어 있을 수 있습니다.
예를 들어 A·B·C가 동시에 시작하고 각각 12개, 5개, 8개의 Token을 만든다고 가정해 봅니다.
세 요청을 요청 단위의 고정 Batch로 묶으면 B는 5번째 Token 뒤에 끝나지만,
A가 12번째 Token을 만들 때까지 B가 차지했던 자리를 새 요청이 사용하지 못할 수 있습니다.
Request A: ████████████
Request B: █████
Request C: ████████
Padding으로 모양을 맞출 수는 있어도 이미 끝난 요청이 더 이상 유용한 Token을 만들지는 않습니다.
Batch 안에서 가장 긴 요청이 끝날 때까지 자원을 묶어 두는 구조가 됩니다.
Padding은 길이가 다른 입력의 Tensor 모양을 맞추려고 의미 없는 자리를 채우는 방식입니다.
입력 단계의 Padding을 줄이는 문제와 생성 도중 먼저 끝난 Sequence의 빈 Slot을 다시 쓰는 문제는 서로 다릅니다.
Autoregressive Decode는 한 번의 Forward Pass로 요청별 다음 Token을 하나씩 만듭니다.
Continuous Batching은 이 반복 경계에서 완료된 요청을 빼고 대기 중인 요청을 넣습니다.
Autoregressive라는 말은 방금 생성한 Token을 다음 Token을 위한 입력으로 다시 사용한다는 뜻입니다.
그래서 전체 문장을 한 번에 계산할 수 없고,
현재 실행 중인 요청 집합을 Token 생성 반복마다 다시 구성할 수 있는 경계가 생깁니다.
Iteration 1 [A] [B] [C]
Iteration 2 [A] [B] [C]
Iteration 3 [A] [D] [C] ← B 완료, D 투입
Iteration 4 [E] [D] [C] ← A 완료, E 투입
새 요청을 GPU Kernel 실행 도중 아무 때나 끼워 넣는다는 뜻은 아닙니다.
Scheduler가 Iteration마다 실행 가능한 Sequence와 Token Budget을 다시 구성한다는 뜻입니다.
여기서 Sequence는 Prompt와 지금까지 생성한 Token을 포함하는 요청별 Token 흐름이고,
Scheduler는 다음 Iteration에 어느 Sequence의 어느 Token을 실행할지 고르는 구성 요소입니다.
Request-level Batching이 요청 묶음을 비교적 오래 유지한다면,
Iteration-level Scheduling은 더 짧은 생성 반복마다 그 묶음을 갱신합니다.
Decode가 Memory Bandwidth에 묶인 상황에서는 요청을 함께 처리할수록,
한 번 읽은 Weight로 여러 Sequence의 다음 Token을 계산할 기회가 생깁니다.
하지만 동시성을 계속 높이면 KV Cache가 커지고 Queue와 Tail Latency가 악화될 수 있습니다.
동시에 4개 요청을 처리하던 Engine을 32개 요청까지 허용하면 GPU가 더 잘 차는 구간이 있을 수 있습니다.
그러나 각 Sequence는 지금까지의 KV Cache를 보유해야 하므로 Memory 사용량도 함께 늘어납니다.
공간이 부족하면 요청이 대기하거나 일부 상태를 다시 계산하는 Preemption이 생기고,
할당 자체가 실패하면 OOM으로 이어질 수 있습니다.
따라서 max_num_seqs는 클수록 좋은 처리량 스위치가 아니라 Memory와 Latency를 함께 제한하는 안전장치입니다.
vLLM 같은 Engine을 튜닝할 때 자주 만나는 값은 서로 다른 경계를 가집니다.
| 설정 | 제한하는 범위 | 잘못 읽기 쉬운 부분 |
|---|---|---|
max_num_seqs | 동시에 Scheduling할 Sequence 수 | 전체 요청 처리량이나 고정 Batch Size와 같지 않음 |
max_model_len | 단일 Sequence가 사용할 수 있는 최대 Context 길이 | Batch 전체 Token Budget이 아님 |
max_num_batched_tokens | 한 Iteration에서 Scheduling할 전체 Token Budget | 고정된 Prefill Chunk 크기가 아님 |
구체적인 기본값과 상호 제약은 Engine Version과 Model Backend에 따라 달라질 수 있습니다.
예제 값을 외우기보다 Startup Log와 실제 적용된 Engine Config를 남겨야 합니다.
세 한도를 요청 A에 대입하면 차이가 선명해집니다.
A의 Prompt가 6,000 Token이고 최대 출력이 1,000 Token이라면 단일 Sequence에는 최대 7,000 Token의 Context 공간이 필요합니다.
그러나 한 Iteration에서 7,000 Token을 모두 Scheduling한다는 뜻은 아닙니다.
Engine은 전체 동시 Sequence 수와 Iteration Token Budget 안에서 A의 Prefill 일부와 B·C의 Decode Token을 나눠 담을 수 있습니다.
Batching 비교에서는 Throughput 하나만 기록하지 않습니다.
같은 Model과 입력·출력 길이 분포에서 다음 값을 함께 봅니다.
Batching은 GPU를 더 바쁘게 만드는 수단입니다.
사용자가 더 빨리 응답을 받았다는 결론은 Latency 분포와 Error까지 함께 좋아졌을 때만 내릴 수 있습니다.
이제 빈 Slot을 빨리 채우는 방법은 알았습니다.
다음 문제는 새로 들어온 A의 긴 Prefill이 이미 답변을 생성 중인 B·C를 한동안 멈추게 할 수 있다는 점입니다.
긴 Prefill이 Streaming 중인 Decode를 방해하면 두 작업에 Token Budget을 어떻게 나눌까?
Continuous Batching으로 빈 Slot을 채워도 긴 Prompt가 들어오면 다른 문제가 생깁니다.
Prefill은 입력 전체의 KV Cache를 만드는 큰 작업이고,
Decode는 사용자가 Streaming으로 읽는 Token을 짧은 간격으로 계속 만들어야 합니다.
긴 Prefill을 한 번에 실행하면 진행 중인 Decode가 그 작업을 기다릴 수 있습니다.
새 요청의 TTFT를 줄이려다 기존 요청의 ITL이 흔들리는 간섭입니다.
예를 들어 요청 A가 6,000 Token짜리 문서를 처음 읽는 동안,
B와 C는 이미 짧은 답변을 Streaming하고 있다고 가정해 봅니다.
A의 Prefill을 하나의 큰 실행으로 우선하면 A는 입력 처리를 빨리 끝낼 수 있지만,
B와 C의 다음 Token은 그 실행이 끝날 때까지 늦어질 수 있습니다.
사용자 화면에서는 평균 ITL보다 갑작스러운 멈춤인 Streaming Stall로 더 쉽게 드러납니다.
Chunked Prefill은 긴 입력을 여러 Token Chunk로 나눕니다.
Scheduler는 한 Iteration의 Token Budget 안에서 Decode와 Prefill 일부를 함께 배치할 수 있습니다.
Token Budget이 512이고 B·C의 Decode에 각각 1 Token이 필요하다고 단순화하면,
남은 510 Token을 A의 Prefill Chunk에 사용할 수 있습니다.
다음 Iteration에서도 B·C가 먼저 1 Token씩 진행하고,
남은 Budget으로 A의 다음 입력 구간을 처리하는 식입니다.
실제 Engine에는 여러 Sequence와 정렬 조건이 더 있지만 이 예제는 Budget의 역할을 보여 줍니다.
Without chunking
[──────────── long prefill ────────────] [decode] [decode]
With chunking
[prefill 1] [decode] [prefill 2] [decode] [prefill 3]
Chunk를 작게 만들면 Decode가 기다리는 시간을 줄일 기회가 생깁니다.
반대로 Prefill을 여러 번 나누는 Scheduling Overhead가 늘고,
새 요청의 전체 Prefill 완료가 늦어져 TTFT가 나빠질 수 있습니다.
Chunk 크기가 너무 크면 B·C가 기다리는 긴 구간이 다시 생깁니다.
너무 작으면 A를 여러 번 이어 처리하는 Scheduler·Kernel 실행 비용이 늘고 GPU를 충분히 채우지 못할 수 있습니다.
따라서 Chunked Prefill의 질문은 켤지 말지가 아니라 현재 Traffic에서 한 Iteration에 얼마만큼의 Prefill을 허용할지입니다.
max_num_batched_tokens를 키우면 한 Iteration에서 더 많은 Prefill Token을 처리할 수 있습니다.
이것이 항상 좋은 TTFT나 Throughput으로 이어지지는 않습니다.
동시에 실행하는 Decode 수, Prompt 길이 분포, Model 크기와 GPU에 따라 간섭 지점이 달라집니다.
vLLM v0.28.0의 공식 Optimization 문서는 V1에서 가능한 경우 Chunked Prefill을 활성화하고,
Decode 요청을 먼저 배치한 뒤 남은 Token Budget으로 Prefill을 Scheduling한다고 설명합니다.
이 동작과 기본값은 Version-sensitive하므로 설치된 Version의 문서와 기동 로그를 다시 확인해야 합니다.
또한 max_num_batched_tokens만 단독으로 바꿔서는 원인을 분리하기 어렵습니다.
동시에 실행할 Sequence 수와 KV Cache 여유가 달라지면 같은 Budget에서도 Preemption과 Queue가 달라질 수 있습니다.
실험 기록에는 최소한 Model Revision, Engine Version, max_num_seqs, max_model_len, Token Budget과 KV Cache 설정을 함께 남깁니다.
짧은 Prompt만 순서대로 보내면 Chunked Prefill이 해결하려는 간섭을 관찰하기 어렵습니다.
다음 두 Traffic을 같은 Engine에 섞는 편이 낫습니다.
그 상태에서 Chunking 전후의 다음 값을 비교합니다.
| 관점 | 확인할 값 |
|---|---|
| 새 요청 | Prefill Time, TTFT |
| 진행 중인 요청 | ITL/TPOT와 Streaming Stall |
| 전체 시스템 | Output TPS, GPU Utilization |
| Scheduler | Scheduled Token, Queue, Preemption |
| 안정성 | OOM, Timeout, Error Rate |
평균 ITL만 보면 특정 요청이 길게 멈춘 순간을 숨길 수 있습니다.
p95·p99와 가장 긴 Stall을 함께 남기고,
변경 전후에 동일한 Arrival Pattern과 Prompt 길이 분포를 사용해야 합니다.
Chunked Prefill은 Prefill을 공짜로 만드는 기법이 아닙니다.
Prefill과 Decode가 서로를 방해하는 방식을 더 작은 Scheduling 단위로 조절하는 기법입니다.
동시 요청을 더 잘 섞으면 다음 한계는 요청마다 쌓이는 KV Cache입니다.
다음 블록에서는 Attention Head가 무엇을 저장하며,
MHA·GQA·MQA가 그 저장량을 왜 다르게 만드는지 계산합니다.
Decode는 이전 Token의 Key와 Value를 KV Cache에서 반복해서 읽습니다.
Query Head 수와 같은 수의 KV Head를 저장하는 MHA는 표현력이 크지만,
Sequence와 동시 요청이 늘어날수록 KV Cache가 빠르게 커집니다.
Self-Attention은 각 Token 표현에서 Query, Key와 Value를 만듭니다.
Query는 현재 위치가 무엇을 찾을지 표현하고,
Key는 이전 위치가 어떤 정보를 가졌는지 비교할 기준이며,
Value는 그 비교 결과에 따라 가져올 실제 정보입니다.
여러 관점을 병렬로 학습하도록 이 계산을 나눈 단위가 Head입니다.
Prefill에서는 입력 전체의 Key와 Value를 만들고,
Decode에서는 새 Query가 이전 모든 위치의 Key와 Value를 다시 참조합니다.
이전 Key와 Value를 매번 재계산하지 않고 보관한 것이 KV Cache입니다.
KV Cache는 Decode 계산을 줄이는 대신 Sequence 길이와 동시 요청 수에 비례하는 Memory를 사용합니다.

32개의 Query Head가 있는 Model을 예로 들면 MHA는 KV Head도 32개입니다.
KV Head가 8개인 GQA에서는 Query Head 네 개가 KV Head 하나를 공유하고,
MQA에서는 32개 Query Head 전체가 단 하나의 KV Head를 공유합니다.
Query Head 수를 그대로 유지하면서 저장해야 할 K·V 묶음 수를 줄이는 것이 핵심입니다.
GQA는 MHA와 MQA 사이의 고정된 한 지점이 아닙니다.
Model Config의 Query Head와 KV Head 수에 따라 공유 비율이 달라집니다.
KV Head를 줄이면 Cache와 Decode Memory Traffic을 줄일 수 있지만,
품질과 실제 속도는 Model Architecture와 학습 방식까지 포함해 평가해야 합니다.
Layer별 KV Cache의 이상적인 Bytes per Token은 다음처럼 볼 수 있습니다.
KV bytes/token
= 2 × num_layers × num_kv_heads × head_dim × bytes_per_element
└ K와 V
32 Layers, Head Dimension 128, BF16 2 bytes를 가정하면 다음과 같습니다.
MHA의 한 Token을 직접 계산하면 다음과 같습니다.
2 × 32 layers × 32 KV heads × 128 values × 2 bytes
= 524,288 bytes/token
= 512 KiB/token
GQA에서 KV Head만 8개로 줄이면 128 KiB/token이고,
MQA에서 하나로 줄이면 16 KiB/token입니다.
한 요청이 1,000 Token을 보유한다면 이상적인 값은 각각 약 500 MiB, 125 MiB, 15.6 MiB가 됩니다.
동시 요청이 10개라면 이 부분만 다시 열 배가 되므로 Head 공유가 Serving 동시성에 직접 영향을 줍니다.
| 구조 | Query Heads | KV Heads | KV Head 하나를 공유하는 Query Heads | KV bytes/token |
|---|---|---|---|---|
| MHA | 32 | 32 | 1 | 524,288 |
| GQA | 32 | 8 | 4 | 131,072 |
| MQA | 32 | 1 | 32 | 16,384 |
이 계산은 Metadata, Alignment, Allocator Padding과 Runtime Buffer를 제외한 구조 비교입니다.
모든 GQA가 MHA의 1/4이고 모든 MQA가 1/32이라는 뜻이 아닙니다.
실제 Model Config의 Layer, KV Head, Head Dimension과 KV dtype을 넣어 다시 계산해야 합니다.
bytes_per_element도 고정값이 아닙니다.
위 표는 BF16을 가정했기 때문에 2 bytes를 사용했지만,
KV Cache를 FP8 계열로 저장하는 Engine 경로라면 이상적인 항목 크기는 더 줄어들 수 있습니다.
다만 dtype만 바꿨다고 실제 Memory와 품질이 같은 비율로 변하는 것은 아니며 Metadata와 Kernel 지원을 별도로 확인해야 합니다.
MLA는 Key와 Value 표현을 더 작은 Latent로 압축해 저장하고,
Attention 계산 시 필요한 표현을 복원하거나 분해하는 Model Architecture입니다.
따라서 MHA·GQA·MQA의 Head 공유 표에 같은 비율로 넣기 어렵습니다.
Model이 MLA로 학습됐다는 사실과 Serving Engine이 해당 Kernel을 효율적으로 실행한다는 사실도 분리해야 합니다.
Architecture 이름만으로 KV Memory나 Throughput을 단정하지 않고 Model Config와 Backend 지원을 확인합니다.
harness/calculation-checks/ch06.py는 위 가정의 MHA·GQA·MQA Bytes per Token과 비율을 계산합니다.
이번 작성 과정에서 실행해 9개 계산 검사를 모두 통과했습니다.
이는 GPU Memory 실측이나 품질·처리량 Benchmark가 아니라 고정된 가정의 산술 검증입니다.
이 계산은 M1 Air 16GB에서도 외부 Model 없이 실행할 수 있습니다.
python3 harness/calculation-checks/ch06.py를 실행하면 공식 Benchmark가 아니라 식과 단위 변환을 잘 옮겼는지만 확인합니다.
KV를 덜 저장하도록 Model Architecture를 바꾸는 방법을 이해했다면,
이제 같은 Attention 계산과 KV 할당을 Runtime이 더 효율적으로 수행하는 방법을 분리해서 볼 차례입니다.
Model Architecture·Attention Kernel·Runtime Memory Manager는 각각 어떤 비용을 줄일까?
MHA를 GQA로 바꾸는 일과 FlashAttention을 적용하는 일,
PagedAttention으로 KV Cache를 관리하는 일은 같은 변경이 아닙니다.
각각 Model Architecture, Attention Kernel과 Runtime Memory Manager라는 다른 계층을 바꿉니다.
Model Architecture는 Checkpoint가 학습된 계산 구조를 정합니다.
Kernel은 그 계산 일부를 GPU에서 실제로 실행하는 함수이고,
Runtime Memory Manager는 여러 요청의 KV Cache를 언제 어디에 배치하고 회수할지 관리합니다.
Architecture는 저장해야 할 데이터의 모양을 바꾸고,
Kernel은 같은 수학을 실행하는 데이터 이동 방식을 바꾸며,
Memory Manager는 요청 수명에 따라 공간을 배분하는 방식을 바꿉니다.
GPU 연산을 여러 Kernel로 잘게 실행하면 각 단계의 중간 결과를 HBM에 쓰고 다시 읽는 일이 늘어날 수 있습니다.
호환되는 연산을 하나의 Kernel로 합치면 Launch Overhead와 Memory Traffic을 줄일 기회가 생깁니다.
HBM(High Bandwidth Memory)은 GPU가 큰 Tensor를 보관하는 주 Memory이고,
SRAM은 GPU Chip 안에서 훨씬 작지만 빠르게 접근하는 Memory입니다.
CPU가 GPU Kernel을 시작시키고 실행 경계를 관리하는 데에도 비용이 드는데 이를 Launch Overhead라고 부릅니다.
작은 연산을 따로 실행해 HBM에 중간 결과를 계속 왕복시키는 대신,
호환되는 연산을 묶어 가까운 Memory에서 이어 계산하려는 것이 Fusion의 직관입니다.
Fusion 가능한 범위는 연산 모양, dtype, Hardware와 Compiler에 따라 달라집니다.
Kernel 수가 줄었다는 사실만으로 E2E Latency가 좋아졌다고 결론 내리지 않고,
Profiler에서 Kernel Time과 Memory Transfer가 실제로 줄었는지 확인해야 합니다.
FlashAttention은 Approximation으로 Attention을 생략하는 방식이 아닙니다.
Exact Attention 계산을 Tile 단위로 재배열해 HBM과 On-chip SRAM 사이의 읽기·쓰기를 줄이는 IO-aware Algorithm입니다.
모든 Tensor가 항상 SRAM에 머물고 마지막 결과만 HBM에 기록된다고 이해하면 지나친 단순화입니다.
핵심은 SRAM 용량에 맞는 Tile로 계산 순서를 구성하고,
큰 Attention Matrix를 HBM에 그대로 Materialize하는 비용을 피하는 데 있습니다.
Tile은 전체 Matrix를 빠른 On-chip Memory에 들어갈 작은 조각으로 나눈 단위입니다.
Materialize는 중간 값을 논리적으로만 계산하는 데 그치지 않고 실제 Memory에 완전한 Tensor로 만들어 쓰는 것을 뜻합니다.
FlashAttention은 Tile별로 필요한 통계와 결과를 갱신해,
전체 Attention Score Matrix를 HBM에 만들었다가 다시 읽는 경로를 피합니다.
FlashInfer는 LLM Serving에 필요한 Attention Kernel과 Runtime Primitive를 제공하는 Library입니다.
FlashAttention Version이나 FlashInfer 같은 Backend 이름은 곧바로 성능 등급을 뜻하지 않습니다.
GPU Architecture, dtype, Head Dimension, Attention Type과 Engine Version에 따라 지원되는 Kernel이 다릅니다.
실제 선택 Backend는 Startup Log와 Profiler로 확인합니다.
Backend는 Model이 요청한 Attention 연산을 특정 Hardware와 Kernel 구현에 연결하는 실행 경로입니다.
Engine 설정에 FLASH_ATTN 같은 이름을 썼더라도 입력 Shape나 dtype 때문에 다른 경로가 선택될 수 있습니다.
따라서 설정값은 선언된 의도이고 Startup Log와 Profile은 실제 실행 경로를 확인하는 Evidence입니다.
KV Cache는 요청마다 길이가 계속 늘고 완료 시점도 다릅니다.
Sequence별 최대 길이를 미리 연속 공간으로 예약하면 사용하지 않는 공간과 Fragmentation이 커질 수 있습니다.
Fragmentation은 전체 빈 Memory가 남아 있어도 필요한 크기의 연속 공간을 만들기 어렵거나,
미리 크게 잡은 공간의 일부를 쓰지 못해 낭비되는 상태입니다.
LLM 요청은 실제 출력 길이를 미리 알기 어렵고 종료 시점도 다르므로 연속 예약 방식에서 이 문제가 커집니다.
PagedAttention은 KV Cache를 Block 단위로 관리하고,
Block Table로 Logical KV Block을 Physical GPU-memory Block에 매핑합니다.
한 Physical Block에는 여러 Token의 KV 상태가 들어갈 수 있으며 Token은 Block 내부 Offset을 가집니다.
Sequence logical blocks [0] [1] [2]
│ │ │
Block table 7 3 11
│ │ │
GPU physical blocks [3] [7] [11]
이 방식의 주된 목적은 Attention FLOP 자체를 줄이는 것이 아닙니다.
동적으로 늘어나는 KV Cache를 더 유연하게 할당하고 Fragmentation과 중복 예약을 완화하는 것입니다.
운영체제의 Virtual Memory처럼 Sequence는 연속된 Logical Block을 보는 반면,
실제 GPU Memory의 Physical Block은 떨어져 있어도 됩니다.
요청 A가 끝나면 A가 쓰던 Physical Block을 요청 D에 다시 배정할 수 있고,
Prefix를 공유하는 경우에는 동일한 Physical Block을 참조하도록 관리할 수도 있습니다.
이 유연성에는 Block Table 조회와 Metadata 관리 비용이 따라옵니다.
PagedAttention 논문이 보고한 Memory Waste와 Throughput 수치는 당시 Model과 Baseline의 실험 결과입니다.
현재 vLLM이나 다른 Engine의 보편적인 배율로 사용하지 않습니다.
| 변경 | 먼저 확인할 것 | 함께 볼 대가 |
|---|---|---|
| Model Attention 구조 | KV bytes/token, 품질 | 재학습·Checkpoint 호환성 |
| Attention Kernel | Kernel Time, HBM Traffic | Hardware·dtype·Shape 지원 |
| KV Memory Manager | Fragmentation, Preemption, 동시 Sequence | Block Metadata와 Scheduling Overhead |
한 번에 여러 계층을 바꾸면 무엇이 효과를 냈는지 분리하기 어렵습니다.
가능하면 Model과 Workload를 고정하고 한 계층씩 비교합니다.
M1 Air에서는 CUDA용 FlashAttention·FlashInfer Backend의 GPU Profile을 재현할 수 없습니다.
대신 Architecture·Kernel·Memory Manager의 책임을 구분하고,
Block Mapping 예제를 손으로 추적하는 학습은 Hardware와 무관하게 할 수 있습니다.
실제 성능 주장은 NVIDIA GPU, 고정된 CUDA·Engine Version과 Profiler Evidence가 있을 때만 추가합니다.
여기까지는 같은 Model이 가진 Weight와 KV를 더 효율적으로 실행하는 방법이었습니다.
다음 블록에서는 Weight 표현이나 Model 자체를 줄여 Memory 경계를 바꾸는 Compression을 살펴봅니다.
Model을 작게 만든다는 말에는 여러 방법이 섞여 있습니다.
Quantization은 숫자를 표현하는 Bit 수를 줄이고,
Distillation은 더 작은 Student Model을 학습하며,
Pruning은 중요도가 낮은 Weight나 구조를 제거합니다.
세 방법은 적용 시점과 필요한 실행 경로가 다릅니다.
파일 크기가 줄었다는 결과만으로 같은 속도와 품질을 기대할 수 없습니다.
Precision은 숫자를 몇 Bit와 어떤 Format으로 표현하는지 뜻합니다.
Model의 학습된 Parameter가 Weight이고,
입력과 Layer 계산 도중 만들어지는 중간 Tensor가 Activation입니다.
Weight는 Model을 Load하는 동안 계속 Memory를 차지하고,
Activation은 입력 Shape와 Batch에 따라 실행 중 크기가 달라집니다.

W4A16은 Weight를 4-bit로 표현하고 Activation은 16-bit로 계산하는 구성을 뜻합니다.
W8A8은 Weight와 Activation을 모두 8-bit 계열로 처리하는 구성을 가리킵니다.
세부 Format이 INT인지 FP인지, Scale과 Group Size가 무엇인지는 Scheme에 따라 달라집니다.
낮은 Bit 정수는 원래 실수 범위를 그대로 표현할 수 없으므로 값의 범위를 작은 구간에 대응시켜야 합니다.
Scale은 양자화된 값과 원래 값 사이의 간격을 정하고,
Zero-point는 0이 어느 정수 값에 대응하는지 정합니다.
여러 Weight가 Scale을 공유하는 묶음 크기가 Group Size입니다.
Group을 작게 하면 지역별 범위에 더 잘 맞출 여지가 있지만 Scale Metadata와 처리 복잡도가 늘 수 있습니다.
낮은 Bit 수가 자동으로 같은 비율의 Latency 개선을 만들지는 않습니다.
Hardware가 해당 Format을 지원하고 Engine이 효율적인 Kernel을 선택할 수 있어야 합니다.
Dequantization과 Metadata 비용 때문에 어떤 Workload에서는 속도가 그대로이거나 느려질 수도 있습니다.
예를 들어 INT4 Weight를 Load한 뒤 연산 전에 FP16 계열로 계속 풀어야 한다면,
줄어든 Memory Traffic의 이득 일부를 Dequantization 비용이 사용합니다.
반대로 Hardware가 낮은 정밀도 Matrix 연산을 직접 지원하고 Engine Kernel도 이를 활용한다면 저장량뿐 아니라 계산 경로까지 달라질 수 있습니다.
그래서 파일 이름에 4bit가 있다는 사실과 실제 Kernel이 4-bit 경로를 사용한다는 사실을 분리해야 합니다.
7B를 정확히 70억 Parameters로 놓으면 Weight 저장량은 다음과 같습니다.
| 표현 | Bytes per Parameter | 이상적 Weight Footprint |
|---|---|---|
| FP32 | 4 | 28.0 GB |
| FP16/BF16 | 2 | 14.0 GB |
| FP8/INT8 | 1 | 7.0 GB |
| INT4 | 0.5 | 3.5 GB |
이 값은 Decimal GB를 사용한 단순 계산입니다.
실제 GPU Memory에는 Scale, Zero-point, Packing, Padding, KV Cache, Activation과 Runtime Buffer가 더해집니다.
또한 14.0 GB는 10진수 GB이고 운영체제가 표시하는 GiB와 단위가 다릅니다.
14,000,000,000 bytes는 약 13.04 GiB이지만,
16GB 통합 Memory를 가진 Laptop에 FP16 7B가 안전하게 들어간다는 뜻은 아닙니다.
운영체제, Runtime, KV Cache와 임시 Buffer가 같은 Memory를 함께 사용하기 때문입니다.
harness/calculation-checks/ch06.py로 이 산술을 다시 실행했지만,
그 결과는 특정 Quantization Format이 GPU에서 Load되거나 빠르게 동작한다는 증거가 아닙니다.
PTQ는 이미 학습된 Model을 Calibration이나 Weight 분석을 통해 낮은 정밀도로 변환합니다.
비용이 비교적 작지만 낮은 Bit로 갈수록 Outlier와 Quantization Error의 영향을 받을 수 있습니다.
Calibration은 대표 입력을 흘려 Activation 범위나 Scale을 정하는 과정이고,
Outlier는 대부분 값보다 훨씬 커서 좁은 정밀도 범위를 잡기 어렵게 만드는 값입니다.
대표성이 부족한 Calibration Dataset을 사용하면 일반 Benchmark는 괜찮아도 실제 도메인에서 품질이 더 크게 떨어질 수 있습니다.
QAT는 Training 과정에 Quantization 효과를 반영해 Model이 오차에 적응하도록 합니다.
더 많은 Data와 Training Cost가 필요하며 모든 상황에서 유일한 답은 아닙니다.
Model, Task와 목표 Bit에 맞춰 PTQ 결과가 품질 기준을 만족하는지 먼저 평가할 수 있습니다.
Distillation은 Teacher의 Logit, Label 또는 생성 결과를 이용해 Student를 학습하는 접근입니다.
Student가 작아지면 Weight와 연산량을 줄일 수 있지만,
KV Cache 절감량은 Layer 수, Hidden Dimension, Attention 구조와 KV dtype으로 별도 계산해야 합니다.
Teacher는 지식을 제공하는 큰 Model이고 Student는 그 출력을 학습하는 작은 Model입니다.
Logit은 확률로 변환하기 전 각 Token 후보의 점수입니다.
정답 Label만 따라가는 것보다 Teacher의 후보 분포를 함께 배우면 후보 사이의 상대적인 정보도 전달할 수 있습니다.
Pruning은 Weight나 Channel, Head 같은 구조를 제거해 Sparsity를 만듭니다.
Sparse 비율이 높아도 Hardware와 Kernel이 그 구조를 이용하지 못하면 E2E 속도는 기대만큼 좋아지지 않을 수 있습니다.
개별 Weight를 불규칙하게 0으로 만드는 Unstructured Pruning과,
Channel·Head처럼 묶인 단위를 제거하는 Structured Pruning은 실행 친화성이 다릅니다.
파일 안의 0이 많다는 것만으로 Dense Kernel의 연산이 자동으로 생략되지는 않습니다.
Compression 비교에는 Serving 지표뿐 아니라 품질 기준이 필요합니다.
품질 평가가 없다면 “더 빠른 Serving Variant”까지만 말할 수 있습니다.
Production에서 대체 가능한 Model을 골랐다는 결론은 내릴 수 없습니다.
vLLM v0.28.0도 Quantization Method마다 지원 Hardware가 다르다고 명시합니다.
발행이나 실험 시점에는 사용하려는 Method와 GPU 조합을 Versioned Compatibility Matrix에서 다시 확인합니다.
M1 Air 16GB에서는 MLX처럼 Apple Silicon을 지원하는 Runtime과 작은 Model로 4-bit·8-bit Variant의 Load 가능 여부와 응답 품질을 비교할 수 있습니다.
다만 그 결과는 vLLM CUDA Quantization Kernel이나 NVIDIA GPU 성능을 검증하지 않습니다.
로컬 실습에서는 동일한 Prompt·Sampling으로 출력 변화, Peak Memory와 E2E 시간을 기록하고,
NVIDIA Reference에서는 별도로 Kernel 선택, TTFT·ITL·Throughput과 GPU Memory를 측정해야 합니다.
Model과 KV가 Memory 안에 들어와도 반복되는 긴 입력을 매번 Prefill하면 같은 계산 비용을 다시 냅니다.
마지막 블록에서는 동일한 Token Prefix를 재사용하는 Cache와 여러 Replica에서 그 Cache를 찾아가는 Routing을 연결합니다.
같은 Token Prefix의 계산을 재사용하려면 Prompt·Replica Routing·Tenant 경계를 어떻게 설계할까?
일반적인 Response Cache는 같은 질문의 최종 답변을 재사용합니다.
Prefix Cache는 이미 처리한 Token Prefix의 KV Cache를 보존하고,
새 요청이 같은 Prefix를 공유할 때 그 구간의 Prefill 계산을 건너뜁니다.
Prefix Cache는 의미가 비슷한 문장을 찾는 Semantic Cache가 아닙니다.
Tokenizer 결과가 동일하고 Engine의 Prefix Match 단위를 만족하는 Token Sequence만 재사용합니다.
효과도 Prefill에 한정되며 긴 Decode 자체를 줄이지는 않습니다.
세 Cache의 반환 대상은 서로 다릅니다.
Response Cache는 요청에 대한 완성된 답을 반환하고,
Semantic Cache는 Embedding 등으로 의미가 가까운 이전 요청이나 결과를 찾으며,
Prefix Cache는 Model 내부에서 이미 계산한 KV Block을 다음 요청의 시작 상태로 사용합니다.
Prefix Cache에 Hit해도 새로운 질문과 답변 Token은 Model이 계속 계산합니다.

Multi-turn Chat은 이전 대화 전체가 다음 요청의 앞부분에 반복될 수 있습니다.
긴 문서에 여러 질문을 던지는 Workload도 Document Context를 고정하면 긴 Prefix가 반복됩니다.
요청 A가 system prompt + 6,000-token document + question 1이었다면,
다음 요청 D를 system prompt + 같은 document + question 2로 만들 수 있습니다.
두 요청의 Token Sequence가 문서 끝까지 같다면 그 구간의 Prefill KV를 재사용할 수 있습니다.
반면 같은 문서라도 앞에 현재 시각을 넣거나 JSON Key 순서가 바뀌면 불일치 지점부터 Cache Miss가 생길 수 있습니다.
[stable system prompt]
[stable document context]
[tenant-specific boundary]
[dynamic question]
RAG에서 같은 문서를 가져와도 정렬 순서나 구분 Token이 달라지면 긴 Prefix가 일찍 끊길 수 있습니다.
Cache 효율만을 위해 검색 품질을 바꾸지는 않되,
동점 문서의 정렬이나 Template처럼 불필요하게 흔들리는 입력을 안정화할 수 있는지 확인합니다.
여기서 Cache Hit는 재사용 가능한 Prefix Block을 찾은 경우이고,
Cache Miss는 해당 Block을 찾지 못해 Prefill을 다시 계산해야 하는 경우입니다.
전체 요청 수 기준 Hit Rate만 보면 10 Token Hit와 6,000 Token Hit가 같아 보이므로,
재사용된 Token 수를 전체 Prompt Token 수와 함께 보는 편이 실제 계산 절감량에 더 가깝습니다.
Prefix Cache가 별도 GPU 영역을 항상 예약하는 것은 아닙니다.
vLLM V1은 같은 KV Block Pool에서 참조되지 않는 Cache Block을 재사용하고,
새 할당이 필요하면 해당 Block을 Evict할 수 있습니다.
Eviction은 새 KV Block을 만들 공간이 필요할 때 재사용 가능성이 낮거나 참조되지 않는 Cache Block을 내보내는 일입니다.
현재 실행 중인 Sequence의 KV가 부족해 작업을 중단하고 나중에 다시 이어 가는 Preemption과는 목적이 다릅니다.
Preemption 이후 상태를 다시 계산하는 경로가 있다면 Recompute 비용도 생깁니다.
따라서 Hit Rate만 높이는 것은 충분한 목표가 아닙니다.
vLLM의 공식 APC 문서도 Prefix Caching이 Prefill을 줄일 뿐,
새 Token을 만드는 Decode 시간은 줄이지 않는다고 설명합니다.
Replica별 Prefix Cache가 Local이면 같은 Prefix 요청을 이미 따뜻한 Replica로 보내는 것이 유리할 수 있습니다.
하지만 Cache Hit만 따라가면 특정 Replica에 Queue가 몰립니다.
Cache-aware Router는 다음 두 값을 함께 봐야 합니다.
routing score = cache locality benefit - queue and load penalty
정확한 식은 구현마다 다릅니다.
중요한 것은 Cache Hit와 Load Balance가 별도의 목표이며,
Scale-out이나 Replica 교체가 Cache Warmth를 초기화할 수 있다는 점입니다.
예를 들어 Replica 1은 A의 6,000 Token Prefix를 가지고 있지만 Queue에 20개 요청이 있고,
Replica 2는 Cache가 차갑지만 즉시 실행할 수 있다고 가정해 봅니다.
Replica 1의 Prefill 절감보다 Queue 대기가 더 길다면 Cache Miss를 감수하고 Replica 2로 보내는 편이 TTFT에 유리할 수 있습니다.
Router는 Prefix가 어디에 있는지만이 아니라 각 Replica의 예상 대기와 실패 상태도 함께 알아야 합니다.
공유 Prefix Cache에서는 TTFT 차이를 통해 다른 사용자의 Prefix 존재 여부를 추측하는 Timing Side-channel을 고려해야 합니다.
Prompt 안에 예측 가능한 Tenant ID를 넣는 것만으로 격리가 보장되지는 않습니다.
vLLM v0.28.0은 요청별 cache_salt를 Prefix Cache Key에 포함할 수 있습니다.
Multi-tenant 환경에서는 추측하기 어려운 Salt를 Tenant 또는 User 경계에 맞게 사용하고,
Authentication과 Endpoint Isolation을 함께 적용해야 합니다.
Salt가 다르면 Cache를 공유하지 못하므로 Privacy와 Hit Rate 사이의 Trade-off도 측정합니다.
Salt는 Password를 대체하지 않고 요청을 인증하지도 않습니다.
Tenant A와 B가 같은 Salt를 사용하거나 공격자가 Salt를 예측할 수 있다면 Cache Key 분리 효과가 약해질 수 있습니다.
따라서 인증된 Tenant Identity에서 예측하기 어려운 Salt Scope를 결정하고,
API 인증·Endpoint 접근 제어와 별도의 방어 계층으로 운영해야 합니다.
같은 Token Prefix와 질문 변형을 사용하고 다음 조건을 고정합니다.
| 조건 | 기록할 값 |
|---|---|
| Prefix 길이 | Reused Token, TTFT |
| 질문·출력 길이 | ITL/TPOT, E2E |
| Cache Memory | Hit, Eviction, Preemption |
| Replica 수 | Cache Locality, Queue 분포 |
| Tenant 경계 | Salt Scope, Cross-tenant Hit 차단 |
답변 문장이 비슷하다는 이유로 Hit라고 판단하지 않습니다.
Engine Metric과 KV Event, Request Trace로 실제 Prefix 재사용을 확인합니다.
M1 Air에서는 작은 Model과 Apple Silicon Runtime으로 반복 Prompt의 E2E 변화를 관찰할 수 있지만,
그 Runtime이 실제 Prefix KV 재사용 Metric을 제공하지 않으면 Hit를 증명했다고 말할 수 없습니다.
CH06의 로컬 기본 실습은 Prompt를 안정화하고 Cache ON·OFF 비교 계약을 작성하는 데 두고,
vLLM APC와 Multi-replica Routing 주장은 NVIDIA 또는 별도 Serving 환경의 Trace로 검증합니다.
이제 처음의 A·B·C 요청으로 돌아가면 여섯 기법의 연결이 보입니다.
Batching은 함께 실행할 요청을 고르고,
Chunked Prefill은 A의 긴 입력과 B·C의 Decode가 번갈아 진행할 틈을 만듭니다.
Attention 구조와 PagedAttention은 늘어난 KV Cache의 크기와 배치를 다루고,
Compression은 Weight와 실행 표현을 줄이며,
Prefix Cache와 Router는 다음 A형 요청이 이미 계산한 문서 구간을 다시 찾도록 합니다.
Batching은 한 번 읽은 Weight로 더 많은 요청을 처리할 기회를 만듭니다.
대신 Queue와 KV Cache가 커질 수 있습니다.
Chunked Prefill은 긴 입력과 Decode의 간섭을 작은 Scheduling 단위로 나눕니다.
대신 TTFT, ITL과 Throughput 사이에서 Token Budget을 다시 조정해야 합니다.
MHA에서 GQA나 MQA로 이동하면 KV Cache를 줄일 수 있습니다.
하지만 이는 Model Architecture와 학습의 선택이며 Runtime Flag 하나로 바꿀 수 없습니다.
FlashAttention은 Attention IO를 줄이고,
PagedAttention은 동적으로 늘어나는 KV Cache의 Memory Allocation을 다룹니다.
Quantization은 Weight나 Activation, KV Cache의 표현을 작게 만들 수 있습니다.
실제 속도는 Hardware와 Kernel에 달려 있고 품질 평가는 별도로 필요합니다.
Distillation과 Pruning도 더 작은 Model을 만들 수 있지만 새로운 Training과 Serving Artifact를 관리해야 합니다.
Prefix Cache는 반복되는 입력의 Prefill 계산을 줄입니다.
그 대신 Cache Memory, Eviction, Replica Locality와 Multi-tenant Isolation이라는 운영 문제가 생깁니다.
그래서 실제 적용 순서는 기법 목록보다 다음에 가깝습니다.
증상과 SLO 확인
→ Queue·TTFT·ITL·Throughput·Memory로 병목 분해
→ 바꿀 계층 하나 선택
→ 동일 Workload로 전후 측정
→ 품질·비용·운영 복잡도까지 비교
→ 채택하거나 되돌림
여러 Optimization을 한꺼번에 켜면 무엇이 효과를 냈는지 알기 어렵습니다.
한 번에 하나의 가설을 검증하고,
개선된 지표와 새로 생긴 비용을 같이 기록하는 편이 다음 선택에도 도움이 됩니다.
처음 보는 증상에서 출발할 때는 다음처럼 후보를 좁힐 수 있습니다.
| 관찰한 증상 | 먼저 의심할 경계 | 첫 비교 |
|---|---|---|
| GPU가 자주 비고 Queue가 쌓임 | Batching·Scheduler | 동시 Sequence와 TTFT·Throughput |
| 긴 입력 유입 뒤 Streaming이 멈춤 | Prefill·Decode 간섭 | Chunking 전후 p99 ITL과 Stall |
| Context·동시성 증가 시 Memory 부족 | KV Cache 구조·관리 | KV bytes/token, Fragmentation, Preemption |
| Attention Kernel 시간이 큼 | IO·Backend | 실제 Backend, Kernel Time, HBM Traffic |
| Model Weight가 Memory 대부분을 차지함 | Compression | 품질을 포함한 Precision별 비교 |
| 반복 문서의 TTFT가 계속 큼 | Prefix Cache·Routing | Reused Token, Queue와 Tenant 경계 |
M1 Air 16GB에서는 KV·Weight 산술, Prompt 안정화와 작은 양자화 Model의 기능·품질 비교를 먼저 할 수 있습니다.
CUDA Kernel, vLLM Scheduler 성능, Multi-GPU와 Multi-replica Cache Routing은 같은 결과로 대신할 수 없습니다.
이 부분은 NVIDIA Reference 실습으로 남기고 Hardware·Engine·Workload가 고정된 Evidence가 생겼을 때 본문 결과에 합칩니다.
이번 챕터에서 기억할 문장은 하나입니다.
LLM Serving 최적화는 가장 빠르다고 알려진 기법을 모으는 일이 아니라,
관찰한 병목에 맞는 계층을 바꾸고 그 대가를 측정하는 일입니다.
다음 CH07에서는 이 단일 Instance의 최적화를 넘어,
여러 GPU와 Prefill·Decode 분리, Speculative Decoding과 분산 KV Cache 같은 System-level 최적화로 확장합니다.