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

LLM 서빙

KV 인식 라우터와 분리 서빙 비용 계산기 만들기

LabHub 에서 이어서 보기

목표

서버가 여러 대일 때 요청을 앞부분이 가장 많이 겹치는 워커로 보내는 라우터를 직접 만들고, 세 전략(라운드 로빈, 무작위, KV 인식)의 적중률을 비교한다. 워커가 KV 이벤트를 알리지 않으면 라우터가 어떻게 되는지 재현하고, 분리 서빙이 KV 를 옮기는 값을 얼마나 치르는지 계산한 뒤, 실제 측정 파일에서 배율을 직접 구한다. GPU 는 쓰지 않고 파이썬만 쓴다.

왜 중요한가

여러 워커 앞에서 요청을 어떻게 나누느냐가 캐시 적중을 정한다. 2026-10-07 의 측정에서 같은 접두사를 다시 보낸 60번 가운데 KV 인식 라우팅은 거의 모두(60번, 한 번은 53번)를, 라운드 로빈은 35–43번을 적중시켰다. 같은 --router-mode kv 라도 워커가 이벤트를 내보내지 않으면 적중이 39, 44, 41번으로 무작위 수준이었고 어떤 오류도 나지 않았다. 분리 서빙은 한 GPU 에서 첫 토큰이 4.7–7.2배 느려졌다. 이 실습의 시뮬레이터는 방향을 같게 재현하지만 숫자는 다르다(시뮬레이터는 워커 캐시 용량의 한계를 그리지 않는다, 읽기 자료의 설명). 7단계의 표는 측정 파일에서 직접 계산한 값이다.

단계

  1. /root/dynlab/router.py 에 block_hashes(tokens, block=16) 를 만든다. 토큰 목록을 block 개씩 끊어 온전한 블록마다 이름 하나를 돌려주는 리스트이다(자투리는 버린다). 이름은 앞에서부터 그 블록까지 전체를 가리켜야 한다.
  2. router.py 에 overlap(hashes, cached) 를 더한다. 앞에서부터 이어서 cached(집합)에 있는 블록의 수이다. 첫 빈 자리에서 멈춘다.
  3. router.py 에 KvRouter(n_workers, block=16) 를 더한다. route(tokens) 는 겹침이 가장 큰 워커를, 비기면 요청을 적게 받은 워커를, 그래도 비기면 번호가 작은 워커를 고르고, 고른 뒤 그 요청의 블록을 그 워커의 것으로 기억한다.
  4. /root/dynlab/sim.py 로 rr, random, kv 세 전략을 같은 부하로 돌려 적중을 센다. 기본 설정의 출력 셋을 /root/dynlab/modes.json 에 저장한다.
  5. KvRouter 에 use_events 와 on_event(worker, hashes) 를 더하고, sim.py 에 --mode kv-events --publish yes|no 를 더한다. 두 경우의 출력을 /root/dynlab/events.json 에 저장한다.
  6. /root/dynlab/disagg.py 에 KV 크기와 전송 시간, 분리 서빙의 첫 토큰 시간, 처리량 역산, 손익 분기 처리량을 계산하는 함수 다섯을 만들고, 1,500 토큰을 0.112, 0.608, 13.4 GB/s 로 옮기는 비용을 /root/dynlab/kvcost.json 에 저장한다.
  7. 실측 재료를 cp /opt/fixtures/dynamo/disagg.jsonl /root/dynlab/disagg.jsonl 로 복사하고(내용은 고치지 않는다), /root/dynlab/summarize.py 로 칸마다 가운데 값과 배율을 계산해 /root/dynlab/measured.json 에 저장한다.

참고

프롬프트를 블록으로 나누고 이름 붙이기

/root/dynlab/router.py 에 block_hashes(tokens, block=16) 를 만드세요. 토큰 목록을 block 개씩 끊어 온전한 블록마다 이름 하나를 돌려주는 리스트입니다(마지막의 모자란 자투리는 버립니다). 이름은 해시 가능한 값(문자열 등)이면 되고, 어떤 해시 함수를 쓸지는 정하세요. 단 이름은 그 블록의 내용만이 아니라 앞에서부터 그 블록까지 전체를 가리켜야 합니다. 두 요청의 i번째 블록 이름이 같다는 것은 앞의 i개 블록이 모두 같다는 뜻이어야 합니다.

블록 안의 토큰만 해시하면, 앞부분이 다른데 우연히 같은 내용인 블록이 같은 이름을 얻습니다. 그러면 라우터가 그 블록을 다른 요청의 것으로 착각합니다. 앞 블록의 이름을 다음 블록을 해시할 때 함께 섞으세요(해시 사슬). 같은 입력을 두 번 불렀을 때 같은 값이 나와야 하므로 파이썬 내장 hash() 는 실행마다 달라질 수 있어 맞지 않습니다. hashlib 을 쓰세요.

캐시와 얼마나 겹치는가

router.py 에 overlap(hashes, cached) 를 더하세요. hashes 는 요청의 블록 이름 리스트이고 cached 는 어느 워커가 가지고 있는 블록 이름의 집합입니다. 앞에서부터 이어서 cached 에 있는 블록의 수를 돌려줍니다. 첫 번째로 없는 블록에서 멈춥니다. 입력을 바꾸지 마세요.

KV 캐시는 앞부분이 같아야 쓸 수 있습니다. 첫 블록이 없는데 셋째 블록만 있는 워커는 그 셋째 블록을 쓸 수 없습니다. 전부 세어 버리면 라우터가 쓸 수 없는 겹침을 점수로 칩니다.

KV 인식 라우터 만들기

router.py 에 KvRouter(n_workers, block=16) 를 더하세요. route(tokens) 는 워커 번호(0부터)를 돌려줍니다. 규칙은 셋입니다. (1) 앞에서부터 겹치는 블록이 가장 많은 워커를 고릅니다. (2) 겹침이 같으면 지금까지 처리하도록 보낸 요청 수가 적은 워커. (3) 그래도 같으면 번호가 작은 워커. 고른 뒤에는 그 요청의 모든 블록을 고른 워커가 가지고 있다고 기억합니다(워커가 알려 주는 것이 아니라 라우터가 자기가 보낸 곳을 기억하는 방식입니다).

워커마다 블록 이름의 집합 하나와 처리 수 하나면 됩니다. min(워커, key=(-겹침, 처리 수, 번호)) 한 줄로 세 규칙이 모두 표현됩니다. 처리 수를 정하는 시점은 고른 직후입니다. 블록보다 짧은 요청은 이름이 하나도 없으니 겹침이 모두 0 이고, 그때는 한가한 워커로 가야 합니다.

세 전략의 적중률 비교

/root/dynlab/sim.py 를 만드세요. 인자는 --mode(rr, random, kv) --workers(기본 2) --prefixes(12) --reps(6) --plen(96) --tail(16) --seed(1) 입니다. 접두사 번호 k 의 요청은 [k*1000 + j for j in range(plen)] 뒤에 요청마다 다른 꼬리 [10**6 + i*100 + j for j in range(tail)](i 는 요청 순번)를 붙인 토큰입니다. 요청 순서는 rng = random.Random(seed) 로 [k for k in range(prefixes) for _ in range(reps)] 를 rng.shuffle 한 것입니다. rr 는 i 번째 요청을 i % workers, random 은 rng.randrange(workers)(섞은 뒤, 요청 순서대로 한 번씩), kv 는 3단계의 KvRouter 입니다. 적중은 접두사를 이전에 본 적이 있는 요청(재요청)이 그 접두사를 이미 처리한 적 있는 워커로 간 경우입니다. 표준 출력에 JSON 하나: {"mode", "workers", "requests", "repeat_requests", "hits", "hit_rate"} (hit_rate 는 hits/repeat_requests 의 소수 넷째 자리). 기본 설정으로 세 모드를 돌려 {"rr": 출력, "random": 출력, "kv": 출력} 을 /root/dynlab/modes.json 에 저장하세요.

워커마다 '지금까지 처리한 접두사' 집합을 따로 두고(라우터의 색인과 별개입니다), 재요청이 들어올 때 선택된 워커의 집합에 그 접두사가 있는지만 보면 됩니다. 처음 보는 접두사는 어디로 가도 적중할 수 없으므로 분모에서 뺍니다. 난수를 쓰는 순서가 정해져 있으니(섞기, 그다음 요청마다 한 번) 순서를 바꾸면 기준과 다른 값이 나옵니다. 세 모드의 숫자를 읽어 보세요: kv 가 모든 재요청을 적중시키는 것과, rr 과 random 이 생각보다 높은 이유(워커가 둘이면 두 번째 방문부터 이미 양쪽에 있을 수 있습니다).

워커가 알려 주지 않으면

KvRouter 에 use_events=False 인자와 on_event(worker, hashes) 를 더하세요. use_events=True 이면 route 가 고른 워커의 블록을 스스로 기억하지 않고, 워커가 on_event 로 알려 준 블록만 색인에 더합니다. use_events=False 의 동작은 3단계 그대로입니다. sim.py 에는 --mode kv-events 와 --publish yes|no 를 더하세요. kv-events 는 KvRouter(workers, use_events=True) 를 쓰고, --publish yes 이면 요청마다 선택된 워커가 그 요청의 블록 이름 전부(block_hashes(토큰))를 on_event 로 알립니다. no 이면 아무도 알리지 않습니다. 출력에는 "publish" 키가 더해집니다. 두 경우의 출력을 {"yes": 출력, "no": 출력} 으로 /root/dynlab/events.json 에 저장하세요.

이벤트 모드에서 색인은 on_event 로만 자랍니다. 알리지 않으면 색인이 영원히 비어 모든 겹침이 0 이 되고, 그러면 (2)(3) 규칙만 남아 요청 수가 가장 적은 워커로 번갈아 가게 됩니다. 그 결과가 어느 전략과 같은지 3단계의 modes.json 과 견줘 보세요. 실제 Dynamo 에서도 라우터는 KV 이벤트가 오는 만큼만 압니다. 이 실습의 뒤 읽기 자료의 실측 표가 그 모습을 보여 줍니다.

분리 서빙은 KV 를 옮기는 값을 치른다

/root/dynlab/disagg.py 를 만드세요. 함수는 다섯입니다. (1) kv_bytes_per_token(layers=28, kv_heads=8, head_dim=128, dtype_bytes=2): 토큰 하나의 KV 크기(바이트), 층마다 키와 값 둘입니다. (2) transfer_ms(tokens, bytes_per_token, gbps): gbps(10⁹ 바이트/초)로 옮기는 시간(ms). (3) ttft_disagg(prefill_ms, tokens, bytes_per_token, gbps, overhead_ms=0.0): 프리필 + 전송 + 기타. (4) effective_gbps(nbytes, ms): 옮긴 바이트와 걸린 시간에서 구한 처리량. (5) breakeven_gbps(tokens, bytes_per_token, saved_ms): 분리해서 요청마다 saved_ms 를 아낀다면 전송이 그 안에 끝나기 위한 최소 처리량. 명령줄 python3 disagg.py --tokens N --gbps G 는 {"bytes_per_token", "kv_mib", "transfer_ms"} JSON 한 줄을 찍습니다(kv_mib 는 N 토큰의 크기를 MiB 로, 둘 다 소수 첫째 자리). 1,500 토큰을 0.112, 0.608, 13.4 GB/s 로 옮길 때의 출력을 {"0.112": 출력, "0.608": 출력, "13.4": 출력} 으로 /root/dynlab/kvcost.json 에 저장하세요.

기본값 28, 8, 128, 2 는 Qwen3-0.6B 의 config.json(층 28, KV 헤드 8, 헤드 크기 128, bfloat16)입니다. 단위를 조심하세요: GB/s 는 10⁹ 바이트, MiB 는 2²⁰ 바이트이고, 시간은 ms 입니다. 세 대역폭은 측정에서 왔습니다(0.112 는 서버 사이 TCP 처리량, 0.608 은 이 모듈의 읽기 자료에 나오는 분리 서빙의 KV 전송 처리량 608 MB/s, 13.4 는 PCIe Gen4 x8 의 호스트에서 GPU 로 가는 전송). 결과를 읽을 때는 같은 1,500 토큰이 대역폭에 따라 몇 ms 가 되는지, 그것이 프리필 자체(수십 ms)와 견줘 어떤지 보세요.

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

실측 재료를 cp /opt/fixtures/dynamo/disagg.jsonl /root/dynlab/disagg.jsonl 로 복사하세요(내용은 고치지 않습니다). 그리고 /root/dynlab/summarize.py <입력.jsonl> <출력.json> 을 만드세요. 입력은 줄마다 JSON 객체(mode: agg 또는 disagg, input: 입력 토큰 수, conc: 동시 요청 수, rep: 반복 번호, ttft_p50_ms)이고 빈 줄은 건너뜁니다. 출력은 {"입력x동시성": {"agg_ms", "disagg_ms", "ratio"}} 입니다(키 예: 1500x1). agg_ms 와 disagg_ms 는 그 칸의 반복들의 ttft_p50_ms 의 가운데 값(개수가 짝수면 가운데 두 값의 평균), 소수 첫째 자리이고, ratio 는 disagg_ms ÷ agg_ms 로 소수 둘째 자리입니다. 복사한 파일로 돌려 /root/dynlab/measured.json 에 저장하세요.

칸마다 모드별로 값을 모아 statistics.median 을 씁니다. 평균이 아니라 가운데 값을 쓰는 이유는 반복 하나가 튀어도 흔들리지 않기 때문입니다(채점기는 한 반복만 아홉 배인 입력으로도 시험합니다). 결과를 읽을 때는 ratio 가 1 보다 큰지를 보고, 입력이 길어지거나 동시 요청이 늘 때 ratio 가 어떻게 움직이는지 보세요. 이 측정은 GPU 한 장에 프로세스 둘이라는 조건입니다.