GPU 한 장으로 챗봇을 돌리다 보면 동시 접속이 조금만 늘어도 TTFT가 늘어지는 지점이 온다. 긴 시스템 프롬프트에 멀티턴 대화, 거기에 RAG로 끌어온 문서까지 매번 다시 prefill하면 GPU가 금방 포화된다. 같은 GPU를 그대로 두고 더 받아내고 싶을 때 손대볼 만한 게 KV 캐시 오프로딩이다.
결론부터 말하자면, vLLM에 LMCache를 LMCacheConnectorV1로 붙이면 GPU에서 밀려난 KV 캐시를 CPU RAM과 NVMe 디스크로 내려보내고, 원격 백엔드를 쓰면 여러 노드가 같은 캐시를 공유한다. prefix가 반복되는 워크로드(긴 시스템 프롬프트, 멀티턴, RAG)에서 재사용률이 올라가고, 두 번째 요청부터 prefill을 건너뛰어 TTFT가 크게 줄어든다. 설정은 YAML 파일 한 장과 --kv-transfer-config 플래그가 전부다.
GPU 메모리에서 KV 캐시가 밀려나는 지점
KV 캐시는 트랜스포머가 디코딩할 때 이전 토큰들의 key와 value 텐서를 저장해 두는 버퍼다. 매 토큰마다 앞 토큰을 다시 계산하지 않으려고 들고 있는 값이라, 시퀀스가 길수록 커진다. 이게 전부 GPU HBM에 올라가는데, 모델 가중치가 차지하고 남은 공간을 나눠 쓴다. 동시 요청이 늘면 이 공간이 먼저 바닥나고, vLLM은 자리를 만들려고 오래된 블록을 그냥 버린다(preemption).
vLLM에는 자동 prefix 캐싱이 이미 있다. 같은 접두사를 쓰는 요청끼리 GPU 안에서 블록을 재사용한다. 문제는 용량이다. GPU HBM은 수십 GB 수준이고, 버려진 블록은 다음에 같은 프롬프트가 들어와도 다시 prefill해야 한다. 멀티턴 대화에서 사용자가 잠깐 자리를 비웠다 돌아오면 그 세션의 캐시는 이미 증발한 뒤인 경우가 많다.
LMCache는 이 버려질 블록을 GPU 밖 더 싼 메모리 계층으로 내려보낸다. CPU RAM이 1차 티어, 로컬 NVMe가 2차 티어, 원격 저장소가 3차 티어다. GPU는 활성 요청만 들고 있고, 식은 캐시는 아래 계층에 쌓인다. 다시 같은 prefix가 들어오면 CPU나 NVMe에서 끌어올려 prefill을 건너뛴다. 디스크에서 읽어 올리는 비용이 전부 다시 계산하는 것보다 싸기 때문에 이득이 난다.
LMCache 붙이고 CPU 티어 켜기
LMCache는 vLLM v1 엔진과 커넥터로 연결된다. 설치는 vLLM이 깔린 환경에 패키지 하나를 더 얹는다.
pip install lmcache python -c "import lmcache; print(lmcache.__version__)"
버전 숫자는 저장소 상태에 따라 다를 수 있다. LMCache는 vLLM, PyTorch, CUDA 버전에 묶여 있으니 vLLM을 먼저 깔아 둔 가상환경에 설치하는 게 깔끔하다.
설정은 환경변수로도 되지만 YAML 파일이 관리하기 낫다. CPU 티어만 켜는 최소 설정은 이렇다.
# lmcache_cpu.yaml chunk_size: 256 local_cpu: true max_local_cpu_size: 20
chunk_size는 캐시를 쪼개 저장하는 토큰 단위로 기본 256이다. local_cpu: true가 CPU RAM 티어를 켜고, max_local_cpu_size는 티어당 확보할 호스트 메모리를 GB로 지정한다. 기본값이 5.0GB로 작으니 서버 RAM 여유를 보고 20이든 40이든 올려 잡는다. 여기 잡은 만큼 핀된(pinned) 호스트 메모리를 먹으므로 실제 RAM보다 작게 둔다.
실행은 설정 파일을 환경변수로 넘기고, 커넥터는 --kv-transfer-config로 지정한다.
LMCACHE_CONFIG_FILE=lmcache_cpu.yaml \
vllm serve meta-llama/Meta-Llama-3.1-8B-Instruct \
--gpu-memory-utilization 0.8 \
--kv-transfer-config '{"kv_connector":"LMCacheConnectorV1","kv_role":"kv_both"}'
kv_role을 kv_both로 두면 한 인스턴스가 저장과 로드를 다 한다. 기동 로그에 LMCacheConnectorV1이 초기화됐다는 줄이 보이면 붙은 것이다. 여기서 한 번 걸리는 게 있는데, vLLM v0 엔진으로 뜨면 커넥터가 먹히지 않는다. 최근 vLLM은 v1이 기본이지만, VLLM_USE_V1=0 같은 걸 어딘가 걸어 뒀다면 풀어야 한다.
NVMe 디스크를 2차 티어로 붙이기
CPU RAM도 수십 GB면 금방 찬다. 더 많은 세션의 캐시를 오래 들고 있으려면 NVMe를 아래에 깐다. LMCache는 로컬 디스크를 2차 티어로 받고, CPU가 넘치면 디스크로 흘려보낸다.
# lmcache_disk.yaml chunk_size: 256 local_cpu: true max_local_cpu_size: 20 local_disk: "file:///mnt/nvme/lmcache" max_local_disk_size: 200
local_disk는 file:///절대경로 형식으로 준다. 쉼표로 여러 경로를 넣으면 멀티 디바이스 I/O로 나눠 쓸 수 있고, local_disk_path_sharding: by_gpu를 주면 GPU 디바이스 ID로 경로를 고른다. max_local_disk_size는 디스크 캐시 상한을 GB로 잡는다(기본 0이라 반드시 지정해야 디스크 티어가 산다).
디렉터리를 미리 만들어 두고, 쓰기 권한과 여유 용량을 확인한 뒤 올린다.
mkdir -p /mnt/nvme/lmcache df -h /mnt/nvme
느린 SATA SSD나 네트워크 스토리지에 이걸 올리면 디스크에서 끌어오는 시간이 prefill보다 길어져 역효과가 난다. 2차 티어는 로컬 NVMe에 두는 게 맞다. 경로를 네트워크 마운트로 잡지 않도록 주의한다.
여러 노드가 같은 캐시를 나눠 쓰기
인스턴스를 여러 개 띄우면 각자 로컬 캐시를 따로 쌓는다. 같은 prefix가 다른 노드로 라우팅되면 캐시가 있어도 못 쓴다. 노드 간 공유는 원격 백엔드를 공통으로 바라보게 해서 푼다. 가장 간단한 건 Redis다.
# lmcache_shared.yaml chunk_size: 256 local_cpu: true remote_url: "redis://10.0.0.10:6379" remote_serde: "cachegen"
remote_url이 공유 저장소를 가리키고, remote_serde는 원격으로 보낼 때 캐시를 직렬화하는 방식이다. cachegen은 KV를 압축해 전송량을 줄여 준다. 두 인스턴스를 같은 Redis로 묶는다.
# 공유 Redis
docker run --rm -p 6379:6379 redis:7
# 노드 A (GPU 0)
PYTHONHASHSEED=0 LMCACHE_CONFIG_FILE=lmcache_shared.yaml CUDA_VISIBLE_DEVICES=0 \
vllm serve meta-llama/Meta-Llama-3.1-8B-Instruct \
--gpu-memory-utilization 0.8 --port 8000 \
--kv-transfer-config '{"kv_connector":"LMCacheConnectorV1","kv_role":"kv_both"}'
# 노드 B (GPU 1)
PYTHONHASHSEED=0 LMCACHE_CONFIG_FILE=lmcache_shared.yaml CUDA_VISIBLE_DEVICES=1 \
vllm serve meta-llama/Meta-Llama-3.1-8B-Instruct \
--gpu-memory-utilization 0.8 --port 8001 \
--kv-transfer-config '{"kv_connector":"LMCacheConnectorV1","kv_role":"kv_both"}'
PYTHONHASHSEED=0을 모든 인스턴스에 똑같이 거는 게 핵심이다. LMCache는 토큰 해시로 캐시 키를 만드는데, 이 시드가 인스턴스마다 다르면 같은 프롬프트라도 다른 키가 나와 공유가 깨진다. 한쪽만 빼먹으면 조용히 캐시 미스가 나니 꼭 통일한다.
노드가 물리적으로 떨어져 있고 네트워크가 빠르면 Redis 대신 P2P(NIXL) 전송이나 Mooncake 같은 전용 저장소를 쓴다. 다만 Redis 공유만으로도 멀티 레플리카 환경에서 prefix 재사용률은 눈에 띄게 올라간다.
처리량과 TTFT를 직접 재보기
TTFT(Time To First Token)는 요청을 보낸 뒤 첫 토큰이 나올 때까지 걸리는 시간이다. prefill을 건너뛰면 가장 먼저 줄어드는 지표가 이것이다. 오프로딩 효과는 vLLM에 딸린 vllm bench serve로 캐시가 빈 상태와 채워진 상태를 비교하면 드러난다.
vllm bench serve \ --model meta-llama/Meta-Llama-3.1-8B-Instruct \ --port 8000 \ --dataset-name random \ --random-input-len 7500 \ --random-output-len 200 \ --num-prompts 200 \ --request-rate 3.6
같은 명령을 두 번 돌린다. 첫 실행은 캐시를 채우는 단계라 평소대로 느리고, 두 번째 실행에서 입력 prefix가 재사용되며 TTFT가 떨어진다. 출력에 Mean TTFT, P99 TTFT, Output token throughput이 나온다. 재사용률이 높은 워크로드에서 두 번째 실행 TTFT는 환경에 따라 다르지만 첫 실행 대비 큰 폭으로 줄어든다. 반대로 prefix가 매번 완전히 다른 트래픽이라면 오프로딩 이득은 거의 없고, 디스크 I/O만 늘어 손해일 수 있다.
정확히 재려면 입력 길이를 실제 서비스의 시스템 프롬프트 길이에 맞추고, 캐시 적중을 흉내 내는 데이터셋(공통 접두사가 있는 프롬프트)을 쓰는 게 낫다. GPU 메모리 압박이 풀렸는지는 서빙 로그의 preemption 발생 횟수와 nvidia-smi의 메모리 사용률로 같이 본다. preemption 로그가 줄고 같은 --request-rate에서 P99 TTFT가 안정되면 오프로딩이 일하고 있는 것이다.
다음 단계로는 로컬 NVMe 2차 티어를 붙인 설정과 CPU만 쓴 설정을 같은 벤치로 비교해, 디스크 끌어올림 지연이 prefill 절감분을 까먹지 않는 임계 지점을 자기 환경에서 찾아 두면 운영 중 티어 용량을 잡을 때 쓸 수 있다.