Transformers KV 캐시 선택법, Dynamic·Static·Offloaded를 추론 조건에 맞추는 법

로컬 AI 추론용 GPU와 서버 장비를 연결해 KV 캐시 메모리를 조정하는 작업을 표현한 현실적인 사진풍 이미지 1

AI를 활용하여 생성한 이미지입니다.

LLM 추론이 느려지거나 GPU 메모리가 갑자기 부족해질 때 모델 가중치만 의심하기 쉬워요. 실제로는 입력 문맥과 생성 토큰을 저장하는 KV 캐시가 메모리를 크게 차지할 수 있습니다. Transformers는 기본적으로 Dynamic Cache를 사용하지만, 같은 길이의 요청을 반복 처리하거나 긴 문맥을 다룰 때는 Static, Offloaded, Quantized 전략이 더 맞을 수 있어요.[1]

캐시를 바꾸면 무조건 빨라지는 것은 아닙니다. Static Cache는 컴파일과 반복 추론에 유리한 대신 최대 길이만큼 메모리를 미리 잡고, Offloaded Cache는 GPU 메모리를 아끼는 대신 CPU와 장치 사이 이동 비용이 생겨요. 이 글에서는 네 가지 캐시를 어떤 조건에서 선택하고, 실패했을 때 어느 설정부터 되돌릴지 정리했어요.

KV 캐시는 무엇을 저장하나

자기회귀 모델은 다음 토큰을 만들 때 이전 토큰의 어텐션 계산을 다시 하지 않도록 각 레이어의 key와 value를 저장합니다. 이 저장 공간이 KV 캐시예요. 캐시는 레이어별로 관리되고, 일반적인 구조는 [batch_size, num_heads, seq_len, head_dim] 형태입니다. 문맥이 길어지거나 동시에 처리하는 요청이 많아질수록 seq_len과 배치 크기가 커져 메모리도 늘어납니다.[2]

그래서 모델 파일 크기만 보고 실행 가능 여부를 판단하면 안 돼요. 같은 모델이라도 짧은 단일 요청은 괜찮고, 긴 대화나 여러 동시 요청에서 OOM이 날 수 있습니다. 캐시는 학습용 옵션이 아니라 추론용 최적화이며, Transformers 문서도 학습 중 캐시 사용은 예상하지 못한 오류를 만들 수 있다고 경고합니다.[2]

기본값인 Dynamic Cache

Dynamic Cache는 생성이 진행될수록 캐시 크기를 늘립니다.[1] 최대 길이를 미리 정하지 않아 요청마다 낭비되는 공간이 적고, 일반적인 길이의 요청이 섞이는 서버에서 다루기 편해요.[1] 특별한 이유가 없다면 먼저 기본값으로 동작을 확인하는 게 좋습니다.[1]

out = model.generate(
    **inputs,
    do_sample=False,
    max_new_tokens=256,
    cache_implementation="dynamic"
)

다만 캐시 크기가 계속 변하므로 torch.compile 같은 정적 그래프 최적화와는 잘 맞지 않습니다. 요청 길이가 크게 들쭉날쭉하지 않고, 컴파일 비용을 회수할 만큼 반복 호출한다면 Static Cache로 넘어갈 이유가 생겨요.

Static Cache는 반복 추론에 맞다

Static Cache는 KV 저장 공간을 고정 크기로 미리 할당합니다.[1] Transformers 문서는 고정 크기 캐시가 torch.compile과 같은 JIT 최적화에 사용할 수 있다고 설명합니다.[1] 같은 길이의 배치나 정해진 최대 토큰 수를 반복 처리하는 서비스라면 디코딩 지연을 줄이는 방향으로 실험해 볼 수 있어요.[1]

out = model.generate(
    **inputs,
    do_sample=False,
    max_new_tokens=128,
    cache_implementation="static"
)

대신 최대 길이를 크게 잡으면 실제로 쓰지 않는 토큰 자리까지 계산·할당하게 됩니다. 8,000토큰까지 필요할 때가 한 번 있다는 이유로 모든 요청을 8,000토큰 고정으로 만들면 짧은 요청에서 손해가 커질 수 있어요. 짧은 요청과 긴 요청을 별도 프로파일로 나누거나, Dynamic Cache를 유지하는 편이 낫습니다.

GPU 메모리가 부족하면 Offloaded

Offloaded Cache는 대부분의 레이어 캐시를 CPU로 옮기고 현재 계산에 필요한 레이어만 GPU에 유지합니다.[1] 긴 문맥에서 GPU OOM을 피하는 데 도움이 되지만, 캐시를 CPU와 GPU 사이로 이동해야 하므로 처리량이 떨어질 수 있어요.[1] 문서도 이 방식이 전체 캐시를 장치에 유지하는 경우보다 느려질 수 있다고 명시합니다.[1]

out = model.generate(
    **inputs,
    do_sample=False,
    max_new_tokens=256,
    cache_implementation="offloaded"
)

이 선택은 평균 지연시간이 아니라 성공률이 먼저인 상황에 어울립니다. 예를 들어 긴 문서를 한 번씩 요약하고, 기본 캐시에서 OOM이 나지만 CPU 메모리는 충분한 경우예요. 반대로 짧은 질의를 많이 처리하는 API에서는 Offloaded를 기본값으로 두기 전에 전송 비용을 측정해야 합니다.

Static의 컴파일 이점을 유지하면서 오프로딩하려면 offloaded_static을 선택할 수 있습니다. 다만 고정 최대 길이와 CPU 이동 비용이 동시에 들어가므로, 두 설정을 한 번에 도입하지 말고 Static 단독, Offloaded 단독, Offloaded Static 순서로 비교하세요.

Quantized Cache는 마지막 메모리 절약 카드

Quantized Cache는 KV 값을 낮은 비트 정밀도로 저장해 메모리를 줄입니다. 현재 문서에는 HQQ의 int2·int4·int8, Quanto의 int2·int4가 안내되어 있어요.[1] 메모리에는 유리하지만, 짧은 문맥에서 GPU 메모리가 충분하면 양자화와 복원 비용 때문에 지연시간이 늘 수 있습니다.

out = model.generate(
    **inputs,
    do_sample=False,
    max_new_tokens=256,
    cache_implementation="quantized",
    cache_config={"backend": "quanto", "nbits": 4}
)

양자화 캐시는 모델 가중치 양자화와 다른 기능이에요. 4비트 모델을 쓴다고 KV 캐시까지 자동으로 4비트가 되는 것은 아닙니다. 둘을 따로 측정해야 원인이 보입니다. 짧은 프롬프트에서 출력 품질과 지연이 흔들린다면 먼저 기본 Dynamic Cache로 되돌리고, 문맥 길이와 동시 요청 수를 줄여 기준값을 확보하세요.

선택 기준을 표로 정리하면

조건 우선 선택 감수할 점
요청 길이가 제각각이고 기준값이 없음 Dynamic 컴파일 최적화 활용이 제한됨
비슷한 길이의 요청을 반복하고 컴파일을 활용 Static 짧은 요청에서 예약 공간 낭비
긴 문맥에서 GPU OOM이 발생 Offloaded CPU 이동으로 처리량 저하 가능
캐시 메모리가 병목이고 지연 증가를 허용 Quantized 짧은 문맥에서는 오히려 느릴 수 있음

프로파일링은 토큰 조건을 고정해야 한다

캐시 비교에서 모델, dtype, 입력 문장, max_new_tokens, 배치 크기를 바꾸면 결과를 해석할 수 없어요. 최소한 다음 네 가지를 따로 기록하세요.

  1. 입력 문맥 길이와 생성 토큰 수
  2. 첫 토큰까지 걸린 시간과 생성 중 토큰 처리 속도
  3. 피크 GPU 메모리와 CPU 메모리
  4. 동일 요청을 반복했을 때 두 번째 실행 이후의 시간

한 번의 빠른 실행보다 여러 번의 중앙값이 유용합니다. Static Cache는 첫 컴파일 비용이 있으므로 워밍업 실행을 별도로 두고, 사용자 요청에 포함되는 지연과 분리해서 기록하세요. 공식 문서에 특정 모델의 보편적인 속도 수치는 없으므로, 자신의 모델·하드웨어·문맥 길이로 판단해야 합니다.[3]

실패했을 때 되돌리는 순서

  • 먼저 cache_implementation을 제거해 기본 Dynamic 동작을 확인하세요.
  • 같은 입력에서 use_cache=False를 잠깐 시험해 캐시 자체와 모델·입력 문제를 분리하세요.
  • OOM이면 max_new_tokens, 입력 문맥, 배치 크기를 줄인 뒤 Offloaded를 시험하세요.
  • 컴파일 오류가 나면 Static을 빼고 Dynamic으로 돌아간 뒤 고정 길이와 모델 지원 여부를 다시 확인하세요.
  • 양자화 캐시가 느리면 짧은 문맥·충분한 GPU 메모리 조건에서 Quantized를 기본값으로 쓰지 마세요.

자주 묻는 것

Static Cache가 항상 더 빠른가요?

아니에요. 반복 길이가 비슷하고 컴파일 비용을 회수할 때 유리합니다. 길이가 크게 달라지면 예약 공간과 불필요한 계산이 생겨 Dynamic이 더 나을 수 있어요.[1]

Offloaded를 쓰면 CPU만 있으면 되나요?

아니요. 모델 추론은 계속 가속기에서 진행하고, KV 캐시 일부를 CPU로 옮기는 방식이에요. CPU 메모리가 충분해야 하고, 전송 비용도 측정해야 합니다.

캐시를 끄면 메모리가 줄어드나요?

KV 캐시 메모리는 줄지만, 이전 토큰을 매번 다시 계산하므로 생성 속도가 크게 달라질 수 있어요. 디버깅 기준값으로만 잠깐 사용하고, 서비스 기본값으로 채택하기 전에는 처리량을 비교하세요.

Sources:

함께 읽을 글