대규모 모델 추론 배치 처리: Continuous Batching
프롬프트 입력과 토큰화, 임베딩 조회, Transformer 전방 계산, prefill KV 캐시 생성, decode 단계까지 요청 처리 전 과정을 살펴본다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
여기까지 우리는 하나의 요청이 들어와 나가는 전체 경로를 한 번 훑어보았다. prompt가 들어오고, 토큰화되고, embedding을 조회하고, 레이어별 Transformer 전방 계산을 거치고, prefill로 KV Cache를 만들고, decode로 토큰을 하나씩 생성하고, 마지막으로 토큰이 다시 텍스트로 변환된다.
이 전체 경로 안에는 더 깊이 살펴볼 만한 기술적 세부 사항과 최적화 수단이 적지 않다. 앞으로 하나씩 풀어볼 생각이다. 오늘은 첫 번째 주제부터 시작한다: 배치 처리(Batching).
단일 요청으로 추론을 돌리는 것은 얼마나 낭비인가
여섯 번째 글에서 prefill과 decode의 병목이 완전히 다르다고 설명했다. prefill은 한 번에 prompt 전체를 병렬로 처리하는 행렬 곱하기 행렬의 대규모 연산이라 GPU의 연산 능력을 비교적 꽉 채워 쓸 수 있다. decode는 다르다. 매 단계마다 새 토큰 하나만 처리하며, 본질적으로 행렬 곱하기 벡터라 계산량이 매우 작지만, 매 단계마다 전체 모델 가중치와 전체 KV Cache를 VRAM에서 한 번씩 읽어야 한다.
이런 차이를 측정하는 지표가 바로 여섯 번째 글에서 소개한 산술 강도(Arithmetic Intensity)다. VRAM에서 1바이트를 읽을 때마다 부동소수점 연산을 몇 번 할 수 있는지를 나타낸다. 산술 강도가 높은 작업은 계산 제약(compute-bound)이고 병목은 연산 능력에 있다. 산술 강도가 낮은 작업은 대역폭 제약(bandwidth-bound)이고 병목은 VRAM 대역폭에 있다. decode는 정확히 후자의 극단적 사례다. 가중치가 몇 GB에서 수십 GB에 이르고, 매 단계마다 그대로 한 번씩 읽어서 겨우 토큰 하나를 계산한다.
그 결과, 단일 요청으로 decode를 돌릴 때 GPU의 계산 유닛은 대부분의 시간을 데이터를 기다리며 보낸다. 수백에서 수천 TFLOPS를 표방하는 카드라도 실제로 쓰이는 연산 능력은 아주 작은 일부에 불과하다.
보완책은 아주 직접적이다. 어차피 매 단계마다 전체 모델을 한 번 읽어야 한다면, 그 단계에서 여러 요청을 동시에 계산하게 하자는 것이다. 가중치를 한 번 읽고 수십 개의 요청이 함께 쓰면, 산술 강도는 배치 크기만큼 배로 올라간다. VRAM 대역폭은 같은 시간을 쓰지만 산출되는 토큰은 수십 배로 늘어난다. 이것이 배치 처리의 동기다. 매 단계 가중치를 읽는 비용을 희석하는 것.
정적 배치 처리: 배치 전체가 함께 들어가고 함께 나온다
가장 먼저 떠올릴 수 있는 배치 방식은 정적 배치 처리(Static Batching)다. 요청을 한 배치만큼 모아 하나의 큰 batch로 묶어 GPU에 함께 보내고, 배치 안의 모든 요청이 생성이 끝날 때까지 기다렸다가 배치 전체를 함께 반환한 뒤, 다음 배치를 받는다.
그 문제점은 최단 목표 효과(단판 효과)다. 같은 배치의 요청들은 생성 길이 차이가 매우 크다. 어떤 것은 20개 토큰이면 끝나고, 어떤 것은 500개가 필요하다. 정적 배치 처리에서는 짧은 요청이 생성을 마쳐도 먼저 빠져나갈 수 없다. 배치 안의 자리를 차지한 채 가장 긴 요청이 끝날 때까지 함께 달려야 한다. 함께 달리는 동안 GPU는 그를 위해 의미 없는 전방 계산을 하거나, pad 토큰을 끼워 자리를 채운다. 이 부분의 연산 능력은 순전히 공회전이다. 새로 도착한 요청도 문밖에서 줄을 서야 하고, 현재 배치가 완전히 비워져야 들어올 수 있다.
시간축으로 그리면 이렇다.
빨간색 부분이 순전한 낭비다. 요청 A와 B는 이미 생성을 마쳤는데도 요청 C와 함께 200번째 단계까지 끌려가야 한다. 요청 D는 이미 도착했는데도 그냥 기다릴 수밖에 없다. 배치 내 길이 차이가 클수록 공회전은 더 심해진다.
Transformers로 직접 체감해 보기
Hugging Face Transformers의 generate 인터페이스는 자연스럽게 정적 배치 처리이며, 바로 가져다 최단 목표 효과를 체감할 수 있다. Qwen/Qwen3-0.6B 같은 작은 모델로, 길이 요구가 서로 다른 세 개의 prompt를 하나의 배치로 묶어 보자.
import torch from transformers import AutoModelForCausalLM, AutoTokenizer
model_id = "Qwen/Qwen3-0.6B" tokenizer = AutoTokenizer.from_pretrained(model_id) model = AutoModelForCausalLM.from_pretrained(model_id, dtype="auto")
세 개의 prompt, 예상 출력 길이 차이가 매우 크다 prompts = [ "한 문장으로 항저우를 소개해 줘.", "가을에 관한 오언절구 한 수를 지어 줘.", "하늘이 왜 파란지 자세히 설명해 줘. 300자 이상으로.", ]
대화 템플릿을 씌우고, Qwen3는 사고 모드를 꺼서 답변이 최대한 빨리 끝나게 한다 texts = [ tokenizer.apply_chat_template( [{"role": "user", "content": p}], tokenize=False, add_generation_prompt=True, enable_thinking=False, ) for p in prompts ]
배치 생성은 왼쪽 패딩을 해야 각 시퀀스의 마지막 위치가 새 토큰의 위치가 된다 tokenizer.padding_side = "left" if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token
inputs = tokenizer(texts, return_tensors="pt", padding=True).to(model.device) prompt_len = inputs["input_ids"].shape[1]
outputs = model.generate(**inputs, max_new_tokens=512, do_sample=False)
for i, out in enumerate(outputs): new_tokens = out[prompt_len:] n = int((new_tokens != tokenizer.pad_token_id).sum()) print(f"요청 {i}: 유효 생성 {n}개 토큰") print(tokenizer.decode(new_tokens, skip_special_tokens=True))
내 Mac에서 돌린 결과는 다음과 같다.
세 요청은 각각 23, 18, 290개의 토큰을 유효하게 생성했고, 최장과 최단의 차이는 십몇 배에 달했지만 generate는 가장 긴 요청이 끝난 뒤에야 반환한다. 짧은 요청은 먼저 EOS에 부딪혔고, 뒤쪽 위치는 pad 토큰으로 채워질 수밖에 없었다. 이 위치들의 매 단계 전방 계산은 헛수고다.
실제 추론 서비스가 처리해야 하는 것은 요청이 도착하는 대로 처리되고 양이 많고 이윤이 얇은 장면이다. 정적 배치 처리의 공회전은 생산 환경에서 받아들일 수 없다.
연속 배치 처리: 단계를 단위로 스케줄링
연속 배치 처리(Continuous Batching)는 스케줄링의 입도를 배치 전체에서 단일 반복 단계로 줄였다. 이 메커니즘은 ORCA 시스템에서 왔으며, FriendliAI와 서울대학교 팀이 제안했다. 논문은 OSDI 2022에 발표되었고, 논문에서의 원래 이름은 반복 수준 스케줄링(Iteration-level Scheduling)이다.
발상도 아주 직관적이다. decode를 한 단계 마칠 때마다 스케줄러가 다시 한 번 결정한다. 어떤 요청이 EOS를 생성했거나 길이 상한에 도달하면 즉시 제거해 결과를 반환하고, 대기 큐에 새 요청이 있고 VRAM에 자리가 있으면 즉시 실행 집합에 채워 넣는다. 배치의 구성은 매 단계마다 변하고, GPU는 누구도 기다릴 필요가 없다.
같은 네 개의 요청을 연속 배치 처리로 바꾼 시간축은 이렇다.
앞의 그림과 비교하면 공회전 영역이 완전히 사라졌다. 요청 A는 20번째 단계에 끝나고, 요청 D는 21번째 단계에 그 자리를 메웠다. 각 요청의 실제 생성 시간은 변하지 않았지만, 같은 GPU가 단위 시간에 서비스하는 요청은 늘어났다.
스케줄러 내부: 두 개의 큐
스케줄러를 뜯어보면 두 개의 큐를 유지한다. **대기 큐(waiting)**는 이미 접수했지만 아직 계산을 시작하지 않은 요청을 담고, **실행 집합(running)**은 토큰을 하나씩 생성 중인 요청을 담는다. 반복을 한 단계 돌 때마다 스케줄러는 세 가지 일을 한다.
- 배치 구성: 먼저 실행 집합을 순회하며 실행 중인 각 요청에 이번 단계의 KV 블록과 토큰 예산을 할당한다. 예산이 남으면 대기 큐에서 순서대로 요청을 꺼내 채운다
- 실행: 구성된 배치를 GPU에 보내 전방 계산을 한 단계 돌리고, 각 요청이 새 토큰을 하나씩 산출한다
- 정산: 각 요청이 새로 산출한 토큰을 검사해, EOS나 길이 상한에 부딪힌 것을 완료로 표시하고 자원을 해제하며 결과를 클라이언트에 전달한다. 비워진 자리는 다음 단계 배치 구성 때 대기 큐에서 채워진다
대기 큐는 기본적으로 선착순(FCFS)이며, vLLM은 우선순위 스케줄링도 지원한다. 시작할 때 --scheduling-policy priority를 추가하면 숫자가 작을수록 먼저 처리된다.
효과는 얼마나 큰가
ORCA 논문은 한 가지 비교 데이터를 제시한 적이 있다. GPT-3 175B 분산 서비스 실험에서 같은 지연 목표 아래 ORCA의 처리량은 NVIDIA FasterTransformer의 36.9배였다. 구체적인 수치는 토큰당 190ms 지연 목표에서 FasterTransformer는 초당 0.185개 요청만 처리할 수 있었고, ORCA는 6.81개를 처리할 수 있었다.
이 수치는 특정 baseline과 특정 설정에서의 비교이며, 다른 장면에서도 36배 빠르다는 뜻은 아니라는 점에 유의해야 한다. 하지만 스케줄링 입도를 요청 수준에서 반복 수준으로 낮추기만 해도 처리량에서 수량급 차이가 벌어진다는 점을 충분히 보여준다.
ORCA 논문에는 선택적 배치 처리(Selective Batching)라는 두 번째 메커니즘도 있다. 어텐션 계산은 각 요청의 독립적인 KV Cache에 의존해 배치 전체로 합치기 적합하지 않으므로 요청별로 따로 계산하고, 나머지 선형 계층과 정규화는 배치 전체로 합칠 수 있다. 이 세부 사항은 다루지 않겠다. 관심 있는 사람은 원 논문을 읽어보면 좋겠다.
지금은 업계 표준
반복 수준 스케줄링은 이제 모든 주류 추론 엔진의 기본기이며, 명칭도 거의 continuous batching으로 통일되었고, 몇몇 예외만 있다.
엔진 명칭 vLLM continuous batching SGLang continuous batching Hugging Face TGI continuous batching llama.cpp continuous batching TensorRT-LLM in-flight batching LMDeploy persistent batching
TensorRT-LLM은 이를 in-flight batching이라 부르고, LMDeploy는 persistent batching이라 부른다. 본질적으로 모두 ORCA의 그 반복 수준 스케줄링이다. 이 프레임워크들의 문서를 읽다가 다른 이름을 만나면 그냥 참고하면 된다.
스케줄러 튜닝
연속 배치 처리는 요청이 배치 안에 도착하는 대로 들어가고 끝나는 대로 나가며 다른 요청을 기다리지 않게 한다. 하지만 배치도 무한정 부풀 수는 없고, 항상 경계가 있어야 한다. vLLM을 예로 들면, 스케줄러의 가장 중요한 두 파라미터는 다음과 같다.
- max_num_seqs: 단일 반복 단계에서 동시에 실행하는 최대 요청 수. 제한하는 것은 배치의 요청 개수 상한이다
- max_num_batched_tokens: 단일 반복 단계에서 처리하는 최대 토큰 수로, 모든 요청을 합산해 계산한다. 제한하는 것은 배치의 토큰 총량 상한이다
서비스를 시작할 때 명시적으로 지정할 수 있다.
$ vllm serve Qwen/Qwen3-0.6B \ --max-num-seqs 256 \ --max-num-batched-tokens 8192
이 두 파라미터가 함께 매 단계 GPU에 먹이는 작업량을 결정하며, 이들을 키웠을 때의 영향은 다음과 같다.
- max_num_seqs를 키우면: 처리량이 더 높아지고 가중치 읽기가 더 얇게 희석된다. 하지만 단일 요청 지연은 더 나빠지고, 단계가 더 느려지고, 대기가 더 길어진다
- max_num_batched_tokens를 키우면: 처리량이 더 높아지고 긴 prompt의 prefill이 더 빨리 끝난다. TTFT는 좋아지지만 decode의 토큰 간격은 나빠질 수 있다
보다시피 max_num_seqs든 max_num_batched_tokens든, 이들이 높이는 것은 처리량이지 단일 요청의 속도가 아니다. 세상에 공짜 점심은 없고, 처리량과 지연은 동시에 얻을 수 없다. 배치가 클수록 매 단계 계산할 토큰이 많아져 단계당 소요 시간이 길어지고, 각 요청의 토큰 간격은 더 나빠진다. 요청이 많으면 줄도 서야 하므로 TTFT도 길어진다. 배치가 작을수록 단일 요청의 지연 수준에 가까워지지만 처리량은 다시 돌아간다.
따라서 추론 서비스의 튜닝 목표는 애초에 배치를 최대로 키우는 것이 아니라, SLO(Service Level Objective, 서비스 수준 목표)가 허용하는 범위 안에서 처리량을 높이는 것이다. SLO는 서비스가 지연에 대해 정한 약속선이다. 예를 들어 TTFT가 2초를 넘지 않고, 토큰 간격이 100밀리초를 넘지 않는다는 식이며, 배치 크기는 이 선을 넘지 않는 것을 한도로 한다.
청크 프리필
연속 배치 처리는 decode 단계의 공회전을 해결했지만, prefill과 decode가 같은 엔진 안에 섞여 있는 데에는 여전히 문제가 하나 남아 있다. 수천 token짜리 긴 prompt로 prefill을 하려면 큰 스텝을 한 번 돌려야 하는데, 그동안 배치 전체의 decode가 모두 붙잡혀 사용자는 출력이 갑자기 한 박자 멈추는 것을 보게 된다. 이 현상을 generation stall(생성 정지)이라고 부른다.
Sarathi-Serve(OSDI 2024)의 해법은 청크 단위 프리필(Chunked Prefill, 分块预填充)이며, 이는 서로 맞물리는 두 가지 메커니즘으로 구성된다. 첫째는 긴 prefill을 거의 같은 크기의 블록으로 잘라 한 번에 다 돌리는 대신 여러 스텝에 걸쳐 반복적으로 소화하는 것이다. 둘째는 무정지 스케줄링(Stall-free Scheduling, 无停顿调度)이다. 매 스텝 반복에서 배치를 구성할 때, 스케줄러는 먼저 실행 집합(running set)에 있는 모든 요청의 decode token을 채워 넣고, 남은 예산으로 아직 끝나지 않은 prefill의 다음 블록을 밀어 넣으며, 예산이 아직 남을 때에야 새 요청을 고려한다. 매 스텝의 token 총량은 예산 상한을 넘지 않으며, 이 예산이 바로 앞 절에서 설명한 max_num_batched_tokens다.
왜 decode와 prefill 블록을 섞어서 계산해도 서로 발목을 잡지 않을까? 답은 역시 산술 강도에 있다. decode는 메모리 접근 집약형이라 한 스텝에 수십 개 token만 계산하고 연산 능력은 대량으로 유휴 상태가 된다. prefill은 계산 집약형이라 마침 이 유휴 연산 능력을 메워 준다. 논문에는 직관적인 수치가 하나 있다. 선형 계층에서 decode token 1개의 실행 시간은 prefill token 약 128개와 맞먹는다. 즉, decode 배치에 몇백 개의 prefill token을 곁다리로 실어도 이 스텝의 소요 시간은 거의 변하지 않는다. 논문은 이 메커니즘을 piggyback(편승, 捎带)이라고 부른다. prefill 블록이 decode 반복에 편승하여, 두 부하가 각자 필요한 것을 얻고 GPU의 연산 능력과 대역폭 활용률이 동시에 끌어올려진다.
블록을 자르는 데도 대가가 없는 것은 아니다. prefill을 N개 블록으로 자르면 뒤의 각 블록이 어텐션을 수행할 때마다 앞 블록의 KV Cache를 다시 읽어야 하고, 블록을 잘게 자를수록 재읽기가 많아진다. 논문에서 측정한 바에 따르면 블록 크기를 512로 잡으면 prefill의 추가 오버헤드가 최대 약 25%이고, 2048로 잡으면 거의 무시할 수 있다. 또한 블록 크기는 GPU kernel의 분할 크기에 맞추는 것이 좋은데, 논문에는 극단적인 예가 하나 있다. 257개 token의 블록이 256개보다 32% 느린데, 이는 초과된 1개 token이 분할 블록 하나를 더 차지하기 때문이다. 예산의 구체적인 값은 지연 목표에 따라 정한다. SLO가 엄격하면 작은 예산을, 느슨하면 큰 예산을 쓴다.
두 메커니즘은 따로 쓰면 각각 단점이 있고, 결합해야 비로소 완전해진다. 논문은 Yi-34B에서 소거 비교(ablation)를 진행했다(TTFT는 첫 token 지연, TBT는 인접 token 간격이며, 앞서 설명한 ITL과 같은 종류의 지표다).
| 방안 | P50 TTFT | P99 TBT | | --- | --- | --- | | 혼합 배치만 하고 블록 분할 안 함 | 0.53 s | 0.68 s | | 블록 분할만 하고 혼합 배치 안 함 | 1.04 s | 0.17 s | | 둘 결합(Sarathi-Serve) | 0.76 s | 0.14 s |
혼합 배치만 하고 블록 분할을 안 하면 TBT 꼬리 지연이 긴 prefill에 의해 매우 높게 밀린다. 블록 분할만 하고 혼합 배치를 안 하면 prefill 블록이 decode 배치가 끝나기를 대기해야 해서 TTFT가 다시 나빠진다. 둘을 결합하면 두 지표가 동시에 최저로 눌린다. 꼬리 지연 제약을 충족한다는 전제하에, 논문이 보고한 서비스 능력 향상은 2.6배에서 5.6배다(Mistral-7B부터 Falcon-180B까지의 서로 다른 구성에 걸쳐 있다). vLLM의 V1 엔진은 이미 기본적으로 청크 단위 프리필을 켜 두었으니, 오늘 직접 서비스를 띄운다면 이 메커니즘은 바로 사용할 수 있다.
소결
오늘 우리는 서비스의 관점에서 배치 처리라는 일을 처음부터 끝까지 정리해 보았다.
- 왜 배치 처리가 필요한가 : decode 단계는 대역폭 제약을 받아 단일 요청으로 추론할 때 GPU 연산 능력이 대량으로 유휴 상태가 된다. 배치 처리는 매 스텝 읽는 가중치를 여러 요청에 분산시켜 산술 강도를 몇 배로 끌어올린다
- 정적 배치 처리의 단점 : 배치 전체가 함께 들어오고 함께 나가며, 짧은 요청이 함께 달리고 새 요청은 대기하게 되어 생성 길이 차이가 클수록 공회전이 심해진다. Transformers의 generate가 사용하는 것이 바로 정적 배치 처리다
- 연속 배치 처리 : ORCA에서 온 반복 수준(iteration-level) 스케줄링으로, 스케줄러가 waiting과 running 두 큐를 유지하며 매 스텝마다 배치를 재구성해 완료된 요청은 즉시 빼고 새 요청은 즉시 채워 넣는다. 논문에서는 FasterTransformer 대비 자릿수 단위의 처리량 향상이 있었고, 이미 오늘날 주류 추론 엔진의 표준 장비가 되었다
- 두 가지 핵심 파라미터 : max_num_seqs는 배치의 개수를, max_num_batched_tokens는 단일 스텝의 token 총량을 관리하며, 둘이 함께 처리량과 지연을 균형 잡는다
- 청크 단위 프리필 : 긴 prefill을 블록으로 잘라 decode 반복에 편승해 혼합 실행하고, 두 부하가 산술 강도에서 상호 보완하여 TTFT와 TBT 두 지표가 동시에 개선된다. 대가는 블록 분할이 가져오는 KV Cache 재읽기 오버헤드이며, 블록 크기는 지연 목표에 따라 고른다
다만 배치를 크게 열수록 새로운 병목도 따라온다. 동시 요청마다 KV Cache 한 몫을 차지하므로 VRAM이 먼저 버티지 못하게 된다. 이 VRAM을 어떻게 아끼고 관리할 것인가가 바로 PagedAttention과 접두사 캐싱(prefix caching)이 답해야 할 문제이며, 다음 글에서 살펴보겠다.
참고
- ORCA 논문: OSDI 2022(USENIX)
- Sarathi-Serve 논문(arXiv)
- vLLM 스케줄러 설정 문서
- vLLM 성능 튜닝 문서
- TensorRT-LLM 공식 문서
- vLLM GitHub 저장소
- SGLang GitHub 저장소
- Hugging Face TGI GitHub 저장소
- llama.cpp GitHub 저장소
- LMDeploy GitHub 저장소
- Transformers 공식 문서
- Qwen3-0.6B 모델 카드
팔로우 환영
이 글이 도움이 되셨다면, 제 동명의 위챗 공식 계정을 팔로우해 주시면 좋겠습니다: 日习一技, 매일 새로운 기술을 조금씩 배웁니다.
저는 매일 한 시간을 들여 제가 배운点点滴滴을 기록합니다. 내용은 다음을 포함하되 이에 국한되지 않습니다.
- 어떤 제품의 사용 팁
- 오픈소스 프로젝트의 실천과 소감
- 기술 포인트의 간단한 해설
목표는 여러분이 5분만 읽어도 얻는 것이 있도록 하는 것이고, 너무 애쓸 필요 없이 쉽게 실속 있는 내용을 얻어 가는 것입니다. 기술 초보든 고수든 제게 조언을 주시면 환영하고, 배우고 싶은 기술이 있으면 역시 교류해 주시면 좋겠습니다!
日习一技
201
글
163k
읽음
130
팬