CHAPTER 01

제 1 장: vLLM의 설계 철학과 전체 아키텍처 조감

소속 프로젝트: vllm-project/vllm · 전체 진행률: 제 1 / 14 장 · 검증 상태: FACT 행 번호 실제 앵커링

A100 한 장을 가지고 LLaMA-7B로 온라인 추론 서비스를 제공하려 한다고 가정하자. 가장 단순한 방법은: 요청이 오면 model.generate()를 한 번 실행하고 결과를 반환하는 것이다. 이 방식은 동시성이 올라가면 즉시 무너진다 — GPU 연산 능력이 부족해서가 아니라 두 가지 이유 때문이다: 첫째, VRAM이 파편화로 소모된다. 자기회귀 생성은 각 레이어의 Key/Value 텐서(KV Cache)를 캐싱해야 한다. 만약 각 요청이 max_model_len에 따라 연속된 VRAM 블록 전체를 사전 할당한다면, 4096 token 요청 하나가 수십 MB를 차지하게 되지만 실제 생성되는 시퀀스는 200 token에 불과할 수 있다. 더 나쁜 것은, 서로 다른 길이의 요청이 교차로 드나들면서 연속 VRAM 블록이 조각조각 잘려나가, 최종적으로 총량은 충분한데 충분히 큰 연속 공간을 찾지 못하는 상황이 발생한다 — 이것이 전형적인 VRAM 파편화 문제다. 둘째, 배치 처리 효율이 낮다. 전통적인 정적 배치 처리는 하나의 batch 안의 모든 요청이 동시에 시작하고 동시에 끝나야 한다. 하지만 생성 작업의 출력 길이는 본질적으로 예측 불가능하다: 어떤 요청은 10개 token에서 멈추고, 다른 요청은 2000개를 생성해야 할 수 있다. 짧은 요청이 끝나면, 그것이 차지한 batch 슬롯은 긴 요청이 끝날 때까지 빈 채로 기다릴 수밖에 없고, GPU 활용률은 절벽처럼 떨어진다. vLLM의 두 가지 설계 초석은 바로 이 두 가지痛点을 겨냥한다: PagedAttention은 페이징 메커니즘으로 VRAM 파편화를 제거하고, Continuous Batching은 반복 수준 스케줄링으로 배치 처리 공회전을 제거한다. 이 장에서는 이 두 메커니즘의 구현 세부사항을 깊이 다루지 않고(그것은 제 2, 4장의 주제다), 먼저 전체 지도를 구축한다: vLLM v1의 프로세스 아키텍처는 어떤 모습인지, 각 계층의 책임은 어떻게 나뉘는지, 하나의 요청이 시스템에 들어와 token을 토해내기까지 어떤 컴포넌트를 통과하는지. 이 지도를 이해해야 이후 각 장의 소스코드 해설이 발판을 가질 수 있다.

프로세스 아키텍처: 왜 vLLM은 단일 프로세스 프로그램이 아닌가

직관적 모델

vLLM을 식당이라고 상상해 보자. 프런트(API Server)는 손님을 맞이하고 주문을 기록하며, 주방 핵심(EngineCore)은 어떤 요리를 먼저 만들지, 어느 화구를 사용할지 결정하고, 각 화구(GPU Worker)는 한 명의 요리사가 독점적으로 조작한다. 한 사람이 접객과 요리를 동시에 한다면, 피크 시간에는 반드시 손이 꼬이게 된다 — 이것이 vLLM이 이러한 역할을 독립 프로세스로 분리하는 이유다.

〔설계 추론과 아키텍처 트레이드오프〕

이러한 다중 프로세스 분리의 핵심 동기는관심사 분리다: HTTP 파싱, tokenization, 멀티모달 데이터 로딩은 CPU 집약적이고 블로킹될 수 있는 작업인 반면, 모델 순전파는 GPU 집약적이다. 만약 같은 프로세스에 둔다면, Python의 GIL이 둘을 서로 끌어내리게 할 것이다. 독립 프로세스로 분리하면, API Server는 지속적으로 새 요청을 받고, EngineCore는 지속적으로 스케줄링하며, GPU Worker는 지속적으로 연산할 수 있고, 셋은 ZMQ 메시지 큐를 통해 디커플링된다.

프로세스 토폴로지와 수량 관계

vLLM v1의 프로세스 아키텍처는 하나의 공식으로 요약할 수 있다.N개의 GPU, 텐서 병렬도TP, 파이프라인 병렬도PP, 데이터 병렬도DP, API Server 수A인 배포에 대해:

프로세스 유형수량책임
API ServerA(기본값은DP)HTTP 요청 처리, 입력 전처리, 결과 스트리밍 반환
EngineCoreDP(기본값 1)스케줄링, KV Cache 관리, GPU Worker 조정
GPU WorkerN(= DP × PP × TP)가중치 로딩, 순전파 실행, VRAM 관리
DP CoordinatorDP > 1일 때 1, 그렇지 않으면 0DP 랭크 간 로드 밸런싱과 MoE 웨이브 조정

📎 docs/design/arch_overview.md:113-113이 표의 권위 있는 정의를 제시한다. 전형적인 단일 머신 4-GPU 배포(vllm serve -tp=4)는 1개의 API Server + 1개의 EngineCore + 4개의 GPU Worker = 6개의 프로세스를 생성한다📎 docs/design/arch_overview.md:115-115. 반면 8-GPU TP=2/DP=4 배포는 4 + 4 + 8 + 1 = 17개의 프로세스로 팽창한다📎 docs/design/arch_overview.md:123-123。

여기서 간과하기 쉬운 세부 사항이 있다:API Server의 수는 기본적으로 DP 크기를 따른다.--data-parallel-size 4일 때 4개의 API Server가 자동으로 시작되며, 각각은 ZMQ를 통해 다대다 토폴로지로 모든 EngineCore에 연결된다📎 docs/design/arch_overview.md:73-73. 이는 어떤 API Server든 어떤 EngineCore로든 요청을 라우팅할 수 있음을 의미하며, 단일 지점 병목을 방지한다.

데이터 흐름

아래 그림은 하나의 요청이 프로세스 간에 흐르는 전체 경로를 보여준다. 각 노드에 표시된 것은 실제 클래스 이름과 데이터 구조임에 주목하라:

mermaid
flowchart LR
    client["客户端 HTTP 请求"] --> api["API Server 进程<br/>输入预处理 + tokenization"]
    api -->|"EngineCoreRequest<br/>via ZMQ ADD"| core["EngineCore 进程<br/>Scheduler + KVCacheManager"]
    core -->|"SchedulerOutput<br/>via Executor"| worker["GPU Worker 进程<br/>ModelRunner.forward()"]
    worker -->|"ModelRunnerOutput<br/>token ids + logprobs"| core
    core -->|"EngineCoreOutputs<br/>via ZMQ"| api
    api -->|"流式 SSE 响应"| client

이 그림의 핵심은:API Server와 EngineCore 사이는 비동기 메시지 전달이며, 함수 호출이 아니다. 요청은EngineCoreRequest구조체(msgspec.Struct,📎 vllm/v1/engine/__init__.py:109-113참조)로 직렬화되어 ZMQ의ADD메시지 타입을 통해 전송된다📎 vllm/v1/engine/__init__.py:287-299. EngineCore가 처리를 마치면 결과를EngineCoreOutputs로 패키징하여 반환한다📎 vllm/v1/engine/__init__.py:256-260。

〔설계 추론 및 아키텍처 트레이드오프〕

gRPC나 공유 메모리 대신 ZMQ를 선택한 이유는 ZMQ가 프로세스 간 통신 시나리오에서 지연이 극히 낮고(마이크로초 수준), 다대다 토폴로지와 메시지 큐 의미론을 자연스럽게 지원하기 때문이다. 첫 토큰 지연에 민감한 추론 서비스 시나리오에서는 통신 오버헤드가 가능한 한 작아야 한다.

설계 고찰: EngineCore가 스레드가 아닌 독립 프로세스인 이유

자연스러운 질문이 하나 있다: EngineCore와 API Server가 같은 머신에 있으니, 왜 같은 프로세스에 두고 스레드로 통신하지 않는가?

답은 EngineCore의 작업 모드에 숨어 있다. EngineCore는바쁜 루프(busy loop)를 실행하며, 지속적으로 요청을 스케줄링하고 GPU Worker에 작업을 분배한다📎 docs/design/arch_overview.md:73-73. 이 루프는 중단될 수 없다——HTTP 파싱이나 토큰화에 의해 블로킹되면 전체 추론 파이프라인에 버블이 발생한다. 독립 프로세스는 EngineCore의 CPU 시간 슬라이스가 프런트엔드 로직에 의해 선점되지 않도록 보장한다.

또한 독립 프로세스는장애 격리를 제공한다: API Server가 어떤 잘못된 요청 때문에 크래시하더라도 EngineCore와 GPU Worker는 영향을 받지 않고, 다른 API Server가 전달한 요청을 계속 서비스할 수 있다.

계층적 멘탈 모델: 진입점에서 GPU까지의 책임 경계

직관적 모델

프로세스 아키텍처가 "누가 어디서 일하는가"라면, 계층 모델은 "각 계층이 어떤 결정을 담당하는가"이다. vLLM의 코드 구성은 명확한 계층 원칙을 따른다:상위 계층은 무엇을 할지 결정하고, 하위 계층은 어떻게 할지 결정한다. 진입 계층은 어떤 요청을 받을지 결정하고, 엔진 코어 계층은 누구를 먼저 처리할지 결정하며, 실행기 계층은 어떤 병렬 전략을 사용할지 결정하고, Worker 계층은 구체적 하드웨어에서 어떻게 결과를 낼지 결정한다.

4계층 구조

진입 계층(Entrypoints)은 두 가지 상호작용 방식을 제공한다: 오프라인 추론의LLM클래스와 온라인 서비스의vllm serve명령📎 docs/design/arch_overview.md:16-16📎 docs/design/arch_overview.md:56-56. 이 계층의 핵심 책임은 입력 전처리——토큰화, 멀티모달 데이터 로딩, 샘플링 파라미터 파싱——그리고 출력의 역토큰화와 스트리밍 반환이다. 스케줄링 전략에는 관심이 없고 GPU도 건드리지 않는다.

엔진 코어 계층(EngineCore)은 전체 시스템의 두뇌이다. Scheduler(각 decode step에서 어떤 요청을 처리할지 결정)와 KV Cache Manager(페이지드 VRAM 관리)를 보유하며, Executor 추상을 통해 GPU Worker와 통신한다📎 docs/design/arch_overview.md:79-85. 이 계층의 핵심 설계는스케줄링과 실행의 분리이다: Scheduler는 "이 단계에서 어떤 토큰을 실행할지"에 대한 결정(SchedulerOutput)만 산출하고, 구체적으로 GPU에서 어떻게 실행할지는 Worker의 몫이다.

실행기 계층(Executor)은 EngineCore와 Worker 사이의 다리이다. 분산 실행 전략을 캡슐화한다——단일 프로세스는UniProcExecutor, 다중 프로세스는MultiprocExecutor, Ray 클러스터는RayDistributedExecutor. Executor의 추상 인터페이스 덕분에 EngineCore는 하위가 단일 GPU인지 8-GPU TP인지 알 필요가 없다.

Worker 계층은 GPU마다 하나의 Worker 프로세스이며, 내부에 ModelRunner와 실제torch.nn.Module모델 객체를 보유한다📎 docs/design/arch_overview.md:171-191. ModelRunner는 입력 텐서 준비, CUDA Graph 캡처, 순전파 계산 실행을 담당한다. 이 계층은 GPU VRAM과 CUDA 스트림을 직접 조작하는 유일한 곳이다.

구성 객체: 모든 계층을 관통하는 전역 상태

4계층 사이에서 정보는 무엇으로 전달되는가? 답은VllmConfig——모든 구성을 담은 거대한 dataclass이다📎 vllm/config/vllm.py:357-357。

python
@config(config=ConfigDict(arbitrary_types_allowed=True))
class VllmConfig:
    """Dataclass which contains all vllm-related configuration."""
    model_config: ModelConfig = None
    cache_config: CacheConfig = Field(default_factory=CacheConfig)
    parallel_config: ParallelConfig = Field(default_factory=ParallelConfig)
    scheduler_config: SchedulerConfig = Field(default_factory=SchedulerConfig.default_factory)
    # ... 还有 20+ 个子配置

📎 vllm/config/vllm.py:363-371는 핵심 필드를 보여준다. 이 설계 선택의 배경 논리는 더 자세히 살펴볼 가치가 있다.

〔설계 추론 및 아키텍처 트레이드오프〕

문서에서 왜 분산된 매개변수 전달 대신 하나의 큰 구성 객체를 사용하는지 명확히 설명합니다:확장성. ModelRunner에만 영향을 주는 새로운 기능을 추가한다고 가정하면,VllmConfig에 필드 하나만 추가하면 되고, ModelRunner가 직접 읽으면 되므로 Engine, Worker, Model의 생성자 시그니처를 수정할 필요가 없습니다📎 docs/design/arch_overview.md:203-203. 빠르게 진화하는 추론 프레임워크에서 이러한 「필드 추가 시 인터페이스 불변」 능력은 개발 마찰을 크게 줄여줍니다.

대가는VllmConfig이 극도로 거대해진다는 것입니다——📎 vllm/config/vllm.py:356-3509에서 볼 수 있듯이, 이 클래스는 3000줄이 넘는 코드에 걸쳐 있으며 수십 개의 필드와 검증 메서드를 포함합니다.__post_init__메서드📎 vllm/config/vllm.py:1405-2317은 900줄이 넘으며, 모든 구성 항목 간의 교차 검증과 기본값 도출을 담당합니다.

구성의 해시와 캐싱

VllmConfig에는 간과하기 쉽지만 매우 중요한 능력이 하나 더 있습니다:compute_hash() 📎 vllm/config/vllm.py:464-580. 이는 계산 그래프 구조에 영향을 주는 모든 구성 항목에 대해 짧은 해시를 생성합니다.

python
def compute_hash(self, include_version: bool = True) -> str:
    factors: list[Any] = []
    vllm_factors: list[Any] = []
    if include_version:
        from vllm import __version__
        vllm_factors.append(__version__)
    if self.model_config:
        vllm_factors.append(self.model_config.compute_hash())
    # ... 逐个追加各子配置的哈希
    hash_str = safe_hash(str(factors).encode(), usedforsecurity=False).hexdigest()[:10]
    return hash_str

📎 vllm/config/vllm.py:479-580은 전체 해시 계산 흐름을 보여줍니다. 주석의 경고에 주목하세요: 「Whenever a new field is added to this config, ensure that it is included in the factors list if it affects the computation graph」📎 vllm/config/vllm.py:465-467。

〔설계 추론 및 아키텍처 트레이드오프〕

이 해시의 용도는torch.compile 캐시 키입니다. vLLM은torch.compile으로 모델 전방 그래프를 컴파일하고, 컴파일 결과는 디스크에 캐시됩니다. 다음 시작 시 구성 해시가 동일하면 컴파일 캐시를 직접 재사용하여 시간이 많이 걸리는 컴파일 과정을 건너뛸 수 있습니다. 계산 그래프에 영향을 주는 구성 항목이 해시에 포함되지 않으면 캐시 히트 오류가 발생합니다——이전 구성으로 컴파일된 그래프를 새 구성으로 실행하여 결과가 조용히 잘못됩니다. 이것이 주석에서 「계산 그래프에 영향을 주는 필드는 반드시 해시에 포함해야 한다」고 반복 강조하는 이유입니다.

요청 라이프사이클 Walkthrough: HTTP에서 Token까지

시나리오 설정

클라이언트가vllm serve으로 시작된 서비스에 OpenAI 호환/v1/completions요청을 보낸다고 가정합니다. prompt는 "The capital of France is"이고, 16개의 token 생성을 요구합니다. 소스 코드를 따라 이 요청의 전체 여정을 추적해 봅시다.

Step 1: API Server 수신 및 전처리

API Server 프로세스가 HTTP 요청을 받으면 tokenization과 샘플링 매개변수 파싱을 수행한 후EngineCoreRequest:

python
class EngineCoreRequest(
    msgspec.Struct,
    array_like=True,
    omit_defaults=True,
    gc=False,
):
    request_id: str
    prompt_token_ids: list[int] | None
    mm_features: list[MultiModalFeatureSpec] | None
    sampling_params: SamplingParams | None
    pooling_params: PoolingParams | None
    arrival_time: float
    lora_request: LoRARequest | None
    cache_salt: str | None
    data_parallel_rank: int | None
    prompt_embeds: torch.Tensor | None = None
    # ... 更多字段

📎 vllm/v1/engine/__init__.py:109-124을 구성합니다.msgspec.Struct이array_like=True과omit_defaults=True의 조합과 함께📎 vllm/v1/engine/__init__.py:109-113——이것은직렬화 성능。array_like을 위한 것으로, msgspec이 딕셔너리 대신 위치 배열로 인코딩하게 하고,omit_defaults기본값 필드를 건너뛰게 하여, 둘을 결합하면 ZMQ 메시지 크기를 크게 줄입니다.

〔설계 추론 및 아키텍처 트레이드오프〕

gc=False은 msgspec에게 이 구조체에 대해 GC 추적 코드를 생성하지 말라고 지시합니다📎 vllm/v1/engine/__init__.py:109-113. 고빈도로 생성/소멸되는 메시지 객체의 경우, GC 추적을 끄면 Python 가비지 컬렉터의 부담을 줄일 수 있으며, 이는 초당 수천 건의 요청을 처리하는 시나리오에서 필요한 최적화입니다.

Step 2: EngineCore 스케줄링

EngineCore가 요청을 받으면 Scheduler가 이를 대기 큐에 넣습니다. 각 스케줄링 단계에서 Scheduler는 이 요청을 현재 배치에 포함할지 결정합니다. 포함되면 KV Cache Manager가 물리적 block을 할당합니다(PagedAttention의 핵심 작업, 자세한 내용은 제2장 참조).

스케줄링 결과는SchedulerOutput으로 캡슐화되어 Executor를 통해 GPU Worker로 전송됩니다.

Step 3: GPU Worker 전방 실행

Worker의 ModelRunner가SchedulerOutput을 받아 입력 텐서(block table, slot mapping 등 attention metadata 포함)를 준비하고, 모델 전방을 실행하여 다음 token을 샘플링합니다.

Step 4: 결과 반환

Worker가 생성한 token은EngineCoreOutput:

python
class EngineCoreOutput(
    msgspec.Struct,
    array_like=True,
    omit_defaults=True,
    gc=False,
):
    request_id: str
    new_token_ids: list[int]
    new_logprobs: LogprobsLists | None = None
    finish_reason: FinishReason | None = None
    stop_reason: int | str | None = None
    # ...

📎 vllm/v1/engine/__init__.py:199-217으로 캡슐화됩니다.finish_reason은 출력 구조를 정의합니다.IntEnum은STOP、LENGTH、ABORT、ERROR、REPETITION 📎 vllm/v1/engine/__init__.py:68-69이며, 값은Int을 포함합니다. 주석은 왜Str:「Int rather than Str for more compact serialization」📎 vllm/v1/engine/__init__.py:56-57대신

을 사용하는지 설명합니다——또 하나의 직렬화 크기 최적화입니다.EngineCoreOutput여러EngineCoreOutputs이📎 vllm/v1/engine/__init__.py:256-260。

으로 패킹되어 ZMQ를 통해 API Server로 반환됩니다

Step 5: API Server 스트리밍 반환EngineCoreOutputsAPI Server가EngineCoreOutput을 받으면 각

에 대해 역 tokenization을 수행한 후, SSE(Server-Sent Events)를 통해 클라이언트로 스트리밍 푸시합니다.

전체 시퀀스

mermaid
sequenceDiagram
    participant Client as 客户端
    participant API as API Server 进程
    participant Core as EngineCore 进程
    participant Sched as Scheduler
    participant Worker as GPU Worker 进程

    Client->>API: POST /v1/completions
    API->>API: tokenize(prompt) -> prompt_token_ids
    API->>Core: EngineCoreRequest via ZMQ ADD
    Core->>Sched: add_request(EngineCoreRequest)
    loop 每个 decode step
        Sched->>Sched: schedule() -> SchedulerOutput
        Sched->>Worker: execute_model(SchedulerOutput)
        Worker->>Worker: ModelRunner.forward() + sample()
        Worker-->>Sched: ModelRunnerOutput
        Sched->>Sched: update_from_output() -> EngineCoreOutput
        Core-->>API: EngineCoreOutputs via ZMQ
        API-->>Client: SSE chunk (new_token_ids)
    end
    Note over Sched: finish_reason != None 时请求退出

복사이 다이어그램의 핵심 정보:EngineCoreOutputs각 decode step마다반환이 발생하며

, 전체 시퀀스 생성이 완료될 때까지 기다리지 않습니다. 이것이 바로 Continuous Batching의 구현입니다——완료된 시퀀스는 즉시 종료되고, 새 요청은 즉시 추가되며, 출력은 클라이언트로 스트리밍 반환됩니다.

설계 사고와 프로덕션 함정

VllmConfig.__post_init__전체 구성 시스템의 핵심입니다. 이것은 단순한 필드 할당이 아니라다단계 검증 파이프라인:

1. 먼저 멀티모달 인코더 모드를 파싱합니다📎 vllm/config/vllm.py:1416-1416

2. 그런 다음try_verify_and_update_config()를 호출하여 모델별 구성 훅이 구성을 수정할 기회를 갖도록 합니다📎 vllm/config/vllm.py:1434-1434

3. 이어서 병렬 구성, 양자화 구성, LoRA 구성 간의 일관성을 검증합니다📎 vllm/config/vllm.py:1442-1444

4. 마지막으로 비동기 스케줄링, CUDA Graph, KV Transfer 등 런타임 기능의 호환성 검사를 처리합니다📎 vllm/config/vllm.py:1544-1635

〔설계 추론 및 아키텍처 트레이드오프〕

이러한 '후처리 초기화' 패턴은 근본적인 모순을 해결합니다:구성 항목 간에 의존 관계가 존재하지만, 사용자가 임의의 순서로 설정할 수 있습니다. 예를 들어,async_scheduling의 활성화 여부는 speculative_config의 메서드 유형, executor 백엔드 지원 여부, pipeline parallelism 사용 여부 등 여러 조건에 따라 달라집니다📎 vllm/config/vllm.py:1544-1575. 이러한 로직을 필드의__set__에 넣으면 복잡한 순환 의존성이 형성됩니다.__post_init__에 통합하여 순서대로 처리하면 로직이 명확하고 디버깅이 용이합니다.

함정 포인트: KV Connector와 expandable_segments의 충돌

📎 vllm/config/vllm.py:1219-1260의_verify_kv_transfer_compat은 매우 은밀한 프로덕션 함정을 드러냅니다.

KV Connector(예: NIXL, Mooncake)를 사용하여 PD 분리 배포를 할 때, 이러한 connector는ibv_reg_mr등의 메커니즘을 통해KV cache의 물리적 메모리 페이지를 고정(pin)합니다. 그러나 동시에PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True를 설정하면 PyTorch의 CUDA VMM 할당자가 런타임에 동일한 가상 주소를 다른 물리적 페이지에 재매핑할 수 있습니다📎 vllm/config/vllm.py:1227-1233。

결과는 무엇일까요? Connector에 등록된 RDMA 메모리 영역이 이미 무효화된 물리적 페이지를 가리키게 됩니다. 첫 번째 노드 간 KV 전송에서IBV_WC_REM_ACCESS_ERR또는NIXL_ERR_REMOTE_DISCONNECT 📎 vllm/config/vllm.py:1232-1233。

vLLM의 대응 전략은보수적 거부입니다:expandable_segments:True가 감지되고 KV connector가 구성되어 있으면 즉시 예외를 발생시킵니다📎 vllm/config/vllm.py:1249-1260. 유일한 예외는enable_cumem_allocator가 활성화된 경우입니다 — CuMem 할당자가 자체 메모리 풀 주변에서expandable_segments 📎 vllm/config/vllm.py:1238-1241。

〔설계 추론 및 아키텍처 트레이드오프〕

이 사례의 교훈은:RDMA 메모리 등록과 가상 메모리 재매핑은 의미론적으로 호환되지 않습니다. GPU 메모리 pin과 관련된 모든 기능(KV 전송, NCCL 등록 버퍼 등)은 기본 물리적 페이지가 할당자에 의해 조용히 이동되지 않도록 보장해야 합니다. 이러한 문제를排查할 때 RDMA 전송이 첫 번째 노드 간 통신에서 실패하는 것을 보면, 첫 번째 반응은PYTORCH_CUDA_ALLOC_CONF。

함정 포인트: 비동기 스케줄링의 자동 다운그레이드 체인

__post_init__에서async_scheduling처리 로직📎 vllm/config/vllm.py:1544-1635은 정교하게 설계된자동 다운그레이드 체인。

을 보여줍니다async_scheduling사용자가None를 명시적으로 설정하지 않은 경우(값이

  • ), vLLM은 자동으로 활성화를 시도하지만 일련의 비호환 조건을 순차적으로 확인해야 합니다:📎 vllm/config/vllm.py:1578-1587
  • pooling 모델이면📎 vllm/config/vllm.py:1588-1601
  • 비활성화disable_padded_drafter_batch=Truespeculative 메서드가 지원 목록에 없으면📎 vllm/config/vllm.py:1602-1610
  • 비활성화📎 vllm/config/vllm.py:1611-1617
  • 이면📎 vllm/config/vllm.py:1618-1624
  • 비활성화📎 vllm/config/vllm.py:1625-1633

executor 백엔드가 지원하지 않으면📎 vllm/config/vllm.py:1639-1640。

비활성화

ROCm DeepEP 고처리량 DBO이면비활성화PP > 1이고 V1 Model Runner를 사용하면

비활성화

모든 검사를 통과해야 최종적으로

1. 활성화〔설계 추론 및 아키텍처 트레이드오프〕

2. 이 다운그레이드 체인의 설계 철학은:기본적으로 최적 구성을 활성화하고, 비호환 시 조용히 다운그레이드하며 경고를 기록합니다A + DP + N. 이는 사용자가 모든 호환성 스위치를 수동으로 구성하도록 요구하는 것보다 훨씬 친화적입니다. 그러나 대가는 — 성능이 예상보다 낮을 때 사용자가 로그를 뒤져야 비동기 스케줄링이 자동으로 비활성화되었음을 발견할 수 있다는 것입니다. 프로덕션 환경에서 처리량이 비정상적이면 시작 로그에 "Async scheduling will be disabled" 경고가 있는지 확인하는 것이 좋습니다.

3. 이 장 요약이 장에서는 vLLM v1의 전역 멘탈 모델을 구축했으며, 핵심 요점은:

4. vLLM이 해결하는 두 가지 근본 문제: 메모리 단편화(PagedAttention 페이징 관리)와 배치 처리 공회전(Continuous Batching 반복 수준 스케줄링).compute_hash()다중 프로세스 아키텍처__post_init__: API Server(진입점) → EngineCore(스케줄링) → GPU Worker(실행) 3계층 프로세스로, ZMQ를 통한 비동기 통신. 프로세스 수는

5. 공식을 따릅니다.:HTTP → tokenize → EngineCoreRequest → Scheduler → Worker forward → EngineCoreOutput4계층 계층 모델

: 진입 계층은 전처리를, 엔진 코어 계층은 스케줄링 결정을, 실행기 계층은 분산 전략을, Worker 계층은 GPU 계산을 담당합니다.

VllmConfig는 모든 계층을 관통하는 전역 상태EngineCoreRequest이며,msgspec.Struct를 통해 컴파일 캐시를 지원하고,array_like=True, omit_defaults=True를 통해 구성 항목 간 검증과 기본값 도출을 구현합니다.array_like=False, omit_defaults=False요청 수명 주기📎 vllm/v1/engine/__init__.py:109-113→ SSE 스트리밍 반환.📎 vllm/v1/engine/__init__.py:256-260이 장 사고와 자가 테스트

Q1::array_like=True의omit_defaults=True매개변수를EngineCoreRequest모든 필드 이름을 포함하는 딕셔너리 구조로 인코딩되어 크기가 2-3배 팽창할 수 있습니다. 높은 동시성 시나리오(초당 수천 건의 요청)에서는 API Server와 EngineCore 간의 ZMQ 메시지 양이 현저히 증가하여 직렬화/역직렬화 CPU 오버헤드 상승과 네트워크 대역폭 낭비를 초래합니다.EngineCoreOutputs마찬가지로 이 두 매개변수를 사용하며📎 vllm/v1/engine/__init__.py:256-260, 각 decode step마다 생성되므로 영향이 더 큽니다. 또한gc=FalseGC 추적을 비활성화하면 빈도가 높고 수명이 짧은 객체에 대해 Python GC 부담을 줄일 수 있습니다.

Q2:VllmConfig.__post_init__에서,async_scheduling의 자동 활성화 로직(📎 vllm/config/vllm.py:1576-1635)은 「비호환 조건을 순차적으로 검사하고, 모두 통과해야 활성화」하는 전략을 채택합니다. 비동기 스케줄링과 호환되지 않는 기능을 새로 추가했는데 개발자가 이 검사 체인에 해당 분기를 추가하는 것을 잊었다면 어떤 문제가 발생할까요? 시스템 동작 관점에서 분석하세요.

참고 해석: 검사 분기를 추가하는 것을 잊으면 비동기 스케줄링이 잘못 활성화됩니다. 비동기 스케줄링의 핵심 가정은 「현재 step의 스케줄링 결정이 이전 step의 출력에 의존하지 않는다」는 것이며, 이를 통해 EngineCore가 이전 step의 GPU 계산이 아직 완료되지 않은 상태에서 다음 step을 스케줄링할 수 있습니다. 새 기능이 이 가정을 위반한다면(예: 이전 step의 logits를 읽어야 하는 후처리 로직), 비동기 스케줄링은 데이터 경쟁이나 잘못된 결과를 초래합니다. 더 은밀한 점은 이런 종류의 버그가 특정 동시성 타이밍에서만 발생하여 재현하기 어렵다는 것입니다. 이것이 바로📎 vllm/config/vllm.py:1549-1552에서 명시적 활성화 경로가 「hard fail」 전략을 채택한 이유입니다 — 사용자가 직접 활성화할 때 조용히 성능 저하하는 대신 직접 오류를 발생시켜 개발자가 호환성 문제에 직면하도록 강제합니다.

Q3: VllmConfig.compute_hash()의 주석은 「계산 그래프에 영향을 미치는 필드는 반드시 factors 목록에 추가해야 한다」(📎 vllm/config/vllm.py:465-467)고 경고합니다. 새로운 필드attention_sink_tokens가 attention 계산 로직에 영향을 미치지만 해시에서 누락되었다고 가정하면, 프로덕션 환경에서 어떤 유형의 장애가 발생할까요? 왜 이런 장애가 특히 위험할까요?

참고 해석:compute_hash()의 출력은 torch.compile 컴파일 캐시의 키로 사용됩니다. 만약attention_sink_tokens가 계산 그래프 구조에 영향을 미치지만 해시에 포함되지 않으면, 사용자가attention_sink_tokens=0에서attention_sink_tokens=4로 변경할 때 해시 값이 변하지 않아 vLLM이 이전에 컴파일된 그래프(sink token 로직이 없는)를 재사용합니다. 결과적으로 모델이 조용히 잘못된 출력을 생성합니다 — 오류도 없고 충돌도 없으며 단지 결과가 틀릴 뿐입니다. 이런 장애가 특히 위험한 이유는: (1) 어떤 예외나 로그 경고도 발생시키지 않습니다; (2) 출력이 여전히 「그럴듯해 보이는」 텍스트이며 단지 품질이 저하되거나 동작이 이상할 뿐입니다; (3) 문제를排查하려면 컴파일 캐시 적중 상황과 실제 구성 차이를 대조해야 하므로 위치 파악 비용이 극히 높습니다. 이것이 주석에서 새 필드가 계산 그래프에 영향을 미치는지 반드시 평가해야 한다고 반복 강조하는 이유입니다.

이 장은 평범한 추론 요청의 충돌 현장에서 출발하여 vLLM이 반드시 해결해야 할 두 가지 근본적 모순 — 메모리 파편화와 배치 처리 공회전 — 을 밝히고, PagedAttention과 Continuous Batching이라는 두 가지 열쇠를 제시했습니다. 이어서 vLLM v1의 전체 아키텍처를 조망하며 프로세스 모델, 컴포넌트 계층화, 요청의 전체 생명주기를 정리했습니다. 이 전역 지도를 바탕으로 다음 장에서는 vLLM의 가장 핵심적인 데이터 구조 — Request, Sequence, KV Cache의 block 관리 메커니즘 — 을 깊이 파고들어 PagedAttention이 코드 수준에서 「논리적 연속, 물리적 분산」 메모리 매핑을 어떻게 구현하는지 밝힙니다.

CHAPTER 02

제 2 장: 핵심 추상화: Request, Sequence 및 KV Cache 데이터 구조

소속 프로젝트: vllm-project/vllm · 전체 진행률: 제 2 / 14 장 · 검증 상태: FACT 행 번호 실제 앵커링

이전 장에서 우리는 vLLM v1의 계층적 멘탈 모델을 구축하여 요청이 API Server에서 출발해 EngineCore를 거쳐 최종적으로 Worker에 도달하여 실행된다는 것을 알았습니다. 하지만 HTTP 요청 본문의 JSON 문자열이 어떻게 엔진 내부에서 스케줄링 가능하고, 추적 가능하며, 중단 가능한 객체로 변환될까요? 이것이 Request 클래스가 답해야 할 질문입니다.

KV Cache의 규격 체계: KVCacheSpec에서 레지스트리까지

Request는 「누가 계산할 것인가」 문제를 해결하고,KVCacheSpec는 「어디서 계산할 것인가」 문제를 해결합니다. PagedAttention의 세계에서 각 모델 레이어의 KV cache는 정확히 설명되어야 합니다: head가 몇 개인지, 각 head가 얼마나 큰지, 하나의 block이 몇 개의 token을 저장할 수 있는지, 양자화가 필요한지. 이 정보들은KVCacheSpec의 상속 체계에 인코딩됩니다.

직관적 모델: KVCacheSpec은 메모리의 「평면도」

〔설계 추론과 아키텍처 트레이드오프〕

GPU 메모리를 개발 예정인 토지라고 상상하면,KVCacheSpec는 각 건물(각 cache group)의 평면도입니다: 각 층(각 block)에 방(head slot)이 몇 개인지, 각 방이 얼마나 큰지(head_size), 몇 명이 거주할 수 있는지(block_size개의 token)를 규정합니다. 그리고KVCacheConfig전체 단지의 계획안에 해당한다 — 총 몇 동의 건물이 있고, 각 동이 얼마의 땅을 차지하며, 어떤 동들이 같은 기초(block table)를 공유하는지.

이러한 규격 체계가 없으면 KV cache 할당은 하드코딩된 가정에만 의존할 수밖에 없어, 표준 MHA부터 MLA까지, 전체 어텐션부터 슬라이딩 윈도우까지, FP16부터 FP8 양자화까지의 다양한 모델 요구를 지원할 수 없다.

데이터 구조: KVCacheSpec의 상속 트리와 핵심 필드

KVCacheSpec모든 규격의 기반 클래스이며, 이는@dataclass(frozen=True) 📎 vllm/v1/kv_cache_interface.py:150-152이다. frozen은 규격 객체가 한 번 생성되면 변경 불가능함을 의미한다 — 이는 여러 컴포넌트(스케줄러, Worker, KV Cache Manager)가 동일한 규격을 보게 하여, 어딘가에서 수정되어 불일치가 발생하지 않도록 보장한다.

기반 클래스는 서브클래스가 반드시 구현해야 하는 세 가지 추상 속성을 정의한다:num_heads、tokens_per_state、state_content_size_bytes 📎 vllm/v1/kv_cache_interface.py:182-183이 세 가지 속성이 함께 결정한다page_size_bytes— 즉 하나의 block이 차지하는 바이트 수를.

AttentionSpec가장 핵심적인 서브클래스이며, 다음을 도입한다num_kv_heads、head_size、dtype、kv_quant_mode등의 필드📎 vllm/v1/kv_cache_interface.py:485-498. 그중tokens_per_state필드의 설계가 특히 정교하다: 기본값은 1로, 하나의 state가 하나의 token에 대응함을 의미하지만, 1보다 큰 정수(예: DeepSeek-V4의 sparse MLA는 여러 token을 하나의 state로 압축)로 설정할 수도 있고, 1보다 작은 분수(예: Whisper의 block pooling은Fraction(1, block_pool_size)로 하나의 token이 여러 state에 대응함을 나타냄)로 설정할 수도 있다.📎 vllm/v1/kv_cache_interface.py:501-501。

FullAttentionSpec는AttentionSpec기반 위에sliding_window과attention_chunk_size 📎 vllm/v1/kv_cache_interface.py:566-566을 추가한다. 그 문서 문자열이 중요한 설계 결정을 설명한다는 점에 주목하라: 혼합 할당기가 비활성화되면, 슬라이딩 윈도우 어텐션 레이어는 KV Cache Manager에서 전체 어텐션으로 처리되어(모든 token에 block 할당) 모델 실행 시에는 여전히 슬라이딩 윈도우로 계산한다📎 vllm/v1/kv_cache_interface.py:540-545. 이는보수적 할당, 정밀 계산전략이다.

MLAAttentionSpec는 DeepSeek 계열 모델의 핵심 규격이다. 이는head_size_v를 기본값 0으로 설정하는데📎 vllm/v1/kv_cache_interface.py:670, MLA는 하나의 latent vector만 저장하고 독립적인 V가 없기 때문이다.alignment필드는 페이지 정렬 패딩에 사용되며📎 vllm/v1/kv_cache_interface.py:646-652, 이는 FlashMLA 등 특정 정렬이 필요한 백엔드에 매우 중요하다.

MambaSpec는 어텐션 경로를 전혀 따르지 않는다. 이는shapes과dtypes튜플로 상태 텐서의 형상을 설명한다📎 vllm/v1/kv_cache_interface.py:1027-1028,state_content_size_bytes는 모든 상태 텐서 크기의 총합이다📎 vllm/v1/kv_cache_interface.py:1048-1052. Mamba의max_memory_usage_bytes은mamba_cache_mode에 따라 세 가지 서로 다른 계산 방식을 가진다📎 vllm/v1/kv_cache_interface.py:1073-1084. 이는 Mamba 상태 관리의 복잡성을 반영한다 — 어텐션처럼 선형으로 증가하지 않고 고정된 상태 크기를 가진다.

시나리오 주도: 규격에서 VRAM 레이아웃으로의 변환

엔진이 시작되면, 모든 레이어의KVCacheSpec을 실제 VRAM 레이아웃으로 변환해야 한다. 이 과정은KVCacheTensor과create_kv_cache_views에 의해 완료된다.

KVCacheTensor는 동일한 형상의 레이어 그룹이 KV cache 할당에서 차지하는 위치를 설명한다📎 vllm/v1/kv_cache_interface.py:1406-1427. 핵심 필드는layer_stride과block_stride이다: 전자는 인접 레이어 간의 바이트 거리이고, 후자는 인접 block 간의 바이트 거리이다. 문서 문자열은 두 가지 레이아웃 모드를 상세히 설명한다: 레이어 최외곽(layer-outermost) 레이아웃은 각 레이어에 연속 영역을 부여하고, 블록 최외곽(block-outermost) 레이아웃은 각 block이 모든 레이어의 page를 포함하게 한다📎 vllm/v1/kv_cache_interface.py:1416-1416。

mermaid
flowchart LR
    subgraph spec["KVCacheSpec 层"]
        fas["FullAttentionSpec<br/>num_kv_heads=32<br/>head_size=128<br/>block_size=16"]
    end
    subgraph tensor["KVCacheTensor 层"]
        kt["KVCacheTensor<br/>size=2GB<br/>layer_stride=page*num_blocks<br/>block_stride=page"]
    end
    subgraph view["torch.Tensor 视图"]
        v1["layer_0: [B, H, N, C]"]
        v2["layer_1: [B, H, N, C]"]
        v3["layer_N: [B, H, N, C]"]
    end
    fas -->|"compute_layer_kv_cache_shape_bytes()"| kt
    kt -->|"create_kv_cache_views()"| v1
    kt -->|"create_kv_cache_views()"| v2
    kt -->|"create_kv_cache_views()"| v3

create_kv_cache_views함수는 이 과정의 핵심이다📎 vllm/v1/kv_cache_interface.py:353-417. 이는 평탄한 int8 buffer를 받아torch.as_strided를 통해 각 레이어에 대해 4D 뷰를 생성한다[B, H, N, C]. 핵심 매개변수는strides이며,compute_layout_strides에 의해 계산된다📎 vllm/v1/kv_cache_interface.py:314-350. 이 함수는layout.stride_order에 지정된 차원 순서에 따라, 가장 안쪽 차원부터 역방향으로 각 차원의 바이트 스트라이드를 계산한다.

여기서 주목할 만한 경계 검사가 있다: kernel_block_size가 spec.block_size보다 작을 때(즉 하나의 manager block이 여러 kernel block으로 분할될 때), 코드는 block_stride가 dense_page_size와 같은지 검증한다📎 vllm/v1/kv_cache_interface.py:381-382. 같지 않다면 레이아웃에 padding이 존재하여 균등 분할이 불가능함을 의미하며, 이때 명확한 수정 제안을 담은 ValueError를 발생시킨다.

설계 고찰: 레지스트리 패턴과 확장성

KVCacheSpecRegistry는 vLLM 확장성의 핵심 설계이다📎 vllm/v1/kv_cache_spec_registry.py:39-40. 이는 두 개의 전역 딕셔너리를 유지한다:_REGISTRY_KVCACHESPEC_LIST은 spec 클래스에서 메타데이터로의 매핑을 저장하고,_REGISTRY_ROLE_MANAGERS은 역할에서 관리자로의 매핑을 저장한다📎 vllm/v1/kv_cache_spec_registry.py:35-36。

get_manager_class메서드는 레지스트리의 핵심 조회 로직을 보여준다: spec 클래스의 MRO(메서드 해석 순서)를 따라 위로 순회하며, 첫 번째로 등록된 기반 클래스를 찾는다📎 vllm/v1/kv_cache_spec_registry.py:129-130. 이는 사용자 정의CustomFullAttentionSpec가 별도로 등록되지 않았다면 자동으로FullAttentionSpec의 관리자를 상속함을 의미한다. 이러한상속 기반 조회는 새로운 spec 타입을 추가할 때 차이점 부분만 등록하면 되게 한다.

check_kv_cache_spec_registry메서드는 시작 시 모든 레이어의 spec이 등록되었는지 검증한다📎 vllm/v1/kv_cache_spec_registry.py:165-174. 이는raise ValueError를 사용하고assert를 사용하지 않는다는 점에 주목하라. 주석은 이것이 프로덕션 환경에서도 적용되게 하기 위함이라고 명확히 설명한다📎 vllm/v1/kv_cache_spec_registry.py:165-174. 이는 중요한 엔지니어링 결정이다: Python의-O플래그는 assert를 제거하지만, 프로덕션 환경의 구성 오류는 런타임에야 충돌하는 것이 아니라 시작 시에 반드시 드러나야 한다.

〔설계 추론과 아키텍처 트레이드오프〕

레지스트리의 지연 초기화 설계(_ensure_registered)는 순환 의존성 문제를 해결한다:kv_cache_interface.py는 spec 타입을 확인하기 위해 레지스트리를 참조해야 하고, 레지스트리는 임포트해야 한다single_type_kv_cache_manager관리자 클래스를 가져오기 위해, 이는 다시kv_cache_interface에 의존한다. 실제 등록을 첫 번째 조회 시점까지 지연시킴으로써 이 순환을 끊었다.

이 장 요약

이 장에서는 vLLM v1의 두 가지 핵심 데이터 구조를 분석했다.Request는 엔진 내부에서 요청의 생명주기 운반체로, 이중 token 리스트, 비동기 스케줄링 카운터 및 block hash 메커니즘을 통해 연속 배치 처리와 프리픽스 캐싱이라는 두 가지 핵심 기능을 지원한다.KVCacheSpec및 그 상속 체계는 KV cache의 VRAM 레이아웃 규격을 정의하며, 표준FullAttentionSpec에서MLAAttentionSpec、MambaSpec까지 다양한 모델 아키텍처 요구를 포괄한다. 레지스트리 패턴 덕분에 새로운 spec 타입을 추가할 때 핵심 코드를 수정할 필요가 없어 시스템의 확장성이 보장된다.

여기까지 우리는 Request가 EngineCoreRequest로부터 어떻게 변환되는지, 그리고 상태 카운터, block hash 등의 메커니즘을 통해 스케줄링 결정을 어떻게 지원하는지 살펴보았다. 하지만 외부 요청이 실제로 API Server, chat template 및 멀티모달 처리를 거쳐 최종적으로 EngineCoreRequest가 되는 과정은 어떠한가? 다음 장에서는 요청 진입 계층으로 들어가 HTTP/CLI에서 EngineCore까지의 전체 경로를 추적한다.

CHAPTER 03

제 3 장: 요청 진입: HTTP/CLI에서 EngineCore까지의 전체 경로

소속 프로젝트: vllm-project/vllm · 전체 진행률: 제 3 / 14 장 · 검증 상태: FACT 행 번호 실제 앵커링

이전 장에서 우리는 Request와 KVCacheSpec이라는 두 가지 엔진 내부 핵심 데이터 구조를 분석하며, 논리적 시퀀스와 물리적 VRAM 블록이 어떻게 분리되는지 이해했다. 하지만 HTTP 요청 본문이나 Python 문자열이 실제로 API Server, chat template 및 멀티모달 처리를 거쳐 최종적으로 EngineCoreRequest가 되는 과정은 어떠한가? 이 장에서는 이 경로를 완전히 추적하고, 동기 CLI, 비동기 API 및 오프라인 LLM 클래스라는 세 가지 진입 경로가 어떻게 동일한 엔진 코어로 수렴하는지 밝힌다.

3.1 세 가지 진입 경로의 수렴점: AsyncLLMEngine과 LLMEngine

요청 파싱을 깊이 파고들기 전에, 먼저 세 가지 진입 경로의 토폴로지 구조를 명확히 파악해야 한다. vLLM은 세 가지 사용 방식을 제공한다:vllm serve로 시작하는 OpenAI 호환 HTTP 서비스, 명령줄vllm도구, 그리고 Python에서 직접 인스턴스화하는LLM클래스를 통한 오프라인 추론. 이들은 겉보기에는 독립적이지만, 실제로는 동일한 엔진 코어를 공유한다.

먼저 비동기 API 경로의 별칭 메커니즘을 살펴보자.

📎 vllm/engine/async_llm_engine.py:7-7

이 파일은 모듈이라고 부르기 어려울 정도로 짧다—단 한 가지 일만 한다:AsyncLLMEngine별칭을vllm.v1.engine.async_llm.AsyncLLM으로 지정하는 것이다. 이는 전형적인 아키텍처 마이그레이션 흔적이다. vLLM v0 시대의AsyncLLMEngine은 거대하고 복잡한 클래스였으며, v1 아키텍처 재작성 후 새로운AsyncLLM이 동일한 역할을 담당하게 되었다. 기존 사용자 코드를 깨뜨리지 않기 위해 vLLM은 이전 모듈 경로를 호환 계층으로 유지했다.

〔설계 추론 및 아키텍처 트레이드오프〕

이러한 「이전 경로 별칭이 새 구현을 가리키는」 패턴은 vLLM에서 반복적으로 나타난다(api_server.py의 deprecation warning 등). 이는 프로젝트가 v0에서 v1으로의 마이그레이션에서 점진적 전략을 취했음을 보여준다: 새 코드는 새 경로를 사용하고, 이전 코드는 오류를 내지 않지만 경고를 받으며, 사용자에게 충분한 마이그레이션 기간을 제공한다.

다음으로 오프라인 경로의 진입점을 살펴보자.

📎 vllm/entrypoints/llm.py:344-346

LLM.__init__은 최종적으로LLMEngine.from_engine_args을 호출하며,UsageContext.LLM_CLASS을 전달한다. 이UsageContext열거형은 진입 경로를 구분하는 핵심이다—엔진이 자신이 오프라인 배치 처리 모드에서 실행 중인지 온라인 서비스 모드에서 실행 중인지 알게 하여, 로그, 지표 및 리소스 관리 전략을 조정할 수 있게 한다.

📎 vllm/entrypoints/llm.py:357-359

여기서self.renderer = self.llm_engine.renderer과self.input_processor = self.llm_engine.input_processor의 할당에 주목하자. 오프라인LLM클래스는 자체적으로 chat template 렌더링을 구현하지 않고, 엔진 내부의renderer을 재사용한다. 이는 chat template 파싱 로직이 오프라인과 온라인 경로에서 동일한 코드이며, 단지 호출 시점만 다르다는 것을 의미한다.

세 경로의 수렴 관계는 아래 데이터 흐름도로 표현할 수 있다.

mermaid
flowchart LR
    subgraph entry["入口层"]
        http["HTTP 请求体<br/>ChatCompletionRequest"]
        cli["CLI 参数<br/>vllm serve / vllm chat"]
        offline["Python 调用<br/>LLM.chat(messages)"]
    end

    subgraph parse["解析层"]
        chat_utils["chat_utils.parse_chat_messages<br/>-> ConversationMessage + mm_data"]
        renderer["renderer<br/>apply_chat_template -> token_ids"]
    end

    subgraph engine["引擎层"]
        async_llm["AsyncLLM<br/>add_request()"]
        llm_engine["LLMEngine<br/>add_request()"]
        core["EngineCore<br/>input_queue"]
    end

    http --> chat_utils
    cli --> chat_utils
    offline --> chat_utils
    chat_utils --> renderer
    renderer --> async_llm
    renderer --> llm_engine
    async_llm --> core
    llm_engine --> core

이 그림은 핵심 설계를 드러낸다: 요청이 HTTP, CLI 또는 Python에서 오든,chat_utils은 멀티모달 및 chat template 처리의 유일한 진입점이다. 이는 이기종 입력 형식을ConversationMessage리스트와MultiModalDataDict으로 통일한 후, renderer에 전달하여 token 시퀀스를 생성한다.

3.2 chat_utils: 이기종 메시지에서 통합 대화 구조로

chat_utils.py은 전체 요청 진입 계층에서 가장 복잡한 모듈로, 2264줄의 코드가 OpenAI 호환 형식, 사용자 정의 확장, 멀티모달 임베딩, 도구 호출 등 모든 입력 형태를 처리한다. 그 핵심 역할은 한 문장으로 요약할 수 있다: 사용자가 전달한 임의의 메시지 리스트를 chat template이 이해할 수 있는ConversationMessage리스트로 정규화하고, 동시에 멀티모달 데이터를 독립적인MultiModalDataDict으로 추출하는 것이다.

직관적 모델: 번역가와 수하물 분류원

을chat_utils공항의 번역가 겸 수하물 분류원이라고 상상해 보자. 여행객(사용자)은 여러 나라(OpenAI 형식, 사용자 정의 형식, Harmony 형식)에서 왔고, 각기 다른 언어를 사용한다. 번역가는 먼저 모든 사람의 말을 통일된 작업 언어(ConversationMessage), 동시에 승객이 위탁한 수하물(이미지, 오디오, 비디오)을 독립된 컨베이어 벨트로 분류하고(MultiModalDataDict), 라벨(UUID)을 붙인 뒤, 마지막으로 사람과 수하물을 각각 같은 비행기(엔진)에 태운다.

이 계층이 없으면 엔진이 모든 입력 형식의 세부 사항을 이해해야 하며, 멀티모달 데이터 추출 로직이 각 진입점에 흩어져 새로운 형식이 추가될 때마다 엔진 코어를 수정해야 한다.

데이터 구조: 트래커와 파서의 이중 클래스 협업

chat_utils의 핵심은 두 그룹의 클래스 협업이다:BaseMultiModalItemTracker및 그 하위 클래스는 멀티모달 항목을 "추적"한다,BaseMultiModalContentParser및 그 하위 클래스는 콘텐츠 부분을 "파싱"한다.

먼저 트래커의 필드 레이아웃을 살펴보자.

📎 vllm/entrypoints/chat_utils.py:598-601

_items_by_modality은defaultdict[str, list[_T]]이며, 모달리티(image, audio, video 등)별로 처리할 항목을 그룹화하여 저장한다._modality_order은vision_chunk모달리티를 위해 각 chunk의 원본 모달리티(image인지 video인지)를 기록하는데, 통합 비전 chunk 모델이 둘 다vision_chunk로 매핑하지만 이후 처리에서는 원본 타입을 알아야 하기 때문이다.

📎 vllm/entrypoints/chat_utils.py:613-615

use_unified_vision_chunk_modality은cached_property이며, HuggingFace 설정에서use_unified_vision_chunk플래그를 읽는다. 일반 속성 대신cached_property을 사용하는 이유는 이 검사가 매add호출 시 트리거되므로 캐싱으로 반복적인getattr오버헤드를 피할 수 있기 때문이다.

트래커의add메서드는 핵심 진입점이다.

📎 vllm/entrypoints/chat_utils.py:656-684

add메서드는 먼저_validate_add을 호출해 검증한 후, 통합 비전 chunk 모달리티 사용 여부에 따라 항목을 다른 키 아래에 저장한다.prompt_embeds의 특수 처리를 주목하자: 이는_items_by_modality["prompt_embeds"]에 직접 추가되고None을 반환하는데, 사전 계산된 임베딩은 HF processor를 거치지 않아 플레이스홀더 문자열이 없기 때문이다.

_validate_add의 검증 로직은 자세히 볼 가치가 있다.

📎 vllm/entrypoints/chat_utils.py:686-721

여기 미묘한 분기가 있다:enable_mm_embeds=True이고 해당 모달리티의 프롬프트당 제한이 0이며 원본 모달리티가_embeds로 끝날 때 수량 검증을 건너뛴다. 이는 임베딩 입력이 원본 모달리티의 수량 제한을 우회하도록 허용하기 위함이다 — 임베딩은 사전 계산되어 원본 모달리티의 처리 리소스를 차지하지 않는다.

시나리오 기반: 이미지가 포함된 chat 요청이 어떻게 파싱되는가

사용자가 이미지 URL과 텍스트를 포함한 chat 요청을 보낸다고 가정하자.parse_chat_messages은 동기 경로의 진입점이다.

📎 vllm/entrypoints/chat_utils.py:2161-2197

parse_chat_messages은MultiModalItemTracker을 생성하고, 각 메시지를 순회하며_parse_chat_message_content을 호출하고, 마지막으로_postprocess_messages을 호출해 도구 호출 파라미터를 처리한 후,mm_tracker.resolve_items()을 통해 멀티모달 데이터를 구체화한다.

_parse_chat_message_content은 단일 메시지 파싱을 담당한다.

📎 vllm/entrypoints/chat_utils.py:2007-2029

먼저 content를 정규화한다:None은 빈 리스트가 되고, 문자열은 단일 텍스트 part가 된다. 그런 다음_parse_chat_message_content_parts을 호출하는데, 여기서wrap_dicts파라미터는content_format == "openai"에 의해 결정된다 — 이는 출력이 구조화된 딕셔너리 리스트인지 연결된 문자열인지를 결정한다.

_parse_chat_message_content_parts은 각 part를 순회한다.

📎 vllm/entrypoints/chat_utils.py:1814-1853

각 part는_parse_chat_message_content_part처리를 거친다. 만약wrap_dicts=False이면 최종적으로 텍스트와 플레이스홀더를 단일 문자열로 연결하고,wrap_dicts=True이면 구조화된 딕셔너리 리스트를 반환한다.

_parse_chat_message_content_part은 분배의 핵심이다.

📎 vllm/entrypoints/chat_utils.py:1875-1884

순수 텍스트 part의 경우 먼저 플레이스홀더 보존 검사를 하고,wrap_dicts에 따라 반환 형식을 결정한다. 구조화된 part의 경우_parse_chat_message_content_mm_part을 호출해 타입과 콘텐츠를 추출한다.

📎 vllm/entrypoints/chat_utils.py:1690-1723

_parse_chat_message_content_mm_part은MM_PARSER_MAP을 통해 해당 파싱 함수를 찾는다.uuid is None의 조건을 주목하자 — 사용자가 UUID를 제공했다면 미디어 데이터가 요청 본문에 없을 수 있으며(다른 방식으로 업로드됨), 이때 아래의 직접 URL 필드 분기를 탄다.

📎 vllm/entrypoints/chat_utils.py:1731-1733

이part_type is None또는uuid is not None일 때, 코드는 part에서 직접 URL 필드를 추출하려 시도한다. 이러한 "관대한 파싱"은 OpenAI 형식을 엄격히 따르지 않는 클라이언트와의 호환을 위한 것이다.

으로 돌아가서,_parse_chat_message_content_part미디어 타입의 part는 해당mm_parser메서드로 분배된다.

📎 vllm/entrypoints/chat_utils.py:1923-1968

각 미디어 타입은 해당parse_*메서드를 호출하며, 이 메서드들은 내부적으로tracker.add을 호출해 항목을 트래커에 추가하고 플레이스홀더 문자열을 반환한다. 마지막으로interleave_strings에 따라 플레이스홀더를 반환할지None。

📎 vllm/entrypoints/chat_utils.py:1984-1999

prompt_embeds의 처리는 특별하다:interleave_strings과 관계없이PROMPT_EMBEDS_PLACEHOLDER_TOKEN을 반환한다. 주석은 그 이유를 설명한다 — prompt_embeds는 토큰 오프셋 위치에 연결되므로 위치가 중요하며,missing_placeholders의 앞쪽 패딩 로직을 타면 순서가 뒤섞이기 때문이다.

비동기 경로의 차이

비동기 경로는AsyncMultiModalItemTracker과AsyncMultiModalContentParser을 사용한다. 핵심 차이는resolve_items。

📎 vllm/entrypoints/chat_utils.py:906-952

에 있다. 비동기 버전은asyncio.gather으로 모든 모달리티 항목을 동시에 대기한다. 주석은 명확히 지적한다: 각 추적 항목은 이미 독립적인 awaitable이고, 비동기 커넥터가 블로킹 디코딩 작업을 스레드 풀에 오프로드하므로, 한 모달리티를 직렬로 기다린 후 다음을 기다리는 것은 불필요하게 지연을 증가시킨다.return_exceptions=True은 모든 작업이 완료되거나 실패한 후에 통합적으로 예외를 던져, 첫 번째 실패로 아직 진행 중인 네트워크 요청을 포기하는 것을 방지한다.

설계 고찰: 왜 트래커와 파서를 분리하는가

〔설계 추론 및 아키텍처 트레이드오프〕

트래커와 파서의 분리는 음미할 가치가 있는 설계다. 트래커는 "상태 관리"를 담당한다 — 각 모달리티에 항목이 몇 개인지 기록하고, 수량 제한을 검증하며, vision_chunk의 원본 모달리티 순서를 유지한다. 파서는 "콘텐츠 추출"을 담당한다 — URL에서 이미지를 가져오고, base64에서 임베딩을 디코딩하며, 오디오 형식 변환을 처리한다. 이러한 분리 덕분에 동기 및 비동기 경로가 추적 로직(BaseMultiModalItemTracker은 추상 기반 클래스)을 공유하고 파서 수준에서만 분기할 수 있다. 만약 하나의 클래스로 합친다면 동기와 비동기의 차이가 추적 로직에 스며들어 코드 중복과 상태 관리 복잡화를 초래할 것이다.

3.3 메시지에서 token으로: renderer와 EngineCore의 교대

chat_utils이 생성한ConversationMessage리스트와MultiModalDataDict은 chat template 렌더링을 거쳐야 token 시퀀스가 된다. 이 단계는 renderer가 수행하며, 이후 요청이 실제로 엔진에 진입한다.

시나리오 기반: chat template 렌더링과 요청 전달

parse_chat_messages반환 후, 호출자(예:OpenAIServingChat)는conversation과mm_data를 renderer에 전달합니다. renderer는 chat template을 적용하여ConversationMessage리스트를 텍스트로 렌더링한 뒤, token ID 시퀀스로 tokenize합니다. 멀티모달 플레이스홀더(예:<##IMAGE##>)는 tokenize 후 모델별 플레이스홀더 토큰으로 대체됩니다.

렌더링이 완료되면 요청은EngineCoreRequest로 캡슐화되어AsyncLLM.add_request()또는LLMEngine.add_request()를 통해 EngineCore의 입력 큐에 전달됩니다.

📎 vllm/entrypoints/llm.py:420-484

오프라인LLM.generate메서드는 이 경로를 보여줍니다. 먼저runner_type을 검증하고, 기본 샘플링 파라미터를 가져온 다음,_run_completion。_run_completion를 호출합니다. 내부적으로 renderer를 호출하여 prompt를 렌더링하고,llm_engine를 통해 요청을 전달합니다.

📎 vllm/entrypoints/llm.py:615-708

LLM.chat메서드는 chat 경로를 보여줍니다.messages리스트를 받아_run_chat을 호출하며, 내부적으로parse_chat_messages과 renderer를 호출합니다.

설계 고찰: 왜 renderer가 엔진 내부에 있는가

〔설계 추론 및 아키텍처 트레이드오프〕

LLM.__init__에서self.renderer = self.llm_engine.renderer이 한 줄은 중요한 설계 결정을 드러냅니다. renderer는 진입 계층이 아닌 엔진에 속합니다. 이는 chat template의 로딩, 캐싱, 워밍업(self.renderer.warmup(ChatParams(...)))이 모두 엔진 초기화 시 완료되며, 진입 계층은 단순한 호출자임을 의미합니다. 이렇게 하면 오프라인LLM과 온라인AsyncLLM이 동일한 renderer 구현과 캐시를 공유하여 tokenizer와 chat template의 중복 로딩을 방지합니다. 또한 renderer 워밍업이 엔진 시작 시 완료되어 첫 요청의 콜드 스타트 지연을 방지합니다.

오류 복구와 프로덕션 함정

_postprocess_messages의 도구 호출 파라미터 처리는 전형적인 프로덕션 환경 함정입니다.

📎 vllm/entrypoints/chat_utils.py:2118-2158

assistant 메시지에tool_calls이 포함될 때,arguments필드는 JSON 문자열, 딕셔너리, 또는 유효하지 않은 JSON일 수 있습니다. 코드는 JSON 문자열 파싱을 시도하고, 실패하면 경고를 기록한 뒤 빈 객체로 강제 변환합니다. 주석은 그 이유를 설명합니다. 형식이 잘못된arguments이 대화 기록에 존재하면, 여기서 요청을 실패시킬 경우 이후 매 턴마다 실패하여 대화가 복구 불가능해집니다. 이는 신중한 내결함성 설계로, 모델이 빈 도구 파라미터를 보더라도 전체 대화가 멈추지 않게 합니다.

또 다른 함정은 예약 플레이스홀더 주입 방어입니다.

📎 vllm/entrypoints/chat_utils.py:1856-1872

가enable_prompt_embeds활성화되면,PROMPT_EMBEDS_PLACEHOLDER_TOKEN이 분할 불가능한 특수 토큰으로 등록됩니다. 사용자 텍스트에 이 리터럴 시퀀스가 포함되면 tokenizer가 동일한 token ID로 인코딩하고, renderer는 이를 연결점으로 오인하여 호출자가 순수 텍스트 콘텐츠를 통해 연결 위치를 이동하거나 주입할 수 있게 됩니다._reject_reserved_placeholder_in_text은 텍스트 part 파싱 시 이러한 입력을 거부하여 이 보안 취약점을 차단합니다.

📎 vllm/entrypoints/chat_utils.py:1889-1892

이 검사는isinstance(part, str)분기와 구조화 텍스트 분기 모두에서 호출되어 모든 텍스트 경로가 방어됨을 보장합니다.

이 장 요약

이 장에서는 요청이 외부에서 시스템으로 진입하는 첫 번째 경로를 추적했습니다. 세 가지 진입 경로 — HTTP API, CLI, 오프라인LLM클래스 — 는 최종적으로chat_utils의 멀티모달 파싱 계층으로 수렴합니다.BaseMultiModalItemTracker은 상태 관리를,BaseMultiModalContentParser은 콘텐츠 추출을 담당하며, 이 둘의 분리로 동기 및 비동기 경로가 추적 로직을 공유할 수 있습니다.parse_chat_messages은 이기종 메시지를ConversationMessage리스트와MultiModalDataDict로 정규화한 뒤, 엔진 내부의 renderer에 전달하여 chat template 렌더링과 tokenize를 완료합니다. 최종적으로 요청은EngineCoreRequest로 캡슐화되어 EngineCore의 입력 큐에 전달됩니다.

이 장 생각해보기와 자가 점검

Q1:_parse_chat_message_content_mm_part에서uuid is None이 조건을 제거하면(즉,if isinstance(part_type, str) and part_type in MM_PARSER_MAP:로 변경), 어떤 시나리오에서 문제가 발생하는가?

참고 해석:uuid is None이 조건은 「사용자가 UUID를 제공했지만 미디어 데이터가 요청 본문에 없는」 시나리오를 처리하기 위해 존재합니다. 사용자가 UUID를 제공할 때 미디어 데이터는 이미 다른 방식으로 업로드되었을 수 있으며(예: 미디어 캐시에 사전 업로드), 이때 요청 본문의 part에는 실제 URL이나 데이터 없이 UUID만 포함될 수 있습니다. 이 조건을 제거하면 코드가MM_PARSER_MAP[part_type](part)을 통해 파싱을 시도하지만, part에 해당 데이터 필드가 없을 수 있어(예:image_url이 비어 있음)None콘텐츠가 파싱됩니다. 더 심각한 것은 이후parse_image(None, uuid)이_connector.fetch_image(None)을 호출하여 불필요한 네트워크 요청이나 예외가 발생할 수 있다는 점입니다.uuid is not None분기는 직접 필드 추출 경로를 따라 「UUID는 있지만 데이터가 없는」 상황을 올바르게 처리합니다. 참조:📎 vllm/entrypoints/chat_utils.py:1713-1723및📎 vllm/entrypoints/chat_utils.py:1731-1733。

Q2: AsyncMultiModalItemTracker.resolve_items기본asyncio.gather(..., return_exceptions=True)대신return_exceptions=False을 사용합니다.False로 변경하면 어떤 동시성 시나리오에서 리소스 누수가 발생하는가?

참고 해석:return_exceptions=False시,asyncio.gather은 첫 번째 예외가 발생하면 즉시 반환하지만, 아직 진행 중인 다른 작업들은 취소되지 않고 백그라운드에서 계속 실행됩니다. 이 작업들은 네트워크 연결, 스레드 풀 작업 항목, 파일 핸들을 보유할 수 있습니다. 이 작업들이 최종적으로 실패하면 예외는 조용히 버려지고(gather가 이미 반환되었으므로), 리소스 누수와 추적하기 어려운 오류가 발생합니다.return_exceptions=True모든 작업이 완료되거나 실패할 때까지 기다린 후 일괄적으로 검사하여, 버려지는 작업이 없도록 보장합니다. 주석은 이 점을 명확히 설명합니다: 「Gathering with return_exceptions=True lets every task finish (or itself fail) before we raise, instead of abandoning still-in-flight fetches (real network/thread-pool work) the moment the first one fails.」참조📎 vllm/entrypoints/chat_utils.py:924-931。

Q3: _postprocess_messages에서,arguments가 유효하지 않은 JSON일 때, 코드는 예외를 발생시키는 대신 강제로 빈 객체로 변환합니다. 만약 예외를 발생시키도록 변경한다면, 어떤 프로덕션 시나리오에서 복구 불가능한 대화 상태가 발생할까요?

참고 분석:arguments필드가 대화 기록에 존재합니다 (assistant 메시지의tool_calls). 만약 특정 턴에서 모델이 잘못된 형식의arguments를 생성했다면, 이 오류는 대화 기록에 저장됩니다. 만약_postprocess_messages가 기록을 파싱할 때 예외를 발생시킨다면, 이후 모든 턴의 요청이 기록에 있는 이 오류 때문에 실패하게 됩니다 — 현재 턴의 입력이 완전히 올바르더라도 마찬가지입니다. 사용자는 이 대화를 계속할 수 없고, 전체 세션을 포기하고 처음부터 다시 시작해야만 합니다. 강제로 빈 객체로 변환하면 대화를 계속할 수 있고, 모델은 빈 도구 인자를 보고 올바른 호출을 다시 생성합니다. 주석은 이 점을 설명합니다: 「A malformed arguments string lives in conversation history, so failing the request here would fail every subsequent turn too and leave the conversation unrecoverable.」참조📎 vllm/entrypoints/chat_utils.py:2124-2139。

다음 장에서는 스케줄러로 들어가서, EngineCore가 연속 배칭과 VRAM 인식 전략으로 이러한 요청들을 어떻게 편성하는지 살펴봅니다.

여기까지 요청은 외부 입력에서 EngineCoreRequest로의 정규화 변환을 완료하고 엔진 코어의 입구에 도달했습니다. 하지만 요청은 들어온 후 즉시 실행되지 않습니다 — 엔진은 각 단계에서 어떤 요청을 처리할지, 한정된 VRAM 자원을 어떻게 할당할지 결정해야 합니다. 다음 장에서는 EngineCore의 스케줄링 루프를 깊이 파고들어, Scheduler가 연속 배칭에서 처리량과 지연을 어떻게 저울질하는지, 그리고 chunked prefill, prefix caching, KV block 할당이 어떻게 협력하여 작동하는지 분석합니다.

CHAPTER 04

제 4 장: 스케줄러: 연속 배칭과 VRAM 인식 요청 편성

소속 프로젝트: vllm-project/vllm · 전체 진행률: 제 4 / 14 장 · 검증 상태: FACT 행 번호 실제 앵커링

요청이 EngineCore의 입력 큐에 들어간 후, 즉시 실행되지 않습니다. 각 단계에서 어떤 요청을 처리할지, 각 요청에 얼마나 많은 token 예산을 할당할지, VRAM이 부족할 때 누구를 우선적으로 희생할지, 이러한 결정들은 모두Scheduler.schedule()메서드에 집중되어 있습니다. 이 장에서는 스케줄러의 데이터 구조부터 시작하여, 한 번의schedule()호출이 waiting 큐, running 리스트, KV cache 풀을 어떻게 실행 가능한 배치로 구성하는지 추적합니다.

4.1 스케줄러의 데이터 구조: 세 개의 큐와 하나의 VRAM 풀

스케줄러가 답해야 할 핵심 질문은:한정된 token 예산과 KV block 예산 아래에서, 이 단계에서 어떤 요청들을 얼마나 많은 token만큼 전진시켜야 하는가?이를 이해하려면, 먼저 그것이 어떤 상태를 쥐고 있는지 봐야 합니다.

스케줄러는 세 가지 유형의 요청 컨테이너를 유지합니다.self.requests는 전역 딕셔너리로,req_id -> Request, 모든 활성 요청의 유일한 진실 원천📎 vllm/v1/core/sched/scheduler.py:208-209。self.waiting와self.skipped_waiting는 두 개의 우선순위 큐로, 전자는 정상적으로 스케줄링을 기다리는 요청을 넣고, 후자는 비동기 의존성이나 제약으로 인해 일시적으로 스케줄링할 수 없는 요청을 넣습니다 (예: 원격 KV 대기, 구조화된 출력 문법 컴파일 대기)📎 vllm/v1/core/sched/scheduler.py:208-209。self.running는 일반 리스트로, 이미 실행 상태에 들어가 KV block을 보유한 요청을 저장합니다📎 vllm/v1/core/sched/scheduler.py:208-209。

여기에 간과하기 쉬운 설계가 있습니다:max_num_running_reqs와max_num_active_reqs는 두 개의 서로 다른 상한입니다. 전자는max_num_seqs에서 오며, model runner의 슬롯 수를 결정합니다; 후자는max_num_active_seqs에서 오며, RUNNING에 들어갈 수 있는 요청 수만 제한하고, 기본값은 전자와 같습니다📎 vllm/v1/core/sched/scheduler.py:123-131. 이 분리는 CUDA graph 캡처 용량을 줄이지 않으면서 실제 동시 디코딩 배치 크기를 낮출 수 있게 해줍니다.

VRAM 측은KVCacheManager이 통합 관리하며, 내부적으로BlockPool。BlockPool를 보유합니다.self.blocks의 핵심은KVCacheBlock(모든free_block_queue의 리스트)와📎 vllm/v1/core/block_pool.py:171-177(축출 순서로 배열된 빈 블록 이중 연결 리스트)입니다null_block. 주목할 점은is_null=True의 존재입니다: 이것은 빈 큐 헤드에서 팝된 첫 번째 블록으로,📎 vllm/v1/core/block_pool.py:183-187, 참조 카운트는 일반 유지보수에 참여하지 않고, 전용으로 플레이스홀더로 사용됩니다

. 요청의 특정 token 위치에 실제 KV block이 필요하지 않을 때 (예: 슬라이딩 윈도우에 의해 건너뛰어진 위치), block table에 이 null block을 채웁니다.BlockHashToBlockMap프리픽스 캐싱의 인덱스 구조는BlockHashWithGroupId이며,KVCacheBlock를{block_id: KVCacheBlock}또는📎 vllm/v1/core/block_pool.py:56-59. 왜 유니온 타입을 사용하는가? 주석이 답을 제시한다: 대부분의 해시는 단 하나의 블록에만 대응하므로 딕셔너리를 사용하면 불필요한 GC 오버헤드가 발생한다; 동일한 해시가 여러 블록에 의해 공유될 때만 딕셔너리로 승격된다📎 vllm/v1/core/block_pool.py:56-59. 이것은 타입 복잡도로 런타임 오버헤드를 교환하는 전형적인 트레이드오프이다.

KVCacheBlocks. 스케줄러와 KV cache 관리자 사이의 인터페이스 객체로, 내부 데이터 구조를 숨긴다.它的blocks. 필드는tuple[Sequence[KVCacheBlock], ...]. 이며, 외부 차원은 KV cache group, 내부는 블록 시퀀스이다📎 vllm/v1/core/kv_cache_manager.py:41-54. 주석은 왜 블록을 외부 차원으로 사용하지 않는지 명확히 설명한다: 그것은 모든 group의 블록 수가 동일하다고 가정하게 되는데, 미래에는 서로 다른 group에 서로 다른 block size를 설정할 수 있기 때문이다📎 vllm/v1/core/kv_cache_manager.py:43-48。

mermaid
flowchart LR
    subgraph Sched["Scheduler 状态"]
        W["waiting<br/>RequestQueue"]
        SW["skipped_waiting<br/>RequestQueue"]
        R["running<br/>list[Request]"]
        REQ["requests<br/>dict[str, Request]"]
    end
    subgraph KV["KVCacheManager"]
        BP["BlockPool.blocks<br/>list[KVCacheBlock]"]
        FQ["free_block_queue<br/>FreeKVCacheBlockQueue"]
        MAP["cached_block_hash_to_block<br/>BlockHashToBlockMap"]
    end
    W -->|"admit + allocate_slots"| R
    R -->|"preempt"| W
    R -->|"free / pop_blocks_for_free"| FQ
    FQ -->|"get_new_blocks"| BP
    BP -->|"cache_full_blocks"| MAP
    MAP -->|"get_cached_block"| W

. 이 그림은 스케줄러와 VRAM 풀 사이의 데이터 흐름을 고정한다: waiting 큐의 요청은allocate_slots. 을 통해 running으로 진입하고, running의 요청이 선점되면 waiting으로 돌아가며, 해제된 블록은 유휴 큐로 반환되고, 프리픽스 캐시 해시 테이블은 waiting 요청이 캐시를 히트하는 진입점이다.

. 4.2 schedule() 메인 흐름: running 우선, waiting 보충, 선점 폴백

schedule(). 은 전체 스케줄러의 핵심 메서드로,SchedulerOutput. 을 반환하여 이 단계에서 무엇을 실행할지 설명한다. 메서드 서두의 주석은 설계 철학을 밝힌다: 스케줄러에는 "디코드 단계"와 "프리필 단계"의 구분이 없으며, 각 요청은 오직num_computed_tokens. 과num_tokens_with_spec. 만 가지고, 스케줄러의 임무는 전자가 후자를 따라잡게 하는 것이다📎 vllm/v1/core/sched/scheduler.py:559-568. 이러한 통합적 관점은 chunked prefill, prefix caching, 추측 디코딩이 공존할 수 있는 기반이다.

. 4.2.1 예산 초기화와 임계값 계산

. 메인 루프에 진입하기 전에 스케줄러는 먼저 두 가지 예산을 설정한다:token_budget. 은max_num_scheduled_tokens,input_budget. 으로 초기화되고, 은 으로 초기화된다. 둘은 일반적으로 동일하지만, 모델이 배치에서 토큰을 추가할 수 있는 경우(예: 추측 디코딩),max_num_batched_tokens 📎 vllm/v1/core/sched/scheduler.py:577-580. 은max_num_scheduled_tokens. 보다 작아지며, 그 차이가 draft token을 위한 공간이다.max_num_batched_tokens. 의 처리는 별도로 볼 가치가 있다. 그 역할은 긴 prefill이 다른 요청을 기아 상태로 만드는 것을 방지하는 것이지만, 현재 요청이 하나뿐이라면 기아 상태가 될 대상이 없으므로 임계값은 0으로 설정된다

long_prefill_token_threshold. 이📎 vllm/v1/core/sched/scheduler.py:606-616. 활성화되면 임계값은adaptive_long_prefill_threshold. 으로 높아져, 단일 요청의 예산이 공정한 몫 이하로 압축되지 않도록 보장한다input_budget // num_eligible_reqs. 4.2.2 running 요청의 스케줄링 루프📎 vllm/v1/core/sched/scheduler.py:617-622。

. 메인 루프는

. 의 헤드부터 순회를 시작하며,self.running. 은 커서이다req_index. 각 요청에 대해 먼저 일련의 건너뛰기 판단을 수행한다:📎 vllm/v1/core/sched/scheduler.py:624-627. 비동기 스케줄링에서 요청의 출력 플레이스홀더가 이미

  • . 에 도달했음을 나타내면, 한 단계 더 실행하는 것을 피하기 위해 건너뛴다max_tokens. V2 + PP + 비동기 시나리오에서 현재 단계가 아직📎 vllm/v1/core/sched/scheduler.py:631-645。
  • . 에 도달하지 않았다면, worker 측의 샘플링 토큰 브로드캐스트 리듬에 맞추기 위해 건너뛴다next_decode_eligible_step. DP prefill 균형이 활성화되면, 리듬 정렬 단계가 아닌 곳의 prefill chunk는 지연된다📎 vllm/v1/core/sched/scheduler.py:647-651。
  • . 건너뛰기 판단을 통과하면, 이 요청이 이번 단계에서 얼마나 많은 토큰을 진행할 수 있는지 계산한다:📎 vllm/v1/core/sched/scheduler.py:653-657。

. 복사

code
num_new_tokens = request.num_tokens_with_spec
               + request.num_output_placeholders
               - request.num_computed_tokens

. 과long_prefill_token_threshold、token_budget、input_budget - draft_slots. 에 의해 제약된다max_model_len. 요청에 인코더 입력이 있으면📎 vllm/v1/core/sched/scheduler.py:670-688. 을 거쳐 조정된다_try_schedule_encoder_inputs. 다음이 가장 핵심적인 단계이다: KV block 할당.📎 vllm/v1/core/sched/scheduler.py:700-712。

. 은allocate_slots. 루프 안에 감싸져 있다while True. 이📎 vllm/v1/core/sched/scheduler.py:742-747. 을 반환하면 VRAM이 부족하다는 의미이므로, 스케줄러는 선점을 시작한다: 전략에 따라 희생자를 선택하고(PRIORITY 전략은 우선순위가 가장 낮은 것을, FCFS 전략은 running 리스트 끝의 것을 선택)None. ,📎 vllm/v1/core/sched/scheduler.py:761-767. 을 호출하여 waiting 큐로 되돌린 후 할당을 재시도한다_preempt_request. 희생자가 현재 요청 자신이라면 선점할 대상이 더 이상 없다는 의미이므로 루프를 빠져나오고, 현재 요청도 스케줄링할 수 없다📎 vllm/v1/core/sched/scheduler.py:801-806. 선점 로직에는 정교한 디테일이 하나 있다: PRIORITY 전략에서 선점된 요청이 이미📎 vllm/v1/core/sched/scheduler.py:807-813。

. 에 있는 경우(즉, 이번 단계에서 이미 리소스가 할당된 경우), 해당 요청의 토큰 예산, block, 추측 토큰, 인코더 예산을 모두 반환해야 한다scheduled_running_reqs. 이는 예산 장부의 일관성을 보장한다.📎 vllm/v1/core/sched/scheduler.py:779-797. 할당이 성공하면 요청은

. 에 추가되고, block과 토큰 수를 기록하며, 예산을 차감한다scheduled_running_reqs. 추측 디코딩 관련 토큰은 여기서 트리밍되고 기록된다📎 vllm/v1/core/sched/scheduler.py:815-823. 4.2.3 waiting 요청의 진입 허용📎 vllm/v1/core/sched/scheduler.py:825-841。

. running 루프가 끝난 후, 이번 단계에서 선점이 발생하지 않았고 스케줄러가 일시 중지되지 않았다면 waiting 큐 처리를 시작한다

. 진입 허용 전에 두 가지 상한을 확인한다:📎 vllm/v1/core/sched/scheduler.py:868-872. 과max_num_active_reqs. waiting 요청의 스케줄링은 running보다 프리픽스 캐시 조회 단계가 하나 더 있다.当input_budget 📎 vllm/v1/core/sched/scheduler.py:873-879。

. 일 때,request.num_computed_tokens == 0. 을 호출하여 로컬 캐시 히트를 조회한다_get_local_prefix_cache_hit. KV connector가 구성되어 있으면 원격 캐시 히트도 조회한다📎 vllm/v1/core/sched/scheduler.py:932-939. 여기에는 로컬과 원격 히트 충돌을 처리하는 정교한 로직이 있다. 로컬 히트는 블록 정렬이 아닐 수 있으며(📎 vllm/v1/core/sched/scheduler.py:942-954。

. ), 원격 히트가 로컬 완전 히트를 엄격히 초과하면 로컬의 하위 블록 꼬리를 버리고 원격 로드가 이를 덮어쓰게 하여 쓰기 시 복사를 방지한다partial_tail. 반대로 로컬 꼬리를 유지하고 외부를 로드하지 않는다📎 vllm/v1/core/sched/scheduler.py:977-988. 진입 허용이 성공하면 요청은 waiting 큐에서 팝되고, 상태가 RUNNING으로 설정되며, running 리스트에 추가된다📎 vllm/v1/core/sched/scheduler.py:989-995。

. 이번 단계 이후에도 여전히 prefill 중이면(📎 vllm/v1/core/sched/scheduler.py:1263-1319. ),num_computed_tokens + num_new_tokens < request.num_tokens. 집합에 추가된다_inflight_prefills. 복사📎 vllm/v1/core/sched/scheduler.py:1326-1328。

mermaid
flowchart TD
    start["schedule() 开始"] --> init["初始化 token_budget / input_budget"]
    init --> run_loop{"running 循环<br/>req_index < len(running)<br/>且 token_budget > 0?"}
    run_loop -->|是| skip_check{"跳过条件?<br/>max_tokens 已达 /<br/>decode_eligible / defer_prefills"}
    skip_check -->|跳过| run_inc["req_index += 1"]
    run_inc --> run_loop
    skip_check -->|不跳过| calc["计算 num_new_tokens<br/>受多约束裁剪"]
    calc --> alloc{"allocate_slots<br/>返回 None?"}
    alloc -->|成功| admit_run["加入 scheduled_running_reqs<br/>扣减预算"]
    admit_run --> run_inc
    alloc -->|失败| can_preempt{"有可抢占请求?<br/>_request_blocks_can_be_freed"}
    can_preempt -->|否| break_run["跳出 running 循环"]
    can_preempt -->|是| preempt["_preempt_request<br/>踢回 waiting"]
    preempt --> alloc
    break_run --> wait_loop{"无抢占且未暂停?<br/>waiting 非空且 token_budget > 0?"}
    run_loop -->|否| wait_loop
    wait_loop -->|是| blocked{"blocked 状态?<br/>_is_blocked_waiting_status"}
    blocked -->|是且无法提升| skip_wait["移入 skipped_waiting"]
    skip_wait --> wait_loop
    blocked -->|否| prefix{"num_computed_tokens == 0?<br/>查找前缀缓存"}
    prefix -->|命中| alloc_wait["allocate_slots<br/>带 new_computed_blocks"]
    prefix -->|未命中| alloc_wait
    alloc_wait --> wait_ok{"分配成功?"}
    wait_ok -->|是| admit_wait["加入 running<br/>状态设为 RUNNING"]
    admit_wait --> wait_loop
    wait_ok -->|否| break_wait["跳出 waiting 循环"]
    wait_loop -->|否| build["构建 SchedulerOutput"]
    break_wait --> build

. 의 두 가지 주요 루프와 선점 분기를 포함한다. running 루프에서schedule(). 실패 후의 선점 재시도 경로, 그리고 waiting 루프에서 blocked 상태 요청이 이동되는 부분에 주목하라allocate_slots 失败后的抢占重试路径,以及 waiting 循环中 blocked 状态请求被移入 skipped_waiting의 바이패스.

4.3 메모리 인식의 핵심: allocate_slots와 선점

allocate_slots는 스케줄러와 메모리 사이의 게이트입니다. 그 파라미터 목록 자체가 하나의 메모리 장부입니다:num_new_tokens는 새로 계산할 token 수이고,num_new_computed_tokens는 프리픽스 캐시에서 새로 히트된 token 수이며,num_external_computed_tokens는 connector가 제공하는 외부 히트 수이고,num_lookahead_tokens는 추측 디코딩을 위해 예약된 슬롯입니다📎 vllm/v1/core/kv_cache_manager.py:371-383。

메서드 시작 부분의 주석은 ASCII 그림 한 장으로 블록 레이아웃을 정확히 설명합니다📎 vllm/v1/core/kv_cache_manager.py:417-438:

code
| < comp > | < new_comp > | < ext_comp >  | < new >  | < lookahead > |
                                          |   < to be computed >     |
                        |            < to be allocated >           |

comp는 이미 계산된 token이고,new_comp는 프리픽스 캐시 히트이며,ext_comp는 외부 히트이고,new는 이번 단계에서 새로 계산하는 것이며,lookahead는 추측 예약입니다. 할당은 세 단계로 나뉩니다: 먼저 불필요한 블록을 해제하고 충분한 빈 블록이 있는지 확인한 뒤, 프리픽스 token을 처리하고, 마지막으로 새로 계산할 token을 위해 블록을 할당합니다📎 vllm/v1/core/kv_cache_manager.py:458-461。

4.3.1 워터마크와 진입 제어

allocate_slots에는 두 개의 진입 게이트가 있습니다. 첫 번째는full_sequence_must_fit입니다: 활성화되면 전체 요청 시퀀스(첫 번째 chunk만이 아님)가 들어갈 수 있는지 먼저 확인하고, 들어갈 수 없으면 바로None 📎 vllm/v1/core/kv_cache_manager.py:515-531를 반환합니다. 이는 chunked prefill 상황에서 과도한 진입으로 인한 KV cache 요동을 방지합니다.

두 번째는 워터마크입니다.watermark_blocks는 요청 상태가 WAITING 또는 PREEMPTED이고 이미 요청이 스케줄된 경우에만 적용됩니다📎 vllm/v1/core/kv_cache_manager.py:506-513. 이는 할당 후 최소한 일정 비율의 빈 블록을 남겨두도록 요구하여 빈번한 축출과 선점을 피합니다.reserved_blocks는 비동기 KV 로딩 시나리오에 사용되어, 진행 중인 prefill의 예약 블록이 새 요청에 의해 잠식되지 않도록 보장합니다📎 vllm/v1/core/kv_cache_manager.py:564-570。

4.3.2 선점의 대가와 복구

〔설계 추론 및 아키텍처 트레이드오프〕

_preempt_request는 겉보기에는 폭력적이지만 필요한 일을 합니다: 요청의num_computed_tokens를 0으로 재설정합니다📎 vllm/v1/core/sched/scheduler.py:1560-1561. 이는 선점된 요청이 다음 스케줄링 시 처음부터 다시 prefill해야 함을 의미합니다. 왜 이렇게 설계했을까요? vLLM의 KV block은 요청 전용이므로 선점 시 모든 블록을 해제해야 하고, 해제 후 재할당 시 동일한 블록을 얻을 수 있다는 보장이 없기 때문에 처음부터 계산할 수밖에 없습니다. 프리픽스 캐시의 존재가 이 비용을 부분적으로 상쇄합니다: 선점된 요청의 프리픽스가 이미 캐시되어 있다면 재스케줄링 시 캐시를 히트할 수 있어 실제로 다시 계산할 필요가 없습니다.

선점은 또한 비동기 스케줄링에서의 "오래된 출력" 문제도 처리합니다.num_stale_output_tokens가num_in_flight_tokens로 설정되어, 모든 진행 중인 출력을 오래된 것으로 표시합니다📎 vllm/v1/core/sched/scheduler.py:1571-1574. 이 token들은 여전히 전달되지만(버리면 추측 디코딩 수용률이 교란됨), 재설정된 카운터는 수정하지 않습니다.drop_stale_output플래그는 버릴지 전달할지를 결정합니다📎 vllm/v1/core/sched/scheduler.py:1539-1547。

4.3.3 지연 해제: 비동기 connector의 쓰기 후 읽기 위험

KV connector를 사용하고 여러 진행 중인 배치가 있을 때,defer_block_free가True 📎 vllm/v1/core/sched/scheduler.py:175-181로 설정됩니다. 이유는: 한 단계가 아직 해제된 요청의 KV 블록에 쓰고 있을 수 있는데, 소비자 connector가 그 쓰기와 정렬되지 않은 로드를 통해 이 블록들을 재할당하고 채울 수 있기 때문입니다.

지연 해제는deferred_frees덱으로 구현되며, 각 항목은(fence_seq, blocks) 📎 vllm/v1/core/sched/scheduler.py:388-390。_free_request_blocks입니다_request_blocks_can_be_freed를 확인하여, 요청의 마지막 스케줄링 단계가 아직 처리되지 않았다면 블록을 지연 큐에 넣습니다📎 vllm/v1/core/sched/scheduler.py:2679-2688。_drain_deferred_frees는update_from_output에서processed_step_seq를 진행한 후 호출되어, fence가 충족된 블록을 해제합니다📎 vllm/v1/core/sched/scheduler.py:2701-2706。

4.4 프리픽스 캐시 히트 판정과 블록 수명 주기

프리픽스 캐시의 조회 진입점은KVCacheManager.get_computed_blocks입니다. 먼저 캐시가 활성화되었고 요청이 읽기 건너뛰기로 표시되지 않았는지 확인합니다📎 vllm/v1/core/kv_cache_manager.py:286-287. 그런 다음coordinator.find_longest_cache_hit를 호출하여request.block_hashes와max_cache_hit_length = request.num_tokens - 1 📎 vllm/v1/core/kv_cache_manager.py:295-300。

를 전달합니다num_tokens - 1왜📎 vllm/v1/core/kv_cache_manager.py:289-294일까요? 주석은 다음과 같이 설명합니다: 모든 token이 캐시를 히트할 때 logits를 얻으려면 마지막 token을 다시 계산해야 합니다

. 이는 간과하기 쉬운 경계 조건입니다: 프리픽스가 완전히 히트하더라도 최소한 하나의 token은 계산해야 합니다.BlockPool블록의 수명 주기는get_new_blocks가 관리합니다._maybe_evict_cached_block는 빈 큐의 헤드에서 블록을 꺼내고, 캐시가 활성화되어 있으면 먼저📎 vllm/v1/core/block_pool.py:683-702。free_blocks를 호출하여 해시 메타데이터를 지운 뒤 참조 카운트를 증가시킵니다📎 vllm/v1/core/block_pool.py:785-805。

cache_full_blocks는 블록에 해시가 있는지에 따라 큐의 헤드로 되돌릴지 테일로 되돌릴지 결정합니다: 해시가 없는 블록은 LIFO 재사용(더 나은 GPU 지역성), 해시가 있는 블록은 FIFO 재사용(LRU 축출 동작)입니다cached_block_hash_to_block 📎 vllm/v1/core/block_pool.py:272-300는 블록이 프리픽스 캐시 해시 테이블에 기록되는 시점입니다. 새로 가득 찬 블록을 순회하며 null 블록과 mask된 블록을 건너뛰고, 각 블록의 해시를 계산하여📎 vllm/v1/core/block_pool.py:285-293。

touch에 삽입합니다. 블록에 이미 해시가 있는 경우(부분 블록이 가득 찬 블록으로 승격되는 시나리오), 먼저 기존 해시를 제거한 뒤 새 해시를 삽입합니다ref_cnt == 0메서드는 캐시 히트 시의 참조 카운트를 처리합니다: 블록이 빈 큐에 있으면(📎 vllm/v1/core/block_pool.py:754-770), 먼저 큐에서 제거한 뒤 참조 카운트를 증가시킵니다

. 이는 히트된 블록이 축출되지 않도록 보장합니다.

설계 고찰

〔설계 추론 및 아키텍처 트레이드오프〕왜 선점은 "부분 보존"이 아니라 "처음부터 재계산"을 선택할까요?

부분 보존은 선점 시 각 요청의 블록 물리 위치를 기록하고, 재스케줄링 시 매핑 복원을 시도해야 합니다. 하지만 블록 풀은 전역 공유이므로 다른 요청이 이미 그 블록들을 점유했을 수 있습니다. 이러한 매핑을 유지하는 복잡도와 메모리 오버헤드는 재계산 비용을 초과합니다. 특히 프리픽스 캐시가 대부분의 프리픽스를 히트할 수 있는 경우에는 더욱 그렇습니다.

〔설계 추론 및 아키텍처 트레이드오프〕워터마크는 왜 기본값이 0일까요?

워터마크는 빈번한 선점을 방지하는 보험이지만, 메모리 활용률을 희생하는 대가를 치릅니다. 기본적으로 꺼져 있다는 것은 vLLM이 안정성보다 처리량을 우선한다는 의미이며, 사용자는 부하 특성에 따라 직접 활성화해야 합니다.

skipped_waiting〔설계 추론 및 아키텍처 트레이드오프〕이 큐가 없다면, 블로킹된 요청이 계속 waiting 큐의 헤드를 차지하여 뒤의 요청들이 스케줄링될 수 없게 된다(FCFS 전략 하에서). 이를 분리하면 스케줄러가 블로킹된 요청을 건너뛰고 뒤의 요청을 계속 처리할 수 있으며, 동시에 블로킹된 요청의 상태를 보존하여 이후 승격할 수 있다.

이 장의 요약

스케줄러의 핵심은schedule()메서드 내의 두 루프이다: running 루프는 이미 실행 중인 요청의 전진을 우선 보장하고, waiting 루프는 예산이 허용할 때 새로운 요청을 승인한다. VRAM이 부족하면 running 리스트에서 우선순위가 가장 낮은 요청을 선점하여 공간을 확보하고, 선점된 요청의num_computed_tokens은 0으로 리셋되지만, 프리픽스 캐싱이 재계산 비용의 일부를 상쇄할 수 있다.allocate_slots은 VRAM 게이트이며,full_sequence_must_fit, 워터마크, 그리고reserved_blocks세 계층의 승인 제어를 통해 과다 할당을 방지한다. 프리픽스 캐싱은 블록 해시 인덱스를 통해 요청 간 공유를 구현하며, 히트 판정은num_tokens - 1을 상한으로 하여 최소 하나의 토큰을 계산하여 logits를 얻도록 보장한다.

이 장의 생각과 자가 점검

Q1:schedule()의 running 루프에서, 만약allocate_slots이None를 반환하고_request_blocks_can_be_freed이 희생자에 대해False를 반환하면, 코드는break루프를 빠져나간다. 이 검사를 제거하고 직접_preempt_request을 호출하면, 어떤 시나리오에서 상태 불일치가 발생하는가?

참고 해석:_request_blocks_can_be_freed검사request.last_sched_seq <= self.processed_step_seq 📎 vllm/v1/core/sched/scheduler.py:2672-2677.defer_block_free이 활성화되어 있을 때, 희생자의 마지막 스케줄링 단계가 아직 처리되지 않았다면, 그 블록은 여전히 인플라이트 GPU 단계에 의해 쓰여지고 있을 수 있다. 직접 선점하면_free_request_blocks을 호출하는데, 후자는_request_blocks_can_be_freed이False일 때 블록을deferred_frees에 넣고 즉시 해제하지 않는다📎 vllm/v1/core/sched/scheduler.py:2679-2688. 그러나 선점의 의미는 "현재 요청을 위해 즉시 블록을 확보한다"이므로, 지연 해제는 이 요구를 충족할 수 없고,allocate_slots은 다시 실패하여 무한 루프를 형성한다. 더 심각한 것은, 희생자의 블록이 지연 해제된 후 현재 요청에 의해 할당되고, GPU가 여전히 희생자의 블록에 쓰고 있다면 데이터 레이스가 발생한다.

Q2: get_computed_blocks에서max_cache_hit_length = request.num_tokens - 1. 만약request.num_tokens로 변경하면, 어떤 경우에 출력 오류가 발생하는가?

참고 해석: 요청의 모든 토큰이 캐시에 히트하면,num_computed_tokens은num_tokens과 같아진다. 이때 스케줄러는 새로운 토큰을 계산할 필요가 없다고 판단하지만, logits 샘플링에는 마지막 위치의 히든 스테이트가 필요하고, 히든 스테이트는 순전파에서 나온다. 어떤 토큰도 계산되지 않으면 샘플링할 logits가 없어 요청이 멈추거나 잘못된 출력을 생성한다. 주석이 이를 명확히 설명한다📎 vllm/v1/core/kv_cache_manager.py:289-294. 또한,allocate_slots은num_computed_tokens이 블록 크기에 정렬되어야 하며, 마지막 토큰을 재계산하면 전체 블록의 재계산을 트리거할 수 있는데, 이는 현재 구현의 알려진 제한 사항이다.

Q3: _preempt_request은num_computed_tokens을 0으로 리셋하지만,request.num_tokens(prompt + 생성된 토큰)은 보존한다. 선점된 요청이 재스케줄링될 때 프리픽스 캐시가 미스하면, 얼마나 많은 토큰을 재계산해야 하는가? 히트하면 얼마나 절약되는가?

참고 해석:num_computed_tokens = 0은 재스케줄링 시 첫 번째 토큰부터 시작함을 의미한다📎 vllm/v1/core/sched/scheduler.py:1561。request.num_tokens은 변경되지 않고 유지되며, 원래 prompt와 생성된 출력 토큰을 포함한다. 프리픽스 캐시가 미스하면 전체num_tokens개 토큰의 prefill을 재계산해야 한다. 히트하면,get_computed_blocks은 히트된 블록을 반환하고,num_computed_tokens은 히트 위치부터 시작한다📎 vllm/v1/core/kv_cache_manager.py:296-300. 선점된 요청의 출력 토큰도num_tokens에 있으며, 그들의 프리픽스 해시는 생성 시 캐시되었다(활성화된 경우). 따라서 재스케줄링 시 이 출력 토큰들의 프리픽스도 히트할 수 있다. 그러나max_cache_hit_length = num_tokens - 1은 마지막 토큰이 항상 재계산되어야 함을 의미한다.

스케줄러가 출력하는SchedulerOutput은 이 단계의 실행 내용을 명확히 한다: 새 요청의 블록 ID, 캐시된 요청의 토큰 수, 투기 토큰, 인코더 입력 등. 다음 장에서는 이 출력이 ModelRunner에 의해 어떻게 소비되는지 추적하며,SchedulerOutput부터 GPU 순전파까지 따라간다.

CHAPTER 05

제5장: 모델 실행 주간: SchedulerOutput에서 GPU 순전파까지

소속 프로젝트: vllm-project/vllm · 전체 진행률: 제5 / 14장 · 검증 상태: FACT 행 번호 실제 앵커링

이전 장에서 우리는 Scheduler가 각 단계의 스케줄링 루프에서 어떤 요청이 running 큐에 들어가고, 어떤 것이 선점되며, 어떤 것이 VRAM 부족으로 대기하는지를 결정하고, 최종적으로 SchedulerOutput을 생성하는 것을 보았다——이는 이 단계에서 무엇을 계산해야 하는지를 설명한다: 어떤 요청, 각각 얼마나 많은 토큰, 어떤 KV block을 사용하는지. 그러나 이 목록은 논리적 의도일 뿐이며, GPU가 필요로 하는 것은 물리적 텐서이다. 이 장에서는 SchedulerOutput이 Executor에 의해 Worker로 분배되고, 다시 GPUModelRunner에 의해 input_ids, positions, slot_mapping, block table 등 GPU에서 실행 가능한 입력으로 변환되며, 최종적으로 forward_context를 통해 계층 간 공유 배치 설명을 모델의 각 계층에 주입하여 스케줄링 결정에서 순전파까지의 도약을 완성하는 과정을 추적한다.

5.1 Executor: 스케줄링 결과를 각 카드에 전달

직관적 모델

Executor은 EngineCore와 GPU Worker 사이의 「전령관」이다. 이것이 없다면, EngineCore는 클러스터에 몇 장의 카드가 있고, 각 카드가 어느 프로세스에 있으며, 어떻게SchedulerOutput과거를 직렬화하는 것—스케줄링 로직이 분산 토폴로지와 얽히게 된다.Executor이 책임을 분리한다: EngineCore는 호출만 담당하고execute_model(scheduler_output), 나머지 「누구에게 보낼지, 어떻게 보낼지, 몇 개의 결과를 받을지」는 Executor가 결정한다.

클래스 계층과 필드

Executor는 추상 기본 클래스이며, 클래스 수준 필드가 백엔드 능력을 직접 인코딩한다📎 vllm/v1/executor/abstract.py:48-49:

python
uses_ray: bool = False  # whether the executor uses Ray for orchestration.
supports_pp: bool = False  # whether the executor supports PP

이 두 플래그는 장식용이 아니다—상위 코드가 이들을 읽어 특정 최적화 경로를 활성화할지 결정한다.__init__에서 초기화된다sleeping_tags、kv_output_aggregator、ec_output_aggregator세 가지 상태 필드📎 vllm/v1/executor/abstract.py:119-120는 각각 슬립 모드 라벨 추적, KV 커넥터 출력 집계, 인코더 커넥터 출력 집계에 사용된다.

백엔드 선택:get_class의 분기 라우팅

get_class는 정적 팩토리로,distributed_executor_backend설정에 따라 구체적인 Executor 클래스를 반환한다📎 vllm/v1/executor/abstract.py:51-96. 그 분기 구조는 자세히 볼 가치가 있다:

  • 설정 자체가type인 경우, 그것이Executor의 서브클래스인지 검증한 후 직접 사용한다📎 vllm/v1/executor/abstract.py:52-61;
  • "ray"분기 아래에 2차 분기가 있다:VLLM_USE_RAY_V2_EXECUTOR_BACKEND가 참이면RayExecutorV2를 사용하고, 그렇지 않으면RayDistributedExecutor 📎 vllm/v1/executor/abstract.py:64-72;
  • "mp"를 사용한다MultiprocExecutor,"uni"는UniProcExecutor 📎 vllm/v1/executor/abstract.py:73-80;
  • 로 매핑된다resolve_obj_by_qualname문자열 형태의 커스텀 백엔드는📎 vllm/v1/executor/abstract.py:85-90。
mermaid
flowchart TD
    start["Executor.get_class(vllm_config)"] --> check_type{"backend 是 type?"}
    check_type -->|是| verify_sub{"issubclass(Executor)?"}
    verify_sub -->|否| err_type["raise TypeError"]
    verify_sub -->|是| use_direct["executor_class = backend"]
    check_type -->|否| check_ray{"backend == 'ray'?"}
    check_ray -->|是| ray_v2{"VLLM_USE_RAY_V2?"}
    ray_v2 -->|是| use_rayv2["RayExecutorV2"]
    ray_v2 -->|否| use_ray["RayDistributedExecutor"]
    check_ray -->|否| check_mp{"backend == 'mp'?"}
    check_mp -->|是| use_mp["MultiprocExecutor"]
    check_mp -->|否| check_uni{"backend == 'uni'?"}
    check_uni -->|是| use_uni["UniProcExecutor"]
    check_uni -->|否| check_ext{"backend == 'external_launcher'?"}
    check_ext -->|是| use_ext["ExecutorWithExternalLauncher"]
    check_ext -->|否| check_str{"backend 是 str?"}
    check_str -->|是| resolve["resolve_obj_by_qualname"]
    check_str -->|否| err_unknown["raise ValueError"]

복사execute_modelStep-by-Step: 한 번의

호출 흐름SchedulerOutput시나리오를 대입하면: EngineCore가 한 단계 스케줄링을 완료하고executor.execute_model(scheduler_output)。

Executor.execute_model를 얻어📎 vllm/v1/executor/abstract.py:237-238:

python
def execute_model(
    self, scheduler_output: SchedulerOutput, non_block: bool = False
) -> ModelRunnerOutput | None | Future[ModelRunnerOutput | None]:
    output = self.collective_rpc(
        "execute_model", args=(scheduler_output,), non_block=non_block
    )
    return output[0]
의 구현은 극도로 간결하다

복사collective_rpc〔설계 추론과 아키텍처 트레이드오프〕output[0]핵심은output[0]에 있다—그것은 메서드 이름과 인자를 모든 Worker에 브로드캐스트하고, 각 Worker의 반환값 리스트를 수집한 다음collective_rpc첫 번째만 취한다. 왜 첫 번째만 취하는가? 텐서 병렬화에서 모든 Worker는 동일한 논리적 전방향을 실행하며 출력은 의미상 동등하기 때문이다; 샘플링 결과는 마지막 PP stage 또는 rank 0에 의해 결정되므로,📎 vllm/v1/executor/abstract.py:220-221를 취하는 것이 중복 집계를 피한다.SchedulerOutput의 문서는 명확히 「제어 메시지만 전달하고, 데이터 평면 통신은 별도로 구축하라」고 권장한다

sample_tokens, 이것이 바로📎 vllm/v1/executor/abstract.py:257-258의 위치다—그것은 제어 메시지이며, 실제 token 데이터는 GPU 텐서를 통해 Worker 내부에서 흐른다.None는 같은 패턴을 따른다execute_model, 하지만 반환 타입에None를 포함하지 않는다—샘플링은 반드시 결과를 산출한다. 이 두 메서드의 분업은 vLLM v1의 「실행-샘플링 분리」 설계에 대응한다:ExecuteModelState는

를 반환할 수 있다(전방향이 제출되었지만 샘플링이 지연됨을 의미), 이때 상태는

collective_rpc에 임시 저장된다.@abstractmethod 📎 vllm/v1/executor/abstract.py:186-192설계 사고MultiprocExecutor가RayDistributedExecutor로 선언된다는 것은, 서로 다른 백엔드가 「어떻게 RPC를 Worker로 보낼지」를 스스로 구현해야 함을 의미한다.UniProcExecutor는 공유 메모리 큐를 사용하고,

는 Ray actor 호출을 사용하며,supported_tasks는 직접 로컬 호출한다. 이러한 추상화 덕분에 상위 코드는 분산 세부사항을 전혀 신경 쓸 필요가 없다.@cached_property 📎 vllm/v1/executor/abstract.py:306-309쉽게 간과되는 세부사항:get_supported_tasks가

로 표시되어 있으며, 주석은 「불필요한 RPC 호출을 피하라」고 직언한다. 왜냐하면

는 프로세스 간 통신이 필요하고, 작업 목록은 모델 생명주기 동안 변하지 않으므로 캐싱은 정확하고 필수적인 최적화다.

GPUModelRunner5.2 GPUModelRunner: SchedulerOutput에서 입력 텐서로SchedulerOutput직관적 모델

는 「번역가」다: 그것은

GPUModelRunner안의 논리적 설명(요청 ID, token 수, 블록 ID)을 GPU가 직접 소비할 수 있는 물리적 텐서로 번역한다. 이것이 없다면, 모델 계층이 「3번째 요청의 7번째 token이 어느 KV 슬롯에 있는가」 같은 문제를 스스로 처리해야 한다—이것은 재앙적인 관심사 누출이다.📎 vllm/v1/worker/gpu_model_runner.py:479-480:LoRAModelRunnerMixin、KVConnectorModelRunnerMixin、ECConnectorModelRunnerMixin핵심 상태와 메모리 레이아웃

__init__는 세 가지 Mixin📎 vllm/v1/worker/gpu_model_runner.py:488-498을 상속하며, 각각 LoRA 어댑테이션, KV 커넥터, 인코더 커넥터 능력을 제공한다.

  • check_ep_fault에는 모든 설정 객체📎 vllm/v1/worker/gpu_model_runner.py:507-509;
  • is_pooling_model가 캐시되어 있으며, 몇 가지 핵심 플래그를 초기화한다:runner_type == "pooling": 데이터 병렬화 > 1이고 MoE 모델일 때만, EP all2all 관리자가 내결함성을 지원하는지 조회한다📎 vllm/v1/worker/gpu_model_runner.py:515;
  • enable_prompt_embeds:📎 vllm/v1/worker/gpu_model_runner.py:516。

ExecuteModelState에 의해 결정된다NamedTuple: prompt embedding 입력을 활성화할지 여부execute_model()는sample_tokens()이며,📎 vllm/v1/worker/gpu_model_runner.py:463-476와logits、hidden_states、sample_hidden_states사이의 임시 상태를 담는다spec_decode_metadata、slot_mappings. 그 필드 설계는 실행-샘플링 분리의 본질을 드러낸다:📎 vllm/v1/worker/gpu_model_runner.py:464-464。

Step-by-Step:_update_states는 전방향 산물이고,

는 샘플링 단계에서 여전히 필요한 메타데이터다. 주석은 이것이 「execute_model()이 None을 반환한 후 전달되는 임시 캐시 상태」라고 명확히 말한다

캐시 상태를 어떻게 동기화하는가시나리오를 대입하면: 스케줄러가 이번 단계에서 요청 A(새 요청), B(이전 단계의 decode 계속), C(선점 후 복구)를 처리하기로 결정하고, 동시에 요청 D는 완료되었다.finished_req_ids첫 번째 단계: 완료된 요청 정리.self.requests는input_batch를 순회하며,📎 vllm/v1/worker/gpu_model_runner.py:1202-1217딕셔너리에서 상태를 팝하고,finished_req_ids에서scheduled_req_ids를 제거한다. 주석이 지적하는 경계 사례에 주목하라:📎 vllm/v1/worker/gpu_model_runner.py:1211-1215。

와는 겹칠 수 있다—요청이 중단된 후 같은 ID로 다시 제출되면, 그들은 두 개의 다른 요청으로 간주된다new_block_ids_to_zero두 번째 단계: 새로 할당된 KV 블록 제로화._zero_block_ids만약📎 vllm/v1/worker/gpu_model_runner.py:1219-1222가 비어 있지 않으면,

를 호출하여 GPU 메모리를 제로화하고, 오래된 NaN이 어텐션 또는 SSM 계산을 오염시키는 것을 방지한다. 이것은 PagedAttention 블록 재사용의 안전 전제다.📎 vllm/v1/worker/gpu_model_runner.py:1238-1247:

python
scheduled_req_ids = scheduler_output.num_scheduled_tokens.keys()
cached_req_ids = self.input_batch.req_id_to_index.keys()
resumed_req_ids = scheduler_output.scheduled_cached_reqs.resumed_req_ids
unscheduled_req_ids = cached_req_ids - (scheduled_req_ids - resumed_req_ids)

이것이 가장 실수하기 쉬운 단계다scheduled_req_ids - resumed_req_ids복사scheduled_req_ids주석은 왜cached_req_ids이고 직접resumed_req_ids가 아닌지 설명한다: 일반적으로reset_prefix_cache와📎 vllm/v1/worker/gpu_model_runner.py:1241-1246。

는 서로소이지만,가 트리거한 강제 선점 시나리오에서는 복구된 요청을 영속 배치에서 먼저 제거한 후 다시 추가해야 한다scheduled_new_reqs네 번째 단계: 새 요청 처리.CachedRequestState 📎 vllm/v1/worker/gpu_model_runner.py:1295-1308각RANDOM_SEED에 대해torch.Generator 📎 vllm/v1/worker/gpu_model_runner.py:1277-1284를 구성한다. 샘플링 타입이_init_mrope_positions이면, 시드를 가진📎 vllm/v1/worker/gpu_model_runner.py:1319-1321。

를 생성한다. 모델이 M-RoPE를 사용하면,를 호출하여 위치를 미리 계산한다scheduled_cached_reqs다섯 번째 단계: 실행 중 요청 업데이트.num_computed_tokens 📎 vllm/v1/worker/gpu_model_runner.py:1402각📎 vllm/v1/worker/gpu_model_runner.py:1437-1448에 대해req_index is None를 업데이트하고, 블록 ID 추가 또는 교체를 처리한다reqs_to_add 📎 vllm/v1/worker/gpu_model_runner.py:1450-1465。

. 요청이 영속 배치에 없으면( condense()제거 요청이 남긴 빈 공간을 채움📎 vllm/v1/worker/gpu_model_runner.py:1511-1512,_may_reorder_batch어텐션 백엔드가 필요에 따라 재배열하도록 함📎 vllm/v1/worker/gpu_model_runner.py:1513-1514,refresh_metadata()배치 메타데이터를 새로 고침📎 vllm/v1/worker/gpu_model_runner.py:1515-1516。

입력 텐서 준비:_prepare_input_ids의 비동기 고속 경로

_prepare_input_ids미묘한 문제를 처리함: 비동기 스케줄링에서 이전 단계의 샘플링 token이 아직 GPU에 있고, 이번 단계의input_ids에 이를 채워 넣어야 함📎 vllm/v1/worker/gpu_model_runner.py:1767-1772。

정상 경로(prev_sampled_token_ids is None)는 CPU 텐서를 GPU로 직접 복사함📎 vllm/v1/worker/gpu_model_runner.py:1788-1794. 비동기 경로는 요청을 순회하며 각 요청의 마지막 token이 평탄화된input_ids에서의 인덱스를 계산함📎 vllm/v1/worker/gpu_model_runner.py:1809-1836. 주석에 구체적인 예시가 제시됨:cu_num_tokens = [2, 5, 8]、draft_tokens = [1, 2, 2]일 때,sample_flattened_indices = [0, 2, 5],spec_flattened_indices = [1, 3, 4, 6, 7] 📎 vllm/v1/worker/gpu_model_runner.py:1820-1822。

에는 핵심 최적화가 있음📎 vllm/v1/worker/gpu_model_runner.py:1859-1868:

python
if common_indices_match and max_flattened_index == (num_common_tokens - 1):
    self.input_ids.gpu[:num_common_tokens].copy_(
        self.input_batch.prev_sampled_token_ids[:num_common_tokens, 0],
        non_blocking=True,
    )
    return

배치가 변경되지 않았고 재배열이 없을 때, 인덱스는0..N-1의 동일한 순열이므로 단일 슬라이스 복사를 바로 사용할 수 있어 scatter 오버헤드를 피할 수 있음. 이는 영속 배치 최적화의 직접적인 구현임.

slot_mapping과 block table

_get_slot_mappings은 두 가지 형식을 반환함📎 vllm/v1/worker/gpu_model_runner.py:4078-4078: KV cache group으로 인덱싱된dict[int, torch.Tensor]은 어텐션 메타데이터에 사용되고, 레이어 이름으로 인덱싱된dict[str, torch.Tensor]은ForwardContext에 사용됨. encoder-only KV cache group의 경우 slot mapping은 전부 0인 텐서📎 vllm/v1/worker/gpu_model_runner.py:4096-4115이며, 그렇지 않으면block_table.slot_mapping.gpu에서 슬라이스함📎 vllm/v1/worker/gpu_model_runner.py:4107-4109. 사용되지 않는 꼬리 부분은-1로 채우며, 주석은 이것이reshape_and_cache의 전체 CUDA graph 모드에서 필요하다고 설명함📎 vllm/v1/worker/gpu_model_runner.py:4118-4122。

_get_block_table각 KV cache group에 대해 디바이스 텐서를 가져오고,📎 vllm/v1/worker/gpu_model_runner.py:2319-2335으로 CUDAGraph padding 행을 채움——블록 0은 padding용으로 예약됨NULL_BLOCK_ID5.3 forward_context: 레이어 간 공유되는 배치 설명📎 vllm/v1/worker/gpu_model_runner.py:2332-2334。

직관적 모델

은 교실 앞에 붙은 「통합 알림판」임: 각 모델 레이어는 고개를 들면 이번 시험의 좌석 배치(attention metadata)와 규칙(slot mapping)을 볼 수 있어 각자 물어볼 필요가 없음. 이것이 없다면 각 어텐션 레이어는 매개변수에서 이 정보를 받아야 하는데——모델 레이어의

forward_context시그니처는 고정되어 있어 레이어마다 개별적으로 매개변수를 전달할 수 없음.forward데이터 구조

은

ForwardContext이며, 핵심 필드는:@dataclass 📎 vllm/forward_context.py:141-202:

  • no_compile_layers에서 복사하며, 컴파일에 참여하지 않는 레이어를 표시함static_forward_context: 레이어 이름에서 어텐션 메타데이터로의 매핑, DBO 모드에서는 길이 2의 리스트(microbatch마다 하나)📎 vllm/forward_context.py:132-137;
  • attn_metadata: 레이어 이름에서 slot mapping 텐서로의 매핑📎 vllm/forward_context.py:144-152;
  • slot_mapping: 런타임 CUDA graph 모드, 기본값📎 vllm/forward_context.py:145;
  • cudagraph_runtime_mode: 배치 디스크립터, CUDA graph 디스패치에 사용NONE 📎 vllm/forward_context.py:155-157;
  • batch_descriptor: token 축의 불리언 마스크,📎 vllm/forward_context.py:158;
  • is_padding는 padding 행을 나타냄True은 또 다른📎 vllm/forward_context.py:162-165。

BatchDescriptor이며, 필드 설계는 「설명 항목 최소화」 원칙을 따름:@dataclass(frozen=True) 📎 vllm/forward_context.py:30-57(PIECEWISE 모드에서는 None 가능),num_tokens、num_reqs(모든 요청의 token 수가 동일),uniform. 주석은has_lora、num_active_loras의 존재 이유를 설명함:num_active_loras이 활성화되면 각 LoRA 수량 값이 독립 CUDA graph를 캡처하는데,cudagraph_specialize_lora_count등의 커널 grid size가 이 값에 의존하기 때문임fused_moe_lora전역 싱글턴과 컨텍스트 관리📎 vllm/forward_context.py:60-64。

은 모듈 수준 전역 변수

_forward_context이며,📎 vllm/forward_context.py:199-201컨텍스트 관리자를 통해 진입 시 이전 값을 저장하고 종료 시 복원함override_forward_context은 더 상위 수준의 래퍼📎 vllm/forward_context.py:263-274。set_forward_context로, DP 메타데이터 구성, batch descriptor 자동 생성, 플랫폼별 kwargs 주입을 추가로 처리함.📎 vllm/forward_context.py:277-394Step-by-Step:

에서 모델 전방향까지execute_model시나리오 대입:

이 모든 입력 텐서를 준비했고, 곧 모델을 호출함.GPUModelRunner.execute_model에서

,execute_model이 호출됨set_forward_context복사📎 vllm/v1/worker/gpu_model_runner.py:4408-4420:

python
with (
    set_forward_context(
        attn_metadata,
        self.vllm_config,
        num_tokens=num_tokens_padded,
        num_tokens_across_dp=num_tokens_across_dp,
        cudagraph_runtime_mode=cudagraph_mode,
        batch_descriptor=batch_desc,
        ubatch_slices=ubatch_slices_padded,
        slot_mapping=slot_mappings,
        skip_compiled=has_encoder_input,
        is_padding=is_padding,
    ),
    ...
):
    model_output = self._model_forward(...)

set_forward_context를 구성하고(DP 또는 시퀀스 병렬 MoE가 활성화된 경우)DPMetadata, 그다음📎 vllm/forward_context.py:299-328을 호출해create_forward_context인스턴스를 구성하며ForwardContext, 마지막으로📎 vllm/forward_context.py:347-358를 통해 전역 변수를 설정함override_forward_context모델 레이어는📎 vllm/forward_context.py:361-362。

을 통해get_forward_context()을 읽음📎 vllm/forward_context.py:208-214. 설정되지 않았다면 어서션이 실패하고set_forward_context。

mermaid
sequenceDiagram
    participant EC as EngineCore
    participant EX as Executor
    participant W as Worker
    participant MR as GPUModelRunner
    participant FC as ForwardContext
    participant M as Model Layers

    EC->>EX: execute_model(SchedulerOutput)
    EX->>W: collective_rpc("execute_model", args)
    W->>MR: execute_model(scheduler_output)
    MR->>MR: _update_states(scheduler_output)
    MR->>MR: _prepare_inputs(...)
    MR->>MR: _get_slot_mappings(...)
    MR->>FC: set_forward_context(attn_metadata, slot_mapping, ...)
    FC-->>MR: context manager entered
    MR->>M: _model_forward(input_ids, positions, ...)
    M->>FC: get_forward_context()
    FC-->>M: ForwardContext
    M-->>MR: hidden_states
    MR->>MR: compute_logits(sample_hidden_states)
    MR-->>W: ExecuteModelState / None
    W-->>EX: ModelRunnerOutput
    EX-->>EC: output[0]

복사

설계 사고

〔설계 추론 및 아키텍처 트레이드오프〕forward왜 전역 변수를 쓰고 명시적 매개변수 전달을 하지 않는가? 모델 레이어의get_forward_context()시그니처는 HuggingFace 규약에 의해 고정되어 있어 레이어마다 추가 매개변수를 주입할 수 없기 때문임. 전역 변수 + 컨텍스트 관리자는 모델 코드를 수정하지 않고 레이어 간 주입을 구현할 수 있는 유일한 방안임. 대가는 암시적 의존성——set_forward_context의 호출자는 자신이

is_padding의 스코프 안에 있음을 반드시 보장해야 함.📎 vllm/forward_context.py:162-165필드의 설계는 주목할 만함

all_moe_layers: 주석은 「소비자가 이를 사용해 padding token 작업을 건너뛸 수 있다」고 말함. 이는 CUDA graph 시나리오의 최적화——padding 행은 그래프 캡처에 참여하지만 실제 계산을 생성해서는 안 됨.moe_layer_index과📎 vllm/forward_context.py:170-195은 한 쌍의 교묘한 workaroundvllm.moe_forward임. 주석은 문제를 자세히 설명함:ForwardContext사용자 정의 연산자는 레이어 이름 문자열을 그래프에 하드코딩하여 torch.compile 콜드 스타트 시간이 너무 길어짐. 해결책은 레이어 이름 리스트를📎 vllm/forward_context.py:182-184。

에 저장하고, 사용자 정의 연산자가 순서대로 문자열을 꺼내고 카운터를 증가시키는 것임. 주석은 또한 이것이 「사용자 정의 연산자가 순서대로 실행되고 torch.compile이 재배열하지 않는다」는 가정에 의존함을 솔직히 인정함

설계 사고와 프로덕션 함정 _update_states비동기 스케줄링의 상태 일관성.output_token_ids은 비동기 투기적 디코딩에서 「낙관적 가정」 전략을 채택함: 이전 단계의 모든 draft token이 수락되었다고 가정하고 먼저📎 vllm/v1/worker/gpu_model_runner.py:1376-1384를 확장한 다음, 지연 수정 함수📎 vllm/v1/worker/gpu_model_runner.py:1509-1510를 등록함. 수정 함수는 모델 전방향이 시작된 후num_computed_tokens 📎 vllm/v1/worker/gpu_model_runner.py:1547-1558에 호출되어, GPU에서 실제 수락 수를 읽고

_may_reorder_batch를 롤백함. 이 설계의 정교함은 수정이 「배치가 이미 시작된」 이후에 발생하여 전방향을 차단하지 않고 비동기 파이프라인의 연속성을 유지한다는 점에 있음.의 트리거 조건.kv_cache_groups이 메서드는 먼저📎 vllm/v1/worker/gpu_model_runner.py:1131-1132이 비어 있는지 확인함is_attention_free: Mamba 모델도 attention-free이지만, KV cache를 사용해 내부 상태를 저장한다📎 vllm/v1/worker/gpu_model_runner.py:1116-1139. 실제로 KV cache group이 없는 모델만 재배치를 건너뛴다.

_prepare_input_ids의 인덱스 계산 함정.배치에 이전 단계의 decode 요청과 새 요청이 함께 있을 때,num_common_tokens < total_without_spec, CPU 텐서를 먼저 복사한 후 scatter해야 한다📎 vllm/v1/worker/gpu_model_runner.py:1849-1854. 만약num_common_tokens == 0, 이전 단계와 겹치는 요청이 전혀 없음을 의미하므로 바로 반환한다📎 vllm/v1/worker/gpu_model_runner.py:1855-1858. 이 두 분기의 구분은 매우 중요하다——어느 하나라도 놓치면input_ids부분이 초기화되지 않는다.

AsyncGPUModelRunnerOutput의 스트림 동기화.출력 복사는 독립적인 CUDA stream에서 수행된다📎 vllm/v1/worker/gpu_model_runner.py:308-328, 사용blocking=True의 Event로 CUDA 드라이버 잠금의 바쁜 폴링을 방지한다📎 vllm/v1/worker/gpu_model_runner.py:296-298。get_output()에서 먼저 synchronize한 후 디바이스 텐서 참조를 해제한다📎 vllm/v1/worker/gpu_model_runner.py:336-340, 순서를 바꿀 수 없다——그렇지 않으면 텐서가 복사 완료 전에 회수될 수 있다.

이 장 요약

이 장에서는SchedulerOutput의 EngineCore에서 GPU 전방향까지의 전체 경로를 추적했다.Executor를 통해collective_rpc스케줄링 결과를 모든 Worker에 브로드캐스트하고,GPUModelRunner의_update_states로 캐시 상태를 동기화하고,_prepare_inputs입력 텐서를 구성하고,_get_slot_mappingsKV 슬롯 매핑을 생성하며, 마지막으로set_forward_context배치 설명을 전역 컨텍스트에 주입하여 모델의 각 레이어에서 소비하도록 한다. 비동기 스케줄링 경로는 낙관적 가정 + 지연 수정을 통해 파이프라인 연속성을 유지하며,ForwardContext의 전역 싱글톤 설계는 모델 레이어 시그니처 고정과 크로스 레이어 메타데이터 주입 사이의 모순을 해결한다.

이 장의 생각과 자습

Q1: _update_states에서unscheduled_req_ids = cached_req_ids - (scheduled_req_ids - resumed_req_ids)이 표현식에서, 만약resumed_req_ids를 뺄셈에서 제거하여cached_req_ids - scheduled_req_ids로 바꾸면, 어떤 시나리오에서 상태 불일치가 발생하는가?

참고 해석: 주석은 명확히 지적한다📎 vllm/v1/worker/gpu_model_runner.py:1241-1246,cached_req_ids와resumed_req_ids는 일반적으로 교차하지 않지만,reset_prefix_cache에 의해 트리거된 강제 선점 시나리오에서 하나의 요청이 동시에cached_req_ids와resumed_req_ids에 나타날 수 있다. 이때scheduled_req_ids - resumed_req_ids는 이 요청을 '스케줄됨' 집합에서 제외하여unscheduled_req_ids에 떨어뜨려, 영속 배치에서 먼저 제거한 후 정상적인 resumed 경로를 통해 다시 추가한다. 만약resumed_req_ids를 제거하면, 해당 요청은 '스케줄됨'으로 간주되어 배치에 남지만, 블록 ID가 이미 교체되었으므로(req_state.block_ids = new_block_ids 📎 vllm/v1/worker/gpu_model_runner.py:1448), block table의 이전 행과 새 블록 ID가 일치하지 않아 attention 계산이 잘못된 KV 위치를 읽게 된다.

Q2: _prepare_input_ids의 빠른 경로📎 vllm/v1/worker/gpu_model_runner.py:1859-1868는common_indices_match and max_flattened_index == (num_common_tokens - 1)를 조건으로 사용한다. 만약 배치 내 요청 순서가 변경되었지만(예: attention 백엔드가 배치를 재배치),common_indices_match가 여전히 True라면, 무슨 일이 발생하는가?

참고 해석:common_indices_match는 루프에서prev_index == flattened_index를 통해📎 vllm/v1/worker/gpu_model_runner.py:1835。prev_index를 누적한다prev_positions에서, 현재 배치 위치를 이전 단계 배치 위치로 매핑한다;flattened_index는 현재 배치에서 해당 요청의 마지막 token의 플랫 인덱스이다. 만약 배치가 재배치되면,prev_index와flattened_index의 대응 관계가 변경되어,common_indices_match가 False가 되고 빠른 경로가 트리거되지 않는다. 하지만 재배치가 우연히prev_index == flattened_index가 모든 요청에 대해 성립하도록 만들면(예: token 수가 같은 두 요청을 교환), 빠른 경로는 잘못하여prev_sampled_token_ids[:num_common_tokens, 0]로 직접 슬라이스 복사한다——이것은 요청 A의 샘플링 token을 요청 B의 위치에 채우게 된다.max_flattened_index == num_common_tokens - 1이 추가 조건은 바로 이러한 퇴화 상황을 방지하기 위한 것이다: 플랫 인덱스가 정확히0..N-1의 순열이어야 하며, 모든 비자명한 재배치를 배제한다.

Q3: ForwardContext는 모듈 수준 전역 변수_forward_context를 사용하며, 스레드 로컬 변수가 아니다.execute_model와sample_tokens가 분리된 비동기 스케줄링에서, 만약sample_tokens가 전방향 완료 전에 호출되면,get_forward_context()는 무엇을 반환하는가? 이것이 어떤 문제를 일으키는가?

참고 해석:set_forward_context는 컨텍스트 관리자📎 vllm/forward_context.py:278-288이며,with블록 종료 시override_forward_context의finally를 통해 이전 값을 복원한다📎 vllm/forward_context.py:263-274.execute_model에서,set_forward_context의with블록은_model_forward호출만 감싸며📎 vllm/v1/worker/gpu_model_runner.py:4408-4433, 전방향 반환 후 컨텍스트가 복원된다. 만약sample_tokens가 전방향 완료 후 호출되면,get_forward_context()는 어서션 실패를 일으킨다📎 vllm/forward_context.py:208-214, 왜냐하면_forward_context가 이미None(또는 외부 값)으로 재설정되었기 때문이다. 이것이 바로ExecuteModelState가 존재하는 이유이다📎 vllm/v1/worker/gpu_model_runner.py:463-476: 샘플링에 필요한 상태(logits、hidden_states、slot_mappings)는 NamedTuple에 명시적으로 저장되며,ForwardContext의 암시적 전달에 의존하지 않는다. 만약ForwardContext가sample_tokens에서 여전히 사용 가능하다고 잘못 가정하면, 어서션 오류가 발생하거나 잘못된 메타데이터를 읽게 된다.

여기까지, 우리는 SchedulerOutput에서 GPU 전방향 전파까지의 전체 경로를 걸어왔다: Executor 디스패치, Worker 실행, GPUModelRunner가 논리적 목록을 물리적 텐서로 변환하고, forward_context를 통해 배치 설명을 각 레이어에 주입한다. 그러나 모델 전방향 전파에서 가장 시간이 많이 걸리는 부분——attention 계산——은 아직 펼쳐지지 않았다. 다음 장에서는 attention 백엔드로 깊이 들어가, attn_metadata의 block table과 slot mapping이 PagedAttention 커널에 의해 어떻게 소비되는지, 그리고 FlashAttention, FlashInfer, Triton 등 다양한 백엔드가 통합 인터페이스를 통해 어떻게 선택되고 스케줄링되는지 살펴본다.

CHAPTER 06

제 6 장: Attention 백엔드와 PagedAttention 커널 구현

소속 프로젝트: vllm-project/vllm · 전체 진행률: 제 6 / 14 장 · 검증 상태: FACT 행 번호 실제 앵커링

지난 장에서 우리는 GPUModelRunner가 스케줄링 결과를 input_ids, slot_mapping, block_table 등의 물리적 텐서로 변환하고 forward_context를 통해 각 레이어에 주입하는 방법을 살펴보았다. 하지만 실제로 GPU 시간을 가장 많이 소모하는 부분인 어텐션 계산은 아직 공중에 떠 있다. attn_metadata 안의那些 텐서들은究竟 누가 소비하는가? FlashAttention, FlashInfer, Triton 같은 구현들이凭什么 동일한 모델 코드 아래에서 서로 교체 가능한가? 답은 AttentionBackend 추상화 계층에 있다. 이는 "어텐션을 어떻게 계산하는가"와 "모델이 어떻게 호출하는가"를 분리한다: 모델 계층은 AttentionImpl 참조만 보유하고 통일된 forward(query, key, value, kv_cache, attn_metadata, output)를 호출하며, 구체적 백엔드는 block_table, slot_mapping, seq_lens를 자체 커널이 소화할 수 있는 파라미터로 변환하는 역할을 담당한다. 이 장에서는 FlashAttentionBackend를 주된 흐름으로 삼는데, 이는 PagedAttention의 gather 의미론, CUDA Graph 호환성, 계단식 어텐션, DCP 분산 컨텍스트 등 가장 풍부한 분기를 동시에 포괄하기 때문이다. 이를 완전히 이해하면 다른 백엔드는 단지 파라미터 매핑의 변형일 뿐이다. 이러한 "백엔드 등록 + 통일 인터페이스" 설계의 동기는 매우 직접적이다: 어텐션 커널은 진화가 매우 빠르며(FA2→FA3→FA4, FlashInfer 반복, Triton 자체 개발), 모델 계층이 특정 커널에 직접 의존하면 커널 업그레이드 때마다 모델 코드를 수정해야 한다. 추상화 계층은 변화를 get_impl_cls()라는 하나의 팩토리 메서드 뒤에 격리한다.

백엔드 선택: 능력 선언과 메타데이터 구축

직관적 모델

를AttentionBackend구인 공고라고 생각하자: 그것은 일을 하지 않고 단지 "내가 어떤 dtype, 어떤 head_size, 어떤 KV cache 양자화 형식, 어떤 attention 유형을 처리할 수 있는지"만 선언한다. 스케줄러는 모델 설정을 가지고 매칭하며, 매칭에 실패하면 다음 후보로 넘어간다. 이러한 선언 계층이 없다면 시스템은 런타임에 "이 head_size는 커널이 지원하지 않는다"는 것을 발견하고 바로 크래시할 것이다.

능력 행렬: 필드가 곧 계약

FlashAttentionBackend의 클래스 속성이 곧 그 능력의 경계이다.supported_dtypesfp16/bf16을 제한하고📎 vllm/v1/attention/backends/flash_attn.py:287-287;supported_kv_cache_dtypes추가로 fp8 계열을 허용한다📎 vllm/v1/attention/backends/flash_attn.py:298-299. 하지만 "지원을 선언"하는 것이 "무조건 지원"과 같지는 않다——supports_kv_cache_dtype양자화 KV에 대해서는 추가로flash_attn_supports_kv_cache_dtype에 위임하여 장치 관련 판단을 수행한다📎 vllm/v1/attention/backends/flash_attn.py:431-438。

더 정교한 것은supports_combination이다: 이는 head_size, dtype, block_size, use_mla, has_sink 등 일련의 조합 파라미터를 받아None를 반환하면 사용 가능, 문자열을 반환하면 거부 이유를 나타낸다📎 vllm/v1/attention/backends/flash_attn.py:454-507. 예를 들어 sink는 연산 능력 < 9.0에서 거부되며📎 vllm/v1/attention/backends/flash_attn.py:467-468, SM90에서 FP8 KV와 mm_prefix 조합은 반드시 Triton을 거쳐야 한다📎 vllm/v1/attention/backends/flash_attn.py:472-472. 이러한 "이유 문자열 반환" 설계는 상위 계층이 진단 가능한 오류를 제공할 수 있게 하며, 조용한 폴백을 방지한다.

block_size 선택 역시 능력에 의해 주도된다. 기본적으로MultipleOf(16)를 반환하지만, SM90 FP8-KV는 64를 강제하며📎 vllm/v1/attention/backends/flash_attn.py:297-324, FA4의 head_size=256 커널은FA4_HD256_PAGE_SIZE 📎 vllm/v1/attention/backends/flash_attn.py:326-352를 강제한다. 이는 KV cache의 block 크기가 아무렇게나 정해지는 것이 아니라——커널의 TMA tile 크기에 의해 역으로 제약됨을 설명한다.

메타데이터 구조: FlashAttentionMetadata의 필드 레이아웃

FlashAttentionMetadata은 dataclass이며, 필드는 네 그룹으로 나뉜다📎 vllm/v1/attention/backends/flash_attn.py:511-566:

첫 번째 그룹은 기본 배치 설명이다:num_actual_tokens(패딩을 제거한 실제 token 수),max_query_len、query_start_loc(접두사 합, varlen 커널이 각 시퀀스의 시작과 끝을 찾는 데 사용),seq_lens、block_table、slot_mapping 📎 vllm/v1/attention/backends/flash_attn.py:520-526. 소스 코드 주석에 있는 ASCII 그림📎 vllm/v1/attention/backends/flash_attn.py:512-518에 주목하라, 이는context_len(과거 KV),query_len(이번에 추가),seq_len(둘의 합)——을 정확히 구분하며, 이는 varlen 커널 파라미터를 이해하는 핵심이다.

두 번째 그룹은 계단식 어텐션 필드이다:use_cascade、common_prefix_len、cu_prefix_query_lens등📎 vllm/v1/attention/backends/flash_attn.py:528-533。

세 번째 그룹은 DCP(Decode Context Parallel) 필드이다:max_dcp_context_kv_len、dcp_context_kv_lens, 그리고 decode/prefill 요청 수를 구분하는 카운터📎 vllm/v1/attention/backends/flash_attn.py:535-544。

네 번째 그룹은 선택적 스케줄링과 특수 마스크이다:scheduler_metadata(FA3 AOT 스케줄링용),causal(bool 또는 텐서가 될 수 있으며, 시퀀스별 인과 지원),mm_prefix_query_range_tensor(멀티모달 양방향 범위), R-SWA 관련 필드📎 vllm/v1/attention/backends/flash_attn.py:546-566。

〔설계 추론과 아키텍처 트레이드오프〕

causal필드 타입이bool | torch.Tensor이고 순수 bool이 아닌 이유는 "동일 배치에서 일부 시퀀스는 인과적, 일부는 비인과적"인 시나리오(예: PrefixLM)를 지원하기 위함이다. 이것이 텐서일 때 FA4의dynamic_causal파라미터가 이를 인수하고, FA2/FA3는 직접 NotImplementedError를 던진다📎 vllm/v1/attention/backends/flash_attn.py:1429-1433。

build()의 단계별 진행

시나리오 대입: 혼합 배치, 3개의 decode 시퀀스 + 2개의 prefill 시퀀스, 계단식 없음, DCP 없음.

첫 번째 단계,common_attn_metadata에서 기본 텐서를 언패킹한다📎 vllm/v1/attention/backends/flash_attn.py:824-832. 두 번째 단계에서는 AOT 스케줄링을 활성화할지 결정합니다:aot_schedule = self.aot_schedule and not fast_build and not envs.VLLM_BATCH_INVARIANT 📎 vllm/v1/attention/backends/flash_attn.py:836-838。self.aot_schedule에서__init__이get_flash_attn_version() == 3를 결정합니다📎 vllm/v1/attention/backends/flash_attn.py:709-709— FA3만 사전 계산된 스케줄링 메타데이터를 지원합니다. 세 번째 단계에서는 최초 build 시 지연 방식으로aot_sliding_window를 채웁니다: 모든FlashAttentionImpl레이어를 순회하며 슬라이딩 윈도우 구성을 수집하고, 구성이 유일하면 채택하며, 하나보다 많으면 AOT를 비활성화합니다📎 vllm/v1/attention/backends/flash_attn.py:848-851。

네 번째 단계에서는max_num_splits를 계산합니다. 기본값은 0입니다(FA3가 휴리스틱을 사용하도록). full CUDA graph가 활성화되고 토큰 수가 캡처 범위 안에 있을 때만self.max_num_splits 📎 vllm/v1/attention/backends/flash_attn.py:856-866로 설정합니다. 주석은 그 이유를 설명합니다:num_splits > 1는[num_splits, num_heads, num_tokens, head_size]의 중간 버퍼를 할당하므로 VRAM 비용이 높고, CUDA graph 시나리오에서만📎 vllm/v1/attention/backends/flash_attn.py:862-865。

할 가치가 있습니다_get_scheduler_metadata다섯 번째 단계에서는 비계단식 비 DCP 분기를 타고,📎 vllm/v1/attention/backends/flash_attn.py:976-986를 호출해 FA3의 스케줄링 메타데이터를 생성합니다_store_scheduler_metadata. 여섯 번째 단계에서는📎 vllm/v1/attention/backends/flash_attn.py:671-684가 CUDA graph 시나리오를 처리합니다: 새 메타데이터를 사전 할당된 버퍼에 복사하고 나머지 부분을 0으로 지웁니다📎 vllm/v1/attention/backends/flash_attn.py:671-672。

. 이 0 초기화 단계는 매우 중요합니다—주석은 그렇지 않으면 일부 thread block이 유효하지 않은 메타데이터를 읽고 출력 버퍼를 덮어쓴다고 명확히 밝힙니다FlashAttentionMetadata일곱 번째 단계에서는📎 vllm/v1/attention/backends/flash_attn.py:992-1015。

mermaid
flowchart TD
    start["build(common_prefix_len, common_attn_metadata)"] --> unpack["解包 query_start_loc / seq_lens / block_table / slot_mapping"]
    unpack --> aot{"aot_schedule 且非 fast_build 且非 BATCH_INVARIANT?"}
    aot -->|是| sw_check{"aot_sliding_window 已初始化?"}
    aot -->|否| maxsplit
    sw_check -->|否, 首次| collect["_get_sliding_window_configs 收集层滑窗"]
    collect --> sw_unique{"配置数量 == 1?"}
    sw_unique -->|是| set_sw["设置 aot_sliding_window"]
    sw_unique -->|否, >1| disable_aot["self.aot_schedule = False"]
    set_sw --> maxsplit
    disable_aot --> maxsplit
    sw_check -->|是| maxsplit["计算 max_num_splits"]
    maxsplit --> cg_check{"use_full_cuda_graph 且 tokens <= max_cudagraph_size?"}
    cg_check -->|是| set_splits["max_num_splits = self.max_num_splits"]
    cg_check -->|否| zero_splits["max_num_splits = 0"]
    set_splits --> branch
    zero_splits --> branch
    branch{"dcp_world_size > 1?"}
    branch -->|是| dcp_path["计算 dcp_context_kv_lens, 可能 skip"]
    branch -->|否| cascade_check{"common_prefix_len > 0?"}
    cascade_check -->|是| cascade_path["构造 prefix/suffix 双份 scheduler_metadata"]
    cascade_check -->|否| normal_path["_get_scheduler_metadata 单份"]
    dcp_path --> store
    cascade_path --> store
    normal_path --> store
    store["_store_scheduler_metadata: CUDA graph 时拷入预分配缓冲并清零尾部"] --> build_meta["构造 FlashAttentionMetadata"]
    build_meta --> mm_check{"mm_req_doc_ranges 非空?"}
    mm_check -->|是| fill_mm["fill_mm_prefix_query_ranges + 拷贝到 GPU"]
    mm_check -->|否| rswa_check
    fill_mm --> rswa_check{"rswa_window 非空?"}
    rswa_check -->|是| copy_rswa["拷贝 prefix_lens 到持久缓冲"]
    rswa_check -->|否| done
    copy_rswa --> done["返回 attn_metadata"]

---

를 반환합니다

복사

forward()forward(): 메타데이터에서 커널 호출까지의 전체 경로

직관적 모델

는 백엔드의 "최종 조립 공장"입니다: 모델 레이어가 계산한 Q/K/V, KV cache 텐서, 그리고 이전 단계에서 구성한 메타데이터를 받아 KV cache의 물리적 레이아웃을 커널이 기대하는 형태로 조정한 다음 구체적인 커널로 디스패치합니다. 이 단계가 없으면 커널이 잘못된 메모리 레이아웃을 읽어 출력이 조용히 잘못됩니다—충돌보다 디버깅하기 더 어렵습니다.[num_blocks, num_kv_heads, block_size, 2 * head_size]KV cache의 메모리 레이아웃 변환📎 vllm/v1/attention/backends/flash_attn.py:1246-1247vLLM의 KV cache 물리적 형태는[num_blocks, block_size, num_kv_heads, head_size]。

입니다—K와 V가 마지막 차원에 결합되어 있습니다forward(). 하지만 FlashAttention 커널은 K와 V가 분리되어 있고 레이아웃이kv_cache.transpose(1, 2).split(self.head_size, dim=-1) 📎 vllm/v1/attention/backends/flash_attn.py:1310-1310。transpose(1,2)이기를 기대합니다[blocks, heads, block_size, 2D]변환은[blocks, block_size, heads, 2D],split시작 부분에서 일어납니다:transpose는

를canonicalize_singleton_dim_strides 📎 vllm/v1/attention/backends/flash_attn.py:1310-1310로 바꾸고 마지막 차원을 따라 K와 V로 자릅니다. 참고로num_kv_heads=1는 stride만 바꾸고 데이터는 옮기지 않으므로, 이후 커널은 비연속 접근을 지원해야 합니다.📎 vllm/v1/attention/backends/flash_attn.py:1310-1310바로 뒤에는

가 이어집니다. 주석은 동기를 분명히 밝힙니다:

(TP 시나리오에서 흔함)일 때 size-1 차원의 stride는 퇴화되며, FA3/FA4는 H100+에서 TMA를 사용하므로 stride가 최소 16바이트 정렬을 요구합니다if not attn_metadata.use_cascade. 이는 전형적인 "논리적으로는 동등하지만 물리적으로는 불법"인 함정입니다.📎 vllm/v1/attention/backends/flash_attn.py:1326-1342:cu_seqlens_q = query_start_loc,seqused_k = seq_lens,block_table = attn_metadata.block_table。descale_shape비계단식 경로의 파라미터 흐름(batch_size, num_kv_heads)분기에 들어간 뒤 파라미터가 하나씩 매핑됩니다(num_sequences, num_kv_heads)는.expand()를 취해 FP8 양자화의 scale 브로드캐스트에 사용합니다—주석은 flash-attn이 기대하는 descale 형태가📎 vllm/v1/attention/backends/flash_attn.py:1258-1258。

라고 설명하며,_maybe_symmetrize_window를 사용해 복사를 피합니다(w, 0)다음은 슬라이딩 윈도우의 대칭화 처리입니다.(w, w)의 로직: 인과적 슬라이딩 윈도우📎 vllm/v1/attention/backends/flash_attn.py:587-589는 비인과적 시나리오에서📎 vllm/v1/attention/backends/flash_attn.py:1362-1365。

로 바뀌어 양방향 query가 두 방향을 볼 수 있게 합니다

. 주석은 또한 "레이어 자체의 window가 group의 window보다 우선한다"고 강조합니다. 하나의 KV cache group이 윈도우 레이어와 전역 레이어를 동시에 담을 수 있기 때문입니다(예: Gemma-3에서 hybrid KV cache manager를 끈 경우)mm_prefix_query_ranges마스크 분기: mm_prefix와 R-SWAmask_mod 📎 vllm/v1/attention/backends/flash_attn.py:1374-1407가 비어 있지 않고 FA4 + 정적 인과 조건을 만족할 때, 코드는 CuTE-DSL의causal = False를 구성합니다sliding_window_size = None 📎 vllm/v1/attention/backends/flash_attn.py:1406-1407. 핵심 동작은(causal ∧ window) ∨ bidirectional-range와📎 vllm/v1/attention/backends/flash_attn.py:1402-1405。

_make_mm_prefix_mask_mod입니다. 주석은 그 이유를 설명합니다: mm_prefix의 의미는functools.cache이며 causal의 부분집합이 아닙니다; FA #155 이후 mask_mod를 설정해도 causal/local이 자동으로 지워지지 않으므로, 호출자가 명시적으로 꺼야 합니다. 그렇지 않으면 내장 causal 경로가 mask_mod를 단락시킵니다📎 vllm/v1/attention/backends/flash_attn.py:1793-1802는hash_callable를 사용해repr()를 캐시합니다_load_q_range. 주석은 강력한 근거를 제시합니다: FA4의📎 vllm/v1/attention/backends/flash_attn.py:1793-1802는 클로저 셀의

를 컴파일 키에 섞어 넣고, 중첩된q_idx는 호출마다 주소가 달라져 매 forward마다 전체 JIT 재컴파일을 유발합니다kv_idx. 이는 프로덕션 환경 성능 함정의 전형적인 표본입니다.q_abs = q_idx + seqlen_k - seqlen_q마스크 내부에는 좌표 변환 세부 사항이 있습니다: FA4가 전달하는 것은 로컬📎 vllm/v1/attention/backends/flash_attn.py:1859-1865。__vec_size__ = 1(현재 prefill chunk 내 0-based)이고,_load_q_range는 절대 위치입니다. 코드는📎 vllm/v1/attention/backends/flash_attn.py:1897-1897。

를 사용해 절대 위치를 복원합니다causal & (in_prefix | in_window) 📎 vllm/v1/attention/backends/flash_attn.py:1945-1948의 설정에도 이유가 있습니다:use_fast_sampling = True는 lane 0을 읽고, 한 번의 호출이 query 행을 넘을 수 없습니다📎 vllm/v1/attention/backends/flash_attn.py:1950-1950。

R-SWA의 mask_mod도 비슷하지만 의미는

이고,self.fa4_hd256는 FA4가 완전히 마스크된 KV block을 건너뛰게 하여 해당 데이터를 로드하지 않습니다num_pages = cdiv(max_seqlen_k, FA4_HD256_PAGE_SIZE),max_seqlen_kFA4 hd256의 특수 처리block_table가 참일 때 코드는 page 정렬을 강제합니다:num_splits = 1 📎 vllm/v1/attention/backends/flash_attn.py:1442-1448는 페이지 경계로 올림하고,

는 정확한 페이지 수로 자르며,_FA4_DENSE_ATTENTION_KERNEL(...). 주석은 hd256 커널이 페이지 정렬 길이, 정확한 너비의 block table을 요구하고 SplitKV를 지원하지 않는다고 설명합니다.📎 vllm/v1/attention/backends/flash_attn.py:1450-1475。

최종적으로

forward()를 호출하여 q, k, v, out, cu_seqlens_q, seqused_k, block_table, softcap, mask_mod, aux_tensors 등을 함께 전달합니다do_kv_cache_updateKV cache 쓰기: do_kv_cache_updatereshape_and_cache_flash는 KV cache를 읽기만 하며, 쓰기는slot_mapping가 수행합니다. 이는📎 vllm/v1/attention/backends/flash_attn.py:1532-1541를 호출하고,key/value를 사용해 새로 계산된 K/V를 cache에 산란 기록합니다slot_mapping아니요, 하지만 수동 슬라이싱은 필요하지 않습니다. 왜냐하면 op가slot_mapping의 shape로 실제 token 수를 결정하기 때문입니다📎 vllm/v1/attention/backends/flash_attn.py:1527-1531. 여기서는 stride 정규화를 하지 않습니다. TMA 커널이 참여하지 않기 때문입니다📎 vllm/v1/attention/backends/flash_attn.py:1520-1521。

mermaid
sequenceDiagram
    participant Model as 模型层 Attention
    participant Impl as FlashAttentionImpl
    participant KVC as kv_cache 张量
    participant Kernel as flash_attn_varlen_func
    Model->>Impl: forward(query, key, value, kv_cache, attn_metadata, output)
    Impl->>Impl: output_scale 非空? 抛 NotImplementedError
    Impl->>Impl: attn_metadata is None? 返回 output.fill_(0)
    Impl->>KVC: transpose(1,2).split(head_size)
    KVC-->>Impl: key_cache, value_cache
    Impl->>Impl: canonicalize_singleton_dim_strides(key_cache)
    Impl->>Impl: use_cascade?
    alt 非级联
        Impl->>Impl: 映射 cu_seqlens_q / seqused_k / block_table
        Impl->>Impl: _maybe_symmetrize_window
        Impl->>Impl: mm_prefix 或 R-SWA? 构造 mask_mod
        Impl->>Kernel: _FA4_DENSE_ATTENTION_KERNEL(q, k, v, out, ...)
        Kernel-->>Impl: output 就地写入
    else 级联
        Impl->>Kernel: cascade_attention(prefix + suffix 两次调用)
        Kernel-->>Impl: merge_attn_states 合并
    end
    Impl-->>Model: output

---

설계 사고: 왜 이렇게 작성했는가

〔설계 추론 및 아키텍처 트레이드오프〕

능력 선언과 구현 분리。supports_combination은 bool 대신 이유 문자열을 반환합니다. 이는 상위 계층이 다른 백엔드로 폴백할 때 "왜 FA를 사용하지 않았는지"를 기록할 수 있게 하여, 운영 환경에서의 문제 추적 비용을 크게 줄이기 위한 것입니다. 조용한 폴백과 달리, 이 설계는 의사결정 근거를 명시적으로 드러냅니다.

CUDA Graph 호환성은 메타데이터 설계의 숨겨진 제약。_store_scheduler_metadata의 "복사 + 꼬리 부분 제로화" 패턴은📎 vllm/v1/attention/backends/flash_attn.py:671-684R-SWA 영구 버퍼📎 vllm/v1/attention/backends/flash_attn.py:787-798와 mm_prefix 임시 저장 영역📎 vllm/v1/attention/backends/flash_attn.py:800-813에서 반복적으로 나타납니다. 공통 패턴은:__init__에서 최대 크기의 영구 버퍼를 사전 할당하고,build()에서는 복사만 하고 할당은 하지 않는다는 것입니다. 그 이유는 주석에 명시되어 있습니다 — CUDA graph 캡처 중에는 할당 연산이 있어서는 안 됩니다📎 vllm/v1/attention/backends/flash_attn.py:1044-1046。

DCP와 fused draft decode의 상호 배제。supports_draft_decode_metadata_update = self.dcp_world_size == 1 📎 vllm/v1/attention/backends/flash_attn.py:742-742. 주석 설명: fused draft decode는 draft 단계에 걸쳐 캡처된 메타데이터 객체를 재사용하지만, DCP의 빌드 타임 호스트 측 결정(예:skip_dcp_context_attention())이 메타데이터 shape를 변경하며, 이러한 Python 필드는 graph replay 사이에 제자리에서 갱신되지 않습니다📎 vllm/v1/attention/backends/flash_attn.py:736-741. 이는 "성능 최적화와 정확성이 충돌할 때 정확성을 선택한다"는 전형적인 트레이드오프입니다.

계단식 어텐션의 휴리스틱 임계값。use_cascade_attention은 일련의 임계값으로 필터링합니다: common_prefix_len < 256이면 즉시 거부📎 vllm/v1/attention/backends/flash_attn.py:1967-1967, alibi/sliding_window/local_attention은 지원하지 않음📎 vllm/v1/attention/backends/flash_attn.py:1978-1979, 요청 수 < 8이면 거부📎 vllm/v1/attention/backends/flash_attn.py:1982-1984, DCP 시나리오에서는 비활성화📎 vllm/v1/attention/backends/flash_attn.py:1985-1987. 통과 후에도 대략적인 성능 모델로 cascade와 FlashDecoding의 CTA 수와 wave 수를 비교합니다📎 vllm/v1/attention/backends/flash_attn.py:2011-2029. 주석은 이 모델이 "very rough"임을 솔직히 인정합니다📎 vllm/v1/attention/backends/flash_attn.py:2009-2010。

운영 환경 함정 포인트:forward()에는 눈에 띄는 주석이 있어, piece-wise CUDA graph 하에서 이 메서드가 eager 모드로 실행되며,view/slice과 같이 GPU 연산이 없어 보이는 메서드가 실제로는 매우 느리므로 변경 시 반드시 benchmark해야 한다고 경고합니다📎 vllm/v1/attention/backends/flash_attn.py:1277-1284. 이는 코드에서 더 "우아한" 작성법 대신[:num_actual_tokens]슬라이싱을 많이 사용하는 이유를 설명합니다 — 모든 곳이 성능 트레이드오프의 결과입니다.

---

이 장 요약

이 장에서는FlashAttentionBackend을 따라 어텐션 백엔드의 전체 생명주기를 살펴보았습니다: 능력 선언(supports_*시리즈) → 메타데이터 구축(build()이CommonAttentionMetadata을FlashAttentionMetadata로 변환) → 커널 호출(forward()이 KV cache 레이아웃을 변환하고, 마스크를 구성하고, FA 커널로 디스패치). 핵심 메커니즘은 다음과 같습니다: KV cache의transpose+split레이아웃 변환, 퇴화된 stride의 정규화, CUDA graph 하의 영구 버퍼 패턴, mm_prefix/R-SWA의 CuTE-DSL 마스크 구성, 그리고 계단식 어텐션의 휴리스틱 의사결정.

핵심 설계 원칙: 능력 선언과 구현 분리, CUDA graph 호환성이 주도하는 메타데이터 사전 할당, 성능 최적화와 정확성 충돌 시 정확성 우선(DCP에서 fused draft decode 비활성화).

다음 장에서는 샘플링과 출력으로 넘어갑니다:logits이 어떻게 프로세서 체인(온도, top-p, 페널티 항)을 거쳐 token이 되는지, 구조화된 출력이 어떻게 디코딩을 제약하는지, 그리고 스트리밍 반환이 어떻게 스케줄러와 협력하는지.

이 장의 생각해보기와 자가 점검

Q1: 만약_store_scheduler_metadata에서self.scheduler_metadata[n:] = 0제로화 연산을 삭제하면, 어떤 시나리오에서 출력 오류가 발생하는가? 왜 주석에서 특히 이 점을 강조하는가?

참고 해설:_store_scheduler_metadata은 CUDA graph 시나리오에서 새 메타데이터를 사전 할당된 버퍼의 앞 n개 위치에 복사합니다📎 vllm/v1/attention/backends/flash_attn.py:671-684. 꼬리 부분을 제로화하지 않으면, 이전 build에서 남은 스케줄링 메타데이터를 이번 커널이 읽게 됩니다. 주석은 "some thread blocks may use the invalid scheduler metadata and overwrite the output buffer"라고 명확히 지적합니다📎 vllm/v1/attention/backends/flash_attn.py:671-672. 트리거 시나리오: 배치 크기가 큰 것에서 작은 것으로 줄어들 때(예: 8개 시퀀스에서 3개로 감소), 버퍼의 앞 3개 위치는 새 데이터이지만 4-8번째 위치는 여전히 이전 배치의 데이터입니다. FA3의 스케줄링 메타데이터에는 tile 할당 정보가 포함되어 있으며, 커널이 batch_size로 읽을 때 batch_size 계산에 편차가 있거나 커널이 고정 stride로 스캔하면 더티 데이터를 읽고 출력을 손상시킵니다. 이는 CUDA graph 버퍼 재사용의 전형적인 함정입니다: 버퍼 생명주기가 여러 번의 replay에 걸쳐 있으므로 반드시 명시적으로 정리해야 합니다.

Q2: _make_mm_prefix_mask_mod은functools.cache으로 캐시하는데, 주석에서는 그렇지 않으면 "force a full JIT recompile every forward"가 발생한다고 합니다. 이 캐시 데코레이터를 제거하면 성능이 얼마나 저하되는가? 왜 FA4의 컴파일 키가 클로저 주소에 영향을 받는가?

참고 해설: 주석에서 FA4의hash_callable이 클로저 셀의repr()을 컴파일 키에 혼입한다고 설명합니다📎 vllm/v1/attention/backends/flash_attn.py:1793-1802。_make_mm_prefix_mask_mod내부에 중첩 함수_load_q_range가 정의되어 있어, 팩토리 함수를 호출할 때마다 새로운 함수 객체가 생성되고, 그repr()메모리 주소를 포함하며, 주소는 매번 달라짐 → 컴파일 키가 매번 달라짐 → FA4가 재 JIT 컴파일이 필요하다고 판단함. 캐시 후에는 동일해짐(sliding_window, sliding_window_left)파라미터가 동일한 함수 객체를 재사용하므로 컴파일 키가 안정적임. 성능 저하 정도는 FA4 컴파일 소요 시간에 달려 있지만, "매 forward마다 전체 컴파일이 트리거된다"는 것은 확실하며, decode 루프에서 매 스텝마다 한 번씩 컴파일되므로 지연이 밀리초 수준에서 초 수준으로 퇴화함. 이는 "겉보기에는 무해한 Python 클로저"가 JIT 캐시 무효화를 유발하는 전형적인 사례임.

Q3: supports_draft_decode_metadata_update = self.dcp_world_size == 1이 줄은 DCP 시나리오에서 fused draft decode를 비활성화함. 이를 강제로 다음과 같이 바꾼다고 가정하면True, 추측 디코딩 + DCP 조합에서 어떤 구체적 오류가 발생하는가?

참고 해석: 주석은 fused draft decode가 draft 스텝 간에 캡처된 메타데이터 객체를 재사용한다고 설명하며, DCP의 build-time 호스트 측 결정(예:skip_dcp_context_attention())이 메타데이터 형상/제어 경로를 변경함. 예를 들어max_dcp_context_kv_len 📎 vllm/v1/attention/backends/flash_attn.py:736-741. 이러한 Python 필드는 CUDA graph replay 사이에 제자리에서 갱신되지 않음. 구체적 오류: draft 스텝 간에 시퀀스 길이가 증가하고,skip_dcp_context_attention의 판정이 True에서 False로(또는 그 반대로) 바뀔 수 있지만, 재사용된 메타데이터 객체는 여전히 이전 값을 유지함. 이전 값이max_dcp_context_kv_len = 0이면, 커널은 "DCP context 없음" 경로를 타고📎 vllm/v1/attention/backends/flash_attn.py:1565-1589, rank 간 context 어텐션을 건너뛰어 출력에 컨텍스트 정보가 누락됨 — 조용한 오류, 크래시 없음. 이는 바로 "성능 최적화와 정확성이 충돌할 때 정확성을 선택한다"는 것의 구현임.

여기까지, 어텐션 백엔드가 추상 인터페이스에서 커널 구현까지 이어지는 전체 경로가 관통되었다: 모델 계층은 AttentionImpl을 통해 통일적으로 호출하고, 백엔드는 block_table, slot_mapping 등의 메타데이터를 구체적 커널 파라미터로 변환하는 역할을 하며, FlashAttentionBackend의 PagedAttention 구현은 페이지드 KV Cache 하의 gather 의미론과 CUDA Graph 호환 전략을 보여준다. 그러나 어텐션 계산이 산출하는 것은 은닉 상태일 뿐이며, 모델이 최종적으로 출력해야 하는 것은 다음 token이다. 이 은닉 상태가 어떻게 logits가 되고, logits가 어떻게 샘플링과 후처리를 거쳐 최종적으로 스트리밍 텍스트로 클라이언트에 반환되는가? 다음 장에서는 이 마지막 1킬로미터를 추적한다.

CHAPTER 07

제7장: 샘플링과 출력: Logits 처리, 구조화 출력, 스트리밍 반환

소속 프로젝트: vllm-project/vllm · 전체 진행률: 제7 / 14장 · 검증 상태: FACT 행 번호 실제 앵커링

이전 장에서 우리는 어텐션 백엔드가 block table을 커널 파라미터로 변환하여 비연속 메모리에서 gather 방식 어텐션 계산을 완료하는 방법을 추적했다. 그러나 어텐션이 산출하는 것은 은닉 상태일 뿐이다 — 모델이 실제로 사용자에게 전달해야 하는 것은 다음 token의 텍스트이다. 이번 장에서는 이 마지막 1킬로미터를 추적한다: 은닉 상태가 lm_head를 통해 logits로 투영된 후, 정교하게 정렬된 프로세서 체인(온도, 페널티, top-k/top-p, 구조화 제약)을 통과하여 token id로 샘플링되고, 다시 detokenizer를 거쳐 텍스트로 복원되어 스트리밍으로 푸시된다. 이 경로에서 어느 한 단계라도 순서가 뒤바뀌거나 상태가 누출되면 출력 품질이 조용히 악화된다.

Sampler: 프로세서 체인의 순서가 곧 정확성

직관적 모델: Sampler는 하나의 조립 라인과 같고, logits는 가공할 원자재이다. 라인 위의 각 공정(processor)은 원자재를 수정하며, 공정의 선후 순서가 완성품을 직접 결정한다 — 먼저 깎고 나서 갈면 것과 먼저 갈고 나서 깎으면 것은 서로 다른 두 가지가 된다. 이 체인이 없다면 모델은 원시 확률 분포만 출력할 수 있고, 사용자가 받는 것은 온도 제어도, 반복 억제도, 형식 제약도 불가능한 "날 샘플링"이 된다.

데이터 구조와 메모리 레이아웃

Sampler 자체는nn.Module이지만, 핵심 상태는 매우 얇다: 단지topk_topp_sampler서브모듈,logprobs_mode과use_fp64_gumbel플래그만 보유한다📎 vllm/v1/sample/sampler.py:61-64. 실제 배치 수준 상태는 전부SamplingMetadata에 캡슐화되어 forward 파라미터로 전달된다. 이러한 "무상태 Sampler + 외부 메타데이터" 설계는 의도적이다: Sampler 인스턴스는 엔진 생명주기 동안 한 번만 생성되지만, 각 decode step의 배치 구성은 계속 변하므로, 상태를 외부로 빼내야 Sampler가 CUDA Graph에 캡처된 후 안전하게 재생될 수 있다.

핵심 상수는_SAMPLING_EPS = 1e-5 📎 vllm/v1/sample/sampler.py:18이다. 이는 동시에 두 가지 의미를 가진다: 온도가 이 값보다 낮으면 그리디로 간주하며, 그리고apply_temperature에서 0으로 나누기를 방지하는 폴백이다.

Step-by-Step Walkthrough

시나리오 대입: 하나의 batch에 그리디 요청과 랜덤 샘플링 요청이 섞여 있고, 일부 요청은 logprobs도 켜져 있다.

첫 번째 단계, 원본 logprobs를 스냅샷한다.어떤 페널티나 온도를 적용하기 전에, 요청이 logprobs를 필요로 하면 먼저logprobs_mode에 따라 스냅샷 내용을 결정한다📎 vllm/v1/sample/sampler.py:84-93. 주석이 V0와의 차이를 명확히 지적한다는 점에 주목하라: V1은원본 logits(페널티와 온도 이전)로 top-k logprobs를 계산한다📎 vllm/v1/sample/sampler.py:72-77. 이것은 의미론적 계약이다 — 사용자가 보는 logprob은 모델의 실제 분포를 반영해야 하며, 페널티로 왜곡된 분포가 아니어야 한다.

두 번째 단계, float32로 통일한다. 📎 vllm/v1/sample/sampler.py:95-96입력이 bf16이든 fp16이든 모두 float32로 업캐스트한다. 이유는 이후의 log_softmax, top-k, 누적 확률이 저정밀도에서 오차를 누적하기 때문이며, 특히 vocab이 15만에 달할 때 그러하다.

세 번째 단계, 비-argmax 불변 프로세서 체인. apply_logits_processors순차적으로 적용한다: allowed token 화이트리스트 마스크, bad words 제외,non_argmax_invariant프로세서, 페널티 항📎 vllm/v1/sample/sampler.py:391-404. 여기서의 분류가 핵심 설계이다 —non_argmax_invariant이는 다음을 가리킨다그리디 결과를 변경하는프로세서(예: min_tokens, logit_bias)는 그리디 샘플링 이전에 반드시 적용되어야 한다; 반면argmax_invariant프로세서(예: min_p)는 argmax를 변경하지 않으므로 온도 이후로 지연할 수 있다.

네 번째 단계, 샘플링. sample메서드는 먼저 완전 무작위인지 판단한다📎 vllm/v1/sample/sampler.py:256-271: 만약all_greedy이면 직접 argmax를 반환한다; 그렇지 않으면 먼저 그리디 결과를 계산해 두고, 온도, argmax 불변 프로세서, top-k/top-p를 적용한다📎 vllm/v1/sample/sampler.py:275-291. 마지막으로torch.where를 사용해 온도 임계값에 따라 그리디와 무작위 결과 사이에서 선택하고📎 vllm/v1/sample/sampler.py:305-306, 그리고greedy_sampled텐서를 출력 버퍼로 재사용하여 추가 할당을 피한다.

다섯 번째 단계, logprobs를 수집하고 출력을封装한다.에 따라num_logprobs세 가지 경우로 나뉜다: None은 지정된 token의 logprobs만 반환; -1은 전체 미정렬 logprobs 반환; 그렇지 않으면 top-k📎 vllm/v1/sample/sampler.py:120-131. 최종 token id는 int32로 변환하여 크기를 압축하고, 다음과 같이 확장한다[num_requests, 1]의 2차원 텐서📎 vllm/v1/sample/sampler.py:138-148。

mermaid
flowchart TD
    in_logits["logits (bf16/fp16)"] --> snap{"需要 logprobs?"}
    snap -->|是| raw["compute_logprobs / clone<br/>raw_logprobs 快照"]
    snap -->|否| f32
    raw --> f32["logits.to(float32)"]
    f32 --> proc["apply_logits_processors"]
    proc --> mask{"allowed_token_ids_mask?"}
    mask -->|是| fill["masked_fill_(-inf)"]
    mask -->|否| bad
    fill --> bad{"bad_words_token_ids?"}
    bad -->|是| apply_bad["apply_bad_words"]
    bad -->|否| noninv
    apply_bad --> noninv["non_argmax_invariant 处理器"]
    noninv --> pen["apply_penalties"]
    pen --> sample["sample()"]
    sample --> allg{"all_greedy?"}
    allg -->|是| greedy["greedy_sample (argmax)"]
    allg -->|否| temp["apply_temperature"]
    temp --> arginv["argmax_invariant 处理器"]
    arginv --> topp["topk_topp_sampler"]
    topp --> where["torch.where(temp < EPS)"]
    greedy --> out
    where --> out["SamplerOutput<br/>sampled_token_ids"]

설계 고찰과 함정

왜 페널티 항이 온도 이전에 있어야 하는가?온도는 분포에 대한 스케일링이고, 페널티는 특정 token에 대한 가감점이다. 만약 먼저 스케일링하고 나중에 페널티를 주면, 페널티의 절대적 진폭이 온도에 의해 확대되거나 축소되어 동일한 페널티 파라미터 세트가 서로 다른 온도에서 일관되지 않은 동작을 보인다. V1은 페널티를 온도 이전에 고정하여 파라미터 의미론의 안정성을 보장한다.

mark_unbacked의 컴파일 함정.에서gather_logprobs,batched_count_greater_than이 컴파일되며, batch 차원이 1에서 ≥2로 변할 때 dynamo의 0/1 특수화 재컴파일이 트리거된다📎 vllm/v1/sample/sampler.py:345-348。mark_unbacked해당 차원을 완전히 심볼릭으로 표시하여 이 재컴파일을 피한다. 프로덕션 환경에서 decode 첫 요청 후 갑자기 한 번 멈춤이 보인다면, 십중팔구 이런 종류의 재컴파일이다.

gpu_sync_allowed의 동기화 경계. batched_count_greater_than내부에서 GPU 동기화가 트리거될 수 있으며, vLLM은gpu_sync_allowed(first_only=True)컨텍스트로 "여기서 동기화를 허용하지만, 첫 번째만 허용한다"고 명시적으로 선언한다📎 vllm/v1/sample/sampler.py:345-348. 만약 CUDA Graph 캡처 영역 내에서 의도치 않게 동기화가 발생하면 캡처 실패를 초래한다 — 이것이 그래프 캡처 문제를排查하는 핵심 단서이다.

구조화된 출력: 비트마스크와 문법의 이중 트랙 상태 머신

직관적 모델: 구조화된 출력은 샘플러에게 "문법 안경"을 씌우는 것과 같다 — 각 단계에서 JSON schema나 문법에 맞는 token만 볼 수 있다. 이것이 없으면 모델이 문법 오류가 있는 JSON을 생성하여 다운스트림 파서가 바로崩溃할 수 있다. vLLM 구현의精髓는: 문법 상태 머신은 CPU 측에서 진행되고, 제약은 비트마스크 형태로 GPU 측 샘플링에 전달된다.

데이터 구조와 메모리 레이아웃

StructuredOutputManager은 엔진 레벨 싱글톤으로,backend(xgrammar/guidance/outlines/lm-format-enforcer 중 하나),reasoner_cls과 두 개의 스레드 풀을 보유한다📎 vllm/v1/structured_output/__init__.py:39-98。

비트마스크는 핵심 데이터 구조이다:_grammar_bitmask은 형태가[max_batch_size * (1 + max_num_spec_tokens), vocab_size/32]인 int32 텐서📎 vllm/v1/structured_output/__init__.py:327-336. 각 bit는 하나의 token이 합법인지에 대응한다._full_mask = torch.tensor(-1, dtype=torch.int32)는 "전부 1"을 나타낸다 — 모든 token 합법📎 vllm/v1/structured_output/__init__.py:59。

두 스레드 풀의 분업은 명확하다:executor은 문법 컴파일 담당(CPU 집약적, worker 수는 CPU 수의 절반)📎 vllm/v1/structured_output/__init__.py:71-78;executor_for_fillmask은 대형 batch 비트마스크 병렬 채우기 담당, batch가 128을 초과할 때만 활성화📎 vllm/v1/structured_output/__init__.py:62-69。

Step-by-Step Walkthrough

문법 초기화.요청이 처음 진입할 때grammar_init이 호출된다📎 vllm/v1/structured_output/__init__.py:115-176. backend가 초기화되지 않았으면 설정에 따라 구현을 선택한다📎 vllm/v1/structured_output/__init__.py:130-165. 이후 컴파일 작업을 제출한다: 기본적으로 비동기executor.submit를 사용하지만,external_launcher모드에서는 반드시 동기📎 vllm/v1/structured_output/__init__.py:167-176。

비트마스크 생성.각 decode step마다,grammar_bitmask이 배치 내 모든 구조화된 요청에 대해 마스크를 생성한다📎 vllm/v1/structured_output/__init__.py:314-442. 대형 batch는 병렬 경로: 16개씩 한 배치로 스레드 풀에 제출📎 vllm/v1/structured_output/__init__.py:346-373. 소형 batch는 직렬 경로, token별로 문법 상태를 진행📎 vllm/v1/structured_output/__init__.py:374-433。

투기적 디코딩 하의 마스크 정렬.이것이 가장 정교한 부분이다. draft token이 있을 때, 각 요청은1 + max_num_spec_tokens행 마스크가 필요하다. 직렬 경로는 token별로 처리한다: 만약 어떤 draft token이 문법에 의해 거부되면,failed_index을 기록하고, 이후 행은 해당 행의 마스크를 직접 복사한다📎 vllm/v1/structured_output/__init__.py:396-418. 이는 "draft가 거부된 후, 이후 위치의 제약 상태가 거부 지점으로 롤백됨"을 보장한다.

상태 롤백.비트마스크 채우기 과정에서 문법 상태가state_advancements단계 진행되었지만, draft token이 아직 실제로 수락되지 않았으므로 반드시grammar.rollback(state_advancements)롤백해야 한다📎 vllm/v1/structured_output/__init__.py:422-430. 실제 수락은accept_tokens 📎 vllm/v1/structured_output/__init__.py:444-466。

mermaid
sequenceDiagram
    participant Sched as Scheduler
    participant Mgr as StructuredOutputManager
    participant Pool as executor_for_fillmask
    participant Gram as StructuredOutputGrammar
    participant GPU as GPU Runner

    Sched->>Mgr: grammar_bitmask(requests, ids, spec_tokens)
    Mgr->>Mgr: allocate_token_bitmask(max_batch*(1+spec))
    alt batch > 128 且无投机
        Mgr->>Pool: _async_submit_fill_bitmask(batch)
        Pool->>Gram: fill_bitmask(bitmask, index)
        Gram-->>Pool: 写入合法 token 位
        Pool-->>Mgr: Future.result()
    else 小 batch 或含投机
        loop 每个 req 的每个 spec token
            Mgr->>Gram: fill_bitmask(bitmask, cumulative_index)
            Mgr->>Gram: accept_tokens(req_id, [token])
            Gram-->>Mgr: True/False
            Note over Mgr: 失败则记录 failed_index<br/>后续行复制该行
        end
        Mgr->>Gram: rollback(state_advancements)
    end
    Mgr-->>Sched: bitmask.numpy() (NDArray int32)
    Sched->>GPU: 传入采样内核

설계 고찰과 함정

왜 external_launcher는 반드시 동기 컴파일이어야 하는가?주석이 정확한 이유를 제시한다: 비동기 컴파일은WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR → WAITING상태 전환이 서로 다른 TP rank에서 서로 다른 시점에 발생하게 하여, external_launcher가 의존하는 결정론적 가정을 깨뜨린다📎 vllm/v1/structured_output/__init__.py:47-56. 이는 분산 결정성과 비동기 최적화 충돌의 전형적인 사례이다.

추론 모델 하에서의 제약 시작점. _get_constraint_start몇 번째 token부터 문법 제약을 적용할지 결정한다📎 vllm/v1/structured_output/__init__.py:220-292. 사고 연쇄(chain-of-thought)가 있는 모델의 경우, reasoning 단계는 JSON 제약을 받지 않아야 하며, reasoning이 끝난 후에만 시작된다.enable_in_reasoningTrue일 때 직접 0을 반환한다 (전체 구간 제약)📎 vllm/v1/structured_output/__init__.py:235-236. reasoner가 지원하면find_reasoning_end_offset, 이를 사용해 정확히 위치를 찾는다📎 vllm/v1/structured_output/__init__.py:261-267; 그렇지 않으면 token별 역방향 탐색으로 폴백한다📎 vllm/v1/structured_output/__init__.py:287-291。

validate_tokens의 접두사 의미.투기적 디코딩 시 draft token이 문법을 위반할 수 있다,validate_tokens"최장 합법 접두사"를 반환한다📎 vllm/v1/structured_output/__init__.py:294-312. 주의: 먼저 투기적 패딩(-1)을 제거하고, 그 다음 제약 시작점을 계산하며, 마지막으로 제약 구간 내의 token에 대해서만 문법 검증을 수행한다.

Detokenizer: 증분 디코딩과 stop string의 경계博弈

직관적 모델: detokenizer는 한 글자씩 베껴 쓰는 서기와 같아서, token id를 사람이 읽을 수 있는 텍스트로 번역한다. 어려운 점은: token과 문자가 일대일 대응이 아니며(하나의 token이 UTF-8 문자의 절반만 대응할 수 있음), stop string이 여러 token에 걸쳐 있을 수 있다는 것이다. 증분 디코딩이 없으면 매 단계마다 전체 시퀀스를 처음부터 디코딩해야 하며, O(n²)의 오버헤드가 처리량을 무너뜨린다.

데이터 구조와 메모리 레이아웃

IncrementalDetokenizer기반 클래스는 단지 보유한다token_ids리스트📎 vllm/v1/engine/detokenizer.py:32-33。BaseIncrementalDetokenizerstop 관련 필드가 추가되었다:stop리스트,min_tokens、include_stop_str_in_output、stop_buffer_length과_last_output_text_offset 📎 vllm/v1/engine/detokenizer.py:70-94。

stop_buffer_length이 핵심이다: stop string이 출력에 포함되지 않을 때, 이는 최장 stop string 길이에서 1을 뺀 값과 같다📎 vllm/v1/engine/detokenizer.py:87-90. 이 "되돌림 버퍼"는 스트리밍 출력이 stop string의 접두사일 수 있는 문자를 미리 내보내지 않도록 보장한다.

두 가지 구현 경로:FastIncrementalDetokenizertokenizers 라이브러리의DecodeStream 📎 vllm/v1/engine/detokenizer.py:166-246;SlowIncrementalDetokenizerPython 측detokenize_incrementally 📎 vllm/v1/engine/detokenizer.py:249-305. 선택 기준은 tokenizers 버전 ≥ 0.22.0이고 tokenizer 유형이 일치하는지 여부이다📎 vllm/v1/engine/detokenizer.py:32-33📎 vllm/v1/engine/detokenizer.py:61-63。

Step-by-Step Walkthrough

증분 디코딩. update새로운 token ids와stop_terminated플래그를 받는다📎 vllm/v1/engine/detokenizer.py:96-142. stop이 종료되고 stop string을 포함하지 않으면, 마지막 token은 디코딩에서 제외된다📎 vllm/v1/engine/detokenizer.py:107-111. 이후 token별로decode_next을 호출하여 텍스트를 누적한다📎 vllm/v1/engine/detokenizer.py:117-122。

stop string 감지. check_stop_strings새로 추가된 문자 범위 내에서만 검색한다📎 vllm/v1/engine/detokenizer.py:308-360. 검색 시작점은1 - new_char_count - stop_string_len 📎 vllm/v1/engine/detokenizer.py:338이며, 이 오프셋은 token 경계를 넘는 stop string도 포착되도록 보장한다. 여러 stop string이 동시에 매칭될 때, 선택한다가장 먼저 완료되는것📎 vllm/v1/engine/detokenizer.py:342-347。

스트리밍 출력 슬라이스. get_next_output_text에 따라delta파라미터가 전체를 반환할지 증분을 반환할지 결정한다📎 vllm/v1/engine/detokenizer.py:148-163. 미완료 시stop_buffer_length개 문자를 내보내지 않고 보류한다📎 vllm/v1/engine/detokenizer.py:145-146,_last_output_text_offset으로 이미 전송된 위치를 기록한다📎 vllm/v1/engine/detokenizer.py:148-163。

예외 복구. FastIncrementalDetokenizer._protected_step두 가지 예외를 처리한다: OverflowError/TypeError는 로그를 기록하고 None을 반환한다📎 vllm/v1/engine/detokenizer.py:225-229; "Invalid prefix" 오류의 경우DecodeStream을 재구성한다하고 재시도한다📎 vllm/v1/engine/detokenizer.py:222-246. 후자는 tokenizer가 비단조적 UTF-8 출력을 생성하는 경계 상황에 대응한다.

설계 고찰과 함정

stop_buffer_length의 트레이드오프.버퍼가 길수록 스트리밍 지연이 커지지만(사용자가 텍스트를 보는 시간이 늦춰짐), token을 넘는 stop string을 놓칠 확률이 낮아진다. "최장 stop string 길이에서 1을 뺀 값"을 취하는 것이 정확한 하한이다: 어떤 stop string의 접두사도 최대 이 길이를 넘지 않는다.

min_tokens와 stop_check_offset.출력 token 수가min_tokens에 미달할 때,stop_check_offset은 계속 텍스트 끝으로 밀린다📎 vllm/v1/engine/detokenizer.py:120-122, 이는 이 텍스트가 stop 감지되지 않음을 의미한다. 이는 모델이 시작 부분에서 stop string에 부딪혀 빈 출력이 되는 것을 방지한다.

Fast 경로의 added_token_ids 캐시.가spaces_between_special_tokensFalse일 때, 특수 token 사이의 공백을 억제해야 한다📎 vllm/v1/engine/detokenizer.py:192-207. 코드는added_token_ids을 tokenizer 객체에 캐시한다📎 vllm/v1/engine/detokenizer.py:195-200, 매 decode마다 딕셔너리를 재구성하는 것을 피한다.

설계 고찰

세 모듈은 하나의 설계 철학을 공유한다:상태 진행과 제약 검사를 분리하여, GPU 측은 무상태 텐서 연산만 수행하게 한다. Sampler는 무상태이고, 상태는SamplingMetadata에 있다; 문법 상태 머신은 CPU 측에서 진행되고, GPU는 비트 마스크만 소비한다; detokenizer의_last_output_text_offset은 유일한 스트리밍 커서이다. 이러한 분리는 각 GPU 측 컴포넌트가 CUDA Graph에 의해 캡처될 수 있게 한다.

또 다른 주된 흐름은순서가 곧 의미이다. Sampler의 프로세서 체인 순서, 구조화된 출력의 제약 시작점, detokenizer의 stop 감지 오프셋, 어느 하나라도 순서가 틀리면 크래시하지 않고 조용히 잘못된 결과를 낸다——이것이 바로 이런 코드가 가장 디버깅하기 어려운 이유이다.

이 장 요약

  • Sampler의 프로세서 체인은 엄격히 정렬된다: 원시 logprobs 스냅샷 → float32 → 화이트리스트/bad words → non-argmax-invariant → 페널티 → 온도 → argmax-invariant → top-k/top-p.
  • 구조화 출력은 비트마스크로 CPU 측 문법 상태를 GPU에 전달하며, 투기적 디코딩 하에서failed_index복사와rollback를 통해 상태 일관성을 보장한다.
  • Detokenizer는stop_buffer_length폴백 버퍼로 스트리밍 지연과 stop string의 토큰 간 검출을 균형 있게 처리하며, Fast 경로는 tokenizers ≥ 0.22.0의DecodeStream。

이 장의 생각과 자가 점검

Q1: 만약apply_logits_processors의 페널티 항(apply_penalties)을 온도 이후로 옮겨 실행하면, temperature=2.0의 고온 샘플링 시나리오에서 어떤 구체적 편차가 발생하는가? 왜인가?

참고 해석: 온도는 전체 logits 벡터에 대한 스케일링(logits.div_(temp))📎 vllm/v1/sample/sampler.py:241-242이다. 페널티 항(예: repetition penalty)은 특정 토큰에 대한 곱셈/덧셈 조정이다. 만약 스케일링 후 페널티를 적용하면, 페널티의 절대적 크기가 온도에 의해 2배로 증폭되어 동일한repetition_penalty파라미터가 고온에서 억제 효과가 저온보다 훨씬 강해지며, 파라미터 의미가 온도에 따라 표류한다. V1은 페널티를 온도 앞에 고정하여📎 vllm/v1/sample/sampler.py:403-404, 페널티 크기가 온도와 분리되도록 보장한다. 또한 페널티는non_argmax_invariant범주에 속하며(그리디 결과에 영향), 그리디 경로는 온도 이전에 이미 반환되므로📎 vllm/v1/sample/sampler.py:261-271, 만약 온도 이후로 옮기면 그리디 요청은 페널티를 완전히 우회하게 되어 동작이 일관되지 않는다.

Q2:grammar_bitmask의 직렬 경로에서 만약grammar.rollback(state_advancements) 📎 vllm/v1/structured_output/__init__.py:422-430이 줄을 삭제하면, 투기적 디코딩 + 구조화 출력의 조합에서 무슨 일이 발생하는가?accept_tokens의 호출 시점과 결합하여 분석하라.

참고 해석: 비트마스크 채우기 시, 코드는 각 draft token에 대해grammar.accept_tokens를 호출하여 문법 상태를 진행시켜 다음 위치의 마스크를 생성하지만📎 vllm/v1/structured_output/__init__.py:396-418, 이는 단지 "시험적 진행"일 뿐이다 — draft token은 아직 대상 모델에 의해 검증·수락되지 않았다. 만약rollback를 삭제하면, 문법 상태는 영구적으로 "모든 draft가 수락됨" 위치에 머무른다. 대상 모델이 실제로 일부 draft token을 거부했을 때, 실제 수락된 토큰 시퀀스와 문법 상태가 불일치하게 된다:accept_tokens 📎 vllm/v1/structured_output/__init__.py:444-466는 잘못된 문법 상태를 기반으로 검증하여, 합법적 토큰이 거부되거나 불법 토큰이 통과될 수 있다. 결과적으로 JSON 출력이 조용히 손상되며, 크래시는 없지만 하위 파싱이 실패한다.

Q3: check_stop_strings의 검색 시작점은1 - new_char_count - stop_string_len 📎 vllm/v1/engine/detokenizer.py:338이다. 만약 0부터 전체 검색으로 변경하면, 기능적으로 올바른가? 긴 시퀀스 스트리밍 시나리오에서 어떤 성능 문제가 발생하는가?

참고 해석: 기능적으로 올바르다 — 0부터 검색하면 토큰 경계를 넘는 것을 포함한 모든 매칭을 찾을 수 있다. 그러나 성능상, 매 단계마다 전체output_text에 대해find를 수행하여, 복잡도가 O(new_char_count)에서 O(total_length)로 퇴화하며, 긴 시퀀스에서는 O(n²)이다. 더 심각한 것은, 0부터 검색하면이미 사용자에게 전송된 역사 텍스트내의 stop string 부분 문자열과 매칭될 수 있어, stop이 중복 트리거되거나 잘못 절단될 수 있다. 원래 설계의 오프셋1 - new_char_count - stop_string_len은 "신규 문자 + 경계를 넘을 수 있는 stop string 접두사"라는 최소 필요 윈도우를 정확히 커버하여, 누락 검출을 방지하면서 역사 오매칭도 피한다.

여기까지, 단일 머신에서의 추론 전체 체인이 완성되었다: 어텐션 계산부터 샘플링 출력까지, 각 단계가 최종 전달되는 텍스트 품질에 직접 영향을 미친다. 그러나 모델 규모가 단일 카드 용량을 초과하면, 이 체인은 반드시 여러 장치에 걸쳐 협력하여 완료되어야 한다. 다음 장에서는 단일 머신을 떠나 분산 병렬로 진입한다: TP, PP, EP가 모델을 어떻게 분할하는지, 통신 원시 연산이 rank 간에 이러한 샘플링 결과를 어떻게 동기화하는지.

CHAPTER 08

제8장: 분산 병렬: TP, PP, EP와 통신 원시 연산

소속 프로젝트: vllm-project/vllm · 전체 진행도: 제8 / 14장 · 검증 상태: FACT 행 번호 실제 앵커링

이전 장에서 우리는 단일 추론 생명주기의 마지막 1킬로미터를 완주했다: logits 샘플링부터 스트리밍 출력까지. 그러나 모델이 단일 카드에 담을 수 없을 만큼 커지면, 이 파이프라인은 반드시 여러 장치로 분할되어 협력 실행되어야 한다. 분산 추론의 제1의 문제는 "모델을 어떻게 분할하는가"가 아니라 "분할 후, 누가 누구와 대화하고, 어떤 방식으로 대화하는가"이다. vLLM은 이 두 문제를 각각 parallel_state.py의 프로세스 그룹 토폴로지와 custom_all_reduce.py의 통신기 구현에 맡긴다. 이 장은 "그룹 구축 → 분할 → 통신 → 부하 재균형"이라는 체인을 따라, TP, PP, EP의 병렬 전략과 저수준 통신 원시 연산을 층층이 해부한다.

8.1 프로세스 그룹 토폴로지: 하나의 rank 그리드에서 TP/PP/DP/EP를 어떻게 잘라내는가

직관 모델

8장의 GPU를 8개 좌석의 긴 테이블이라고 상상하자. 텐서 병렬(Tensor Parallelism, TP)은 "같은 테이블 사람들이 동시에 잔을 들어야 한다"를 요구하고, 파이프라인 병렬(Pipeline Parallelism, PP)은 "인접 좌석이 릴레이로 요리를 전달"을 요구하며, 데이터 병렬(Data Parallelism, DP)은 "다른 테이블은 각자 먹되 마지막에 대조"를 요구하고, 전문가 병렬(Expert Parallelism, EP)은 "토큰을 진료과별로 분류"를 요구한다. 만약 통일된 좌석 배치가 없다면, 각 모듈이 각자new_group, "TP 그룹에 있다고 생각했는데 실제로는 DP 그룹에 있다"는 통신 불일치가 발생한다——집합 통신에서 rank가 하나라도 빠지면 NCCL은 오류를 내는 대신 그대로 멈춰버린다.

데이터 구조와 메모리 레이아웃

GroupCoordinator이 모든 것의 운반체이다. 그 필드 설계는 "하나의 프로세스가 여러 병렬 차원에서 가지는 다중 정체성"에 직접 대응한다:

  • rank은 전역 rank이고,ranks은 본 그룹 구성원의 전역 rank 목록이며,world_size은 그룹 크기📎 vllm/distributed/parallel_state.py:434-436。
  • local_rank는 디바이스 바인딩에 사용되고,rank_in_group은 그룹 내 순번이다——소스 코드는 하나의 표로 둘을 정확히 구분한다: 두 노드에 걸친 4카드 그룹에서 rank 2의local_rank은 0이고(노드 1에서 첫 번째 카드), 하지만rank_in_group은 2이다📎 vllm/distributed/parallel_state.py:437-445。
  • cpu_group과device_group은 쌍으로 존재한다: 전자는 gloo로 메타데이터/객체 통신을 하고, 후자는 NCCL로 텐서 통신을 한다📎 vllm/distributed/parallel_state.py:446-447。

여기에는 핵심 설계가 있다:왜 각 그룹마다 CPU 그룹을 유지해야 하는가?왜냐하면broadcast_object、send_object같은 연산이 전송하는 것은 Python 객체(직렬화된 바이트)라서, NCCL로 가면 VRAM을 낭비할 뿐만 아니라 현재 CUDA 디바이스를 오염시킬 수 있기 때문이다.barrier()의 주석은 이 점을 아주 직설적으로 설명한다: NCCL의 barrier는 내부적으로 broadcast 한 번이라서 GPU 텐서를 몰래 생성해 현재 디바이스를 쉽게 어지럽히므로, 반드시 CPU 그룹을 써야 한다📎 vllm/distributed/parallel_state.py:1355-1362。

Step-by-Step:initialize_model_parallel그리드를 어떻게 자르는가

구체적인 시나리오를 대입해보자: 8카드, TP=2, PP=4, DP=1. 핵심은 1차원 rank 시퀀스를 다차원 그리드로 reshape한 뒤 각 차원을 따라 분할하는 것이다.

첫 번째 단계, rank 그리드를 구성한다. 레이아웃 순서는ExternalDP x DP x PP x PCP x TP 📎 vllm/distributed/parallel_state.py:2045-2060:

python
all_ranks = torch.arange(world_size).reshape(
    -1, data_parallel_size, pipeline_model_parallel_size,
    prefill_context_model_parallel_size, tensor_model_parallel_size,
)

두 번째 단계, TP 그룹을 자른다: 그리드를(-1, tp_size)로 view한 뒤 unbind하여[g0,g1],[g2,g3],... 📎 vllm/distributed/parallel_state.py:2065-2077을 얻는다. TP 그룹은use_message_queue_broadcaster=True를 추가로 전달하는데, TP 그룹은 메타데이터 배포를 위해 공유 메모리 broadcast가 필요하기 때문이다.

세 번째 단계, PP 그룹을 자른다:all_ranks.transpose(2, 4)PP 차원을 마지막 차원으로 옮긴 뒤 잘라서[g0,g2,g4,g6],[g1,g3,g5,g7] 📎 vllm/distributed/parallel_state.py:2175-2188을 얻는다. 이것이 바로 docstring에 제시된 예시이다📎 vllm/distributed/parallel_state.py:1997-1997。

네 번째 단계, DP 그룹을 자른다:transpose(1, 4)후📎 vllm/distributed/parallel_state.py:2195-2202。

를 자른다📎 vllm/distributed/parallel_state.py:2210-2241다섯 번째 단계, EP 그룹을 자른다——여기에는 놓치기 쉬운 세부 사항이 있다: EP 그룹은 MoE 모델에서만 생성되고, dense 모델은 그냥 건너뛴다DP x PCP x TP. EP 그룹의 rank 집합은

mermaid
flowchart TD
    start["initialize_model_parallel()"] --> grid["all_ranks = arange(world_size).reshape(-1, DP, PP, PCP, TP)"]
    grid --> tp["TP: view(-1, tp_size).unbind(0)"]
    grid --> pp["PP: transpose(2,4).reshape(-1, pp_size)"]
    grid --> dp["DP: transpose(1,4).reshape(-1, dp_size)"]
    grid --> ep_check{"model_config.is_moe?"}
    ep_check -->|是| ep["EP: transpose(1,2).reshape(-1, DP*PCP*TP)"]
    ep_check -->|否| skip["_EP 保持 None"]
    ep --> eplb_check{"enable_eplb?"}
    eplb_check -->|是| eplb["EPLB: 与 EP 同 rank 集,独立 PG"]
    eplb_check -->|否| no_eplb["_EPLB 保持 None"]
    tp --> done["logger.info_once 打印各维度 rank"]
    pp --> done
    dp --> done
    ep --> done
    skip --> done
    eplb --> done
    no_eplb --> done

복사

설계 고찰과 함정EPLB는 왜 독립 프로세스 그룹이 필요한가?📎 vllm/distributed/parallel_state.py:2243-2246주석이 답을 준다: EPLB 통신과 MoE 순전파의 집합 통신을 격리하여 "실행 시점의 torch.distributed"와 "EPLB의 torch.distributed"가 서로 교착되는 것을 방지한다

. 이는 전형적인 "독립 통신 도메인으로 결정성을 얻는" 트레이드오프이다——PG 하나가 더 쓰는 VRAM 비용을 치르고, 가중치 이동 시 순전파가 멈추지 않는 것을 얻는다.DP 그룹의 동기화 제약generate은 프로덕션 환경에서 가장 자주 밟는 함정이다: 같은 DP 그룹 내 모든 rank가 동시에📎 vllm/distributed/parallel_state.py:2048-2051를 호출해야 하며, 그렇지 않으면 교착된다

. DP 그룹 내에서는 그래디언트/샘플링 결과의 all-reduce를 수행하므로, 어떤 rank든 빠지면 집합 통신이 영구적으로 차단된다.파괴 순서destroy()도 역시 주의가 필요하다.📎 vllm/distributed/parallel_state.py:1380-1393는 먼저 device communicator를 파괴하고, 그다음 device_group과 cpu_group을 파괴한다📎 vllm/distributed/parallel_state.py:1377-1377。

. 주석은 그 이유를 설명한다: device communicator는 이들 PG에 의존하는 집합 통신 작업 공간(예: FlashInfer PCIe IPC barrier)을 보유할 수 있으므로 반드시 먼저 해제해야 한다

8.2 통신 프리미티브: 커스텀 all-reduce가 NCCL을 어떻게 우회하는가

직관적 모델cudaMemcpyNCCL의 all-reduce는 "범용 트럭"으로, 어떤 화물이든 실을 수 있고 어떤 길이든 갈 수 있지만 시작 오버헤드와 프로토콜 오버헤드가 고정되어 있다. 8카드 NVLink 전연결 머신에서 작은 텐서 all-reduce를 반복적으로 해야 할 때(TP의 각 attention/MLP 레이어마다), 범용 트럭의 "통행료"는 무시할 수 없게 된다. 커스텀 all-reduce는 "전용 손수레"이다: 동일 머신, NVLink 전연결, 적절한 텐서 크기 시나리오에서만 활성화되며, 한 번의

로 NCCL의 핸드셰이크와 프로토콜 오버헤드를 대체한다.

CustomAllreduce데이터 구조와 메모리 레이아웃

  • _SUPPORTED_WORLD_SIZES = [2, 4, 6, 8, 16]의 초기화는 "능력 탐지 + 자원 사전 할당"의 조합이다. 핵심 필드:📎 vllm/distributed/device_communicators/custom_all_reduce.py:113-129。
  • meta_ptrs: 이 그룹 크기들만 지원한다ops.meta_size() + max_size 📎 vllm/distributed/device_communicators/custom_all_reduce.py:291-294。
  • buffer_ptrs: 메타데이터 + 중간 결과 버퍼 동기화, 크기📎 vllm/distributed/device_communicators/custom_all_reduce.py:298-305。
  • rank_data: 사전 등록된 IPC 버퍼, eager 모드에서 입력 텐서를 먼저 복사한 뒤 계산한다📎 vllm/distributed/device_communicators/custom_all_reduce.py:309-315。

: 8MB uint8 텐서로, 모든 rank의 IPC 버퍼 포인터 튜플을 저장한다왜 버퍼를 사전 등록해야 하는가?register_graph_buffersCUDA Graph 캡처는 캡처 시점에 모든 주소가 고정되어야 하기 때문이다.📎 vllm/distributed/device_communicators/custom_all_reduce.py:474-491。

는 캡처 종료 시 사용된 모든 버퍼 주소를 모든 rank에 broadcast하고 등록한다

Step-by-Step: 한 번의 all-reduce 의사결정 흐름

시나리오를 대입하자: TP 그룹 내 어떤 MLP 레이어의 출력이 all-reduce를 해야 하고, 입력은 4MB bf16 텐서이다.custom_all_reduce첫 번째 단계,should_custom_ar 📎 vllm/distributed/device_communicators/custom_all_reduce.py:529-533。

비활성화 여부,should_custom_ar항목별 필터링: world_size > 8이면 거부; dtype은 반드시 fp32/fp16/bf16이어야 함; 바이트 수는 반드시 16의 배수여야 함; 반드시 weakly contiguous해야 함; world_size==2이거나 완전 연결일 때만 계속 진행📎 vllm/distributed/device_communicators/custom_all_reduce.py:493-508。

세 번째 단계, CUDA Graph 캡처 중인지에 따라 분기: 캡처 중에는registered=True(주소가 이미 고정됨), 그렇지 않으면registered=False(먼저 사전 등록된 버퍼로 memcpy 필요)📎 vllm/distributed/device_communicators/custom_all_reduce.py:529-545。

네 번째 단계, 실제로 호출ops.all_reduce, 전달buffer_ptrs[rank]및max_size 📎 vllm/distributed/device_communicators/custom_all_reduce.py:519-527。

mermaid
flowchart TD
    call["custom_all_reduce(input)"] --> disabled{"self.disabled?"}
    disabled -->|是| ret_none["return None → 回退 NCCL"]
    disabled -->|否| should{"should_custom_ar(input)?"}
    should -->|否| ret_none
    should -->|是| capturing{"self._IS_CAPTURING?"}
    capturing -->|是| stream_cap{"is_current_stream_capturing()?"}
    stream_cap -->|是| reg["all_reduce(registered=True)"]
    stream_cap -->|否| mimic["return empty_like(input) 模拟分配"]
    capturing -->|否| eager["all_reduce(registered=False) 先 memcpy"]
    reg --> out["返回 out 张量"]
    eager --> out

설계 고민과 함정

다중 노드 시나리오의 폴백 경로가 이 코드에서 가장 정교한 부분이다.same_node가 거짓일 때,mnnvl_only를 참으로 설정📎 vllm/distributed/device_communicators/custom_all_reduce.py:198-199, 이후 MNNVL(Multi-Node NVLink) 능력을 확인한다. 그룹 내 모든 카드가 MNNVL을 지원하지 않으면 커스텀 집합 통신을 즉시 비활성화📎 vllm/distributed/device_communicators/custom_all_reduce.py:228-233。_group_can_attempt_mnnvlCPU all-reduce(MIN 연산) 한 번으로 모든 rank가 동일한 제어 흐름을 타도록 보장📎 vllm/distributed/device_communicators/custom_all_reduce.py:59-73——이것이 이기종 클러스터에서 "일부 rank는 MNNVL 경로로, 일부는 NCCL로" 가서 교착되는 것을 방지하는 핵심 보호 장치다.

P2P 검사의 비용:_can_p2p은 모든 peer를 순회하며gpu_p2p_access_check를 수행하고, 주석에는 최초 계산이 매우 비싸지만 캐시된다고 되어 있다📎 vllm/distributed/device_communicators/custom_all_reduce.py:278-278. 프로덕션 환경에서 시작이 느리면VLLM_SKIP_P2P_CHECK를 설정해 건너뛰고 드라이버의 P2P 보고를 직접 신뢰할 수 있다📎 vllm/distributed/device_communicators/custom_all_reduce.py:86-100。

reduce-scatter의 3단계 백엔드 선택은 따로 볼 가치가 있다:_select_reduce_scatter_backend은 우선순위에 따라 반환mnnvl_multimem > mnnvl_lamport > legacy 📎 vllm/distributed/device_communicators/custom_all_reduce.py:601-636. multimem 경로는 world_size가(2,4,8)에 있고 디바이스 능력이 (10,0) 또는 (10,3)(Blackwell급)이어야 한다📎 vllm/distributed/device_communicators/custom_all_reduce.py:103-104. 주의:VLLM_BATCH_INVARIANT는 multimem 경로를 비활성화한다📎 vllm/distributed/device_communicators/custom_all_reduce.py:628——multimem의 리덕션 순서가 비결정적이라 배치 불변성을 깨뜨리기 때문이다.

8.3 EPLB: 전문가 부하 재균형의 스케줄링 로직

직관적 모델

MoE 모델에서 256개의 논리 전문가가 32장의 카드에 분배되어 카드당 8개다. 하지만 실제 트래픽에서는 일부 "인기 전문가"(예: 흔한 문법 구조를 처리하는)에 대량의 토큰이 라우팅되어, 이를 보유한 카드가 병목이 되고 다른 카드는 유휴 상태가 된다. EPLB(Expert Parallel Load Balancer)는 "인기 전문가에 복제본을 추가"하는 것: 인기 전문가의 가중치를 유휴 카드에 복사해 토큰을 분산시킨다. 이것이 없으면 MoE의 실제 처리량은 가장 느린 카드에 의해 잠긴다.

데이터 구조와 메모리 레이아웃

EplbModelState은 세 개의 매핑 테이블로 "논리 전문가 ↔ 물리 전문가" 관계를 기술한다:

  • physical_to_logical_map: 형상(num_moe_layers, num_physical_experts), 각 물리 슬롯이 담당하는 논리 전문가 id 저장📎 vllm/distributed/eplb/eplb_state.py:105-120。
  • logical_to_physical_map: 형상(num_moe_layers, num_logical_experts, max_replicas+1), 희소 행렬, -1은 매핑 없음📎 vllm/distributed/eplb/eplb_state.py:123-146。
  • logical_replica_count: 각 논리 전문가의 복제본 수📎 vllm/distributed/eplb/eplb_state.py:147-161。

expert_load_window은 슬라이딩 윈도우, 형상(window_size, num_moe_layers, num_physical_experts) 📎 vllm/distributed/eplb/eplb_state.py:180-187. 주석에서 특히 지적: 이제 로컬 전문가만이 아니라 모든 물리 전문가의 부하를 기록하여 서로 다른 dispatch 방법(naive all-to-all, DeepEP)의 통계가 일치하도록 보장; naive all-to-all에서는 각 DP rank가 동일한 토큰 집합을 기여하므로 부하가 dp_size만큼 곱해진다📎 vllm/distributed/eplb/eplb_state.py:180-187。

Step-by-Step: 한 번의 재배치 전체 경로

시나리오 대입:expert_rearrangement_step이 임계값에 도달, 트리거rearrange()。

첫 번째 단계, 물리 부하를 논리 전문가로 역매핑. 사용scatter_add_을physical_to_logical_map기준으로 집계, 유효하지 않은 슬롯(<0)은invalid_idx버킷에 채운 후 마지막에 버림📎 vllm/distributed/eplb/eplb_state.py:794-816。

두 번째 단계, rank 간 all-reduce로 전역 논리 부하 획득._allreduce_list은 여러 모델의 부하를 연결한 후 한 번 all-reduce하고 다시 분리하여 다중 통신 회피📎 vllm/distributed/eplb/eplb_state.py:1045-1068。

세 번째 단계, 전략 호출로 새 매핑 계산.policy.rebalance_experts은 host에서 실행되므로 부하 윈도우와 현재 매핑을 모두 CPU로 복사해야 함📎 vllm/distributed/eplb/eplb_state.py:859-867。

네 번째 단계, ROCm 특화 "재배치 건너뛰기" 판단: 새 매핑이 가져오는 rank 부하 불균형 개선이 5% 미만이면 이번 재배치를 건너뜀📎 vllm/distributed/eplb/eplb_state.py:869-923. 이는 실용적 최적화——재배치 자체에 통신 비용이 있으니 이득이 충분치 않으면 하지 않는다.

다섯 번째 단계, 가중치 이송 실행 및 새 매핑 커밋📎 vllm/distributed/eplb/eplb_state.py:925-942。

mermaid
sequenceDiagram
    participant Main as 主线程 step()
    participant Policy as DefaultEplbPolicy
    participant Comm as EplbCommunicator
    participant Async as async_worker 线程
    Main->>Main: expert_rearrangement_step >= interval
    Main->>Main: scatter_add_ 物理负载→逻辑负载
    Main->>Main: _allreduce_list 跨 rank 聚合
    Main->>Policy: rebalance_experts(load, replicas, groups, nodes, gpus, map)
    Policy-->>Main: new_physical_to_logical_map
    alt 同步模式
        Main->>Comm: rearrange_expert_weights_inplace()
        Comm-->>Main: 权重搬运完成
        Main->>Main: _commit_eplb_maps()
    else 异步模式
        Main->>Main: eplb_stats = EplbStats(...); rebalanced = True
        Main->>Async: rearrange_event.record()
        Async->>Comm: 后台搬运权重到 expert_buffer
        Async-->>Main: pending_result 就绪
        Main->>Main: _move_to_workspace() 提交
    end

설계 고민과 함정

비동기 모드의 동기화 원시 연산이 이 코드에서 가장 미묘한 부분이다.rebalanced플래그는 GIL에 의존해 메인 스레드와 async worker 사이에서 동기화된다📎 vllm/distributed/eplb/eplb_state.py:194-203. 하지만 주석은 경고한다:rebalanced는 모든 rank에서 일관되어야 하며, 그렇지 않으면_all_ranks_result_ready내부의 all-reduce가 교착된다📎 vllm/distributed/eplb/eplb_state.py:664-665。_all_ranks_result_ready은 CPU 그룹으로 all-reduce를 우선 사용하는데, CPU 그룹이 더 신뢰할 수 있기 때문이다📎 vllm/distributed/eplb/eplb_state.py:1024-1043。

슬라이딩 윈도우의 "사전 녹화" 최적화:_should_record_current_step은 다음 재배치까지window_size단계 이내일 때만 녹화를 시작한다📎 vllm/distributed/eplb/eplb_state.py:689-709. 주석 설명: 각 재배치 주기 전step_interval - window_size단계의 데이터는 슬라이딩 윈도우에 덮어써지므로 녹화해도 헛수고, GPU 계산 낭비📎 vllm/distributed/eplb/eplb_state.py:1196-1199。should_record_tensor은 모든 레이어가 공유하는 동일한 스칼라 텐서, 한 번의fill_로 모든 레이어 갱신📎 vllm/distributed/eplb/eplb_state.py:272-278。

탄력적 EP의 용량 예약:enable_elastic_ep시,physical_expert_capacity은elastic_ep_max_dp_size기준으로 예약, 매핑 테이블은 -1로 여분 슬롯 채움📎 vllm/distributed/eplb/eplb_state.py:375-386. 이렇게 하면 확장 시 메모리 재할당 없이 -1 슬롯에 실제 전문가만 채우면 된다.reconfigure_physical_expert_slots은 확장/축소 시 뷰를 새로고침하는 역할📎 vllm/distributed/eplb/eplb_state.py:1135-1160。

_commit_eplb_maps의 pin memory 처리:PIN_MEMORY이 켜져 있고 소스가 CPU일 때, 먼저 pinned 메모리로 복사한 후non_blocking=True비동기로 GPU에 복사📎 vllm/distributed/eplb/eplb_state.py:1392-1400. 이는 H2D 복사가 메인 스레드를 막는 것을 방지——매핑 테이블은 매 레이어 매 라운드마다 갱신되므로 동기 복사는 병목이 된다.

설계 고민

세 코드는 하나의 설계 철학을 공유한다:능력 탐지로 결정적 성능 저하를 얻는다。GroupCoordinator에서world_size == 1일 때 모든 집합 통신을 직접 bypass한다📎 vllm/distributed/parallel_state.py:736-738;CustomAllreduce어느 한 조건이라도 충족되지 않으면 을 반환한다None호출자가 NCCL로 폴백하도록 한다📎 vllm/distributed/device_communicators/custom_all_reduce.py:532-533; EPLB는 개선이 5% 미만일 때 재배치를 건너뛴다📎 vllm/distributed/eplb/eplb_state.py:916. 이러한 "빠른 실패 + 우아한 성능 저하" 패턴은 동일한 코드가 단일 카드에서 다중 머신 MNNVL까지 전 스펙트럼 하드웨어에서 실행될 수 있게 하며, 각 구성마다 분기를 작성할 필요가 없다.

또 다른 공통점은제어 흐름 일관성이 성능보다 우선한다。_group_can_attempt_mnnvlCPU all-reduce로 모든 rank가 동일한 분기를 타도록 강제한다📎 vllm/distributed/device_communicators/custom_all_reduce.py:59-73,_all_ranks_result_ready마찬가지로📎 vllm/distributed/eplb/eplb_state.py:1024-1043. 분산 시스템에서는 "일부 rank는 빠른 경로, 일부는 느린 경로"가 "모든 rank가 느린 경로"보다 훨씬 위험하다—전자는 교착 상태에 빠지고, 후자는 단지 느릴 뿐이다.

이 장 요약

  • GroupCoordinator1차원 rank 시퀀스를 으로 reshape한다ExternalDP x DP x PP x PCP x TP그리드로, 각 차원을 따라 TP/PP/DP/EP/EPLB 프로세스 그룹을 분할한다; 각 그룹은 동시에 CPU(gloo)와 device(NCCL) 두 개의 PG를 유지한다.
  • CustomAllreduce능력 탐지(동일 머신, NVLink 전 interconnect, 텐서 크기, dtype, 16바이트 정렬)를 통해 all-reduce를 인계할지 결정하며, 다중 머신 시나리오에서는 MNNVL 또는 NCCL로 성능 저하한다.
  • EPLB는 세 개의 매핑 테이블로 논리/물리 전문가 관계를 설명하고, 슬라이딩 윈도우로 부하를 통계하며, 전략으로 새 매핑을 계산하고, 통신기로 가중치를 운반하며, 동기와 비동기 두 모드를 지원한다.
  • 세 가지의 공통 설계 원칙: 능력 탐지 + 결정적 성능 저하 + 제어 흐름 일관성 우선.

이 장 사고와 자가 테스트

Q1: GroupCoordinator.destroy()device communicator를 먼저 파괴한 후 process group을 파괴한다📎 vllm/distributed/parallel_state.py:1380-1393. 만약 순서를 반대로 하여 PG를 먼저 파괴하고 communicator를 나중에 파괴하면, 어떤 시나리오에서 충돌이 발생하는가?

참고 해석: 주석은 device communicator가 이러한 PG에 의존하는 집합 통신 작업 공간, 예를 들어 FlashInfer PCIe IPC barrier를 보유할 수 있음을 명확히 지적한다📎 vllm/distributed/parallel_state.py:1377-1377. 만약 PG를 먼저 파괴하면, communicator의destroy()내부에서 이 PG를 사용하여 barrier나 정리 통신을 수행해야 할 경우, 이미 파괴된 ProcessGroup에 접근하여 use-after-free 또는 NCCL 내부 assertion 실패를 유발한다. 올바른 순서는 "의존자가 먼저 죽는다": communicator가 PG에 의존하므로 communicator를 먼저 파괴한다.

Q2: should_custom_ar을 요구한다inp_size % 16 == 0 📎 vllm/distributed/device_communicators/custom_all_reduce.py:493-508. 만약 이 검사를 제거하면, 15바이트 bf16 텐서(예를 들어 7.5개 요소, 실제로는 불가능하지만 8개 요소 = 16바이트 경계 상황이라고 가정)는 어떻게 되는가? 왜 사용자 정의 kernel에 이 정렬이 필요한가?

참고 해석: 사용자 정의 all-reduce kernel 내부에서 벡터화 로드(예: 128-bit load)를 사용하며, 주소와 크기가 16바이트로 정렬되어야 과 같은 와이드 로드 명령을 사용할 수 있다. 정렬되지 않으면 kernel이 범위를 벗어나 읽거나 misaligned address 예외를 유발한다. 더 은밀한 것은,float4사전 등록 버퍼가 으로 할당되며, 입력 크기가 16의 배수가 아니면 버퍼에 복사한 후 꼬리에 잔여 데이터가 함께 reduce되어 조용한 오류를 발생시킨다. 따라서 이 검사는 정확성 보호이자 성능 전제 조건이다.buffer_ptrsQ3: EPLB 비동기 모드에서,max_size플래그는 GIL 동기화에 의존하며

, 주석은 모든 rank가 일관성을 유지해야 한다고 경고한다. 그렇지 않으면 all-reduce가 교착 상태에 빠진다rebalanced. 만약 어떤 rank가 네트워크 지터로 인해 async worker가 미리 을 False로 설정했는데, 다른 rank는 여전히 True라면,📎 vllm/distributed/eplb/eplb_state.py:194-203무슨 일이 발생하는가?📎 vllm/distributed/eplb/eplb_state.py:664-665참고 해석rebalanced에 대해 all-reduce 합계를 수행한 후, 그룹 크기와 같은지 판단한다_all_ranks_result_ready. 만약 어떤 rank의 이 미리 False로 바뀌면, 그 rank의 은 이미 소비되었을 수 있고,

이 0이 되어 합계 결과가 그룹 크기보다 작아지며, 다른 rank는 계속 대기한다. 더 나쁜 것은, 이 rank가 이미 루프를 종료했다면, 더 이상 후속 all-reduce에 참여하지 않아 다른 rank의 all-reduce가 영구적으로 차단된다—이것이 주석에서 말한 "hang at collective communication calls"이다. 방어 수단은 device 그룹 대신 CPU 그룹을 사용하고, 재배치 전에 모든 pending result를 명시적으로 배출하는 것이다.:_all_ranks_result_ready 对 has_result 做 all-reduce 求和,然后判断是否等于组大小 📎 vllm/distributed/eplb/eplb_state.py:1030-1032。如果某个 rank 的 rebalanced 提前变 False,它的 pending_result 可能已被消费,has_result 为 0,导致求和结果小于组大小,其他 rank 会一直等待。更糟的是,如果这个 rank 已经退出 while ms.rebalanced 循环,它不会再参与后续的 all-reduce,其他 rank 的 all-reduce 会永久阻塞——这就是注释所说的"hang at collective communication calls"。防护手段是 _all_ranks_result_ready 用 CPU 组而非 device 组,且 drain_async 在重排前显式排空所有 pending result 📎 vllm/distributed/eplb/eplb_state.py:985-1022。

여기까지 우리는 카드 간 통신의 그룹 생성, 분할, 부하 재분배 메커니즘을 정리했다. 그러나 분산 추론의 통신 과제는 단일 인스턴스 내부에 그치지 않는다——prefill과 decode가 서로 다른 인스턴스로 분리되면 KV Cache는 노드를 넘어 전송되어야 한다. 다음 장에서 우리는 "카드 간 통신"을 떠나 "인스턴스 간 통신"으로 들어간다: KV Cache가 분리형 배포의 prefill과 decode 인스턴스 사이에서 어떻게 전송되는지, KV Connector 추상화가 NIXL, Mooncake 등 전송 백엔드를 어떻게 통합하는지.

CHAPTER 09

제9장: KV Cache 전송과 분리형 배포(PD 분리)

소속 프로젝트: vllm-project/vllm · 전체 진행률: 제9 / 14장 · 검증 상태: FACT 행 번호 실제 앵커링

이전 장에서 우리는 시점을 단일 추론 인스턴스 내부에 고정했다: TP/PP/DP/EP 프로세스 그룹이 어떻게 구성되는지, 텐서가 카드 간에 어떻게 분할되는지, EPLB가 MoE 계층에서 전문가 재분배를 어떻게 하는지. 그러나 이 모든 메커니즘은 동일한 전제 위에 세워져 있다——prefill과 decode가 같은 인스턴스에서 실행되고, KV Cache가 처음부터 끝까지 로컬 VRAM에 머문다. 분리형 배포(Prefill-Decode Disaggregation, 줄여서 PD 분리)는 이 전제를 깨뜨린다. 그것은 prefill과 decode를 두 개의 독립적인 vLLM 인스턴스로 분리한다: prefill 인스턴스는 prompt의 전방 계산만 수행하고, KV Cache를 생성한 후 decode 인스턴스에 넘긴다; decode 인스턴스는 이 KV Cache를 받아 자동 회귀 생성을 계속한다. 이렇게 하면 자원을 단계 특성에 따라 독립적으로 구성할 수 있다——prefill은 계산 집약적이므로 큰 TP, 큰 batch에 적합하다; decode는 메모리 접근 집약적이므로 작은 batch, 낮은 지연 스케줄링에 적합하다. 둘은 더 이상 서로를 방해하지 않는다. 대가는: KV Cache가 반드시 인스턴스 간 전송되어야 한다는 것이다. 이것이 이 장의 주인공——KV Connector이다. vllm/distributed/kv_transfer/kv_connector/v1/base.py의 파일 헤더 주석은 이미 전체 추상화의 핵심 원시 요소를 나열했다: Scheduler 측은 메타데이터 바인딩, 원격 캐시 히트 조회, 비동기 block 해제 여부 결정을 담당한다; Worker 측은 실제 KV 로드와 저장을 담당한다. 이 인터페이스 세트의 설계 목표는 상위 스케줄링 로직과 하위 전송 백엔드(NIXL, Mooncake, MoRIIO)를 완전히 분리하는 것이다. 공학적 관점에서 PD 분리의 가장 큰 위험은 전송이 느린 것이 아니라 상태 불일치이다: prefill 인스턴스는 KV가 이미 전송되었다고 생각하는데 decode 인스턴스는 받지 못했거나; 또는 decode 인스턴스가 block을 조기에 해제했는데 prefill이 아직 거기에 쓰고 있는 경우이다. 이 장에서 밝히려는 것은 바로 이 커넥터 체계가 핸드셰이크 프로토콜, 리스(lease), 하트비트, 실패 복구 메커니즘으로 이러한 경계를 어떻게 감당하는가이다.

一、KVConnectorBase_V1: 이중 역할 추상화와 메타데이터 계약

직관적 모델

KV Connector는 두 지점 간의 택배 시스템과 같다. Prefill 지점은 반제품(KV Cache)을 계산해 포장하여 Decode 지점에 보내 계속 가공한다. 그러나 택배 시스템은 "발송"이라는 하나의 동작만으로는 안 된다——무엇을 보내는지, 어디로 보내는지 설명하는 운송장(metadata)이 필요하다; 상대방이 받았는지 확인하는 서명 메커니즘이 필요하다; 또한 패키지가 영원히 길에 막혀 선반을 차지하는 것을 방지하는 타임아웃 규칙도 필요하다.

만약 이 추상화가 없다면, 각 전송 백엔드(NIXL, Mooncake)가 스케줄링 로직을 스스로 구현해야 하고, vLLM의 Scheduler는 각 백엔드마다适配 코드를 작성해야 한다. KVConnectorBase_V1의 가치는 이 계약을 고정하는 것이다.

이중 역할: Scheduler 측과 Worker 측

📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:137-142커넥터의 두 가지 역할을 정의한다:

python
class KVConnectorRole(enum.Enum):
    # Connector running in the scheduler process
    SCHEDULER = 0
    # Connector running in the worker process
    WORKER = 1

이 구분은 임의적이지 않다. Scheduler 프로세스는 전역 스케줄링 결정을 담당한다——어떤 요청이 전송되어야 하는지, 언제 block을 해제할 수 있는지; Worker 프로세스는 실제 데이터 운반을 담당한다. 둘은KVConnectorMetadata을 통해 통신한다.

📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:153-158Scheduler에서 Worker 방향의 메타데이터 기본 클래스를 정의한다:

python
class KVConnectorMetadata(ABC):  # noqa: B024
    """Abstract Metadata used to communicate
    Scheduler KVConnector -> Worker KVConnector.
    """
    pass

반대 방향인 Worker에서 Scheduler 방향은,📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:161-176이 정의한다KVConnectorWorkerMetadata, 이는 구현을 요구한다aggregate메서드——하나의 engine step에서 여러 worker가 각각 메타데이터를 반환할 수 있으므로 집계 후 Scheduler에 전달해야 하기 때문이다.

핵심 데이터 구조: KVConnectorTransferResults

📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:87-96전송 결과의 스냅샷 구조를 정의한다:

python
@dataclass
class KVConnectorTransferResults:
    finished_sending: set[str] = field(default_factory=set)
    finished_recving: set[str] = field(default_factory=set)
    failed_recving: set[str] = field(default_factory=set)

주석의 핵심 설계에 주목:실패한 수신도finished_recving에 나타난다. 이는 Scheduler가 요청을 "전송 대기" 상태에서 해제할 수 있도록 하기 위한 것입니다——전송이 실패하더라도 요청이 영원히 멈춰 있어서는 안 됩니다. 실패 정보는failed_recving를 통해 별도로 전달되며, Scheduler는 이를 기반으로 재시도할지 아니면 성능을 낮출지 결정합니다.

라이프사이클 훅: 요청부터 해제까지

전체 커넥터의 라이프사이클은 몇 가지 핵심 훅을 중심으로 전개됩니다. Scheduler 측:

  • get_num_new_matched_tokens 📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:485-518: 원격 캐시에서 몇 개의 토큰을 히트할 수 있는지 조회합니다. 주석에서는 "실제로 사용 가능한 최대 프리픽스만 고려해야 한다"고 특히 강조합니다. 일부 토큰이 연결 문제나 축출로 인해 가져올 수 없다면 포함해서는 안 됩니다.
  • update_state_after_alloc 📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:520-544: block 할당 후 상태를 업데이트합니다. 주석에 쉽게 빠질 수 있는 함정이 하나 있습니다——로드 여부를 판단하려면num_external_tokens을 봐야 하며,blocks이 비어 있는지 여부를 봐서는 안 됩니다. MultiConnector의 비선택 서브 커넥터도 실제 block을 수신하기 때문입니다.
  • request_finished 📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:579-598: 요청 완료 시 호출되며,True을 반환하면 커넥터가 block의 비동기 해제 책임을 인계받았음을 나타냅니다.

Worker 측:

  • start_load_kv / wait_for_layer_load: 레이어별로 로드하며, 파이프라인을 지원합니다.
  • save_kv_layer / wait_for_save: 레이어별로 저장합니다.
  • get_transfer_results 📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:396-397: 비동기 전송의 완료 상황을 반환합니다.

📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:192-201간과하기 쉽지만 매우 중요한 설계가 하나 더 있습니다——requires_kv_delivery속성:

python
@property
def requires_kv_delivery(self) -> bool:
    """Whether this connector hands off KV that must be reliably delivered.
    ...
    """
    return self._kv_transfer_config.is_kv_producer

주석에서 동기를 설명합니다: 요청이 KV 핸드오버가 아직 완료되지 않은 상태에서 선점되면, 완료시켜 이미 선점 해제된 block을 핸드오버하도록 하는 대신 재계산해야 합니다. producer 역할만 신뢰할 수 있는 전달이 필요하며, best-effort 캐시가 손실되면 단지 미래의 cache miss일 뿐입니다.

핸드셰이크 메타데이터

📎 vllm/distributed/kv_transfer/kv_connector/v1/base.py:145-150은 핸드셰이크 메타데이터의 기본 클래스를 정의합니다:

python
class KVConnectorHandshakeMetadata(ABC):  # noqa: B024
    """Metadata used for out of band connector handshake between
    P/D workers. This needs to serializable.
    """
    pass

"out of band"는 핸드셰이크가 정상적인 요청 경로를 거치지 않고 P/D worker 간에 직접 통신한다는 의미입니다. 이는 NIXL의 ZMQ 핸드셰이크 프로토콜을 위한 복선이었습니다.

---

2. NIXL 커넥터: 핸드셰이크, 등록 및 디스크립터 구축

직관적 모델

NIXL(NVIDIA Inference Xfer Library)은 NVIDIA가 제공하는 저수준 전송 라이브러리로, UCX, GDS 등 다양한 백엔드를 지원합니다. NixlBaseConnectorWorker의 역할은 마치 택배 회사의 분류 센터와 같습니다——먼저 상대 분류 센터와 전용선을 구축하고(핸드셰이크), 자신의 선반 배치를 등록한 후(KV Cache 메모리 영역 등록), 그제서야 주소에 따라 효율적으로 물건을 꺼내고 발송할 수 있습니다.

만약 이런 메커니즘이 없다면, 매 전송마다 주소를 재협상하고 연결을 다시 구축해야 하므로 지연이 감당할 수 없을 정도로 높아질 것입니다.

메모리 레이아웃: Region과 Descriptor

NIXL의 핵심 개념은region(메모리 영역)과descriptor(디스크립터)입니다. 각 KV Cache 레이어는 NIXL에 하나 이상의 region으로 등록되며, 각 region은 베이스 주소, 블록 길이, 블록 스트라이드를 가집니다.

📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:740-751은 region 관련 핵심 필드를 나열합니다:

python
# Number of NIXL regions. Currently one region per cache
# (so 1 per layer for MLA, otherwise 2 per layer)
self.num_regions = 0
self.region_mem_types: list[str] = []
self.region_group_ids: list[int] = []
self._uses_region_group_mapping = False
self.region_names: list[str] = []
self.region_num_blocks: list[int] = []
self._mixed_mem_types = False

📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:897-900은 블록 스트라이드의 출처를 추가로 설명합니다:

python
# Per-region block stride in bytes. Taken from the registered tensor's
# stride(0) so it stays correct under layouts that interleave layers
# within a block (BLHNC/BHLNC), where stride > block_len.
self.block_stride_per_layer = list[int]()

여기서 핵심 통찰은:block_stride는 block_len과 같지 않습니다. BLHNC/BHLNC 같은 레이어 간 인터리브 레이아웃에서는 하나의 block의 실제 범위가 유효 데이터 길이보다 클 수 있습니다. 만약 block_len을 직접 스트라이드로 사용하면 주소를 잘못 읽게 됩니다.

핸드셰이크 프로토콜: ZMQ + 호환성 해시

핸드셰이크는 NIXL 커넥터에서 가장 복잡한 부분입니다.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:974-1128의_nixl_handshake메서드가 이 과정을 완전히 보여줍니다.

첫 번째 단계는 CUDA 디바이스 컨텍스트를 설정하는 것입니다.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:988-998의 주석에서 이유를 설명합니다:

python
# the first time we connect to a remote agent.
# be careful, the handshake happens in a background thread.
# it does not have an active cuda context until any cuda runtime
# call is made. when UCX fails to find a valid cuda context, it will
# disable any cuda ipc communication, essentially disabling any NVLink
# communication.
if not self.use_host_buffer:
    current_platform.set_device(self.device_id)

이것은 매우 은밀한 함정입니다: 핸드셰이크가 백그라운드 스레드에서 실행되는데, 디바이스를 명시적으로 설정하지 않으면 UCX가 유효한 CUDA 컨텍스트를 찾지 못하고 NVLink 통신을 조용히 비활성화하여 느린 경로로 퇴화합니다.

두 번째 단계는 ZMQ를 통해 메타데이터 쿼리를 전송하는 것입니다.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1029-1036:

python
msg = msgspec.msgpack.encode(
    (GET_META_MSG, remote_pp_rank, remote_rank)
)
# Set receive timeout to 5 seconds to avoid hanging on dead server
sock.setsockopt(zmq.RCVTIMEO, 5000)  # milliseconds
start_time = time.perf_counter()
sock.send(msg)
reply_parts = sock.recv_multipart()

5초 타임아웃은 상대방이 죽은 후 무한 대기하는 것을 방지하기 위한 것입니다. 동시에 코드는 RTT로 클럭 오프셋📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1042-1045을 추정하며, 최소 RTT 샘플을 유지합니다——높은 RTT는 단지 노이즈일 뿐이며 중간점 추정을 왜곡시키기 때문입니다.

세 번째 단계는 호환성 검증입니다.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1063-1080:

python
assert self.compat_hash is not None
if (
    self.enforce_compat_hash
    and handshake_payload.compatibility_hash != self.compat_hash
):
    raise RuntimeError(
        f"NIXL compatibility hash mismatch. "
        ...
    )

호환성 해시는📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1372-1376에서 계산됩니다:

python
self.compat_hash = compute_nixl_compatibility_hash(
    self.vllm_config,
    self.backend_name,
    transfer_mode=self._TRANSFER_MODE,
)

transfer_mode도 해시에 참여한다는 점에 주목하세요——📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:163-166의 주석에서 설명합니다: push(WRITE) 커넥터와 pull(READ) 커넥터는 절대 핸드셰이크에 성공해서는 안 됩니다.

비동기 핸드셰이크 스케줄링

핸드셰이크는 비동기이며, 스레드 풀을 통해 실행됩니다.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:824-835:

python
self._handshake_initiation_executor = ThreadPoolExecutor(
    # NIXL is not guaranteed to be thread-safe, limit 1 worker.
    max_workers=1,
    thread_name_prefix="vllm-nixl-handshake-initiator",
)
self._ready_requests = queue.Queue[tuple[ReqId, ReqMeta]]()
self._handshake_futures: dict[
    EngineId, Future[tuple[dict[tuple[int, int], str], float]]
] = {}
# Protects _handshake_futures and _remote_agents.
self._handshake_lock = threading.RLock()

max_workers=1은 NIXL이 스레드 안전성을 보장하지 않기 때문입니다._handshake_lock은_handshake_futures과_remote_agents두 딕셔너리를 보호합니다.

_ensure_handshake 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:1257-1317은 멱등적인 핸드셰이크 개시를 구현합니다: 이미 핸드셰이크에 성공했다면 직접 None을 반환하고, 핸드셰이크 진행 중이라면 기존 Future를 반환하며, 그렇지 않으면 새 작업을 제출하고 콜백을 등록합니다.

디스크립터 구축: block ID에서 NIXL descriptor까지

핸드셰이크가 완료되면 각 요청에 대해 디스크립터를 구축해야 합니다.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:172-310의_compute_desc_ids가 핵심입니다.

순수 attention 모델(SSM 없음)의 경우 빠른 경로📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:226-262를 사용합니다. 주석에서 HMA 시나리오에서의 처리를 설명합니다:

python
# NOTE (NickLucche) With HMA, every kv group has the same number of layers
# and layers from different groups share the same kv tensor.
# eg block_ids=[[1, 2], [3]]->blocks [1, 2] need to be
# read across all regions, same for [3], but group0-group1 blocks will
# always differ (different areas). Therefore we can just flatten the
# block_ids and compute the descs ids for all groups at once.

하이브리드 SSM 모델의 경우 디스크립터 레이아웃이 더 복잡합니다📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:285-304:

python
elif _is_ssm_spec(spec_type):
    # NOTE (NickLucche) SSM and Attention block regions can
    # be exchanged arbitrarily by manager.  Therefore, descs
    # are laid out as:
    #   [descs_fa (all regions) | descs_ssm (all regions)].
    # num_fa_descs offset must be computed per-engine since
    # P and D can have different num_blocks (and thus
    # different FA desc counts).

전송 토폴로지와 TP 매핑

이기종 TP는 NIXL 커넥터에서 가장 복잡한 시나리오입니다.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:2130-2178의add_remote_agent문서에서 다양한 상황을 자세히 설명합니다:

D.world_size > P.world_size일 때, 여러 D worker가 동일한 P worker로부터 서로 다른 KV head 샤드를 읽습니다. 문서에서는 구체적인 예시를 제시합니다: D TP=4, P TP=2, tp_ratio=2. D-Worker0는 P-Worker0의 전반부 KV head를 읽고, D-Worker1은 후반부를 읽습니다.

MLA 모델의 경우, KV Cache는 TP worker 간에 복제되므로 rank_offset은 항상 0입니다.

임대와 하트비트: block이 조기에 해제되는 것을 방지

이것은 NIXL 커넥터의 가장 정교한 설계 중 하나입니다. Prefill 인스턴스는 KV를 전송한 후 block을 즉시 해제할 수 없습니다——decode 인스턴스가 아직 읽고 있을 수 있기 때문입니다. 하지만 영원히 해제하지 않으면 VRAM이 누출됩니다.

해결책은 임대(lease)입니다.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:528-528:

python
kv_lease_duration: int = vllm_config.kv_transfer_config.get_from_extra_config(
    "kv_lease_duration", 30
)
# NOTE (NickLucche): For now we use a hardcoded value for a simpler interface.
self._lease_extension = kv_lease_duration * 2 // 3

기본 임대는 30초이며, 매 하트비트마다 20초(2/3)씩 연장됩니다.

하트비트 처리는📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3014-3034:

python
def _handle_heartbeat(self, payload: str) -> None:
    new_expiry = time.perf_counter() + self._lease_extension
    for req_id in payload.split(","):
        if req_id in self._reqs_to_send:
            old = self._reqs_to_send[req_id]
            self._reqs_to_send[req_id] = max(old, new_expiry)

주의max(old, new_expiry)——하트비트는 임대를 연장할 수만 있고, 단축할 수는 없습니다.

임대 만료 후 회수는📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:2986-3012:

python
def _reap_expired_send_leases(self, done_sending: set[str]) -> None:
    """Reclaim expired send-side KV leases into ``done_sending``.

    ``_reqs_to_send`` is not ordered by expiry: heartbeats update the
    deadline in place, and mixed TTLs share the map, so a live head
    entry can sit in front of already-expired ones. Scan every entry
    rather than stopping at the first still-live request.
    """

주석은 흔히 범하기 쉬운 실수를 지적합니다: 첫 번째 만료되지 않은 요청을 만나면 스캔을 중단해서는 안 됩니다. 하트비트가 제자리에서 만료 시간을 갱신하므로 map이 만료 시간 순으로 정렬되지 않기 때문입니다.

전송 상태 머신과 실패 복구

전송의 수명 주기는_pop_done_transfers 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3036-3086로 관리됩니다:

python
for handle in handles:
    try:
        xfer_state = self.nixl_wrapper.check_xfer_state(handle)
        if xfer_state == "DONE":
            res = self.nixl_wrapper.get_xfer_telemetry(handle)
            self.xfer_stats.record_transfer(res)
            self.nixl_wrapper.release_xfer_handle(handle)
        elif xfer_state == "PROC":
            in_progress.append(handle)
        else:
            self._log_failure(
                failure_type="transfer_failed",
                req_id=req_id,
                xfer_state=xfer_state,
            )

NIXL 전송에는 세 가지 상태가 있습니다:DONE(완료),PROC(진행 중), 기타(실패).

실패 처리는📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3103-3127:

python
def _handle_failed_transfer(
    self,
    req_id: str,
    handle: int | None,
    failed_req_ids: set[str] | None = None,
    record_failed_transfer: bool = True,
) -> bool:
    if record_failed_transfer:
        self.xfer_stats.record_failed_transfer()
    if failed_req_ids is not None:
        failed_req_ids.add(req_id)
    return handle is None or self._try_release_xfer_handle(req_id, handle)

_try_release_xfer_handle 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3088-3101의 주석이 매우 중요합니다:

python
except Exception as e:
    # A status error does not guarantee that the backend stopped DMA.
    self._log_failure(
        failure_type="transfer_release_failed",
        msg="Retaining handle and blocks until release succeeds",
        ...
    )
    return False

상태 오류는 백엔드가 DMA를 중지했음을 보장하지 않습니다. 해제가 실패하면, 해제가 성공할 때까지 handle과 block을 유지해야 합니다. 이것은 전형적인 "누출되더라도 잘못 사용하지 말라"는 설계입니다.

실패한 요청의 block 처리

수신이 실패하면,📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:2876-2891은 처리 로직을 보여줍니다:

python
for req_id in done_recving:
    meta = self._recving_metadata.pop(req_id, None)
    assert meta is not None, f"{req_id} not found in recving_metadata list"

    # Skip KV sync and post-processing for failed requests
    if req_id in failed_recv_reqs:
        self._pending_recv_notifs.pop(req_id, None)
        # TODO (NickLucche) handle failed transfer for HMA.
        if not self._is_hma_required:
            self._invalid_block_ids.put(set(meta.local_block_ids[0]))
        logger.warning(
            "Skipping KV post-processing for failed request %s",
            req_id,
        )
        continue

실패한 block ID는_invalid_block_ids큐에 들어가고, Scheduler는get_block_ids_with_load_errors 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3491-3504를 통해 꺼내어 재시도 여부를 결정합니다.

원격 엔진의 TTL 축출

장기 실행 인스턴스는 계속해서 새로운 원격 엔진을 만나게 되며, 정리하지 않으면 메모리가 무한히 증가합니다.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3506-3532의_evict_stale_engines은 TTL 축출을 구현합니다:

python
def _evict_stale_engines(self) -> None:
    """Scan for and evict remote engines that have exceeded their TTL.

    Called from the main thread in when a new remote engine appears.
    We can only go OOM as we discover and register a new remote, therefore we make
    sure we clean up stale engine data structures before then.
    """
    if self._engine_ttl <= 0:
        return

    now = time.perf_counter()
    busy = self._engines_with_inflight_transfers()
    for eid, last_active in list(self._engine_last_active.items()):
        if now - last_active > self._engine_ttl and eid not in busy:
            self._cleanup_remote_engine(eid)

핵심 제약은busy집합입니다——진행 중인 전송이 있는 엔진은 축출될 수 없습니다.📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3534-3546의 주석이 그 이유를 설명합니다:

python
"""Remote engines a transfer is still reading from.

The timestamp is stamped when a read is issued and not refreshed while
it runs, so a transfer that outlives the TTL leaves its engine looking
idle. A peer that has lost its NIC holds one indefinitely.
"""

상대방의 NIC가 고장 나면 전송이 영원히 걸려 있을 수 있고, 타임스탬프가 갱신되지 않아 엔진이 유휴 상태로 보입니다.busy집합이 이러한 상황을 명시적으로 보호합니다.

핸드셰이크와 전송의 타이밍

아래 시퀀스 다이어그램은 요청부터 전송 완료까지의 핵심 상호작용을 보여줍니다:

mermaid
sequenceDiagram
    participant Sched as Scheduler
    participant Worker as NixlWorker
    participant BgThread as 握手后台线程
    participant Remote as 远程 NIXL Agent

    Sched->>Worker: build_connector_meta()
    Worker->>Worker: _ensure_handshake(engine_id)
    alt 已握手
        Worker->>Worker: 直接返回 None
    else 握手中
        Worker->>BgThread: 返回已有 Future
    else 新握手
        Worker->>BgThread: submit(_nixl_handshake)
        BgThread->>Remote: ZMQ GET_META_MSG
        Remote-->>BgThread: NixlHandshakePayload
        BgThread->>BgThread: 校验 compat_hash
        BgThread->>Remote: add_remote_agent()
        BgThread-->>Worker: done_callback 注册 _remote_agents
    end
    Worker->>Remote: prep_xfer_dlist + make_xfer_req
    Worker->>Worker: _recving_transfers[req_id] = handles
    Sched->>Worker: get_transfer_results()
    Worker->>Worker: _pop_done_transfers()
    alt xfer_state == DONE
        Worker->>Remote: release_xfer_handle
        Worker-->>Sched: finished_recving
    else xfer_state == PROC
        Worker->>Worker: 保留 handle 等待下一轮
    else 失败
        Worker->>Worker: _handle_failed_transfer
        Worker-->>Sched: failed_recving + invalid_block_ids
    end

---

3. 설계 사고: 왜 이렇게 설계했는가

왜 핸드셰이크가 비동기여야 하는가?

핸드셰이크는 네트워크 왕복을 포함하며 수십 밀리초가 걸릴 수 있습니다. 동기적으로 실행하면 Scheduler의 메인 루프를 차단하여 모든 요청의 스케줄링에 영향을 미칩니다. 비동기 핸드셰이크를 통해 Scheduler는 먼저 다른 요청을 처리할 수 있고, 핸드셰이크 완료 후 콜백으로 통지받습니다.

하지만 비동기는 복잡성도 가져옵니다:_handshake_futures딕셔너리는 잠금 보호가 필요하고, 콜백에서 성공과 실패 두 가지 경우를 처리해야 하며, 중복 핸드셰이크도 방지해야 합니다.

왜 참조 카운팅 대신 임대를 사용하는가?

참조 카운팅은 decode 인스턴스가 prefill에게 "다 읽었습니다"라고 명시적으로 통지해야 합니다. 하지만 decode 인스턴스가 충돌하면 통지가 영원히 도착하지 않아 prefill의 block이 영원히 누출됩니다.

임대는 더 강건한 방식입니다: decode가 충돌하더라도 임대 만료 후 prefill이 자동으로 회수합니다. 하트비트 메커니즘은 정상 상황에서의 임대 갱신을 보장합니다.

왜 실패 시 handle을 유지하는가?

📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3088-3101의 주석이 명확히 설명합니다: 상태 오류는 DMA 중지를 보장하지 않습니다. 이때 handle을 해제하면 DMA가 이미 해제된 메모리에 계속 데이터를 쓸 수 있어 데이터 손상이나 충돌을 초래합니다. 일시적으로 누출되더라도 이 위험을 감수할 수 없습니다.

왜 TTL 축출 시 busy를 확인하는가?

📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3534-3546의 주석은 은밀한 버그 시나리오를 드러냅니다: 타임스탬프는 읽기 시작 시 찍히고, 읽기 중에는 갱신되지 않습니다. 전송 시간이 TTL을 초과하면 엔진이 유휴 상태로 보이지만 실제로는 여전히 읽히고 있습니다. 이때 축출하면 진행 중인 전송이 실패합니다.

프로덕션 환경 함정 포인트

1. CUDA 컨텍스트 문제: 핸드셰이크가 백그라운드 스레드에서 실행되므로 반드시 명시적으로set_device해야 합니다. 그렇지 않으면 UCX가 NVLink를 조용히 비활성화합니다.

2. 호환성 해시 불일치: P/D 인스턴스의 vLLM 버전, 모델, dtype, KV layout, attention backend가 완전히 일치해야 합니다. 불일치 시 핸드셰이크가 실패하며, 오류 메시지가 검사를 비활성화하는 방법을 안내합니다(하지만 권장하지 않음).

3. 임대 만료: decode 인스턴스의 부하가 매우 높으면 하트비트가 지연되어 임대가 만료될 수 있습니다. 로그에 "Releasing expired KV blocks" 경고가 나타납니다.kv_lease_duration。

4. 를 늘릴 수 있습니다. TP 불일치: 이기종 TP는 block-contiguous 레이아웃(예: LBHNC)이 필요합니다. 비연속 레이아웃을 사용하면 이기종 TP가 실패합니다.

5. NIXL UAR 고갈:📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:631-636의 주석 경고: 각 UCX 스레드는 DevX를 통해 UAR(doorbell pages)을 할당하며, 과도한 NIXL UAR 사용은 NIC UAR 공간을 고갈시켜 NVSHMEM(DeepEP 커널에서 사용)이 RDMA 초기화 시 실패하게 만든다.

---

이 장 요약

이 장에서는 KV Connector 체계의 핵심 메커니즘을 깊이 다루었다:

1. KVConnectorBase_V1Scheduler 측과 Worker 측의 이중 역할 추상화를 정의하고,KVConnectorMetadata와KVConnectorTransferResults를 통해 메타데이터 교환과 전송 결과 피드백을 구현한다.

2. NIXL 커넥터는 가장 성숙한 구현으로, ZMQ 핸드셰이크 프로토콜을 통해 P/D 인스턴스 간 연결을 설정하고, 호환성 해시로 구성 불일치를 방지하며, 비동기 스레드 풀로 메인 루프 차단을 피한다.

3. 임대와 하트비트메커니즘은 block 해제의 타이밍 문제를 해결한다: prefill은 KV를 전송한 후 즉시 해제하지 않고, decode의 하트비트 갱신 또는 임대 만료를 기다린다.

4. 실패 복구는 "누수가 발생하더라도 잘못 사용하지 말라"는 원칙을 따른다: 해제 실패 시 handle을 유지하고, 실패한 block ID를 Scheduler에 보고하여 재시도를 결정한다.

5. TTL 축출은 장기 실행 시 원격 엔진 상태가 무한히 증가하는 것을 방지하지만, 진행 중인 전송이 있는 엔진은 반드시 보호해야 한다.

다음 장에서는 오버헤드를 제거하는 또 다른 방향인 컴파일 가속과 CUDA Graph로 전환한다. PD 분리가 리소스 활용률 문제를 해결한 후, 단일 전방향의 시작 오버헤드가 새로운 병목이 된다—CUDA Graph로 수백에서 수천 개의 커널 시작을 한 번의 재생으로 압축하는 방법.

이 장 생각해보기와 자가 점검

Q1: 만약_try_release_xfer_handle의 예외 처리를 제거하고 직접release_xfer_handle를 호출하면, 어떤 시나리오에서 데이터 손상이 발생하는가? 왜인가?

참고 해석:_try_release_xfer_handle 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3088-3101의 주석은 명확히 지적한다: "A status error does not guarantee that the backend stopped DMA." 예외 처리를 제거하면,release_xfer_handle가 예외를 던질 때 호출자는 해제가 성공했다고 생각하고 block 해제를 계속한다. 하지만 실제로 NIXL 백엔드의 DMA가 아직 진행 중일 수 있으며, 이 메모리에 데이터를 쓰고 있을 수 있다. block이 다른 요청에 재할당되면 DMA 쓰기가 새 요청의 KV Cache를 오염시켜 출력이 깨지거나 NaN이 발생한다. 더 나쁜 경우, block이 VRAM 풀로 반환되어 다른 텐서에 재사용되면 DMA가 잘못된 주소에 쓰기를 시도하여 크래시가 발생할 수 있다. 올바른 방법은 handle과 block을 유지하고 다음 라운드_pop_done_transfers에서 해제를 재시도하는 것이다.

Q2: _reap_expired_send_leases의 주석은 "만료되지 않은 첫 번째 요청을 만났다고 해서 스캔을 중단해서는 안 된다"고 말한다. 만료되지 않은 요청을 만나면 break하도록 변경하면, 어떤 시나리오에서 block 누수가 발생하는가?

참고 해석:_reqs_to_send는 일반 dict이며, 만료 시간으로 정렬된 우선순위 큐가 아니다. 하트비트 처리_handle_heartbeat 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3014-3034는 만료 시간을 제자리에서 갱신한다:self._reqs_to_send[req_id] = max(old, new_expiry). 이는 먼저 추가된 요청이 지속적으로 하트비트를 받아 매우 늦은 만료 시간을 가질 수 있고, 그 뒤에 있는 요청은 이미 만료되었을 수 있음을 의미한다. 만료되지 않은 첫 번째 요청을 만나면 break하면, 뒤에 있는 이미 만료된 요청은 영원히 회수되지 않고 그들의 block이 계속 VRAM을 점유한다. 장시간 실행되고 요청 패턴이 혼합된 시나리오(일부 요청은 빈번히 하트비트로 갱신되고, 일부 요청의 decode 인스턴스는 이미 크래시됨)에서 이는 심각한 VRAM 누수로 누적된다.

Q3: _evict_stale_engines는_engines_with_inflight_transfers로 진행 중인 전송이 있는 엔진을 보호한다. 이 보호를 제거하면, 어떤 네트워크 장애 시나리오에서 전송 실패가 발생하는가?

참고 해석:_engines_with_inflight_transfers 📎 vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py:3534-3546의 주석은 핵심 시나리오를 설명한다: "The timestamp is stamped when a read is issued and not refreshed while it runs, so a transfer that outlives the TTL leaves its engine looking idle. A peer that has lost its NIC holds one indefinitely." 상대방 NIC에 장애가 발생하여 NIXL 읽기 작업이 TTL(기본 3600초)을 초과하여 중단된다고 가정하자._engine_last_active타임스탬프는 읽기 시작 시 찍히고 읽기 동안 갱신되지 않으므로 엔진은 이미 유휴 상태로 보인다. 이때_evict_stale_engines가 이 엔진을 축출하면,_cleanup_remote_engine를 호출하여dst_xfer_side_handles를 해제하고 remote agent를 제거한다. 하지만 진행 중인 DMA가 아직 이 리소스들을 사용하고 있어, 해제 후 전송 실패나 크래시가 발생한다.busy집합은 이러한 상황을 명시적으로 보호하여 진행 중인 전송이 있는 엔진이 축출되지 않도록 보장한다.

지금까지 우리는 KV Connector가 prefill과 decode 인스턴스 사이에 어떻게 신뢰할 수 있는 데이터 채널을 구축하는지, 그리고 리스, 하트비트, 실패 복구 메커니즘으로 어떻게 상태 일관성을 지키는지 살펴보았다. 하지만 인스턴스 간 전송은 PD 분리의 절반에 불과한 이야기다—KV Cache가 decode 인스턴스에 도착한 후에도 추론 엔진은 단일 인스턴스 내부에서 각 전방 계산 단계를 여전히 효율적으로 실행해야 한다. 그리고 Python 스케줄링과 커널 시작 오버헤드가 바로 단일 단계 지연을 제약하는 다음 병목이다. 다음 장에서는 컴파일 가속과 CUDA Graph로 전환하여, vLLM이 torch.compile과 piecewise backend로 이러한 오버헤드를 어떻게 제거하고 CUDA Graph와 동적 배치 형태를 조화롭게 공존시키는지 살펴본다.

CHAPTER 10

제10장: 컴파일 가속과 CUDA Graph: 시작 및 스케줄링 오버헤드 제거

소속 프로젝트: vllm-project/vllm · 전체 진행률: 제10 / 14장 · 검증 상태: FACT 행 번호 실제 앵커링

이전 장에서 우리는 KV Connector가 NIXL, Mooncake 등의 커넥터를 통해 Prefill과 Decode 엔진 사이에서 KV cache를 효율적으로 운반하여, 분리형 아키텍처가 TTFT를 낮추는 동시에 리소스 활용률을 높이는 것을 보았다. 하지만 전송이 아무리 빨라도, 자기회귀 디코딩에는 알고리즘으로 제거할 수 없는 두 가지 고정 비용이 여전히 존재한다: Python 인터프리터의 스케줄링 오버헤드와 GPU 커널의 시작 오버헤드다. 모델 전방 계산이 수백 개의 연산자로 쪼개지고, 각 연산자가 매번 Python 함수 호출 한 번과 CUDA 커널 시작 한 번을 거쳐야 할 때, CPU 측 오버헤드는 GPU가 두 계산 사이에 유휴 상태로 있게 만들기에 충분하다. 이 장에서는 vLLM이 torch.compile로 연산자를 정적 그래프로 융합하고, 다시 CUDA Graph로 전체 커널 시작 시퀀스를 한 번의 재생으로 녹화하여 이 두 가지 오버헤드를 거의 0에 가깝게 압축하는 방법을 분석한다.

컴파일 캐시와 컴파일러 어댑터 계층: 컴파일 결과를 프로세스 간 재사용하기

직관적 모델

컴파일 가속의 이점은 "한 번 컴파일, 여러 번 실행"이지만, 대가는 최초 컴파일 소요 시간이 수 분에 달할 수 있다는 점이다. 캐시가 없으면 서비스가 재시작될 때마다 다시 컴파일해야 하므로 콜드 스타트 시간을 감당할 수 없다.CompilerInterface이 계층이 해결하려는 것이 바로 "컴파일 산출물을 어떻게 직렬화하고, 어떻게 해시로 식별하며, 다음 시작 시 어떻게 정확히 적중시킬 것인가"라는 문제다. 이것이 없으면 시스템이 직면하는 재앙은 충돌이 아니라, 매번 재시작할 때마다 "최초 실행"으로 퇴화하는 것이다—자동 확장/축소가 이루어지는 프로덕션 환경에서 이는 확장된 인스턴스가 수 분 동안 저지연 서비스를 제공할 수 없다는 것을 의미한다.

데이터 구조와 인터페이스 계약

CompilerInterface컴파일러 어댑터의 추상 계약을 정의하며, 핵심은 네 가지 메서드다:initialize_cache컴파일러 자체의 캐시 디렉터리를 vLLM의 캐시 디렉터리 아래로 리디렉션하는 역할을 담당한다📎 vllm/compilation/compiler_interface.py:36-51;compute_hash컴파일러 관련 구성 정보를 수집하여 해시를 생성한다📎 vllm/compilation/compiler_interface.py:53-62;compile컴파일을 실행하고 호출 가능한 객체와 핸들을 반환한다📎 vllm/compilation/compiler_interface.py:64-95;load핸들로부터 컴파일 산출물을 복원한다📎 vllm/compilation/compiler_interface.py:97-103。

여기서 핵심 설계는compile이원 튜플을 반환한다(callable, handle)。callable은 이번 프로세스 내에서 직접 호출 가능한 컴파일 결과다;handle은 "다음 시작 시 복원에 사용할" 증빙이며, 문서는 이것이 "plain Python object, preferably a string or a file path"여야 한다고 명확히 요구한다📎 vllm/compilation/compiler_interface.py:81-81. 이 분리는 캐시 적중 경로와 최초 컴파일 경로가 완전히 다른 코드를 탈 수 있게 한다—적중 시에는compile이 전혀 필요 없고,load。

compile_range만 필요하다. 매개변수는 동적 형태의 의미를 담고 있다. 주석은 이것이 "could be concrete size (if compile_sizes is provided), e.g. [4, 4] or a range [5, 8]"일 수 있으며, "Right now we only support one variable in ranges for all inputs, which is the batchsize (number of tokens) during inference"라고 설명한다📎 vllm/compilation/compiler_interface.py:74-74. 이것이 vLLM 컴파일 전략의 핵심 제약이다: 모든 동적 형태가 단일 변수—토큰 수—로 귀결된다.

시나리오 기반: 한 번의 컴파일 요청의 전체 흐름

서비스가 최초로 시작되어,InductorAdaptor.compile이 호출된다고 가정하자. 이것은 먼저 컴파일 카운터를 증가시키고📎 vllm/compilation/compiler_interface.py:477-489, 그다음 정교하게 구성된 패치 스택에 진입한다.

첫 번째 단계는 그래프를 깊은 복사하는 것이다. 주석은 "inductor can inplace modify the graph, so we need to copy it"이라고 지적한다📎 vllm/compilation/compiler_interface.py:500-502. 이것은 방어적 설계다—컴파일 실패 후에도 원본 그래프를 재시도에 사용할 수 있다.

두 번째 단계는 일련의 monkey-patch를 설치하는 것이다.hijacked_compile_fx_inner은 Inductor의 내부 컴파일 함수를 감싸서, 컴파일 완료 후inductor_compiled_graph._fx_graph_cache_key에서 해시를 가져온다📎 vllm/compilation/compiler_interface.py:512-536。hijack_compiled_fx_graph_hash은 해시 계산 함수 자체를 가로챈다📎 vllm/compilation/compiler_interface.py:538-542. 왜 해시를 "하이재킹"하는가? vLLM은 Dynamo 추적 컨텍스트 밖에서 별도로 컴파일해야 하는데, Inductor의 해시 계산이 해당 컨텍스트에 의존하기 때문이다.

세 번째 단계는_check_can_cache패치, 그것은 직접 반환하고 아무런 검사도 하지 않는다📎 vllm/compilation/compiler_interface.py:544-551. 주석은 동기를 설명한다: "Inductor refuses to cache the graph outside of Dynamo tracing context, and also disables caching for graphs with high-order ops. For vLLM, in either case, we want to cache the graph"📎 vllm/compilation/compiler_interface.py:544-551。

네 번째 단계는 추적 컨텍스트를 정리하는 것이다. 이것이 가장 미묘한 부분이다: vLLM은PiecewiseCompileInterpreter내부에서compile_fx를 호출하며, 이때 Dynamo의FakeTensorMode와 서브그래프 입력의FakeTensorMode가 일치하지 않아,detect_fake_mode()는 단언 실패를 일으킨다📎 vllm/compilation/compiler_interface.py:615-622. 코드는TracingContext를 저장한 후 이를 비우고, 종료 시 복원하는 콜백을 등록한다📎 vllm/compilation/compiler_interface.py:623-630。

mermaid
flowchart TD
    start["InductorAdaptor.compile()"] --> deepcopy["copy.deepcopy(graph)"]
    deepcopy --> patch_stack["ExitStack 安装补丁"]
    patch_stack --> p1["patch compiled_fx_graph_hash"]
    patch_stack --> p2["patch FxGraphCache._get_shape_env"]
    patch_stack --> p3["patch _check_can_cache"]
    patch_stack --> p4["清空 TracingContext"]
    p4 --> call_fx["compile_fx(graph, example_inputs)"]
    call_fx --> check{"hash_str is None?"}
    check -->|"是"| err["RuntimeError: 编译失败<br/>建议删除 torch_compile_cache"]
    check -->|"否"| check2{"file_path is None?"}
    check2 -->|"是"| assert_err["AssertionError"]
    check2 -->|"否"| ret["return (compiled_graph, (hash_str, file_path))"]
    err --> cleanup["ExitStack 退出<br/>恢复 TracingContext"]
    assert_err --> cleanup
    ret --> cleanup

설계 고찰: AlwaysHitShapeEnv와 캐시 일관성

AlwaysHitShapeEnv이 클래스는 별도로 분석할 가치가 있다. 그 독스트링은 동기를 직설적으로 설명한다: vLLM은 Dynamo 바이트코드 컴파일을 한 번만 실행하지만, 서로 다른 형상과 하나의 범용 형상으로 Inductor 컴파일을 여러 번 실행해야 한다; 특정 형상에 대한 컴파일은 Dynamo 컨텍스트 외부에서 발생하며, 이때 Inductor에 제공할 shape environment가 없어 Inductor 코드 캐시 조회가 실패한다📎 vllm/compilation/compiler_interface.py:114-131。

해결책은 "항상 히트"하는 가짜 shape environment를 제공하는 것이다:evaluate_guards_expression항상True 📎 vllm/compilation/compiler_interface.py:144-145,get_pruned_guards를 반환하고 빈 리스트를 반환한다📎 vllm/compilation/compiler_interface.py:144-145,produce_guards_expression빈 문자열을 반환한다📎 vllm/compilation/compiler_interface.py:147-159. 주석은 이 메서드들이 "obtained by trial-and-error until it works"임을 인정한다📎 vllm/compilation/compiler_interface.py:137-142——이것은 PyTorch 내부 구현과 결합된 취약점이며, PyTorch 업그레이드 시 가장 문제가 발생하기 쉬운 부분이다.

캐시 해시의 구성도 마찬가지로 중요하다.get_inductor_factors세 가지 종류의 인자를 수집한다: 시스템 상태CacheBase.get_system(), PyTorch 상태torch_key(), 그리고 Inductor와 functorch의 구성📎 vllm/compilation/compiler_interface.py:165-185. functorch 구성은patch(_get_vllm_functorch_config())컨텍스트에서 수집된다는 점에 주목하라📎 vllm/compilation/compiler_interface.py:188-189, 이는 "컴파일 시 구성과 캐시 키가 항상 일치"하도록 보장한다——주석은 이것이set_functorch_config()와get_inductor_factors()를 일치시키기 위한 것이라고 명확히 말한다📎 vllm/compilation/compiler_interface.py:147-159. 이 두 곳이 일치하지 않으면 "컴파일 시 구성 A를 사용하고 캐시 키는 구성 B로 계산"하는 불일치가 발생하여, 캐시가 히트되었지만 잘못된 산출물을 로드하게 된다.

프로덕션 함정:_patch_standalone_compile_atomic_save은 torch < 2.10.0을 위한 백포트이다📎 vllm/compilation/compiler_interface.py:205-243. 그것은CompiledArtifact.save()를write_atomic로 바이너리 형식을 쓰도록 변경하며, 주석은 목적이 "preventing corrupt cache files when multiple processes compile concurrently"라고 설명한다📎 vllm/compilation/compiler_interface.py:208-210. 여러 복제본이 동시에 콜드 스타트하는 시나리오에서 여러 프로세스가 동일한 캐시 파일에 동시에 쓰게 되며, 비원자적 쓰기는 잘린 파일을 생성하고, 이후 프로세스가 손상된 산출물을 읽으면 동작이 예측 불가능해진다.

PiecewiseBackend: 형상별 구간 컴파일과 런타임 디스패치

직관적 모델

PiecewiseBackend은 컴파일과 실행 사이의 스케줄링 허브이다. 그것은 "하나의 FX 서브그래프"를 "여러 형상 구간의 호출 가능 객체"로 컴파일하고, 런타임에 실제 토큰 수에 따라 가장 적합한 것을 선택한다. 이것이 없다면, 모든 형상이 동일한 범용 컴파일을 거치거나(성능 차선), 각 형상이 개별적으로 컴파일되어야 한다(컴파일 시간 폭발).

데이터 구조: RangeEntry와 컴파일 범위

핵심 데이터 구조는RangeEntry이며, 그것은compile_range、compiled플래그와runnable를 함께 묶는다📎 vllm/compilation/piecewise_backend.py:80-83。PiecewiseBackendrange_entries: dict[Range, RangeEntry] 📎 vllm/compilation/piecewise_backend.py:166-171。

컴파일 범위를 유지한다. 구성은 두 단계로 나뉜다. 먼저compile_sizes(정확한 크기)를 처리하며, 각 크기는Range(start=size, end=size)의 단일 지점 구간을 생성한다📎 vllm/compilation/piecewise_backend.py:166-171. 여기서 문자열"cudagraph_capture_sizes"에 대해 직접NotImplementedError를 던지며, "should be handled inpost_init_cudagraph_sizes" 📎 vllm/compilation/piecewise_backend.py:166-171——이것은 명시적인 책임 경계 선언이다. 그런 다음compile_ranges(구간)를 처리하며, 각 구간은 하나의 entry를 생성한다📎 vllm/compilation/piecewise_backend.py:173-173。

PiecewiseBackend📎 vllm/compilation/piecewise_backend.py:117-119는 두 가지 상호 배타적 모드를 지원하며, 생성자는 XOR 단언으로 이를 강제한다compile_all_ranges() 📎 vllm/compilation/piecewise_backend.py:193-194: 컴파일 모드(graph 있음, compiled_runnables 없음)는load_all_ranges() 📎 vllm/compilation/piecewise_backend.py:193-194를 사용한다; 사전 컴파일 모드(graph 없음, compiled_runnables 있음)는

를 사용한다. 이 설계는 콜드 스타트와 핫 스타트가 동일한 클래스를 공유하되 데이터 출처만 다르게 한다.

시나리오 기반: 컴파일에서 런타임 디스패치까지:compile_all_ranges컴파일 단계_log_compile_start는 모든 range entry를 순회하며, 컴파일되지 않은 각 entry에 대해📎 vllm/compilation/piecewise_backend.py:252-256를 호출한다create_concrete_args추적 이벤트를 기록한다📎 vllm/compilation/piecewise_backend.py:258-261. 핵심 분기는 인자 구성에 있다: 단일 지점 크기라면get_fake_args_from_graph를 호출하여 구체적 형상의 FakeTensor를 생성한다📎 vllm/compilation/piecewise_backend.py:262-263。

create_concrete_args; 그렇지 않으면ShapeEnv를 호출하여 그래프의 placeholder 메타데이터를 직접 재사용한다FakeTensorMode 📎 vllm/compilation/piecewise_backend.py:54SymInt의 구현은 심볼릭 형상 구체화의 세부 사항을 드러낸다. 그것은concretize를 가진size 📎 vllm/compilation/piecewise_backend.py:47-52를 구성한 다음, placeholder 노드를 순회한다.Tensor유형의 입력에 대해서는compute_required_storage_length를 사용하여 모든 자유 심볼을as_strided로 대체한다;📎 vllm/compilation/piecewise_backend.py:64-73. 왜 shape만 바꿀 수 없는가? stride와 storage_offset에도 기호가 포함될 수 있고, 이 세 가지가 서로 일관되어야 하기 때문이다. 그렇지 않으면as_strided가 범위를 벗어난다.

런타임 디스패치:__call__는 핫 패스이다. 만약sym_shape_indices가 존재하면,args에서 런타임 shape📎 vllm/compilation/piecewise_backend.py:357-362를 가져온 후,_find_range_for_shape를 호출하여 조회한다. 조회 로직에는 우선순위가 있다: 먼저 정확한compile_sizes에 매칭되는지 확인하고, 매칭되면 해당 단일 포인트 구간📎 vllm/compilation/piecewise_backend.py:342-355을 반환한다. 그렇지 않으면compile_ranges를 순회하며 해당 shape를 포함하는 구간을 찾는다.📎 vllm/compilation/piecewise_backend.py:342-355。

mermaid
flowchart TD
    call["PiecewiseBackend.__call__(*args)"] --> has_sym{"sym_shape_indices 非空?"}
    has_sym -->|"是"| get_shape["runtime_shape = args[sym_shape_indices[0]]"]
    get_shape --> find["_find_range_for_shape(runtime_shape)"]
    find --> exact{"runtime_shape in compile_sizes?"}
    exact -->|"是"| exact_entry["返回 Range(start=shape, end=shape) 的 entry"]
    exact -->|"否"| scan["遍历 compile_ranges 找包含区间"]
    scan --> found{"找到?"}
    found -->|"否"| assert_fail["AssertionError: 形状超出编译范围"]
    found -->|"是"| entry_ok["返回对应 entry"]
    has_sym -->|"否"| static["取唯一已编译 entry"]
    static --> check_count{"compiled_entries 数量 == 1?"}
    check_count -->|"否"| count_err["AssertionError"]
    check_count -->|"是"| entry_ok
    exact_entry --> run["range_entry.runnable(*args)"]
    entry_ok --> run

설계 고찰: 직렬화와 CachingAutotuner의 특수 처리

〔설계 추론 및 아키텍처 트레이드오프〕

to_bytes메서드는 컴파일 산출물을 직렬화하여 AOT 캐시에 사용하는 역할을 한다. 여기에는 정교한reducer_override가 있다: pickle이CachingAutotuner를 만나면 먼저obj.prepare_for_pickle()를 호출한 후📎 vllm/compilation/piecewise_backend.py:209-218를 직렬화한다. 왜 이 훅이 필요한가?CachingAutotuner는 내부적으로 Triton 컴파일 산출물과 런타임 상태를 보유하고 있어, 직접 pickle하면 실패하거나 재사용 불가능한 객체가 생성될 수 있다;prepare_for_pickle는 분명히 객체를 직렬화 가능한 순수한 형태로 변환하는 것이다.

직렬화 시에는 임시로bundled_autograd_cache 📎 vllm/compilation/piecewise_backend.py:222를 활성화하는데, 이는_get_vllm_functorch_config의 로직과 호응한다——즉VLLM_USE_MEGA_AOT_ARTIFACT가 활성화되지 않았을 때 해당 설정은False 📎 vllm/compilation/compiler_interface.py:160-161이며, 직렬화 시에는 강제로True로 설정하여 산출물이 패키징되도록 보장한다.

load_all_ranges는 핫 스타트 경로로, 각 range가compiled_runnables에서 대응하는 key를 찾을 수 있다고 단언하며, 그렇지 않으면 사용 가능한 key 목록을 포함한 오류📎 vllm/compilation/piecewise_backend.py:329-339를 발생시킨다. 이 오류 메시지는 매우 실용적으로 설계되었다——사용 가능한 key를 직접 나열하여 캐시 버전 불일치를 쉽게排查할 수 있다.

CUDA Graph 래퍼: 캡처, 재생 및 중첩 디스패치

직관적 모델

CUDA Graph는 "일련의 커널 실행"을 정적 그래프로 녹화하여, 이후 매 재생 시 단 한 번의 API 호출만 필요로 한다.CUDAGraphWrapper는 녹화와 재생의 실행자이다. 핵심 난제는: vLLM의 배치 크기는 동적이지만, CUDA Graph는 입력 주소가 고정되어야 한다는 것이다. 해결책은 "batch descriptor별로 분할 캡처"——각 shape 등급마다 그래프를 하나씩 녹화하고, 런타임에 descriptor로 테이블을 조회하여 재생하는 것이다.

데이터 구조: CUDAGraphEntry와 디스패치 계약

CUDAGraphEntry는 세 가지 핵심 필드를 보유한다:batch_descriptor는 디스패치 키로 사용📎 vllm/compilation/cuda_graph.py:128-135、cudagraph는 캡처된 그래프 객체📎 vllm/compilation/cuda_graph.py:128-135、output는 캡처 시의 출력 (메모리 절약을 위해 약한 참조로 저장)📎 vllm/compilation/cuda_graph.py:128-135。input_addresses는 디버그 모드에서만 재생 시 입력 주소 일관성을 검증하는 데 사용📎 vllm/compilation/cuda_graph.py:128-135。

CUDAGraphWrapper의 클래스 문서는 디스패치 계약을 정확히 설명한다: 초기화 시 런타임 모드(FULL 또는 PIECEWISE)를 할당📎 vllm/compilation/cuda_graph.py:158-158; 런타임에 forward context로부터 runtime_mode와 batch_descriptor를 수신하고 "blindly trust them"📎 vllm/compilation/cuda_graph.py:158-158; runtime_mode가 NONE이거나 일치하지 않으면 직접📎 vllm/compilation/cuda_graph.py:158-158를 호출; 그렇지 않으면 캡처 또는 재생을 수행📎 vllm/compilation/cuda_graph.py:158-158。

문서는 또한 경계를 특별히 선언한다: "CUDAGraphWrapper does not store persistent buffers or copy any runtime inputs into that buffers for replay"📎 vllm/compilation/cuda_graph.py:164-164. 이는 입력 버퍼 관리는 호출자의 책임이라는 의미이다——wrapper는 그래프 자체만 담당한다.

시나리오 기반: 한 번의 캡처와 한 번의 재생

캡처 경로:__call__가 트리거되고 runtime_mode가 일치할 때, 먼저 forward context가 사용 가능한지 확인한다. 사용 불가능한 경우(예: 비전 인코더의 전방향), 직접 하위 함수📎 vllm/compilation/cuda_graph.py:232-233를 호출한다. 이는 멀티모달 시나리오의 핵심 분기이다——ViT 전방향은 CUDA Graph를 거치지 않는다.

다음으로batch_descriptor와cudagraph_runtime_mode 📎 vllm/compilation/cuda_graph.py:242-244를 가져온다. mode가 NONE이거나 일치하지 않으면 직접📎 vllm/compilation/cuda_graph.py:246-256를 호출한다. 이 "불일치 시 직통" 설계는 중첩 wrapper의 공존을 가능하게 한다: FULL wrapper가 외부에, PIECEWISE wrapper가 내부에 있으며, 런타임에는 하나만 활성화된다.

entry의cudagraph가 None이면 캡처에 진입한다. 먼저validate_cudagraph_capturing_enabled()를 호출하여 유효성을 검증하고📎 vllm/compilation/cuda_graph.py:279, 그런 다음 입력 주소를 기록하고📎 vllm/compilation/cuda_graph.py:281-284,torch.cuda.CUDAGraph() 📎 vllm/compilation/cuda_graph.py:285。

를 생성한다. 캡처 컨텍스트에는 몇 가지 핵심 작업이 있다. 만약gc_disable가 활성화되면,gc.collect와torch.accelerator.empty_cache 📎 vllm/compilation/cuda_graph.py:288-303를 패치한다. 주석은 그 이유를 설명한다: piecewise 모드에서는 각 레이어마다 그래프를 하나씩 캡처해야 하는데, 반복적인 GC는 캡처를 극도로 느리게 만들기 때문에 "only run gc for the first graph, and disable gc for the rest"📎 vllm/compilation/cuda_graph.py:289-294. 다음으로 graph pool id📎 vllm/compilation/cuda_graph.py:305-308를 설정하고, offloader의 복사 스트림을 동기화한다📎 vllm/compilation/cuda_graph.py:310-312。

실제 캡처는torch.cuda.graph(cudagraph, pool=..., stream=...)컨텍스트에서 실행된다self.runnable(*args, **kwargs) 📎 vllm/compilation/cuda_graph.py:315-321. 캡처 후get_offloader().join_after_forward()를 호출하여 join되지 않은 스트림 오류를 방지한다📎 vllm/compilation/cuda_graph.py:322-326. 만약weak_ref_output가 활성화되면, 메모리 절약을 위해 output을 약한 참조로 변환한다📎 vllm/compilation/cuda_graph.py:327-334. 마지막으로 entry는 약한 참조 output과 그래프 객체를 저장하지만📎 vllm/compilation/cuda_graph.py:338-339,반환되는 것은 약한 참조가 아닌 원본 output이다——주석은 이것이 PyTorch가 캡처 중에 메모리를 올바르게 관리하도록 하기 위함이라고 강조한다📎 vllm/compilation/cuda_graph.py:343-346。

재생 경로: entry에 이미 그래프가 있으면, 디버그 모드에서 입력 주소 일관성을 검증하고📎 vllm/compilation/cuda_graph.py:348-357, 그런 다음 offloader를 동기화하고📎 vllm/compilation/cuda_graph.py:359-361,entry.cudagraph.replay()를 호출하여 반환한다entry.output 📎 vllm/compilation/cuda_graph.py:362-363。

설계 고민: 왜 출력은 약한 참조여야 하고, 반환은 강한 참조여야 하는가

이것은CUDAGraphWrapper에서 가장 직관에 반하는 부분이다. 캡처 시output은 PyTorch의 cudagraph pool에 의해 관리된다📎 vllm/compilation/cuda_graph.py:320. 만약 entry가 output을 강하게 참조하면, 이 그래프가 차지하는 VRAM은 영원히 해제될 수 없다. 그러나 캡처 중에 이를 약한 참조로 변환하면, PyTorch가 캡처가 완료되기 전에 메모리를 회수하여 캡처가 실패할 수 있다. 그래서 코드는 캡처 블록 내에서 약한 참조📎 vllm/compilation/cuda_graph.py:334를 사용하고, entry에는 약한 참조📎 vllm/compilation/cuda_graph.py:338를 저장하지만, 함수 반환값은 강한 참조📎 vllm/compilation/cuda_graph.py:346이다. 이 "삼중 참조 상태"는 메모리 안전성과 VRAM 효율성의 정밀한 균형이다.

또 하나 주목할 만한 설계는_all_instances이WeakSet 📎 vllm/compilation/cuda_graph.py:173-176이다. 이는clear_all_graphs가 모든 wrapper의 그래프를 한 번에 비울 수 있게 하여📎 vllm/compilation/cuda_graph.py:173-176, VRAM이 부족할 때 긴급 회수를 위해 사용된다. 일반 집합 대신WeakSet를 사용하는 이유는 wrapper가 GC되는 것을 막지 않기 위해서다. 그렇지 않으면 wrapper 자체가 누수된다.

프로덕션 함정:__getattr__의 구현은 디버그 모드에서 존재하지 않는 속성에 대해 컨텍스트가 포함된 오류를 발생시킨다📎 vllm/compilation/cuda_graph.py:211-217. 사소해 보이지만, "왜 특정 메서드 호출이 실패하는가"를 조사할 때 wrapper가 감싼 runnable 문자열 설명을 볼 수 있어서 순수AttributeError보다 훨씬 유용하다.

설계 고민: 컴파일과 CUDA Graph의 디커플링

설계 문서는 이번 리팩터링의 동기를 명확히 기록하고 있다. 초기 piecewise 컴파일은 piecewise CUDA Graph 캡처를 지원하기 위해 CUDA Graph를 지원하지 않는 연산자(주로 attention)를 제외했다📎 docs/design/cuda_graphs.md:25. 이후 full CUDA Graph 지원이 추가되었지만, "this tight coupling between compilation and cudagraph capture led to an all-or-nothing experience with little flexibility"📎 docs/design/cuda_graphs.md:25。

리팩터링 후 목표는 네 가지다: prefill/mixed와 uniform-decode 배치를 명시적으로 구분하고 각각 캡처📎 docs/design/cuda_graphs.md:25-25; CUDA Graph 캡처 로직을 컴파일과 디커플링하여 "capturing piecewise and full cudagraphs using the same compiled graph"📎 docs/design/cuda_graphs.md:25-25; 런타임에 배치 구성에 따라 디스패치📎 docs/design/cuda_graphs.md:25-25; 복잡도를 낮추기 위한 중앙 집중 제어📎 docs/design/cuda_graphs.md:25-25。

BatchDescriptor는 디스패치 키의 핵심 구조로,num_tokens、num_reqs、uniform、has_lora네 개의 필드를 포함한다📎 docs/design/cuda_graphs.md:86-93。uniform플래그가 특히 중요한데, 많은 attention 백엔드가 배치가 uniform일 때만 full CUDA Graph를 지원하기 때문이다📎 docs/design/cuda_graphs.md:95-95. 문서는 또한 이 구조가 확장될 수 있음을 예고하는데, 예를 들어uniform_query_len를 추가하여 여러 uniform decode 길이를 지원하는 것이다📎 docs/design/cuda_graphs.md:95-95。

디스패치 우선순위는FULL > PIECEWISE > None이며, 디스패치 키가 존재하지 않으면 NONE 모드로 폴백하여 eager 실행을 한다📎 docs/design/cuda_graphs.md:112-115. 이 "오류 대신 강등" 전략은 어떤 배치 조합이든 실행될 수 있게 보장하며, 성능만 다를 뿐이다.

AttentionCGSupport열거형은 백엔드의 CUDA Graph 능력을 정량화하며, 값은ALWAYS=3 > UNIFORM_BATCH=2 > UNIFORM_SINGLE_TOKEN_DECODE=1 > NEVER=0 📎 docs/design/cuda_graphs.md:153-162이다. 혼합 attention 모델(예: mamba mixer)은 모든 백엔드 능력의 최솟값을 취하고, 그에 따라 CUDA Graph 모드를 강등한다📎 docs/design/cuda_graphs.md:173-175. 이 설계는 "능력 선언"과 "모드 선택"을 디커플링한다. 새 백엔드는 능력만 선언하면 강등 전략이 자동으로 적용된다.

이 장 요약

이 장 생각과 자가 점검

Q1: 만약_check_can_cache패치(📎 vllm/compilation/compiler_interface.py:544-551)를 제거하고 Inductor가 스스로 캐시 여부를 결정하게 하면, 어떤 시나리오에서 컴파일 캐시가 무효화되는가? 왜 주석은 "Inductor refuses to cache the graph outside of Dynamo tracing context"라고 말하는가?

참고 해석:_check_can_cache은 직접 반환하고 아무 검사도 하지 않으며, 주석은 Inductor가 두 가지 경우에 캐싱을 거부한다고 설명한다: 하나는 Dynamo 추적 컨텍스트 밖이고, 둘은 그래프에 고차 연산자가 포함된 경우이다📎 vllm/compilation/compiler_interface.py:544-551. vLLM의 컴파일 흐름은 정확히 Dynamo 컨텍스트 밖에 있다(compile_fx이PiecewiseCompileInterpreter에 의해 호출되고, 코드가 명시적으로TracingContext 📎 vllm/compilation/compiler_interface.py:623-625를 비운다). 패치를 제거하면 Inductor는 "캐시 불가"로 판정하고, 매 시작마다 다시 컴파일하여 콜드 스타트 시간이 초 단위에서 분 단위로 퇴화한다. 더 은밀한 것은, vLLM이hijacked_compile_fx_inner에 의존하여hash_str를 가져오는데, 캐시 경로가 건너뛰어지면hash_str이 None이 될 수 있어📎 vllm/compilation/compiler_interface.py:640-652의 RuntimeError를 트리거한다. 이는 왜 주석이 "vLLM today assumes and requires the monkey-patched functions to get hit"라고 강조하는지 설명한다📎 vllm/compilation/compiler_interface.py:596-598。

Q2: CUDAGraphWrapper은 캡처 시 output을 약한 참조로 변환하여 entry에 저장하지만(📎 vllm/compilation/cuda_graph.py:338), 강한 참조를 반환한다(📎 vllm/compilation/cuda_graph.py:346). 만약 반환값도 약한 참조로 바꾸면 어떤 시나리오에서 크래시하는가?

참고 해석: 캡처 기간 동안output은 PyTorch의 cudagraph pool에 의해 관리된다📎 vllm/compilation/cuda_graph.py:320. 반환값이 약한 참조라면, 호출자가 받은 객체는 캡처 블록이 종료된 직후 GC에 의해 회수될 수 있다 — 이때 이를 유지하는 강한 참조가 전혀 없기 때문이다. PyTorch는 캡처 기간 동안 output이 살아 있어야 메모리 풀의 매핑 관계를 올바르게 구축할 수 있다; 일단 회수되면 이후 재생 시entry.output가 가리키는 약한 참조는 이미 무효화되어,replay()이후 반환된 객체는 이미 덮어쓰이거나 해제되었을 수 있다. 주석은 명확히 "we need to return the output, rather than the weak ref of the output, so that pytorch can correctly manage the memory during cuda graph capture"라고 말한다📎 vllm/compilation/cuda_graph.py:343-345. 이 설계는 "캡처 기간 강한 참조, 저장 기간 약한 참조"의 정교한 균형이다.

Q3:PiecewiseBackend._find_range_for_shape(📎 vllm/compilation/piecewise_backend.py:342-355에서 정확한 크기 조회가 구간 조회보다 우선한다.compile_sizes=[8]、compile_ranges=[Range(1,16)]이고 런타임 shape=8이라고 가정하면, 어떤 entry에 매칭될까? 만약 우선순위를 반대로 하면 어떤 결과가 발생할까?

참고 해석: 현재 로직은 먼저runtime_shape in self.compile_sizes을 확인하고, 매칭되면Range(start=8, end=8)의 단일 포인트 entry📎 vllm/compilation/piecewise_backend.py:342-355를 반환한다. 이 entry는create_concrete_args로 컴파일되었으며, 형상이 완전히 구체화되어 Triton 커널이 최대 수준의 특화를 수행할 수 있다 (예:set_inductor_config에서 단일 포인트 크기는max_autotune 📎 vllm/compilation/compiler_interface.py:747-754을 활성화한다). 만약 우선순위를 반대로 하면, shape=8은 구간Range(1,16)의 entry에 매칭된다 — 이는 심볼릭 형상으로 컴파일된 범용 버전으로 성능이 차선이다. 더 심각한 것은,compile_sizes은 일반적으로cudagraph_capture_sizes에서 오며, 이러한 크기들은 바로 CUDA Graph가 캡처하려는 단계이다; 만약 런타임에 범용 entry로 디스패치되면, CUDA Graph가 캡처한 그래프와 디스패치된 runnable이 일치하지 않아 재생 시 형상 불일치가 발생할 수 있다. 따라서 정확한 우선순위는 성능 선택일 뿐만 아니라 정확성 요구사항이기도 하다.

다음 장에서는 양자화와 사용자 정의 커널로 전환하여, vLLM이 가중치 로딩 단계부터 정밀도 제어에 개입하고 고도로 특화된 연산자로 양자화 이점을 실제 처리량 향상으로 실현하는 방법을 살펴본다.

이 장에서는 vLLM 컴파일 가속의 두 계층 메커니즘을 분석했다. 첫 번째 계층은 CompilerInterface와 PiecewiseBackend이다: 전자는 컴파일러 적응 계약과 캐시 해시 전략을 정의하고, AlwaysHitShapeEnv로 Dynamo 컨텍스트 부재 문제를 우회한다; 후자는 단일 FX 서브그래프를 여러 형상 단계로 컴파일하고 런타임에 토큰 수에 따라 디스패치한다. 두 번째 계층은 CUDAGraphWrapper이다: BatchDescriptor에 따라 CUDA Graph를 단계별로 캡처하고, runtime mode 매칭을 통해 중첩 디스패치를 구현하여 FULL과 PIECEWISE 두 모드가 동일한 컴파일 그래프에서 공존할 수 있게 한다.两者的 분리는 이번 리팩토링의 핵심이다 — 컴파일 산출물은 두 CUDA Graph 모드에서 재사용될 수 있고, CUDA Graph도 컴파일과 독립적으로 작동할 수 있다. 그러나 컴파일과 그래프 캡처가 해결하는 것은 스케줄링 오버헤드이며, 모델 자체의 가중치 정밀도와 연산자 효율성은 여전히 또 다른 최적화 주선이다. 다음 장에서는 양자화와 사용자 정의 커널로 전환하여, vLLM이 양자화 설정을 파싱하고 가중치 로딩 시 FP8/INT4/AWQ/GPTQ 등의 형식 변환을 완료하며, _custom_ops와 Triton 커널을 통해 하드웨어 성능을 더욱 짜내는 방법을 살펴본다.

CHAPTER 11

제11장: 양자화와 사용자 정의 커널: 가중치 로딩에서 고성능 연산자까지

소속 프로젝트: vllm-project/vllm · 전체 진행률: 제11 / 14장 · 검증 상태: FACT 행 번호 실제 앵커링

이전 장에서 우리는 torch.compile과 CUDA Graph가 Python 스케줄링과 커널 시작 오버헤드를 극한까지 줄이는 것을 보았다. 그러나 스케줄링이 아무리 빨라도 가중치 자체가 FP16이고 행렬 곱셈이 범용 GEMM을 사용한다면, 하드웨어 연산 능력은 여전히 메모리 대역폭과 비효율적인 연산자에 발목이 잡힌다. 양자화와 사용자 정의 커널은 또 다른 직교적인 최적화 주선이다: 전자는 가중치 로딩 단계에서 정밀도를 낮추고, 후자는 양자화 이점을 실제 처리량으로 실현한다. 이 장은 양자화 설정의 파싱 진입점에서 출발하여 _custom_ops의 연산자 등록과 Triton 커널 스케줄링까지 다룬다.

11.1 양자화 설정: CLI 문자열에서 QuantKey까지

직관적 모델

양자화 설정 모듈의 역할은 식당의 주문 번역기와 같다. 사용자가 프런트에서 "fp8_per_tensor를 원합니다"(CLI 문자열)라고 말하면, 주방에서는 정확한 레시피 번호(QuantKey)가 필요하다. 번역기는 세 가지 입력을 처리해야 한다: 순수 CLI 약어, checkpoint에 내장된 양자화 메타데이터, 그리고两者가 중첩된 조합 시나리오. 이러한 번역 계층이 없으면 주방은 의미가 모호한 문자열 더미를 받아 어떤 kernel을 호출해야 할지 결정할 수 없다.

데이터 구조와 메모리 레이아웃

핵심 데이터 구조는QuantSpec와QuantizationConfigArgs이다. 전자는 단일 유형 계층(linear 또는 MoE)의 가중치와 활성화 양자화 키를 설명하고, 후자는 사용자에게 보이는 최상위 설정이다.

📎 vllm/config/quantization.py:73-99

python
@config
class QuantSpec:
    weight: QuantKeyField = None
    activation: QuantKeyField = None

    def __str__(self) -> str:
        def quant_key_str(quant_key: QuantKey | None) -> str:
            if quant_key is None:
                return "None"
            return next(
                (
                    name
                    for name, known_quant_key in QUANT_KEY_NAMES.items()
                    if known_quant_key == quant_key
                ),
                str(quant_key),
            )
        return quant_key_str(self.weight)

weight과activation은 모두 선택 사항이다QuantKey。None의 의미는 "메서드 클래스 자체의 기본값으로 폴백" — 일반적으로 checkpoint에서 상속되며, 온라인 양자화 시나리오에서는 양자화하지 않음을 의미📎 vllm/config/quantization.py:74-74。QuantKey자체는 다음을 포함하는 복잡한 타입NamedTuple과ClassVar[GroupShape]선언을 포함하며, pydantic이 직접 내성할 수 없으므로 작성자는GetPydanticSchema를 사용해 커스텀 검증기를 주입_coerce_quant_key하여 문자열 또는QuantKey을 통일적으로 정규화📎 vllm/config/quantization.py:60-69。

QuantizationConfigArgs의 필드 레이아웃은 주목할 만함📎 vllm/config/quantization.py:102-126:

  • linear / moe: 각각LinearBase과FusedMoEFactory레이어에 적용됨;
  • ignore: 양자화를 건너뛸 레이어 이름 목록, 온라인 양자화는 fnmatch 와일드카드도 지원;
  • targets: 레이어별 온라인 양자화 오버라이드, 키는 정확한 레이어 이름,re:접두사의 정규식, 또는 fnmatch 패턴이 될 수 있으며, 값은linear/moe와 상호 배타적.

targets과linear/moe의 상호 배타성은model_validator에 의해 강제됨📎 vllm/config/quantization.py:172-179. 이 제약은 형식주의가 아님:targets은 레이어별 오버라이드 경로를 따르고,linear/moe은 전역 기본 경로를 따르며, 둘이 동시에 존재하면 "특정 레이어가 어떤 spec을 사용하는지"가 결정 불가능해짐.

Step-by-Step: 한 번의--quantization fp8_per_tensor파싱

시나리오 대입: 사용자가 명령줄에서--quantization fp8_per_tensor를 전달하고, 동시에--quantization-config를 통해 MoE 레이어의 활성화 양자화를 지정.

첫 번째 단계,resolve_quantization_config이 호출되며, 인자는 CLI 문자열과 설정 딕셔너리📎 vllm/config/quantization.py:233-235. 먼저quantization이ONLINE_QUANT_SHORTHAND_NAMES에 있는지 확인 — 이 튜플은 모든 축약 이름과"online" 📎 vllm/config/quantization.py:216-222。

을 포함fp8_per_tensor두 번째 단계,base이 축약 테이블에 매칭되어,_ONLINE_SHORTHANDS["fp8_per_tensor"]로 파싱됨, 즉 linear와 moe 모두kFp8StaticTensorSym 📎 vllm/config/quantization.py:188-190。

을 사용quantization_config세 번째 단계,QuantizationConfigArgs이 비어 있지 않아📎 vllm/config/quantization.py:267-268객체로 생성됨. 이후 병합 로직에 진입quantization_config.xxx or base.xxx: 각 필드는or로 결정 — 사용자가 명시적으로 설정한 필드가 우선하고, 설정되지 않은 필드는 축약 기본값을 상속. 여기서if is not None대신QuantSpec을 사용한 것은 의도적:

과 빈 리스트 모두 falsy이므로, 의미상 "미설정"과 "빈 값"이 동등함.quantization네 번째 단계, 만약awq이 축약 테이블에 없고 (예: checkpoint 자체의quantization_config), 그리고None이None 📎 vllm/config/quantization.py:256-257이면, 함수는 직접

을 반환. 이는 "온라인 양자화를 중첩하지 않음"을 의미하며, checkpoint의 양자화 방법이 주도권을 유지._DEFERRED_ONLINE_SHORTHANDS놓치기 쉬운 분기가 하나 있음:mxfp4이mxfp8 📎 vllm/config/quantization.py:233-235과--quantization mxfp4을 포함. 이 두 이름은 CLI 축약이자 checkpoint 양자화 메서드 이름. 사용자가quantization_config만 전달하고None없이base 📎 vllm/config/quantization.py:267-268을 전달하면, 함수는

mermaid
flowchart TD
    start["resolve_quantization_config(quantization, quantization_config)"]
    check_shorthand{"quantization in ONLINE_QUANT_SHORTHAND_NAMES?"}
    checkpoint_path{"quantization_config is None?"}
    return_none1["return None (checkpoint 主导)"]
    build_args["QuantizationConfigArgs(**quantization_config)"]
    get_base["base = _ONLINE_SHORTHANDS.get(quantization)"]
    cfg_none{"quantization_config is None?"}
    deferred{"quantization in _DEFERRED_ONLINE_SHORTHANDS?"}
    return_none2["return None (推迟到 checkpoint)"]
    return_base["return base"]
    merge["逐字段合并: cfg.xxx or base.xxx"]
    return_merged["return 合并后的 QuantizationConfigArgs"]

    start --> check_shorthand
    check_shorthand -->|否| checkpoint_path
    checkpoint_path -->|是| return_none1
    checkpoint_path -->|否| build_args
    check_shorthand -->|是| get_base
    get_base --> cfg_none
    cfg_none -->|是| deferred
    deferred -->|是| return_none2
    deferred -->|否| return_base
    cfg_none -->|否| merge
    merge --> return_merged

을 반환하여 결정권을 checkpoint 메타데이터로 미룸 — checkpoint에 양자화 정보가 없을 때만 온라인 축약으로 폴백.

_coerce_spec복사linear설계 고찰과 함정moe검증기는 미묘한 시나리오를 처리:_ONLINE_SHORTHANDS또는QuantKey이 문자열을 받으면, 먼저📎 vllm/config/quantization.py:130-139을 조회하여 히트하면 해당 필드의 spec을 가져오고; 미스하면 단일linear="fp8_per_tensor"이름으로 처리linear="fp8_per_tensor_static". 이는None과int8_per_channel_weight_only이 두 가지 다른 경로를 따른다는 의미 — 전자는 완전한 설정 축약, 후자는 단일 양자화 키. 만약 축약에서 해당 필드가linear이면 (예:ValueError에None 📎 vllm/config/quantization.py:130-139。

필드가 없음), 명확한targets을 던지고 조용히_validate_targets을 반환하지 않음📎 vllm/config/quantization.py:166-167프로덕션 환경의 흔한 함정:

11.2 _custom_ops의 정규식 키는

에서 사전 컴파일 검증되지만

_custom_ops.py, fnmatch 패턴의 키는 검증하지 않음. 사용자가 어떤 레이어와도 절대 매칭되지 않는 fnmatch 패턴을 작성하면, 오류가 발생하지 않고 해당 레이어는 양자화되지 않은 채로 유지됨 — 문제 해결 시 레이어 이름이 실제로 매칭되는지 확인해야 함.torch.ops._C: 연산자 등록과 fake 구현torch.compile직관적 모델_custom_ops은 vLLM과 하위 CUDA/C++ 연산자 사이의 어댑터 레이어로, 세관과 같음. PyTorch의

네임스페이스에는 컴파일된 C++ 연산자가 등록되어 있지만, 직접 호출에는 세 가지 문제가 있음: 플랫폼마다 (CUDA/ROCm/CPU/XPU) 연산자 집합이 다르고,

은 출력 형태를 추론하기 위한 fake 구현이 필요하며, 일부 연산자는 Python 측 매개변수 전처리가 필요.current_platform.import_kernels() 📎 vllm/_custom_ops.py:25-26이 이러한 문제들을 통합적으로 캡슐화.register_fake데이터 구조와 등록 메커니즘TYPE_CHECKING모듈 로드 시 먼저torch.library을 호출하여 플랫폼 레이어가 자체 연산자 라이브러리를 임포트할 기회를 줌. 이후📎 vllm/_custom_ops.py:25-26。

을 정의 —torch.compile하에서는 빈 데코레이터, 런타임에는scaled_fp4_quant에서 임포트

📎 vllm/_custom_ops.py:90-100

python
if hasattr(torch.ops, "_C") and hasattr(torch.ops._C, "scaled_fp4_quant"):

    @register_fake("_C::scaled_fp4_quant")
    def _scaled_fp4_quant_fake(
        input: torch.Tensor,
        input_scale: torch.Tensor,
        is_sf_swizzled_layout: bool,
    ) -> tuple[torch.Tensor, torch.Tensor]:
        n = input.shape[-1]
        m = input.numel() // n
        return create_fp4_output_tensors(m, n, input.device, is_sf_swizzled_layout)

이 추적 단계에서 실제 실행 없이 연산자의 출력 형태와 dtype을 알게 하는 것.hasattr을 예로 들면:_C::scaled_fp4_quant복사

create_fp4_output_tensors📎 vllm/_custom_ops.py:69-87가드에 주목: 플랫폼이 실제로is_sf_swizzled_layout=True을 등록했을 때만 fake 구현이 정의됨. 이는 CPU나 구형 GPU에서 연산자 부재로 인해 모듈 임포트가 크래시하지 않도록 보장.n // 16은 FP4 양자화 출력의 메모리 레이아웃 세부 사항을 보여줌📎 vllm/_custom_ops.py:55-64.📎 vllm/_custom_ops.py:60-61。

일 때, scale 텐서는 Tensor Core가 요구하는 128x4 tile 배치로 배열되어야 함: 행 수는 128의 배수로 올림, 열 수 (

)는 4의 배수로 올림, 매 4개의 float8_e4m3가 하나의 int32로 패킹됨

. 주석은 NVFP4 양자화 커널이 모든 padding된 scale 항목을 명시적으로 0으로 초기화하므로 별도의 제로 초기화 kernel이 필요 없음을 명확히 밝힘awq_gemm 📎 vllm/_custom_ops.py:587-592Step-by-Step: 한 번의 AWQ GEMM 호출 흐름VLLM_USE_TRITON_AWQ시나리오 대입: 모델이 AWQ 양자화된 가중치를 로드했고, 순전파 시 활성화와 양자화 가중치의 행렬 곱셈이 필요.awq_gemm_triton첫 번째 단계,

을 호출. 함수는 먼저 환경 변수torch.ops._C.awq_gemm을 확인. 참이면,split_k_iters 📎 vllm/_custom_ops.py:598-598。

을 지연 임포트하고 호출 — 이는 순수 Triton 구현 경로로, CUDA 연산자를 지원하지 않는 플랫폼이나 디버깅 시나리오에 사용.torch.ops._C.awq_gemm존재하며, fake 구현이 등록됨📎 vllm/_custom_ops.py:601-616. fake가 반환하는 shape는(split_k_iters, num_in_feats, qweight.size(1) * 8)그런 다음.sum(0)——이는 split-K의 중간 결과 shape와 reduce 후의 최종 shape를 정확히 모사한다.qweight.size(1) * 8AWQ의 패킹 방식에서 유래: 각 int32에 4-bit 가중치 8개를 저장.

네 번째 단계,awq_dequantize유사한 경로를 따르지만📎 vllm/_custom_ops.py:553-559, fake 구현의 shape 추론은 다름:out_c = qout_c * 8, 역양자화 후 열 수가 8배 확장되기 때문📎 vllm/_custom_ops.py:587-592。

Marlin 시리즈의 repack 함수는 또 다른 패턴을 보여준다.gptq_marlin_repack의 fake 구현은 다음을 계산하고pack_factor = 32 // num_bits, 출력 shape는(size_k // 16, size_n * 16 // pack_factor) 📎 vllm/_custom_ops.py:1103-1119. 여기서16는 Marlin tile size이고,size_k // 16는 K 차원이 tile 단위로 분할됨을 나타낸다. MoE 버전의gptq_marlin_moe_repack는 Python 레벨에서 각 expert를 순회하며 단일 expert의 repack을 호출하고📎 vllm/_custom_ops.py:1154-1172, 다음을 단언한다size_k % 16 == 0——이는 Marlin 포맷의 강제 제약이다.

mermaid
flowchart LR
    input["input: torch.Tensor (FP16/BF16)"]
    qweight["qweight: torch.Tensor (INT32 packed)"]
    scales["scales: torch.Tensor"]
    qzeros["qzeros: torch.Tensor"]
    check_env{"VLLM_USE_TRITON_AWQ?"}
    triton_path["awq_gemm_triton(input, qweight, scales, qzeros, split_k_iters)"]
    cuda_path["torch.ops._C.awq_gemm(...)"]
    output["output: torch.Tensor (FP16/BF16)"]

    input --> check_env
    qweight --> check_env
    scales --> check_env
    qzeros --> check_env
    check_env -->|是| triton_path
    check_env -->|否| cuda_path
    triton_path --> output
    cuda_path --> output

설계 고찰과 함정

fake 구현은 반드시 실제 연산자의 출력 shape와 완전히 일치해야 한다. 그렇지 않으면torch.compile가 추적한 그래프가 런타임에 shape 불일치를 일으킨다.create_fp4_output_tensors의 주석은 특히 "Must match the C++ scaled_fp4_quant_func allocation exactly when padded_n is None"📎 vllm/_custom_ops.py:69-74을 강조한다. 이는 실수하기 쉬운 지점이다: C++ 측이 할당 로직을 바꿨는데 fake가 동기화하지 않으면, 컴파일된 그래프가 CUDA Graph 재생 시 크래시한다.

또 다른 함정은torch.library.custom_op의 앨리어싱 규칙이다.safeFusedQuantizeNv의 주석은 torch 2.12+에서 커스텀 연산자의 출력이 어떤 입력도 앨리어싱할 수 없다고 지적하며, 따라서 저자는 반환 텐서를 in-place 파라미터로 변경했다📎 vllm/_custom_ops.py:4650-4655. 이렇게 "프레임워크 제한을 우회하기 위해 API 형태를 바꾸는" 방식은 연산자 적응 계층에서 흔하며, 디버깅 시mutates_args선언이 실제 동작과 일치하는지 주의해야 한다.

CPUDNNLGEMMHandler는 또 다른 리소스 관리 패턴을 보여준다: handler 포인터를 int64 tensor에 저장하고,__del__시release_dnnl_matmul_handler를 호출해📎 vllm/_custom_ops.py:3708-3717를 해제한다. 포인터를 tensor에 저장하는 것은 Python의 정수 인라인 최적화로 제거되는 것을 방지하기 위함이다——이는 저수준 바인딩의 고전적 기법이다.

11.3 Triton 커널 디스패치:KernelOverride와 크로스 모듈 리바인딩

직관적 모델

Triton 커널 디스패처의 역할은 회사의 직무 대체 시스템과 같다. 어떤 플랫폼(예: ROCm)이 vLLM 코어의 Triton 커널을 자체 구현으로 교체해야 할 때, 코어 코드를 직접 수정할 수 없다——그러면 업스트림이 오염된다.dispatcher는 플랫폼이 대체 구현을 등록하고, 원래 커널을 가리키는 모든 참조를 조용히 대체 구현으로 바꾸도록 허용한다. 이런 메커니즘이 없으면 각 플랫폼이 fork를 유지해야 하고, 업스트림 변경을 병합할 때 충돌이 끊이지 않는다.

데이터 구조와 메모리 레이아웃

핵심 데이터 구조는_registry딕셔너리와KernelOverride클래스📎 vllm/triton_utils/dispatcher.py:29-36。

KernelOverride의 주요 필드📎 vllm/triton_utils/dispatcher.py:50-61:

  • _impl: 플랫폼 구현 함수;
  • arg_names: 원래 커널의 파라미터 이름 튜플을 미러링하며, launch 시 키워드 바인딩에 사용;
  • constexprs: 원래 커널에서 상속된 constexpr 선언;
  • func: 구현 함수를 가리키며, warmup 내성에 제공;
  • _forward_by_name: 불리언 플래그로, launch 시 키워드로 전달할지 위치로 전달할지 결정.

_forward_by_name의 계산 로직은: 비교inspect.signature(impl).parameters와 원래 커널의arg_names이 완전히 동일한지📎 vllm/triton_utils/dispatcher.py:50-61. 동일하면 구현의 파라미터 이름이 커널과 일치하므로 안전하게 키워드로 전달 가능; 그렇지 않으면 원래 커널의 파라미터 순서대로 위치 전달해야 한다.

Step-by-Step:register_kernels의 리바인딩 한 번

시나리오 대입: ROCm 플랫폼이 초기화 시register_kernels({"vllm.v1.sample.rejection_sampler.expand_kernel": my_expand_impl})。

를 호출register_kernels첫 번째 단계,_resolve_kernel 📎 vllm/triton_utils/dispatcher.py:162-166。_resolve_kernel가 overrides를 순회하며 각 이름에 대해.를 호출해 이름을 마지막📎 vllm/triton_utils/dispatcher.py:83-94기준으로 모듈명과 속성명으로 분리getattr. 모듈명의 마지막 세그먼트 첫 글자가 대문자면 커널이 어떤 클래스(JIT warmup owner)에 속하므로, 부모 모듈을 먼저 import한 후(类, 属性名)로 클래스를 얻어(模块, 属性名)。

를 반환; 그렇지 않으면 모듈 자체를 import해KernelOverride를 반환_registry 📎 vllm/triton_utils/dispatcher.py:167-169。

두 번째 단계, 원래 커널 객체를 얻은 후_rebind_kernels래퍼를 생성하고📎 vllm/triton_utils/dispatcher.py:97-144에 기록sys.modules세 번째 단계,__dict__가 전체 모듈 스캔을 수행is. 이는==내 모든 모듈의PlaceholderModule를 순회하며 각 속성 값에 대해 아이덴티티 비교 수행——주의:📎 vllm/triton_utils/dispatcher.py:116-123。

가 아니라setattr, 일부 속성 값(예:📎 vllm/triton_utils/dispatcher.py:125-135센티넬)이 hash/eq 시 import나 예외를 트리거하기 때문kernel네 번째 단계, 원래 커널과 매칭된 속성에 대해서는 직접value.kernel를 wrapper로 교체_kernel_arg_names. JIT warmup owner(인스턴스 속성📎 vllm/triton_utils/dispatcher.py:138-139。

이 원래 커널을 가리키는 객체)에 대해서는_rebind_kernels를 교체하고 캐시된📎 vllm/triton_utils/dispatcher.py:170-174를 제거해, launch 바인딩이 wrapper에서 다시 추론되도록 함📎 vllm/triton_utils/dispatcher.py:170-171。

mermaid
sequenceDiagram
    participant Platform as "ROCm 平台"
    participant Dispatcher as "register_kernels"
    participant Resolver as "_resolve_kernel"
    participant Scanner as "_rebind_kernels"
    participant Modules as "sys.modules"

    Platform->>Dispatcher: register_kernels({"vllm...expand_kernel": my_impl})
    Dispatcher->>Resolver: _resolve_kernel("vllm...expand_kernel")
    Resolver-->>Dispatcher: (module, "expand_kernel")
    Dispatcher->>Dispatcher: KernelOverride(original, my_impl)
    Dispatcher->>Scanner: _rebind_kernels([(original, wrapper)])
    Scanner->>Modules: 遍历所有模块 __dict__
    Modules-->>Scanner: 属性值列表
    Scanner->>Scanner: lookup(value) 身份比较
    Scanner->>Modules: setattr(module, attr, wrapper)
    Scanner->>Modules: value.kernel = wrapper (JIT owner)
    Scanner-->>Dispatcher: 重绑定完成
    Dispatcher->>Modules: setattr(host, attr, wrapper)
    Dispatcher-->>Platform: 注册完成

완료 후에야 정의 위치의 속성도 wrapper로 교체

KernelOverride.__getitem__. 주석은 순서의 중요성을 설명한다: 정의 위치를 먼저 교체하면 스캔 시 원래 커널을 찾을 수 없다self._launch복사kernel[grid](**kwargs)설계 고찰과 함정📎 vllm/triton_utils/dispatcher.py:63-74。_launch가📎 vllm/triton_utils/dispatcher.py:63-74를 반환하므로,_forward_by_name같은 Triton 표준 launch 문법이 wrapper에 투명하게 작동RuntimeError의 전달 로직은 세 가지 경우로 나뉜다

: 위치 인자가 있으면 직접 통과;RuntimeError중요한 방어책이다: 플랫폼 구현의 파라미터 이름이 커널과 일치하지 않고, 호출자가 구현이 인식하지 못하는 파라미터를 전달했을 때, 조용히 무시하면 추적하기 어려운 잘못된 결과를 초래한다. 명시적 오류 발생은 문제를 등록 단계에서 바로 드러나게 한다.

프로덕션 환경의 함정:_rebind_kernels의 스캔은 O(모듈 수 × 속성 수 × 커널 수)이다. 대형 모델의 경우,sys.modules에는 수천 개의 모듈이 있을 수 있고, 각 모듈에는 수백 개의 속성이 있다. 초기화 시에만 한 번 실행되지만, 등록된 커널이 많으면 시작 시간이 눈에 띄게 증가한다.lookup함수는 해시 조회 대신 선형 스캔을 사용하며, 주석에 그 이유를 설명한다 — 일부 속성 값은 해시할 수 없다📎 vllm/triton_utils/dispatcher.py:116-123. 이는 전형적인 "정확성 우선, 성능 차선"의 트레이드오프이다.

또 다른 함정:_resolve_kernel은 "모듈 이름 마지막 세그먼트의 첫 글자 대문자"로 클래스 속성인지 판단한다📎 vllm/triton_utils/dispatcher.py:83-94. 만약 어떤 모듈 이름이 우연히 대문자로 시작하면(Python 명명 관례에 맞지 않지만 문법적으로는 유효), 클래스로 오판된다. 이는 관례 우선 설계로, vLLM 내부의 명명 규칙에 의존한다.

설계 고찰

양자화 설정과 연산자 등록이라는 두 계층 메커니즘이 함께 vLLM의 "정밀도-성능" 조절면을 구성한다.QuantizationConfigArgs의 설계는 "사용자 의도"와 "메서드 기본값"의 분리를 보여준다:None은 "양자화하지 않음"이 아니라 "메서드 클래스가 스스로 결정하게 함"이다. 이러한 지연 결정 덕분에 동일한 설정이 checkpoint 양자화와 온라인 양자화 두 시나리오에 모두 적용될 수 있다.

_custom_ops의 fake 구현 패턴은torch.compile생태계의 표준이지만, vLLM의 독특한 점은hasattr가드의 보편적 사용이다. 이 덕분에 동일한 모듈을 CUDA, ROCm, CPU, XPU에서 임포트해도 크래시하지 않으며, 대가는 각 연산자마다 세 곳의 코드가 필요하다는 것이다: Python 래퍼, fake 구현, 그리고 플랫폼 가드.

Triton dispatcher의 크로스 모듈 재바인딩은 공격적인 방식이다. Python의 임포트 훅이나__getattr__에 의존하지 않고, 모든 참조를 직접 스캔하여 교체한다. 이 방식의 장점은 철저함이다 — 커널이from mod import kernel복사된 곳이 얼마나 많든 교체될 수 있다; 단점은 취약함이다 — 커널 참조를 보유하는 새로운 방식(예: 클로저 캡처)은 스캔을 피할 수 있다.

이 장 요약

이 장 생각해보기와 자가 점검

Q1:resolve_quantization_config에서 만약_DEFERRED_ONLINE_SHORTHANDS분기를 제거하면(즉,quantization in _DEFERRED_ONLINE_SHORTHANDS일 때base대신None을 반환), checkpoint에 자체quant_method: "mxfp4"이 있는 모델을 로드하고 사용자가--quantization mxfp4만 전달할 때 무슨 일이 발생하는가?

참고 해석:_DEFERRED_ONLINE_SHORTHANDS의 설계 의도는 checkpoint 양자화 메서드를 우선하게 하는 것이다📎 vllm/config/quantization.py:233-235. 이 분기를 제거하면,mxfp4이_ONLINE_SHORTHANDS에 매칭되어base을 반환한다(즉,QuantSpec(weight=kMxfp4Static))📎 vllm/config/quantization.py:198-210. 이때 온라인 양자화 설정이 checkpoint의 양자화 메서드를 덮어쓰게 되는데, checkpoint의 가중치는mxfp4형식으로 저장되어 있다 — 만약 온라인 설정의kMxfp4Static이 checkpoint의 실제 형식과 완전히 일치하지 않으면(예: scale 레이아웃이 다름), 가중치 로딩이 실패하거나 잘못된 결과를 낳는다. 더 은밀한 경우는: checkpoint의mxfp4이 다른 group size나 scale dtype을 사용했을 수 있고, 온라인 설정의 기본값이 이와 맞지 않아 추론 정밀도가 저하되지만 오류는 발생하지 않는다.

Q2: KernelOverride._launch에서 만약_forward_by_name이False이고 호출자가 전달한 kwargs에 원래 커널이 인식하지 못하는 파라미터 이름이 포함되어 있으면, 코드는RuntimeError을 발생시킨다. 이 검사를 제거하고 알 수 없는 파라미터를 조용히 무시하도록 바꾸면, 어떤 시나리오에서 추적하기 어려운 문제가 발생하는가?

참고 해석:_forward_by_name이False이라는 것은 플랫폼 구현의 파라미터 이름이 원래 커널과 일치하지 않아 위치 기반으로 전달해야 함을 의미한다📎 vllm/triton_utils/dispatcher.py:50-61. 만약 호출자가 원래 커널이 인식하지 못하는 파라미터를 전달하면(예: 업스트림에서 선택적 파라미터를 새로 추가), 조용히 무시하면 해당 파라미터의 값이 유실된다. Triton 커널 시나리오에서 이는 보통 어떤 constexpr이나 grid 차원이 전달되지 않았다는 뜻이며, 커널이 기본값으로 시작될 수 있다 — 결과는 크래시가 아닌 잘못된 계산 결과일 수 있다. Triton 커널의 잘못된 결과는 종종 예외가 아닌 수치 편차로 나타나므로 추적 난이도가 극히 높다. 명시적RuntimeError은 문제를 첫 launch 시에 드러나게 한다📎 vllm/triton_utils/dispatcher.py:63-74。

Q3: _rebind_kernelsJIT warmup owner의kernel속성을 교체한 후,value.__dict__.pop("_kernel_arg_names", None)을 실행한다. 이 줄을 제거하면 어떤 경우에 launch 바인딩 오류가 발생하는가?

참고 해석: JIT warmup owner는_kernel_arg_names을 캐시하여 launch 시 kwargs를 커널 파라미터에 바인딩한다📎 vllm/triton_utils/dispatcher.py:138-139.kernel을 wrapper로 교체한 후, wrapper의arg_names이 원래 커널과 다를 수 있다(플랫폼 구현의 파라미터 이름이 다르면, wrapper의arg_names은 여전히 원래 커널을 미러링하지만,_forward_by_name은False일 수 있다). 캐시를 지우지 않으면, warmup 메커니즘이 계속 이전 파라미터 이름 목록으로 바인딩하는데, wrapper의 launch 로직은 다른 바인딩 방식을 기대할 수 있다. 구체적으로,KernelOverride._launch은_forward_by_name이False일 때self.arg_names순서로 값을 추출한다📎 vllm/triton_utils/dispatcher.py:79-80. 만약 캐시된_kernel_arg_names이 wrapper의arg_names과 일치하지 않으면, 추출된 파라미터 순서가 뒤섞여 커널이 잘못된 파라미터 값을 받게 된다.

다음 장에서는 고급 추론 기능으로 넘어가, 프리픽스 캐싱이 KV block을 어떻게 재사용하는지, 추측 디코딩이 소형 모델로 대형 모델을 어떻게 가속하는지, 그리고 LoRA가 기반 가중치를 변경하지 않고 어댑터를 동적으로 전환하는 방법을 살펴본다.

이 장에서는 vLLM 양자화와 사용자 정의 커널의 두 계층 인프라를 분석했다. 첫 번째 계층은 양자화 설정 파싱이다. QuantSpec과 QuantizationConfigArgs는 CLI 문자열, checkpoint 메타데이터, 레이어별 오버라이드를 QuantKey로 통일해 정규화하고, resolve_quantization_config는 축약형 확장과 필드 병합을 처리하며, _DEFERRED_ONLINE_SHORTHANDS는 이름 충돌 시나리오를 해결한다. 두 번째 계층은 연산자 적응이다. _custom_ops는 hasattr 가드와 register_fake를 통해 크로스 플랫폼 연산자 등록을 구현하고, fake 구현은 torch.compile을 지원하기 위해 실제 연산자의 출력 형태를 정확히 미러링한다. dispatcher는 KernelOverride와 전체 모듈 스캔을 통해 Triton 커널의 플랫폼 교체를 구현한다. 이 둘은 함께 가중치 로딩부터 전방 계산까지의 양자화 이득 실현을 뒷받침한다. 다음으로 처리량을 높이고 지연을 줄이는 고급 추론 기능으로 넘어간다. 자동 프리픽스 캐싱이 요청 간 KV를 어떻게 재사용하는지, 추측 디코딩이 드래프트 모델로 생성을 어떻게 가속하는지, 그리고 LoRA가 어댑터를 어떻게 동적으로 전환하는지 살펴본다.

CHAPTER 12

제12장: 고급 추론 기능: 프리픽스 캐싱, 추측 디코딩과 LoRA

소속 프로젝트: vllm-project/vllm · 전체 진행률: 제12 / 14장 · 검증 상태: FACT 행 번호 실제 앵커링

지난 장에서 우리는 vLLM의 양자화 체계와 사용자 정의 연산자 인프라를 깊이 살펴보았고, 양자화 설정이 어떻게 파싱되고 해당 kernel이 선택되는지, 그리고 FP8, INT4, AWQ, GPTQ 등의 방식이 가중치 로딩 시 어떻게 변환을 완료하는지 보았다. 동시에 _custom_ops가 CUDA 연산자를 어떻게 등록하는지, Triton 커널의 스케줄링 메커니즘, 그리고 MoE 융합 커널이 어떻게 메모리 왕복을 줄이는지도 확인했다. 이러한 저수준 능력은 더 고급 추론 최적화를 위한 길을 열어주었다. 이 장에서는 vLLM의 세 가지 고급 추론 기능인 자동 프리픽스 캐싱(APC), 추측 디코딩, LoRA에 집중한다. 이들은 겉보기에는 독립적이지만, 실제로는 동일한 저수준 인프라—KV block의 해시, 스케줄러의 slot 할당, 그리고 모델 실행 시의 동적 가중치 주입—를 공유한다. 이들을 이해하는 핵심은 PagedAttention 페이징 의미론을 훼손하지 않으면서 '재사용'을 극한까지 달성하는 방법을 이해하는 것이다.

12.1 프리픽스 캐싱: block hash가 프리픽스를 지문화하는 방법

직관적 모델

프리픽스 캐싱은 도서관의 '공용 문단 발췌본'과 같다. 두 학생이 작문을 쓰는데 서두에서 같은 고문을 인용한다면, 선생님은 이 고문 부분을 한 번만 첨삭하면 되고, 이후 각자 다른 부분만 따로 보면 된다. 이것이 없다면 모든 요청이 처음부터 전체 prompt를 prefill해야 하며, 긴 문서 질의응답 시나리오에서는 연산력이 여러 배로 중복 소모된다.

데이터 구조: token에서 block hash로의 매핑

프리픽스 캐싱의 핵심은 '두 요청의 프리픽스가 같은지 어떻게 판단하는가'이다. vLLM의 답은 token 시퀀스를 block 단위로 나누고, 각 block에 대해 체인 해시를 계산하는 것이다. 체인이라는 것은 N번째 block의 해시가 앞 N-1개 block의 해시를 포함한다는 의미이므로, 하나의 block hash는 '시퀀스 시작부터 해당 block 끝까지'의 전체 프리픽스를 고유하게 지문화한다.

해시의 담체는BlockHash이며, 이는bytes의NewType로 정의되고, 순수bytes가 아니며, 목적은 타입 수준에서 오용을 방지하는 것이다📎 vllm/v1/core/kv_cache_utils.py:59-62. block hash와 KV cache group id를 조합해 딕셔너리 키로 만들 때, vLLM은 튜플을 사용하지 않고 4바이트 빅엔디언 group id를 hash 바이트 끝에 직접 이어 붙인다📎 vllm/v1/core/kv_cache_utils.py:75-76:

python
def make_block_hash_with_group_id(block_hash, group_id):
    return BlockHashWithGroupId(block_hash + group_id.to_bytes(4, "big", signed=False))
〔설계 추론과 아키텍처 트레이드오프〕

이것은 전형적인 '튜플 할당 회피' 최적화이다. 핫 패스에서 각 block의 조회는 매번 키를 구성해야 하는데, 튜플은 추가적인 Python 객체 할당과 해시 오버헤드를 유발한다. 반면 바이트열 연결은 C 계층에서 완료되고, 바이트열 자체가 해시 가능하다. 되찾을 때는 슬라이싱key[:-4]과int.from_bytes(key[-4:])로 복원한다📎 vllm/v1/core/kv_cache_utils.py:87-89。

해시 함수 자체는hash_block_tokens가 담당하며, 부모 block hash, 현재 block의 token id 튜플, 그리고 추가 키를 함께 해시 함수에 넣는다📎 vllm/v1/core/kv_cache_utils.py:650-680. 첫 번째 block의 부모 해시는None가 아니라 전역NONE_HASH:

python
if not parent_block_hash:
    parent_block_hash = NONE_HASH

📎 vllm/v1/core/kv_cache_utils.py:674-675。NONE_HASH의 시드 선택에는 보안 설계가 숨어 있다. SHA-256 같은 암호학적 해시의 경우 시드는 고정"vllm-none-hash"이므로 서로 다른 vLLM 프로세스가 동일한 내용에 대해 같은 해시를 계산해 노드 간 프리픽스 캐시를 공유할 수 있다. 반면 xxhash 같은 비암호학적 해시의 경우 시드는 프로세스마다 무작위인데, 예측 가능한 시드는 공격자가 오프라인에서 충돌 block을 미리 계산할 수 있게 하기 때문이다📎 vllm/v1/core/kv_cache_utils.py:105-126。resolve_none_hash_seed이 분기를 구현한다:PYTHONHASHSEED환경 변수가 우선이고, 그렇지 않으면 암호학적 해시는 고정 시드를, 비암호학적 해시는os.urandom(32) 📎 vllm/v1/core/kv_cache_utils.py:132-145。

시나리오 기반: 한 요청의 block hash 계산

하나의 요청이 128개의 token을 가지고 들어오고, block size가 16이라고 가정하자.get_request_block_hasher반환된 클로저는 증분 계산을 담당한다📎 vllm/v1/core/kv_cache_utils.py:802-861:

첫 번째 단계, 어디서부터 계산을 시작할지 결정한다.start_token_idx = len(request.block_hashes) * hash_block_size 📎 vllm/v1/core/kv_cache_utils.py:812-812즉, 이미 계산된 block 수에 block 크기를 곱한 값이다. 남은 token이 하나의 block에 미치지 못하면 바로 빈 값을 반환한다📎 vllm/v1/core/kv_cache_utils.py:812-812。

두 번째 단계, 멀티모달 오프셋을 처리한다. 시작 위치가 어떤 멀티모달 입력 내부에 걸쳐 있다면,get_mm_features_in_window를 사용해 재배치해야 한다curr_mm_idx 📎 vllm/v1/core/kv_cache_utils.py:823-832. 이는 멀티모달 입력의 placeholder token 자체가 의미를 지니지 않기 때문에, mm 특징 식별자와 그것의 block 내 오프셋을 추가 키로 해시에 섞어 넣어야 하기 때문이다.

세 번째 단계, 각 block을 반복 계산한다.generate_block_hash_extra_keys모든 추가 키를 수집한다📎 vllm/v1/core/kv_cache_utils.py:611-647. LoRA 이름, 멀티모달 키, cache salt, prompt embeds 해시를 포함한다. 그중 cache salt는 첫 번째 block에서만 적용된다📎 vllm/v1/core/kv_cache_utils.py:633-635. 이는 의도된 것이다: salt의 역할은 전체 캐시 네임스페이스를 격리하는 것이므로, 체인의 시작점에서 한 번만 주입하면 된다.

네 번째 단계,hash_block_tokens부모 해시, token 튜플, 추가 키를 함께 해시하고, 그 결과를 다음 block의 부모 해시로 삼는다📎 vllm/v1/core/kv_cache_utils.py:851-857. 연쇄 구조가 이로써 형성된다.

다중 block size의 입도 변환

모델에 여러 KV cache group이 있고 block size가 서로 다를 때, 해시 입도와 group의 block 입도가 일치하지 않을 수 있다.BlockHashListWithBlockSize이 문제를 해결한다: 해시를 다시 계산하지 않고, 연쇄 해시의 성질을 이용한다 — target block의 해시는 그 내부의 마지막 hash block의 해시이다📎 vllm/v1/core/kv_cache_utils.py:2781-2851. 예를 들어 hash block이 16, target block이 32일 때, token 0-31의 해시는 두 번째 16-size 해시이다(그것이 이미 연쇄적으로 0-31을 커버한다)📎 vllm/v1/core/kv_cache_utils.py:2794-2806。_get_value_at의 구현은 바로self.block_hashes[(idx + 1) * self.scale_factor - 1] 📎 vllm/v1/core/kv_cache_utils.py:2848-2851。

mermaid
flowchart TD
    req["Request 到达"] --> check{"剩余 token >= hash_block_size?"}
    check -->|否| empty["返回空列表"]
    check -->|是| mm{"起始位置在多模态窗口内?"}
    mm -->|是| reloc["get_mm_features_in_window 重定位 curr_mm_idx"]
    mm -->|否| extra
    reloc --> extra["generate_block_hash_extra_keys 收集 LoRA/MM/salt/embeds 键"]
    extra --> hash["hash_block_tokens 链式哈希"]
    hash --> append["追加到 new_block_hashes"]
    append --> advance["start_token_idx += hash_block_size"]
    advance --> check

설계 고찰과 함정

왜 독립 해시가 아니라 연쇄 해시를 쓰는가?독립 해시는 「같은 block이 서로 다른 프리픽스 위치에 나타나는」 경우를 구분하지 못한다. 연쇄 해시는 block hash가 전체 프리픽스를 유일하게 지문화하게 만든다. 이것이 바로find_longest_cache_hit가 KV를 안전하게 재사용할 수 있는 전제이다.

비암호학적 해시의 프로세스 간 함정.xxhash를 사용하면서PYTHONHASHSEED를 설정하지 않으면, 각 프로세스의NONE_HASH가 달라져 인스턴스 간 프리픽스 캐시가 완전히 무효화된다.init_none_hash경고를 출력한다📎 vllm/v1/core/kv_cache_utils.py:161-169. 프로덕션 환경에서 여러 인스턴스가 캐시를 공유하도록 배포한다면, 반드시 명시적으로PYTHONHASHSEED를 설정하거나 sha256으로 바꿔야 한다.

멀티모달 오프셋의 미묘함. _gen_mm_extra_hash_keys를(mm_identifier, offset - start_token_idx)추가 키로 삼는다📎 vllm/v1/core/kv_cache_utils.py:552. 오프셋은 block 시작점을 기준으로 하므로, 같은 mm 항목이 서로 다른 block 위치에 나타날 때 해시가 달라져 잘못된 적중을 피한다.

12.2 투기적 디코딩: 초안과 검증의 협업

직관적 모델

투기적 디코딩은 비서가 먼저 상사를 대신해 몇 가지 답변 초안을 작성하고, 상사는 어느 버전을 쓸 수 있는지만 빠르게 골라내는 것과 같다. 초안 모델(drafter)은 매우 낮은 비용으로 여러 후보 token을 예측하고, 목표 모델(target)은 한 번의 전방 패스로 이 후보들을 병렬 검증하여 일치하는 부분을 받아들인다. 이것이 없다면 목표 모델은 token을 하나씩 직렬 생성할 수밖에 없어, decode 단계에서 GPU 활용률이 극히 낮다.

데이터 구조: EAGLE group의 표기

투기적 디코딩이 KV cache 관리에서 갖는 핵심 문제는: 초안 모델의 KV 레이어와 목표 모델의 KV 레이어를 어떻게 그룹화할 것인가?_annotate_eagle_groups두 가지 규칙으로 초안 그룹을 식별한다📎 vllm/v1/core/kv_cache_utils.py:2134-2189:

규칙 1은 spec 기반이다:non_causal_multi_token_decode플래그는MLAAttentionSpec에 선언되며, 비인과적 다중 token decode를 실행하는 초안 어텐션 레이어에 의해 설정되고,merge연산을 거쳐도 살아남을 수 있다📎 vllm/v1/core/kv_cache_utils.py:2175-2177。

규칙 2는 위치 폴백이다: MTP 초안기(예: DeepseekV4/V4.1 DSpark)는 목표 모델 자신의 decoder 레이어를 재사용하며, spec에 표시가 없지만 그들의 초안 어텐션 레이어는 항상 모든 목표 레이어 뒤에 등록되므로, 마지막으로 등록된 레이어를 보유한 group에 표기를 부여한다📎 vllm/v1/core/kv_cache_utils.py:2183-2184. 이 규칙은 group이 정확히kv_cache_spec의 모든 레이어를 분할했을 때만 유효하다📎 vllm/v1/core/kv_cache_utils.py:2183-2184。

시나리오 기반: 투기적 디코딩의 KV 할당

가speculative_config활성화되고use_eagle_block_drop()가 참일 때,_annotate_eagle_groups가 호출된다📎 vllm/v1/core/kv_cache_utils.py:2175-2177. 표기 결과is_eagle_group는 이후의 block 할당 전략에 영향을 준다 — 초안 그룹의 block은 검증 후 폐기될 수 있다.

의get_kv_cache_groups주 경로에서 표기는 그룹화 이후에 일어난다📎 vllm/v1/core/kv_cache_utils.py:2364-2365. 어떤 group도 초안 그룹으로 표기되지 않으면,_warn_if_unannotated_eagle_mamba경고를 발생시킨다📎 vllm/v1/core/kv_cache_utils.py:2192-2222。

mermaid
sequenceDiagram
    participant Sched as Scheduler
    participant Drafter as 草稿模型
    participant Target as 目标模型
    participant KV as KV Cache Manager
    Sched->>Drafter: 请求生成 k 个候选 token
    Drafter->>KV: 分配草稿组 block (is_eagle_group=True)
    Drafter-->>Sched: 返回候选 token 序列
    Sched->>Target: 并行验证候选 (一次前向)
    Target->>KV: 读取目标组 block
    Target-->>Sched: 返回接受/拒绝掩码
    Sched->>KV: 丢弃被拒绝的草稿 block

설계 고찰과 함정

왜 초안 그룹은 별도로 표기해야 하는가?초안 모델이 생성한 token은 검증 후 거부될 수 있고, 대응하는 KV는 폐기해야 한다. 초안 KV가 목표 KV와 같은 group에 섞여 있으면, 폐기 연산이 목표 KV를 잘못 건드리게 된다. 표기를 통해 스케줄러가 정확히 회수할 수 있다.

위치 폴백 규칙의 취약성.규칙 2는 「초안 레이어가 마지막에 등록된다」는 관례에 의존하며, 주석에는 이것이 hacky check임을 명확히 표시하고 FIXME를 남겨 두었다📎 vllm/v1/core/kv_cache_utils.py:2158-2159. 초안의 꼬리 캐시가 여러 group에 걸쳐 있을 때, 이 규칙은 마지막 레이어를 보유한 group만 표기하므로 일반화가 필요하다.

Mamba 모델의 추가 제약.투기적 디코딩이 활성화되었지만 초안 그룹으로 인식된 group이 없고 Mamba group이 존재하면 경고가 발생합니다📎 vllm/v1/core/kv_cache_utils.py:2211-2213. 이는 일반적으로 초안 레이어의 spec과 대상 레이어를 구분할 수 없음을 의미하며, 모델 등록 순서를 확인해야 합니다.

12.3 LoRA: 베이스를 재로드하지 않는 동적 어댑터

직관적 모델

LoRA는 같은 휴대폰에 다른 케이스를 씌우는 것과 같습니다: 휴대폰 본체(베이스 모델)는 변하지 않고, 케이스(어댑터)만 바꾸면 다른 스타일이 됩니다. 이것이 없다면 각 미세 조정 작업마다 전체 가중치를 로드해야 하므로 VRAM이 감당할 수 없습니다.

데이터 구조: 이중 LRU 캐시와 slot 배열

LoRAModelManager두 개의 LRU 캐시로 어댑터 수명 주기를 관리합니다📎 vllm/lora/model_manager.py:115-120:

python
self._registered_adapters: AdapterLRUCache[LoRAModel] = AdapterLRUCache(
    self.capacity, self.deactivate_adapter
)
self._active_adapters: AdapterLRUCache[None] = AdapterLRUCache(
    self.lora_slots, self._deactivate_adapter
)

capacity는 CPU 측에서 캐시할 수 있는 어댑터의 총 수입니다(max_cpu_loras)📎 vllm/lora/model_manager.py:340-342,lora_slots는 GPU 측에서 동시에 활성화할 수 있는 어댑터 수입니다(max_loras)📎 vllm/lora/model_manager.py:345-346。_registered_adapters가 제거될 때deactivate_adapter콜백이 트리거됩니다📎 vllm/lora/model_manager.py:71-74, CPU 캐시에서 제거될 때 GPU의 복사본도 정리되도록 보장합니다.

lora_index_to_id는 길이가lora_slots인 배열로, GPU slot 인덱스를 어댑터 id에 매핑합니다📎 vllm/lora/model_manager.py:122. 이 배열은 punica wrapper가 배치 LoRA 계산을 할 때의 핵심 인덱스입니다.

시나리오 기반: 어댑터 활성화

요청이 LoRA 어댑터를 가지고 들어오면,activate_adapter가 호출됩니다📎 vllm/lora/model_manager.py:352-409:

첫 번째 단계, 이미 활성화되었는지 확인하고, 그렇다면 바로 반환합니다📎 vllm/lora/model_manager.py:352-354。

두 번째 단계, 유휴 slot을 찾습니다.lora_index_to_id를 순회하여 첫 번째None 📎 vllm/lora/model_manager.py:362-362를 찾습니다. 유휴 slot이 없으면ValueError("No free lora slots") 📎 vllm/lora/model_manager.py:368-368。

를 발생시킵니다module.set_lora(index, lora_a, lora_b)세 번째 단계, 상태를 업데이트하고 모든 래핑된 모듈을 순회하며📎 vllm/lora/model_manager.py:377-401를 호출하여 가중치를 GPU의 stacked buffer에 복사합니다reset_lora(index). 특정 모듈에 대응하는 LoRA 가중치가 없으면📎 vllm/lora/model_manager.py:378-385。

를 호출하여 0으로 초기화합니다📎 vllm/lora/model_manager.py:411-416네 번째 단계, 적용된 가중치가 없으면 일회성 디버그 로그를 출력합니다

. 이는 파이프라인 병렬 또는 전문가 병렬에서 예상되는 동작입니다——일부 rank는 어댑트된 레이어를 보유하지 않습니다.

_create_lora_modules모듈 래핑: nn.Linear에서 BaseLayerWithLoRA로📎 vllm/lora/model_manager.py:462-606모델의 모든 명명된 모듈을 순회합니다

  • . 핵심 로직:PPMissingLayer 📎 vllm/lora/model_manager.py:473-474。
  • 를 건너뜁니다target_modules에 따라 필터링합니다: 지정되지 않으면is_supported_lora_module로 판단하고, 그렇지 않으면_match_target_modules 📎 vllm/lora/model_manager.py:479-493。
  • 로 별칭 모듈을 처리합니다: 동일한 기본 모듈이 여러 경로를 통해 접근될 수 있습니다(예: MoE gate가 block에도 있고 runner 내에도 있음). 이때 별칭 속성을 동일한 wrapper로 리디렉션하지만 중복 등록하지 않습니다. 그렇지 않으면activate_adapter가 별칭에 대해reset_lora를 호출하여 방금 설정한 가중치를 지웁니다📎 vllm/lora/model_manager.py:512-527。
  • 로from_layer를 생성하여 wrapper로 원래 모듈을 교체합니다📎 vllm/lora/model_manager.py:546-553。

설계 고민과 함정

slot 레이아웃 변경이 매핑 업데이트를 트리거합니다. set_adapter_mapping는 mapping이 변경되었는지 비교할 뿐만 아니라lora_index_to_id의 튜플 스냅샷도 비교합니다📎 vllm/lora/model_manager.py:1323-1331. 이유는 주석에 명확히 설명되어 있습니다: 대역 외add_lora()가 LRU 제거를 트리거하고 slot을 재할당할 수 있지만, 실행 중인 batch와 그 mapping은 변경되지 않습니다📎 vllm/lora/model_manager.py:1323-1331. mapping만 보면 punica metadata가 만료된 slot 레이아웃을 사용하게 됩니다.

MoE의 EP 슬라이싱.전문가 병렬이 활성화되면 checkpoint는 모든 전역 전문가의 가중치를 보유하지만, 각 rank는local_num_experts개만 소유합니다._stack_moe_lora_weights먼저global_num_experts로 reshape한 후 슬라이스합니다[expert_start:expert_end] 📎 vllm/lora/model_manager.py:966-977. 비 EP일 때 슬라이스는 no-op입니다.

pin_memory의 타이밍.가중치 패킹(예:pack_moe)이 pin_memory 할당을 무효화할 수 있으므로, pin_memory는 모든 가중치 병합 이후에 실행됩니다📎 vllm/lora/model_manager.py:916-934. 주석은 두 가지 이유를 명확히 지적합니다: MoE 모델의 LoRA 가중치 수가 많아 너무 이른 pin은 오버헤드가 상당하며; 패킹이 할당을 무효화할 수 있습니다📎 vllm/lora/model_manager.py:916-921。

설계 고민: 세 가지의 협력 지점

세 가지 기능이 KV cache 관리 계층에서 교차합니다. 프리픽스 캐싱은 block hash를 통해 KV를 재사용하고; 투기적 디코딩은is_eagle_group로 초안 KV를 구분하며; LoRA는_gen_lora_extra_hash_keys로 어댑터 이름을 block hash에 섞어📎 vllm/v1/core/kv_cache_utils.py:568-581, 서로 다른 어댑터의 동일한 토큰 시퀀스가 서로의 KV를 잘못 히트하지 않도록 보장합니다.

generate_block_hash_extra_keys는 LoRA 키를 추가 키 목록의 맨 앞에 둡니다📎 vllm/v1/core/kv_cache_utils.py:640-642, 멀티모달 키, cache salt, prompt embeds 키와 함께 완전한 해시 입력을 구성합니다. 이는 다음을 보장합니다: 두 요청의 토큰이 완전히 동일하더라도 LoRA 어댑터가 다르면 block hash가 달라져 KV가 섞이지 않습니다.

이 장 요약

이 장 고민과 자가 테스트

Q1: 만약init_none_hash에서 비암호학적 해시의 랜덤 시드 로직을 제거하고 항상 고정 시드를 사용하도록 변경하면, 어떤 시나리오에서 보안 위험이 발생합니까? 왜 소스 주석은 xxhash에 비밀 시드가 필요하다고 특별히 강조합니까?

참고 해석: 소스는_NON_CRYPTO_HASH_FUNCTIONS에서 xxhash와 xxhash_cbor를 비충돌 저항 알고리즘으로 명시하고📎 vllm/v1/core/kv_cache_utils.py:125-126。resolve_none_hash_seed이러한 알고리즘에 대해os.urandom(32).hex() 📎 vllm/v1/core/kv_cache_utils.py:143-144를 반환합니다. 고정 시드로 변경하면 공격자가 오프라인에서 대상 프리픽스와 충돌하는 block을 미리 계산하여 해시는 같지만 내용이 다른 요청을 구성할 수 있고, 이를 통해 타인의 KV cache를 히트하여 읽을 수 있습니다——이는 요청 간 정보 유출입니다. SHA-256의 충돌 저항은 시드 비밀성에 의존하지 않으므로 고정 시드는 재현성에만 영향을 미치고 보안에는 영향을 미치지 않습니다📎 vllm/v1/core/kv_cache_utils.py:97-111。

Q2: _create_lora_modules에서 별칭 모듈을 처리할 때, "중복 등록하지 않음" 로직을 제거하고 별칭에도 직접register_module를 호출하면,activate_adapter때 어떤 일이 발생하는가? 다음을 결합하여reset_lora의 호출 경로를 분석하라.

참고 해석:activate_adapter을 순회하고self.modules각 모듈에 대해set_lora또는reset_lora 📎 vllm/lora/model_manager.py:377-401을 호출한다. 별칭과 정규 이름이 모두 등록되면 동일한底层 wrapper가 두 번 접근된다. 정규 이름 경로에서는_get_lora_layer_weights가 가중치를 찾아set_lora쓰기를 호출할 수 있고, 별칭 경로에서는 이름 불일치로 인해_get_lora_layer_weights가 None을 반환하여reset_lora(index) 📎 vllm/lora/model_manager.py:378-385을 트리거하고, 방금 쓴 가중치를 0으로 만든다. 소스 주석은 이 함정을 명확히 지적한다📎 vllm/lora/model_manager.py:519-523. 올바른 방법은 별칭 속성을 동일한 wrapper로 리디렉션하되 중복 등록하지 않는 것이다📎 vllm/lora/model_manager.py:531-537。

Q3: BlockHashListWithBlockSize은 "target block의 해시가 내부 마지막 hash block의 해시와 같다"는 성질에 의존한다. 해시 함수가 체인 방식이 아니라면(즉, 각 block이 독립적으로 해시된다면) 이 클래스는 여전히 올바르게 작동할까? 어떤 경우에 잘못된 캐시 히트가 발생하는가?

참고 해석: 불가능하다._get_value_at은self.block_hashes[(idx + 1) * self.scale_factor - 1] 📎 vllm/v1/core/kv_cache_utils.py:2848-2851을 직접 반환하는데, 이 구현의 전제는 마지막 hash block의 해시가 이미 이전의 모든 토큰을 체인 방식으로 커버했다는 것이다. 해시가 독립적이라면 이 값은 마지막 hash block의 내용만 지문화할 뿐 전체 target block을 지문화하지 않는다. 두 target block은 앞부분이 다르지만 마지막 hash block이 같을 수 있어 해시 충돌이 발생하고,find_longest_cache_hit은 잘못된 KV를 재사용하게 된다. 소스 주석은 "Each hash_block_size hash is already chained over its entire prefix"라고 명확히 설명한다📎 vllm/v1/core/kv_cache_utils.py:2787-2792。

다음 장에서는 플러그인 시스템과 확장성으로 넘어가, vLLM이 플랫폼 추상화, IO 프로세서, 엔드포인트 확장을 통해 다양한 배포 형태를 어떻게 지원하는지 살펴본다.

이 장에서는 vLLM의 세 가지 고급 추론 기능의底层 메커니즘을 분석했다. 접두사 캐싱의 핵심은 체인 방식 block hash이다: hash_block_tokens는 부모 해시, 토큰 튜플, 추가 키를 함께 해시하며, NONE_HASH의 시드 전략은 프로세스 간 공유와 충돌 안전성 사이에서 균형을 잡는다. 투기적 디코딩은 is_eagle_group 주석으로 드래프트 KV 그룹을 구분한다. LoRA는 이중 LRU 캐시와 slot 배열로 어댑터 수명 주기를 관리하고, block hash에 어댑터 이름을 섞어 캐시 격리를 구현한다. 이러한 기능들은 vLLM이 추론 최적화에서 보여주는 깊이와 유연성을 함께 보여준다. 다음으로 vLLM의 플러그인 시스템과 확장성으로 넘어가, 플랫폼 플러그인이 새로운 하드웨어를 어떻게 적응시키는지, IO processor 플러그인이 멀티모달 입력 처리를 어떻게 개입하는지, 엔드포인트 플러그인이 사용자 정의 API 라우트를 어떻게 주입하는지 살펴본다. 플러그인 등록과 발견의 로딩 순서를 이해하면 핵심 코드를 수정하지 않고도 vLLM의 능력을 확장하는 방법을 알 수 있다.

CHAPTER 13

제13장: 플러그인 시스템과 확장성: 플랫폼, IO 프로세서, 엔드포인트 확장

소속 프로젝트: vllm-project/vllm · 전체 진행률: 제13 / 14장 · 검증 상태: FACT 행 번호 실제 앵커링

이전 장에서 우리는 접두사 캐싱, 투기적 디코딩, LoRA 같은 고급 기능들이 스케줄러, KV 관리, 모델 실행의 핵심 경로에 깊이 결합되어 있음을 보았다. 그러나 추론 엔진이 실제로 프로덕션에 나아가려면 성능만으로는 충분하지 않다. 더 까다로운 질문에 답해야 한다: 커뮤니티가 새로운 하드웨어, 새로운 멀티모달 입력 형식, 또는 사용자 정의 HTTP 라우트를 연결하려 할 때, 핵심 코드를 fork하지 않고 어떻게 완료할 수 있는가? 이것이 바로 플러그인 시스템이 존재하는 이유이다. vLLM의 아키텍처는 본질적으로 다중 프로세스이다: API Server 프론트엔드 프로세스, EngineCore 프로세스, 그리고 각 TP/PP rank에 대응하는 Worker 프로세스. 만약 플러그인 메커니즘이 단순히 "import 시 코드 한 조각을 실행"하는 것이라면, 그것은 각 프로세스에서 반복 실행되어 부작용이 누적되거나, 메인 프로세스에서만 실행되어 Worker가 확장을 받지 못하게 된다. 이 장에서 분석할 것은 vLLM이 Python 표준 entry_points 메커니즘을 사용하여 그룹(group) + 프로세스 경계 + 로딩 시점이라는 삼중 제약과 함께, 모든 프로세스를 커버하면서도 노출 면을 정밀하게 제어할 수 있는 플러그인 체계를 어떻게 구축했는가이다. 우리는 세 가지 주선에 집중한다: 플랫폼 플러그인(새 하드웨어 적응), IO processor 플러그인(멀티모달 입력 처리 개입), 엔드포인트 플러그인(사용자 정의 API 라우트 주입). 세 가지의 로딩 전략은 완전히 다르며, 이러한 차이를 이해하면 vLLM의 "확장 능력"과 "안전 경계"에 대한 균형 철학을 이해하게 된다.

1. 플러그인 발견과 로딩: entry_points의 그룹 계약

직관적 모델: 플러그인의 "방송 채널"

vLLM의 플러그인 시스템을 일련의 방송 채널이라고 상상해 보자. 각 플러그인 패키지는 설치 시setup.py의entry_points을 통해 특정 채널에 자신의 호출 부호(plugin name)와 응답 함수(plugin value)를 "등록"한다. vLLM은 시작 시 이러한 채널을 스캔하여 어떤 채널이 어떤 프로세스에서 "청취"될지 결정한다.

이 메커니즘이 없다면 vLLM 확장은 소스 코드 수정에만 의존해야 합니다——커뮤니티가 하드웨어를 하나 추가할 때마다 fork를 유지보수해야 하고, 결국 버전이 분열됩니다. 그룹 메커니즘의 가치는 다음과 같습니다:동일한 플러그인 패키지가 특정 채널에만 등록되어 특정 프로세스에서만 로드되도록 제한할 수 있습니다。

데이터 구조: 다섯 개의 그룹 상수와 전역 플래그

vLLM은vllm/plugins/__init__.py상단에 다섯 개의 entry point group 상수를 정의하며, 각 상수는 하나의 로딩 전략에 대응합니다:

📎 vllm/plugins/__init__.py:16-30

python
DEFAULT_PLUGINS_GROUP = "vllm.general_plugins"
IO_PROCESSOR_PLUGINS_GROUP = "vllm.io_processor_plugins"
PLATFORM_PLUGINS_GROUP = "vllm.platform_plugins"
STAT_LOGGER_PLUGINS_GROUP = "vllm.stat_logger_plugins"
ENDPOINT_PLUGINS_GROUP = "vllm.endpoint_plugins"

주석에 핵심 정보가 숨어 있습니다:DEFAULT_PLUGINS_GROUP에서모든 프로세스로드(process0, engine core, worker);IO_PROCESSOR_PLUGINS_GROUP process0에서만;PLATFORM_PLUGINS_GROUP모든 프로세스에서 로드되지만 트리거 시점은current_platform최초 접근 시;STAT_LOGGER_PLUGINS_GROUPprocess0에서만 그리고 비동기 모드에서만;ENDPOINT_PLUGINS_GROUPAPI Server 프론트엔드 프로세스에서만.

바로 뒤에는 모듈 수준 전역 변수plugins_loaded = False 📎 vllm/plugins/__init__.py:32-33가 있으며, 이것은 멱등 로딩의 가드입니다——주석에 명확히 "make sure one process only loads plugins once"라고 적혀 있습니다.

Step-by-Step: 한 번의load_plugins_by_group전체 호출 흐름

시나리오: 사용자가setup.py에vllm.general_plugins아래의register_dummy_model를 등록했고, 이제 vLLM이 시작되어 어떤 프로세스가load_general_plugins()。

를 호출합니다 load_general_plugins첫 번째 단계: 멱등 가드.plugins_loaded먼저True를 확인하고, 이미📎 vllm/plugins/__init__.py:77-90이면 바로를 반환합니다. 여기에는 미묘한 점이 있습니다: 가드가 로딩이전

에 설정되므로, 이후 로딩에서 예외가 발생해도 재시도하지 않습니다. 이는 의도된 것입니다——플러그인 로딩 실패가 프로세스의 반복 시도를 유발해서는 안 됩니다.두 번째 단계: 발견.load_plugins_by_group가importlib.metadata.entry_points(group=group)에 진입하여📎 vllm/plugins/__init__.py:36-45를 통해 해당 그룹 아래에 설치된 모든 entry points

를 가져옵니다. 비어 있으면 debug 로그를 기록한 후 빈 딕셔너리를 반환합니다.세 번째 단계: 로그 레벨 분류.is_default_group소스 코드는 기본 그룹과 비기본 그룹의 로그 레벨을 구분합니다:logger.debug가 참이면logger.info 📎 vllm/plugins/__init__.py:47-54를 사용하고, 그렇지 않으면vllm.general_plugins를 사용합니다. 동기는 매우 실용적입니다——

아래에는 보통 대량의 모델 등록 플러그인이 있어 INFO를 사용하면 화면이 도배됩니다; 반면 플랫폼/엔드포인트 플러그인은 수가 적고 중요하므로 INFO로 보일 가치가 있습니다.네 번째 단계: 화이트리스트 필터링.envs.VLLM_PLUGINS가None를 읽고,📎 vllm/plugins/__init__.py:62-70이면 전부 로드하고, 그렇지 않으면 이름이 목록에 있는 플러그인만 로드합니다plugin.load().📎 vllm/plugins/__init__.py:68-72。

가 try/except로 감싸져 있어 단일 플러그인 로딩 실패는 exception 로그만 기록하고 다른 플러그인에 영향을 주지 않습니다다섯 번째 단계: 실행.load_general_plugins가func() 📎 vllm/plugins/__init__.py:77-90로 돌아가서, 로드된 각 함수에 대해 직접를 호출합니다. 이것이 문서에서 플러그인 함수가 반드시재진입 가능(re-entrant)

해야 한다고 강조하는 이유입니다——여러 프로세스에서 여러 번 호출될 수 있습니다.load_plugins_by_group아래 흐름도는

mermaid
flowchart TD
    start["load_plugins_by_group(group)"] --> discover["entry_points(group=group)"]
    discover --> empty{"len(discovered) == 0?"}
    empty -->|是| ret_empty["返回 {}"]
    empty -->|否| log["按 is_default_group 选 log_level"]
    log --> loop["遍历 discovered_plugins"]
    loop --> check{"allowed_plugins is None<br/>或 plugin.name in allowed?"}
    check -->|否| skip["跳过该插件"]
    check -->|是| load["func = plugin.load()"]
    load --> load_ok{"加载成功?"}
    load_ok -->|否| log_exc["logger.exception 记录"]
    load_ok -->|是| add["plugins[name] = func"]
    skip --> next["下一个插件"]
    log_exc --> next
    add --> next
    next --> loop
    loop --> ret["返回 plugins 字典"]

복사

설계 고찰: 왜 설정 파일이 아니라 entry_points를 사용하는가

〔설계 추론과 아키텍처 트레이드오프〕entry_points사용자 정의 설정 파일이 아니라를 선택한 핵심 동기는플러그인이 Python 패키지와 함께 배포되도록 하기 위함pip install vllm-add-dummy-platform입니다. 사용자가

---

하면 플러그인이 자동으로 해당 그룹에 나타나며, vLLM 설정을 수동으로 편집할 필요가 없습니다. 이는 pytest, flake8 등의 도구 플러그인 생태계와 맥을 같이합니다. 대가는 플러그인 발견이 패키지 메타데이터에 의존한다는 점이며, 플러그인 패키지가 불완전하게 설치되면(예: pip를 거치지 않고 소스 디렉터리만 복사한 경우) entry_points를 스캔할 수 없습니다.

2. 플랫폼 플러그인: 하드웨어 적응의 추상화 계층

Platform직관적 모델: 플랫폼은 "하드웨어 방언 번역가"클래스는 전체 vLLM이 하드웨어와 대화하는유일한 번역가current_platform.get_attn_backend_cls()、current_platform.is_cuda_alike()입니다. 모델 코드는import torch.cuda같은 추상 메서드만 호출하고, 결코 직접if device == "xpu"하지 않습니다. 이 추상화 계층이 없다면 새로운 하드웨어를 지원할 때마다 모델 코드에

분기를 추가해야 하고, 결국 스파게티가 됩니다.

Platform데이터 구조: Platform 기반 클래스의 필드 레이아웃vllm/platforms/interface.py는 순수 클래스(인스턴스화하여 사용하지 않음)이며, 주요 클래스 속성은📎 vllm/platforms/interface.py:135-179:

python
class Platform:
    _enum: PlatformEnum
    device_name: str
    device_type: str
    dispatch_key: str = "CPU"
    ray_device_key: str = ""
    device_control_env_var: str = "VLLM_DEVICE_CONTROL_ENV_VAR_PLACEHOLDER"
    ray_noset_device_env_vars: list[str] = []
    simple_compile_backend: str = "inductor"
    dist_backend: str = ""
    supported_quantization: list[str] = []
    additional_env_vars: list[str] = []
    _global_graph_pool: Any | None = None

_enum복사PlatformEnum는is_cuda()、is_rocm()열거형 값으로,📎 vllm/platforms/interface.py:69-78。device_control_env_var등의 판정을 결정합니다CUDA_VISIBLE_DEVICES는 플랫폼 독립적인 "장치 가시성 환경 변수" 추상화입니다——CUDA는📎 vllm/platforms/interface.py:151-152。_global_graph_pool이고, 다른 플랫폼은 각자 정의합니다get_global_graph_pool는 클래스 수준의 CUDA graph 메모리 풀 캐시로,📎 vllm/platforms/interface.py:1210-1215。

를 통해 지연 초기화됩니다__getattr__주목할 점은📎 vllm/platforms/interface.py:1189-1208의 폴백 로직torch.<device_type>입니다: Platform에 존재하지 않는 속성에 접근하면current_platform.memory_allocated()네임스페이스에서 전달을 시도합니다. 이를 통해 플랫폼 코드는torch.cuda.memory_allocated()라고 작성하고 실제로는__getstate__를 호출할 수 있습니다. 하지만 소스 코드는 의도적으로 dunder 메서드를 제외합니다——그렇지 않으면 pickle 검사 시None가📎 vllm/platforms/interface.py:1182-1185。

를 가져와 호출하려고 시도할 것입니다

Step-by-Step: 장치 ID의 삼중 네임스페이스 변환플랫폼 추상화에서 가장 함정에 빠지기 쉬운 것은장치 ID 네임스페이스📎 vllm/platforms/interface.py:275-283:

  • logical입니다. 소스 코드 주석은 세 가지_assigned_physical_gpu_ids
  • visible를 명확히 나열합니다: vLLM 내부의 local rank,CUDA_VISIBLE_DEVICES를 인덱싱: 현재 프로세스가
  • physical로 재매핑된 후의 torch/CUDA 번호

: NVML 등 토폴로지 API가 사용하는 전역 GPU ID로, 환경 변수의 영향을 받지 않습니다[4, 5]시나리오: 하나의 Worker 프로세스에 물리 GPUCUDA_VISIBLE_DEVICES=4,5가 할당되고, 환경 변수torch.device("cuda:0")。

이며, 이제 local rank 0을 device_id_to_physical_device_id(0)로 변환해야 합니다_assigned_physical_gpu_ids첫 번째 단계: logical → physical.4 📎 vllm/platforms/interface.py:296-297먼저device_control_env_var를 조회하고, 이미 설정되어 있으면 바로 인덱싱하여📎 vllm/platforms/interface.py:305-311를 반환합니다. 설정되지 않았으면에서 쉼표 목록을 분리하여 0번째 항목을 가져옵니다. 소스 코드는 의도적으로📎 vllm/platforms/interface.py:296-297。

2단계: physical → visible. logical_device_id_to_visible_device_id(0)physical4을(를) 가져온 후, 환경 변수를 분해하여[4, 5]를 찾고,4의 인덱스0를 반환합니다📎 vllm/platforms/interface.py:316-339. 만약 physical ID가 가시 목록에 없으면RuntimeError을(를) 던집니다 — 이는 프로세스 간에 보이지 않는 디바이스를 잘못 사용하는 것을 방지하기 위한 강력한 보호 장치입니다.

set_assigned_physical_gpu_ids의 멱등 설계도 주목할 만합니다: 동일한 값을 반복 설정하면 무작동이고, 다른 값을 설정하면RuntimeError 📎 vllm/platforms/interface.py:38-56을(를) 던집니다. 이는 멀티스레드 환경에서 디바이스 매핑이 의도치 않게 덮어쓰이는 것을 방지합니다.

플랫폼 플러그인의 등록 및 구성 주입

플랫폼 플러그인은vllm.platform_plugins그룹으로 등록되며, 플러그인 함수는 플랫폼 클래스의 정규화된 이름(또는None는 현재 환경에서 지원되지 않음을 나타냄)을 반환합니다📎 docs/design/plugin_system.md:50-50. 문서에서 제시하는 최소 구현 요구 사항은📎 docs/design/plugin_system.md:100-100:

  • _enum보통PlatformEnum.OOT(out-of-tree)
  • device_type로 설정되어 PyTorch가 인식하는 디바이스 유형 문자열을 반환합니다
  • check_and_update_config는 vLLM 초기화 초기에 호출되며,반드시 여기에서 설정해야 합니다worker_cls
  • get_attn_backend_cls는 어텐션 백엔드 클래스 이름을 반환합니다
  • get_device_communicator_cls는 통신기 클래스 이름을 반환합니다

check_and_update_config는 플랫폼 플러그인의 가장 핵심적인 훅입니다📎 vllm/platforms/interface.py:583-592. 이는VllmConfig참조를 받아 제자리에서 수정하며, block size, graph mode 등을 조정할 수 있습니다. 문서에서는 "가장 중요한 것은 worker_cls를 여기에서 반드시 설정해야 한다"고 강조합니다📎 docs/design/plugin_system.md:105-105— vLLM이 작업 프로세스를 인스턴스화할 때 어떤 Worker 클래스를 사용할지 알아야 하기 때문입니다.

설계 고찰: block size 정렬의 3단계 전략

플랫폼 인터페이스에서 가장 복잡한 로직은update_block_size_for_backend 📎 vllm/platforms/interface.py:666-708입니다. 이는 세 단계로 나누어 block size가 어텐션 백엔드와 호환되도록 보장합니다:

Phase 1: 사용자가 명시적으로--block-size를 지정하지 않은 경우,_preferred_block_size_for_backends를 호출하여 모든 백엔드가 지원하는 최소 block size를 선택합니다📎 vllm/platforms/interface.py:687-697. 이 함수는 LCM(최소공배수)으로 후보 값을 열거하는데, 일부 백엔드(예: CPU_MLA)는 배수가 아닌 정확한 크기만 허용하기 때문입니다📎 vllm/platforms/interface.py:622-663。

Phase 2: 하이브리드 모델(attention + mamba)은 block과 mamba page size를 정렬해야 합니다📎 vllm/platforms/interface.py:699-702。

Phase 3: 여러 KV dtype이 block pool을 공유할 때(예: nvfp4 메인 + 비양자화 skip 레이어), 메인 block을 가장 큰 padded spec page를 커버할 수 있을 만큼 확장해야 합니다📎 vllm/platforms/interface.py:704-708。

〔설계 추론 및 아키텍처 트레이드오프〕

이러한 단계적 설계는 vLLM이 직면한 현실을 반영합니다: 서로 다른 하드웨어, 서로 다른 양자화 방식, 서로 다른 모델 아키텍처가 block size에 대해 서로 충돌하는 제약을 가지므로 단일 공식으로 해결할 수 없습니다. 단계적으로 나누면 각 제약을 독립적으로 처리하고, 최종적으로 모든 제약을 만족하는 해를 취합니다.

---

3. IO Processor와 엔드포인트 플러그인: 입력 처리 및 API 확장

직관적 모델: IO Processor는 "멀티모달 번역 계층"

멀티모달 모델(예: LLaVA)의 입력은 순수 텍스트가 아니라 텍스트 + 이미지의 혼합체입니다. IO Processor 플러그인은 원시 멀티모달 데이터를 모델이 소화할 수 있는 텐서로 변환하고, 모델 출력을 다시 사람이 읽을 수 있는 형식으로 변환합니다. 이는 세관의 통역사와 같습니다: 들어오는 외국어(이미지/오디오)를 모델의 모국어로 번역하고, 나가는 모델의 모국어를 다시 외국어로 번역합니다.

단계별: IO Processor의 발견 및 인스턴스화

시나리오 대입:io_processor_plugin필드를 가진 HF config의 모델을 로드합니다.

1단계: 플러그인 이름 결정. get_io_processor우선 명시적으로 전달된plugin_from_init를 사용하고, 그렇지 않으면hf_config의io_processor_plugin필드에서📎 vllm/plugins/io_processors/__init__.py:42-50를 읽습니다. 둘 다 비어 있으면None를 반환합니다 — 해당 모델에 IO processor가 필요하지 않음을 나타냅니다📎 vllm/plugins/io_processors/__init__.py:52-54。

2단계: 설치된 모든 플러그인 로드.를 호출하여load_plugins_by_group(IO_PROCESSOR_PLUGINS_GROUP)해당 그룹 아래의 모든 플러그인을 가져옵니다📎 vllm/plugins/io_processors/__init__.py:59-61。

3단계: 로드 가능 매핑 구성.각 플러그인을 순회하며 함수를 호출하여processor_cls_qualname를 가져오고,None가 아니면loadable_plugins 📎 vllm/plugins/io_processors/__init__.py:66-76에 기록합니다. 여기서 각 플러그인의 함수 호출도 try/except로 감싸져 있어 단일 실패가 다른 것에 영향을 주지 않습니다.

4단계: 검증 및 인스턴스화.로드 가능한 플러그인 수가 0이면ValueError를 던져 "IOProcessor 플러그인이 필요하지만 하나도 설치되지 않았습니다"를 알립니다📎 vllm/plugins/io_processors/__init__.py:66-76. 모델이 요구하는 플러그인 이름이 로드 가능 목록에 없으면ValueError를 던지고 사용 가능한 모든 플러그인 이름을 나열합니다📎 vllm/plugins/io_processors/__init__.py:80-81. 마지막으로resolve_obj_by_qualname를 통해 클래스 이름을 해석하고 인스턴스화합니다📎 vllm/plugins/io_processors/__init__.py:80-81。

엔드포인트 플러그인: 기본 거부의 보안 태세

엔드포인트 플러그인은 이 장에서 가장 특별한 유형인데, 그것이기본적으로 로드되지 않기。load_endpoint_plugins때문입니다. 문서 문자열은 그 이유를 명확히 설명합니다: 엔드포인트 플러그인은 API Server에 HTTP 라우트를 추가하여 네트워크 노출 면을 확대하므로,load_plugins_by_group보다 더 엄격한 "기본 거부" 태세를 취합니다📎 vllm/plugins/__init__.py:93-94。

구체적 규칙은: 플러그인 이름이에 명시적으로 나타나고VLLM_PLUGINS,그required_tasks가None이거나 서버가 지원하는 tasks와 교집합이 있을 때만 로드됩니다📎 vllm/plugins/__init__.py:108-108。

시나리오 대입: 사용자가 엔드포인트 플러그인을 설치했지만VLLM_PLUGINS。

설정을 잊었습니다1단계: VLLM_PLUGINS가 설정되지 않았는지 확인.envs.VLLM_PLUGINS is None만약📎 vllm/plugins/__init__.py:126-126이면, 먼저 해당 그룹 아래의 플러그인을 발견하고, 있으면 warning을 기록하여 "명시적 allowlist가 필요합니다"를 알립니다VLLM_PLUGINS="". 소스 주석에서 특히 지적하기를:[""]는None가 아니라📎 vllm/plugins/__init__.py:108-108로 해석되므로, "어떤 플러그인과도 매칭되지 않는 allowlist"로 간주되지 "미설정"이 아닙니다None. 이 경계 구분은 중요합니다 — 빈 문자열은 명시적인 "아무것도 로드하지 않음"이고,

는 "미구성"입니다.2단계: 로드 및 인스턴스화.load_plugins_by_group를 통해factory()팩토리 함수를 가져온 후, 하나씩 호출하여📎 vllm/plugins/__init__.py:133-141를 인스턴스화합니다

. 인스턴스화 실패는 exception을 기록하고 continue합니다.3단계: task 게이팅.plugin.required_tasks를 확인하여None가 아니고supported_tasks교집합이 없으므로 해당 플러그인을 건너뜁니다📎 vllm/plugins/__init__.py:144-145. 이를 통해 동일한 플러그인 패키지가 서로 다른 작업(예: embedding vs generation)에 대해 서로 다른 엔드포인트를 등록할 수 있습니다.

아래 시퀀스 다이어그램은 엔드포인트 플러그인의 발견부터 로딩까지의 전체 상호작용을 묘사합니다:

mermaid
sequenceDiagram
    participant App as "API Server 前端进程"
    participant Loader as "load_endpoint_plugins()"
    participant Env as "envs.VLLM_PLUGINS"
    participant EP as "entry_points(ENDPOINT_PLUGINS_GROUP)"
    participant Factory as "plugin factory()"

    App->>Loader: load_endpoint_plugins(supported_tasks)
    Loader->>Env: 读取 VLLM_PLUGINS
    alt VLLM_PLUGINS is None
        Loader->>EP: entry_points(group)
        EP-->>Loader: discovered plugins
        Loader-->>App: 返回 [] (记 warning)
    else VLLM_PLUGINS 已设置
        Loader->>EP: load_plugins_by_group(group)
        EP-->>Loader: factories 字典
        loop 每个 factory
            Loader->>Factory: factory()
            Factory-->>Loader: EndpointPlugin 实例
            Loader->>Loader: 检查 required_tasks 交集
            alt tasks 不匹配
                Loader->>Loader: 跳过 (记 info)
            else tasks 匹配
                Loader->>Loader: append 到结果列表
            end
        end
        Loader-->>App: 返回 endpoint_plugins 列表
    end

설계 고찰: 프로세스 경계가 로딩 전략을 결정한다

세 가지 플러그인 유형의 로딩 전략 차이는 본질적으로프로세스 경계의 매핑입니다:

플러그인 유형로딩 프로세스기본 동작동기
general모든 프로세스전부 로드모델 등록은 모든 Worker에서 가시적이어야 함
platform모든 프로세스전부 로드하드웨어 추상화는 모든 프로세스에 의존됨
io_processorprocess0만전부 로드입력 처리는 프론트엔드에서만 발생
stat_loggerprocess0만 (비동기)전부 로드로그는 메인 프로세스에서만 수집
endpointAPI Server만기본 거부네트워크 노출 면적을 확대하므로 명시적 권한 부여 필요
〔설계 추론 및 아키텍처 트레이드오프〕

엔드포인트 플러그인의 "기본 거부"는 보안 엔지니어링의 표준 관행입니다: 공격 면적을 확대하는 모든 확장은 opt-in이어야 합니다. 반면 다른 플러그인은 기본 로드되는데, 이는 네트워크 인터페이스를 직접 노출하지 않으며 커뮤니티 생태계가 저마찰 접근 경험을 필요로 하기 때문입니다.

프로덕션 함정: 플러그인 로딩 실패의 조용한 성능 저하

load_plugins_by_group각 플러그인의plugin.load()을 try/except로 감싸고, 실패 시 exception만 기록합니다📎 vllm/plugins/__init__.py:68-72. 이는 다음을 의미합니다:손상된 플러그인이 vLLM 시작을 막지 않지만, 명시적 오류도 제공하지 않습니다——사용자는 "왜 내 플러그인이 작동하지 않는가"에 혼란스러울 수 있습니다.

문제 해결 제안: 로그 레벨을 DEBUG로 조정하고"Failed to load plugin"을 검색하세요. 플러그인이vllm.general_plugins그룹 아래에 있다면 기본 로그 레벨은 DEBUG이므로, 로딩 세부 정보를 보려면 명시적으로 활성화해야 합니다📎 vllm/plugins/__init__.py:49-50。

또 다른 함정은plugins_loaded가드의 설정 시점입니다📎 vllm/plugins/__init__.py:77-90: 로딩 전에 이미 설정됩니다True. 첫 로딩이 어떤 이유로 실패하면(예: entry_points 스캔 예외), 이후 호출은 재시도 없이 바로 반환됩니다. 이는 테스트 환경에서 "플러그인이 될 때도 있고 안 될 때도 있는" 기이한 현상을 초래할 수 있습니다.

---

이 장의 요약

vLLM의 플러그인 시스템은 Pythonentry_points을 기반으로 하며,다섯 개의 그룹 상수로 확장 유형을 구분하고,프로세스 경계로 로딩 범위를 결정하며,VLLM_PLUGINS화이트리스트로 로딩 집합을 제어합니다. 플랫폼 플러그인은Platform기본 클래스로 하드웨어 차이를 추상화하며, 그 디바이스 ID 삼중 네임스페이스 변환(logical/visible/physical)은 크로스 프로세스 디바이스 관리의 핵심입니다; IO processor 플러그인은 HF config의io_processor_plugin필드로 트리거되어 멀티모달 입력 번역을 담당합니다; 엔드포인트 플러그인은 "기본 거부" 자세를 취하며, 명시적 allowlist가 있고 task가 일치할 때만 로드되어 네트워크 노출 면적을 제어합니다.

세 가지 주류는 동일한 발견 메커니즘을 공유하지만, 로딩 전략의 차이는 vLLM이 "확장 편의성"과 "보안 경계" 사이에서 균형을 잡는 것을 보여줍니다: 네트워크를 노출하지 않는 플러그인은 기본 로드되고, 네트워크를 노출하는 플러그인은 반드시 opt-in이어야 합니다.

이 장의 사고와 자가 점검

Q1: 만약load_plugins_by_group에서plugin.load()의 try/except를 제거하여 로딩 실패가 직접 예외를 발생시키면, vLLM의 멀티프로세스 시작에 어떤 영향을 미칠까요? 어떤 시나리오에서는 이것이 오히려 더 나은 설계일까요?
〔설계 추론 및 아키텍처 트레이드오프〕

참고 해석: 현재 구현은📎 vllm/plugins/__init__.py:68-72단일 플러그인 로딩 실패를 조용히 삼키고 exception 로그만 기록합니다. try/except를 제거하면 로딩 실패가load_general_plugins로 전파되어 프로세스 시작이 중단됩니다. 멀티프로세스 시나리오에서는 다음과 같은 결과가 발생합니다: 특정 Worker 프로세스의 플러그인 로딩이 실패하면 전체 엔진이 시작될 수 없습니다——이는 좋을 수도 있고(빠른 실패, 일부 프로세스가 문제를 안고 실행되어 상태 불일치를 초래하는 것을 방지), 나쁠 수도 있습니다(선택적 플러그인의 버그가 전체 서비스를 마비시킴). 더 나은 설계는VLLM_PLUGINS_STRICT환경 변수를 도입하는 것입니다: 기본은 관대(현재 동작), 엄격 모드에서는 로딩 실패 시 예외를 발생시킵니다. 이렇게 하면 프로덕션 환경에서는 "선언된 모든 플러그인이 성공적으로 로드되어야 함"을 요구할 수 있고, 개발 환경에서는 내결함성을 유지할 수 있습니다.

Q2: load_endpoint_plugins에서,VLLM_PLUGINS=""과VLLM_PLUGINS이 설정되지 않은(None) 경우의 동작 차이는 무엇인가요? 소스 코드가 왜 이 두 가지 경우를特意 구분하는 걸까요?
〔설계 추론 및 아키텍처 트레이드오프〕

참고 해석: 소스 코드 주석은VLLM_PLUGINS=""이[""]이 아닌None으로 해석되므로, "어떤 플러그인과도 매칭되지 않는 allowlist"📎 vllm/plugins/__init__.py:108-108로 간주된다고 명확히 밝힙니다.VLLM_PLUGINS is None일 때,load_endpoint_plugins은 바로[]을 반환하고 warning을 기록합니다📎 vllm/plugins/__init__.py:126-126; 반면VLLM_PLUGINS=""일 때, 코드는 계속load_plugins_by_group로 진행되지만, 빈 문자열이 어떤 플러그인 이름과도 매칭되지 않으므로 최종적으로도 빈 리스트를 반환합니다. 둘의결과는 동일하지만(둘 다 엔드포인트 플러그인을 로드하지 않음),의미는 다릅니다:None는 "사용자가 구성하지 않았으므로 우리가 주도적으로 거부하고 경고함"을,""는 "사용자가 명시적으로 빈 allowlist를 구성했으므로 우리가 그 의도를 존중하여 경고하지 않음"을 나타냅니다. 이러한 구분을 통해 운영자는 빈 문자열을 설정하여 "모든 엔드포인트 플러그인을 조용히 비활성화"할 수 있으며, 매번 시작 시 발생하는 warning 소음을 감수할 필요가 없습니다.

Q3: device_id_to_physical_device_id에서, 소스 코드가 왜 빈device_control_env_var을 설정되지 않은 것으로 처리하는가📎 vllm/platforms/interface.py:302-308? 이 빈 문자열 검사를 제거하면 Ray의 CPU-only placement group 시나리오에서 무슨 일이 발생할까요?

참고 해석: 소스 코드 주석은 빈 환경 변수가 Ray가 GPU 노드에서 CPU-only placement group을 시작할 때의 합법적인 구성이라고 설명합니다📎 vllm/platforms/interface.py:296-297. 만약!= ""검사를 제거하면, 코드는device_ids = "".split(",")분기로 진입하여[""]을 얻고, 그런 다음device_ids[device_id]이 빈 문자열을 반환하며, 최종적으로int("")이 예외를 발생시킵니다ValueError. 이로 인해 엔진이 합법적인 Ray 구성에서 시작 실패할 수 있다. 검사를 유지하면 빈 환경 변수는else분기로 직접 반환되어device_id을 반환한다. 즉, logical ID가 physical ID와 같다고 가정하는데, 이는 CPU-only 시나리오에서는 GPU 매핑이 필요 없기 때문에 안전하다. 이 사례는 환경 변수의 "미설정"과 "빈 값 설정"이 분산 오케스트레이션 시스템에서 의미가 다르며, 코드가 이를 명시적으로 처리해야 함을 보여준다.

---

다음 장에서는 아키텍처 트레이드오프, 프로덕션 함정, 미래 진화로 전환하여, 앞 13개 장에서 분해한 메커니즘을 함께 놓고 vLLM이 성능, 유지보수성, 확장성 사이에서 어떤 선택을 하는지 검토하고 추론 엔진의 진화 방향을 전망한다.

여기까지 우리는 vLLM이 entry_points의 그룹화 메커니즘, 프로세스 경계 인식 로딩 타이밍, 그리고 플랫폼, IO processor, 엔드포인트 세 가지 플러그인의 차별화 전략을 통해 핵심 코드를 안정적으로 유지하면서 확장 면을 열어가는 방식을 살펴보았다. 이 플러그인 체계는 새로운 하드웨어, 새로운 입력 형식, 새로운 API 라우팅을 비침습적으로 통합할 수 있게 하지만, 확장성 자체는 더 많은 트레이드오프 차원을 의미한다. 다음 장에서는全书를 마무리하며 vLLM의 핵심 설계 결정에서의 긴장 관계—연속 배치와 VRAM 단편화, CUDA Graph와 동적 형상, 분리형 배포와 네트워크 오버헤드—를 체계적으로 정리하고, 프로덕션 환경 함정 목록과 진단 경로를 제시하며, Rust 프론트엔드, IR 계층, 이기종 하드웨어 방향의 진화 트렌드를 전망한다.

CHAPTER 14

제14장: 아키텍처 트레이드오프, 프로덕션 함정, 미래 진화

소속 프로젝트: vllm-project/vllm · 전체 진행률: 제14 / 14장 · 검증 상태: FACT 행 번호 실제 앵커링

이전 장에서 우리는 vLLM의 플러그인 확장 메커니즘을 분해하며, 플랫폼 플러그인, IO processor 플러그인, 엔드포인트 플러그인이 핵심 코드를 수정하지 않고도 엔진이 새로운 하드웨어, 새로운 모달리티, 새로운 API에 적응할 수 있게 하는 방식을 살펴보았다. 이러한 확장성은 vLLM이 변화를 빠르게 수용할 수 있게 하지만, 확장점이 많을수록 프로덕션 환경에서의 상호작용 경로는 더 복잡해진다. VRAM 단편화, NCCL 핸드셰이크 실패, 컴파일 캐시 무효화, 네트워크 지터 같은 실제 문제가 동시에 발생할 때, 앞 13개 장에서 소개한 메커니즘들은 서로 당기며 이상적인 환경에서는 드러나지 않던 긴장을 노출한다. 이 장에서는 새로운 핵심 메커니즘을 도입하지 않고, 이러한 메커니즘을 함께 놓고 공식 troubleshooting 문서를 앵커로 삼아 Rust 프론트엔드 bench 도구의 설계와 결합하여 성능과 운영 가능성 사이의 트레이드오프를 검토하고, 실행 가능한 진단 경로를 제시한다.

1. 최적화 등급: 시작 시간과 실행 성능의 명시적 계약

직관적 모델

최적화 등급은 카메라의 "장면 모드"와 같다: 자동 모드(-O2)는 대부분의 장면에 적합하지만, 빠른 스냅샷(디버깅)이 필요할 때 수동 모드(-O0)로 전환하면 즉시 응답할 수 있으며, 대가는 화질(성능) 저하다. vLLM은 이러한 트레이드오프를 수십 개의 불리언 flag에 숨겨 사용자가 직접 조합하게 하는 대신, 명시적인 네 단계 계약으로 만들었다.

네 단계의 필드 레이아웃

vLLM은-O0부터-O3까지 네 등급을 제공한다📎 docs/design/optimization_levels.md:5-5. 핵심 설계 원칙은:사용자가 명시적으로 설정한 flag가 최적화 등급의 기본값보다 우선한다 📎 docs/design/optimization_levels.md:5-5. 이는 최적화 등급이 단지 기본값 집합일 뿐, 강제 제약이 아님을 의미한다.

-O0모든 것을 끈다: autotuning 없음, 컴파일 없음, cudagraph 없음📎 docs/design/optimization_levels.md:32-33. 구체적으로 네 가지 스위치에 적용된다:cudagraph_mode=NONE、mode=NONE, 모든 fusion 비활성화,enable_flashinfer_autotune=False 📎 docs/design/optimization_levels.md:37-40。

-O1는 개발 시나리오의 균형점이다:PIECEWISEcudagraph와VLLM_COMPILE모드 활성화📎 docs/design/optimization_levels.md:50-51. 여기에는 정교한 세부 사항이 있다:fuse_norm_quant와fuse_act_quant는 둘 중 하나의 연산자만 사용자 정의 kernel을 사용할 때만 활성화되며, 그렇지 않으면 Inductor의 자동 fusion 효과가 더 좋다📎 docs/design/optimization_levels.md:61. 이는 전형적인 "컴파일러와 일을 다투지 말라"는 설계 판단이다.

-O2는 기본값이며 프로덕션을 지향한다📎 docs/design/optimization_levels.md:66-67. 이는-O1기반에FULL_AND_PIECEWISEcudagraph와fuse_allreduce_rms 📎 docs/design/optimization_levels.md:72-73。-O3를 추가한다-O2현재는📎 docs/design/optimization_levels.md:80-81。

와 동일하며, 미래의 더 공격적인 실험적 최적화를 위해

시나리오 기반 선택 흐름vllm serve model -O1사용자가

mermaid
flowchart TD
    start["用户启动 vllm serve -O1"] --> parse["解析 optimization_level=1"]
    parse --> load_defaults["加载 O1 默认值集合"]
    load_defaults --> check_user{"用户是否显式设置了<br/>cudagraph_mode?"}
    check_user -->|是| user_wins["使用用户值<br/>覆盖 O1 默认"]
    check_user -->|否| use_default["使用 O1 默认<br/>PIECEWISE"]
    user_wins --> check_fusion{"fuse_norm_quant<br/>是否涉及自定义 kernel?"}
    use_default --> check_fusion
    check_fusion -->|是| enable_fuse["启用该 fusion"]
    check_fusion -->|否| skip_fuse["跳过,交给 Inductor"]
    enable_fuse --> done["配置完成,进入引擎初始化"]
    skip_fuse --> done

복사check_user이 흐름의 핵심은📎 docs/design/optimization_levels.md:5-5분기이다: 사용자 명시적 설정이 항상 우선한다

. 이는 "최적화 등급이 내 디버깅 flag를 조용히 덮어썼다" 같은排查하기 어려운 문제를 방지한다.

설계 고찰과 함정최적화 등급의 가장 흔한 프로덕션 함정은시작 시간 과다-O0이다. 문서는 명확히 권장한다: 시작 시간이 너무 길면-O1 📎 docs/design/optimization_levels.md:87또는-O0를 사용하라. 하지만 여기에는 숨은 대가가 있다—

에는 cudagraph가 없어 각 kernel의 CPU 발사 오버헤드가 드러나며, 고동시성 시나리오에서 처리량이 수 배 감소할 수 있다.또 다른 함정은。-O2컴파일 오류FULL_AND_PIECEWISE이다.-O2의-O1cudagraph는 모델 구조에 더 강한 가정을 하며, 일부 사용자 정의 모델은debug_dump_path에서 컴파일 실패하지만📎 docs/design/optimization_levels.md:88에서는 정상이다. 문서는-O0를 사용하여 더 많은 디버깅 정보를 얻으라고 권장한다-O1、-O2.排查 경로는 다음과 같아야 한다: 먼저

로 기능 정확성을 확인하고, 점차

로 올리며 어느 등급에서 문제가 도입되었는지 찾는다.--enforce-eager동일한 방법론입니다: 가장 보수적인 설정으로 정확성을 확인한 후, 점진적으로 최적화를 활성화하여 문제를 최소한의 설정 차이로 격리합니다.

---

2. 프로덕션 함정 체크리스트: 증상에서 근본 원인까지의 진단 경로

직관적 모델

프로덕션 환경의 장애 대응은 응급 분류와 같습니다: 모든 환자에게 전체 검사를 할 수 없으므로, 먼저 증상(OOM, hang, 크래시)에 따라 범위를 빠르게 좁힌 후, 표적을 정해 깊이 파고들어야 합니다. vLLM의 troubleshooting 문서는 본질적으로 분류 매뉴얼입니다.

증상 분류와 진단 도구

문서는 일반적인 문제를 몇 가지 큰 범주로 나누는데, 진단 난이도가 높아지는 순서로 정리하겠습니다.

첫 번째 범주: 모델 다운로드/로딩 멈춤.증상은 시작 후 장시간 응답이 없는 것입니다. 근본 원인은 보통 네트워크가 느리거나 공유 파일 시스템이 느린 것입니다📎 docs/usage/troubleshooting.md:11-11. 진단 수단은--load-format dummy가중치 로딩을 건너뛰어 다운로드가 느린지 로딩이 느린지를 격리하는 것입니다📎 docs/usage/troubleshooting.md:23-23. 이는 전형적인 "이분법적 격리" 기법입니다.

두 번째 범주: VRAM OOM.문서는 곧바로 conserving_memory 설정 문서를 가리킵니다📎 docs/usage/troubleshooting.md:23. 하지만 프로덕션에서의 OOM은 모델이 너무 커서가 아니라 KV cache 단편화나 예상보다 많은 동시 요청 수 때문인 경우가 많습니다.

세 번째 범주: 생성 품질 변화.이는 간과하기 쉬운 함정입니다. v0.8.0은 기본 샘플링 파라미터의 출처를 변경했습니다: vLLM의 중립적 기본값에서 모델 작성자의generation_config.json 📎 docs/usage/troubleshooting.md:23-23로 바뀌었습니다. 대부분의 경우 품질이 향상되지만, 일부 모델에서는 설정이 오히려 더 나빠집니다📎 docs/usage/troubleshooting.md:23-23. 진단 방법은--generation-config vllm로 되돌려 비교하는 것입니다📎 docs/usage/troubleshooting.md:23-23。

네 번째 범주: 멈춤(hang).이것이 가장 진단하기 어려운 범주입니다. 문서는 점진적인 디버깅 환경 변수 세트를 제시합니다📎 docs/usage/troubleshooting.md:41-41:

  • VLLM_LOGGING_LEVEL=DEBUG: 상세 로그 활성화
  • VLLM_LOG_STATS_INTERVAL=1.: 고빈도 출력 큐와 캐시 히트 상태
  • CUDA_LAUNCH_BLOCKING=1: 어떤 CUDA kernel에서 문제가 발생했는지 특정
  • NCCL_DEBUG=TRACE: NCCL 상세 로그 활성화
  • VLLM_TRACE_FUNCTION=1: 모든 함수 호출을 기록하지만 100배 이상 느려짐📎 docs/usage/troubleshooting.md:41

여기에는 중요한 운영 규율이 있습니다: 디버깅 후 반드시 이 환경 변수들을 꺼야 하며, 또는 새 shell을 열어야 합니다. 그렇지 않으면 잔류한 디버깅 설정이 계속 시스템을 느리게 만듭니다📎 docs/usage/troubleshooting.md:11-11。

브레이크포인트 디버깅의 프로세스 경계 함정

vLLM의 다중 프로세스 아키텍처는 일반적인pdb브레이크포인트를 무효화합니다——브레이크포인트가 자식 프로세스에서 실행되면BdbQuit 📎 docs/usage/troubleshooting.md:45-54를 던집니다. 두 가지 해결법:forked-pdb 📎 docs/usage/troubleshooting.md:57-61를 사용하거나,VLLM_ENABLE_V1_MULTIPROCESSING=0를 설정하여 스케줄러를 동일 프로세스에 유지하는 것입니다📎 docs/usage/troubleshooting.md:63-68。

〔설계 추론과 아키텍처 트레이드오프〕

두 번째 방법은 편리하지만 실행 모델을 변경합니다——단일 프로세스 모드에서는 EngineCore와 API Server가 더 이상 큐를 통해 통신하지 않으므로 일부 동시성 버그가 재현되지 않을 수 있습니다. 따라서 논리 오류를 찾는 데는 적합하지만 동시성 문제를 재현하는 데는 적합하지 않습니다.

분산 통신의 진단

분산 배포에는 전용 진단 문서가 있습니다. 핵심 권장 사항은:클러스터 생성 시 환경 변수를 설정하라, 변수가 모든 노드로 전파되기 때문입니다; shell에서 설정하면 로컬 노드에만 영향을 미칩니다📎 docs/serving/distributed_troubleshooting.md:16-16。

빈번한 문제는No available node types can fulfill resource request이며, 클러스터에 충분한 GPU가 있어도📎 docs/serving/distributed_troubleshooting.md:16-16가 발생합니다. 근본 원인은 보통 노드에 여러 IP가 있는데 vLLM이 잘못된 것을 선택했기 때문입니다. 해결법은VLLM_HOST_IP로 명시적으로 지정하고,ray status로 검증하는 것입니다📎 docs/serving/distributed_troubleshooting.md:16-16。

NCCL 초기화 실패 진단 스크립트

문서는 통신 스택을 계층별로 검증하는 완전한 진단 스크립트를 제공합니다📎 docs/usage/troubleshooting.md:89-150. 그 설계는 매우 계층적입니다:

mermaid
flowchart TD
    start["运行诊断脚本"] --> nccl_test["测试 PyTorch NCCL<br/>dist.all_reduce"]
    nccl_test --> nccl_ok{"value == world_size?"}
    nccl_ok -->|否| hw_broken["硬件/驱动故障<br/>联系系统管理员"]
    nccl_ok -->|是| gloo_test["测试 PyTorch GLOO<br/>CPU 通信"]
    gloo_test --> gloo_ok{"value == world_size?"}
    gloo_ok -->|否| gloo_fail["GLOO 配置问题<br/>检查网络接口"]
    gloo_ok -->|是| pynccl_test["测试 vLLM PyNcclCommunicator"]
    pynccl_test --> pynccl_ok{"all_reduce 正确?"}
    pynccl_ok -->|否| pynccl_fail["vLLM NCCL 封装问题"]
    pynccl_ok -->|是| graph_test["测试 CUDA Graph 内 all_reduce"]
    graph_test --> graph_ok{"g.replay() 后正确?"}
    graph_ok -->|否| graph_fail["CUDA Graph 捕获问题<br/>检查 stream 语义"]
    graph_ok -->|是| success["sanity check 成功"]

이 스크립트의 정교함은 계층별 격리에 있습니다: 먼저 최하위 계층의 PyTorch NCCL을 검증하고, 다음으로 CPU 측 GLOO를 검증하고, 그 다음 vLLM 자체의 PyNcclCommunicator 래퍼를 검증하고, 마지막으로 CUDA Graph 내의 통신을 검증합니다📎 docs/usage/troubleshooting.md:90-146. 각 계층의 실패는 서로 다른 근본 원인을 가리킵니다.

스크립트에서 주목할 만한 세부 사항:pynccl.disabled = False는 0.6.4 이하 버전과의 하위 호환성을 위한 것입니다📎 docs/usage/troubleshooting.md:121-125. 0.6.5+에서는 기본 활성화되지만, 이 코드를 남겨두면 최신 문서를 읽는 사용자가 혼란스럽지 않습니다.

다중 노드 테스트 시 문서는 의도적으로--rdzv_backend=static가 아닌c10d를 사용하는데,c10d는 다중 노드에서 DNS 해석 실패로📎 docs/usage/troubleshooting.md:168-168가 발생하기 때문입니다. 이는 전형적인 "겪어봐야 아는" 설정입니다.

설계 고찰과 함정

NCCL 초기화 실패(ncclCommInitRank는 unhandled system error를 보고하며) 보통 두 가지 근본 원인을 가리킵니다:IPC_LOCKcapability 부족 또는/dev/shm가 마운트되지 않음📎 docs/usage/troubleshooting.md:311-311. 둘 다 컨테이너화 배포의 전형적인 함정입니다.

CUDA PTX 툴체인 불일치(the provided PTX was compiled with an unsupported toolchain)는 wheel 내의 PTX가 더 높은 버전의 CUDA toolkit으로 컴파일되었음을 의미합니다📎 docs/usage/troubleshooting.md:325-327. 해결법은 CUDA forward compatibility를 활성화하는 것입니다: Docker에서는-e VLLM_ENABLE_CUDA_COMPATIBILITY=1 📎 docs/usage/troubleshooting.md:325-327를 추가하고, 베어메탈에서는cuda-compat패키지를 설치하고VLLM_CUDA_COMPATIBILITY_PATH 📎 docs/usage/troubleshooting.md:325-327。

를 설정합니다:vLLM >= 0.4.3, <= 0.10.1.1알려진 NCCL 메모리 오버헤드 문제NCCL_CUMEM_ENABLE=0는 NCCL 버그를 회피하기 위해📎 docs/usage/troubleshooting.md:375를 설정하며, 외부 프로세스가 vLLM에 연결할 때도 이 변수를 설정해야 합니다. 그렇지 않으면 hang 또는 크래시가 발생합니다📎 docs/usage/troubleshooting.md:375. NCCL 2.22.3에서 수정된 후, 새 버전에서는 성능 최적화를 허용하기 위해 이 오버라이드를 제거했습니다. 이 사례는 다음을 보여줍니다:프로세스 간 환경 변수 계약은 분산 시스템의 암묵적 의존성이며

---

, 업그레이드 시 반드시 동기화해야 합니다.

3. Rust 프론트엔드: bench 도구의 제로 카피 설계 철학

직관적 모델

Python 프론트엔드가 "기능은 완비됐지만 무거운" 스위스 군용 칼이라면, Rust bench 도구는 "부하 테스트만을 위해 태어난" 메스입니다. 그 설계 목표는 기능 커버리지가 아니라, 높은 동시성에서 클라이언트 자체의 오버헤드를 최소로 줄여 측정된 숫자가 서버 성능을 진실되게 반영하도록 하는 것입니다.

bench 도구의 핵심 데이터 구조는RequestFuncInput 📎 rust/src/bench/src/backends/mod.rs:59-89입니다. 이는Arc<str>과Arc<[u32]>을(를) 대량으로 사용하고String/Vec을(를) 사용하지 않으며, 이것이 제로 카피 설계의 핵심입니다.

몇 가지 주요 필드를 살펴보겠습니다:prompt: Arc<str> 📎 rust/src/bench/src/backends/mod.rs:50-52——여러 동시 요청이 동일한 prompt 문자열을 공유할 수 있어 각 요청마다 복제하는 것을 방지합니다.prompt_token_ids: Option<Arc<[u32]>> 📎 rust/src/bench/src/backends/mod.rs:77——미리 계산된 token ID를 서버로 직접 전송하여 서버 측 tokenization을 건너뜁니다.📎 rust/src/bench/src/backends/mod.rs:74-76。

가장 정교한 부분은multi_modal_content: Option<Arc<[Arc<str>]>> 📎 rust/src/bench/src/backends/mod.rs:81입니다. 주석 설명: 멀티모달 콘텐츠를 사전 직렬화된 JSON 조각으로 처리하여 chat backend가 payload 바이트 스트림에 직접 연결하며, base64 이미지 데이터를 파싱하거나 깊은 복사하는 것을 방지합니다.📎 rust/src/bench/src/backends/mod.rs:78-80입니다. 이는 이중 레이어Arc구조입니다: 외부Arc<[...]>은 전체 배열을 공유하고, 내부Arc<str>은 단일 조각을 공유합니다.

chat_messages_json: Option<Arc<str>>우선순위가 가장 높아 payload에 그대로 직접 연결됩니다.📎 rust/src/bench/src/backends/mod.rs:82-85。

제로 할당 역직렬화

SSE 스트리밍 응답 파싱은 또 다른 성능 핵심 포인트입니다. 주석에서 명확히 지적합니다: 타입화된 역직렬화를 사용하여 완전한serde_json::Value트리 구축을 피하고 필요한 필드만 추출합니다.📎 rust/src/bench/src/backends/mod.rs:20-24。

CompletionChunkchoices과usage두 필드만 유지합니다.📎 rust/src/bench/src/backends/mod.rs:20-24,ChatChunk마찬가지로📎 rust/src/bench/src/backends/mod.rs:33-37。#[serde(default)]은 누락된choices필드를 기본적으로 빈 배열로 설정합니다.📎 rust/src/bench/src/backends/mod.rs:20-24이는 스트리밍 응답의 일반적인 경우입니다.

시나리오 기반 요청 흐름

부하 테스트 요청이 전송될 때 데이터는 어떻게 흐르나요? 아래 데이터 흐름 다이어그램은 입력에서 출력까지의 변환을 보여줍니다:

mermaid
flowchart LR
    input["RequestFuncInput<br/>Arc&lt;str&gt; prompt"] --> build["build_headers<br/>+ payload 拼接"]
    build --> send["reqwest::Client<br/>send_request"]
    send --> sse["SSE 流式响应<br/>字节流"]
    sse --> parse["CompletionChunk<br/>类型化反序列化"]
    parse --> output["RequestFuncOutput<br/>ttft/itl/tpot"]

Backend열거형은 정적 디스패치를 사용하여 async trait object 문제를 방지합니다.📎 rust/src/bench/src/backends/mod.rs:150-154。send_requestmatch을 통해 구체적인 구현으로 디스패치합니다.📎 rust/src/bench/src/backends/mod.rs:158-168。get_backendBackendKind에 따라 해당 백엔드를 반환합니다.📎 rust/src/bench/src/backends/mod.rs:172-181。

한 가지 세부 사항:API_KEYOnceLock을 사용하여 캐시하여 각 요청마다 환경 변수 syscall을 수행하는 것을 방지합니다.📎 rust/src/bench/src/backends/mod.rs:186-188。build_headersContent-Type, Authorization, extra headers, request-id를 순서대로 삽입합니다.📎 rust/src/bench/src/backends/mod.rs:191-215。

설계 고찰과 함정

〔설계 추론 및 아키텍처 트레이드오프〕

Rust bench 도구의 제로 카피 설계는 중요한 판단을 반영합니다:부하 테스트 도구의 클라이언트 오버헤드는 측정 오류의 원인이 됩니다.만약 각 요청마다 prompt를 복제하고, 전체 JSON을 파싱하고, base64 이미지를 깊은 복사한다면, 측정된 지연 시간에 클라이언트 오버헤드가 섞여 서버 성능을 진정으로 반영할 수 없습니다.Arc을 사용하여 불변 데이터를 공유하고, 타입화된 역직렬화로 관련 없는 필드를 건너뛰는 것은 본질적으로 클라이언트 오버헤드를 거의 0에 가깝게 줄이는 것입니다.

RequestFuncOutput의 필드 설계도 주목할 만합니다:ttft(time to first token)、itl(inter-token latency 배열),tpot(time per output token)📎 rust/src/bench/src/backends/mod.rs:93-105. 이 세 가지 지표는 각각 다른 성능 차원에 대응합니다: TTFT는 prefill과 대기 지연을 반영하고, ITL은 decode의 안정성을 반영하며, TPOT는 전체 처리량을 반영합니다. 부하 테스트 시 평균 지연만 보면 ITL의 변동을 숨길 수 있습니다.

---

설계 고찰: 아키텍처 트레이드오프의 근본 논리

이 장과 앞의 13개 장의 메커니즘을 함께 놓으면 vLLM의 몇 가지 핵심 트레이드오프 라인을 볼 수 있습니다.

〔설계 추론 및 아키텍처 트레이드오프〕

연속 배치 처리 vs VRAM 단편화.연속 배치 처리는 배치가 매 단계 재구성되어 처리량이 크게 향상되지만, 대가는 KV cache의 할당과 해제가 극도로 빈번하다는 것입니다. PagedAttention의 블록 테이블 메커니즘은 바로 이러한 고빈도 할당에 대응하기 위한 것입니다——고정 크기 block은 외부 단편화를 제거하지만, 블록 테이블의 간접 주소 지정 오버헤드와 내부 단편화(마지막 block이 채워지지 않을 수 있음)를 도입합니다. 이는 전형적인 "간접 계층으로 단편화율을 교환"하는 트레이드오프이며, 운영체제의 가상 메모리 페이징과 동일한 사고방식입니다.

CUDA Graph vs 동적 형태.CUDA Graph는 정적 형태를 요구하지만, 연속 배치 처리의 배치 크기는 매 단계 변합니다. vLLM의 해결책은PIECEWISE과FULL_AND_PIECEWISE모드입니다.📎 docs/design/optimization_levels.md:50,72——정적으로 만들 수 있는 부분을 그래프로 캡처하고, 동적 부분은 eager로 유지합니다.-O0cudagraph를 완전히 끄는 것은 디버깅을 위한 것이고,-O2전부 켜는 것은 프로덕션을 위한 것이며, 중간의-O1은 절충안입니다.

분리형 배포 vs 네트워크 오버헤드.KV Connector는 prefill과 decode를 서로 다른 인스턴스로 분리할 수 있게 하지만, KV cache의 인스턴스 간 전송은 네트워크 지연을 도입합니다. 문서에서 GPUDirect RDMA의 구성 요구사항(IPC_LOCK、/dev/shm)📎 docs/usage/troubleshooting.md:311-311은 이 경로가 인프라에 엄격한 요구사항이 있음을 보여줍니다. 네트워크 지터는 KV 전송 타임아웃을 유발하여 재시도 또는 성능 저하를 트리거합니다.

운영 가능성 vs 성능.최적화 등급, 디버깅 환경 변수, 진단 스크립트는 모두 운영 가능성을 위해 지불하는 비용입니다.VLLM_TRACE_FUNCTION=1은 100배 느려집니다.📎 docs/usage/troubleshooting.md:41하지만 hang 문제를 찾는 최후의 수단입니다. 성숙한 엔진은 이러한 "느리지만 명확히 볼 수 있는" 도구를 반드시 제공해야 합니다.

---

이 장 요약

이 장은 책 전체를 마무리하며, 앞의 13개 장의 메커니즘을 프로덕션 관점에서 재검토합니다.

최적화 등급(-O0에서-O3까지)은 시작 시간과 런타임 성능의 명시적 계약이며, 사용자 flag는 항상 등급 기본값보다 우선합니다.📎 docs/design/optimization_levels.md:5-5. 프로덕션 함정 목록은 모델 로딩, VRAM OOM, 생성 품질 변화에서 분산 통신 실패까지 완전한 진단 경로를 다루며, 핵심 방법론은 "이분법적 격리"와 "계층별 검증"입니다. Rust bench 도구는Arc공유와 타입화된 역직렬화를 사용하여 클라이언트 오버헤드를 거의 0에 가깝게 줄여 부하 테스트 수치가 서버 성능을 진정으로 반영하도록 보장합니다.

세 가지 핵심 트레이드오프 축이 전서를 관통한다: 연속 배치 처리와 VRAM 단편화, CUDA Graph와 동적 형상, 분리형 배포와 네트워크 오버헤드. 이러한 긴장 관계를 이해하는 것이 어떤 단일 메커니즘을 암기하는 것보다 중요하다 — 프로덕션 환경의 모든 튜닝은 본질적으로 이러한 긴장 사이에서 균형점을 찾는 것이기 때문이다.

본 장 사고와 자가 점검

Q1: 만약-O2의FULL_AND_PIECEWISEcudagraph를-O1의PIECEWISE로 변경하면, 어떤 시나리오에서 성능 회귀가 발생하는가? 왜인가?

참고 해석:-O2은-O1기반 위에FULL_AND_PIECEWISEcudagraph 모드📎 docs/design/optimization_levels.md:72。FULL모드는 전체 순방향 전파를 하나의 그래프로 캡처하는 반면,PIECEWISE은 정적으로 만들 수 있는 조각만 캡처한다. 배치 형상이 안정적인 프로덕션 시나리오에서FULL모드는 더 많은 kernel 발사 오버헤드를 제거할 수 있어 처리량이 더 높다. 그러나 모델에 동적 제어 흐름(예: MoE의 token 라우팅)이 포함된 경우,FULL모드는 캡처하지 못하거나 캡처 후 동작이 비정상일 수 있으며, 이때PIECEWISE이 오히려 더 안정적이다. 성능 회귀는 다음과 같은 경우에 나타난다: 배치 크기가 빈번하게 변하여FULL그래프가 히트되지 않거나, 모델 구조가FULL모드의 fallback 경로를 트리거할 때이다.排查 방법은 먼저-O1로 기준선을 확인한 후,-O2로 승격하여 비교하고,VLLM_LOG_STATS_INTERVAL=1.로 큐 상태를 관찰하는 것이다.📎 docs/usage/troubleshooting.md:41-41。

Q2: 진단 스크립트에서 vLLM PyNcclCommunicator를 테스트하기 전에 왜 PyTorch GLOO를 먼저 테스트해야 하는가? GLOO 테스트를 건너뛰고 바로 PyNccl을 테스트하면 무엇을 놓치는가?

참고 해석: 스크립트의 실행 순서는 PyTorch NCCL → PyTorch GLOO → vLLM PyNccl → CUDA Graph📎 docs/usage/troubleshooting.md:90-146이다. GLOO는 CPU 측 통신을 테스트하며📎 docs/usage/troubleshooting.md:106-112, vLLM의PyNcclCommunicator은 GLOO group을 bootstrap으로 필요로 한다📎 docs/usage/troubleshooting.md:120. GLOO 테스트를 건너뛰면 PyNccl 초기화 실패 시 NCCL 자체의 문제인지 GLOO bootstrap의 문제인지 구분할 수 없다. GLOO는 네트워크 인터페이스 설정(GLOO_SOCKET_IFNAME)📎 docs/usage/troubleshooting.md:81-81에 의존하며, 복잡한 네트워크 환경에서 이는 빈번한 장애 지점이다. 계층별 테스트의 가치는 장애를 최소한의 설정 차이로 격리하는 데 있다.

Q3: Rust bench 도구가Arc<str>을 사용하여 prompt를 공유하는데, 부하 테스트 시나리오에서 각 요청마다 다른 prompt를 보내야 한다면 이 설계는 무효화되는가? 왜인가?

참고 해석:Arc<str>의 설계 목표는 여러 동시 요청이 동일한 불변 문자열📎 rust/src/bench/src/backends/mod.rs:50-52을 공유하도록 하는 것이다. 만약 각 요청의 prompt가 모두 다르다면,Arc의 공유 이점은 확실히 사라진다 — 각 요청이 자체Arc<str>을 구성해야 한다. 그러나 설계가 무효화된 것은 아니다:Arc<str>은String에 비해 여전히 요청 흐름 과정에서의 다중 복제(예: 입력 큐에서 backend로, 다시 payload 구성으로 전달)를 방지한다. 진정한 제로 카피 최적화는prompt_token_ids: Option<Arc<[u32]>> 📎 rust/src/bench/src/backends/mod.rs:77에 있다 — prompt 텍스트가 달라도 사전 계산된 token ID 배열은Arc을 통해 요청 생명주기 동안 공유되어 중복 할당을 방지할 수 있다. 부하 테스트 도구의 설계 가정은 "동일 prompt 고동시성" 또는 "사전 계산 token ID"이며, 전자는Arc<str>으로 텍스트를 공유하고 후자는Arc<[u32]>으로 token 시퀀스를 공유한다.

---

이로써 전서 14장의 소스 코드 해석이 일단락되었다. 우리는 하나의 API 호출에서 출발하여 스케줄러, KV cache 관리자, 어텐션 백엔드, 분산 통신 계층을 거쳐 최종적으로 GPU kernel의 발사 지점에 도달했고, 다시 프로덕션 운영의 진단 콘솔로 돌아왔다. vLLM의 모든 설계 결정 뒤에는 명확한 트레이드오프가 있으며, 이러한 트레이드오프를 이해해야 새로운 하드웨어, 새로운 모델, 새로운 부하에 직면했을 때 올바른 엔지니어링 판단을 내릴 수 있다. 추론 엔진의 진화는 멈추지 않을 것이다 — Rust 프론트엔드, IR 계층, 이기종 하드웨어 지원이 빠르게推进되고 있다 — 그러나 저층의 트레이드오프 논리는 안정적이며, 이것이 바로 이 책이 전달하고자 하는 핵심 역량이다.

이로써 우리는 요청 진입점에서 GPU Kernel까지의 완전한 여정을 마쳤고, 프로덕션 환경에서 시스템을 "돌아가는" 상태에서 "안정적으로 돌아가는" 상태로 만드는 트레이드오프와 함정도 명확히 파악했다. vLLM의 진화는 현재 아키텍처에서 멈추지 않을 것이며, 더 효율적인 어텐션 구현, 더 지능적인 스케줄링 전략, 더 원활한 이기종 지원이 모두 진행 중이다. 그러나 미래가 어떻게 변하든, 이러한 메커니즘 사이의 긴장과取舍를 이해하는 것이 항상 추론 엔진을 다루는 핵심이다.

어떤 복잡한 프로젝트든, 사실 좋은 책 한 권이면 읽을 수 있다

이 《vLLM 소스 코드 심층 해석: 요청에서 Token까지의 고성능 추론 엔진》은 AiReadCode가 공식 오픈소스 저장소를 스캔하여 전자동으로 편찬한 것이다. 수십만 줄의 대형 오픈소스 명작이든, 기업 내부의 복잡한 엔지니어링이든, 클릭 한 번으로 동일하게 체계적인 전용 저서를 생성할 수 있다.

AiReadCode 클라이언트 무료 다운로드 더 많은 오픈소스好书 둘러보기 →
🇨🇳 중국어 · 🇺🇸 EN · 🇯🇵 일본어 · 🇰🇷 한국어 · 🌐 번체 중국어 · 🇪🇸 ES · 🇩🇪 DE · 🇫🇷 FR · 🇧🇷 PT · 🇷🇺 RU