Split View: vLLM 내부 구조 (4) — 스케줄러와 선점, 처리량이 갑자기 무너지는 지점
vLLM 내부 구조 (4) — 스케줄러와 선점, 처리량이 갑자기 무너지는 지점
- 스케줄러가 매 스텝에 푸는 문제
- 대기 큐와 실행 큐
- 선점: 이미 돌던 요청을 뒤로 물리기
- recompute와 swap
- 스케줄링 정책: fcfs와 priority
- 선점이 보이면 무엇을 해야 하는가
- 직접 해보기
- 참고 자료
스케줄러가 매 스텝에 푸는 문제
3편에서 vLLM이 스텝마다 배치를 다시 구성한다는 것을 봤습니다. 그 결정을 실제로 내리는 것이 스케줄러입니다. 매 스텝 스케줄러는 두 가지 한도를 동시에 만족하는 조합을 찾아야 합니다.
하나는 토큰 예산입니다. 이번 스텝에 처리할 토큰 수의 합이 상한을 넘으면 안 됩니다. V1 스케줄러 소스를 보면 요청을 하나 넣을 때마다 예산에서 그만큼을 빼 나가고, 전체 합이 상한을 넘지 않도록 확인합니다.
다른 하나는 KV 캐시 블록입니다. 이쪽이 훨씬 까다롭습니다. 토큰 예산은 계산하면 나오지만, 블록은 지금 실제로 비어 있어야 합니다. 그리고 이미 실행 중인 요청들도 매 스텝 새 토큰을 만들면서 블록을 더 달라고 합니다. 즉 스케줄러가 상대하는 자원은 가만히 있지 않고 계속 줄어듭니다.
내용은 2026-08-12에 공식 문서·소스에서 확인했습니다. vLLM은 변화가 빠르니 설정값과 동작은 사용 중인 버전의 문서로 다시 확인하세요.
대기 큐와 실행 큐
구조 자체는 단순합니다. V1 스케줄러 소스에는 대기 중인 요청을 담는 큐와 실행 중인 요청 목록이 나란히 있습니다. 실행 목록은 평범한 리스트이고, 대기 큐는 스케줄링 정책에 따라 다른 형태로 만들어집니다.
[대기 큐] 아직 한 번도 안 돌았거나, 물러난 요청들
│
│ 블록이 확보되면 승격
▼
[실행 목록] 이번 스텝 배치에 들어가는 요청들
│
│ 블록이 모자라면 선점되어 되돌아감
└──────────────▶ 다시 대기 큐 (맨 앞자리로)
되돌아가는 화살표가 이 글의 주인공입니다. 그리고 중요한 세부가 하나 있습니다. 선점된 요청은 대기 큐의 맨 뒤가 아니라 맨 앞에 놓입니다. 소스에서 요청을 큐 앞쪽에 다시 넣는 동작으로 확인할 수 있습니다. 방금 밀려난 요청이 곧바로 다시 후보가 된다는 뜻이고, 덕분에 특정 요청이 계속 밀려 굶는 상황을 피합니다.
선점: 이미 돌던 요청을 뒤로 물리기
KV 캐시가 모자라면 스케줄러는 새 요청을 안 받는 데서 그치지 않고, 이미 돌고 있던 요청을 물러나게 합니다. 이것이 선점입니다.
왜 이렇게까지 하느냐면 대안이 없기 때문입니다. 실행 중인 요청 전부가 다음 토큰을 만들려면 블록이 더 필요한데 남은 블록이 없다면, 누군가는 나가야 나머지가 전진합니다. 아무도 안 내보내면 전부 멈춥니다.
공식 최적화 문서는 이때 나오는 경고를 예시로 보여 줍니다.
WARNING 05-09 00:49:33 scheduler.py:1057 Sequence group 0 is preempted by
PreemptionMode.RECOMPUTE mode because there is not enough KV cache space.
로그 형식과 줄 번호는 버전마다 다르지만, 봐야 할 부분은 마지막 구절입니다. KV 캐시 공간이 부족하다는 것. 이 줄이 로그에 반복해서 찍히고 있다면 그 배포는 이미 용량을 넘겨 돌고 있는 상태입니다.
선점이 무서운 이유는 비용이 눈에 잘 안 띄기 때문입니다. 요청이 실패하지 않습니다. 에러도 안 납니다. 그저 느려집니다. 그것도 평균이 아니라 꼬리에서 느려집니다. 대시보드의 평균 지연은 멀쩡한데 일부 사용자만 유난히 오래 기다리는 상황이 만들어집니다.
recompute와 swap
물러난 요청의 KV 캐시는 어떻게 할까요. 역사적으로 두 가지 방법이 있었습니다.
재계산은 그냥 버리는 쪽입니다. 나중에 다시 실행될 때 프롬프트부터 다시 계산합니다. 메모리를 즉시 온전히 회수하는 대신 그동안의 계산을 날립니다. V1 스케줄러 소스를 보면 선점된 요청의 상태를 표시하고 지금까지 계산한 토큰 수를 0으로 되돌립니다. 진행 상황이 초기화된다는 사실이 이 한 줄에 그대로 드러납니다.
스왑은 KV 캐시를 CPU 메모리로 옮겨 두었다가 되돌리는 쪽입니다. 계산은 아끼지만 GPU와 CPU 사이로 데이터를 왕복시키는 비용이 듭니다.
지금 기준으로 알아야 할 것은 이것입니다. 공식 문서는 vLLM V1의 기본 선점 방식이 스왑이 아니라 재계산이며, V1 구조에서는 재계산 쪽의 부담이 더 작다고 밝히고 있습니다. 그리고 V1 가이드는 GPU와 CPU 사이의 KV 캐시 스왑이 제거된 기능 목록에 올라 있다고 적고 있습니다. 그러니 오래된 글에서 본 스왑 관련 설정을 지금 그대로 적용하려 들면 안 됩니다. 사용 중인 버전에서 그런 인자가 실제로 존재하는지부터 확인하세요.
스케줄링 정책: fcfs와 priority
스케줄러 설정에는 정책 항목이 있습니다. main 브랜치 SchedulerConfig 소스에서 policy의 기본값은 fcfs이고, 선착순 처리를 뜻합니다. 다른 선택지는 priority로, 요청에 주어진 우선순위를 따릅니다. OpenAI 호환 서버의 추가 파라미터 목록에도 priority가 들어 있습니다.
정책은 선점 대상을 고르는 방식까지 바꿉니다. V1 스케줄러 소스를 보면 우선순위 정책에서는 실행 중인 요청 가운데 우선순위와 도착 시각을 기준으로 가장 뒤에 놓이는 요청을 골라 물러나게 하고, 그 외 정책에서는 목록의 마지막 요청을 꺼냅니다.
실무에서 이 선택이 갈리는 지점은 분명합니다. 모든 요청이 대등하면 선착순으로 충분합니다. 반면 대화형 요청과 대량 배치 작업이 한 엔드포인트를 공유한다면, 우선순위를 쓰지 않는 한 배치 작업이 대화형 사용자를 밀어냅니다. 다만 정책을 바꾸기 전에 먼저 물어야 할 질문이 있습니다. 지금 겪는 문제가 순서 문제인지 용량 문제인지입니다. 용량이 모자란 상태에서 순서만 바꾸면 누가 손해를 볼지가 달라질 뿐 총량은 그대로입니다.
선점이 보이면 무엇을 해야 하는가
공식 최적화 문서가 제시하는 대응은 네 가지입니다. gpu_memory_utilization을 올려 KV 캐시에 쓸 공간을 늘리기, max_num_seqs나 max_num_batched_tokens를 줄여 한 스텝에 살아 있는 요청을 줄이기, tensor_parallel_size를 올리기, pipeline_parallel_size를 올리기입니다.
앞의 둘과 뒤의 둘은 성격이 다릅니다. 앞의 둘은 지금 GPU 안에서 배분을 바꾸는 일이고, 뒤의 둘은 GPU를 더 쓰는 일입니다. 순서대로 시도하는 편이 낫습니다. 대부분의 배포는 배분만 고쳐도 선점이 사라집니다. 여기서 max_num_seqs를 줄이는 것이 처리량을 떨어뜨릴 것 같지만 실제로는 반대인 경우가 많습니다. 선점이 반복되는 상태는 이미 재계산으로 낭비하고 있는 상태라서, 동시 요청 수를 줄여 선점을 없애는 편이 순처리량에 유리합니다.
직접 해보기
- LLM GPU 메모리(VRAM) 계산기 — 선점은 결국 KV 캐시 용량 문제입니다. 지금 설정에서 몇 개의 시퀀스가 동시에 살 수 있는지 계산해 보세요.
- K8s 실습 랩 — 쿠버네티스 위에 모델 서버를 올린다면 리소스 요청과 한도, 롤아웃 같은 기본기를 여기서 손으로 익혀 두면 좋습니다.
- 이전 편: vLLM 내부 구조 (3) — 연속 배칭이 GPU를 놀게 두지 않는 방법
- 다음 편: vLLM 내부 구조 (5) — 접두사 캐싱과 KV 재사용
참고 자료
- vLLM Optimization and Tuning (docs.vllm.ai) — 선점 경고 예시, V1의 기본 선점 방식이 재계산이라는 서술, 대응 네 가지의 출처입니다.
- vLLM V1 Scheduler 소스 (GitHub main) — 대기 큐와 실행 목록, 선점 시 계산 토큰 수 초기화, 정책별 선점 대상 선택을 읽은 곳입니다.
- vLLM V1 Guide (docs.vllm.ai) — GPU와 CPU 사이 KV 캐시 스왑이 제거되었다는 서술의 출처입니다.
- vLLM SchedulerConfig 소스 (GitHub main) —
policy기본값과 선택지를 읽은 곳입니다.
Inside vLLM (4) — The Scheduler and Preemption, Where Throughput Collapses
- The Problem the Scheduler Solves Every Step
- The Waiting Queue and the Running Queue
- Preemption: Pushing Back a Request That Was Already Running
- Recompute and Swap
- Scheduling Policy: fcfs and priority
- What to Do When You See Preemption
- Try It Yourself
- References
The Problem the Scheduler Solves Every Step
Part 3 showed that vLLM reassembles the batch on every step. The scheduler is what actually makes that call. On every step, the scheduler has to find a combination that satisfies two limits at the same time.
One is the token budget. The total number of tokens processed this step must not exceed the cap. Looking at the V1 scheduler source, every time a request is admitted, that amount gets subtracted from the budget, and the running total is checked against the cap.
The other is KV cache blocks, and this one is much trickier. The token budget is just arithmetic, but blocks have to actually be free right now. And the requests that are already running also ask for more blocks every step as they generate new tokens. In other words, the resource the scheduler is working against does not hold still. It keeps shrinking.
Everything here was verified against the official documentation and source on 2026-08-12. vLLM moves fast, so re-check settings and behavior against the documentation for the version you are running.
The Waiting Queue and the Running Queue
The structure itself is simple. The V1 scheduler source has a queue holding waiting requests sitting alongside a list of running requests. The running list is an ordinary list, while the waiting queue is built as a different structure depending on the scheduling policy.
[Waiting queue] requests that have never run yet, or were preempted
│
│ promoted once blocks are secured
▼
[Running list] requests that go into this step's batch
│
│ preempted and sent back if blocks run short
└──────────────▶ back to the waiting queue (at the front)
That arrow going back is the real subject of this post. And there is one important detail. A preempted request is placed at the front of the waiting queue, not the back. This can be confirmed in the source by the operation that reinserts the request at the front of the queue. It means a request that was just pushed out becomes a candidate again immediately, which avoids a situation where a specific request keeps getting pushed back and starves.
Preemption: Pushing Back a Request That Was Already Running
When the KV cache runs short, the scheduler does not stop at simply refusing new requests. It forces a request that is already running to step back. This is preemption.
The reason it goes this far is that there is no alternative. If every running request needs more blocks to produce its next token and there are no blocks left, someone has to leave so the rest can move forward. If no one leaves, everything stalls.
The official optimization documentation shows an example of the warning this produces.
WARNING 05-09 00:49:33 scheduler.py:1057 Sequence group 0 is preempted by
PreemptionMode.RECOMPUTE mode because there is not enough KV cache space.
The log format and line number vary by version, but the part to focus on is the last clause: that there is not enough KV cache space. If this line keeps showing up repeatedly in the logs, that deployment is already running past its capacity.
What makes preemption dangerous is that its cost is hard to see. The request does not fail. No error gets raised. It just gets slower, and not on average but out at the tail. This creates a situation where the average latency on the dashboard looks fine while a subset of users end up waiting unusually long.
Recompute and Swap
What happens to the KV cache of a request that got pushed back? Historically, there have been two approaches.
Recompute is the approach of simply discarding it. When the request runs again later, computation starts over from the prompt. It recovers memory fully and immediately, at the cost of throwing away all the computation done so far. Looking at the V1 scheduler source, it marks the preempted request's state and resets the number of tokens computed so far back to 0. That one line lays bare the fact that progress gets reset.
Swap is the approach of moving the KV cache to CPU memory and bringing it back later. It saves the computation, but it costs the price of shuttling data back and forth between GPU and CPU.
Here is what matters as of now. The official documentation states that vLLM V1's default preemption mode is recompute, not swap, and that the overhead of recompute is smaller under the V1 structure. The V1 guide also lists GPU-CPU KV cache swap among the features that have been removed. So do not try to apply a swap-related setting you saw in an older post as-is. Check first whether that argument actually exists in the version you are running.
Scheduling Policy: fcfs and priority
The scheduler settings include a policy field. In the SchedulerConfig source on the main branch, the default value of policy is fcfs, meaning first-come, first-served. The other option is priority, which follows the priority assigned to a request. priority also appears in the list of extra parameters for the OpenAI-compatible server.
The policy also changes how the target of preemption gets chosen. Looking at the V1 scheduler source, under the priority policy, the request ranked lowest by priority and arrival time among the running requests is the one pushed back, while under other policies, the last request in the list is pulled out.
In practice, where this choice matters is clear. If every request is equally important, first-come-first-served is enough. But if interactive requests and large batch jobs share one endpoint, the batch job will push out interactive users unless priority is used. Before switching policy, though, there is a question to ask first: is the problem you are seeing an ordering problem or a capacity problem. If capacity is insufficient, changing only the order just changes who loses out. The total amount of work does not change.
What to Do When You See Preemption
The official optimization documentation lays out four responses: raise gpu_memory_utilization to give the KV cache more room, lower max_num_seqs or max_num_batched_tokens to reduce how many requests are alive in a step, raise tensor_parallel_size, or raise pipeline_parallel_size.
The first two and the last two are different in nature. The first two change how allocation is done within the GPU you already have, while the last two mean using more GPU. It is better to try them in that order. In most deployments, preemption goes away just by fixing the allocation. It may seem like lowering max_num_seqs would hurt throughput, but the opposite is often true in practice. A state where preemption keeps repeating is already a state of wasting work on recompute, so reducing the number of concurrent requests to eliminate preemption tends to favor net throughput.
Try It Yourself
- LLM GPU Memory (VRAM) Calculator — Preemption ultimately comes down to KV cache capacity. Calculate how many sequences your current settings can keep alive at once.
- K8s Hands-on Lab — If you are deploying a model server on Kubernetes, this is a good place to get hands-on with the basics like resource requests and limits, and rollouts.
- Previous: Inside vLLM (3) — How Continuous Batching Keeps the GPU Busy
- Next: Inside vLLM (5) — Prefix Caching, and Why System Prompt Design Is Performance
References
- vLLM Optimization and Tuning (docs.vllm.ai) — The source for the preemption warning example, the statement that V1's default preemption mode is recompute, and the four responses.
- vLLM V1 Scheduler source (GitHub main) — Where the waiting queue and running list, the reset of computed token count on preemption, and the per-policy preemption target selection were read.
- vLLM V1 Guide (docs.vllm.ai) — The source for the statement that GPU-CPU KV cache swap has been removed.
- vLLM SchedulerConfig source (GitHub main) — Where the
policydefault and its options were read.