서빙 구조가 LLM 처리량을 바꾸는 방식: 엔진 설정과 서빙 계층을 분리해 재기¶
- 출처: 서빙 구조가 LLM 처리량을 바꾸는 방식 (Notion, Engineering Knowledge Base / CloudNetaStudy)
- 주제: Ray Serve, Triton, KServe를 같은 조건에서 비교해 엔진 설정과 서빙 구조의 영향을 분리 측정
- 환경: RTX 4080 Laptop 12,282 MiB, WSL2 커널 6.18.33.1, k3s v1.36.2+k3s1, Qwen2.5-1.5B-Instruct
- 읽은 날짜: 2026-09-10 (접힌 토글과 부록 A~D 포함 전문)
- 태그: #vLLM #RayServe #Triton #KServe #LLMServing #Throughput #Observability #GQA #KVCache
한 줄 요약¶
max_num_seqs를 올려도 처리량이 안 오른다면 엔진이 아니라 그 앞에 놓인 서빙 계층을 의심해야 한다. 같은 vLLM을 단독으로 돌리면 슬롯 변경으로 처리량이 139% 오르는데, Ray Serve 위에서는 3.7%에 그쳤다.
실험 설계¶
수치보다 설계를 먼저 봐야 하는 글이다. 통제 방식이 이 글의 값어치다.
| 항목 | 값 |
|---|---|
| GPU | NVIDIA GeForce RTX 4080 Laptop, 12,282 MiB |
| 드라이버 | 581.57 |
| 커널 | 6.18.33.1-microsoft-standard-WSL2 |
| k3s | v1.36.2+k3s1 |
| 모델 | Qwen/Qwen2.5-1.5B-Instruct |
공통 설정은 max_model_len=4096, gpu_memory_utilization=0.85, max_num_seqs=64다. 설정 스윕 구간만 예외다.
| 서빙 구조 | 버전 | 내장 vLLM |
|---|---|---|
| Ray Serve | KubeRay 1.4.2 / ray-llm 2.44.1 | 0.7.2 |
| Triton | 24.12 (vllm-python-py3) | 0.5.5 |
| KServe | 0.20.0 (RawDeployment) | 0.20.0 |
부하는 모든 구성에 같은 명령으로 보냈다. 짧은 프롬프트, 동시성 1·2·4·8·16·32·64, 단계마다 100개 요청이다. 각 프롬프트 앞에 서로 다른 접두사를 붙여 캐시 효과를 배제했다.
python3 benchmark.py --scenarios short --concurrency 1,2,4,8,16,32,64 \
--requests-per-level 100 --warmup 1 --unique-prefix \
--ttft-slo 0.5 --e2e-slo 10 --output results/<구성>.json
goodput은 TTFT 0.5초 이하와 E2E 10초 이하를 모두 만족한 요청의 비율이다. 처리량만 보면 토큰은 나오는데 응답이 늦는 상황을 못 잡는다.
GPU가 한 장뿐이라 구성을 동시에 못 돌린다. 한 구성을 완전히 내리고 nvidia-smi로 VRAM 반환을 확인한 뒤 다음을 올렸다.
측정 구간의 GPU 사용률은 84%와 91%까지 올라갔다. 뒤에 나오는 낮은 처리량을 GPU를 안 쓴 탓으로 돌릴 수 없다는 근거다. 다만 WSL2에서는 DCGM_FI_PROF_* 계열이 노출되지 않아 Tensor Core 사용률은 데이터 자체가 없다.
발단: 엔진 설정을 아무리 만져도 안 움직인다¶
Ray Serve 위에서 max_num_seqs를 16에서 64로 늘린 결과다.
| 구성 | c=64 처리량 | TTFT p95 | goodput |
|---|---|---|---|
| Ray Serve, 슬롯 16 | 821.6 tok/s | 3.777s | 16% |
| Ray Serve, 슬롯 64 | 852.3 tok/s | 4.006s | 19% |
| 차이 | +3.7% |
컨텍스트 길이와 GPU 메모리 비율을 바꿔 KV Cache 예산을 크게 흔들어도 마찬가지였다.
설정 (max_num_seqs=64) |
기동 로그의 Maximum concurrency | c=64 처리량 |
|---|---|---|
| 기준 (len 4096, util 0.85) | 62.04x | 852.3 tok/s |
| 컨텍스트 4096 → 16384 | 14.28x | 964.0 tok/s |
| 메모리 0.85 → 0.60 | 34.62x | 961.0 tok/s |
| 슬롯 16 (참고) | 64.02x | 821.6 tok/s |
추정 동시성이 4.5배 벌어졌는데 처리량은 822에서 964 사이에 머물렀다. 네 설정 모두 1,000 tok/s를 넘지 못했다. 위에서 뚜껑을 덮고 있다는 신호다.
설정이 실제로 먹었는지도 확인했다. /v1/models 응답의 rayllm_metadata.max_request_context_length가 4096으로, 표의 기준 행과 같았다.
여기서 저자가 짚은 오해가 하나 있다. 기동 로그의 Maximum concurrency는 실제 동시 사용자 수가 아니다. 모든 요청이 최대 컨텍스트를 쓴다고 가정한 추정치이고, 이번 부하는 짧은 프롬프트라 그 상한에 닿지도 않았다.
엔진 버전을 통제한 방식¶
2주차 기준선은 vLLM 0.23.0, Ray Serve 이미지 안은 0.7.2였다. 그냥 비교하면 엔진 버전 차이까지 서빙 오버헤드로 잡힌다.
| 구성 | 엔진 | 서빙 계층 |
|---|---|---|
| A | vLLM 0.23.0 | 없음 (기존 기준선) |
| B | vLLM 0.7.2 | 없음 (이 실험에서 새로 만듦) |
| C | vLLM 0.7.2 | Ray Serve |
A와 C를 바로 비교하면 -39.3%가 나온다. 그런데 엔진 버전만 바꾼 A에서 B로 갈 때 이미 -9.9%가 발생한다. 그래서 같은 0.7.2를 쓰는 B와 C만 비교했다. Triton과 KServe도 각자 자기 이미지에서 서빙 계층만 걷어낸 기준선을 따로 만들었고, 로그로 일치를 확인했다. Triton 쪽은 양쪽 모두 # GPU blocks: 15326, KServe 쪽은 GPU KV cache size: 247,024 tokens와 Maximum concurrency 60.31x가 같았다.
서빙 계층을 걷어내자 설정이 다시 살아났다¶
| 구조 | 슬롯 16 → 64, c=64 처리량 | 변화 |
|---|---|---|
| Ray Serve | 821.6 → 852.3 | +3.7% |
| vLLM 단독 | 1,186.1 → 2,836.8 | +139.2% |
max_num_seqs 설정 자체는 정상 동작했다. 다만 Ray Serve 구성에서는 엔진이 더 처리할 수 있게 돼도 그게 전체 처리량으로 나오지 못했다.
요청 경로를 보면 짐작이 간다. Ray Dashboard 기준으로 엔진을 감싼 LLMDeployment replica 1개 앞에 LLMRouter replica 2개와 프록시가 놓여 있다.
부하가 커질수록 격차가 벌어진다¶
| 동시성 | vLLM 단독 | Ray Serve | 차이 |
|---|---|---|---|
| 1 | 104.6 | 97.6 | -6.7% |
| 2 | 201.1 | 182.4 | -9.3% |
| 4 | 384.8 | 336.2 | -12.6% |
| 8 | 700.9 | 567.3 | -19.1% |
| 16 | 1,201.5 | 745.8 | -37.9% |
| 32 | 1,853.1 | 788.8 | -57.4% |
| 64 | 2,836.8 | 852.3 | -70.0% |
단독 구성은 동시성이 오를수록 처리량이 계속 따라 올라 c=64에서 2,837 tok/s에 닿았다. Ray Serve는 c=16을 넘기면서 850 tok/s 언저리에서 포화됐다. 오버헤드가 고정 비율이 아니라 부하에 비례해 커진다는 뜻이다.
처리량보다 goodput이 먼저 무너진다¶
| 동시성 | vLLM 단독 TTFT p95 / goodput | Ray Serve TTFT p95 / goodput |
|---|---|---|
| 16 | 0.112s / 100% | 0.285s / 100% |
| 32 | 0.190s / 100% | 1.698s / 48% |
| 64 | 0.330s / 100% | 4.006s / 19% |
단독 구성은 c=64에서도 0.33초로 SLO에 여유가 있고 goodput 100%를 유지했다. Ray Serve는 c=32부터 절반이 SLO를 못 맞추고, c=64에서는 10건 중 8건이 미달했다.
운영 관점에서 이쪽이 더 아프다. 처리량 30%는 장비를 늘려 메울 수 있지만, 첫 토큰이 4초 걸리는 건 사용자가 바로 느낀다.
Triton과 KServe는 어땠나¶
Triton (24.12 컨테이너, vLLM 0.5.5)
| 동시성 | vLLM 단독 | Triton | 차이 |
|---|---|---|---|
| 1 | 98.0 | 91.5 | -6.6% |
| 8 | 633.4 | 603.4 | -4.7% |
| 16 | 1,065.3 | 1,021.3 | -4.1% |
| 32 | 1,593.4 | 1,537.1 | -3.5% |
| 64 | 2,310.1 | 2,018.9 | -12.6% |
동시성이 올라도 오버헤드가 누적되는 패턴이 없다. c=64에서 goodput 100%, TTFT p95 0.384초로 SLO를 지켰다.
저자가 여기서 스스로 제동을 건다. c=64의 -12.6%는 재현 측정의 변동폭과 겹친다. 같은 서버를 두 번 쟀을 때 c=64에서만 약 10% 차이가 났고 c가 32 이하에서는 1.3% 안이었다.
KServe (RawDeployment, vLLM 0.20.0, Istio와 Knative 없는 k3s)
| 동시성 | vLLM 단독 | KServe | 차이 |
|---|---|---|---|
| 1 | 113.5 | 113.7 | +0.2% |
| 8 | 791.3 | 781.8 | -1.2% |
| 16 | 1,400.5 | 1,384.9 | -1.1% |
| 32 | 2,032.5 | 2,166.5 | +6.6% |
| 64 | 2,714.2 | 2,654.0 | -2.2% |
부호가 오르내린다. 측정 변동과 구분되지 않는 수준이다.
갈림은 요청 경로 개입 여부다¶
| 서빙 구조 | 요청 경로 개입 | vLLM 버전 | 오버헤드 (c=64) | goodput | TTFT p95 | vllm:* 메트릭 |
|---|---|---|---|---|---|---|
| vLLM 단독 | 해당 없음 | 0.7.2 / 0.5.5 / 0.20.0 | 기준 | 100% | 0.30~0.35s | 15 / 21 / 66개 |
| KServe (RawDeployment) | 아니오 (컨트롤 플레인) | 0.20.0 | -2.2% | 100% | 0.310s | 66개 보존 |
| Triton (vLLM 백엔드) | 예 | 0.5.5 | -12.6% | 100% | 0.384s | 0개 (nv_* 22개) |
| Ray Serve (ray-llm) | 예 | 0.7.2 | -70.0% | 19% | 4.006s | 0개 (ray_* 184개) |
KServe RawDeployment는 컨트롤 플레인이다. InferenceService를 받아 Deployment와 Service를 만들고 나면 요청 경로에서 빠진다. 클라이언트 요청이 vLLM HTTP 서버로 바로 가므로 중간 비용이 거의 없다.
Triton과 Ray Serve는 데이터 플레인이다. 모든 요청이 그 서버를 거쳐 엔진으로 간다. 둘의 차이는 그 처리 비용이 동시성에 따라 얼마나 커지느냐에서 갈렸다.
저자는 여기서도 단정을 피한다. 세 도구는 역할이 다르므로 이 수치로 우열을 정할 수 없다는 것이다. KServe는 요청을 중계하지 않으니 요청 단위 라우팅이나 모델 조합을 애초에 하지 않는다. Ray Serve가 제공하는 요청 단위 라우팅, 모델 조합, 파이썬 파이프라인은 요청 경로에 개입해야만 구현할 수 있는 기능이다.
관측 가능성도 같이 잃는다¶
성능 표에 가려지기 쉬운데, 실무에서는 이쪽이 더 오래 발목을 잡는다.
| 구성 | 메트릭 엔드포인트 | vllm:* 메트릭 수 |
자체 메트릭 |
|---|---|---|---|
| vLLM 단독 (0.20.0) | :8080/metrics → 200 |
66개 | |
| vLLM 단독 (0.5.5) | :8000/metrics → 200 |
21개 | |
| vLLM 단독 (0.7.2) | 200 | 15개 | |
| KServe + vLLM | :8080/metrics → 200 |
66개 유지 | |
| Triton + vLLM | :9000/metrics → 200 |
0개 | nv_* 22개 |
| Ray Serve + vLLM | :8000/metrics → 404 |
0개 | ray_* 184개 (:8080) |
KServe에서는 vllm:kv_cache_usage_perc와 vllm:num_preemptions_total이 그대로 보인다. 부하 뒤 vllm:prefix_cache_queries_total은 209,650, vllm:prefix_cache_hits_total은 38,452였다.
Triton의 nv_* 22개는 엔진 지표는 아니지만 서빙 계층 대기는 볼 수 있다. nv_inference_queue_duration_us와 nv_inference_pending_request_count가 있다. 2,401건 처리에 누적 큐 대기가 1,179,290µs였으니 건당 약 491µs다.
Ray Serve는 API 포트에 메트릭 엔드포인트가 아예 없다. /v1/models가 정상 응답하는 같은 포트의 :8000/metrics가 Not Found이고, ray_* 184개는 :8080에 따로 있다.
로그 위치도 함정이다. Ray Serve에서 vLLM 엔진은 ServeReplica가 아니라 _EngineBackgroundProcess라는 별개 액터에서 돈다. PID가 377과 187로 다르다. 그래서 replica의 STDOUT에는 네 줄뿐이고, 기동 로그는 파드의 /tmp/ray/session_latest/logs/에서 찾아야 했다.
Triton과 Ray Serve에서는 KV Cache 사용률과 선점 횟수를 직접 볼 수 없다. 메모리 부족으로 요청 선점이 일어났는지를 엔진 지표로 확인할 방법이 없다는 뜻이다.
부록에서 건진 것¶
A. Triton dynamic batching을 쓰지 않은 이유¶
Triton에는 자체 배칭이 있는데 이번에는 껐다. 그 판단을 검증하려고 따로 쟀다. 모델은 LLM이 아니라 mobilenet_v2(ONNX)를 썼다.
| 지연 설정 | c=1 | c=8 | c=32 |
|---|---|---|---|
| 없음 (대조군) | 258.9 inf/s | 1,290.6 | 1,368.9 |
| 1ms | 246.9 | 1,281.1 | 1,371.4 |
| 20ms | 37.5 | 396.1 | 1,371.2 |
동시성이 낮으면 요청을 모으려 기다린 시간이 그대로 지연이 된다. 20ms 조건의 c=1은 대조군의 7분의 1이다. 반대로 도착률이 충분한 c=32에서는 세 조건 모두 약 1,370 inf/s로 같아졌다. dynamic batching의 효과는 설정값보다 요청 도착률에 좌우된다.
LLM에 맞지 않는 이유는 생성 길이의 편차다. 10토큰짜리와 500토큰짜리를 한 배치로 묶으면 먼저 끝난 요청이 나머지를 기다린다. vLLM이 continuous batching을 쓰는 이유이고, 배칭을 vLLM 백엔드에 맡긴 이유다.
B. KV Cache 공식이 6배 틀린 이유¶
이 부록 하나만으로도 글을 읽을 값어치가 있다. 교재 공식은 이렇다.
Qwen2.5-1.5B에 넣으면 2 × 28 × 12 × 128 × 2 = 172,032바이트, 즉 168.0 KiB/토큰이다. 그런데 기동 로그에서 역산한 값은 28.0 KiB/토큰이었다. 6배 차이다.
원인은 GQA(Grouped Query Attention)다. 위 공식은 어텐션 헤드 수와 KV 헤드 수가 같은 MHA를 전제한다. Qwen2.5-1.5B는 어텐션 헤드가 12개지만 KV 헤드는 2개다. KV Cache는 K와 V만 저장하므로 12가 아니라 2를 넣어야 한다.
이 값은 서로 다른 네 번의 기동 로그와 소수점 둘째 자리까지 맞았다. 요즘 모델은 대부분 GQA를 쓰므로 config.json의 num_key_value_heads를 확인하는 습관이 필요하다는 결론이다.
C. KServe를 k3s에 올리며 막힌 지점들¶
첫 응답을 받기까지 걸린 문제들이다. 모두 매니페스트 수정으로 풀렸지만 원인 찾기가 오래 걸렸다고 한다.
kserve.yaml이 네임스페이스를 만들지 않는다.kubectl create namespace kserve가 먼저다.- 기본 모드가 Serverless다. Istio와 Knative가 없으면 InferenceService가 계속
Ready=False다.inferenceservice-config의deploy를 RawDeployment로 바꾸고 컨트롤러를 재시작해야 한다. - 번들 런타임의 실행 설정과 이미지가 어긋난다.
kserve-vllmserver는python을 실행하는데 지정된vllm/vllm-openai:v0.20.0에는python3만 있다. 같은 맥락으로 vLLM 0.20.0에는--disable-log-requests가 없어--no-enable-log-requests를 써야 한다. - HF 캐시를
storageUri로 바로 가리키면 안 된다.snapshots/<hash>/안의 파일은../../blobs/<sha>를 가리키는 심볼릭 링크다. 그 폴더만subPath로 잘라 마운트하면 링크가 끊긴다. 캐시 루트 전체를 마운트하고--model로 스냅샷 경로를 지정해야 한다. - WSL2 k3s에서는
runtimeClassName: nvidia가 필수다. 빠지면 CUDA를 인식하지 못한다. - GPU가 한 장이면 롤링 업데이트가 자동으로 안 끝난다. 새 파드가 GPU를 기다리는 동안 기존 파드가 계속 점유해 교착이 된다. 기존 ReplicaSet을 0으로 줄여야 진행된다.
저자가 밝힌 한계¶
- 짧은 프롬프트 한 종류만 썼다. 긴 프롬프트에서는 KV Cache 상한이 처리량에 직접 영향을 줄 수 있다.
- Ray Serve 병목의 정확한 원인은 규명하지 못했다. 프록시, 라우터 replica 수, 직렬화 비용 중 무엇인지
ray_serve_*지표로 더 재야 한다. - 각 조건을 한 번씩만 측정했다. c=64에서 약 10%, c가 32 이하에서 1.3% 이내의 편차가 있다.
- 엔진 버전이 구성마다 다르다. 각 도구는 같은 버전의 단독 구성과 비교했지만, 구성끼리의 절대값 비교(2,837 대 2,310)에는 버전 차이가 섞여 있다.
- 레플리카가 1개라 Ray Serve의 오토스케일링, 로드밸런싱, KV cache aware routing은 재지 못했다. 성능 오버헤드만 비교했고 운영 기능의 효용은 평가하지 않았다.
여기에 덧붙일 유보¶
- 랩탑 GPU에 WSL2다. RTX 4080 Laptop 12GB이고 커널이 WSL2다. 프로덕션 서버와 CPU, 메모리 대역폭, PCIe, 네트워크 스택이 모두 다르다. 서빙 계층의 오버헤드는 CPU 바운드 성격이 강해 이 조건에서 특히 부풀려 보일 수 있다.
- 모델이 1.5B로 작다. 모델이 커지면 GPU 연산 시간이 지배적이 되어 계층 비용의 상대 비중이 줄어든다. 작은 모델일수록 계층 오버헤드가 두드러진다.
- KServe RawDeployment는 성격이 다른 비교 대상이다. 요청 경로에 개입하지 않으므로 오버헤드가 0에 가까운 건 거의 당연하다. 같은 표에 있다고 대등한 선택지로 읽으면 곤란하다.
- Ray Serve 구성만 라우터가 2개다. replica 1개짜리 엔진 앞에 라우터 2개와 프록시가 있는 구성인데, 이 배치가 기본값인지 설정 결과인지는 글에 없다. 라우터 수를 줄였을 때의 측정이 있었으면 병목 원인이 좁혀졌을 것이다.
가져갈 지점¶
- "설정을 바꿨는데 왜 안 빨라지지"의 진단 순서
이 글이 주는 가장 실용적인 자산은 절차다. 엔진 파라미터를 크게 흔들었는데 처리량과 지연이 거의 안 움직이면, 파라미터를 더 만지지 말고 같은 엔진을 계층 없이 한 번 돌려본다. 거기서 차이가 나면 원인이 엔진 밖에 있다는 뜻이다. 이 한 번의 비교가 며칠을 아낀다.
- goodput을 처리량과 함께 본다
처리량 -70%보다 goodput 19%가 먼저 눈에 띄어야 한다. c=32에서 이미 절반이 SLO를 못 맞추는데 처리량만 보면 788 tok/s로 그럭저럭 나온다. 부하 시험 지표에 TTFT p95와 SLO 충족률을 같이 넣어야 하는 근거다. 정의도 가져다 쓸 만하다. TTFT와 E2E 두 조건을 모두 만족한 비율이다.
- 관측 지표를 잃는 구조는 나중에 비싸다
vllm:* 지표가 사라지면 KV cache 사용률과 선점 횟수를 못 본다. 병목이 생겼을 때 원인을 좁힐 수단이 없다는 뜻이다. 도구를 고를 때 기능 목록만 보지 말고 필요한 엔진 지표가 계속 노출되는지를 조건에 넣어야 한다. 로그 위치가 바뀌는 것도 같은 비용이다.
- KV Cache 용량 계산에는
num_key_value_heads를 쓴다
부록 B는 바로 써먹을 수 있다. GQA 모델에 MHA 공식을 적용하면 용량을 몇 배로 잘못 잡는다. 서빙 용량 산정이나 gpu_memory_utilization 결정에 직결되는 계산이다.
- 버전 통제 없는 벤치마크는 신뢰하지 않는다
A와 C를 바로 비교했으면 -39.3%라는 숫자가 남았을 것이고, 그중 -9.9%는 엔진 버전 차이였다. 남의 벤치마크를 인용할 때도 기준선이 어떻게 잡혔는지부터 확인해야 한다.
결론¶
결론 문장은 단순하다. 엔진 설정의 효과와 서빙 구조의 영향은 분리해서 재야 한다. 같은 설정 변경이 단독 구성에서는 139% 개선을 만들고 Ray Serve 위에서는 3.7%에 그쳤다는 대비가 그 근거다.
글의 미덕은 수치보다 태도에 있다. 엔진 버전을 맞추려고 기준선을 새로 만들었고, 설정이 실제로 적용됐는지 응답으로 확인했으며, 재현 변동과 겹치는 구간은 확정하지 않았다. 자기 실험 조건이 Ray Serve에 불리하다는 점도 스스로 적었다.
다만 랩탑 GPU와 WSL2라는 환경, 1.5B 모델, 단일 레플리카라는 조건은 결과의 적용 범위를 좁힌다. 수치를 그대로 가져다 쓰기보다 측정 설계를 가져다 쓰는 편이 이 글의 올바른 사용법이다.