RSS
AI 자동화

LLM 추론 GPU 메모리 계산: KV 캐시와 메모리 대역폭

작성자
듀오랩스 대표·20분 읽기

NVIDIA의 H100 제품 페이지와 H200 제품 페이지 사양표를 나란히 놓으면 이상한 점이 하나 보입니다. SXM 모델 기준으로 FP16 Tensor Core 성능이 둘 다 1,979 TFLOPS(희소성 기준 표기)이고, FP8도 3,958 TFLOPS로 같습니다. 같은 Hopper 아키텍처라 연산 칸은 한 자리도 다르지 않습니다. 그런데 H200 페이지는 Llama2 70B 추론이 H100보다 1.9배 빠르다고 적어 둡니다.

다른 칸은 두 개뿐입니다. 메모리 용량이 80GB에서 141GB로, 메모리 대역폭이 3.35TB/s에서 4.8TB/s로 늘었습니다. 연산이 그대로인데 추론이 빨라졌다면 추론 속도를 정한 것은 연산이 아니었다는 뜻입니다.

LLM을 직접 올려 보려는 사람이 흔히 가진 그림은 이렇습니다. 「모델 파라미터 크기만큼 GPU 메모리가 있으면 돌아가고, TFLOPS가 높은 GPU가 빠르다.」 이 그림은 두 군데서 틀립니다. 메모리 쪽에서는 가중치 말고도 대화 길이와 사용자 수에 따라 불어나는 KV 캐시가 있고, 속도 쪽에서는 답을 한 토큰씩 쓰는 단계가 연산이 아니라 메모리를 읽는 속도에 묶여 있습니다. 이 글은 두 가지를 실제 모델 설정값과 NVIDIA 공개 사양으로 계산해 봅니다.

이미 읽은 토큰을 다시 계산하지 않게 해 주는 KV 캐시

트랜스포머의 어텐션은 새 토큰 하나를 만들 때 앞에 나온 모든 토큰을 돌아봅니다. 각 토큰은 레이어마다 세 벡터로 바뀝니다. 지금 무엇을 찾는지 나타내는 쿼리(Q), 각 토큰이 어떤 내용인지 알리는 키(K), 실제로 건네줄 내용인 값(V)입니다. 새 토큰의 쿼리가 앞 토큰들의 키와 얼마나 맞는지 점수를 매기고, 그 점수로 값들을 섞어 다음 계산으로 넘깁니다.

여기서 앞 토큰들의 K와 V는 한 번 계산하면 바뀌지 않습니다. 1,000번째 토큰을 만들 때 쓴 1번 토큰의 K, V는 1,001번째 토큰을 만들 때도 똑같습니다. 그래서 추론 엔진은 이 값을 레이어마다, 토큰마다 GPU 메모리에 저장해 둡니다. 이것이 KV 캐시입니다. 캐시가 없으면 토큰 하나를 쓸 때마다 앞 문장 전체를 처음부터 다시 통과시켜야 하므로, KV 캐시는 선택 사항이 아니라 사실상 모든 추론 엔진의 기본 동작입니다.

대가는 메모리입니다. 가중치는 모델을 올릴 때 크기가 정해지지만, KV 캐시는 토큰이 하나 늘 때마다 같이 늘어납니다. 크기는 이렇게 계산합니다.

KV 캐시 바이트 = 2 (K와 V) × 레이어 수 × KV 헤드 수 × 헤드 차원 × 토큰 수 × 원소당 바이트

Qwen3-8B 기준 토큰당 147,456바이트라는 계산

추상적인 공식보다 실제 모델 하나로 계산하는 편이 감이 빨리 옵니다. Meta의 Llama 3.1은 설정 파일을 보려면 접근 승인이 필요해서, 누구나 열어 볼 수 있는 Qwen3-8B의 config.json을 씁니다. 필요한 값은 네 개입니다.

항목 config.json 키 값
레이어 수 num_hidden_layers 36
쿼리 헤드 수 num_attention_heads 32
KV 헤드 수 num_key_value_heads 8
헤드 차원 head_dim 128

저장 형식(torch_dtype)은 bfloat16이라 원소 하나가 2바이트입니다. 공식에 넣으면 토큰 하나당 2 × 36 × 8 × 128 × 2 = 147,456바이트, 약 0.15MB입니다. 작아 보이지만 이것은 토큰 하나, 사용자 한 명의 값입니다.

비교할 기준은 가중치입니다. 같은 저장소의 model.safetensors.index.json에 적힌 total_size는 16,381,470,720바이트, 약 16.4GB입니다. 모델 카드가 밝힌 파라미터 수 8.2B에 2바이트를 곱한 값과 맞아떨어집니다. 흔히 말하는 「8B 모델은 16GB」가 여기서 나옵니다.

128K 문맥 한 건이 가중치보다 커지는 상황

이제 토큰 수를 늘려 봅니다. Qwen3-8B 모델 카드는 기본 문맥 길이를 32,768토큰, YaRN 확장을 켜면 131,072토큰까지로 적고 있습니다.

문맥 길이 한 사용자의 KV 캐시 (BF16) 같은 값을 FP8로
8,192토큰 약 1.21GB 약 0.60GB
32,768토큰 약 4.83GB 약 2.42GB
131,072토큰 약 19.3GB 약 9.66GB

131,072토큰을 꽉 채운 대화 한 건의 KV 캐시가 약 19.3GB로, 가중치 16.4GB보다 큽니다. 사용자 한 명이 긴 문서를 붙여 넣는 것만으로 모델 자체보다 많은 메모리를 씁니다.

동시 사용자를 곱하면 차이는 더 벌어집니다. 8K 문맥 사용자 16명이면 약 19.3GB, 32K 문맥 사용자 8명이면 약 38.7GB, 128K 문맥 사용자 4명이면 약 77.3GB입니다. 마지막 경우는 가중치를 올리기도 전에 80GB GPU 한 장을 거의 채웁니다. 「8B 모델이니 16GB짜리 GPU면 된다」는 계산은 사용자가 한 명이고 대화가 짧을 때만 맞습니다.

표의 오른쪽 열은 KV 캐시를 FP8로 저장했을 때의 값입니다. 원소가 2바이트에서 1바이트로 줄어드니 정확히 절반입니다. vLLM은 이 기능을 kv_cache_dtype="fp8" 옵션으로 제공하고, 공식 문서는 메모리를 줄여 더 많은 토큰을 담고 처리량과 문맥 길이를 늘리는 용도라고 설명합니다. 같은 문서는 스케일을 따로 보정하지 않으면 모든 값을 1.0으로 두고, 정확도를 위해 데이터셋으로 보정하는 방식을 권장한다고 적습니다. 정확도가 얼마나 떨어지는지는 모델과 작업마다 달라서, 저라면 쓰기 전에 실제 작업 샘플로 답을 비교해 보겠습니다.

쿼리 헤드 32개가 KV 헤드 8개를 나눠 쓰는 GQA

앞 계산에서 KV 헤드 수가 8이었습니다. 쿼리 헤드는 32개인데 KV 헤드는 그 4분의 1입니다. 이것이 GQA(grouped-query attention)입니다. 쿼리 헤드 4개가 한 묶음이 되어 K, V 한 벌을 같이 씁니다. 원래 방식(멀티헤드 어텐션)은 쿼리 헤드마다 자기 K, V를 가지므로, Qwen3-8B가 그렇게 설계됐다면 토큰당 KV 캐시는 589,824바이트로 지금의 4배였습니다. 131,072토큰 한 건이 약 77GB가 됩니다.

GQA는 Ainslie 등의 2023년 논문이 제안한 방식으로, 모든 쿼리 헤드가 K, V 한 벌만 쓰는 극단(멀티쿼리 어텐션)과 원래 방식 사이의 중간입니다. K, V 벌 수를 줄이면 메모리와 읽을 양이 줄고, 한 벌로 몰아 버리면 품질이 떨어지니 몇 묶음으로 나누는 절충입니다. 같은 회사의 Qwen2.5-7B-Instruct도 쿼리 헤드 28개에 KV 헤드 4개로, 일곱 개가 한 벌을 나눠 씁니다. 모델을 고를 때 파라미터 수만 보지 말고 num_key_value_heads를 같이 봐야 하는 이유가 여기 있습니다. 파라미터 수가 비슷해도 KV 헤드 수가 두 배면 같은 메모리에 담을 수 있는 대화가 절반이 됩니다.

토큰 하나마다 가중치 전체를 읽는 디코딩 구조

이제 속도 쪽입니다. LLM은 답을 한 번에 쓰지 않습니다. 토큰 하나를 만들고, 그 토큰을 입력에 붙여 다음 토큰을 만드는 일을 반복합니다. 이것을 자기회귀(autoregressive) 디코딩이라고 부릅니다.

문제는 토큰 하나를 만들 때마다 모델의 모든 레이어를 한 번씩 통과해야 한다는 점입니다. 통과하려면 그 레이어의 가중치를 GPU 메모리(HBM)에서 연산 장치로 읽어 와야 합니다. 사용자가 한 명이면 Qwen3-8B는 토큰 하나를 쓸 때마다 16.4GB를 전부 읽습니다. 반면 그 토큰을 만드는 데 드는 연산은 파라미터 하나당 곱셈과 덧셈 한 번씩, 대략 2 × 8.2B = 16.4 GFLOP입니다. 읽는 바이트와 하는 연산의 비율이 1 대 1 근처입니다.

H100 SXM 사양으로 GPU 쪽 비율을 내면 1,979 TFLOPS ÷ 3.35TB/s, 바이트 하나를 읽는 동안 약 590번 연산할 수 있습니다. 표기된 TFLOPS가 희소성 기준이라 실제로 낼 수 있는 값을 절반으로 낮춰 잡아도 300번에 가깝습니다. 디코딩은 바이트 하나당 한 번 정도만 연산하니, 연산 장치는 대부분의 시간을 데이터가 도착하기를 기다리며 보냅니다. 이 상태를 메모리 대역폭에 묶였다(memory-bandwidth-bound)고 합니다. TFLOPS가 두 배인 GPU로 바꿔도 대역폭이 같으면 이 단계는 거의 빨라지지 않습니다.

첫 토큰 시간과 초당 토큰 수가 따로 움직이는 원인

요청 하나를 처리하는 과정은 두 단계로 나뉩니다. 먼저 프롬프트 전체를 읽는 프리필(prefill)이 있고, 그다음 답을 한 토큰씩 쓰는 디코드(decode)가 있습니다.

프리필에서는 프롬프트의 토큰이 전부 이미 주어져 있습니다. 2,000토큰짜리 프롬프트라면 가중치를 한 번 읽어 와서 2,000개 토큰에 한꺼번에 적용합니다. 같은 바이트로 2,000배 많은 연산을 하니 이 단계는 연산에 묶입니다. 여기서는 TFLOPS가 실제로 속도를 정합니다. 이때 만든 K, V가 KV 캐시의 첫 내용이 됩니다.

디코드는 앞 절에서 본 그대로 토큰 하나에 가중치 전체를 읽는 단계입니다. 그래서 두 지표가 따로 움직입니다. 사용자가 질문을 보내고 첫 글자가 나올 때까지의 시간(TTFT, time to first token)은 주로 프리필이 정하고, 프롬프트가 길수록 늘어납니다. 그 뒤 글자가 흘러나오는 속도(초당 토큰 수)는 주로 디코드가 정하고, 메모리 대역폭을 따라갑니다. 긴 문서를 넣었더니 첫 응답은 늦는데 그 뒤 타이핑 속도는 비슷하다면 이 구조 그대로입니다. 반대로 GPU를 바꿨더니 첫 응답만 빨라지고 글자 속도는 그대로라면, 연산만 늘고 대역폭은 그대로인 GPU로 바꾼 것입니다.

대역폭 ÷ 읽는 바이트로 잡는 속도 상한

디코드가 대역폭에 묶여 있다면 속도의 위쪽 한계가 나눗셈 하나로 나옵니다.

초당 토큰 수 상한 ≈ 메모리 대역폭 ÷ 토큰 하나마다 읽는 바이트

이 식은 배치 크기 1, 즉 사용자 한 명일 때의 천장입니다. 실제 엔진은 커널 실행 지연, 레이어 사이의 동기화, 어텐션 계산 같은 비용 때문에 이 값에 도달하지 못합니다. 그래도 GPU나 모델을 비교할 때 어느 쪽이 얼마나 유리한지 순서를 정하는 데는 충분히 쓸 만합니다.

Qwen3-8B BF16 가중치 16.4GB를 넣으면 H100 SXM(3.35TB/s)에서 약 204토큰/초, H200(4.8TB/s)에서 약 293토큰/초가 상한입니다. 비율은 약 1.43배로, NVIDIA가 H200 페이지에서 말하는 대역폭 1.4배와 같습니다.

그렇다면 첫머리의 1.9배는 어디서 왔을까요? H200 페이지의 각주에 답이 있습니다. Llama2 70B 비교 조건이 H100 SXM은 배치 크기 8, H200 SXM은 배치 크기 32입니다. 대역폭만으로는 1.4배이고, 나머지는 141GB 메모리 덕분에 한 번에 네 배 많은 요청을 묶을 수 있었던 데서 나온 것으로 읽힙니다. 용량과 대역폭이 같이 속도를 정한다는 이 글의 두 주제가 NVIDIA의 수치 하나에 겹쳐 있습니다.

여기서 빠뜨리기 쉬운 것이 KV 캐시도 읽는 바이트에 들어간다는 점입니다. 새 토큰의 쿼리는 앞 토큰 전부의 K, V와 맞춰 봐야 하므로, 대화가 길수록 토큰마다 읽는 양이 늘어납니다. H100에서 Qwen3-8B 한 명 기준으로 다시 계산하면 문맥 8K에서 약 190, 32K에서 약 158, 128K에서 약 94토큰/초가 상한입니다. 같은 모델이 긴 대화의 끝 무렵에 느려지는 것은 이 때문입니다.

같은 식에서 두 가지 기법이 왜 빠른지도 나옵니다. 양자화는 읽을 바이트를 줄입니다. 8.2B 파라미터를 4비트로 저장하면 가중치는 대략 4.1GB가 되어 H100 상한이 약 817토큰/초로 올라갑니다(실제 4비트 형식은 스케일 값 등이 붙어 조금 더 큽니다). MoE(전문가 혼합) 모델은 토큰마다 일부 전문가의 가중치만 읽습니다. Qwen3-30B-A3B 모델 카드는 전체 30.5B 중 토큰마다 3.3B만 활성화된다고 적고, config.json에는 전문가 128개 중 8개를 고른다(num_experts_per_tok: 8)고 되어 있습니다. BF16으로 활성 파라미터만 읽으면 약 6.6GB라 H100 상한이 약 508토큰/초인데, 같은 크기의 밀집 모델(61GB)이었다면 약 55토큰/초입니다. 다만 MoE는 읽는 양만 줄 뿐, 61GB 전체는 여전히 메모리에 올라가 있어야 합니다. 속도는 활성 파라미터를, 메모리는 전체 파라미터를 따릅니다.

배치가 올리는 처리량과 KV 캐시가 긋는 한계

서비스에서는 사용자가 한 명이 아닙니다. 여러 요청을 묶어 한 번에 디코드하면, 가중치는 한 번만 읽고 그 바이트로 여러 사용자의 토큰을 동시에 만듭니다. 가중치 읽기 비용을 여러 사람이 나눠 내는 셈이라 GPU 전체의 처리량이 크게 오릅니다.

H100에서 Qwen3-8B, 사용자마다 8K 문맥을 다 채웠다고 가정하고 같은 상한 식으로 계산하면 이렇습니다. 사용자의 KV 캐시도 읽는 바이트에 넣었습니다.

동시 사용자 필요한 메모리 (가중치 + KV) 전체 처리량 상한 사용자 한 명이 보는 속도 상한
1명 약 17.6GB 약 190토큰/초 약 190토큰/초
8명 약 26.0GB 약 1,029토큰/초 약 129토큰/초
32명 약 55.0GB 약 1,948토큰/초 약 61토큰/초

두 가지가 보입니다. 첫째, 처리량은 사용자 수만큼 늘지 않습니다. 사용자가 늘수록 KV 캐시 읽기가 가중치 읽기보다 커지기 때문입니다. 둘째, 더 중요한 것은 메모리 열입니다. 사용자 한 명이 늘 때마다 KV 캐시 1.21GB가 추가되고, 80GB GPU에서는 어느 순간 사용자를 더 받을 자리가 없어집니다. 배치를 키우면 처리량이 오르는데, 배치를 키울 수 있는 크기를 정하는 것은 KV 캐시가 차지하는 메모리입니다. 제가 보기에 LLM 서빙에서 메모리 용량이 중요한 진짜 이유는 「모델이 올라가느냐」보다 「몇 명을 동시에 받느냐」에 있습니다.

그래서 서빙 엔진의 핵심 과제는 KV 캐시를 빈틈없이 담는 일이 됩니다. 요청마다 최대 길이만큼 연속된 메모리를 미리 잡아 두면 실제로 쓰지 않는 공간이 생깁니다. vLLM 발표 글은 기존 시스템이 단편화와 과잉 예약으로 메모리의 60~80%를 낭비한다고 지적하고, 운영체제의 페이지처럼 KV 캐시를 작은 블록으로 나눠 담는 PagedAttention으로 낭비를 4% 미만으로 줄였다고 밝힙니다. 이 구조는 주제가 커서 따로 다룰 만합니다.

MIG 프로파일 크기를 고르기 전의 메모리 견적

지금까지의 내용을 배포 계산으로 옮기면 식은 이렇습니다.

필요한 GPU 메모리 ≈ 가중치 + (토큰당 KV 바이트 × 문맥 길이 × 동시 사용자) + 엔진 오버헤드

가중치와 KV 캐시는 config.json과 모델 카드만으로 계산됩니다. 확실하지 않은 것은 마지막 항입니다. CUDA 컨텍스트, 프리필 때의 중간 활성값, CUDA 그래프 같은 엔진 내부 버퍼는 엔진과 버전, 설정마다 다르고, 저는 이 값을 한 숫자로 말할 근거가 없습니다. vLLM은 이 문제를 반대 방향으로 풉니다. 설정 코드의 gpu_memory_utilization(main 브랜치 기준 기본값 0.92)만큼 GPU 메모리를 쓰겠다고 정하고, 가중치와 내부 버퍼를 뺀 나머지를 전부 KV 캐시로 잡습니다. 그러니 실제 몇 명을 받을 수 있는지는 엔진을 띄운 뒤 로그에 찍히는 KV 캐시 크기로 확인하는 것이 가장 정확합니다.

이 견적이 실제로 쓰이는 곳이 GPU를 MIG로 나눠 쓸 때입니다. NVIDIA MIG 사용자 가이드에 따르면 H100 80GB는 1g.10gb, 1g.20gb, 2g.20gb, 3g.40gb, 4g.40gb, 7g.80gb 프로파일로 나눌 수 있습니다. 프로파일 이름 뒤의 GB가 그 조각이 쓸 수 있는 메모리이고, MIG와 NVLink의 차이에서도 프로파일은 GB부터 보라고 적었습니다. Qwen3-8B BF16에 vLLM 기본값 0.92를 적용하고 오버헤드는 일단 빼서 어림하면 다음과 같습니다.

프로파일 가중치 16.4GB를 뺀 여유 8K 문맥 동시 사용자 (BF16 KV) FP8 KV로 바꾸면
1g.10gb 올라가지 않음 0 0
2g.20gb 약 2.0GB 1명 3명
3g.40gb 약 20.4GB 16명 33명
7g.80gb 약 57.2GB 47명 94명

오버헤드를 뺐으니 실제 숫자는 이보다 작습니다. 2g.20gb는 표에서 1명이지만 오버헤드가 조금만 붙어도 8K 문맥 하나를 못 담을 수 있는 아슬아슬한 크기입니다. 이 모델을 BF16으로 쓸 거라면 저는 3g.40gb부터 보겠습니다. 1g.10gb에 넣고 싶다면 가중치를 4비트로 양자화해 약 4.1GB로 줄이는 것이 먼저입니다.

메모리 말고 하나 더 봐야 할 것은 대역폭입니다. MIG 가이드의 소개 문서는 각 인스턴스에 메모리 컨트롤러와 DRAM 버스가 따로 배정되어, 다른 인스턴스가 메모리를 혹사해도 같은 DRAM 대역폭을 보장받는다고 설명합니다. 거꾸로 말하면 작은 조각은 GPU 전체 대역폭의 일부만 받습니다. 가이드의 표는 메모리 비율(1g.10gb는 1/8)을 적고 있지만 조각별 대역폭 수치는 적어 두지 않아서, 저는 메모리 비율과 비슷하게 나뉜다고 어림만 하고 정확한 값은 단정하지 않겠습니다. 디코드 속도가 대역폭을 따른다는 앞의 결론을 적용하면, 작은 MIG 조각에서는 메모리에 모델이 들어가더라도 사용자 한 명이 보는 글자 속도가 GPU 한 장을 통째로 쓸 때보다 느려집니다. 프로파일을 고를 때 GB는 「몇 명을 받을 수 있나」를, 조각의 크기는 「얼마나 빨리 쓰나」를 정한다고 보면 됩니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.