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다.
| 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% |
해석
- 처리량은 프리필이 무거울수록 TRT-LLM이 유리하다. W1에서 +1.8%(측정 노이즈 수준)였던 격차가 W2에서 +10%로 벌어진다. 긴 컨텍스트 프리필에서 TRT-LLM의 fused MHA 커널이 이득을 낸다.
- 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 프리필을 한 번에 처리해 중앙값이 훨씬 좋다.
- 꼬리(P99 TTFT)는 TRT-LLM이 전 구간 우세 (−13~17%). GUARANTEED_NO_EVICT 스케줄러 정책이 한번 받은 요청을 선점당하지 않게 보장하는 것과 맞닿아 있다. SLO를 P99로 잡는 서비스라면 중앙값만 보고 vLLM을 고르면 안 된다.
- 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
같은 체크포인트를 두 엔진이 모두 그대로 사용할 수 있다.
메모리 효과
| 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 에 잔존
'LLM Serving and Optimization Study' 카테고리의 다른 글
| [LLM 3.5주차] 양자화 실습 (0) | 2026.08.23 |
|---|---|
| [LLMSO 3주차] K8s 기반 vLLM 서빙 파라미터 실측 튜닝기 (0) | 2026.08.23 |
| [LLMSO 2주차] Ray Serve로 LLM(Qwen2.5) 서빙하기 (0) | 2026.08.15 |
| [LLMSO 1주차] 기본 개념과 PagedAttention 최적화 메커니즘 (0) | 2026.08.09 |

