본문으로 건너뛰기

© 2026 Molayo

Reddit요약2026. 06. 15. 09:46

2x RTX 3090에서 구동하는 Qwen3.6-27B: llama.cpp vs vLLM, 모든 플래그, MTP 수락률 및 추론 속도/컨텍스트

요약

2x RTX 3090 환경에서 Qwen3.6-27B 모델을 llama.cpp와 vLLM 백엔드로 구동하며 성능을 비교 분석한 기술 리포트입니다. 각 백엔드별 양자화 방식에 따른 MTP 수락률, 추론 속도, 컨텍스트 길이를 실제 데이터를 통해 상세히 다룹니다.

핵심 포인트

  • llama.cpp와 vLLM의 양자화 및 백엔드 조합별 성능 비교
  • MTP(Multi-Token Prediction) 초안 헤드 수락률 데이터 제공
  • 2x RTX 3090 환경에서의 하드웨어 제약 사항 및 성능 영향 분석
  • OpenAI 호환 엔드포인트를 위한 llama-swap 프록시 구성 방법

제가 약 20% 작성했고 나머지 80%는 Claude code가 작성했습니다. 제 시스템에서 Qwen3.6-27B를 네 가지 양자화/백엔드 조합(llama.cpp Q6_K 및 Q8_0, vLLM INT4 및 INT8) 사이를 핫스왑(hot-swaps)할 수 있는 하나의 OpenAI 호환 엔드포인트(endpoint)로 구축하는 데 거의 하루를 보냈습니다. 이 모든 내용을 기록하는 이유는, 솔직히 제가 가장 찾고 싶었던 것—각 위치별 실제 MTP 초안 헤드(draft-head) 수락 수치(acceptance numbers)—를 어디에서도 찾을 수 없었기 때문입니다. 만약 그것만을 위해 오셨다면 결과는 아래에 있습니다. 아래의 모든 내용은 실제 데이터입니다: 하드웨어, 스왑(swap) 설정, 원격 접속 방법, 실행 중인 모든 플래그, 결과, 그리고 저를 괴롭혔던 사소한 문제들까지 포함되어 있습니다.

요약(TL;DR) 결과
매번 동일한 프롬프트 사용(약 1,000단어 에세이), 온도(temp) 0.6, 단일 요청, 다른 프로세스 실행 없음.

| 백엔드 | 양자화(Quant) | 초안 헤드(Draft head) tok/s | MTP 수락률 (위치당) | 컨텍스트(Context) |
| :--- | :--- | :--- | :--- | : |
| llama.cpp | Q6_K | draft-mtp 43.1 | ~54% | 131k |
| llama.cpp | Q8_0 | draft-mtp 44.2 | ~55% | 131k |
| vLLM | INT8 AutoRound BF16 | 51.6 | 77% / 49% | 32k |
| vLLM | INT4 AutoRound INT4 | 53.7 | 75% / 47% / 27% | 64k |

lama.cpp의 tok/s는 .timings(순수 생성) 기준입니다. vLLM의 수치는 실제 경과 시간(wall-clock) 기준의 단일 스트림(single-stream)이므로 약간 과소평가되었을 수 있습니다. vLLM의 수락(accept) 수치는 /metrics에서 각 초안 위치(draft position)별로 직접 추출한 것입니다.

하드웨어
2x RTX 3090, 총 48GB, 두 카드 모두 전력 제한 230W. 유휴(Idle) 상태 시 약 10-22W. Threadripper 1950X, 30GB RAM, NVMe. NVLink는 없으며, 짜증 나는 부분은 PCIe P2P도 지원되지 않는다는 점입니다. 1950X는 2-다이 MCM(Multi-Chip Module)이므로 카드들이 서로 다른 루트 컴플렉스(root complexes)에 위치하게 됩니다 (cudaDeviceCanAccessPeer 결과가 false로 나오며, NCCL_P2P_DISABLE=1로 실행합니다). 따라서 모든 TP=2 all-reduce 연산은 Infinity Fabric을 거쳐야 합니다. vLLM 수치를 보실 때 이 점을 염두에 두시기 바랍니다. 확실히 성능 손실이 있습니다.

연결 구성 방식
모든 것의 앞단에 단일 포트의 OpenAI API를 제공하는 하나의 llama-swap 프록시(proxy)가 배치되어 있습니다. 네 가지 백엔드 모두 swap: true 설정이 된 하나의 스왑 그룹(swap group) 내에 존재하므로, 한 번에 하나만 로드되어 GPU 자원 경합이 발생하지 않습니다. 10분간 유휴 상태(ttl: 600)가 되면 자동으로 언로드(unload)되므로, 사용하지 않을 때는 카드가 실제로 냉각 상태가 됩니다.

vLLM의 콜드 스타트(cold start)에 2~4분이 소요되어 완료되기 전에 프로세스가 종료되는 문제를 해결하기 위해 healthCheckTimeout을 360으로 늘렸습니다.

사용 중인 구성:

  • 라우터(Router): llama-swap, 단일 포트, 단일 스왑 그룹(swap group)
  • 백엔드 A: 소스에서 빌드한 llama.cpp (CUDA), llama-server
  • 백엔드 B: venv 환경의 vLLM 0.22, TP=2
  • 유휴 상태 언로드(Idle unload) ttl: 600 (10분)
  • 상태 확인 타임아웃(Health timeout) healthCheckTimeout: 360

별도의 포트를 개방하지 않고 원격 접속을 하는 방법:
장비에 Tailscale을 설치하고, 동일한 tailnet 상의 저렴한 VPS에서 Open WebUI를 실행하여 Tailscale을 통해 엔드포인트와 통신합니다. 외부 공개 측면은 Cloudflare Tunnel을 사용합니다. 이는 아웃바운드(outbound) 전용으로, 열려 있는 포트가 없으며 오리진(origin) IP가 숨겨진 상태를 유지합니다. 결과적으로 관리자 권한이 없는 보안이 엄격한 노트북에서도 HTTPS 페이지를 여는 것만으로 접속할 수 있습니다.

4개의 백엔드 + 실제 플래그(flags):
llama.cpp — Q8_0 / Q6_K
이것들은 MTP(Multi-Token Prediction)가 보존된 "Heretic" 검열되지 않은 (uncensored) GGUF 파일들입니다.

lama-server \
  --host 127.0.0.1 --port 8080 \
  -m Qwen3.6-27B-Heretic-Q8_0.gguf --alias Qwen3.6-27B-Q8 \
  --jinja --chat-template-file qwen3.6-chat-template.jinja \
  --chat-template-kwargs '{"preserve_thinking":true}' --reasoning auto \
  --spec-type draft-mtp --spec-draft-n-max 3 \
  -ngl 99 --device CUDA0,CUDA1 -ts 24,24 \
  -c 131072 -fa on -ctk q8_0 -ctv q8_0 --cache-reuse 256 -np 1 \
  --temp 0.6 --top-p 0.95 --top-k 20 --min-p 0 \
  --presence-penalty 0 --repeat-penalty 1.0 --metrics

Q6_K는 정확히 동일한 설정이며, 단지 -c 0 (모델 최대 컨텍스트)과 Q6_K 파일을 사용하는 것뿐입니다.

vLLM — INT4 / INT8 (둘 다 AutoRound) 환경 변수부터 먼저 설정해야 하며, 이들이 중요합니다: NCCL_P2P_DISABLE=1 \ NCCL_CUMEM_ENABLE=0 \ VLLM_WORKER_MULTIPROC_METHOD=spawn \ OMP_NUM_THREADS=1 \ VLLM_USE_FLASHINFER_SAMPLER=1 \ PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,max_split_size_mb:512 그런 다음 다음과 같이 실행합니다: vllm serve <모델-경로> \ --served-model-name Qwen3.6-27B \ --quantization auto_round --dtype float16 \ --tensor-parallel-size 2 \ --max-model-len <M> \ --gpu-memory-utilization <U> \ --max-num-seqs 2 --max-num-batched-tokens 8192 \ --kv-cache-dtype fp8_e5m2 --trust-remote-code \ --reasoning-parser qwen3 \ --default-chat-template-kwargs '{"enable_thinking":false}' \ --enable-auto-tool-choice --tool-call-parser qwen3_coder \ --enable-prefix-caching --enable-chunked-prefill \ --speculative-config '{"method":"mtp","num_speculative_tokens":<N>}' \ --override-generation-config '{"temperature":0.6,"top_p":0.95,"top_k":20,"min_p":0.0,"repetition_penalty":1.0}' \ --disable-custom-all-reduce 두 값 사이에서 변경되는 세 가지 값 ( <N> , <M> , <U> ): 매개변수 INT8 AutoRound INT4 AutoRound num_speculative_tokens (N) 2 3 --max-model-len (M) 32768 65536 --gpu-memory-utilization (U) 0.90 0.92 저는 INT8에서 U를 더 낮게 설정했습니다. 왜냐하면 가중치가 이미 약 36GB이기 때문에, 더 높이 올리고 싶지 않았기 때문입니다. 제가 얻은 교훈: 초안 헤드 정밀도는 수치에 명확하게 나타납니다. INT8은 MTP 헤드를 BF16으로 유지하고 모든 위치에서 더 좋은 성능을 보입니다 (pos0에서 77 vs 75, pos1에서 49 vs 47). INT4는 헤드를 4비트로 양자화하며 무너지는 것을 볼 수 있습니다 — 세 번째 초안 슬롯은 27%의 시간대에만 도달합니다. 제가 실제로 매일 사용할 것은 INT8입니다. 약 52 tok/s의 Q8 수준 품질로, 이는 제 llama.cpp Q8 (44)보다 약 17% 빠르며, 도구 호출도 작동합니다. 하지만 전체적으로는 여전히 INT4가 가장 빠릅니다 (~54). 결국 토큰당 절반의 가중치 바이트를 이동시키는 것이 더 나은 수용률에도 불구하고 이기는 것 같습니다. --spec-draft-n-max 4는 3에 비해 상황을 악화시켰습니다 (46에서 40 tok/s로 떨어졌고, 수용률은 약 12 포인트 하락했습니다).

헤드는 실제로 약 1개의 토큰 앞만 제대로 짚어내며, 그 이상을 요구하는 것은 역효과를 낳습니다. 저를 괴롭혔던 점은 MTP (Multi-Token Prediction)가 아무런 반응 없이 조용히 작동을 멈출 수 있다는 것이었습니다. 만약 양자화 (Quantization) 과정에서 드래프트 헤드 (draft head)가 누락되거나 4-bit로 떨어졌는데 로더 (loader)가 이를 찾지 못하면, 투기적 디코딩 (speculative decoding)은 에러나 경고 없이 그냥 조용히 아무것도 하지 않습니다. spec_decode_num_accepted_tokens_total을 모니터링하세요. 그것이 이를 잡아낼 수 있는 유일한 방법입니다. vLLM은 시작이 실패할 때 VLLM::Worker_TP* 프로세스를 누수(leak)시킵니다. 이 프로세스들은 이름이 변경되기 때문에 pkill vllm 명령어를 사용하면 그냥 지나치게 됩니다. PID를 통해 직접 종료해야 했습니다. INT8 카드는 --calculate-kv-scales 옵션이 KV 캐시 (KV cache)를 손상시킨다는 경고를 보냈기에, 해당 옵션은 제외했습니다.

제가 막힌 부분 / 질문 사항
이 부분이 제가 실제로 도움을 받고 싶은 부분입니다. NVLink와 P2P (Cross-die 1950X)가 없는 환경에서 TP (Tensor Parallelism) = 2 설정은 명확하게 단일 스트림 속도를 갉아먹고 있으며, 저는 이 하드웨어의 실제 한계치가 어디인지 파악하려 노력 중입니다. tok/s 대 컨텍스트 (context) — 여러분은 어디에서 타협점을 찾으시나요? 더 높은 tok/s를 얻을 수 있지만 컨텍스트를 희생해야 하고, 그 반대도 마찬가지입니다. 48GB 메모리에서 27-30B 모델을 구동하는 분들은 일상적으로 어떤 트레이드오프 (tradeoff)를 선택하셨나요? vLLM INT8에서 실제로 유지 가능한 최대 컨텍스트는 어느 정도인가요? 가중치 (Weights)가 약 36GB이므로, 48GB에서 128k 컨텍스트가 현실적으로 가능한지 아니면 제가 꿈을 꾸고 있는 것인지 궁금합니다. 만약 그렇게 사용 중이시라면, --max-model-len, --gpu-memory-utilization, 그리고 --kv-cache-dtype을 어떻게 설정하시나요? 어떤 플래그 (flags)가 실제로 유의미한 변화를 만들어냈나요? 저는 -sm row, MTP 대신 draft-eagle3 사용, 그리고 KV 캐시를 q4로 낮추는 방안을 고려하고 있습니다. 혹시 P2P가 없는 설정에서 이 옵션들을 벤치마크해 본 분이 계신가요? 아니면 솔직한 정답이 TP를 완전히 포기하고, 카드 한 장당 모델 하나를 할당하여 두 개의 별도 인스턴스를 실행하는 것인가요?

특히 llama.cpp의 경우 — 듀얼 3090에서 27B Q8 모델로 약 44 tok/s보다 유의미하게 높은 속도를 뽑아내시는 분이 계신가요? 만약 있다면 비결이 무엇인가요? 드래프트 설정인가요, KV 캐시 타입인가요, -sm 모드인가요, 아니면 다른 무엇인가요? 기본적으로: 여기서 다음에 무엇을 더 밀어붙여 볼 수 있을까요? 그리고 이 하드웨어의 실제 한계는 어디일까요? 제가 한계점에 얼마나 근접해 있는지 진심으로 궁금합니다.

업데이트 2 - 게시물이 너무 길어진 점 정말 죄송합니다만, 문제를 해결하여 llama.cpp 속도가 약 2배 빨라졌고, 디코딩(Decode) 속도가 8K에서 262K 컨텍스트까지 75~84 tok/s로 일정하게 유지된다는 점을 공유하고 싶었습니다. 다시 한번 솔직히 말씀드리자면, 이 글의 상당 부분은 제가 이해하고 여러분께 설명하기 위해 Claude Code를 사용하여 작성 및 조정되었습니다. 덕분에 Full-precision(전정밀도) 롱 컨텍스트(Long context)가 쉽게 들어갑니다. 여기 저의 창피한 방식이 있습니다 (미리 조사를 하지 않았습니다.. 죄송합니다 여러분!). 잘못된 계산 때문에 "안정성을 위해" vLLM 컨텍스트를 64K로 제한해 두었습니다. 그래서 실제 테스트를 진행했습니다 — 맨 위에 비밀 암호가 숨겨진 185,476 토큰의 프롬프트를 입력한 뒤, 모델에게 이를 회상하도록 요청했습니다:

  • Needle: 185K 토큰 이상의 채우기(filler) 데이터 속에서도 위에서 암호를 정확히 회상함
  • Decode: 해당 깊이에서도 27 tok/s 유지
  • Peak KV-cache 풀 사용량: 32% — KV는 한계에 근접하지도 않았음
  • VRAM이 실제 천장임: 카드당 23.3 / 24 GB
  • 충돌(Crash) 없음

KV가 제약 사항이 아니었습니다. 저는 컨텍스트를 약 3배나 낭비하고 있었습니다.

실수 #2: 잘못된 llama.cpp 분할 모드(split mode)를 사용하고 있었습니다.
기존의 약 44 tok/s는 기본 레이어 분할(layer split) 방식이었습니다. 누군가 P2P(Peer-to-Peer)가 없더라도 텐서 병렬(Tensor-parallel) 방식이 더 빠를 것이라고 말했습니다.

깨끗한 A/B 테스트 — 동일 모델 (Heretic Q8_0), 65K 컨텍스트, f16 KV, draft-mtp n=3 — 오직 -sm 옵션만 변경:

lama.cpp -sm | code tok/s | text tok/s
row 44 | 35 | layer (기존 기본값) | 52 | 45
tensor | 70 | 56

-sm tensor 방식이 압승하며 깊이(depth)에서도 속도를 유지합니다 (37K에서도 여전히 ~60 tok/s). NVLink가 없더라도 2배의 메모리 대역폭(Memory bandwidth)이 All-reduce 비용을 압도합니다. 옵션 하나로 약 44 → ~70 tok/s로 향상되었습니다.

⚠️ 주의사항: 텐서 모드는 샘플러(Sampler)와 MTP를 CPU로 밀어냅니다 (경고 메시지가 뜰 것입니다). 하지만 여전히 가장 빠릅니다.

lama-server -m Qwen3.6-27B-Q8_0.gguf -ngl 99 --device CUDA0,CUDA1
-sm tensor --tensor-split 50,50 --no-mmap -c 200000 -fa on
--spec-type draft-mtp --spec-draft-n-max 3 --cache-reuse 256 -np 1 --jinja (no -ctk/-ctv = full f16 KV)

저의 정확한 vLLM 설정 (단일 스트림 승자: ~81 tok/s)
최고의 단일 스트림 속도를 위해서는 INT4 가중치 + MTP를 사용하는 vLLM이 여전히 승리하며, 비전(Vision)과 도구(Tools) 사용도 가능합니다.

설정값이유
vllm/vllm-openai stablepurge된 nightly 버전 아님 / 소스 오버레이 없음
Weights (가중치)Qwen3.6-27B AutoRound INT4 ~13 GB → 거대한 KV 여유 공간
Tensor-parallel (텐서 병렬화)2 (두 카드 모두 사용)
KV cache (KV 캐시)fp8_e5m2 (1 byte/token으로 긴 컨텍스트 지원)
Drafter (초안 작성기)MTP n=3 (속도 배수)
Max ctx (최대 컨텍스트)최대 262K (INT4로 200K, fp8-mtp로 262K 실행)
Vision + tools (비전 + 도구)활성화 (qwen3_coder) 이미지 입력 + 함수 호출 (function calling)
export NCCL_P2P_DISABLE=1
export NCCL_CUMEM_ENABLE=0
export VLLM_USE_FLASHINFER_SAMPLER=1
vllm serve /models/qwen3.6-27b-autoround-int4 \
  --served-model-name qwen3.6-27b-autoround \
  --quantization auto_round --dtype float16 \
  --tensor-parallel-size 2 --disable-custom-all-reduce \
  --max-model-len 200000 --gpu-memory-utilization 0.90 \
  --max-num-seqs 2 --max-num-batched-tokens 8192 \
  --kv-cache-dtype fp8_e5m2 --trust-remote-code \
  --enable-prefix-caching --enable-chunked-prefill \
  --speculative-config '{"method":"mtp","num_speculative_tokens":3}' \
  --reasoning-parser qwen3 \
  --enable-auto-tool-choice --tool-call-parser qwen3_coder \
  --override-generation-config '{"temperature":0.6,"top_p":0.95,"top_k":20,"min_p":0.0,"repetition_penalty":1.0}'

NVLink 없이 TP=2(Tensor Parallelism 2)를 유지하게 해주는 두 가지 플래그는 --disable-custom-all-reduce (NVLink를 가정한 경로가 PCIe에서 깨짐)와 NCCL_P2P_DISABLE=1입니다. 이 플래그들이 없으면 시스템이 멈춥니다.

MTP n=3은 속도를 약 50 → 81 tok/s로 끌어올리는 핵심입니다. 순수 코드 작업 시, 초안으로 작성된 3개의 토큰 중 각각 88% / 78% / 56%를 수락합니다 (accept-length 3.3).

사람들이 실제로 궁금해하는 부분은 이것입니다: 컨텍스트가 길어짐에 따라 속도가 어떻게 유지되는가? 그래서 저는 8K에서 262K까지의 컨텍스트 사다리(context ladder)를 구축하고, 각 단계에서 디코딩 tok/s, 프리필(prefill), MTP 수락률, KV 캐시 사용량, 그리고 '건초더미 속 바늘 찾기(needle-in-a-haystack)' 테스트(컨텍스트를 채운 후 맨 상단에 숨겨둔 비밀 코드를 회상하는 테스트)를 기록했습니다. 각 단계마다 동일한 코드 생성 작업을 수행했습니다. 258,946 토큰 지점에서도 모든 단계에서 바늘을 정확하게 찾아냈습니다.

vLLM INT4 · TP=2 · fp8 KV · MTP n=3 depth decode tok/s MTP 수락률 KV-pool 사용량 needle 8K 80 92/80/61% 5% ✅ 32K 84 91/80/65% 8% ✅ 64K 84 90/79/64% 13% ✅ 120K 69* 80/62/49% 21% ✅ 180K 80 90/78/66% 30% ✅ 200K 78 91/82/66% 33% ✅ 262K 75 93/82/66% 42% ✅ llama.cpp Q8_0 · -sm tensor · f16 KV · MTP n=3 depth decode tok/s needle 8K 76 ✅ 64K 68 ✅ 120K 61 ✅ 180K 57 ✅ 200K 56 ✅ 놀라운 점: vLLM의 디코딩 (decode) 속도는 8K에서 262K까지 거의 평탄하게 유지됩니다 (~75-84). 깊이(Depth)는 거의 비용이 들지 않습니다 — MTP는 262K에서도 약 90/80/65%의 수락률을 유지합니다. (120K에서의 하락은 낮은 수락률을 보인 하나의 탐욕적(greedy)인 패치이며, 84와 80 사이에 끼어 있습니다 — 노이즈일까요??? 추세는 아닙니다.) llama.cpp는 완만하게 감소합니다 (76 → 56, 범위 내 약 26% 감소) — 깊이가 깊어질수록 느려지지만, vLLM의 약 24GB 대비 카드당 약 21GB로 구동되므로 더 많은 여유 공간(headroom)을 가집니다. KV는 결코 병목(bottleneck)이 아닙니다 — 전체 262K에서 풀(pool)은 42%만 채워져 있습니다. 실제 한계는 캐시(cache)가 아니라 VRAM(가중치 + CUDA 그래프 + 예약된 풀)입니다. 프리필(Prefill)은 예상대로 범위 전체에 걸쳐 약 1.4배 더 느리게 확장됩니다 (더 긴 어텐션 (attention)). 디코딩 tok/s vs 컨텍스트 깊이 — 2×3090(NVLink/P2P 없음)에서의 Qwen3.6-27B, 최대 258K 토큰까지 모든 깊이에서 바늘 찾기(needle-in-haystack) 성공, 디코딩 tok/s vs 컨텍스트 깊이 — 2×3090(NVLink/P2P 없음)에서의 Qwen3.6-27B, 최대 258K 토큰까지 모든 깊이에서 바늘 찾기(needle-in-haystack) 성공 vLLM INT4 · TP=2 · fp8 KV · MTP n=3 (블록 = 5 tok/s) 8K ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 80 tok/s 16K ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 78 tok/s 32K ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 84 tok/s 64K ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 84 tok/s 120K ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 69 tok/s <- 단일 하락 지점 (이번 실행에서 낮은 MTP 수락률) 180K ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 80 tok/s 200K ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 78 tok/s

AI 자동 생성 콘텐츠

본 콘텐츠는 r/OpenAI Codex (search)의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0