NVIDIA L40S 1장(쿠버네티스 테넌트 클러스터, KubeVirt 워커 VM + HAMi) 위에서 TensorRT-LLM 1.2.1을 처음부터 굴려본 기록입니다.
1부 — NGC 컨테이너 설치 → LLM API quickstart → trtllm-serve OpenAI 호환 서빙
2부 — trtllm-bench 네이티브 벤치 + 엔진 중립 하네스로 성능 실측
3부ModelOpt FP8 PTQ (L40S = Ada = FP8 네이티브)
보너스 — 동일 하드웨어·동일 모델·동일 하네스로 vLLM 0.28.0과 2×2 대조

질문답

TRT-LLM이 vLLM보다 빠른가? 워크로드에 따라 다르다. 짧은 입력(1K)에선 처리량 **+1.8%**로 사실상 동률, 긴 입력(4K)에선 +10%. FP8을 켜면 격차가 **+15% / +30%**로 벌어진다.
가장 큰 레버는? 엔진 선택이 아니라 FP8 양자화. 같은 엔진에서 FP8이 처리량을 +49~51% 올린다. 엔진 교체 효과(+2~10%)보다 한 자릿수 배 크다.
첫 토큰 지연(TTFT)은? 정반대로 갈린다. 짧은 입력은 vLLM이 중앙값 1.8배 빠르고, 긴 입력은 TRT-LLM이 2.9배 빠르다. 기본값 chunked prefill이 서로 반대(vLLM ON / TRT-LLM OFF)인 것이 핵심.
꼬리 지연은? TRT-LLM이 전 구간 우세 (P99 TTFT 13~26% 낮음).
FP8 양자화 비용은? 66.7초. 캘리브레이션 256샘플 포함. 가중치 15GB→8.2GB(−45%), KV 캐시 +24%.
Int8과 비교하면? GPTQ-Int8은 FP16보다 느렸다(커널 미성숙). 반면 FP8은 Ada 텐서코어 네이티브라 전 지표에서 이득. "비트를 줄이면 빨라진다"가 아니라 **"하드웨어가 그 포맷을 네이티브로 아느냐"**가 갈랐다.
 

한 줄 요약: L40S에서 7B를 굴린다면 엔진 고민보다 FP8을 먼저 켜라. 그다음 트래픽이 프리필 무겁고(긴 입력) 꼬리 지연이 중요하면 TRT-LLM, 짧은 입력에 중앙값 TTFT가 중요하면 vLLM.

0. 개념 — TensorRT-LLM은 무엇이 다른가

0.1 두 개의 백엔드

TensorRT-LLM은 이름 때문에 오해받는데, 1.x부터는 TensorRT 엔진을 굽지 않는 경로가 기본이다.

백엔드흐름상태
PyTorch 백엔드 (--backend pytorch) HF 체크포인트 → 곧바로 실행. 커스텀 CUDA 커널 + CUDA Graph + in-flight batching 1.x 기본값, 본 실습에서 사용
TensorRT 엔진 백엔드 trtllm-build로 AOT 컴파일 → 직렬화된 .engine → 로드 레거시. 형상(batch/seq len)을 미리 고정해야 함
 

즉 "TensorRT-LLM = 엔진 빌드가 오래 걸린다"는 인상은 구버전 이야기다. 실습에서 trtllm-serve가 모델 로드부터 서빙 준비까지 약 100초에 끝난다.

0.2 핵심 메커니즘

  • In-flight batching (= continuous batching): 요청이 끝나는 대로 배치 슬롯을 즉시 회수. vLLM의 continuous batching과 같은 개념.
  • Paged KV cache: 블록 단위 KV 관리(기본 tokens_per_block=32). vLLM의 PagedAttention과 대응.
  • Block reuse (enable_block_reuse=True): 프리픽스 캐싱. vLLM의 enable_prefix_caching과 대응.
  • attn_backend='TRTLLM': 자체 fused MHA 커널.
  • CUDA Graph: 배치 크기별로 그래프를 미리 캡처(기본 1~32, 64, 128)해 디코드 스텝의 런치 오버헤드 제거.

0.3 L40S에서의 양자화 — 왜 FP8인가

L40S는 Ada Lovelace, compute capability 8.9다.

포맷L40S 지원비고
BF16/FP16 ✅ 네이티브 기준선
FP8 (E4M3) 텐서코어 네이티브 Ada부터 지원. 본 실습의 주인공
NVFP4 Blackwell(sm100+) 세대 필요
INT8/INT4 ✅ (커널 성숙도 편차) Int8이 FP16보다 느렸던 이유
 

"비트 반 줄이면 반 빨라진다가 아니다"Int8이 느렸던 건 비트 폭 때문이 아니라 커널이 덜 여물어서였고, FP8은 하드웨어가 네이티브로 아는 포맷이라 전 지표에서 이득이 난다.

# 내 GPU가 FP8을 네이티브로 아는지 확인 (8.9 이상이면 FP8 OK)
nvidia-smi --query-gpu=name,compute_cap --format=csv,noheader
# NVIDIA L40S, 8.9

1. 환경 & 설치

1.1 환경

  • 쿠버네티스 테넌트 클러스터(KubeVirt 워커 VM), NVIDIA L40S 1장HAMi로 할당 ([nvidia.com/gpu](https://nvidia.com/gpu): 1, [nvidia.com/gpumem](https://nvidia.com/gpumem): 44000, [nvidia.com/gpucores](https://nvidia.com/gpucores): 100)
  • 드라이버 580.105.08 / CUDA 13.0
  • TensorRT-LLM 1.2.1 (nvcr.io/nvidia/tensorrt-llm/release:1.2.1), 대조군 vLLM 0.28.0 (vllm/vllm-openai:latest)
  • 모델 Qwen/Qwen2.5-7B-Instruct (BF16, TP=1)
  • 노드: 16 vCPU / 64GB RAM / 디스크 150GB

⚠️ 트러블슈팅 ① — 디스크 100GB로는 시작조차 못 한다

첫 시도에서 워커 노드(디스크 100GB)가 DiskPressure로 파드를 전부 Evict했다.

항목크기
기존 OS/k8s 18GB
TRT-LLM 이미지 (압축 19.5GB → 언팩) ~50GB
Qwen2.5-7B BF16 15GB
kubelet eviction 임계 ~15GB
합계 ~98GB / 100GB
 

Evicted: The node was low on resource: ephemeral-storage → 노드 디스크를 150GB로 리사이즈해서 해결. FP8 체크포인트(8.2GB)까지 얹어도 여유 30GB 남는다. TRT-LLM 컨테이너는 vLLM(~16GB)의 3배라는 점을 용량 산정에 반드시 반영해야 한다.

1.2 이미지 확인 — 문서의 태그를 그대로 믿지 말 것

공식 설치 가이드는 nvcr.io/nvidia/tensorrt-llm/release:1.3.0rc25를 안내하지만, 실제 레지스트리엔 그 태그가 없었다. 익명으로 태그를 직접 조회하는 편이 확실하다:

R=nvidia/tensorrt-llm/release
T=$(curl -s "https://nvcr.io/proxy_auth?scope=repository:$R:pull" \
    | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')

# 버전 태그만 추출
curl -s -H "Authorization: Bearer $T" "https://nvcr.io/v2/$R/tags/list" \
 | python3 -c 'import sys,json,re; t=json.load(sys.stdin)["tags"]; print("\n".join([x for x in t if re.match(r"^\d+\.\d+\.\d+$",x)]))'
# 0.20.0 / 0.21.0 / 1.0.0 / 1.1.0 / 1.2.0 / 1.2.1   ← 최신 stable = 1.2.1

# 이미지 크기(압축 기준) 미리 확인
curl -s -H "Authorization: Bearer $T" \
  -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
  "https://nvcr.io/v2/$R/manifests/1.2.1" \
 | python3 -c 'import sys,json; d=json.load(sys.stdin); print(f"{sum(l[\"size\"] for l in d[\"layers\"])/2**30:.1f} GB")'
# 19.5 GB

NGC의 TensorRT-LLM release 저장소는 NGC API 키 없이 익명 pull이 된다(위 proxy_auth가 그 증거).

1.3 워크벤치 파드

# trtllm-shell.yaml — 실습용 셸 파드
apiVersion: v1
kind: Pod
metadata: { name: trtllm-shell, namespace: trtllm }
spec:
  restartPolicy: Never
  nodeSelector: { nvidia.com/gpu.present: "true" }   # ← nodeName 쓰지 말 것 (아래 트러블슈팅 ②)
  containers:
  - name: trtllm
    image: nvcr.io/nvidia/tensorrt-llm/release:1.2.1
    command: ["bash","-lc","sleep infinity"]          # ← -l 필수 (아래 트러블슈팅 ③)
    env: [ { name: HF_HOME, value: /hf } ]
    resources:
      limits: { nvidia.com/gpu: "1", nvidia.com/gpumem: "44000", nvidia.com/gpucores: "100", cpu: "12", memory: 48Gi }
      requests: { cpu: "4", memory: 16Gi }
    volumeMounts:
      - { name: hf,  mountPath: /hf }
      - { name: shm, mountPath: /dev/shm }            # ← MPI/NCCL용
  volumes:
    - { name: hf,  hostPath: { path: /var/lib/hf-cache, type: DirectoryOrCreate } }
    - { name: shm, emptyDir: { medium: Memory, sizeLimit: 16Gi } }
kubectl create ns trtllm
kubectl apply -f trtllm-shell.yaml
kubectl -n trtllm exec trtllm-shell -- bash -lc 'python3 -c "import tensorrt_llm; print(tensorrt_llm.__version__)"'
# TensorRT-LLM 1.2.1

⚠️ 트러블슈팅 ② — HAMi는 nodeName 직접 지정을 거부한다

Error from server (Forbidden): admission webhook "vgpu.hami.io" denied the request: pod has node assigned

spec.nodeName으로 노드를 못 박으면 기본 스케줄러를 우회하게 되고, HAMi는 자기 스케줄러(hami-scheduler)를 거치지 않은 GPU 파드를 막는다. GPU를 쓰는 파드는 반드시 nodeSelector/affinity로 유도하고 배치는 스케줄러에 맡겨야 한다. (GPU를 안 쓰는 보조 파드는 nodeName을 써도 무방하다.)

⚠️ 트러블슈팅 ③ — command:로 바이너리를 직접 부르면 라이브러리를 못 찾는다

Deployment에 command: ["trtllm-serve"]로 적었더니 기동 즉시 죽었다: ImportError: libnvonnxparser.so.10: cannot open shared object file: No such file or directory

이 라이브러리는 /usr/local/tensorrt/lib에 분명히 존재한다. 문제는 그 경로를 LD_LIBRARY_PATH에 넣어주는 주체가 이미지의 ENTRYPOINT/프로파일이라는 것. 쿠버네티스에서 command:를 주면 ENTRYPOINT가 교체되면서 그 환경 설정이 통째로 사라진다.

# ❌ 라이브러리 경로 소실
command: ["trtllm-serve"]
args: ["Qwen/Qwen2.5-7B-Instruct", "--port=8000"]

# ✅ 로그인 셸을 거쳐 프로파일을 로드
command: ["bash","-lc"]
args: ["exec trtllm-serve Qwen/Qwen2.5-7B-Instruct --host 0.0.0.0 --port 8000 --backend pytorch"]

exec를 붙여야 셸이 PID 1을 넘겨줘서 SIGTERM이 서버까지 전달된다(graceful shutdown).

2. Quickstart — LLM API

# quickstart.py
from tensorrt_llm import LLM, SamplingParams

def main():                                  # ← 이 감싸기가 필수 (아래 트러블슈팅 ④)
    prompts = [
        "Hello, my name is",
        "The capital of France is",
        "The future of AI is",
    ]
    sampling_params = SamplingParams(temperature=0.8, top_p=0.95)

    llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0")
    outputs = llm.generate(prompts, sampling_params)

    for output in outputs:
        print(f"Prompt: {output.prompt!r}, Generated text: {output.outputs[0].text!r}")

if __name__ == "__main__":
    main()
kubectl -n trtllm exec trtllm-shell -- bash -lc 'python3 /tmp/quickstart.py'
[TensorRT-LLM][INFO] Number of tokens per block: 32.
[TensorRT-LLM][INFO] [MemUsageChange] Allocated 35.84 GiB for max tokens in paged KV cache (1708256).
Prompt: 'Hello, my name is', Generated text: 'John Smith. I am a male and I love to play video games...'
Prompt: 'The capital of France is', Generated text: 'Paris.'
Prompt: 'The future of AI is', Generated text: "something that everyone will see in their own lives..."

1.1B 모델인데 KV 캐시로 35.84 GiB(170만 토큰)를 잡는 데 주목. 기본 free_gpu_memory_fraction=0.9가 남는 메모리를 거의 다 KV로 가져간다. 한 GPU에 여러 모델을 얹을 생각이라면 이 값을 반드시 낮춰야 한다.

⚠️ 트러블슈팅 ④ — 노트북에선 되는데 스크립트로 옮기면 터진다

The main script or module attempted to spawn new MPI worker processes...

LLM 생성자가 MPI 워커 프로세스를 spawn하는데, 가드가 없으면 자식이 모듈을 다시 import하며 무한 재귀 spawn이 된다. 주피터 셀에선 __name__이 "__main__"이라 안 걸리고, .py로 옮기는 순간 터진다. 노트북 예제를 프로덕션 스크립트로 옮길 때 가장 먼저 밟는 지뢰.

3. trtllm-serve — OpenAI 호환 서빙

# engine.yaml — 서버 + Service (실험마다 args만 교체)
apiVersion: v1
kind: Service
metadata: { name: engine, namespace: trtllm }
spec:
  selector: { app: engine }
  ports: [ { port: 8000, targetPort: 8000 } ]
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: engine, namespace: trtllm }
spec:
  replicas: 1
  strategy: { type: Recreate }          # GPU 1장 → 롤링 불가
  selector: { matchLabels: { app: engine } }
  template:
    metadata: { labels: { app: engine } }
    spec:
      nodeSelector: { nvidia.com/gpu.present: "true" }
      containers:
      - name: engine
        image: nvcr.io/nvidia/tensorrt-llm/release:1.2.1
        command: ["bash","-lc"]
        args: ["exec trtllm-serve Qwen/Qwen2.5-7B-Instruct --host 0.0.0.0 --port 8000 --backend pytorch"]
        env: [ { name: HF_HOME, value: /hf } ]
        ports: [ { containerPort: 8000 } ]
        resources:
          limits: { nvidia.com/gpu: "1", nvidia.com/gpumem: "44000", nvidia.com/gpucores: "100", cpu: "12", memory: 48Gi }
          requests: { cpu: "4", memory: 16Gi }
        readinessProbe: { httpGet: { path: /health, port: 8000 }, initialDelaySeconds: 30, periodSeconds: 10, failureThreshold: 180 }
        volumeMounts:
          - { name: hf,  mountPath: /hf }
          - { name: shm, mountPath: /dev/shm }
      volumes:
        - { name: hf,  hostPath: { path: /var/lib/hf-cache, type: DirectoryOrCreate } }
        - { name: shm, emptyDir: { medium: Memory, sizeLimit: 16Gi } }
kubectl apply -f engine.yaml
kubectl -n trtllm rollout status deploy/engine --timeout=1200s   # 최초 기동 ~100초 (모델 캐시 있을 때)

kubectl -n trtllm exec bench -- curl -s http://engine:8000/v1/models
# {"object":"list","data":[{"id":"Qwen/Qwen2.5-7B-Instruct","object":"model","owned_by":"tensorrt_llm"}]}

kubectl -n trtllm exec bench -- curl -s http://engine:8000/v1/chat/completions \
  -H 'Content-Type: application/json' -d '{
    "model":"Qwen/Qwen2.5-7B-Instruct",
    "messages":[{"role":"user","content":"What is model quantization? Answer in one sentence."}],
    "max_tokens":60,"temperature":0}'

엔드포인트는 /v1/chat/completions, /v1/completions, /health, /metrics, /version.

3.1 실제 적용된 기본값 — 로그가 정답이다

trtllm-serve는 기동 시 최종 확정된 설정 객체를 통째로 로그에 찍는다. 문서보다 이쪽이 정확하다.

kubectl -n trtllm logs -l app=engine --tail=1200 | grep -oE \
  "max_batch_size=[0-9]+|max_num_tokens=[0-9]+|free_gpu_memory_fraction=[0-9.]+|max_tokens=[0-9]+|enable_chunked_prefill=[A-Za-z]+|enable_block_reuse=[A-Za-z]+|tokens_per_block=[0-9]+|attn_backend='[A-Z]+'"

 

파라미터 TRT-LLM 1.2.1 기본값 vLLM 0.28.0 기본값
KV 메모리 노브 free_gpu_memory_fraction=0.9

(가중치 적재 후 남은 메모리의 90%)
gpu_memory_utilization=0.92

(전체 GPU 메모리의 92%, 가중치 포함)
KV 캐시 크기 (실측) 446,681 tok (25.6GB) 431,024 tok
tokens_per_block / block_size 32 auto (기본 None)
enable_chunked_prefill False ⚠️ True ⚠️
프리픽스 캐싱 enable_block_reuse=True enable_prefix_caching=True
max_batch_size 2048 auto
max_num_tokens 8192 auto
어텐션 백엔드 TRTLLM FlashAttention/FlashInfer
스케줄러 정책 GUARANTEED_NO_EVICT
 

메모리 노브는 이름도 의미도 다르다. TRT-LLM의 0.9는 가중치를 올리고 남은 메모리 기준이고, vLLM의 0.92는 전체 메모리 기준(가중치 포함)이다.

결과적으로 잡힌 KV 캐시가 446,681 vs 431,024 토큰으로 3.6% 차이에 그쳤다. 운이 좋았던 것이며, 덕분에 별도 보정 없이 "기본값 대 기본값"으로 붙여도 메모리 예산이 대등했다. 반면 chunked prefill 기본값이 정반대다. 이 한 줄이 뒤에서 TTFT 결과를 완전히 갈라놓는다.

4. 벤치마크 방법론 — 엔진이 달라도 공정하게

부하 생성기를 vllm bench serve 하나로 통일했다. TRT-LLM도 OpenAI 호환 서버를 제공하므로 같은 클라이언트로 테스트를 수행할 수 있다.

┌──────────────┐    OpenAI /v1/completions    ┌──────────────────────────┐
│  bench 파드   │ ───────────────────────────► │  engine Service :8000     │
│ vllm bench    │                              │  ├ trtllm-serve  (TRT-LLM)│
│   serve       │ ◄─────────────────────────── │  └ vllm serve    (vLLM)   │
│ (GPU 미점유)  │      동일 지표 정의            └──────────────────────────┘
└──────────────┘

핵심은 클라이언트를 서버 파드 바깥에 두는 것. 양쪽 모두 별도 클라이언트 파드로 조건을 맞췄다.

# 공통 벤치 커맨드 — 엔진과 무관하게 동일
vllm bench serve --backend openai --endpoint /v1/completions \
  --base-url http://engine:8000 --model Qwen/Qwen2.5-7B-Instruct \
  --dataset-name random --random-input-len 1024 --random-output-len 256 \
  --num-prompts 300 --max-concurrency 128 \
  --ignore-eos --temperature 0 --seed 12345 \
  --percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 90,99

공정 비교 장치:

  • --ignore-eos → 출력이 항상 정확히 256토큰. (20요청 스모크 테스트에서 Total generated tokens: 5120 = 20 × 256 확인)
  • --temperature 0 + --seed 12345 → 결정적·동일 프롬프트
  • --max-concurrency N → closed-loop(항상 N개 in-flight)
  • 두 워크로드로 프리필/디코드 지배 상황을 분리:
워크로드 입력 출력 요청 동시성 성격
W1 1024 256 300 128 디코드 지배
W2 4096 256 200 64 프리필 무거움
 

5. TRT-LLM vs vLLM — BF16

W1 (in 1024 / out 256, 300요청, 동시성 128)

엔진 req/s out tok/s 총 tok/s TTFT(중앙) TTFT(p99) TPOT(중앙) ITL(중앙)
TRT-LLM 1.2.1 6.15 1574 7872 1683ms 8696ms 70.1ms 38.7ms
vLLM 0.28.0 6.04 1547 7733 925ms 10496ms 70.9ms 37.4ms
차이 +1.8% +1.8% +1.8% 1.82배 느림 −17% −1% +4%
 

W2 (in 4096 / out 256, 200요청, 동시성 64)

엔진 req/s out tok/s 총 tok/s TTFT(중앙) TTFT(p99) TPOT(중앙) ITL(중앙)
TRT-LLM 1.2.1 1.96 501 8521 1041ms 19233ms 119.6ms 49.0ms
vLLM 0.28.0 1.78 456 7751 3007ms 22130ms 126.7ms 46.6ms
차이 +10.1% +9.9% +9.9% 2.89배 빠름 −13% −5.6% +5%
 

해석

  1. 처리량은 프리필이 무거울수록 TRT-LLM이 유리하다. W1에서 +1.8%(측정 노이즈 수준)였던 격차가 W2에서 +10%로 벌어진다. 긴 컨텍스트 프리필에서 TRT-LLM의 fused MHA 커널이 이득을 낸다.
  2. TTFT 중앙값이 워크로드에 따라 완전히 뒤집힌다.
    • W1: vLLM이 1.82배 빠름 (925 vs 1683ms)
    • W2: TRT-LLM이 2.89배 빠름 (1041 vs 3007ms) 원인은 chunked prefill 기본값이 반대라는 것이다. vLLM은 기본 ON이라 프리필을 잘게 쪼개 새 요청의 첫 토큰을 빨리 뽑는다(입력이 짧은 1K일 때 유리). 하지만 입력이 4K로 커지면 청크가 여러 스텝에 걸쳐 디코드와 계속 섞이며 오히려 첫 토큰이 늦어진다. TRT-LLM은 기본 OFF라 4K 프리필을 한 번에 처리해 중앙값이 훨씬 좋다.
  3. 꼬리(P99 TTFT)는 TRT-LLM이 전 구간 우세 (−13~17%). GUARANTEED_NO_EVICT 스케줄러 정책이 한번 받은 요청을 선점당하지 않게 보장하는 것과 맞닿아 있다. SLO를 P99로 잡는 서비스라면 중앙값만 보고 vLLM을 고르면 안 된다.
  4. TPOT/ITL은 BF16에서 사실상 동률(±5%).

6. FP8 양자화 — ModelOpt PTQ

L40S가 FP8을 네이티브로 지원하므로, TensorRT Model Optimizer(ModelOpt)로 직접 양자화한다. 컨테이너에 이미 nvidia-modelopt 0.37.0이 들어있다.

# ptq_fp8.py — Qwen2.5-7B-Instruct → FP8 (weight+activation per-tensor)
import torch, time
from transformers import AutoModelForCausalLM, AutoTokenizer
import modelopt.torch.quantization as mtq
from modelopt.torch.export import export_hf_checkpoint

MODEL = "Qwen/Qwen2.5-7B-Instruct"
OUT   = "/hf/qwen2.5-7b-fp8"
NCALIB, SEQLEN = 256, 512

def main():
    tok   = AutoTokenizer.from_pretrained(MODEL)
    model = AutoModelForCausalLM.from_pretrained(MODEL, torch_dtype=torch.bfloat16, device_map="cuda")

    # 캘리브레이션: 활성값 스케일(amax)을 관측하기 위한 실제 데이터가 필요
    from datasets import load_dataset
    ds = load_dataset("cnn_dailymail", "3.0.0", split=f"train[:{NCALIB}]")
    texts = [x["article"] for x in ds]

    def forward_loop(m):
        for t in texts:
            ids = tok(t, return_tensors="pt", truncation=True, max_length=SEQLEN).input_ids.cuda()
            m(ids)

    model = mtq.quantize(model, mtq.FP8_DEFAULT_CFG, forward_loop)

    with torch.inference_mode():
        export_hf_checkpoint(model, export_dir=OUT)   # ModelOpt 통합 HF 체크포인트
    tok.save_pretrained(OUT)

if __name__ == "__main__":
    main()
kubectl -n trtllm exec quant -- bash -lc 'python3 /tmp/ptq_fp8.py'
Registered Qwen2Attention to _QuantAttention for KV Cache quantization
Inserted 675 quantizers
  calib 256/256
[quantize] 31.0s
[export]    8.5s → /hf/qwen2.5-7b-fp8
[total]    66.7s

총 66.7초. 7B 모델 FP8 PTQ가 1분대에 끝난다. FP8은 per-tensor 스케일만 구하면 되고 가중치 재구성 최적화가 따로 필요 없다.

# 산출물 확인
du -sh /hf/qwen2.5-7b-fp8          # 8.2G  (BF16 15G 대비 −45%)
cat /hf/qwen2.5-7b-fp8/hf_quant_config.json
{
    "producer": { "name": "modelopt", "version": "0.37.0" },
    "quantization": {
        "quant_algo": "FP8",
        "kv_cache_quant_algo": null,
        "exclude_modules": ["lm_head"]
    }
}

kv_cache_quant_algo: null — KV 캐시는 양자화하지 않았다(가중치·활성값만 FP8). lm_head는 출력 분포 왜곡을 피하려 제외한다.

서빙

# TRT-LLM
trtllm-serve /hf/qwen2.5-7b-fp8 --host 0.0.0.0 --port 8000 --backend pytorch

# vLLM — hf_quant_config.json 을 읽어 quantization=modelopt 로 자동 감지
vllm serve /hf/qwen2.5-7b-fp8 --served-model-name qwen2.5-7b-fp8 --host 0.0.0.0 --port 8000

같은 체크포인트를 두 엔진이 모두 그대로 사용할 수 있다.

메모리 효과

가중치KV 캐시(TRT-LLM)KV 캐시(vLLM)
BF16 15GB 446,681 tok (25.6GB) 431,024 tok
FP8 8.2GB (−45%) 553,718 tok (+24%) 563,312 tok (+31%)
 

고정된 free_gpu_memory_fraction=0.9 아래에서 가중치가 줄어든 만큼 KV로 전환되어 동시 수용력이 늘어난다.

7. 2×2 종합 — 엔진 × 정밀도

W1 (in 1024 / out 256, 300요청, 동시성 128)

엔진 정밀도 req/s out tok/s TTFT(중앙) TTFT(p99) TPOT(중앙) ITL(중앙) KV(tok)
TRT-LLM BF16 6.15 1574 1683ms 8696ms 70.1ms 38.7ms 446,681
vLLM BF16 6.04 1547 925ms 10496ms 70.9ms 37.4ms 431,024
TRT-LLM FP8 9.27 2372 1339ms 5985ms 45.2ms 28.2ms 553,718
vLLM FP8 8.03 2055 735ms 8119ms 54.5ms 27.7ms 563,312

W2 (in 4096 / out 256, 200요청, 동시성 64)

엔진 정밀도 req/s 총 tok/s TTFT(중앙) TTFT(p99) TPOT(중앙) ITL(중앙)
TRT-LLM BF16 1.96 8521 1041ms 19233ms 119.6ms 49.0ms
vLLM BF16 1.78 7751 3007ms 22130ms 126.7ms 46.6ms
TRT-LLM FP8 2.92 12724 693ms 10784ms 79.5ms 37.2ms
vLLM FP8 2.25 9783 2326ms 14401ms 92.4ms 36.1ms
 

핵심 분석

① 정밀도 > 엔진. 처리량 개선 폭을 보면 명확하다.


W1 W2
FP8 전환 (TRT-LLM 내) +50.7% +49.0%
FP8 전환 (vLLM 내) +32.9% +26.4%
엔진 교체 (BF16, vLLM→TRT) +1.8% +10.1%
엔진 교체 (FP8, vLLM→TRT) +15.4% +29.8%
 

엔진을 바꿀지 고민하기 전에 FP8부터 켜는 것이 유리하다. 단일 변경으로 얻는 이득이 압도적으로 크고, 체크포인트 하나로 두 엔진 모두에서 바로 활용할 수 있다.

② FP8 이득은 TRT-LLM에서 더 크다 (+50.7% vs +32.9%). FP8 커널 최적화 성숙도 차이로 보이며, 그 결과 FP8을 켜면 엔진 간 격차가 오히려 벌어진다(+1.8%→+15.4%, +10.1%→+29.8%).

③ Int8 vs FP8 대조. GPTQ-Int8은 FP16보다 11% 느렸다(auto_gptq Int8 커널의 dequant 오버헤드). 반면 FP8은 전 지표에서 이득이다.


  처리량 디코드(TPOT) 메모리
GPTQ-Int8 −11% −18% ❌ +35% ✅
FP8 (본 실측) +51% +36% +24%

양자화 이득은 비트 폭이 아니라 "그 포맷을 하드웨어 텐서코어가 네이티브로 아느냐"가 결정한다. Ada는 FP8을 알고, Int8 GPTQ 커널은 그 경로를 못 탄다.

④ TTFT 중앙값은 여전히 vLLM(W1) / TRT-LLM(W2)로 갈리지만, P99는 FP8에서 TRT-LLM이 더 벌린다 (W1 −26%, W2 −25%).

8. 네이티브 trtllm-bench — 교차 검증

서버를 경유한 숫자가 HTTP/토크나이즈 오버헤드로 왜곡되지 않았는지, TRT-LLM 자체 오프라인 벤치로 대조한다.

# 1) 합성 데이터셋 생성 (W1 형상)
trtllm-bench --model Qwen/Qwen2.5-7B-Instruct prepare-dataset \
  --output /tmp/ds_w1.jsonl \
  token-norm-dist --num-requests 300 \
  --input-mean 1024 --input-stdev 0 --output-mean 256 --output-stdev 0

# 2) BF16
trtllm-bench --model Qwen/Qwen2.5-7B-Instruct \
  throughput --dataset /tmp/ds_w1.jsonl --backend pytorch \
  --concurrency 128 --num_requests 300

# 3) FP8 — 로컬 체크포인트는 --model_path 로 지정 (아래 트러블슈팅 ⑤)
trtllm-bench --model Qwen/Qwen2.5-7B-Instruct --model_path /hf/qwen2.5-7b-fp8 \
  throughput --dataset /tmp/ds_w1.jsonl --backend pytorch \
  --concurrency 128 --num_requests 300

 

측정 방식 BF16 req/s  BF16 out tok/s FP8 req/s FP8 out tok/s
서버 경유 (vllm bench serve) 6.15 1574 9.27 2372
네이티브 (trtllm-bench) 6.33 1622 9.60 2457
차이 +2.9% +3.0% +3.5% +3.6%

네이티브가 일관되게 3% 정도 높다. HTTP 왕복 + 서버 측 토크나이즈/디토크나이즈 비용에 해당하며, 두 정밀도에서 같은 폭으로 나타나므로 서버 경유 측정이 편향 없이 유효함을 뒷받침한다.

⚠️ 트러블슈팅 ⑤ — trtllm-bench의 문서/CLI 드리프트
문서에 나오는 prepare-dataset --stdout은 1.2.1에 없다(Error: No such option: --stdout). → --output <파일> 사용.

--tokenizer도 prepare-dataset 레벨엔 없다. 상위 --model이 그 역할을 한다.

--model에 로컬 경로를 주면 HF repo id로 파싱해서 실패한다. HFValidationError: Repo id must be in the form 'repo_name' or 'namespace/repo_name': '/hf/qwen2.5-7b-fp8' → --model <HF이름>(토크나이저·설정용)과 --model_path <로컬경로>(가중치용)를 함께 지정한다.

9. 튜닝 치트시트 (L40S / 7B 기준)

  • 무조건 먼저: FP8 양자화. ModelOpt PTQ 1분, 처리량 +26~51%, 메모리 −45%, 두 엔진 공용. Ada 이상 하드웨어에서 필수 적용 권장.
  • 긴 입력(RAG·문서요약, 프리필 무거움): TRT-LLM. 처리량 +10~30%, TTFT 중앙값 2.9~3.4배 우위.
  • 짧은 입력 대화형(중앙값 TTFT 중요): vLLM. 기본 chunked prefill ON이 유리.
  • P99 SLO가 계약 조건: TRT-LLM. 전 조합에서 꼬리 TTFT 13~26% 낮음.
  • 한 GPU에 여러 모델: --kv_cache_free_gpu_memory_fraction을 반드시 낮출 것. 기본 0.9는 남는 메모리를 거의 다 가져간다(1.1B 모델이 KV로 35.8GB를 잡음).
  • TRT-LLM에서 TTFT를 줄이고 싶다면: enable_chunked_prefill이 기본 OFF임을 기억하고 명시적으로 켜서 재측정해볼 수 있다. 단, 긴 입력에선 켜는 게 손해일 수 있음.
  • 용량 산정: TRT-LLM 컨테이너는 압축 19.5GB / 언팩 ~50GB. 노드 디스크 150GB 이상 권장.

10. 트러블슈팅 총정리

# 증상 원인 해결
파드 전부 Evicted, DiskPressure TRT-LLM 이미지 ~50GB + 모델 15GB > 100GB 노드 노드 디스크 150GB로 리사이즈
admission webhook "vgpu.hami.io" denied: pod has node assigned nodeName이 HAMi 스케줄러를 우회 GPU 파드는 nodeSelector로 유도
ImportError: libnvonnxparser.so.10 command:가 ENTRYPOINT를 교체해 LD_LIBRARY_PATH 소실 command: ["bash","-lc"] + exec ...
MPI ... forgotten to use the proper idiom LLM()이 MPI 워커 spawn, 가드 없으면 재귀 if __name__ == "__main__":
No such option: --stdout / HFValidationError 문서·CLI 버전 간 차이 --output, --model_path
문서의 1.3.0rc25 태그 pull 실패 레지스트리에 존재하지 않음 태그 목록 직접 조회 → 1.2.1 사용
CephFS PVC Pending rados: ret=-22 (테넌트 Ceph 연결) 벤치엔 영속성 불필요 → hostPath 우회
Kyverno PolicyViolation 경고 hostPath/non-root 위반 audit 모드라 통과. 프로덕션이면 정책 예외 필요
 

11. 한계 / 주의사항

  • 각 조합 1회 측정(반복 없음) → 분산·신뢰구간 미반영. W1 BF16의 +1.8% 차이는 노이즈 범위 내일 수 있다.
  • random 데이터셋 사용 → 실트래픽과 프리픽스 캐싱 효과가 다르다(양쪽 다 프리픽스 캐싱 기본 ON이라 랜덤 토큰에선 거의 히트하지 않음).
  • 동시성 1점만 측정 → latency–throughput 곡선 전체를 커버하지는 못함.
  • FP8 품질을 정량 검증하지 않았다. 서빙 sanity(동일 프롬프트 정상 응답)만 확인했고 perplexity/lm-eval 미실시. 채택 전 정확도 하락 측정 필요.
  • 기본값 대 기본값 비교다. 양쪽 다 튜닝하면 순위가 바뀔 수 있다.
  • HAMi 소프트 파티션 위 측정이나, 두 엔진 모두 44GB를 온전히 보므로 통짜 GPU와 동등하게 동작함.

부록 — 재현 스크립트

# 전체 벤치 스윕
./run_trtllm_bench.sh <label> <in> <out> <num_prompts> <concurrency> <outfile>

# 엔진 교체는 args 만 patch (Recreate 전략)
kubectl -n trtllm patch deploy engine --type=json -p '[
 {"op":"replace","path":"/spec/template/spec/containers/0/image","value":"vllm/vllm-openai:latest"},
 {"op":"replace","path":"/spec/template/spec/containers/0/args",
  "value":["exec vllm serve Qwen/Qwen2.5-7B-Instruct --host 0.0.0.0 --port 8000"]}]'
kubectl -n trtllm rollout status deploy/engine --timeout=1200s

# 정리
kubectl delete ns trtllm            # GPU 반납
# 모델 캐시는 노드 hostPath /var/lib/hf-cache 에 잔존

Pre-quantized 모델 4종(Qwen2.5-7B-Instruct FP16, GPTQ-Int8, GPTQ-Int4, AWQ-Int4)을 vLLM V1 엔진에 배포하여 성능을 실측 비교했습니다. 별도의 로컬 양자화 과정 없이 Hugging Face의 pre-quantized 모델 ID를 지정하면 vLLM이 config.json의 quantization_config를 참조해 연산 커널을 자동으로 설정합니다.

변형 사용 모델 ID vLLM 감지 방식
FP16 Qwen/Qwen2.5-7B-Instruct None

GPTQ-Int8 Qwen/Qwen2.5-7B-Instruct-GPTQ-Int8 auto_gptq

GPTQ-Int4 Qwen/Qwen2.5-7B-Instruct-GPTQ-Int4 auto_gptq

AWQ-Int4 Qwen/Qwen2.5-7B-Instruct-AWQ auto_awq

실측 결과 (워크로드: input 1024 / output 256, 300 요청, 동시성 128)

변형 KV 캐시 (tok) 최대 동시성 req/s out tok/s Med TTFT Med TPOT Med ITL
FP16 374,704 11.44x 6.04 1,546 933ms 70.7ms 37.3ms
GPTQ-Int8 505,440 (+35%) 15.42x 5.38 (-11%) 1,377 1,292ms 83.8ms 30.4ms
GPTQ-Int4 560,816 (+50%) 17.11x 7.29 (+21%) 1,867 941ms 62.0ms 24.7ms
AWQ-Int4 561,040 (+50%) 17.12x 7.12 (+18%) 1,822 978ms 63.5ms 24.9ms

동일 프롬프트 샘플링 테스트(Greedy, temp=0) 결과 4가지 모델 모두 문맥 붕괴 없이 일관되고 정확한 정의 문장을 생성했습니다.

심층 분석

  • 메모리 확보 및 동시 수용력 증대: 모델 가중치가 작아진 만큼 동일 GPU 메모리 비율(gpu-memory-utilization=0.85) 환경에서 KV 캐시 영역이 확장됩니다. Int4 적용 시 KV 캐시 용량이 374k에서 561k tokens로 50% 증가하여 최대 동시성이 11.4x에서 17.1x로 대폭 상승합니다.
  • Int4의 처리량 역전 상승: LLM 디코드 연산은 메모리 대역폭 병목(Memory Bandwidth Bound) 환경입니다. Int4 도입으로 가중치 전송량이 줄어들면서 토큰당 처리 속도가 빨라졌고, 전체 처리량이 21% 상향되었습니다.
  • Int8 성능 저하 원인: GPTQ-Int8은 FP16보다 정밀도가 높음에도 불구하고 처리량이 11% 감소했습니다. 이는 vLLM 내 auto_gptq Int8 커널의 역양자화(Dequantization) 오버헤드가 메모리 대역폭 이득을 상쇄했기 때문입니다. L40S 환경에서는 Int4 Marlin 커널이 훨씬 최적화되어 있습니다.

튜닝 치트시트

  • 실시간 대화형 서비스 (TTFT 최우선)
    • Chunked Prefill ON + --max-num-batched-tokens=2048 설정.
    • --max-num-seqs는 너무 작게 설정하지 않아 큐 대기 시간 증가 방지.
  • 오프라인 대량 배치 처리 (Throughput 최우선)
    • --max-num-batched-tokens=16384 이상 설정 또는 Chunked Prefill OFF (--no-enable-chunked-prefill).
  • 단기 컨텍스트 전용 서빙
    • --max-model-len을 실제 최대 응답 길이로 축소하여 KV 캐시 수용력(Concurrency) 확보.
  • 7B 모델 기준 최적 조합 (NVIDIA L40S)
    • GPTQ-Int4 / AWQ-Int4 양자화 적용을 기본 옵션으로 채택 (메모리 9GB 절감, 처리량 20% 이상 상승).

## 스크립트 및 결과

run_vllm_quant.sh
0.00MB
vllm-quant-results.txt
0.02MB

NVIDIA L40S 1장(HAMi vGPU) 환경에서 vLLM V1 엔진의 주요 서빙 파라미터를 변경하며 처리량(Throughput)과 지연시간(TTFT·TPOT)의 트레이드오프를 실측 분석한 기록입니다. 모든 재현 명령어를 순서대로 포함했습니다.

핵심 요약

파라미터 크게(↑) 작게(↓)
--max-num-seqs (배치 상한) 처리량↑ (auto 이상은 KV캐시 한계로 무의미) 디코드 지연(TPOT)↓, 부하 시 TTFT 큐잉 폭증
--max-model-len 긴 컨텍스트 수용 (단기 성능 영향 없음) 동시 수용력(Concurrency) 대폭 증가, 긴 요청 거부
Chunked Prefill & --max-num-batched-tokens 크게/OFF → 총 처리량↑ · TPOT↓, TTFT↑ 작게/ON → 첫 토큰 지연(TTFT)↓, 처리량↓ · TPOT↑
 

0. 테스트 환경 및 GPU 상태 확인

KubeVirt VM 기반 Kubernetes 노드에서 HAMi를 통해 L40S 1장을 할당 받았습니다.

# 1. GPU 노드 및 HAMi 할당 슬롯 확인
kubectl get nodes -o custom-columns='NODE:.metadata.name,GPU:.status.capacity.nvidia\.com/gpu'

# 2. 물리 GPU 세부 사양 및 HAMi 분할 상태 확인 (44GB 메타데이터 확인)
kubectl get node <gpu-node-name> -o jsonpath='{.metadata.annotations.hami\.io/node-nvidia-register}'

1. vLLM 서버 배포

Recreate 전략의 Deployment로 배포하고, 노드 hostPath를 사용하여 모델 캐시를 재사용합니다.

# vllm.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm
  namespace: vllm-bench
  labels:
    app: vllm
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: vllm
  template:
    metadata:
      labels:
        app: vllm
    spec:
      containers:
      - name: vllm
        image: vllm/vllm-openai:latest
        args:
          - "--model=Qwen/Qwen2.5-7B-Instruct"
          - "--port=8000"
          - "--gpu-memory-utilization=0.85"
        ports:
        - containerPort: 8000
        env:
        - name: HF_HOME
          value: /root/.cache/huggingface
        resources:
          limits:
            nvidia.com/gpu: "1"
            nvidia.com/gpumem: "44000"
            nvidia.com/gpucores: "100"
            cpu: "10"
            memory: 40Gi
          requests:
            cpu: "4"
            memory: 16Gi
        readinessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 30
          periodSeconds: 5
          failureThreshold: 240
        volumeMounts:
        - name: hf-cache
          mountPath: /root/.cache/huggingface
        - name: shm
          mountPath: /dev/shm
      volumes:
      - name: hf-cache
        hostPath:
          path: /var/lib/vllm-hf-cache
          type: DirectoryOrCreate
      - name: shm
        emptyDir:
          medium: Memory
          sizeLimit: 8Gi
# 네임스페이스 생성 및 vLLM 배포
kubectl create ns vllm-bench
kubectl apply -f vllm.yaml

# 롤아웃 대기 (최초 배포 시 모델 다운로드로 수 분 소요)
kubectl -n vllm-bench rollout status deploy/vllm --timeout=1800s

# 서빙 상태 확인
kubectl -n vllm-bench exec deploy/vllm -- curl -s localhost:8000/v1/models

트러블슈팅 Note: vLLM 0.27.1 기준 --disable-log-requests 옵션은 삭제되었으므로 제외해야 컨테이너 크래시(unrecognized arguments)를 방지할 수 있습니다.

2. 엔진 기본값 확인 명령어

vLLM CLI 도움말 및 실행 로그를 조회하여 자동 산출된 기본값을 확인합니다.

POD=$(kubectl -n vllm-bench get pod -l app=vllm -o name | head -1)

# CLI 기본 도움말 조회
kubectl -n vllm-bench exec $POD -- sh -c 'vllm serve --help=max-num-seqs'
kubectl -n vllm-bench exec $POD -- sh -c 'vllm serve --help=max-num-batched-tokens'
kubectl -n vllm-bench exec $POD -- sh -c 'vllm serve --help=max-model-len'
kubectl -n vllm-bench exec $POD -- sh -c 'vllm serve --help=enable-chunked-prefill'

# 기동 로그에서 실제 산출된 값 확인
kubectl -n vllm-bench logs $POD | grep -iE "max_seq_len|enable_chunked_prefill|enable_prefix_caching|GPU KV cache size|Maximum concurrency"

 

확인된 기본 산출 값:

  • max_model_len: 32,768 (모델 Config 기반 자동)
  • max_num_seqs: auto
  • max_num_batched_tokens: auto (~8,192 수준)
  • enable_chunked_prefill: True (V1 Engine 기본 ON)
  • GPU KV Cache Size: 374,704 tokens (동시성 11.44x)

3. 벤치마크 실행 명령어

컨테이너 내부에서 내장 vllm bench serve를 실행하여 런 간 공정한 실측 데이터를 수집합니다.

POD=$(kubectl -n vllm-bench get pod -l app=vllm -o name | head -1)

kubectl -n vllm-bench exec $POD -- sh -c 'vllm bench serve \
  --model Qwen/Qwen2.5-7B-Instruct --host localhost --port 8000 \
  --dataset-name random --random-input-len 1024 --random-output-len 256 \
  --num-prompts 300 --max-concurrency 128 \
  --ignore-eos --temperature 0 --seed 12345 \
  --percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 90,99'

4. 과제 1: max_num_seqs 및 max_model_len 실측

Deployment의 args를 패치하여 재배포 후 실측합니다. (input 1024 / output 256 / concurrency 128)

# 예시: max-num-seqs=32 설정 패치 명령
kubectl -n vllm-bench patch deploy vllm --type=json -p \
 '[{"op":"replace","path":"/spec/template/spec/containers/0/args",
    "value":["--model=Qwen/Qwen2.5-7B-Instruct","--port=8000","--gpu-memory-utilization=0.85","--max-num-seqs=32"]}]'

kubectl -n vllm-bench rollout status deploy/vllm --timeout=420s

실측 결과

Config req/s out tok/s TTFT (중앙) TTFT (p99) TPOT (중앙) 최대 동시성
baseline (auto) 5.96 1,525 951ms 10.8s 71.9ms 11.44x
--max-num-seqs=32 3.41 (-43%) 873 27,470ms 29.3s 34.0ms 12.09x
--max-num-seqs=512 5.96 (=) 1,526 943ms 10.8s 71.9ms 11.34x
--max-model-len=8192 5.96 (=) 1,525 946ms 10.8s 71.9ms 45.74x
 

분석:

  • max-num-seqs=32: 동시 요청 128개 중 96개가 큐 대기 상태로 밀려나며 TTFT가 27초로 폭증하고 전체 처리량이 43% 급감합니다.
  • max-model-len=8192: 긴 입력이 없는 워크로드에서는 성능 변화가 없으나, 엔진 내부 KV 캐시를 적게 할당하여 최대 동시 수용력이 11.44x에서 45.74x로 4배 급증합니다.

5. 과제 2: Chunked Prefill 및 max_num_batched_tokens 실측

Prefill 부하를 높여 청크 효과를 명확히 측정합니다. (input 4096 / output 256 / concurrency 64)

# 1. Chunked Prefill ON & batched tokens=2048 패치
kubectl -n vllm-bench patch deploy vllm --type=json -p \
 '[{"op":"replace","path":"/spec/template/spec/containers/0/args",
    "value":["--model=Qwen/Qwen2.5-7B-Instruct","--port=8000","--gpu-memory-utilization=0.85","--max-num-batched-tokens=2048"]}]'

# 2. Chunked Prefill OFF 패치
kubectl -n vllm-bench patch deploy vllm --type=json -p \
 '[{"op":"replace","path":"/spec/template/spec/containers/0/args",
    "value":["--model=Qwen/Qwen2.5-7B-Instruct","--port=8000","--gpu-memory-utilization=0.85","--no-enable-chunked-prefill"]}]'

실측 결과

Config req/s 총 tok/s TTFT (중앙) TTFT (p99) TPOT (중앙)
baseline (chunked ON, auto) 1.73 7,534 2,310ms 23.5s 132ms
--max-num-batched-tokens=2048 1.70 7,410 1,404ms 24.3s 138ms
--max-num-batched-tokens=16384 1.80 7,855 4,333ms 21.8s 116ms
--no-enable-chunked-prefill 1.83 7,971 7,297ms 21.3s 104ms
 

분석:

  • TTFT 개선: 청크 크기를 2048로 줄이면 Prefill을 잘게 쪼개어 스케줄링하므로 첫 토큰 지연이 1404ms로 가장 우수해집니다.
  • 처리량 최적화: Chunked Prefill을 끄거나(--no-enable-chunked-prefill) 청크를 대형화(16384)하면 GPU Batching 효율이 높아져 총 처리량이 최고(1.83 req/s)가 되며 TPOT(104ms)이 가장 빠릅니다.

6. 전체 테스트 자동화 스크립트 및 cleanup

반복 테스트를 진행한 전체 스크립트 흐름 및 cleanup 명령어입니다.

# 튜닝 자동화 스크립트 예시
apply_cfg() {
  ARG_STR=$1
  kubectl -n vllm-bench patch deploy vllm --type=json -p \
   "[{\"op\":\"replace\",\"path\":\"/spec/template/spec/containers/0/args\",
      \"value\":[\"--model=Qwen/Qwen2.5-7B-Instruct\",\"--port=8000\",\"--gpu-memory-utilization=0.85\" ${ARG_STR:+, $ARG_STR}]}]"
  kubectl -n vllm-bench rollout status deploy/vllm --timeout=420s
}

bench() {
  LABEL=$1; IN_LEN=$2; OUT_LEN=$3; NUM_P=$4; CONC=$5
  POD=$(kubectl -n vllm-bench get pod -l app=vllm -o name | head -1)
  echo "=== Running $LABEL ==="
  kubectl -n vllm-bench exec $POD -- sh -c "vllm bench serve \
    --model Qwen/Qwen2.5-7B-Instruct --host localhost --port 8000 \
    --dataset-name random --random-input-len $IN_LEN --random-output-len $OUT_LEN \
    --num-prompts $NUM_P --max-concurrency $CONC \
    --ignore-eos --temperature 0 --seed 12345 \
    --percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 90,99"
}

# 과제 1 실행
apply_cfg ""
bench C1_baseline 1024 256 300 128

apply_cfg '"--max-num-seqs=32"'
bench C1_seqs32 1024 256 300 128

apply_cfg '"--max-num-seqs=512"'
bench C1_seqs512 1024 256 300 128

apply_cfg '"--max-model-len=8192"'
bench C1_modellen8192 1024 256 300 128

# 과제 2 실행
apply_cfg ""
bench C2_baseline 4096 256 200 64

apply_cfg '"--max-num-batched-tokens=2048"'
bench C2_mnbt2048 4096 256 200 64

apply_cfg '"--max-num-batched-tokens=16384"'
bench C2_mnbt16384 4096 256 200 64

apply_cfg '"--no-enable-chunked-prefill"'
bench C2_nochunk 4096 256 200 64

# 자원 자원 해제 (네임스페이스 삭제)
kubectl delete ns vllm-bench

 

### bench 스크립트 및 성능 체크 결과

run_vllm_bench.sh
0.00MB
vllm-bench-results.txt
0.05MB

 

VM은 어떻게 하나의 물리 서버 안에서 또 하나의 컴퓨터로 동작할까?

물리 서버 하나에 여러 개의 가상 머신(VM)을 띄워 사용하는 것은 현대 인프라의 기본입니다. 그렇다면 VM은 어떻게 물리 자원을 공유하면서도 자신만의 독립된 CPU, RAM, Disk를 가지고 있다고 믿게 만드는 것일까요? 가상화(Virtualization)의 작동 원리와 핵심 메커니즘을 정리합니다.

1. VM의 본질: 소프트웨어로 추상화된 컴퓨터

VM은 거창한 하드웨어가 아니라 실제 컴퓨터의 주요 장치들을 소프트웨어로 추상화한 모델입니다. Guest OS 입장에서는 독자적인 하드웨어를 소유한 것처럼 보이지만, 실제로는 가상화 레이어가 물리 자원을 분할 및 매핑하여 전달합니다.

Physical Server (16 Core CPU, 64GB RAM, 1TB Disk)
       │
       ▼
   Hypervisor
 ┌─────┼─────┐
 ▼     ▼     ▼
VM 1  VM 2  VM 3 (각 4 vCPU, 16GB RAM)

Guest OS가 인식하는 vCPU, Guest Memory, Virtual Disk(/dev/vda), Virtual NIC(eth0)는 모두 하드웨어 인터페이스를 흉내 낸 Virtual Device에 불과합니다.

2. CPU 가상화의 난제: Privilege Level과 Ring 0의 충돌

x86 CPU는 보안과 자원 보호를 위해 4단계의 권한 수준(Privilege Level, Ring 0~3)을 가집니다.

  • Ring 0 (Kernel Mode): 가장 높은 권한. 하드웨어 직접 제어 가능.
  • Ring 3 (User Mode): 가장 낮은 권한. 일반 애플리케이션 실행.
[ 전통적인 구조 ]
Application (Ring 3) ──System Call──> Linux Kernel (Ring 0) ──> Hardware

문제 발생: VM 내부의 Guest Kernel도 자신이 시스템의 주 주인이라고 생각해 Ring 0에서 실행되기를 원합니다. 그러나 물리 CPU의 Ring 0은 이미 Host Kernel이 점유하고 있습니다.

  • 과거의 해결책 (Trap-and-Emulate): Guest의 특권 명령(Privileged Instruction) 시도를 CPU Trap으로 잡아 Hypervisor가 대신 처리했습니다. 그러나 모든 명령을 중계하느라 심각한 성능 저하가 발생했습니다.
  • 현대의 해결책 (하드웨어 지원 가상화): CPU 제조사가 가상화를 전폭 지원하기 시작했습니다. Intel은 VT-x (VMX Root / Non-root Mode), AMD는 SVM 기술을 통해 Host와 Guest의 실행 환경을 CPU 하드웨어 차원에서 완전 격리했습니다.

3. Hypervisor의 역할과 분류

하이퍼바이저는 물리 하드웨어와 Guest OS 사이에서 가상 환경을 제공하고 자원을 조율하는 핵심 소프트웨어입니다.

구분 Type 1 (Bare-metal) Type 2 (Hosted) KVM (Kernel-based VM)
구조 하드웨어 위에 직접 구동 Host OS의 애플리케이션으로 구동 Linux 커널 자체를 Hypervisor로 전환
특징 오버헤드가 적고 성능이 뛰어남 설치가 쉽고 데스크톱 환경에 적합 리눅스 커널 모듈을 통해 Type 1급 성능 제공
대표 예시 VMware ESXi, Xen, Hyper-V VirtualBox, VMware Workstation Linux KVM + QEMU
 

4. 메모리, 디스크, 네트워크의 가상화

  • 메모리 가상화 (2단계 주소 변환): Guest OS는 자신만의 연속된 메모리 공간(Guest Physical Address)이 있다고 생각합니다. 하이퍼바이저는 이를 실제 물리 메모리(Host Physical Address)로 변환해야 하며, 이 과정은 Intel EPT / AMD NPT 하드웨어 기술을 통해 고속 처리됩니다.
  • 디스크 가상화: Guest의 /dev/vda 블록 요청을 QEMU와 virtio-blk 드라이버가 수신하여 Host의 실제 저장소 파일(qcow2, raw)이나 NVMe 장치로 매핑합니다.
  • 네트워크 가상화: Guest 내부의 eth0 패킷은 virtio-net ➔ TAP 디바이스 ➔ Linux Bridge를 거쳐 물리 NIC로 전달됩니다.

5. 모든 것은 KubeVirt로 연결된다

가상화의 기본 원리는 현대 클라우드 네이티브 기술인 KubeVirt로 그대로 이어진다.

Kubernetes API ──> KubeVirt ──> virt-launcher Pod ──> QEMU / KVM ──> Physical Hardware

KubeVirt는 하드웨어 가상화 메커니즘(KVM/QEMU)을 쿠버네티스의 API와 선언형 컨테이너 오케스트레이션 모델 안으로 통합한 기술입니다.

 

오늘 꼭 기억해야 할 핵심 5가지

  • 소프트웨어 추상화: VM은 실제 하드웨어를 복제한 소프트웨어 모델이다.
  • 하이퍼바이저: Guest OS에게 하드웨어 인터페이스를 모방하여 제공한다.
  • CPU 특권 문제 해결: Guest Kernel의 특권 명령 처리를 위해 하드웨어 지원 가상화(Intel VT-x / AMD-V)가 필수적이다.
  • 이중 주소 변환: 메모리는 Guest 주소에서 Host 물리 주소로의 2단계 변환(EPT/NPT)을 거친다.
  • 인터페이스 매핑: Disk와 NIC는 virtio 및 TAP/Bridge 기술을 통해 Host의 실제 자원과 연결된다.

'Virtualization' 카테고리의 다른 글

학습 목차  (0) 2026.08.19
1. 가상화의 원리
2. Intel VT-x / AMD-V
3. KVM
4. QEMU
5. QEMU + KVM
6. virtio
7. libvirt
8. CPU / Memory / NUMA
9. Network / TAP / Bridge
10. VFIO / IOMMU / PCI Passthrough
11. Live Migration
12. KubeVirt

'Virtualization' 카테고리의 다른 글

가상화의 원리  (0) 2026.08.19
참고링크

https://docs.ray.io/en/latest/cluster/kubernetes/getting-started/rayservice-quick-start.html

 

RayService Quickstart

Prerequisites: This guide mainly focuses on the behavior of KubeRay v1.6.0 and Ray 2.46.0. What’s a RayService?: A RayService manages these components: RayCluster: Manages resources in a Kubernetes...

docs.ray.io

 

가상화된 워커 노드(KubeVirt VM)와 L40S GPU 소프트셰어링(HAMi)이 적용된 쿠버네티스 환경에서, KubeRay로 RayService를 배포하고 실제 LLM(Qwen2.5)을 서빙해 본 엔지니어링 기록입니다. GPU 커널 벤치마크부터 추론 서빙 과정에서 만난 Triton/gcc 트러블슈팅까지 정리했습니다.

  • 인프라 환경: 워커 노드 = KubeVirt VM / GPU = 호스트에서 vfio-pci로 passthrough한 NVIDIA L40S 1장을 HAMi로 10분할 소프트셰어 (CNI: Cilium KPR, K8s v1.36.1)
  • RayService 구성: KubeRay Operator(v1.6.0)를 통해 Ray Cluster 및 Serve 배포
  • GPU 벤치마크: [nvidia.com/gpu](https://nvidia.com/gpu): 1 할당 후 PyTorch 8192x8192 fp16 matmul 연산 수행 → 191.4 TFLOP/s 달성 (HAMi 슬라이스 정상 동작 확인)
  • LLM 서빙 결과: Qwen2.5-1.5B-Instruct 모델을 Ray Serve로 배포하여 질의응답 테스트 완료. 워밍업 이후 약 100 tok/s의 추론 속도 확인

1. KubeVirt VM 워커 + HAMi GPU 추상화

이번 테스트를 진행한 클러스터는 워커 노드가 물리 서버가 아닌 KubeVirt VM으로 구성되어 있습니다.

호스트의 L40S GPU를 vfio-pci 방식으로 VM에 통째로 넘겨주고(Passthrough), VM 내부에서는 HAMi를 이용해 이 GPU를 여러 개의 슬롯으로 쪼개서(Soft-share) 사용합니다.

$ kubectl get nodes -o wide
NAME                    STATUS   ROLES    VERSION   OS-IMAGE
default-pool-...b7s9p   Ready    worker   v1.36.1   Ubuntu 24.04.4 LTS
gpu-pool-...c5j5z       Ready    worker   v1.36.1   Ubuntu 24.04.4 LTS

$ kubectl get node gpu-pool-...c5j5z -o jsonpath='{.status.allocatable.nvidia\.com/gpu}'
10        # ← L40S 1장을 HAMi가 10개의 슬롯으로 분할하여 노출

즉, 파드에서 [nvidia.com/gpu](https://nvidia.com/gpu): 1 리소스를 요청하면 전체 GPU가 아닌 HAMi가 관리하는 L40S의 논리적 슬라이스를 할당받게 됩니다.

2. KubeRay Operator 및 RayService 배포

먼저 공식 RayService Quick-start 가이드를 따라 KubeRay 환경을 구성합니다.

# KubeRay Operator 설치
helm repo add kuberay https://ray-project.github.io/kuberay-helm/
helm repo update
helm install kuberay-operator kuberay/kuberay-operator \
  --version 1.6.0 -n kuberay-operator --create-namespace --wait

# 샘플 RayService 배포
kubectl create namespace ray
kubectl -n ray apply -f https://raw.githubusercontent.com/ray-project/kuberay/v1.6.0/ray-operator/config/samples/ray-service.sample.yaml

샘플 앱(CPU 전용 API인 fruit, calc)이 배포되고 Head와 Worker 파드가 모두 Ready 상태가 되면 기본적인 검증이 끝납니다.

$ kubectl -n ray get rayservice
NAME                SERVICE STATUS   NUM SERVE ENDPOINTS
rayservice-sample   Running          2

Tip: 대시보드는 kubectl -n ray port-forward svc/rayservice-sample-head-svc 8265:8265 명령어를 통해 로컬에서 확인할 수 있습니다.

 

3. GPU 활용 검증: Ray + HAMi + L40S 커널 테스트

이제 실제로 GPU 슬라이스를 할당받아 연산이 잘 되는지 확인하기 위해 GPU 워커 그룹이 포함된 RayCluster를 배포합니다.

(KubeRay는 Pod의 [nvidia.com/gpu](https://nvidia.com/gpu) limit을 Ray 내부의 GPU 리소스로 자동 매핑해 줍니다.)

Head 파드에 접속해 간단한 Ray GPU 태스크를 던져 봅니다.

import ray, os, subprocess
ray.init(address='auto')

@ray.remote(num_gpus=1)
def gpu_probe():
    smi = subprocess.check_output(
        ['nvidia-smi','--query-gpu=name,memory.total,driver_version,compute_cap',
         '--format=csv,noheader'], text=True).strip()
    return ray.get_gpu_ids(), os.environ.get('CUDA_VISIBLE_DEVICES'), smi

print(ray.get(gpu_probe.remote()))

실행 결과 및 로그:

ray_gpu_ids            : [0]
CUDA_VISIBLE_DEVICES   : 0
nvidia_smi             : NVIDIA L40S, 46068 MiB, 580.105.08, 8.9

# Worker Pod 로그 확인 시 HAMI-core 가로채기 확인
(gpu_probe ...) [HAMI-core Msg(...)]: Cleanup on exit for PID ...

Ray가 정확히 GPU 워커에 태스크를 스케줄링했고, VM 내부에서 드라이버와 Compute Capability(8.9)가 정상 인식됩니다. 특히 HAMI-core 로그를 통해 HAMi가 GPU 접근을 성공적으로 중재하고 있음을 확인할 수 있습니다.

진짜 CUDA 커널 돌려보기 (Matmul 벤치마크)

실제 연산 성능을 보기 위해 Worker에 PyTorch를 설치하고 행렬 곱(Matmul) 벤치마크를 수행했습니다.

import torch, time

n = 8192
a = torch.randn(n, n, device='cuda', dtype=torch.float16)
b = torch.randn(n, n, device='cuda', dtype=torch.float16)

# Warmup
for _ in range(3): torch.matmul(a, b); torch.cuda.synchronize()

# Benchmark
iters = 20; t0 = time.time()
for _ in range(iters): c = torch.matmul(a, b)
torch.cuda.synchronize()

dt = (time.time() - t0) / iters
print(f'{dt*1e3:.2f} ms/iter → {2*n**3/dt/1e12:.1f} TFLOP/s')

결과: 5.74 ms/iter → 191.4 TFLOP/s

KubeVirt VM과 HAMi 소프트셰어라는 두 겹의 추상화를 통과하고도 L40S의 191 TFLOP/s 실효 성능을 뽑아냅니다.

4. 실전: Qwen2.5를 Ray Serve로 서빙하기

가장 중요한 본론입니다. LLM(Qwen2.5-1.5B-Instruct)을 GPU 워커에 올리고 질의응답 API를 서빙해 보았습니다.

먼저 워커 노드에 필요한 패키지를 설치합니다.

kubectl -n ray exec <gpu-worker> -- pip install "transformers>=4.44" accelerate

이후 Head 파드에서 다음 파이썬 스크립트를 실행하여 Ray Serve Deployment를 구성합니다.

import ray
from ray import serve

ray.init(address="auto")

@serve.deployment(ray_actor_options={"num_gpus": 1})
class LLM:
    def __init__(self):
        import torch
        from transformers import AutoModelForCausalLM, AutoTokenizer
        mid = "Qwen/Qwen2.5-1.5B-Instruct"
        self.tok = AutoTokenizer.from_pretrained(mid)
        self.model = AutoModelForCausalLM.from_pretrained(mid).half().to("cuda").eval()

    def generate(self, question, max_new_tokens=128):
        import torch
        msgs = [{"role": "user", "content": question}]
        text = self.tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
        inputs = self.tok(text, return_tensors="pt").to("cuda")
        
        with torch.no_grad():
            out = self.model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
            
        gen = out[0][inputs.input_ids.shape[1]:]
        return self.tok.decode(gen, skip_special_tokens=True).strip()

    async def __call__(self, request):
        data = await request.json()
        return {"answer": self.generate(data["question"])}

handle = serve.run(LLM.bind(), name="llm", route_prefix="/llm")

# 테스트 질의
for q in ["What is the capital of France?", "대한민국의 수도는?", "17 * 23 은?"]:
    print(q, "→", handle.generate.remote(q).result())

추론 테스트 결과

질문 모델 답변 처리 속도 비고
What is the capital of France? The capital of France is Paris. 2.09s 첫 호출 (워밍업)
대한민국의 수도는? 대한민국의 수도는 서울입니다. 0.11s 101 tok/s
Explain Kubernetes in one sentence. Kubernetes (k8s) is an open-source platform that automates... 0.34s 103 tok/s
17 * 23 은? 391 0.04s 89 tok/s

HTTP 엔드포인트를 통해서도 정상적으로 응답을 받아올 수 있습니다.

curl -X POST localhost:8000/llm -H 'Content-Type: application/json' \
  -d '{"question":"What is the capital of France?"}'
# 응답: {"answer": "The capital of France is Paris."}

 

질문 스크립트 사용
#!/usr/bin/env python3
# 사용법:
#   1) 다른 터미널에서 포트포워드:
#        kubectl -n ray port-forward svc/gpu-demo-head-svc 8000:8000
#   2) 이 스크립트 실행:
#        python3 ask_llm.py
#   질문을 입력하면 GPU(L40S)에 올라간 Qwen2.5-1.5B가 답합니다. (종료: Ctrl-D / Ctrl-C)
import json, urllib.request

URL = "http://localhost:8000/llm"

print("Qwen2.5-1.5B @ L40S — 질문을 입력하세요 (종료: Ctrl-D)")
while True:
    try:
        q = input("\n질문> ").strip()
    except (EOFError, KeyboardInterrupt):
        print("\nbye"); break
    if not q:
        continue
    body = json.dumps({"question": q, "max_new_tokens": 256}).encode()
    req = urllib.request.Request(URL, data=body,
                                 headers={"Content-Type": "application/json"}, method="POST")
    try:
        r = json.load(urllib.request.urlopen(req, timeout=120))
        print("답변:", r["answer"])
        print(f"      ({r['tokens']} tok · {r['gen_s']}s · {r['tok_per_s']} tok/s)")
    except Exception as e:
        print("에러:", e, "\n→ 포트포워드가 떠 있는지 확인하세요.")

 

5. 트러블슈팅: torch × Triton × C 컴파일러의 함정

Ray Serve 배포 후 처음 generate 함수를 호출했을 때, 아래와 같은 에러를 만났습니다.

RuntimeError: Failed to find C compiler. Please specify via CC environment variable
  ...
  File ".../triton/backends/nvidia/driver.py", line 66, in __init__
    self.utils = CudaUtils()

원인:

환경에 설치된 torch 2.13.0+cu130 버전은 RoPE(Rotary Position Embedding) 등 일부 연산을 최적화하기 위해 Triton JIT(Just-In-Time) 커널로 디스패치합니다. 이때 Triton은 첫 실행 시 CUDA 유틸 모듈을 런타임에 C 컴파일러로 빌드하려 시도하는데, 베이스로 사용한 rayproject/ray 이미지에는 gcc가 포함되어 있지 않아 발생한 문제였습니다.

해결 방법:

GPU 워커 컨테이너에 C 컴파일러를 설치해 줍니다.

kubectl -n ray exec <gpu-worker> -- sudo apt-get install -y gcc g++

컴파일러가 생기면 첫 호출 시 Triton이 자동으로 커널을 컴파일하며(약 2초 소요), 이후부터는 캐시되어 100+ tok/s의 정상 속도로 동작합니다.

Tip: 프로덕션 환경의 LLM 서빙용 커스텀 이미지를 만들 때는 torch, transformers 외에도 gcc 같은 빌드 도구를 미리 구워두거나, Triton 커널을 사전에 컴파일해 두는 것이 콜드 스타트를 방지하는 좋은 방법입니다.

6. 결론 및 체크리스트

VM 가상화와 GPU 슬라이싱이라는 복잡한 환경 위에서도 표준 Ray 스택(KubeRay, Ray Serve)이 전혀 무리 없이 완벽하게 동작함을 확인했습니다. 모델 서빙에 필요한 레이어를 K8s 네이티브하게 올릴 수 있어 운영 편의성도 크게 챙길 수 있습니다.

+ Recent posts