LabHub
시작하기
배우기 러닝패스 코스

LLM 서빙

LRU 캐시와 두 단계 캐시 시뮬레이터로 KV 캐시 오프로드 확인하기

LabHub 에서 이어서 보기

목표

GPU 의 KV 캐시 자리보다 많은 문서를 번갈아 읽힐 때 LRU 캐시가 어떻게 무너지는지 직접 만든 시뮬레이터로 확인하고, CPU 단계를 더하면 어떻게 달라지는지 본다. 마지막에는 실제 측정이 남긴 요청별 파일에서 배율을 직접 계산한다. GPU 는 쓰지 않고 파이썬만 쓴다.

왜 중요한가

긴 문서를 앞에 붙이고 질문만 바꾸는 요청에서는 문서의 KV 캐시를 다시 쓰는 것이 가장 큰 절약이다. 그런데 GPU 에는 그 캐시를 둘 자리가 작아서, 문서가 자리보다 많아지면 오래된 것부터 밀려난다. 이 밀어내기가 순환 접근을 만나면 자리가 조금만 모자라도 적중이 0 이 된다는 것이 이 실습의 첫 번째 발견이다. 두 번째 발견은 밀려나는 것을 CPU 메모리에 내려 두면 다시 계산하는 대신 불러올 수 있다는 것이고, 2026-10-03 의 측정에서 그 차이는 첫 토큰까지 576 ms 대 48.6 ms 였다. 세 번째는 한계다. 시뮬레이터는 문서 단위이고, 측정은 동시성 1, 모델 1.5B, GPU 한 장, 합성 문서였다. 실습의 CPU 자리 56000 토큰은 측정의 CPU 상한 1.5GB 를 문서당 KV 크기(4,864 토큰에 0.1299GB)로 나눈 근사값이다. 7단계(CPU 자리가 모자랄 때)는 시뮬레이터의 예측이고 측정한 것이 아니다.

단계

  1. /root/kvlab/kvsim.py 에 LRUCache(capacity) 를 만든다. access(key, size) 는 적중이면 True 를 돌려주고 그 항목을 가장 최근으로 옮긴다. 놓치면 False 를 돌려주고, 자리가 날 때까지 가장 오래된 것부터 내보낸 뒤 저장한다. 자리보다 큰 항목은 저장하지 않고 다른 항목도 내보내지 않는다. key in cache 는 순서를 바꾸지 않고, cache.used 는 저장된 크기의 합(속성), cache.keys() 는 오래된 것부터의 키 목록이다.
  2. /root/kvlab/scenario.py 를 만든다. 인자는 --gpu(기본 21520) --docs(기본 10) --tokens(기본 5011) --passes(기본 fwd,fwd,rev)이다. 문서 번호 0부터 docs-1 까지가 키이고 크기는 모두 tokens 이다. fwd 패스는 0부터 차례로, rev 패스는 거꾸로 읽는다. 표준 출력에 JSON 하나만 찍는다: {"passes": [{"order": "fwd", "gpu_hits": 정수, "cpu_hits": 0, "misses": 정수, "hit_docs": [적중한 문서 번호를 읽은 순서대로]}, ...]} (패스마다 한 객체). 기본 시나리오를 --passes fwd,fwd 로 돌린 출력을 /root/kvlab/cyclic.json 에 저장한다.
  3. --passes fwd,fwd,rev 로 돌린 출력을 /root/kvlab/baseline.json 에 저장한다. 세 번째 패스의 hit_docs 에 어떤 문서가 있는지, 왜 그 문서들인지 읽어 본다.
  4. GPU 자리를 21520, 30000, 40000, 45000, 50109, 50110 으로 바꿔 가며 --passes fwd,fwd 로 돌리고, 각 자리에서 두 번째 패스의 적중 수(gpu_hits 와 cpu_hits 의 합)를 /root/kvlab/cliff.json 에 {"자리": 적중 수} 로 저장한다. 키는 문자열이다.
  5. /root/kvlab/kvsim.py 에 TwoTierCache(gpu_capacity, cpu_capacity) 를 더한다(LRUCache 는 그대로 둔다). 속성 gpu 와 cpu 는 각각 LRUCache 이고, access(key, size) 는 "gpu", "cpu", "miss" 중 하나를 돌려준다. (1) GPU 에서 적중하면 "gpu" 이고 CPU 는 건드리지 않는다. (2) GPU 에서 놓쳤는데 CPU 에 있으면 "cpu" 이고, CPU 사본을 최근으로 하고 GPU 에도 올린다. (3) 둘 다 놓치면 "miss" 이고 GPU 와 CPU 양쪽에 저장한다.
  6. scenario.py 에 --cpu(기본 0)를 더해 TwoTierCache 로 돌린다. 출력의 gpu_hits 는 GPU 적중, cpu_hits 는 CPU 에서 불러온 수, hit_docs 는 둘을 합친 문서 번호이다. --cpu 를 주지 않거나 0 이면 2단계의 결과와 같아야 한다. --gpu 21520 --cpu 56000 --passes fwd,fwd,rev 의 출력을 /root/kvlab/offload.json 에 저장한다.
  7. GPU 자리를 21520 으로 두고 CPU 자리를 0, 20000, 30000, 40000, 50109, 50110, 56000 으로 바꿔 가며 --passes fwd,fwd 로 돌린다. 각 CPU 자리에서 두 번째 패스의 미적중 수(misses)를 /root/kvlab/cpu_cliff.json 에 {"CPU 자리": 미적중 수} 로 저장한다. 키는 문자열이다.
  8. 실측 재료를 cp /opt/fixtures/kvoffload/ttft.jsonl /root/kvlab/ttft.jsonl 로 복사한다(내용은 고치지 않는다). 그리고 /root/kvlab/summarize.py <입력.jsonl> <출력.json> 을 만든다. 입력은 줄마다 JSON 객체(config, pass, doc, doc_tokens, ttft_ms, total_ms)이고 빈 줄은 건너뛴다. 출력은 {"baseline": {패스: {"n", "p50_ms", "mean_ms"}}, "lmcache": {패스: {...}}, "ratio": {패스: {"p50", "mean"}}} 이다. p50 은 가운데 값(개수가 짝수면 가운데 두 값의 평균), ms 는 소수 첫째 자리, ratio 는 baseline 을 lmcache 로 나눈 값으로 소수 둘째 자리이다. 패스 이름은 입력에 있는 그대로(pass1-cold, pass2-replay, pass3-reverse)이다. 복사한 파일로 돌려 /root/kvlab/measured.json 에 저장한다.

참고

LRU 캐시 만들기

/root/kvlab/kvsim.py 에 LRUCache(capacity) 를 만드세요. access(key, size) 는 적중이면 True 를 돌려주고 그 항목을 가장 최근으로 옮깁니다. 놓치면 False 를 돌려주고, 자리가 날 때까지 가장 오래된 것부터 내보낸 뒤 저장합니다. 자리보다 큰 항목은 저장하지 않고 다른 항목도 내보내지 않습니다. key in cache 는 순서를 바꾸지 않고, cache.used 는 저장된 크기의 합(속성), cache.keys() 는 오래된 것부터의 키 목록입니다.

파이썬의 OrderedDict 는 항목을 맨 뒤로 옮기는 move_to_end 와 맨 앞을 꺼내는 popitem(last=False) 가 있어 이 일에 맞습니다. 적중했을 때 순서를 바꾸는 것을 잊으면 LRU 가 아니라 들어온 순서대로 내보내는 FIFO 가 됩니다. 자리보다 큰 항목을 만났을 때 비우기부터 하면 멀쩡한 항목까지 잃습니다.

같은 순서로 두 번 읽기

/root/kvlab/scenario.py 를 만드세요. 인자는 --gpu(기본 21520) --docs(기본 10) --tokens(기본 5011) --passes(기본 fwd,fwd,rev)입니다. 문서 번호 0부터 docs-1 까지가 키이고 크기는 모두 tokens 입니다. fwd 패스는 0부터 차례로, rev 패스는 거꾸로 읽습니다. 표준 출력에 JSON 하나만 찍으세요: {"passes": [{"order": "fwd", "gpu_hits": 정수, "cpu_hits": 0, "misses": 정수, "hit_docs": [적중한 문서 번호를 읽은 순서대로]}, ...]} (패스마다 한 객체). 기본 시나리오를 --passes fwd,fwd 로 돌려 그 출력을 /root/kvlab/cyclic.json 에 저장하세요.

패스마다 같은 캐시 하나를 이어서 씁니다(패스가 바뀐다고 캐시를 비우지 않습니다). 적중한 문서는 hit_docs 에 읽은 순서대로 넣습니다. 출력에 설명 문장을 섞으면 채점기가 JSON 으로 읽지 못합니다. 저장은 python3 scenario.py --passes fwd,fwd > cyclic.json 처럼 하면 됩니다.

거꾸로 읽으면 무엇이 남아 있나

같은 시나리오를 처음, 같은 순서로 다시, 거꾸로 순으로 읽도록 --passes fwd,fwd,rev 로 돌려 그 출력을 /root/kvlab/baseline.json 에 저장하세요. 세 번째 패스의 hit_docs 에 어떤 문서가 있는지, 왜 그 문서들인지 읽어 보세요.

새 코드는 필요 없습니다. 2단계의 scenario.py 를 인자만 바꿔 돌립니다. 세 번째 패스가 시작될 때 캐시에 남아 있는 것이 무엇인지, 거꾸로 읽으면 그것을 어떤 순서로 만나는지 생각해 보세요.

자리를 어디까지 늘려야 적중이 생기는가

GPU 자리를 21520, 30000, 40000, 45000, 50109, 50110 으로 바꿔 가며 --passes fwd,fwd 로 돌리고, 각 자리에서 두 번째 패스의 적중 수(gpu_hits 와 cpu_hits 의 합)를 /root/kvlab/cliff.json 에 {"자리": 적중 수} 로 저장하세요. 키는 문자열입니다.

자리를 두 배 가까이 늘려도 적중이 생기지 않는 구간이 있습니다. 문서 열 개의 크기 합이 얼마인지 계산하고, 그보다 1 토큰 모자란 자리와 딱 맞는 자리를 직접 돌려 비교해 보세요. 여섯 번을 손으로 돌리지 말고 반복문으로 돌려 JSON 으로 만드세요.

GPU 와 CPU 두 단계 캐시

/root/kvlab/kvsim.py 에 TwoTierCache(gpu_capacity, cpu_capacity) 를 더하세요(LRUCache 는 그대로 둡니다). 속성 gpu 와 cpu 는 각각 LRUCache 이고, access(key, size) 는 "gpu", "cpu", "miss" 중 하나를 돌려줍니다. 규칙은 셋입니다. (1) GPU 에서 적중하면 "gpu" 이고 CPU 는 건드리지 않습니다. (2) GPU 에서 놓쳤는데 CPU 에 있으면 "cpu" 이고, CPU 사본을 최근으로 하고 GPU 에도 올립니다. (3) 둘 다 놓치면 "miss" 이고 GPU 와 CPU 양쪽에 저장합니다.

단계마다 LRUCache 하나씩이면 됩니다. 적중 여부를 알려면 access 를 부르기 전에 in 으로 먼저 물어야 합니다(access 는 놓치면 저장까지 해 버립니다). CPU 자리가 0 이어도 동작해야 하고, 그때는 한 단계 캐시와 같아집니다.

CPU 로 내리면 두 번째 패스가 어떻게 되는가

scenario.py 에 --cpu(기본 0)를 더해 TwoTierCache 로 돌리세요. 출력의 gpu_hits 는 GPU 적중, cpu_hits 는 CPU 에서 불러온 수, hit_docs 는 둘을 합친 문서 번호입니다. --cpu 를 주지 않거나 0 이면 2단계의 결과와 같아야 합니다. --gpu 21520 --cpu 56000 --passes fwd,fwd,rev 의 출력을 /root/kvlab/offload.json 에 저장하세요.

run 이 만드는 캐시를 LRUCache 에서 TwoTierCache 로 바꾸고, access 의 반환값 세 가지를 각각 세면 됩니다. 앞 단계의 시나리오가 깨지지 않았는지 --cpu 없이도 다시 돌려 보세요. 읽은 결과를 측정과 견줘 보세요: 거꾸로 읽는 패스에서 GPU 적중과 CPU 적중이 각각 몇 개인지, 측정에서 약 30 ms 와 약 48 ms 로 나뉜 문서 수와 맞습니까.

CPU 자리가 모자라면

GPU 자리를 21520 으로 두고 CPU 자리를 0, 20000, 30000, 40000, 50109, 50110, 56000 으로 바꿔 가며 --passes fwd,fwd 로 돌리세요. 각 CPU 자리에서 두 번째 패스의 미적중 수(misses)를 /root/kvlab/cpu_cliff.json 에 {"CPU 자리": 미적중 수} 로 저장합니다. 키는 문자열입니다.

4단계와 같은 반복문에서 바꾸는 인자와 읽는 값만 다릅니다. 결과를 보고 CPU 단계에 필요한 자리가 'GPU 에 못 들어간 몫' 인지 '문서 전체' 인지 판단해 보세요. 이 값은 시뮬레이터의 예측입니다. 이 측정에서는 CPU 상한이 문서 전부를 담은 경우만 쟀습니다.

실측 파일에서 배율 계산하기

실측 재료를 cp /opt/fixtures/kvoffload/ttft.jsonl /root/kvlab/ttft.jsonl 로 복사하세요(내용은 고치지 않습니다). 그리고 /root/kvlab/summarize.py <입력.jsonl> <출력.json> 을 만드세요. 입력은 줄마다 JSON 객체(config, pass, doc, doc_tokens, ttft_ms, total_ms)이고 빈 줄은 건너뜁니다. 출력은 {"baseline": {패스: {"n", "p50_ms", "mean_ms"}}, "lmcache": {패스: {...}}, "ratio": {패스: {"p50", "mean"}}} 입니다. p50 은 가운데 값(개수가 짝수면 가운데 두 값의 평균), ms 는 소수 첫째 자리, ratio 는 baseline 을 lmcache 로 나눈 값으로 소수 둘째 자리입니다. 패스 이름은 입력에 있는 그대로(pass1-cold, pass2-replay, pass3-reverse)입니다. 복사한 파일로 돌려 /root/kvlab/measured.json 에 저장하세요.

config 와 pass 의 쌍마다 ttft_ms 를 모아 statistics.median 과 statistics.mean 을 씁니다. 결과를 읽을 때는 ratio 가 1 보다 큰지 작은지를 보세요. 첫 패스에서 1 보다 작은 값은 무엇을 뜻합니까. 채점기는 문서 개수와 순서가 다른 입력으로도 이 스크립트를 돌립니다.