2강은 이론 중심이었다. Continuous Batching, KV Cache, PagedAttention, RadixAttention 네 가지를 다뤘는데, 처음엔 별개의 기법 네 개인 줄 알았다. 다 듣고 나니 아니었다.
하나의 문제를 계속 파고드는 구조다. 앞 기법이 만든 새 문제를 다음 기법이 해결하는 식으로 이어진다. 그 흐름대로 정리한다.
1. 출발점: LLM은 답을 두 단계로 만든다
Prefill Decode
| 하는 일 | 입력 프롬프트를 한 번에 처리 | 토큰을 하나씩 순차 생성 |
| 병렬화 | 가능 | 불가능 (앞 토큰이 나와야 다음) |
| 병목 | 연산 (compute-bound) | 메모리 읽기 (memory-bound) |
Prefill은 주문서를 읽는 단계다. 아무리 길어도 한눈에 훑을 수 있다. 토큰이 1000개면 1000개를 동시에 GPU에 밀어넣는다. 어텐션은 행렬 곱이라 병렬화가 잘 되니 연산 유닛이 꽉 차게 돌아간다.
Decode는 요리를 한 접시씩 내는 단계다. 앞접시가 나와야 다음을 만들 수 있다(autoregressive). 여기서 문제가 생긴다.
토큰 하나를 만들자고 모델 가중치 전체를 VRAM에서 읽어와야 한다. 15억 개짜리 모델이면 3GB를 읽어서 겨우 글자 하나를 뽑는다. 계산은 순식간인데 읽어오는 데 시간을 다 쓴다. 이게 memory-bound다.
창고에서 재료를 다 꺼내 왔는데 요리 하나만 만들고 다시 집어넣는 격이다.
게이밍 GPU도 LLM 추론이 빠른 이유가 여기 있다. decode는 연산 성능보다 메모리 대역폭이 성능을 좌우하는데, RTX 4090은 대역폭이 A40보다 오히려 높다.
2. 첫 번째 해법: 배칭
어차피 재료를 꺼내온 김에 4인분을 같이 만들면 된다. 읽기 비용은 똑같은데 처리량은 몇 배가 된다. 배칭이 강력한 이유다.
문제: Static Batching
옛날 방식은 요청을 한 묶음으로 고정하고, 그 묶음이 전부 끝나야 다음을 받았다.
- 짧게 끝난 요청도 슬롯을 비우지 못함
- 가장 긴 요청이 끝날 때까지 빈 자리를 계산하며 GPU 낭비
- 대기 중인 요청은 그때까지 시작조차 못 함
LLM에서 특히 나쁜 이유는 출력이 얼마나 길지 미리 알 수 없기 때문이다. "안녕하세요" 한마디와 300줄짜리 코드가 같은 조에 묶일 수 있다.
해결: Continuous Batching
관리 단위를 요청 묶음에서 슬롯으로 바꾼다. 슬롯은 계속 살아있고 그 안의 요청만 교체된다. 하나가 끝나면 즉시 다음 요청이 그 자리에 들어간다.
매 토큰 생성 스텝마다 도는 4단계 루프가 전부다.
- 배치 상태 확인
- EOS 나온 요청 제거
- 메모리 여유가 있으면 대기열에서 새 요청 투입
- 배치 전체에 대해 딱 1토큰 forward pass
수십 ms마다 반복된다. iteration-level scheduling이라 부르고, Orca 논문(OSDI 2022)에서 처음 제안됐다.
효과
처리량과 평균 지연시간이 동시에 개선된다. 보통 이 둘은 트레이드오프인데 여기선 둘 다 좋아진다.
이유는 static batching의 빈 슬롯이 누구에게도 이득이 아니었기 때문이다. 처리량에도 손해고 대기 요청의 지연도 늘렸다. 순수한 낭비를 제거한 거라 양쪽이 같이 개선된다. 트레이드오프를 조정한 게 아니라 비효율을 걷어낸 결과다.
단, 개선폭은 워크로드에 따라 다르다. 출력 길이 편차가 클수록 이득이 크다.
3. 새 문제: 메모리가 발목을 잡는다
효과 슬라이드의 마지막 줄이 복선이었다. "메모리가 허용하는 한 배치 크기를 조절."
슬롯을 빈틈없이 채워도, 슬롯을 몇 개나 둘 수 있느냐는 메모리가 정한다. 위 루프 3단계의 "메모리 여유가 있으면"이 바로 이 제약이다.
그럼 그 메모리를 뭐가 잡아먹고 있나?
KV Cache
새 토큰을 만들 때 어텐션은 이전 토큰들의 K(Key), V(Value)가 전부 필요하다.
- K: 나는 이런 정보를 갖고 있다는 색인
- V: 실제 내용물
핵심은 K와 V가 그 토큰 자신에게서만 나온다는 점이다. t1의 K, V는 뒤에 무슨 토큰이 오든 변하지 않는다. 그래서 캐싱이 가능하다. (Q는 매 스텝 새 토큰 것만 필요하니 저장할 이유가 없다. 이름이 KV 캐시인 이유다.)
연산량
| 캐시 없이 (매번 재계산) | 약 O(n²) |
| KV Cache 사용 | 약 O(n) |
대가: 메모리
속도를 얻는 대신 GPU 메모리를 크게 쓴다. 크기를 결정하는 네 요인이 전부 곱셈으로 붙는다.
요인 조절 가능?
| 레이어 수 | 모델이 정함 |
| 히든 차원 | 모델이 정함 |
| 시퀀스 길이 | 상한만 설정 가능 |
| 배치 크기 | 운영자가 조절 |
시퀀스 길이와 배치 크기가 곱해진다는 게 중요하다. 긴 컨텍스트를 허용하면 동시 처리 가능한 요청 수가 반비례로 줄어든다. 컨텍스트 32K를 열어주면 배치를 크게 못 가져가고, 8K로 제한하면 훨씬 많은 요청을 동시에 받을 수 있다.
1강 과제로 띄웠던 서버 로그를 다시 보면 감이 온다.
KV Cache is allocated. #tokens: 92920, K size: 1.24 GB, V size: 1.24 GB
1.5B짜리 작은 모델인데 캐시에 2.5GB를 잡았다.
4. 두 번째 해법: PagedAttention (단편화 제거)
문제: 연속 할당의 낭비
요청이 얼마나 길어질지 모르니 최대 길이만큼 연속 공간을 미리 잡는다. 그런데 실제로는 그만큼 안 쓴다. 컨텍스트 2048을 예약하고 200토큰만 쓰고 끝나면 나머지는 할당됐지만 비어 있다.
두 종류의 낭비가 생긴다.
- 내부 단편화: 할당했지만 안 쓰는 공간
- 외부 단편화: 자투리를 합치면 충분한데 연속된 덩어리가 없어 새 요청을 못 받음
두 번째가 더 고약하다. 메모리 사용률은 60%인데 새 요청은 거절되는 상황이 벌어진다.
해법: OS 가상 메모리를 그대로 가져온다
KV 캐시를 고정 크기 블록(보통 16토큰)으로 쪼갠다. 그리고 Block Table이 논리 순서를 물리 위치로 매핑한다. 페이지 테이블과 똑같은 구조다.
논리적으로는 Block 0 → 1 → 2 순서지만, 물리적으로는 7번, 2번, 9번 슬롯에 흩어져 있어도 된다. 연속성 요구가 사라지니 외부 단편화가 완전히 없어진다.
35토큰을 16토큰 블록으로 관리하는 예시:
낭비
| 연속 할당 (2048 예약, 35 사용) | 2013 토큰 |
| PagedAttention (블록 16) | 최대 15 토큰 |
마지막 블록만 3/16으로 부분적으로 차고, 낭비가 요청당 최대 1블록으로 상한이 잡힌다.
흩어진 블록을 읽으려면 기존 어텐션 커널로는 안 된다. Block Table을 따라 블록 단위로 계산하는 전용 CUDA 커널을 새로 써야 했고, 그래서 이름이 PagedAttention이다. 메모리 관리 기법만이 아니라 어텐션 연산 자체를 다시 쓴 것.
덤: Copy-on-Write
블록 단위로 쪼개놓으니 공유가 가능해진다. 같은 프롬프트로 여러 답을 뽑을 때(parallel sampling, beam search) 프리픽스 블록을 하나만 만들고 여러 시퀀스가 가리키면 된다.
ref count로 참조 수를 센다. 시퀀스 A, B, C가 공유하면 count = 3. A가 끝났다고 블록을 반환하면 B, C가 망가지니, count가 0이 될 때만 실제로 회수한다.
그러다 B가 다른 토큰을 생성하는 시점에 B만 사본을 만든다. 미리 3벌 만들어두는 것보다 훨씬 절약된다. 리눅스 fork()가 메모리를 즉시 복사하지 않고 쓰기 시점까지 미루는 것과 같은 발상이다.
5. 세 번째 해법: RadixAttention (중복 제거)
여기까지는 한 요청 안에서의 공유였다. SGLang은 이걸 요청 간 공유로 확장한다.
기회: 실제 워크로드는 앞부분이 겹친다
사례 설명
| 시스템 프롬프트 | 모든 요청에 동일한 지침이 앞에 붙음 |
| 멀티턴 대화 | 이전 히스토리가 다음 턴마다 그대로 반복 |
| Few-shot 예제 | 같은 예시가 여러 요청에 반복 사용 |
공통점은 길고, 고정적이고, 앞에 붙는다는 것이다.
기존 엔진은 이걸 매 요청마다 처음부터 다시 계산했다. 시스템 프롬프트가 500토큰인데 사용자 질문이 20토큰이면, prefill 연산의 96%가 이미 했던 계산의 반복이다. 그리고 이게 곧 TTFT(첫 토큰까지 걸리는 시간)다.
해법: Radix Tree로 인덱싱
토큰 시퀀스를 트리로 저장한다. 새 요청이 오면 루트부터 따라 내려가며 비교하고, 일치하는 데까지 캐시 히트, 그 이후만 새로 계산한다.
노드 분할(split)이 핵심이다. 갈래가 없는 구간은 노드 하나로 압축해두는 게 radix tree인데, 나중에 갈래가 생기면 쪼개야 한다.
[매칭 전] root → [A-B-C-D (cached)]
↓ 새 요청 A-B-C-E 도착
[매칭 후] root → [A-B-C (공유, cached)]
├── [D (cached)]
└── [E (신규 계산)]
이때 D의 KV 캐시는 그대로 살아있다. 노드 구조만 재편했지 캐시를 버린 게 아니다.
이 구조 덕분에 완전히 같은 프롬프트가 아니어도 겹치는 만큼 비례해서 이득을 본다. few-shot 예시가 3개냐 5개냐, 대화 턴 수가 다르냐 같은 부분 일치도 활용된다.
cache-aware 스케줄링
이게 SGLang의 진짜 차별점이다.
Continuous Batching 루프의 3단계에서 "어떤 요청을 먼저 투입할까"에 선택권이 있다. 여기서 캐시가 살아있는 요청을 우선 처리한다. 같은 프리픽스를 쓰는 요청들을 연달아 처리하는 식이다. 사이에 전혀 다른 요청을 끼워넣으면 그동안 캐시가 밀려나 사라질 수 있으니까.
자료구조만 있다고 캐시가 잘 맞는 게 아니다. 순서를 잘못 처리하면 애써 만든 캐시를 계속 버리게 된다. RadixAttention이 단순한 캐시 기법이 아니라 스케줄러와 한 몸인 이유다.
퇴거 정책
트리는 무한히 자랄 수 없다. 메모리가 차면 LRU로 오래된 노드부터 버리는데, 두 가지 제약이 있다.
- 리프부터 버려야 한다. 부모를 버리면 자식이 전부 무효가 되니까
- 실행 중인 요청이 참조하는 노드는 못 버린다. ref count가 여기서 다시 쓰인다
의미하는 바는 캐시가 조용히 사라진다는 것이다. 같은 프롬프트를 보내도 그 사이 트래픽이 몰렸으면 이미 퇴거돼 미스가 날 수 있다. 성능이 부하 상태에 따라 변동한다는 뜻이라, 벤치마크할 때 주의해야 한다.
효과
논문 기준 최대 5배 처리량 향상(Zheng et al., 2024). 다만 캐시 히트율이 곧 개선폭이다. 프리픽스가 거의 겹치지 않는 워크로드라면 이득이 거의 없다.
Continuous Batching이 워크로드와 무관하게 항상 이득이었던 것과 대비되는 지점이다.
정리: 세 기법은 보완 관계다
Decode가 memory-bound
→ 배칭으로 해결
→ Static은 슬롯 낭비 → Continuous Batching
→ 배치를 키우려니 메모리 부족
→ 범인은 KV Cache
→ 연속 할당하니 단편화 → PagedAttention (블록 단위)
→ 블록으로 쪼갰으니 공유 가능 → RadixAttention (트리 인덱싱)
기법 겨냥하는 낭비 층위
| Continuous Batching | GPU 유휴 시간 | 스케줄링 |
| PagedAttention | 메모리 단편화 | 메모리 할당 |
| RadixAttention | 중복 연산·저장 | 캐시 인덱싱 |
PagedAttention과 RadixAttention이 헷갈리기 쉬운데, 겨냥하는 낭비가 다르다. 전자는 할당했지만 못 쓰는 공간, 후자는 같은 걸 여러 번 계산·저장하는 것. 배타적이지 않고 함께 쓴다.
SGLang과 vLLM도 경쟁 관계가 아니라 계층 관계에 가깝다. PagedAttention은 vLLM이 제안했고 SGLang도 이 방식을 쓴다. 그 위에 트리 인덱싱과 cache-aware 스케줄링을 얹은 것이 SGLang이다.
한 문장으로 요약하면, 2강은 "GPU를 놀리지 않으려면 메모리를 어떻게 관리해야 하는가" 하나의 질문을 세 층위에서 파고든 내용이었다.
'LLM > sglang강의' 카테고리의 다른 글
| 1강. SGLang 서버 띄우기, 그리고 GPU 라인업 이해하기 (1) | 2026.08.24 |
|---|
