지난 글에서 KV 캐시를 블록으로 쪼개 메모리 낭비를 없애는 이야기를 했다. 메모리가 준비되면 그다음 질문은 「그 자리에 무엇을 언제 넣을 것인가」이고, 그 결정을 매 토큰마다 다시 내리는 것이 연속 배칭이다.
두 아이디어가 짝을 이루는 이유가 여기 있다. 자리를 계속 갈아 끼우려면 메모리를 유연하게 빌리고 돌려줄 수 있어야 하고, 블록 방식이 그것을 가능하게 만든다.
왜 묶어서 돌리는가
먼저 배치 자체의 이유를 다시 짚자. 토큰 하나를 만드는 데 필요한 계산량은 크지 않은데 모델 가중치 전체를 메모리에서 읽어야 한다. 계산이 아니라 메모리 대역폭에 막혀 있다는 뜻이다.
그래서 가중치를 한 번 읽는 김에 여러 요청의 토큰을 같이 만들면, 읽는 비용은 그대로인데 뽑는 양이 배가 된다. KV 캐시와 배치에서 본 이야기다. 문제는 어떻게 묶느냐다.
고정 배치의 빈자리
소박한 구현은 요청이 몇 개 모일 때까지 기다렸다가 한 묶음으로 돌리고, 그 묶음이 전부 끝나야 다음 묶음을 받는다. 그런데 같은 묶음 안의 요청들은 답 길이가 제각각이다.
위쪽이 고정 배치다. B는 두 걸음 만에 끝났는데 A가 여덟 걸음을 가는 동안 그 자리가 비어 있는 채로 함께 돈다. 아래쪽이 연속 배칭이고, 같은 GPU에 같은 요청인데 세 걸음이 줄고 노는 칸이 7에서 1로 준다.
낭비가 두 겹이라는 점이 중요하다. 하나는 GPU 시간이고, 다른 하나는 대기 중인 요청이 기다리는 시간이다. 고정 배치에서 C와 D는 앞 묶음이 끝날 때까지 아무것도 못 받는다. 사용자 쪽에서 보면 첫 글자가 나오기까지 여덟 걸음을 그냥 기다린 것이다.
걸음마다 다시 짠다
연속 배칭은 묶음이라는 개념을 아예 버린다. 토큰을 한 번 만들 때마다 스케줄러가 다시 판단한다 — 끝난 요청은 빼고, 대기 중인 요청 가운데 메모리가 허락하는 만큼 넣는다.
한 배치 안에서 요청들의 진행 상황이 제각각이라는 점을 눈여겨볼 만하다. A는 414번째 토큰을 만드는데 F는 첫 토큰을 만든다. 길이가 다른 요청을 한 번에 처리하는 것이 가능한 이유는 디코드 단계에서는 어느 요청이든 이번에 넣는 토큰이 하나뿐이기 때문이다. 각자 들고 있는 KV 캐시의 길이만 다르고, 그건 블록 테이블이 알아서 이어 준다.
옛 구현은 여기서 짧은 요청에 패딩을 채워 길이를 맞췄고, 그 패딩만큼 계산이 그냥 버려졌다. 요즘 커널은 길이가 들쭉날쭉한 입력을 그대로 이어 붙여 처리하므로 그 낭비도 없다.
프리필이 끼어들면 생기는 일
여기까지가 교과서적인 설명이고, 실제로 튜닝하다 만나는 문제는 그다음이다.
새 요청이 들어오면 입력 전체를 한 번에 통과시키는 프리필을 해야 한다. 프리필은 디코드와 성격이 정반대다.
| 프리필 | 디코드 | |
|---|---|---|
| 한 번에 처리하는 토큰 | 입력 전체(수천 개) | 요청당 1개 |
| 병목 | 연산 능력 | 메모리 대역폭 |
| 걸리는 시간 | 입력 길이에 비례 | 거의 일정 |
문제는 이 둘이 같은 GPU를 나눠 쓴다는 것이다. 4,000토큰짜리 요청이 프리필을 도는 동안 디코드 중이던 요청 전부가 그만큼 멈춘다. 사용자 화면에서는 잘 흘러나오던 글자가 갑자기 반 초쯤 끊겼다가 다시 흐르는 것으로 보인다. 스트리밍을 쓰는 서비스에서 특히 눈에 띈다.
청크 프리필
해법은 프리필을 잘라 나눠 넣는 것이다. 4,000토큰을 한 번에 밀지 말고 512토큰씩 여덟 번에 나눠, 디코드 걸음 사이사이에 조금씩 섞어 넣는다. 이것을 청크 프리필(chunked prefill)이라고 부른다.
- 디코드 중인 요청들의 토큰 간격이 튀지 않는다
- 대신 새 요청의 첫 토큰까지 걸리는 시간은 조금 늘어난다
- GPU 입장에서는 연산에 목마른 프리필과 대역폭에 목마른 디코드가 섞이므로 오히려 이용률이 오른다
vLLM 계열에서는 --max-num-batched-tokens가 이 조각의 크기다. 값을 줄이면 끊김이 줄고 첫 토큰이 느려지며, 키우면 반대다. 스트리밍 챗 서비스라면 줄이는 쪽, 밤에 도는 일괄 처리라면 키우는 쪽이다.
스케줄러가 실제로 보는 것
걸음마다 「무엇을 넣을까」를 정할 때 판단 기준은 대개 셋이다.
- 메모리에 빈 블록이 있는가 — 없으면 대기열에 둔다
- 동시 요청 수 상한에 걸리는가(
--max-num-seqs) — 상한이 없으면 하나하나가 너무 느려진다 - 이번 걸음의 토큰 예산이 남았는가(
--max-num-batched-tokens) — 프리필 조각과 디코드 토큰이 이 예산을 나눠 쓴다
돌고 있는 요청들이 계속 길어져 블록이 떨어지면 선점이 일어난다. 요청 하나를 빼서 자리를 비우고, 나중에 다시 프리필하거나 CPU로 옮겨 두었던 캐시를 도로 올린다. 로그에 선점이 자주 찍히면 튜닝할 자리가 아니라 용량이 모자란 것이다.
처리량과 지연은 같이 못 올린다
연속 배칭 튜닝에서 결국 마주치는 벽이 이것이다. 한 걸음에 요청을 많이 넣을수록 초당 총 토큰 수는 오르고, 개별 요청이 자기 몫의 대역폭을 덜 받아 토큰 간격이 벌어진다.
그래서 재야 하는 것이 셋이다.
- 첫 토큰까지의 시간(TTFT) — 사용자가 빈 화면을 보는 시간. 프리필 정책과 대기열 길이가 정한다
- 토큰 간 간격(ITL) — 스트리밍이 끊겨 보이는지. 동시 요청 수와 프리필 끼어듦이 정한다
- 초당 총 출력 토큰 — 같은 카드로 몇 명을 받는지
사용자가 화면 앞에서 기다리는 서비스면 앞의 둘에 상한을 걸고 그 안에서 셋째를 최대로 올린다. 밤에 도는 일괄 처리면 셋째만 보면 된다. 부하 성격이 다르면 인스턴스를 나누는 편이 낫다 — 같은 엔진에 둘을 섞으면 긴 일괄 요청의 프리필이 대화형 요청의 흐름을 계속 끊는다.
흔한 함정
- 평균만 보고 튜닝한다. 평균 지연은 멀쩡한데 99백분위가 무너지는 것이 이 구조의 전형적인 증상이다. 프리필에 밀린 요청들이 꼬리에 몰린다
--max-num-seqs를 무한대로 둔다. 처리량 그래프는 좋아 보이는데 개별 응답이 기어간다. 서비스 요구사항에서 역산해 상한을 건다- 부하 시험을 동시성 1로 한다. 연속 배칭의 이득은 동시 요청이 있을 때만 나온다. 요청 하나씩 순차로 재면 아무것도 측정하지 못한 것이다
- 입력 길이 분포를 안 본다. 평균 500토큰인데 가끔 30,000토큰이 섞이는 부하라면, 그 몇 건이 청크 프리필 없이는 전체 흐름을 계속 끊는다
- 연속 배칭을 켜면 지연이 좋아진다고 기대한다. 좋아지는 것은 대기 시간이고, 이미 돌고 있는 요청 하나의 속도는 오히려 배치가 붐빌수록 느려진다
정리
- 생성 추론은 메모리 대역폭에 막히므로 여러 요청을 함께 도는 것이 이득이다
- 고정 배치는 묶음이 다 끝나야 다음을 받는다. 답 길이가 제각각이라 끝난 자리가 비어 있는 채로 함께 돈다
- 낭비가 두 겹이다 — GPU 시간과, 대기 중인 요청이 첫 글자를 못 받는 시간
- 연속 배칭은 토큰 한 걸음마다 배치를 다시 짜서 끝난 요청을 빼고 대기 요청을 넣는다
- 진행 상황이 제각각인 요청을 같이 돌 수 있는 이유는 디코드에서 각자 넣는 토큰이 하나뿐이기 때문이다
- 프리필은 연산에, 디코드는 대역폭에 막힌다. 긴 프리필이 들어오면 흐르던 스트리밍이 끊긴다
- 청크 프리필은 프리필을 잘라 디코드 사이에 섞어 넣는다. 끊김이 줄고 첫 토큰은 조금 느려진다
- 스케줄러는 빈 블록·동시 요청 상한·이번 걸음의 토큰 예산 셋을 보고 판단한다
- 블록이 떨어지면 선점이 일어난다. 자주 찍히면 용량 문제다
- 처리량과 지연은 같이 못 올린다. 첫 토큰까지의 시간·토큰 간격·초당 총 출력 토큰 셋을 함께 재고, 부하 성격이 다르면 인스턴스를 나눈다
읽어주셔서 감사합니다. 😊

