B7G.PRO / SHEET 03 OF 04 — BLOG
REV: 2026·05·11
SHEET 03 / NOTE · · UPDATED

하드웨어 한계인 줄 알았는데, 설정이었어요 — RTX 3090에서 vLLM 컨텍스트 16K를 61K로

집 데스크탑에는 RTX 3090이 한 장 있어요. 여기서 vLLM으로 로컬 LLM 서버를 돌려요. Qwen3.6-27B를 AWQ 4비트로 양자화한 걸 --quantization awq_marlin으로 얹어놓고, 개인 프로젝트 몇 개가 이 서버를 머리로 쓰고 있어요.

그런데 이 서버의 --max-model-len이 16,384였어요. 요즘 클라우드 모델들이 20만 토큰씩 받는 걸 생각하면 아주 좁은 방이에요. 대화가 조금만 길어지면 히스토리를 압축하고, 도구 결과는 요약해서 넣고, 코드를 짤 때마다 “컨텍스트는 예산이다”를 주문처럼 외웠어요.

그리고 그게 하드웨어 한계라고 생각했어요. 24GB에 27B를 욱여넣었으니, 이 정도면 감지덕지라고요.

줄어든 KV 캐시의 미스터리

사실 마음에 걸리는 게 하나 있긴 했어요. 예전 기록에는 KV 캐시(컨텍스트가 사는 메모리 공간)가 3.55 GiB라고 적혀 있는데, 언젠가부터 재보면 2.18 GiB였거든요. 1.4기가가 어디로 증발했는지 알 수 없었어요. vLLM 버전이 올라가면서 회귀가 생겼나, 다른 프로세스가 몰래 먹고 있나. 찜찜한 채로 “원래 그런가 보다” 하고 지나갔어요.

이번에 작정하고 들여다본 날, 식부터 세웠어요. 모델 가중치가 18.91 GiB, 실행 오버헤드가 약 1 GiB. 이 둘은 거의 고정이에요. 그러니까:

가용 KV ≈ 24 × --gpu-memory-utilization − 19.9

이 식에 0.98을 넣으면 3.55, 0.92를 넣으면 2.18이 나와요. 미스터리가 아니었어요. 3.55는 --gpu-memory-utilization을 0.98로 두던 시절의 측정치고, 2.18은 0.92로 낮춰두던 시절의 측정치였던 거예요. 캐시는 증발한 적이 없어요. 내가 설정을 바꿔놓고 잊었을 뿐이에요.

측정치를 비교할 땐 그때의 --gpu-memory-utilization을 함께 봐야 해요. 안 그러면 가짜 회귀가 보여요.

이 식이 말해주는 게 하나 더 있었어요. 16K는 하드웨어 한계가 아니라는 것. 모델이 19기가를 깔고 앉는 건 어쩔 수 없지만, 남은 공간을 어떻게 배분하느냐는 전부 설정의 문제였어요.

fp8 KV 캐시는 왜 못 썼나 (Ampere의 벽)

컨텍스트를 늘리려 할 때 제일 먼저 나오는 권고가 --kv-cache-dtype으로 KV를 fp8로 줄이라는 거예요. KV가 절반이 되면 컨텍스트가 두 배가 되니까요. 그런데 Ampere(RTX 3090, compute capability 8.6)에서는 세 방향이 전부 막혀요.

  • fp8 / fp8_e4m3 — E4M3가 네이티브로 지원되지 않아요. Triton 컴파일이 type fp8e4nv not supported로 실패해요.
  • fp8_e5m2 — 모델 어텐션 코드가 거부해요. assert kv_cache_dtype in {"fp8","fp8_e4m3","nvfp4"}.
  • nvfp4 — Blackwell 전용이에요.

게다가 fp8을 강제하면 FlashInfer가 JIT 컴파일에 들어가면서 nvcc를 못 찾고 죽어요. Could not find nvcc and default cuda_home='/usr/local/cuda' doesn't exist. 이 서버에는 CUDA 툴킷을 안 깔았으니 필연이에요.

그래서 --kv-cache-dtype을 아예 빼고 fp16으로 갔어요. 손해를 본 것 같지만 덜 아팠어요. Qwen3.6-27B는 하이브리드 어텐션 구조라(vLLM이 qwen3_next로 잡아요) 전체 레이어 중 full-attention 층이 일부뿐이거든요. 토큰당 KV 비용이 동급 dense 모델의 몇 분의 1이라, fp16으로 가도 컨텍스트 손해가 생각보다 작았어요.

컨텍스트가 목적이라면 여기서 배울 게 하나 있어요. “컨텍스트 늘리려고 모델을 바꾼다”면 하이브리드 어텐션 쪽으로 가야지, 동급 dense로 가면 오히려 역행이에요.

짜내기

그래서 남은 레버로 짜내기 시작했어요.

먼저 --gpu-memory-utilization을 0.98까지 올렸어요. 보통은 이렇게 안 하는데, 이 서버는 모니터도 안 꽂힌 헤드리스라 GPU 메모리를 화면에 뺏길 일이 없거든요.

다음은 --max-num-seqs를 2에서 1로 내렸어요. KV 소요를 가장 크게 좌우하는 게 이 값이에요(대략 max_num_seqs × max_model_len). 어차피 쓰는 사람이 저 하나라 동시에 두 요청이 올 일이 드물고요.

마지막으로 --max-num-batched-tokens를 2048에서 512로 낮추고 --enable-chunked-prefill을 켰어요. 이건 좀 덜 알려진 레버인데, 기동할 때 vLLM이 메모리를 프로파일링하려고 더미 forward를 한 번 돌려요. 그 활성화 피크가 줄어드는 만큼 KV로 갈 여유가 생겨요. 실측으로 수백 MiB를 벌었어요. 대가는 품질이 아니라 prefill 속도예요.

엉뚱한 카드를 집지 않게 (그리고 뜻밖의 선물)

중간에 함정도 하나 밟았어요. 이 데스크탑에는 GPU가 두 장이에요. 3090 옆에 TTS 용도로 쓰는 GTX 1660 SUPER가 한 장 더 있어서, vLLM이 엉뚱한 카드를 집지 않게 CUDA_VISIBLE_DEVICES에 UUID로 정확히 지정하려 했어요.

그랬더니 vLLM이 기동을 거부하더라고요. invalid literal for int() ... 'GPU-...'. UUID 문자열을 인덱스 숫자로 파싱하려다 실패하는 거였어요. Docker의 --gpus device=GPU-...는 UUID를 받아주니까 당연히 될 줄 알았는데, vLLM은 아니었어요.

결국 인덱스로 핀하되 CUDA_DEVICE_ORDER=PCI_BUS_ID를 함께 넣는 방식으로 갔어요. 이게 없으면 CUDA 기본 순서가 FASTEST_FIRST라서 인덱스가 nvidia-smi와 다르게 매겨져요. 엉뚱한 카드에 핀될 수 있다는 뜻이에요.

그런데 이 핀 작업이 뜻밖의 선물을 줬어요. 3090만 노출시키자 어텐션 백엔드가 TRITON_ATTN에서 FLASH_ATTN으로 저절로 바뀌어 있었어요.

예전에 더 느린 백엔드로 도는 걸 보고 “이 환경에선 FlashAttention이 안 되나 보다” 하고 넘겼었는데, 아니었어요. FA2 가용성은 시스템에서 가장 낮은 세대 카드에 끌려가요. GTX 1660 SUPER는 compute capability 7.5라, vLLM이 Cannot use FA version 2 ... only supported on devices with compute capability >= 8으로 판정하고 FLASH_ATTN을 후보에서 통째로 빼버렸던 거예요. 추론 카드가 3090(8.6)인데도요. 문제 카드를 시야에서 치우니 원래 됐어야 할 게 됐어요.

참고로 VLLM_ATTENTION_BACKEND 환경변수로 강제하려는 시도는 소용없었어요. vLLM 0.20.2가 그 변수를 인식하지 않더라고요.

0.26 GiB

목표는 64K였어요. --max-model-len 65536을 질러 넣고 기동했더니 실패했는데, vLLM의 실패 로그가 의외로 친절했어요. estimated maximum model length is 61152이라고 정확한 숫자를 찍어주더라고요.

상한 탐색은 계산보다 질러보기가 빨라요. 안 들어가면 기동을 거부하면서 정확한 상한을 알려주거든요. 조용한 품질 저하가 아니라 기동 거부라 안전하기도 하고요.

계산해보니 64K에는 KV 캐시가 4.16 GiB 필요한데, 확보된 건 3.9 GiB. 0.26 GiB가 모자랐어요. --gpu-memory-utilization을 0.99 이상으로 올리면 닿을 수도 있었지만, 거기는 도박의 영역이에요. 24시간 도는 서버를 그 벼랑에 세우고 싶지 않았어요.

그래서 로그가 알려준 숫자를 그대로 받았어요. 61,152 토큰. 64K라는 동그란 숫자는 아니지만, 16,384에서 3.7배예요. Qwen3.6-27B의 네이티브 컨텍스트가 256K라 이 범위 안에서는 품질 손실도 없고요.

그리고 KV 풀은 기동할 때 선할당이라, 기동만 통과하면 상한 근처를 채운 요청이 와도 런타임에 OOM이 나는 구조가 아니에요. 58.5K짜리 요청을 실제로 넣어봤는데 VRAM 증가가 0이었어요.

대가는 속도로

공짜는 아니었어요. 품질 대신 속도로 지불해요.

--max-num-seqs가 1이니 요청이 겹치면 줄을 서요. 부하 테스트를 해보니 30K짜리 긴 요청 뒤에 도착한 한 줄짜리 질문이 27초를 통째로 기다리더라고요. head-of-line blocking이에요. 컨텍스트를 상한 근처까지 채운 요청은 prefill에만 58초가 걸리고요(58.5K 실측, 약 1,100 tok/s).

그래도 지금은 이게 맞는 트레이드라고 판단했어요. 쓰는 사람이 저 하나뿐인 서버라 줄서기가 생길 트래픽이 아니고, 긴 컨텍스트가 주는 여유는 매일 체감되니까요. 나중에 대기가 문제가 되면 --max-num-seqs를 2로 되돌리는 레버도 남아 있어요. 하이브리드 어텐션의 linear-attn 상태가 시퀀스당 147 MiB씩 고정으로 붙어서, 컨텍스트를 58.8K 정도로 조금 내주는 대신에요.

최종 설정

혹시 비슷한 구성을 만지는 분이 있을까 봐 적어둬요. RTX 3090 한 장, Qwen3.6-27B AWQ 기준이에요.

--quantization awq_marlin
--max-model-len 61152
--gpu-memory-utilization 0.98
--max-num-seqs 1
--max-num-batched-tokens 512
--enable-chunked-prefill

환경변수로 CUDA_DEVICE_ORDER=PCI_BUS_IDCUDA_VISIBLE_DEVICES=1을 함께 줬어요. --kv-cache-dtype은 없어요(= fp16). --enforce-eager도 안 써요.

한 가지 위생 규칙이 있어요. 이런 명령을 백슬래시로 여러 줄에 나눠 쓰면 zsh에 붙여넣을 때 끊겨서 뒷부분 플래그가 통째로 유실돼요. 그래놓고 “설정대로 안 뜬다”고 오진하게 되고요. 한 줄로 실행하고, 기동 로그의 non-default args:에 의도한 플래그가 전부 떴는지 눈으로 확인하는 게 좋아요.

수치는 늙어요

이번 일에서 남은 교훈은 두 개예요.

하나는, “하드웨어 한계”라고 믿었던 게 사실은 설정이었다는 것. 한계라는 단어는 편해요. 거기서 생각이 멈춰도 되니까요. 그런데 식 하나 세워보니 한계가 아니라 배분이었어요.

다른 하나는, 문서에 적힌 수치는 늙는다는 것. 3.55 GiB라는 기록도 한때는 사실이었어요. --gpu-memory-utilization이 바뀌는 순간 조용히 거짓이 됐을 뿐이에요. 그래서 이제 판단은 문서가 아니라 그때그때의 기동 로그에 찍히는 GPU KV cache size로 해요. 문서에는 수치 대신, 수치를 어디서 읽으면 되는지를 적어두고요.

이 글의 숫자들도 마찬가지예요. 늙을 거예요.