LRU 캐시와 두 단계 캐시 시뮬레이터로 KV 캐시 오프로드 확인하기
목표
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 자리가 모자랄 때)는 시뮬레이터의 예측이고 측정한 것이 아니다.
단계
/root/kvlab/kvsim.py에LRUCache(capacity)를 만든다.access(key, size)는 적중이면 True 를 돌려주고 그 항목을 가장 최근으로 옮긴다. 놓치면 False 를 돌려주고, 자리가 날 때까지 가장 오래된 것부터 내보낸 뒤 저장한다. 자리보다 큰 항목은 저장하지 않고 다른 항목도 내보내지 않는다.key in cache는 순서를 바꾸지 않고,cache.used는 저장된 크기의 합(속성),cache.keys()는 오래된 것부터의 키 목록이다./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에 저장한다.--passes fwd,fwd,rev로 돌린 출력을/root/kvlab/baseline.json에 저장한다. 세 번째 패스의hit_docs에 어떤 문서가 있는지, 왜 그 문서들인지 읽어 본다.- GPU 자리를 21520, 30000, 40000, 45000, 50109, 50110 으로 바꿔 가며
--passes fwd,fwd로 돌리고, 각 자리에서 두 번째 패스의 적중 수(gpu_hits 와 cpu_hits 의 합)를/root/kvlab/cliff.json에{"자리": 적중 수}로 저장한다. 키는 문자열이다. /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 양쪽에 저장한다.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에 저장한다.- GPU 자리를 21520 으로 두고 CPU 자리를 0, 20000, 30000, 40000, 50109, 50110, 56000 으로 바꿔 가며
--passes fwd,fwd로 돌린다. 각 CPU 자리에서 두 번째 패스의 미적중 수(misses)를/root/kvlab/cpu_cliff.json에{"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에 저장한다.
참고
- 작업물(
/root/kvlab)은 세션이 끝나면 사라지니, 필요하면 미리 복사해 두세요. - 시뮬레이터는 문서를 통째로 넣고 뺀다. 실측의 vLLM 만 쓴 거꾸로 읽기에서 5번 문서는 438 ms 로 부분 적중처럼 보였는데, 이 시뮬레이터로는 그릴 수 없다.
- 흔한 실수 1: 적중했을 때 항목을 최근으로 옮기지 않는 것. LRU 가 아니라 들어온 순서대로 내보내는 캐시가 된다.
- 흔한 실수 2: 자리보다 큰 항목을 만났을 때 먼저 비워 버리는 것. 넣지도 못하면서 멀쩡한 항목을 잃는다.
- 흔한 실수 3: 패스마다 캐시를 새로 만드는 것. 패스 사이에 캐시가 이어져야 두 번째 패스가 의미를 갖는다.
- 채점기는 여러분의
kvsim.py·scenario.py·summarize.py를 처음 보는 입력으로 직접 실행합니다. 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 보다 작은 값은 무엇을 뜻합니까. 채점기는 문서 개수와 순서가 다른 입력으로도 이 스크립트를 돌립니다.