Skip to content

Split View: vLLM을 고쳐서 빠르게 만들기 — 설정, 내부 구조, 그리고 코드를 건드릴 지점

✨ Learn with Quiz
|

vLLM을 고쳐서 빠르게 만들기 — 설정, 내부 구조, 그리고 코드를 건드릴 지점

들어가며 — 코드를 열기 전에 확인해야 할 여섯 줄

"vLLM이 느린데 소스를 고쳐야 할까요"라는 질문을 받으면, 저는 항상 같은 것부터 확인합니다. 로그에 선점 경고가 찍히고 있는지, max_num_batched_tokens가 얼마인지, 프리픽스 캐싱이 켜져 있는지, GPU 메모리 활용률이 얼마인지, 물리 코어가 몇 개인지, 그리고 지금 무엇을 재고 있는지입니다.

이 여섯 줄에서 답이 나오는 경우가 대부분입니다. 실제로 소스를 고쳐야 하는 상황은 생각보다 드물고, 고쳐야 할 때조차 고칠 지점은 몇 군데로 정해져 있습니다.

이 글은 그 순서를 따라갑니다. 확인 기준은 vLLM v0.26.0(2026년 7월 25일 PyPI 릴리스, Python 3.10 이상 3.15 미만)이며, 내부 구조 설명은 해당 태그의 소스를 직접 읽고 썼습니다. vLLM은 2주에 한 번 꼴로 마이너 버전이 올라가는 프로젝트라, 이 글의 파일 경로와 인자 이름도 몇 달이면 어긋날 수 있습니다. 자기 버전의 소스와 대조하는 습관이 필요합니다.

한 가지 먼저 짚어 둡니다. V0 엔진은 완전히 폐기되었습니다. 공식 문서가 "We have fully deprecated V0"라고 명시하고 RFC #18571을 가리킵니다. 인터넷에 남아 있는 V0 시절 튜닝 글은 대부분 지금 맞지 않습니다.

먼저, 고치지 않고 되는 것들

우선순위 순으로 정리합니다. 위쪽일수록 효과가 크고 비용이 쌉니다.

손잡이무엇을 바꾸나언제 건드리나
gpu_memory_utilizationKV 캐시로 쓸 메모리 비율선점 경고가 보일 때. 기본값에서 올린다
max_num_batched_tokens한 스텝에 처리할 총 토큰 예산TTFT와 ITL 중 무엇을 살릴지 정할 때
max_num_seqs동시에 실행할 요청 수 상한메모리가 모자라거나 배치가 얕을 때
프리픽스 캐싱공통 접두사 KV 재사용시스템 프롬프트가 길고 공유될 때
양자화가중치와 KV 캐시의 바이트 수디코드가 대역폭에 묶여 있을 때
tensor_parallel_size가중치를 GPU에 쪼개는 정도모델이 안 들어가거나 KV 공간이 부족할 때
어텐션 백엔드어떤 커널을 쓸지자동 선택이 최적이 아닐 때
최적화 레벨 -O0에서 -O3시작 시간과 정상 상태 성능의 교환개발 루프냐 프로덕션이냐

선점을 먼저 없앤다

가장 흔한 단일 원인입니다. KV 캐시가 모자라면 vLLM은 실행 중인 요청을 물리고 나중에 다시 계산합니다. 로그에 이런 줄이 반복되면 다른 튜닝은 의미가 없습니다.

WARNING ... Sequence group 0 is preempted by PreemptionMode.RECOMPUTE mode
because there is not enough KV cache space. This can affect the end-to-end
performance. Increase gpu_memory_utilization or tensor_parallel_size ...

V1의 기본 선점 방식은 스왑이 아니라 재계산입니다. 물린 요청의 프리필을 처음부터 다시 합니다. 그래서 선점이 잦으면 처리량과 지연이 함께 무너집니다. 대응은 문서가 그대로 알려 줍니다. gpu_memory_utilization을 올리거나, max_num_seqsmax_num_batched_tokens를 낮추거나, tensor_parallel_size를 올려 GPU당 KV 공간을 늘리는 것입니다.

토큰 예산으로 TTFT와 ITL을 저울질한다

max_num_batched_tokens는 한 스텝에 스케줄할 수 있는 토큰의 총량입니다. 공식 문서가 방향을 명확히 적어 두었습니다.

  • 값이 작으면(예: 2048) ITL이 좋아집니다. 디코드를 늦추는 프리필 덩어리가 작아지기 때문입니다.
  • 값이 크면 TTFT가 좋아집니다. 한 배치에 프리필 토큰을 더 많이 밀어 넣을 수 있기 때문입니다.
  • 처리량이 목표라면 8192보다 크게 두라고 권합니다. 특히 큰 GPU에 작은 모델을 올린 경우에 그렇습니다.

주의할 함정이 하나 있습니다. 청크드 프리필을 끈 상태에서는 max_num_batched_tokensmax_model_len보다 커야 하며, 그렇지 않으면 서버가 시작 시점에 죽을 수 있습니다.

from vllm import LLM

# 대화형 서비스: 토큰당 지연을 우선
llm = LLM(model="meta-llama/Llama-3.1-8B-Instruct", max_num_batched_tokens=2048)

# 배치 처리: 처리량을 우선
llm = LLM(model="meta-llama/Llama-3.1-8B-Instruct", max_num_batched_tokens=16384)

시작 시간도 성능이다

같은 모델과 설정으로 반복해서 띄우는 환경이라면, 문서가 세 가지 장치를 제시합니다.

  • 컴파일 캐시 재사용. torch.compile 산출물이 VLLM_CACHE_ROOT(기본값은 홈 아래 캐시 디렉터리)에 저장되고, 이 디렉터리를 컨테이너 이미지에 구워 넣거나 장비 사이에 복사할 수 있습니다. VLLM_FORCE_AOT_LOAD=1을 주면 캐시가 빗나갔을 때 조용히 재컴파일하는 대신 명시적으로 실패합니다. 모델, 설정, 관련 환경 변수, torch 빌드, GPU 기종 중 하나라도 바뀌면 캐시는 무효가 됩니다.
  • --kv-cache-memory로 메모리 프로파일링 건너뛰기. 시작할 때 vLLM이 현재 할당을 재현하는 값을 로그에 찍어 줍니다. 다음 부팅에 그 값을 넘기면 측정 단계를 생략합니다. 다만 대가가 있습니다. KV 캐시가 측정값이 아니라 지정값으로 고정되므로, 보수적으로 잡으면 동시 실행 수가 깎이고 낙관적으로 잡으면 할당에서 실패합니다. 같은 GPU에 같은 초기 여유 메모리일 때만 유효합니다.
  • --enforce-eager. 컴파일과 CUDA 그래프 캡처를 모두 건너뜁니다. 시작이 가장 빠르고 정상 상태 디코드 성능은 나빠집니다. 개발 루프용이며, 부팅 시간 중 컴파일 비중을 재는 데도 씁니다.

CPU를 굶기지 않는다

의외로 자주 놓치는 항목이라 따로 적습니다. vLLM V1은 다중 프로세스 구조입니다. GPU가 N개면 API 서버 1개, 엔진 코어 1개, GPU 워커 N개로 최소 N 더하기 2개의 프로세스가 CPU를 두고 경쟁합니다.

문서는 최소치를 물리 코어 기준으로 못 박습니다. 하이퍼스레딩이 켜져 있으면 vCPU 1개는 물리 코어의 절반이므로, 필요한 vCPU는 두 배입니다. 특히 엔진 코어 프로세스는 바쁜 대기 루프를 돌기 때문에 CPU 굶주림에 민감합니다. 가상화 환경에서 GPU 사용률이 이유 없이 낮다면 여기를 먼저 의심하는 것이 맞습니다.

어텐션 백엔드는 자동 선택이 기본이다

vLLM은 GPU 아키텍처와 모델, 설정을 보고 우선순위 목록에서 첫 번째로 호환되는 백엔드를 고릅니다. v0.26.0 기준 표준 어텐션의 우선순위는 이렇습니다.

아키텍처1순위2순위3순위4순위5순위
Blackwell (SM 10.x)FLASHINFERFLASH_ATTNTRITON_ATTNFLEX_ATTENTIONTURBOQUANT
Ampere / Hopper (SM 8.x–9.x)FLASH_ATTNFLASHINFERTRITON_ATTNFLEX_ATTENTIONTURBOQUANT

수동으로 바꾸려면 이렇게 합니다. 호환되지 않는 백엔드를 지정하면 이유를 붙여 오류를 냅니다.

vllm serve Qwen/Qwen3-8B --attention-backend FLASH_ATTN
# 또는 구조화 설정으로
vllm serve Qwen/Qwen3-8B -ac.backend FLASH_ATTN

PagedAttention — 단편화를 없애는 아이디어

여기서부터 내부입니다. vLLM의 출발점이 된 관찰은 단순합니다. KV 캐시를 요청마다 연속된 큰 덩어리로 잡으면 메모리 대부분이 낭비된다는 것입니다.

낭비는 세 종류입니다. 요청이 최대 길이까지 갈지 모르니 미리 잡아 두는 내부 예약분, 실제로 쓰지 않고 끝나는 초과 할당분, 그리고 크기가 제각각인 덩어리를 반납하고 다시 잡는 과정에서 생기는 외부 단편화입니다.

해법은 운영체제의 가상 메모리에서 그대로 가져왔습니다. KV 캐시를 고정 크기 블록으로 자르고, 논리적으로 연속된 시퀀스를 물리적으로 흩어진 블록들에 매핑하는 블록 테이블을 둡니다.

요청 A의 논리 KV:  [t0 t1 t2 t3] [t4 t5 t6 t7] [t8 t9 __ __]
                       │             │             │
블록 테이블 A     →   블록 7       블록 3        블록 12

요청 B의 논리 KV:  [t0 t1 t2 t3] [t4 t5 __ __]
                       │             │
블록 테이블 B     →   블록 7       블록 5
                  공통 접두사면 같은 블록을 공유한다 (프리픽스 캐싱)

결과가 두 가지입니다. 첫째, 마지막 블록의 남는 자리 말고는 낭비가 없어집니다. 원 논문은 이를 "near-zero waste in KV cache memory"라고 표현합니다. 둘째, 블록 단위 공유가 가능해집니다. 같은 시스템 프롬프트를 쓰는 요청들이 접두사 블록을 물리적으로 공유하고, 이것이 프리픽스 캐싱입니다. 논문은 이 두 가지로 FasterTransformer와 Orca 대비 같은 지연에서 처리량 2배에서 4배를 보고했습니다(Kwon et al., SOSP 2023).

v0.26.0에서 이 로직이 사는 곳은 vllm/v1/core/ 아래입니다. kv_cache_manager.py가 요청별 블록 할당을, block_pool.py가 블록 풀과 해시 기반 재사용을, kv_cache_coordinator.py가 여러 종류의 캐시를 함께 쓰는 모델(하이브리드 어텐션)의 조율을 담당합니다.

실무적 함의는 이렇습니다. 프리픽스 캐싱은 시스템 프롬프트가 길고 공유될 때만 이깁니다. 매 요청의 접두사가 다르면 해시 계산과 블록 관리 비용만 남습니다. 켜기 전과 후를 재 보는 것이 유일하게 옳은 판단 방법입니다.

연속 배칭 스케줄러가 실제로 결정하는 것

vllm/v1/core/sched/scheduler.pyschedule() 메서드가 매 엔진 스텝마다 하는 일입니다. 소스 첫머리의 주석이 설계를 정확히 요약합니다.

There's no "decoding phase" nor "prefill phase" in the scheduler. Each request just has the num_computed_tokens and num_tokens_with_spec. At each step, the scheduler tries to assign tokens to the requests so that each request's num_computed_tokens can catch up its num_tokens_with_spec.

이 문장이 V1 스케줄러를 이해하는 열쇠입니다. 스케줄러는 프리필과 디코드를 구분하지 않습니다. 요청마다 "지금까지 계산된 토큰 수"와 "계산되어야 할 토큰 수"만 들고 있고, 매 스텝 전자가 후자를 따라잡도록 토큰을 배분합니다. 이 한 가지 추상화로 청크드 프리필, 프리픽스 캐싱, 추측 디코딩이 전부 특수 케이스 없이 표현됩니다.

실제 루프의 뼈대는 이렇습니다.

# vllm/v1/core/sched/scheduler.py 의 구조를 요약한 것 (실제 코드 아님)
def schedule(self):
    token_budget = self.max_num_scheduled_tokens   # = max_num_batched_tokens

    # 1) 먼저 RUNNING 요청부터. 즉 디코드가 우선권을 가진다.
    for request in self.running:
        if token_budget <= 0:
            break
        num_new = min(need(request), token_budget)
        if not kv_cache_has_room(request, num_new):
            preempt(self.running.pop())     # 뒤에서부터 물린다
            continue
        schedule_tokens(request, num_new)
        token_budget -= num_new

    # 2) 남은 예산으로 WAITING 요청을 붙인다. 즉 프리필은 나중이다.
    while self.waiting and token_budget > 0:
        if len(self.running) >= self.max_num_running_reqs:   # = max_num_seqs
            break
        request = self.waiting.peek()
        num_new = min(need(request), token_budget)
        # 다 못 넣으면 잘라서 넣는다 → 이것이 청크드 프리필이다
        schedule_tokens(request, num_new)
        token_budget -= num_new

여기서 우리가 설정으로 건드리는 값들이 정확히 어디에 꽂히는지 보입니다.

  • max_num_batched_tokenstoken_budget의 초기값입니다. 한 스텝의 총 일감입니다.
  • max_num_seqsmax_num_running_reqs입니다. 실행 큐의 길이 상한입니다.
  • long_prefill_token_threshold는 프리필 요청 하나가 한 스텝에 가져갈 수 있는 토큰의 상한입니다. 긴 프롬프트 하나가 예산을 독식해 다른 요청을 굶기는 것을 막습니다.

디코드가 먼저 배분받는다는 점이 중요합니다. 이미 응답을 스트리밍 중인 사용자를 먼저 챙기고, 남는 예산으로 새 요청의 프리필을 시작하는 정책입니다. 그래서 부하가 올라가면 TTFT부터 나빠지고 ITL은 상대적으로 버팁니다.

프리필과 디코드는 성격이 다른 작업이다

두 단계가 하드웨어를 쓰는 방식이 정반대입니다.

프리필디코드
한 번에 처리하는 토큰프롬프트 전체 (수천 개)요청당 1개
산술 강도높음. 큰 행렬곱매우 낮음
병목연산 (텐서 코어)메모리 대역폭
관련 지표TTFTTPOT, ITL
배치를 키우면이미 포화, 이득 작음가중치 읽기를 공유해 이득 큼

디코드가 메모리 바운드인 이유는 단순합니다. 토큰 하나를 만들려고 모델 가중치 전체를 한 번 읽습니다. 배치가 1이면 그 읽기로 토큰 1개를 얻고, 배치가 64면 같은 읽기로 64개를 얻습니다. 그래서 디코드 처리량은 배치 크기에 거의 비례해 올라가다가 대역폭 벽에 붙습니다.

문제는 두 단계를 따로 돌리면 양쪽 다 손해라는 것입니다. 프리필만 도는 스텝에서는 텐서 코어가 포화되고 메모리 파이프가 놉니다. 디코드만 도는 스텝에서는 반대입니다.

청크드 프리필이 이 문제를 정면으로 다룹니다. 긴 프리필을 잘라서 디코드 요청과 같은 배치에 섞습니다. 연산 바운드 작업과 메모리 바운드 작업이 한 배치에 공존하니 두 자원이 동시에 쓰입니다. V1에서는 가능한 한 언제나 기본으로 켜져 있습니다.

여기서 트레이드오프가 명시적으로 드러납니다. 청크를 작게 하면 디코드가 덜 방해받아 ITL이 좋아지고, 프리필이 여러 스텝에 걸쳐 나뉘므로 TTFT가 나빠집니다. 청크를 크게 하면 반대입니다. 어느 쪽이 옳은지는 서비스가 정하는 것이지 vLLM이 정하는 것이 아닙니다.

벤치마킹 — 무엇을 고정하고 무엇을 재는가

여기가 이 글에서 가장 중요한 절입니다. 앞의 모든 튜닝은 측정이 정직할 때만 의미가 있습니다.

재는 값의 정확한 정의

vLLM의 벤치마크 도구가 출력하는 지표는 소스에 정의가 박혀 있습니다.

지표정의누가 신경 쓰나
TTFTTime to First Token. 요청 전송부터 첫 토큰까지사용자 체감 반응성
TPOTTime per Output Token, 첫 토큰 제외 평균스트리밍 체감 속도
ITLInter-token Latency. 연속 토큰 사이 간격의 분포끊김. 평균이 아니라 꼬리를 봐야 함
E2ELEnd-to-end Latency. 요청 전체 소요 시간배치 작업
Output throughput초당 출력 토큰 수비용

TPOT와 ITL을 구분해서 쓰는 것이 중요합니다. TPOT는 요청 하나의 평균이고 ITL은 간격 하나하나의 분포입니다. 평균 TPOT가 20ms인데 ITL의 p99가 400ms이면, 사용자는 "가끔 멈칫한다"고 느낍니다. 평균만 보면 이 현상이 보이지 않습니다.

실제 명령

# 1) 서버를 띄운다. 튜닝 대상 인자를 여기서 고정한다.
vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --max-num-batched-tokens 8192 \
  --max-num-seqs 256 \
  --gpu-memory-utilization 0.90 &

# 2) 부하를 건다. 요청률을 바꿔 가며 여러 점을 찍는다.
vllm bench serve \
  --backend vllm \
  --model meta-llama/Llama-3.1-8B-Instruct \
  --dataset-name sharegpt --dataset-path sharegpt.json \
  --num-prompts 500 \
  --request-rate 8 \
  --percentile-metrics ttft,tpot,itl,e2el \
  --metric-percentiles 50,90,99 \
  --save-result --result-filename rate8.json

# 3) 오프라인 처리량 상한이 궁금하면 이쪽
vllm bench throughput --model meta-llama/Llama-3.1-8B-Instruct \
  --dataset-name sharegpt --dataset-path sharegpt.json --num-prompts 1000

고정해야 할 것들

측정 하나를 믿으려면 다음이 전부 고정되어야 합니다. 하나라도 흔들리면 비교가 무의미해집니다.

  • 입력과 출력 길이 분포. 같은 데이터셋, 같은 시드. 합성 데이터를 쓴다면 입력 길이와 출력 길이를 명시적으로 고정합니다. 길이 분포가 다르면 처리량은 얼마든지 달라집니다.
  • 요청 도착률. 무한 부하로 재면 처리량 상한은 나오지만 지연은 무의미해집니다(큐 대기가 전부입니다). 요청률을 바꿔 가며 곡선을 그리는 것이 유일하게 쓸모 있는 형태입니다. 점 하나로는 아무것도 말할 수 없습니다.
  • 웜업. 첫 요청들에는 컴파일, CUDA 그래프 캡처, 캐시 채우기가 섞입니다. 통계에서 빼야 합니다.
  • 프리픽스 캐싱 상태. 켜 두고 같은 프롬프트를 반복하면 두 번째부터 TTFT가 극적으로 좋아집니다. 이것을 최적화 성과로 보고하면 거짓말이 됩니다. 캐시를 비우고 재든지, 캐시가 있는 정상 상태를 재든지 한쪽으로 정해야 합니다.
  • GPU 클럭과 이웃. 공유 장비라면 같은 시간대에 다른 작업이 없어야 합니다. 전력 제한에 걸리면 클럭이 내려갑니다.
  • 버전. vLLM, PyTorch, 드라이버, 이미지 태그를 결과와 함께 기록합니다.

곡선으로 읽기

한 지점의 숫자 대신 요청률을 5, 10, 15, 20으로 올려 가며 재면 이런 형태가 나옵니다.

   p99 TTFT
      ^
      |                                    ╱  ← 여기서 큐가 쌓이기 시작
      |                                  ╱
      |                          ______╱
      |     ____________________╱
      +--------------------------------------> 요청률(req/s)
                              무릎(knee)

  운영 지점은 무릎의 왼쪽에 잡는다.
  무릎 오른쪽은 "처리량은 나오지만 지연은 통제 불가"인 구간이다.

튜닝의 목표는 최대 처리량이 아니라 SLO를 만족하는 최대 처리량입니다. p99 TTFT 500ms 이하라는 조건이 있다면, 그 조건을 지키면서 낼 수 있는 요청률이 지표입니다. max_num_batched_tokens를 바꿔 가며 이 곡선을 여러 개 그리면 어느 값이 우리 서비스에 맞는지가 눈으로 보입니다.

병목을 특정하고 싶을 때

숫자가 나빴는데 이유를 모르겠으면 프로파일러입니다. 다만 문서가 경고를 앞세웁니다. 프로파일링은 개발자용이고 추론을 크게 느리게 하므로 최종 사용자는 켜면 안 됩니다. 저오버헤드가 필요하면 Nsight Systems, 스택과 텐서 모양까지 필요하면 PyTorch 프로파일러를 씁니다.

# 서버에 프로파일러를 붙여 띄운다 (--profiler-config는 v0.13.0 이상)
vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --profiler-config '{"profiler": "torch", "torch_profiler_dir": "./vllm_profile"}'

# 구간을 잘라서 수집
curl -X POST http://localhost:8000/start_profile
# ... 요청 몇 개만 보낸다. 트레이스가 매우 커진다 ...
curl -X POST http://localhost:8000/stop_profile

# 벤치마크와 함께 쓸 수도 있다
vllm bench serve --backend vllm --model ... --profile --num-prompts 2

수집된 트레이스는 Perfetto UI에서 봅니다. 요청 수를 적게 유지하는 것이 중요합니다. 문서는 70B급 모델에서 100요청 분량을 내리는 데 H100에서 10분쯤 걸린다고 적어 두었습니다.

실제로 코드를 건드릴 만한 지점

여기까지 다 했는데도 부족하다면, 그때 소스입니다. 현실적으로 손댈 값어치가 있는 곳은 세 군데입니다.

1. 커스텀 로짓 프로세서 — 가장 안전한 지점

권장하는 이유는 소스를 수정하지 않고 플러그인으로 붙는다는 점입니다. vLLM을 포크하지 않아도 됩니다. 로짓 프로세서는 배치 단위로 동작해서, 요청 수 곱하기 어휘 크기 모양의 로짓 텐서를 받아 변형한 뒤 softmax로 넘깁니다.

vllm.v1.sample.logits_processor.LogitsProcessor를 상속하고 다섯 개를 구현합니다.

# my_pkg/procs.py
import torch
from vllm.config import VllmConfig
from vllm.sampling_params import SamplingParams
from vllm.v1.sample.logits_processor import BatchUpdate, LogitsProcessor


class BanTokenAfterN(LogitsProcessor):
    """출력이 N개를 넘어가면 특정 토큰을 금지한다 (예시)."""

    @classmethod
    def validate_params(cls, params: SamplingParams):
        # 잘못된 인자를 엔트리포인트에서 미리 거른다. 구현하지 않으면
        # 이상한 값이 그대로 커널까지 흘러 들어간다.
        v = params.extra_args and params.extra_args.get("ban_after")
        if v is not None and not isinstance(v, int):
            raise ValueError("ban_after must be int")

    def __init__(self, vllm_config: VllmConfig, device: torch.device,
                 is_pin_memory: bool):
        self.device = device
        # 배치 인덱스 -> (금지 토큰, 임계값, 출력 토큰 리스트 참조)
        self.req: dict[int, tuple[int, int, list[int]]] = {}

    def is_argmax_invariant(self) -> bool:
        # 최댓값 토큰을 바꿀 수 있으므로 False.
        # True로 두면 배치 전체가 그리디일 때 vLLM이 이 프로세서를 건너뛴다.
        return False

    def update_state(self, batch_update: BatchUpdate | None) -> None:
        if batch_update is None:
            return
        # 반드시 removed -> added -> moved 순서로 처리해야 한다.
        for idx in batch_update.removed:
            self.req.pop(idx, None)
        for idx, params, _prompt_ids, output_ids in batch_update.added:
            self.validate_params(params)
            n = params.extra_args and params.extra_args.get("ban_after")
            if n is None:
                self.req.pop(idx, None)
            else:
                # output_ids는 살아 있는 리스트 참조라서
                # 매 스텝 최신 출력이 그대로 보인다.
                self.req[idx] = (params.extra_args["ban_token"], n, output_ids)
        for a, b, direction in batch_update.moved:
            va, vb = self.req.pop(a, None), self.req.pop(b, None)
            if vb is not None:
                self.req[a] = vb
            if va is not None and direction.name == "SWAP":
                self.req[b] = va

    def apply(self, logits: torch.Tensor) -> torch.Tensor:
        for idx, (tok, n, out_ids) in self.req.items():
            if len(out_ids) >= n:
                logits[idx, tok] = float("-inf")   # in-place가 메모리에 유리
        return logits

붙이는 방법은 두 가지입니다. 완전 수식 클래스 이름을 넘기거나, 패키지 엔트리포인트로 등록합니다.

vllm serve facebook/opt-125m --logits_processors my_pkg.procs:BanTokenAfterN
# pyproject.toml — 설치만 하면 자동으로 로드된다
[project.entry-points."vllm.logits_processors"]
ban_after = "my_pkg.procs:BanTokenAfterN"

주의할 점 세 가지입니다.

  • 로짓 프로세서 집합은 엔진 초기화 시점에 고정됩니다. 요청별로 나중에 추가할 수 없습니다. 요청마다 켜고 끄는 것은 SamplingParams.extra_args로 판단해 apply 안에서 처리하는 방식뿐입니다.
  • is_argmax_invariant()를 정직하게 답해야 합니다. 참으로 두면 배치가 전부 그리디일 때 통째로 건너뛰어 공짜로 빨라지지만, 실제로는 최댓값을 바꾸는 프로세서에 참을 반환하면 조용히 틀린 결과가 나옵니다.
  • apply는 매 스텝, 배치 전체에 대해 돕니다. 파이썬 루프를 요청 수만큼 돌면 그 자체가 병목이 됩니다. 텐서 연산으로 벡터화할 수 있으면 그렇게 하십시오.
  • 문서 자체가 이 API에 대해 "design changes are still in progress and the API may change in the near future"라고 명시합니다. 버전 고정을 전제로 쓰는 편이 안전합니다.

2. 커스텀 어텐션 백엔드 — 값어치는 크고 비용도 크다

vllm/v1/attention/backends/ 아래에 백엔드들이 있고, 공통 인터페이스는 vllm/v1/attention/backend.py에 있습니다. v0.26.0에는 flash_attn.py, flashinfer.py, triton_attn, flex_attention.py와 MLA 전용 구현들이 들어 있습니다.

직접 만들 이유가 되는 상황은 좁습니다. 표준 어텐션의 변형이 필요한데 기존 백엔드에 없고, 그 변형이 성능에 결정적일 때입니다. 예를 들어 도메인 특유의 희소 마스크가 있어서 전체 어텐션의 10퍼센트만 계산해도 되는 경우입니다.

비용은 정직하게 봐야 합니다. 백엔드는 메타데이터 빌더, CUDA 그래프 호환성, 프리필과 디코드 경로, 청크드 프리필과의 상호작용, 프리픽스 캐싱과의 상호작용을 전부 맞춰야 합니다. 커널만 쓰면 끝나는 일이 아닙니다. 그리고 이 인터페이스는 vLLM 릴리스마다 바뀝니다.

3. 스케줄러 정책 — 가장 마지막 수단

vllm/v1/core/sched/ 아래에 scheduler.py, interface.py, request_queue.py가 있습니다. 요청 우선순위를 도메인 규칙으로 바꾸고 싶을 때(예: 유료 티어 우선, 짧은 요청 우선) 손대게 됩니다.

다만 순서가 있습니다. vLLM은 이미 요청 우선순위 기능을 제공하므로, 그것으로 표현되는지 먼저 확인해야 합니다. 스케줄러 수정은 위험도가 가장 높습니다. 앞서 본 대로 이 코드가 토큰 예산, 선점, 청크드 프리필, 프리픽스 캐싱, 추측 디코딩과 전부 얽혀 있습니다.

업스트림을 따라가는 비용

세 지점 모두에 공통되는 대가입니다. 포크한 vLLM은 자동으로 낡습니다.

vLLM은 2주에 한 번꼴로 마이너 릴리스가 나옵니다. 그 사이에 새 모델 지원, 새 커널, 새 양자화 포맷, 성능 개선이 들어옵니다. 포크를 6개월 방치하면 최신 모델을 못 올리고, 최신 어텐션 커널을 못 쓰고, 그 사이 고쳐진 버그를 그대로 안고 있게 됩니다. 그때쯤이면 리베이스 비용이 처음 수정 비용의 몇 배가 되어 있습니다.

그래서 비용 순서는 이렇습니다.

방법업스트림 추적 비용언제
설정 인자만 조정없음항상 먼저
플러그인 (로짓 프로세서 등)낮음. API 변경 시에만대부분의 커스터마이징
업스트림에 PR을 보내 병합리뷰 시간. 이후 0일반적으로 쓸모 있는 기능이면 최선
포크 후 패치 유지높음. 릴리스마다 리베이스정말 다른 방법이 없을 때

세 번째 줄을 강조하고 싶습니다. 우리가 필요한 기능이 다른 사람에게도 쓸모 있다면, 업스트림에 보내는 것이 가장 싼 유지 전략입니다. 병합되는 순간 유지 비용이 0이 됩니다.

마치며 — 측정 없는 튜닝은 취향의 표명일 뿐이다

이 글에서 다룬 것들의 순서가 곧 결론입니다. 선점 로그를 없애고, 토큰 예산으로 TTFT와 ITL을 저울질하고, CPU를 굶기지 않고, 프리픽스 캐싱이 실제로 이기는지 재 봅니다. 그다음에야 PagedAttention과 스케줄러의 동작을 이해할 필요가 생기고, 그것을 이해하고 나서야 코드를 어디에 대야 할지 알게 됩니다.

그리고 이 순서의 어느 단계에서도, 앞뒤를 재지 않고 넘어가면 안 됩니다. max_num_batched_tokens를 8192에서 16384로 올렸을 때 처리량이 12퍼센트 늘고 p99 TTFT가 2배가 되었다면, 그것이 개선인지 개악인지는 서비스의 SLO만이 답할 수 있습니다. 숫자 없이 이 판단을 하는 것은 튜닝이 아니라 취향의 표명입니다.

마지막으로 한 줄. vLLM에서 가장 많이 쓰이는 최적화 기법은 아직도 "배치를 키우는 것"이고, 대부분의 팀은 그 여지를 다 쓰기 전에 소스를 열려고 합니다. 여섯 줄을 먼저 확인하십시오.

참고 자료

Making vLLM Fast — Configuration, Internals, and Where to Actually Touch the Code

Introduction — Six Lines to Check Before You Open the Code

When someone asks "vLLM feels slow, should I patch the source," I always check the same things first: whether preemption warnings are showing up in the logs, what max_num_batched_tokens is set to, whether prefix caching is on, what the GPU memory utilization ratio is, how many physical cores there are, and exactly what you're measuring right now.

Most of the time, the answer is in these six lines. Situations that actually require touching the source are rarer than you'd think, and even when you do need to, there are only a handful of places worth touching.

This post follows that order. The reference is vLLM v0.26.0 (PyPI release on July 25, 2026, Python 3.10 up to but not including 3.15), and the internals sections were written from directly reading that tag's source. vLLM ships a minor version roughly every two weeks, so the file paths and argument names in this post can drift out of date within a few months. Get in the habit of checking against your own version's source.

One thing up front: the V0 engine is fully deprecated. The official docs state "We have fully deprecated V0" and point to RFC #18571. Most V0-era tuning posts still floating around the internet no longer apply.

First, What You Can Fix Without Touching Code

Ordered by priority. The higher up the list, the bigger the payoff and the cheaper the cost.

KnobWhat it changesWhen to touch it
gpu_memory_utilizationFraction of memory used for the KV cacheWhen you see preemption warnings. Raise it from the default
max_num_batched_tokensTotal token budget processed per stepWhen deciding whether to favor TTFT or ITL
max_num_seqsCap on how many requests run concurrentlyWhen memory is tight or batches are shallow
Prefix cachingReuse of KV for a shared prefixWhen system prompts are long and shared
QuantizationBytes for weights and the KV cacheWhen decode is bandwidth-bound
tensor_parallel_sizeHow far weights are sharded across GPUsWhen the model doesn't fit or KV space is short
Attention backendWhich kernel gets usedWhen auto-selection isn't optimal
Optimization level -O0 through -O3Trades startup time for steady-state performanceDev loop versus production

Eliminate preemption first

The single most common root cause. When the KV cache runs short, vLLM preempts a running request and recomputes it later. If a line like this repeats in the logs, no other tuning matters yet.

WARNING ... Sequence group 0 is preempted by PreemptionMode.RECOMPUTE mode
because there is not enough KV cache space. This can affect the end-to-end
performance. Increase gpu_memory_utilization or tensor_parallel_size ...

V1's default preemption mode is recompute, not swap. The preempted request's prefill is redone from scratch. So frequent preemption collapses both throughput and latency together. The docs spell out the fix directly: raise gpu_memory_utilization, lower max_num_seqs and max_num_batched_tokens, or raise tensor_parallel_size to increase KV space per GPU.

Trading off TTFT against ITL with the token budget

max_num_batched_tokens is the total number of tokens that can be scheduled in one step. The official docs state the direction clearly.

  • A smaller value (e.g. 2048) improves ITL. The prefill chunk that delays decoding gets smaller.
  • A larger value improves TTFT. You can push more prefill tokens into a single batch.
  • If throughput is the goal, the docs recommend going above 8192 — especially when a small model sits on a large GPU.

There's one trap to watch for. With chunked prefill turned off, max_num_batched_tokens must be larger than max_model_len, or the server can die at startup.

from vllm import LLM

# Conversational service: prioritize per-token latency
llm = LLM(model="meta-llama/Llama-3.1-8B-Instruct", max_num_batched_tokens=2048)

# Batch processing: prioritize throughput
llm = LLM(model="meta-llama/Llama-3.1-8B-Instruct", max_num_batched_tokens=16384)

Startup time is performance too

If you repeatedly spin up the same model with the same settings, the docs offer three levers.

  • Reuse the compilation cache. torch.compile artifacts are stored under VLLM_CACHE_ROOT (a cache directory under home by default), and you can bake this directory into your container image or copy it between machines. Setting VLLM_FORCE_AOT_LOAD=1 makes a cache miss fail explicitly instead of silently recompiling. The cache is invalidated if any of the model, config, related environment variables, torch build, or GPU model changes.
  • Skip memory profiling with --kv-cache-memory. On startup, vLLM logs a value that reproduces the current allocation. Passing that value on the next boot skips the measurement step. There's a cost, though: the KV cache is now pinned to the specified value instead of the measured one, so setting it conservatively cuts concurrency and setting it optimistically causes allocation failures. Valid only for the same GPU with the same initial free memory.
  • --enforce-eager. Skips both compilation and CUDA graph capture entirely. Startup is fastest, and steady-state decode performance is worse. This is for the dev loop, and also useful for measuring how much of boot time is spent on compilation.

Don't starve the CPU

An easily overlooked item worth calling out separately. vLLM V1 is a multi-process architecture. With N GPUs, you get one API server, one engine core, and N GPU workers — at least N plus 2 processes competing for CPU.

The docs nail the minimum down to physical cores. If hyperthreading is on, one vCPU is half a physical core, so you need twice the vCPUs. The engine-core process in particular is sensitive to CPU starvation because it runs a busy-wait loop. If GPU utilization is inexplicably low in a virtualized environment, this is the first thing to suspect.

The attention backend defaults to auto-selection

vLLM looks at the GPU architecture, the model, and the config, then picks the first compatible backend from a priority list. As of v0.26.0, the priority order for standard attention is this.

Architecture1st2nd3rd4th5th
Blackwell (SM 10.x)FLASHINFERFLASH_ATTNTRITON_ATTNFLEX_ATTENTIONTURBOQUANT
Ampere / Hopper (SM 8.x–9.x)FLASH_ATTNFLASHINFERTRITON_ATTNFLEX_ATTENTIONTURBOQUANT

To override it manually, do this. Specifying an incompatible backend raises an error with the reason attached.

vllm serve Qwen/Qwen3-8B --attention-backend FLASH_ATTN
# or via structured config
vllm serve Qwen/Qwen3-8B -ac.backend FLASH_ATTN

PagedAttention — The Idea That Eliminates Fragmentation

From here on we're in internals territory. The observation vLLM started from is simple: if you allocate the KV cache as one big contiguous chunk per request, most of that memory is wasted.

The waste comes in three flavors: internal reservation, allocated ahead of time in case a request runs to its max length; over-allocation, set aside but never actually used before the request ends; and external fragmentation, created by returning and re-acquiring variously sized chunks.

The fix is lifted straight from OS virtual memory. Cut the KV cache into fixed-size blocks, and keep a block table that maps a logically contiguous sequence onto physically scattered blocks.

Request A's logical KV:  [t0 t1 t2 t3] [t4 t5 t6 t7] [t8 t9 __ __]
                              │              │              │
Block table A         →   block 7        block 3        block 12

Request B's logical KV:  [t0 t1 t2 t3] [t4 t5 __ __]
                              │              │
Block table B         →   block 7        block 5
                    shared prefix means sharing the same block (prefix caching)

Two results follow. First, there's no waste except in the leftover space of the last block. The original paper describes this as "near-zero waste in KV cache memory." Second, block-level sharing becomes possible. Requests using the same system prompt physically share the prefix blocks, and this is prefix caching. The paper reports, from these two effects, 2x to 4x throughput at the same latency compared to FasterTransformer and Orca (Kwon et al., SOSP 2023).

In v0.26.0 this logic lives under vllm/v1/core/. kv_cache_manager.py handles per-request block allocation, block_pool.py handles the block pool and hash-based reuse, and kv_cache_coordinator.py coordinates models that use several kinds of cache together (hybrid attention).

The practical implication is this: prefix caching only wins when the system prompt is long and shared. If each request's prefix differs, all you're left with is the cost of hash computation and block management. Measuring before and after turning it on is the only correct way to judge.

What the Continuous-Batching Scheduler Actually Decides

This is what the schedule() method in vllm/v1/core/sched/scheduler.py does on every engine step. The comment at the top of the source captures the design exactly.

There's no "decoding phase" nor "prefill phase" in the scheduler. Each request just has the num_computed_tokens and num_tokens_with_spec. At each step, the scheduler tries to assign tokens to the requests so that each request's num_computed_tokens can catch up its num_tokens_with_spec.

This sentence is the key to understanding the V1 scheduler. The scheduler doesn't distinguish prefill from decode. Each request holds only "tokens computed so far" and "tokens that need to be computed," and every step allocates tokens so the former catches up to the latter. This single abstraction expresses chunked prefill, prefix caching, and speculative decoding all without any special-cased logic.

Here's the skeleton of the actual loop.

# A summary of the structure of vllm/v1/core/sched/scheduler.py (not the real code)
def schedule(self):
    token_budget = self.max_num_scheduled_tokens   # = max_num_batched_tokens

    # 1) RUNNING requests get served first. Decode has priority.
    for request in self.running:
        if token_budget <= 0:
            break
        num_new = min(need(request), token_budget)
        if not kv_cache_has_room(request, num_new):
            preempt(self.running.pop())     # preempt from the back
            continue
        schedule_tokens(request, num_new)
        token_budget -= num_new

    # 2) Attach WAITING requests with whatever budget remains. Prefill comes later.
    while self.waiting and token_budget > 0:
        if len(self.running) >= self.max_num_running_reqs:   # = max_num_seqs
            break
        request = self.waiting.peek()
        num_new = min(need(request), token_budget)
        # If it doesn't all fit, slice it in → this is chunked prefill
        schedule_tokens(request, num_new)
        token_budget -= num_new

You can see exactly where the values we set as config plug in.

  • max_num_batched_tokens is the initial value of token_budget. The total work for one step.
  • max_num_seqs is max_num_running_reqs. The cap on the length of the running queue.
  • long_prefill_token_threshold is the cap on how many tokens a single prefill request can take in one step. It stops one long prompt from monopolizing the budget and starving other requests.

The important part is that decode gets allocated first. The policy is to take care of users already streaming a response first, then start new requests' prefill with whatever budget remains. So under load, TTFT degrades first and ITL holds up comparatively better.

Prefill and Decode Are Fundamentally Different Kinds of Work

The two phases use hardware in opposite ways.

AxisPrefillDecode
Tokens processed at onceThe whole prompt (thousands)1 per request
Arithmetic intensityHigh. Large matmulsVery low
BottleneckCompute (tensor cores)Memory bandwidth
Related metricTTFTTPOT, ITL
Effect of bigger batchesAlready saturated, small gainBig gain from sharing weight reads

The reason decode is memory-bound is simple: producing one token requires reading the entire model's weights once. With batch size 1, that read gets you 1 token; with batch size 64, the same read gets you 64. So decode throughput scales almost linearly with batch size, up until it hits the bandwidth wall.

The problem is that running the two phases separately loses on both sides. On a prefill-only step, the tensor cores saturate while the memory pipe idles. On a decode-only step, it's the opposite.

Chunked prefill addresses this head-on. A long prefill gets sliced and mixed into the same batch as decode requests. With a compute-bound task and a memory-bound task coexisting in one batch, both resources get used at the same time. In V1 it's on by default whenever possible.

The trade-off here is explicit. Smaller chunks disturb decode less, so ITL improves, but prefill gets spread across more steps, so TTFT gets worse. Larger chunks do the opposite. Which side is right is for the service to decide, not vLLM.

Benchmarking — What to Hold Fixed, and What to Measure

This is the most important section in this post. Everything above only means something if the measurement is honest.

The exact definitions of what you're measuring

The metrics vLLM's benchmark tool prints have their definitions pinned in the source.

MetricDefinitionWho cares
TTFTTime to First Token. From request submission to the first tokenUser-perceived responsiveness
TPOTTime per Output Token, average excluding the first tokenPerceived streaming speed
ITLInter-token Latency. The distribution of gaps between consecutive tokensStutter. Look at the tail, not the mean
E2ELEnd-to-end Latency. Total time for the whole requestBatch jobs
Output throughputOutput tokens per secondCost

Distinguishing TPOT from ITL matters. TPOT is the average for one request, and ITL is the distribution of individual gaps. If mean TPOT is 20ms but p99 ITL is 400ms, users experience "it stutters sometimes." Looking only at the mean, this doesn't show up at all.

The actual commands

# 1) Start the server. Pin the arguments you're tuning here.
vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --max-num-batched-tokens 8192 \
  --max-num-seqs 256 \
  --gpu-memory-utilization 0.90 &

# 2) Apply load. Vary the request rate to plot several points.
vllm bench serve \
  --backend vllm \
  --model meta-llama/Llama-3.1-8B-Instruct \
  --dataset-name sharegpt --dataset-path sharegpt.json \
  --num-prompts 500 \
  --request-rate 8 \
  --percentile-metrics ttft,tpot,itl,e2el \
  --metric-percentiles 50,90,99 \
  --save-result --result-filename rate8.json

# 3) If you want the offline throughput ceiling, use this instead
vllm bench throughput --model meta-llama/Llama-3.1-8B-Instruct \
  --dataset-name sharegpt --dataset-path sharegpt.json --num-prompts 1000

What must be held fixed

For a single measurement to be trustworthy, all of the following must be held fixed. If even one shifts, the comparison becomes meaningless.

  • Input and output length distributions. Same dataset, same seed. If you're using synthetic data, pin input and output lengths explicitly. Throughput can vary arbitrarily if the length distribution differs.
  • Request arrival rate. Measuring under infinite load gives you the throughput ceiling but makes latency meaningless (it's all queue wait). Plotting a curve by varying the request rate is the only useful form. A single point tells you nothing.
  • Warmup. The first requests get compilation, CUDA graph capture, and cache warming mixed in. Exclude them from the stats.
  • Prefix cache state. If it's on and you repeat the same prompt, TTFT improves dramatically from the second request onward. Reporting this as an optimization result is a lie. Either flush the cache and measure again, or measure the steady state with the cache warm — pick one and stick to it.
  • GPU clocks and neighbors. On shared hardware, make sure nothing else runs in the same window. Hitting a power limit drops clocks.
  • Version. Record vLLM, PyTorch, driver, and image tag alongside the results.

Reading it as a curve

Instead of one number, measure while raising the request rate through 5, 10, 15, 20, and you get a shape like this.

   p99 TTFT
      ^
      |                                    ╱  ← the queue starts building up here
      |                                  ╱
      |                          ______╱
      |     ____________________╱
      +--------------------------------------> request rate (req/s)
                              the knee

  Set your operating point to the left of the knee.
  To the right of the knee is a region of "throughput exists,
  but latency is out of control."

The goal of tuning isn't maximum throughput, it's maximum throughput that satisfies your SLO. If your condition is p99 TTFT under 500ms, the metric is the request rate you can sustain while holding that condition. Plot several of these curves while varying max_num_batched_tokens, and you can see with your own eyes which value fits your service.

When you want to pin down the bottleneck

If the numbers are bad and you don't know why, use a profiler. But the docs lead with a warning: profiling is for developers, it slows inference down significantly, so end users should never turn it on. If you need low overhead, use Nsight Systems; if you need stack traces and tensor shapes too, use the PyTorch profiler.

# Attach a profiler to the server on startup (--profiler-config is v0.13.0+)
vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --profiler-config '{"profiler": "torch", "torch_profiler_dir": "./vllm_profile"}'

# Collect over a bounded window
curl -X POST http://localhost:8000/start_profile
# ... send only a few requests. The trace gets very large ...
curl -X POST http://localhost:8000/stop_profile

# Can also be used together with a benchmark
vllm bench serve --backend vllm --model ... --profile --num-prompts 2

Collected traces are viewed in the Perfetto UI. Keeping the request count low matters. The docs note that dumping 100 requests' worth of trace on a 70B-class model takes about 10 minutes on an H100.

Places Actually Worth Touching the Code

If you've done all of the above and it still isn't enough, that's when you go to the source. There are realistically three places worth the effort.

1. Custom logits processors — the safest place to touch

The reason to recommend this is that it attaches as a plugin without modifying the source. You don't need to fork vLLM. Logits processors operate at the batch level: they take a logits tensor shaped [number of requests x vocab size], transform it, and hand it off to softmax.

Subclass vllm.v1.sample.logits_processor.LogitsProcessor and implement five methods.

# my_pkg/procs.py
import torch
from vllm.config import VllmConfig
from vllm.sampling_params import SamplingParams
from vllm.v1.sample.logits_processor import BatchUpdate, LogitsProcessor


class BanTokenAfterN(LogitsProcessor):
    """Bans a specific token once output has passed N tokens (example)."""

    @classmethod
    def validate_params(cls, params: SamplingParams):
        # Filter out bad arguments at the entrypoint ahead of time. If you
        # don't implement this, a bad value just flows straight into the kernel.
        v = params.extra_args and params.extra_args.get("ban_after")
        if v is not None and not isinstance(v, int):
            raise ValueError("ban_after must be int")

    def __init__(self, vllm_config: VllmConfig, device: torch.device,
                 is_pin_memory: bool):
        self.device = device
        # batch index -> (banned token, threshold, reference to the output token list)
        self.req: dict[int, tuple[int, int, list[int]]] = {}

    def is_argmax_invariant(self) -> bool:
        # Can change the argmax token, so False.
        # If set to True, vLLM skips this processor entirely whenever the
        # whole batch is doing greedy sampling.
        return False

    def update_state(self, batch_update: BatchUpdate | None) -> None:
        if batch_update is None:
            return
        # Must be processed in the order removed -> added -> moved.
        for idx in batch_update.removed:
            self.req.pop(idx, None)
        for idx, params, _prompt_ids, output_ids in batch_update.added:
            self.validate_params(params)
            n = params.extra_args and params.extra_args.get("ban_after")
            if n is None:
                self.req.pop(idx, None)
            else:
                # output_ids is a live list reference, so it always
                # reflects the latest output at every step.
                self.req[idx] = (params.extra_args["ban_token"], n, output_ids)
        for a, b, direction in batch_update.moved:
            va, vb = self.req.pop(a, None), self.req.pop(b, None)
            if vb is not None:
                self.req[a] = vb
            if va is not None and direction.name == "SWAP":
                self.req[b] = va

    def apply(self, logits: torch.Tensor) -> torch.Tensor:
        for idx, (tok, n, out_ids) in self.req.items():
            if len(out_ids) >= n:
                logits[idx, tok] = float("-inf")   # in-place is memory-friendlier
        return logits

There are two ways to attach it: pass the fully qualified class name, or register it as a package entrypoint.

vllm serve facebook/opt-125m --logits_processors my_pkg.procs:BanTokenAfterN
# pyproject.toml — loads automatically once installed
[project.entry-points."vllm.logits_processors"]
ban_after = "my_pkg.procs:BanTokenAfterN"

Three things to watch out for.

  • The set of logits processors is fixed at engine initialization. You can't add one per request afterward. The only way to toggle one on or off per request is to branch on SamplingParams.extra_args inside apply.
  • Answer is_argmax_invariant() honestly. Setting it to True lets the batch skip your processor entirely, for free, whenever the whole batch is greedy — but returning True for a processor that actually changes the argmax silently produces wrong results.
  • apply runs on every step, over the whole batch. A Python loop over the number of requests becomes the bottleneck by itself. Vectorize with tensor ops wherever you can.
  • The docs themselves state explicitly that for this API "design changes are still in progress and the API may change in the near future." It's safer to use this while pinning your version.

2. Custom attention backends — high value, high cost

The backends live under vllm/v1/attention/backends/, and the common interface is in vllm/v1/attention/backend.py. v0.26.0 includes flash_attn.py, flashinfer.py, triton_attn, flex_attention.py, and dedicated MLA implementations.

The situations that justify writing your own are narrow: you need a variant of standard attention that isn't in any existing backend, and that variant is decisive for performance. For example, a domain-specific sparse mask where only 10 percent of full attention actually needs computing.

Be honest about the cost. A backend has to get right: the metadata builder, CUDA graph compatibility, the prefill and decode paths, interaction with chunked prefill, and interaction with prefix caching. Writing the kernel alone doesn't finish the job. And this interface changes with every vLLM release.

3. Scheduler policy — the last resort

Under vllm/v1/core/sched/ are scheduler.py, interface.py, and request_queue.py. You'll end up here when you want to replace request priority with domain rules (e.g. paid tier first, short requests first).

But there's an order to follow. vLLM already ships a request priority feature, so first check whether it can express what you need. Modifying the scheduler is the highest-risk option. As we saw above, this code is entangled with the token budget, preemption, chunked prefill, prefix caching, and speculative decoding all at once.

The cost of tracking upstream

A cost common to all three places: a forked vLLM ages automatically.

vLLM ships a minor release roughly every two weeks. New model support, new kernels, new quantization formats, and performance improvements land in between. Leave a fork unattended for six months and you can't run the newest models, can't use the newest attention kernels, and are stuck carrying bugs that were already fixed upstream. By that point, the rebase cost has become several times the cost of the original modification.

So the cost ordering looks like this.

MethodUpstream-tracking costWhen
Config arguments onlyNoneAlways try first
Plugin (logits processor, etc.)Low. Only on API changesMost customization
Send a PR upstream and get it mergedReview time. Zero after thatBest option if it's generally useful
Fork and maintain patchesHigh. Rebase on every releaseOnly when there's truly no other way

I want to underline the third row. If what you need is also useful to other people, sending it upstream is the cheapest maintenance strategy there is. The moment it merges, your maintenance cost drops to zero.

Closing — Tuning Without Measurement Is Just a Statement of Taste

The order this post covered is itself the conclusion. Eliminate preemption logs, weigh TTFT against ITL with the token budget, don't starve the CPU, and measure whether prefix caching is actually winning. Only after that do you need to understand how PagedAttention and the scheduler behave, and only once you understand that do you know where to touch the code.

And at no stage in this sequence should you skip measuring before and after. If raising max_num_batched_tokens from 8192 to 16384 grows throughput by 12 percent and doubles p99 TTFT, only your service's SLO can answer whether that's an improvement or a regression. Making that call without numbers isn't tuning, it's a statement of taste.

One last line. The most-used optimization technique in vLLM is still "make the batch bigger," and most teams try to open the source before they've used up that room. Check the six lines first.

References