분리형 추론¶
목차¶
개념¶
분리형 추론은 LLM 토큰 생성의 두 단계를 서로 다른 노드로 분리합니다:
-
프리필 (프롬프트 처리): 전체 입력 프롬프트를 단일 순전파로 처리해 초기 KV 캐시와 첫 번째 출력 토큰을 생성합니다. 이 단계는 연산 집약적입니다: 프롬프트 길이에 비례하는 많은 곱셈-누산 연산이 필요합니다.
-
디코드 (자기회귀 생성): 프리필이 생성한 KV 캐시에서 시작해 한 번에 하나씩 새 토큰을 생성합니다. 이 단계는 메모리 대역폭 집약적입니다: 각 단계에서 모델은 메모리에서 전체 KV 캐시를 읽지만 연산은 토큰 하나 분량만 수행합니다.
두 단계는 요구하는 하드웨어가 서로 다르므로 같은 GPU에서 실행하면 어느 쪽에도 최적이 아닌 절충이 됩니다.
프리필과 디코드를 분리하는 이유¶
graph TB
subgraph "Compute-Bound (Prefill)"
PF_A["Dense matmul over<br/>full prompt length<br/>(e.g., 2048 tokens)"]
PF_B["Benefits from:<br/>high FLOPs, tensor cores"]
end
subgraph "Memory-Bandwidth-Bound (Decode)"
DC_A["One token forward pass<br/>per step, reads full KV cache<br/>(growing with each step)"]
DC_B["Benefits from:<br/>high memory bandwidth,<br/>large VRAM"]
end
PF_A --- PF_B
DC_A --- DC_B | 특성 | 프리필 | 디코드 |
|---|---|---|
| 단계당 연산량 | O(sequence_length²) | O(1) |
| 단계당 메모리 읽기 | 레이어 × d_model에 비례 | 레이어 × sequence_length에 비례 |
| 산술 집약도 | 높음 (연산 집약) | 낮음 (메모리 집약) |
| 이상적인 하드웨어 | 높은 FLOPs / 텐서 코어 밀도 | 높은 메모리 대역폭 / 용량 |
| 배치 효율성 | 여러 프롬프트를 배치하면 효율적 | 여러 시퀀스를 배치하면 KV 읽기 상각에 도움 |
연산 집약적 하드웨어에 프리필을, 메모리 집약적 하드웨어에 디코드를 배치하면 두 단계 모두 최적의 지점에서 작동하고 두 풀을 독립적으로 확장할 수 있습니다.
운영상의 장점:
- 프리필 노드의 긴 프롬프트가 진행 중인 디코드 세션을 지연시키지 않습니다.
- 긴 문서의 느린 프리필이 디코드 노드를 막지 않습니다.
- 실제 워크로드 구성에 따라 프리필 노드 용량과 디코드 노드 용량을 독립적으로 확장할 수 있습니다.
전체 분리형 흐름¶
sequenceDiagram
participant C as Client
participant R as Router
participant PF as Prefill Node
participant DC as Decode Node
C->>R: POST /v1/chat/completions
R->>PF: Route request to prefill node
Note over PF: Run prefill forward pass<br/>over prompt tokens<br/>Build KV cache for all layers
PF->>PF: Sample first token
PF->>R: Return first token + signal handoff
R->>C: Stream first token (SSE)
PF->>DC: Transfer KV cache (serialized + optionally compressed)
DC->>DC: Admit sequence to decode batch
loop Token generation
DC->>DC: Decode step (one token)
DC->>R: Stream token
R->>C: Forward token (SSE)
end
DC->>R: Finish reason (stop / length)
R->>C: data: [DONE] 클라이언트 API는 단일 노드 서빙과 동일합니다.
프리필 스케줄러¶
src/distributed/disaggregated/prefill_scheduler.rs
프리필 노드는 전용 스케줄러를 실행합니다:
- 대기 중인 프리필 요청을 프롬프트 길이순(짧은 것 먼저)으로 정렬한 우선순위 큐를 유지해 처리량을 높입니다.
- 부하 시 OOM을 방지하기 위해 설정 가능한
max_concurrent_prefills로 동시 프리필 작업을 제한합니다. - 메모리 활용률을 모니터링하고
memory_threshold(기본값: 설정 가능)를 초과하면 새 요청을 거부합니다. - 선택적으로 청크 프리필을 실행합니다: 긴 프롬프트를
chunk_size_tokens단위 청크로 처리하므로 전체 프롬프트가 끝나기 전에 디코드 노드로 KV 캐시를 점진적으로 전송하기 시작할 수 있습니다. - 프리필 완료 후 KV 캐시를 직렬화하고
TransferConfig를 통해 디코드 노드에 전달합니다.
주요 설정 필드:
| 필드 | 설명 |
|---|---|
max_concurrent_prefills | 최대 동시 프리필 작업 수 |
transfer_timeout | 전체 핸드오프(직렬화 + 전송 + 확인)의 타임아웃 |
memory_threshold | 이 값을 넘으면 새 요청을 거부하는 메모리 활용률 기준 (0.0-1.0) |
chunked_prefill_enabled | 긴 시퀀스에 청크 프리필 활성화 |
chunk_size_tokens | 청크 프리필이 활성화된 경우 청크당 토큰 수 |
max_handoff_retries | 실패한 핸드오프의 최대 재시도 횟수 |
디코드 스케줄러¶
src/distributed/disaggregated/decode_scheduler.rs
디코드 노드는 다음과 같은 스케줄러를 실행합니다:
- 프리필 노드로부터 비동기적으로(진행 중인 디코드 단계를 차단하지 않고) KV 캐시를 수신합니다.
- 메모리가 허용할 때 시퀀스를 활성 디코드 배치에 수용합니다.
- 자기회귀 생성을 실행합니다: 반복당 활성 시퀀스당 하나의 디코드 단계.
- 시퀀스가 EOS,
max_tokens, 또는 오류에 도달하면 완료 이벤트를 발생시킵니다. 요청 라우터는 이 이벤트를 받아 최종 응답을 전달합니다. - 수집 큐가 가득 찰 때 프리필 노드에 백프레셔를 적용합니다.
주요 설정 필드:
| 필드 | 기본값 | 설명 |
|---|---|---|
max_batch_size | 32 | 최대 동시 디코드 시퀀스 수 |
max_sequences | 128 | 최대 총 시퀀스 수 (활성 + 대기) |
memory_threshold | 0.85 | 이 메모리 활용률 이상에서 새 시퀀스 거부 |
ingestion_queue_size | 64 | 최대 대기 중인 KV 캐시 수집 요청 수 |
요청 라우터¶
src/distributed/disaggregated/request_router.rs
라우터는 분리형 모드에서 모든 수신 API 요청의 진입점입니다. POST /v1/chat/completions와 POST /v1/completions 모두 같은 프리필/디코드 핸드오프를 거치며, 라우터의 역할은 다음과 같습니다.
- 설정 가능한 부하 분산 전략으로 사용 가능한 프리필 노드 중 최적 노드를 선택합니다.
- 사용 가능한 디코드 노드 중 최적 노드를 선택하고, 그 주소를 프리필 요청 프레임의
decode_target필드에 실어 보냅니다. 그 결과 프리필 노드는 자신의 정적--decode-peers설정이 아니라 라우터가 고른 디코드 노드로 KV 캐시를 넘깁니다. - 프리필 → 디코드 핸드오프를 가로질러 요청 생명주기 전체를 추적합니다.
- 노드가 용량에 달했을 때 백프레셔를 적용합니다 (큐, 거부, 또는 HTTP 503).
- 노드 장애 시 영향받는 요청을 정상 피어로 자동 재라우팅합니다.
부하 분산 전략¶
| 전략 | 설명 |
|---|---|
RoundRobin | 안정적인 순서로 사용 가능한 노드에 요청을 고르게 순환 |
LeastLoaded | 활성 요청이 가장 적은 노드로 라우팅 |
MemoryAware | 여유 메모리가 가장 많은 노드로 라우팅 (OOM 방지) |
PromptLengthAware | 긴 프롬프트는 연산 여유가 더 많은 노드로, 짧은 프롬프트는 빨리 완료할 수 있는 노드로 라우팅 |
프리필 노드와 디코드 노드 선택 모두 현재는 라운드로빈을 사용합니다. 라우터는 자신의 dispatch 카운터 외에 실시간 노드별 부하 정보를 갖고 있지 않기 때문입니다.
백프레셔 액션¶
프리필 또는 디코드 노드가 용량에 달했을 때, 라우터는 다음 중 하나를 취할 수 있습니다:
- Queue: 라우터의 대기 큐에 요청을 보류합니다.
- Reject: 즉시 HTTP 503를 반환합니다.
선택은 BackpressureConfig와 현재 부하 수준(Low, Moderate, High, Critical)에 따라 달라집니다. 모든 요청은 전달 전에 admission control을 통과하므로, 용량이 찬 노드는 처리할 수 없는 요청을 받는 대신 HTTP 503을 반환합니다.
헬스 모니터링과 페일오버¶
백그라운드 헬스 모니터가 일정 주기로 등록된 모든 피어의 서빙 주소를 TCP로 프로브합니다. 라우터는 요청 경로에서 디코드 노드에 직접 접속하지 않으므로 프리필 실패로만 장애를 감지할 수 있는데, 이 프로브 덕분에 죽은 디코드 노드도 감지되고 라우팅 대상에서 제외됩니다. 프리필 프레임 전송 중 transport 오류가 발생하면 해당 노드를 Unreachable로 표시하고 진행 중이던 요청을 정상 피어로 재라우팅하며, 헬스 모니터가 다시 응답을 확인할 때까지 라우팅 대상에서 제외합니다. 첫 토큰 전송이나 KV 핸드오프가 실패해도 영향을 받는 요청 하나만 실패하고, 프리필·디코드 워커는 나머지 부하를 계속 처리합니다. 입장 단계에서 사용 가능한 프리필 노드가 없는데 Unreachable로 표시된 노드가 있으면, 라우터는 먼저 그 노드들을 다시 프로브합니다(single-flight, 라우터 전체에서 250 ms에 한 번 이하, 연결 타임아웃 500 ms). 하나라도 연결을 받으면 요청을 받아들이므로 마지막 모니터 주기 이후에 올라온 피어 때문에 HTTP 503이 나가지 않습니다. 큐가 가득 찬 경우에는 프로브하지 않습니다.
관측: GET /router/stats¶
GET /router/stats는 라우터 자신의 클라이언트용 HTTP 포트에 /v1/chat/completions와 나란히 마운트됩니다. 노드별 프리필/디코드 dispatch 횟수, 노드 헬스, 라우터 메트릭 스냅샷을 보고합니다. 기본적으로 응답은 각 피어의 실제 host:port를 가리고 라우터가 부여한 고정 노드 id(prefill-<i> / decode-<i>)만 보여줍니다. 이 정도 정보만으로도 부하 분산 상태를 읽고 장애 노드가 unreachable로 표시됐는지 확인할 수 있으며, 추론 포트에 접근 가능한 클라이언트에게 클러스터 토폴로지까지 노출하지는 않습니다. 신뢰 구간 안에 있는 배포에서 전체 주소를 보고 싶으면 MLXCEL_ROUTER_STATS_VERBOSE=1을 설정하십시오.
KV 핸드오프 자체에는 심층 방어(defense-in-depth)로 프리필 노드가 연결 전에 수신한 decode_target을 MLXCEL_DECODE_ALLOWLIST(라우터가 선택할 수 있는 모든 디코드 노드를 포함하는 comma-separated host:port 목록)로 검증하는 옵션이 있습니다. 이렇게 하면 침해된 세그먼트에서 위조된 프레임이 들어와도 KV 캐시 핸드오프를 클러스터 밖으로 돌릴 수 없습니다. 라우터의 클라이언트 포트와 내부 프리필/디코드 포트가 이미 같은 신뢰 네트워크 세그먼트에 있을 때만 이 값을 비워 두십시오.
스트림 브리지¶
src/distributed/disaggregated/stream_bridge.rs
스트림 브리지는 클라이언트에 전달되는 SSE 토큰 스트림이 프리필-디코드 노드 경계를 넘어서도 원활하게 이어지도록 보장합니다.
flowchart LR
SB["**StreamBridge**\n\n프리필: 첫 번째 토큰\n\n디코드: 생성 토큰"]
SB -- "SSE stream" --> Client 브리지는 네 가지 스트림 단계를 추적합니다:
| 단계 | 설명 |
|---|---|
WaitingForPrefill | 프리필 노드가 첫 번째 토큰을 생성하기를 기다리는 중 |
HandoffToDecode | 첫 번째 토큰을 수신했으며 디코드 노드가 시작하기를 기다리는 중 |
Decoding | 디코드 노드가 활발하게 생성하고 토큰을 스트리밍하는 중 |
Complete | DONE 마커가 전송되었거나 오류로 스트림이 종료됨 |
브리지는 다음을 보장합니다: - 첫 번째 토큰(프리필에서)과 이후 토큰(디코드에서) 사이의 토큰 스트림에 간격이 없습니다. - 핸드오프에서 중복 토큰이 없습니다. - 디코드 노드가 완료된 후 적절한 DONE 신호를 전송합니다.
역할 기반 서빙 배포에서 디코드 노드는 생성한 토큰마다 별도의 result frame을 보내므로, 클라이언트는 핸드오프를 가로질러 버퍼링된 꼬리가 아닌 토큰 단위 SSE delta를 받습니다. 라우터는 종료 판단을 원격 스트림에 의존하지 않고 요청의 토큰 예산을 직접 집행해 예산에 도달하면 스트림을 닫습니다.
라우터 출력은 단일 노드 서빙과 동일한 StreamFilter를 거칩니다. 모델 내부 마커(<think>, 툴 콜 구분자, 잘못 새어 나온 턴 토큰)는 클라이언트에 도달하지 않고 thinking 내용은 스트리밍 응답에서 delta.reasoning_content로, 비스트리밍 응답에서 reasoning_content로 전달되어 단일 노드 동작과 byte 단위로 일치합니다. tool_calls 조립은 아직 라우터 경로에서 지원하지 않으며 필터가 툴 콜 구분자 마크업을 스트림에서 억제합니다.
KV 캐시 전송¶
src/distributed/kv_cache_transfer/
프리필 노드가 생성한 KV 캐시는 디코딩이 시작되기 전에 디코드 노드로 전송되어야 합니다. 이것이 분리의 주요 지연 비용입니다.
mlxcel은 여러 전송 전략을 지원합니다:
graph LR
PFN[Prefill Node] -->|transfer| DCN[Decode Node]
subgraph "Transfer Strategies"
FS[Full: send entire<br/>cache in one message]
ST[Streamed: layer-by-layer<br/>as each completes]
LP[LayerParallel: multiple layers<br/>concurrently]
end | 전략 | 설명 | 최적 사용 사례 |
|---|---|---|
Full | 전체 KV 캐시를 단일 메시지로 직렬화하고 전송 | 작은 캐시, 단순한 배포 |
Streamed | 각 레이어의 프리필이 완료되는 대로 레이어별로 전송하여 연산과 전송을 겹침 | 느린 링크에서 첫 번째 토큰 지연 감소 |
LayerParallel | 병렬 연결을 통해 여러 레이어를 동시에 전송 | 사용 가능한 병렬성이 있는 고대역폭 링크 |
적응형 전략 선택¶
AdaptiveSelector(src/distributed/kv_cache_transfer/adaptive.rs)는 BandwidthEstimator를 사용하여 노드 간 런타임 대역폭을 측정하고 최적의 전송 설정을 자동으로 선택합니다. 덕분에 배포마다 전송 전략을 수동으로 조정할 필요가 없습니다.
압축¶
선택적 LZ4 압축이 전송 전에 KV 캐시 데이터에 적용됩니다. 압축을 켜면 대역폭이 좁은 링크에서 전송 시간이 줄지만 양쪽 노드의 CPU 시간을 더 씁니다.
양자화¶
KV 캐시 텐서의 즉석 양자화를 사용할 수 있습니다(src/distributed/kv_cache_transfer/quantized.rs):
- float16 → int8: 2배 압축 비율.
- float16 → int4: 4배 압축 비율 (정확도 손실 있음).
양자화는 디코드 단계 어텐션에서 약간의 정확도 저하를 대가로 전송 대역폭 요구사항을 줄입니다. 대부분의 모델에서 int8 양자화가 품질에 미치는 영향은 무시할 수 있는 수준입니다.
KV 캐시 직렬화¶
src/distributed/kv_cache_serde/는 SerializableCacheState(전체 KV 캐시)와 SerializableSamplingState(디코드 노드에서 생성을 재개하기 위한 현재 샘플링 상태)의 직렬화 및 역직렬화를 처리합니다.
서빙 모드¶
src/distributed/disaggregated/serving.rs에 정의되어 있습니다:
| 모드 | CLI 역할 | 설명 |
|---|---|---|
Hybrid (기본값) | --node-role hybrid | 단일 노드 작동. 라우팅, 전송, 직렬화 오버헤드가 없습니다. 코드 경로는 비분산 서빙과 동일합니다. |
PrefillOnly | --node-role prefill | --serving-bind에서 토큰화된 request frame을 받아 프리필을 실행하고 첫 토큰을 내보낸 뒤 KV 캐시를 디코드 피어로 넘깁니다. 첫 번째 이후의 토큰은 생성하지 않습니다. |
DecodeOnly | --node-role decode | --serving-bind에서 프리필 피어로부터 KV 캐시를 받아 자기회귀 디코딩을 실행합니다. 원시 프롬프트는 처리하지 않습니다. |
Router | --node-role router | 모델을 로드하지 않는 HTTP 프런트. 토크나이저와 채팅 템플릿만 로드(가중치 없음)하고 수신 요청을 토큰화해 프리필 노드로 전달하며 프리필 첫 토큰과 디코드 연속 토큰을 하나의 클라이언트 응답으로 병합합니다. |
Hybrid 모드는 오버헤드가 없다: HybridModeGuard 래퍼는 모드가 Hybrid이면 이를 감지해 즉시 반환하거나 로컬 모델 제공자에 위임하며 분산 데이터 구조는 건드리지 않습니다. 이것은 "localhost 전송을 사용한 분리형"이 아니라 직접 패스스루입니다.
설정¶
| CLI 플래그 | 설명 |
|---|---|
--node-role <hybrid\|prefill\|decode\|router> | 서빙 역할. hybrid가 단일 노드 기본값입니다. |
--serving-bind <ADDR> | 서빙 역할 transport에서 이 노드 자신이 바인드할 주소. 프리필 노드는 여기서 request frame을, 디코드 노드는 KV 핸드오프를, 라우터는 result frame을 받습니다. |
--prefill-peers <ADDR,...> | 라우터가 토큰화된 요청을 전달할 프리필 노드의 서빙 주소 목록. |
--decode-peers <ADDR,...> | 프리필 노드가 KV 캐시를 넘길 디코드 노드의 서빙 주소 목록. 라우터도 result demultiplex를 위해 등록합니다. |
--cluster-secret-file <PATH> | 사전 공유 클러스터 시크릿. prefill, decode, router는 시크릿 없이는 시작하지 않습니다(MLXCEL_CLUSTER_SECRET_FILE, MLXCEL_CLUSTER_SECRET도 읽습니다). hybrid는 필요 없습니다. |
--node-id ID | 클러스터 내 고유 노드 식별자 |
--peers addr1,addr2 | 클러스터 수준 검색에 사용할 정적 피어 주소 |
--distributed-config PATH | 분산 추론용 TOML 클러스터 설정 파일 |
클러스터 시크릿과 전송 보호¶
프리필, 디코드, 라우터 프로세스는 하나의 클러스터 시크릿을 공유합니다. openssl rand -hex 32 > cluster.secret으로 한 번 생성(16바이트 이상)해 모든 호스트에 복사합니다. 모든 서빙 역할 연결은 클러스터 핸드셰이크(프로토콜 버전 5)를 거칩니다. 양쪽이 트랜스크립트에 대한 HMAC-SHA256으로 시크릿 보유를 증명하고, 수락 또는 용량 초과 verdict도 인증되며, 이후 모든 프레임은 연결별로 유도한 키로 AES-256-GCM 암호화와 인증을 거칩니다. v0.8.0 이전에 빌드한 프로세스는 v0.8.0 클러스터에 참여할 수 없으므로 모든 노드를 함께 업그레이드해야 합니다. 시크릿 우선순위와 프레임 세부 사항은 파이프라인 병렬화를 참조하십시오.
예시: 라우터, 프리필, 디코드 3-프로세스 배포¶
# 디코드 노드: --serving-bind에서 KV 핸드오프를 받습니다.
mlxcel-server --model ./models/qwen3-0.6b-4bit \
--node-role decode \
--cluster-secret-file ./cluster.secret \
--serving-bind 192.168.1.21:9100
# 프리필 노드: request frame을 받아 디코드 노드로 KV를 넘깁니다.
mlxcel-server --model ./models/qwen3-0.6b-4bit \
--node-role prefill \
--cluster-secret-file ./cluster.secret \
--serving-bind 192.168.1.20:9100 \
--decode-peers 192.168.1.21:9100
# 라우터: 모델 없이 동작하는 HTTP 프런트(토크나이저 + 채팅 템플릿만).
mlxcel-server --model ./models/qwen3-0.6b-4bit \
--port 8080 \
--node-role router \
--cluster-secret-file ./cluster.secret \
--serving-bind 192.168.1.10:9100 \
--prefill-peers 192.168.1.20:9100 \
--decode-peers 192.168.1.21:9100 \
--no-warmup
클라이언트는 라우터의 HTTP 포트에 일반 OpenAI 호환 API로 접속하며 병합된 응답은 같은 체크포인트를 단일 노드로 서빙할 때와 byte 단위로 동일합니다. 세 프로세스 모두 같은 --model 경로를 받습니다. 라우터는 그 경로를 토크나이저와 채팅 템플릿 로드에만 사용하므로 체크포인트 파일은 디스크에 있어야 하지만 가중치 메모리는 쓰지 않습니다. 가중치가 없어 실행할 수 없는 모델 워밍업은 --no-warmup으로 건너뜁니다.
핸드오프 경로에는 두 가지 운영 한도가 적용됩니다. 프리필 청크 크기보다 긴 프롬프트는 KV 핸드오프 전에 청크 프리필을 끝까지 진행하므로 긴 프롬프트도 단일 노드와 동일하게 동작합니다. 네트워크 인테이크는 1,048,576 토큰을 넘는 프롬프트를 거부합니다.