Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

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

"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에서 가장 많이 쓰이는 최적화 기법은 아직도 "배치를 키우는 것"이고, 대부분의 팀은 그 여지를 다 쓰기 전에 소스를 열려고 합니다. 여섯 줄을 먼저 확인하십시오.

참고 자료

현재 단락 (1/223)

"vLLM이 느린데 소스를 고쳐야 할까요"라는 질문을 받으면, 저는 항상 같은 것부터 확인합니다. 로그에 선점 경고가 찍히고 있는지, `max_num_batched_tokens`가...

작성 글자: 0원문 글자: 13,926작성 단락: 0/223