LLM이 긴 응답을 생성할 때, 초기 프롬프트가 빠르게 처리된 직후 왜 속도가 느려지는지 궁금해한 적이 있다면, 여러분은 실시간으로 하드웨어 병목 현상을 목격하고 있는 것입니다. 대부분의 개발자는 Python 코드, 프레임워크, 또는 모델의 거대한 크기를 원인으로 돌립니다. 함수를 프로파일링하고, 옵티마이저를 교체하며, 전처리 시간을 밀리초 단위로 줄여보기도 합니다. 하지만 그 어떤 것도 진짜 문제를 해결하지 못합니다. 속도 제한은 소프트웨어에 있는 것이 아닙니다. 실리콘에 있습니다.

모든 대규모 언어 모델(LLM) 추론 작업은 서버에 장착된 GPU의 두 가지 물리적 특성에 달려 있습니다. 바로 숫자를 얼마나 빨리 계산할 수 있는지, 그리고 그 숫자를 계산할 위치로 얼마나 빨리 옮길 수 있는지입니다.

연산은 저렴하지만, 데이터 이동은 그렇지 않다

GPU 마케팅에서는 연산 능력(compute)을 강조하곤 합니다. 초당 수조 번의 부동 소수점 연산. 그 수치는 경이롭습니다. 하지만 연산은 이야기의 절반일 뿐입니다. 나머지 절반은 메모리 대역폭(memory bandwidth)입니다. 즉, 고대역폭 메모리에서 실제 산술 연산이 일어나는 연산 코어로 데이터가 이동하는 속도를 의미합니다.

LLM은 이 두 연결 고리 중 더 느린 쪽보다 빠르게 작동할 수 없습니다. 20명의 숙련된 요리사가 있는 상업용 주방을 상상해 보세요. 오븐은 뜨겁고 칼은 날카로우며 모든 요리사가 준비되어 있습니다. 하지만 식재료 배달이 자전거로 한 바구니씩 들어옵니다. 주방은 멈춰버립니다. 요리사를 더 늘린다고 해결되지 않습니다. 더 빠른 오븐을 산다고 해결되지 않습니다. 병목 현상은 바로 '도로'에 있습니다.

현대의 데이터센터용 GPU에서 산술 연산 장치는 매우 강력하여, 계산을 마친 후 가중치(weights)와 활성화 값(activations)이 메모리를 통해 흘러 들어오기를 기다리며 유휴 상태로 사이클을 낭비하는 경우가 많습니다. 이러한 불균형은 코드의 버그가 아닙니다. 칩이 만들어지는 물리적 현실입니다. 메모리 대역폭은 순수 연산 능력의 발전 속도를 따라잡지 못했으며, LLM은 매 출력 토큰마다 모든 파라미터를 참조해야 하는 순전파(forward pass) 과정 때문에 이러한 불균형에 특히 취약합니다.

프롬프트는 빠르고 생성은 느리게 느껴지는 이유

LLM 추론은 두 가지 뚜렷한 단계로 나뉘며, 각 단계는 하드웨어에 완전히 다른 방식으로 부하를 줍니다.

**Prefill(프리필)**은 프롬프트가 모델에 처음 입력될 때 발생합니다. 모든 토큰이 한꺼번에 들어옵니다. GPU는 대규모 행렬-행렬 곱셈(matrix-matrix multiplications)을 사용하여 이들을 병렬로 처리할 수 있습니다. 수천 개의 산술 연산 장치가 동시에 작동하며 작업 부하가 밀도 있게 유지됩니다. 이 단계는 연산 중심(compute-bound)입니다. 처음에 보이는 갑작스러운 속도 향상은 GPU가 설계된 목적 그대로 작동하고 있다는 증거입니다.

Decode(디코드) 단계부터는 상황이 힘들어집니다. 모델이 다음 토큰을 생성할 때는 한 번에 하나의 토큰씩 생성합니다. 이 단계는 행렬-벡터 연산(matrix-vector operations)에 의존하는데, 이는 GPU 병렬 처리 능력의 아주 일부분만 사용합니다. 설상가상으로, 새로운 토큰이 생성될 때마다 GPU는 메모리에서 전체 모델 가중치를 다시 불러와야 합니다. 산술 연산 장치는 일을 하고 싶어 하지만, 대신 기다리기만 합니다. 디코드는 메모리 중심(memory-bound)입니다. GPU는 사실상 비싼 교통 관제사 역할을 하며, 연산 엔진이 식어가는 동안 메모리 버스를 통해 파라미터를 앞뒤로 실어 나릅니다. 초기 프롬프트 분석은 즉각적이었음에도 불구하고 100단어 정도의 응답에 10초가 걸릴 수 있는 이유가 바로 이것입니다.

KV 캐시(KV cache)는 이 상황을 더욱 흥미롭게 만듭니다. 디코드 과정에서 모델은 어텐션(attention)을 처음부터 다시 계산하지 않도록 이전 모든 토큰에 대한 키(key)와 값(value) 텐서를 저장합니다. 이 캐시는 시퀀스 길이에 따라 커지며, 역시 메모리에 상주합니다. 따라서 이제 GPU는 가중치를 다시 불러올 뿐만 아니라, 매 순전파 단계마다 계속 확장되는 캐시를 읽고 써야 합니다. 연산 코어는 거의 힘을 쓰지 않는 반면, 메모리 버스는 두 작업을 모두 처리하느라 땀을 흘리고 있습니다.

메모리 벽(Memory Wall)에 맞서기

엔지니어들은 이동해야 하는 데이터의 양을 줄이거나, 적어도 데이터 이동 비용을 분담하기 위해 몇 가지 기술을 개발해 왔습니다.

**배칭(Batching)**은 가장 간단한 방법입니다. 한 사용자의 요청이 메모리로부터 전체 가중치를 불러와야 한다면, 8개 또는 16개의 요청을 동시에 처리함으로써 GPU가 그 부하를 모든 요청에 분산(amortize)시킬 수 있습니다. 가중치는 한 번만 읽어서 배치 내의 모든 시퀀스에 재사용합니다. 실제 운영 환경에서는 정교한 스케줄링 시스템이 요청을 동적으로 그룹화하는데, 이를 때로는 컨티뉴어스 배칭(continuous batching) 또는 인플라이트 배칭(in-flight batching)이라고 부르며, 이를 통해 GPU가 멈추는 일을 최소화합니다. 이는 같은 경로를 달리는 16대의 개별 자동차와 버스 한 대의 차이와 같습니다.

**양자화(Quantization)**는 대역폭 문제를 직접적으로 해결합니다. 모델 가중치는 보통 16비트 부동 소수점 형식으로 저장됩니다. 이를 8비트 또는 심지어 4비트 정수로 압축함으로써, 버스를 통해 이동하는 데이터의 양을 말 그대로 절반 이상 줄일 수 있습니다. 모델은 일관된 출력을 생성할 수 있을 만큼 충분한 정밀도를 유지해야 하지만, 최신 사후 학습 양자화(post-training quantization) 방식은 품질을 저하시키지 않으면서도 모델의 메모리 점유율을 극적으로 줄일 수 있습니다. 전송되는 데이터가 적을수록 메모리 컨트롤러에서 대기하는 시간도 줄어듭니다.

FlashAttention은 중간 결과물을 GPU의 빠른 온칩(on-chip) 메모리 내에 유지하도록 어텐션 메커니즘을 재구성합니다. 기존의 어텐션 방식은 거대한 어텐션 행렬을 느린 외부 메모리에 썼다가 다시 읽어와야 했습니다. FlashAttention은 계산을 SRAM에 들어갈 수 있는 작은 타일(tile) 단위로 나누고, 온칩에서 소프트맥스(softmax)와 스케일링(scaling) 단계를 수행한 뒤, 최종 결과물만 고대역폭 메모리에 기록합니다. 이는 약간의 추가 연산을 대가로 메인 메모리로의 왕복 횟수를 훨씬 줄이는 방식이며, 이는 거의 언제나 이득이 되는 선택입니다.

PagedAttention은 또 다른 종류의 메모리 낭비 문제를 해결합니다. 디코딩 과정에서 KV 캐시는 예측 불가능하게 증가합니다. 기존 시스템은 각 시퀀스에 대해 고정된 연속적인 메모리 블록을 할당하므로, 일부 시퀀스가 일찍 끝나고 다른 시퀀스가 확장됨에 따라 큰 빈 공간(hole)이 생기게 됩니다. PagedAttention은 운영체제의 가상 메모리 개념을 차용합니다. KV 캐시 항목을 고정된 크기의 블록에 저장하며, 이 블록들은 비연속적으로 할당될 수 있고 간접 참조 테이블(indirection table)을 통해 매핑됩니다. 이를 통해 예약되었지만 절반만 채워진 버퍼 내에서 메모리가 유휴 상태로 방치되는 것을 방지하고 더 큰 배치 사이즈를 허용할 수 있습니다. 결과적으로 단편화 오버헤드 대신 유용한 작업으로 메모리 버스를 계속 활용하게 함으로써 전체 처리량(throughput)을 향상시킵니다.

질문을 바꿔라

레이턴시(latency)가 급증할 때, 너무 많은 팀이 더 작은 모델로 교체해야 할지 아니면 추론 서버를 다시 작성해야 할지를 묻습니다. 이러한 질문들도 중요하지만 부차적인 문제입니다. 가장 먼저 던져야 할 질문은 하드웨어 자체에 관한 것이어야 합니다. "GPU가 실제로 연산에 바쁜가, 아니면 데이터가 부족해 굶주리고 있는가?"

활용도 지표를 살펴보십시오. GPU 연산 점유율(compute occupancy)과 함께 메모리 대역폭 포화도를 프로파일링하십시오. 디코딩 중에 높은 메모리 경합(contention)과 낮은 연산 강도(arithmetic intensity)가 관찰된다면, 그것은 모델 아키텍처의 문제가 아닙니다. 물리적인 문제입니다. 해결책은 더 깔끔한 Python 코드가 아니라, 더 공격적인 배칭(batching), 파이프를 더 빠르게 통과할 수 있도록 가중치를 양자화하는 것, 온칩에 머물 수 있도록 어텐션을 재구성하는 것, 그리고 공간 부족 없이 더 큰 배치를 수용할 수 있도록 KV 캐시를 관리하는 것에서 나옵니다.

추론을 이러한 관점에서 바라보기 시작하면 최적화는 기계적인 과정이 됩니다. 모델의 지능이 속도를 늦춘다는 근거 없는 믿음을 쫓는 대신, 하드웨어가 실제로 제공할 수 있는 성능에 기반하여 엔지니어링 결정을 내리기 시작합니다. 이것이 바로 확장 가능한 프로덕션 시스템과 단순히 작동만 하는 시스템을 가르는 차이점입니다.