Tech

HyperCLOVA 서빙 최적화 포인트

Part 3: 최적화 포인트

Introduction

HyperCLOVA 기반 서비스는 최적화 기술을 통해 원활하게 운영될 수 있습니다. 이번 파트에서는 최적화 기술에 대한 두 가지 이야기를 소개해 드리고자 합니다. 첫 번째는 멀티 배치(multi-batch) 기능으로, 사용자의 여러 요청을 묶어 한 번에 효율적으로 처리하는 기술입니다. 두 번째는 멀티 턴(multi-turn) 대화 상황에서 응답을 더 빠르게 제공하는 기술입니다. 목차는 다음과 같습니다.

  • 멀티 배치
  • 멀티 턴

멀티 배치

멀티 배치의 필요성

AI 모델을 이용하는 서비스 혹은 회사가 대규모 트래픽을 다루기 위해 처리량을 높여야 한다면 멀티 배치 기능을 고려할 수 있습니다.

일반적으로 추론 과정은 학습 과정과는 달리, 처리량(throughput)을 높이는 것보다 지연 시간(latency)을 줄이는 데 더 중점을 둡니다. 이에 따라 한꺼번에 처리할 수 있는 크기인 배치 크기(batch size)를 최대로 늘리기보다는 한 번에 하나의 요청(single batch)만 처리하는 방식으로 지연 시간을 줄이기도 합니다.

하지만, AI 모델이 대규모 트래픽을 처리해야 한다면 배치 크기를 최소로 줄인다고 해도 각 요청이 처리되는 전체 지연 시간은 줄이지 못할 가능성이 있습니다. 처리해야 하는 요청량이 늘어나는 경우, AI 모델이 먼저 들어온 요청들을 처리하고 있는 동안 뒤에 들어온 요청은 대기 큐에서 기다리며 많은 시간을 소비할 수 있기 때문입니다. 즉, 연산하는 데 걸리는 지연 시간을 줄여도 연산 과정으로 들어가기까지 대기하는 시간이 증가하면서 결과적으로 사용자는 속도 저하를 경험할 수 있습니다.

또한, 서비스의 GPU 환경을 고려하여 접근해야 합니다. GPU는 수많은 코어와 높은 메모리 대역폭을 이용해 병렬 처리를 진행하는데요. 이런 특성을 지닌 GPU를 최대한 활용하기 위해서는 GPU가 처리할 수 있는 크기의 데이터를 한꺼번에 제공하는 것이 좋습니다. 이런 경우 한 번에 연산하는 양이 증가해도 연산에 소요되는 지연 시간이 크게 늘지 않습니다.

이런 양상은 HyperCLOVA나 GPT-3 기반 모델에서 더욱 두드러지게 나타납니다. 앞서 두 편의 블로그에서 소개해 드린 것처럼, GPT 연산은 입력 문장의 데이터를 한꺼번에 처리하는 summarization 단계와 순차적으로 단어들을 생성해 내는 generation 단계로 나뉩니다. summarization 단계에서는 여러 토큰 ID에 대한 데이터가 한꺼번에 계산되기 때문에 GPU의 수많은 코어를 보다 효율적으로 사용할 수 있습니다. 하지만 문장을 생성하는 단계인 generation 단계에서는 토큰을 한 개씩 생성해 나가기 때문에 GPU의 여러 코어를 제대로 활용하지 못할 수 있습니다. 코어와 메모리 활용도(utilization)가 떨어질 수 있는 것입니다.

이때, 오히려 배치 크기를 증가시켜서 generation 단계에서 처리해야 하는 데이터 양을 늘리면, 데이터 양이 늘어나더라도 GPU의 수많은 코어들이 이를 동시에 잘 처리해 내기 때문에 연산에 따른 지연 시간이 크게 증가하지 않습니다. 따라서 멀티 배치 기능을 이용해 여러 요청을 한꺼번에 처리하면 generation 단계에서 순간 처리량을 높이는 이득을 볼 수 있습니다.

아래 그림을 보시면 특정 입력에 대한 문장 생성 과정에서 배치 크기를 1에서 8까지 늘렸지만 연산에 따른 지연 시간은 거의 증가하지 않은 것을 확인할 수 있습니다. 이와 같이 지연 시간이 증가하지 않는다면, 처리해야 하는 요청이 너무 많을 경우에는 오히려 배치 크기를 키워서 순간 처리량을 늘려 각 요청이 큐에서 대기해야 하는 시간을 줄이고, 이를 통해 전체 지연 시간을 줄이는 선택이 효과적일 수 있습니다.

img

멀티 배치 적용과 발생한 문제점

우리는 다이나믹 배칭이라는 기술을 이용해 배치 연산을 진행하고자 했습니다. 다이나믹 배칭은 최대 배치 크기(e.g. 4, 8)를 지정해 놓고 특정 시간(e.g. 1초) 동안 들어온 요청들을 하나의 배치로 묶어서 연산을 진행하는 것입니다. 이를 통해 비슷한 시간대에 들어온 여러 요청을 한 번에 처리해 처리량을 늘리고 큐에서 대기하는 시간을 줄여 전체 지연 시간을 줄이는 효과를 얻고자 했습니다.

하지만 멀티 배치 연산을 적용해 보니, 대체적으로는 지연 시간의 큰 변동 없이 처리량이 늘어났지만 특정 상황에서는 지연 시간이 배로 증가하는 현상이 발생했습니다. 자세히 살펴보니 하나의 배치로 묶은 요청들의 입력 문장의 길이가 크게 다를 경우 이와 같은 문제가 발생했습니다.

이는 트랜스포머의 구조적인 문제 때문이었는데요, 아래 예시 그림을 보겠습니다. 각 입력 문장에 대해서 배치 크기를 1(single-batch)로 설정해 문장을 생성할 경우, summarization 단계에서는 각 입력 문장을 한 번에 처리하고, generation 단계에서는 원하는 문장 길이만큼만 생성하면 됩니다. 그러나 두 개의 입력 문장을 하나의 배치로 묶으면 summarization 단계에서 처리할 수 있는 문장 길이는 두 문장 중 짧은 문장의 길이로 고정됩니다. 이는 summarization 단계에서 긴 문장의 길이로 처리하면 짧은 문장에 대해서는 정확한 처리가 불가능하기 때문입니다.

img

이와 같이 멀티 배치 처리를 위해 summarization 단계에서 처리하는 문장의 길이가 줄어들면, 나머지 부분들은 모두 generation 단계에서 한 단어씩 처리해야 하기 때문에 시간이 점점 지체됩니다. 최악의 경우, 배치로 묶으려는 문장 간의 길이 차이가 생성하고자 하는 문장 길이보다도 크다면, 멀티 배치 적용으로 인해 지연 시간이 극도로 증가하면서 타임아웃이 발생할 수도 있습니다.

첫 번째 접근 방법: 버킷팅 전략

길이가 다른 문장을 멀티 배치로 처리할 때 발생하는 문제를 해결하기 위해 첫 번째로 시도한 방법은 버킷팅 전략입니다. 버킷팅 전략은 쉽게 말해 여러 양동이(버킷)를 두고 문장의 길이가 비슷한 요청들끼리 길이에 따라 분류하는 방법입니다. 비슷한 시간에 들어온 요청이더라도 두 문장의 길이 차이가 미리 지정해 놓은 임곗값보다 크면 하나의 배치로 묶어서 처리하지 않습니다.

img

이 방법을 사용하면 멀티 배치를 적용하면서 지연 시간이 크게 증가하는 현상을 예방할 수 있습니다. 다만 이 방법을 사용한다고 해도 여전히 입력 문장의 길이가 서로 다른 문장이 배치로 묶이면서 generation 단계에서 처리해야 하는 문장의 길이가 기존보다 증가해 지연 시간이 늘어날 가능성이 있습니다. 또한, 배치로 묶을 때 각 입력 문장의 길이 차이를 최소화하려다 보면 실질적으로 서비스에서 멀티 배치 연산을 진행하지 못하는 경우가 자주 발생하게 됩니다.

두 번째 접근 방법: GPT의 attention mask 특성 이용

두 번째 방법은 Hugging Face 구현에서 영감을 얻은 방법으로, GPT의 attention mask 특성을 이용하는 방법입니다. 앞서 설명드린 버킷팅 전략은 실질적으로 연산을 최적화한다기보다는 서비스 패턴을 고려해 특정 상황에서 성능이 급격히 저하되는 문제를 막기 위한 전략이었습니다. 멀티 배치 연산이 비효율적으로 진행되는 문제의 핵심은, 배치로 묶는 요청들의 입력 문장 길이가 다를 때, 단일 배치에서는 summarization 단계에서 할 수 있었던 연산을 generation 단계의 연산으로 떠넘기면서 연산이 직렬화(serialize)되며 지연 시간이 증가하는 것입니다. 저희는 버킷팅 전략에서 한 발 더 나아가 연산을 좀 더 최적화해서 이 문제를 해결할 수 없을지 고민해 보았습니다.

그렇게 고민하다가 눈에 띈 것이 GPT의 masked self-attention입니다. GPT 모델의 특징은 트랜스포머에서 인코더를 제외하고 디코더만 사용한다는 것입니다. 인코더와 디코더의 주요 차이점은 인코더는 self-attention이고 디코더는 masked self-attention이라는 점입니다. self-attention인 인코더는 학습되는 토큰의 앞뒤 문맥을 모두 살피는 구조입니다. 이 때문에 문장의 의미를 추출하는 데 강점을 지닌 것으로 알려져 있습니다. 반면 masked self-attention인 디코더는 학습되는 토큰 기준으로 이전에 나왔던 문맥만 살펴볼 수 있습니다. 따라서 디코더 레이어로 구성된 GPT 모델은 문장 생성에 강점이 있습니다. 이때 디코더에서 이전에 나왔던 문맥만 살펴볼 수 있도록 사용하는 것이 attention mask로, 이후에 나오는 토큰은 연산에 포함되지 않게 만듭니다.

img

이는 바꿔 말해 attention mask를 이용하면 우리가 원하는 토큰만 계산에 포함시킬 수 있다는 것입니다. 앞서 멀티 배치 기능을 지원하면서 문제가 됐던 부분은 길이가 다른 입력이 들어왔을 때 summarization 단계에서 할 수 있었던 연산을 generation 단계로 넘기면서 지연 시간이 증가하는 것이었습니다. 하지만 attention mask와 패딩(padding)을 이용하면 이 문제를 해결할 수 있습니다.

방법은 다음과 같습니다. 우선 배치 형태로 함께 입력될 문장 중 길이가 짧은 문장에 임의의 값으로 패딩을 넣어 가장 긴 입력의 길이에 맞춥니다. 이렇게 길이를 똑같이 맞춘 입력을 HyperCLOVA로 전달해 문장을 생성하면, HyperCLOVA 모델에서 배치로 함께 처리하는 입력의 길이가 모두 같기 때문에 필요한 만큼만 문장을 생성합니다.

이때 짧은 입력으로 들어온 문장 뒤에는 패딩에 해당하는 토큰이 함께 포함되어 있어서 올바른 입력 문장 생성을 방해하는데요. 앞서 말씀드린 attention mask의 값을 알맞게 조정해 패딩에 위치하는 값은 연산에 포함되지 않도록 만들었습니다. 그리고 NVIDIA와 협업해서 FasterTransformer 내부의 CUDA 커널에도 이와 같은 방법을 적용했습니다.

img

이렇게 attention mask를 수정하는 방식을 적용해서 입력 길이가 서로 다른 요청이 하나의 배치로 묶일 때 지연시간이 크게 증가하는 현상을 막을 수 있었습니다. 요청으로 들어오는 입력 문장 길이의 분포에 따라 성능 향상의 정도는 달랐지만, 배치 크기를 8까지 키웠을 때 입력 문장의 길이가 각기 달라 단일 배치(single batch) 대비 처리 시간이 5배까지 느려지던 것이, 오히려 단일 배치 대비 20% 정도 빠르게 성능이 향상됐습니다. 또한 요청으로 들어오는 입력 문장의 길이가 고른 상황에서는 단일 배치 대비 처리량을 6배까지 향상시킬 수 있었습니다.

멀티 턴

HyperCLOVA를 이용하는 서비스에는 다양한 패턴이 존재하는데요, 그중에는 HyperCLOVA와 유저가 매 턴마다 대화를 해 가면서 이전의 대화 내용을 기반으로 새로운 문장을 생성하는 멀티 턴(multi turn) 패턴이 있습니다.

멀티 턴(multi turn) 패턴에서는 HyperCLOVA가 문맥을 잘 이해하게 되고 고품질의 문장을 생성할 수 있습니다. 이러한 멀티 턴 패턴은 엔지니어링 관점에서 최적화 포인트가 됩니다. 일반적으로는 여러 턴을 거치며 HyperCLOVA와 유저가 대화를 할 때마다 기록이 누적되어 HyperCLOVA로 요청하는 리퀘스트 문장이 길어집니다. 하지만 이전에 나왔던 대화 내용은 앞선 리퀘스트를 처리할 때 이미 연산을 진행했었기 때문에 연산 결과를 잘 캐싱 해둔다면 중복되는 부분은 연산을 스킵 하여 응답시간을 단축시킬 수 있습니다.

아래 그림은 앞서 HyperCLOVA 서빙기 두 번째 편에서도 보여드렸는데요. 디코더 연산 과정을 나타내는 그림입니다.

img

디코더 연산은 masked self-attention으로, 이전에 입력됐던 연산 결과인 key, value를 모두 참조해 자신의 key, value를 생성합니다. 즉 위 그림에서, 7번 토큰 ID 연산에서는 토큰 ID [3, 21, 1]의 key, value를 이용하고, 다음 토큰인 99번 토큰 ID를 연산할 때는 7번 토큰이 추가된 토큰 ID [3, 21, 1, 7]의 key, value 모두를 연산에 이용합니다. 이는 바꿔 말해 이 정보를 다른 곳에 캐싱해 두고, 다음 토큰 ID를 생성할 때 이전에 나왔던 토큰 ID와 하나도 빠짐없이 모두 같다면 캐싱 정보를 가져와 사용하는 것이 가능하다는 것입니다. 아래 그림은 이와 같은 멀티 턴 패턴에서의 캐싱 모습을 보여줍니다.

img

예를 들어 '안녕하세요' 입력 후 '반갑습니다'가 출력된 상황에서 사용자의 입력을 이어서 받는다면, 앞선 '안녕하세요 반갑습니다'에 해당하는 key, value 연산은 다시 연산하지 않고 캐싱 결과를 가져와서 사용하면 됩니다. 이와 같은 방식으로 입력으로 들어오는 대부분의 문장에 대해서 연산을 생략할 수 있습니다.

하지만 모든 요청에 해당하는 연산 결괏값을 GPU에 저장해 둘 수는 없습니다. 저장되는 key, value의 크기는 모델의 크기 및 토큰의 개수에 비례하기 때문에 몇 개의 요청만으로도 GPU 메모리를 모두 차지해 버립니다. 따라서 DRAM과 같은 공간에 key, value를 저장해야 하는데요. 이때 CPU와 GPU 간에 데이터를 주고받는 시간이 추가로 소요됩니다. 즉 연산 시간과 데이터 이동 시간의 트레이드오프 관계가 발생합니다. 이에 우리는 성능을 최대한 높이기 위해 pinned-memory 형태로 DRAM의 일부분을 연산 결과를 저장하기 위한 캐싱 공간으로 할당했습니다.

실험 결과 아래 그림과 같이 캐싱 효과를 발휘할 수 있는 케이스에서 기존 방법보다 20% 정도 성능이 향상됐습니다. 캐시 미스가 발생했을 때는 GPU의 key, value를 DRAM으로 전송하는 시간 때문에 10% 정도 지연 시간이 늘어났는데요. 이때 데이터 전송을 연산과 병렬로 동작할 수 있게 수정한다면 지연 시간 증가를 최소화할 수 있다는 점을 확인했습니다.

img

위와 같이 성능을 향상시킬 수 있다는 것을 확인했지만 실제 서비스에 적용하지는 않았습니다. 예상했던 것보다는 이득이 크지 않았기 때문인데요. 앞서 언급한 것처럼 GPT-3에서 주로 병목이 발생하는 부분은 문장을 생성하는 부분이기 때문에 입력을 캐싱하는 것은 큰 효과가 없었고, 또한 캐싱 대상인 key, value의 크기가 커서 GPU로 불러오는 시간이 생각보다 오래 걸렸기 때문입니다.

향후 key, value의 크기를 줄일 수 있는 방법이나 캐싱 기법을 적용할 수 있는 좀 더 적절한 케이스를 파악해 HyperCLOVA 서비스를 더욱 빠르게 제공할 수 있도록 개선할 계획입니다.