ai technology
AI 모델 분리 서빙 및 효율화 (Flash Attention, MIG GPU 가상화)
Junyoung Park · 2025-11-24 · 10 min
AI 모델 효율화
내부에서 AI 서비스를 구축하기 위해 AI 모델을 개발 및 연구하였다. 해당 모델은 꽤나 큰 편에 속했기 때문에, 그대로 사용하게 되면 GPU 메모리 96GB를 FULL로 사용하게 된다.
우선 검토했던 부분은 메모리/속도 효율화가 가능한 여러 방법이었다. 하지만 효율화보다 더 중요했던 것이 있으니, 바로 메모리나 속도의 향상은 있어도 성능이 떨어지면 안 된다는 점이다.
대부분의 AI 리서치에서 진행된 quantization 방식은 어느 정도의 trade-off를 허용하는 것처럼 보였지만, 대량의 데이터를 활용하여 post-training이 불가능한 환경에서 성능 손실 없이 양자화가 가능한 방법은 많지 않아보였고, 무엇보다 그렇게 리소스를 투자했을때의 리턴도 크지 않았다. 그리고 실제로 논문에는 성능 저하가 크지 않다고 나와있는 방법론을 적용했을때, 내가 구축한 서비스의 AI 모델은 그렇지 않은 경향성을 보여 적용하지 않기로 결정하였다.
모델 분리
앞서 말했던 바와 같이 모델이 꽤나 큰 편이었는데, 해당 모델을 크게 두 파트로 뜯어서 사용할 수 있었다. 우연치 않게도 두 파트로 뜯었을때, 완전히 기능이나 활용적인 측면에서 분리가 가능함과 동시에 메모리도 사이좋게 48GB씩 나눠가진다. 이 부분에서 극한의 마이크로매니징을 시작하면서, 여러 사실들을 많이 깨닫게 된다.
1. 모델 로드 방식에 따라 cache에 일시적으로 파라미터가 2배의 용량을 잡아먹는다.
구현 상에서 찾은 특징은 다음과 같다. 사실 연구할 때는 크게 신경쓰지 않았던 부분이라서 더 중요한 것 같다.
모델을 로드하는 과정에서, 기존 파라미터나 버퍼가 해제되지 않은 상태에서 텐서를 새로 GPU에 올리기 위해 GPU 메모리를 할당하게 된다. 이때, 분명 해당 메모리에 들어가는 텐서의 값은 동일한데도 불구하고, 그냥 별개의 메모리 공간을 할당하면서 2배만큼의 공간을 잡아먹게 되는 것이다.
쉽게 말해서 새 집에 이사를 하려고 이삿짐을 잠시 내려놓고 기다리고 있는데, 이삿짐을 내려놓은 공간 + 새 집에 해당 이삿짐을 놓을 공간까지 더해서 실제 필요한 공간보다 2배만큼이 cache에 할당되어버리는 것.
가장 흔한 패턴이라고 하면 다음과 같다:
device = 'cuda'
model = MyModel().to(device)
ckpt = torch.load('model.pt', map_location=device)
model.load_state_dict(ckpt['model'])
model = MyModel().to('cuda')
state_dict = torch.load('model.pt')
model.load_state_dict(state_dict)
이래서 생각없이 짜면 손해를 보는 구조가 된다. 엥간하면 파라미터를 로드하기 전까지는 cpu에서 건드리거나, cache를 바로바로 비워주거나 하는 것이 좋다.
ckpt = torch.load(path, map_location='cpu')
model.load_state_dict(ckpt)
model.to('cuda')
놀라운 점은 생각보다 많은 수의 소스 코드를 보다보면 이런 형태의 비슷한 비효율 코드가 은근 많다는 점. 써먹기 전에 리팩토링은 필수인 듯하다.
2. 데이터 전처리 효율화가 모델 효율화만큼 중요하다.
미디어 업계에서 사용하는 AI 모델이라 비디오 전처리가 필요했는데, 사실상 비디오 전처리에 필요한 CPU 로드가 GPU 로드 이상을 잡아먹는 일이 많았다. 그래서 그런지 GPU 로드는 크지 않은데도 불구하고 CPU에서 그 이상의 로드를 잡아먹어 병목이 발생하는 일이 많았음.
결국 CPU에서 돌리던 모든 전처리 프로세스를 cuda 디코딩으로 옮기고, 기존에 존재하던 CPU 베이스인 모든 오픈 소스를 본인이 직접 GPU 베이스로 다시 작성하여 사용하였다. 이래서 오픈 소스를 맹신하면 안됨.
3. 데이터 정규화와 에러 폴백은 필수
생각보다 세상에는 다양한 비디오 인코딩 방식이 있다는 것을 여기서 한번 더 깨닫게 된다. 비디오의 프레임 하나를 효율적으로 얻기 위해서 얼마나 많은 엔지니어들이 로우레벨에서 갈려나갔는지 느낄 수 있었던 파트. 사실 나도 여기서 많이 갈려나갔고, cuda 디코딩에서 hanging이 발생해서 API가 멈추는 현상을 없애기 위해 멀티프로세스로 분리한 뒤 타임아웃이 발생하면 CPU로 폴백하는 구조를 작성하였다. 아예 에러로 뻗으면 try except로 에러 캐치라도 가능하지만 hanging은 캐치가 불가능해서 멀티프로세스말곤 답이 없었던 케이스다. 처음에 이걸 멀티쓰레드로 분리했다가 수많은 좀비들이 쌓이는 아찔한 경험도 했었다.
Flash Attention은 신이야
Flash attention을 써보면 알겠지만 속도도 빠르고 메모리 누수도 현저히 적어진다. 원래는 배치 크기에 따라 48GB를 아슬아슬하게 사용했던 모델도 Flash attention으로 효율화를 하고 나서는 아무리 무리하게 사용해도 24GB를 넘지 않게 되어버렸다.
다만 Flash attention을 사용해서 구축한 도커 이미지를 다양한 cuda 환경에서 사용할 때는 조심해야 한다. 특히나 torch와 엮을때는 꼭 서버마다의 드라이버 호환성을 체크해주길.
NVML 환경 변화로 인한 CUDA 인식 손실 문제
GPU 기반 서비스 운영 시 흔히 직면할 수 있는 문제 중 하나가, 컨테이너 내부에서 GPU가 일정 시간이 지나면 갑자기 인식되지 않는 현상이다. 처음 컨테이너를 띄웠을 때는 torch.cuda.is_available()도 정상이고 NVML 역시 제대로 초기화된다. 그러나 며칠이 지나면, 분명 GPU가 붙어 있어야 할 컨테이너에서 CUDA 장치가 완전히 사라진 것처럼 보이는 문제가 발생한다.
흥미로운 점은, 이미 로드되어 실행 중이던 모델의 추론은 계속 정상적으로 수행되는 것처럼 보였다는 것이다. 이는 모델이 로딩될 때 이미 CUDA Graph가 생성되어 있었고, 이 Graph가 GPU 메모리에 직접 연결된 상태로 동작하고 있었기 때문이다. 즉, 모델은 “붙어 있는 척” 하고 있지만, 새로운 CUDA 컨텍스트나 NVML 초기화는 더 이상 불가능한 상태였다.
이 문제의 원인이라고 파악된 부분은 Docker 실행 환경에서 cgroups 기능이 비활성화되어 있었던 것이었다.
(/etc/nvidia-container-runtime/config.toml)
--no-cgroups=true
NVIDIA Container Runtime은 GPU 자원을 컨테이너에 안전하게 전달하기 위해 cgroups 기반의 디바이스 할당 및 트래킹을 수행한다.
- GPU 자원 할당 상태 추적
- NVML 초기화 및 GPU visibility 관리
- 컨테이너가 사용하는 GPU 디바이스 ID 유지
- 메모리/디바이스 접근권한을 cgroups로 분리 및 고정
그런데 no-cgroups=true 상태에서는 이러한 기능이 아예 동작하지 않는다.
cgroups가 꺼져 있으면, NVIDIA Container Runtime은 GPU를 “관리할 수 없고”, 시간이 지나면 컨테이너가 해당 GPU 디바이스를 잃어버릴 수 있다는 것이 얼추 현재의 생각이었다. 도커 컨테이너는 처음 실행될 때 GPU를 물고 들어갔지만, cgroups가 비활성화되어 있기 때문에 장시간 실행 시 GPU 디바이스 파일을 재검증하거나 보호할 방법이 없었고, 결국 NVML 초기화가 실패하면서 CUDA 런타임 전체가 끊기는 상태가 되었다.
그러나 해당 옵션을 추가했음에도 불구하고 시간이 꽤 지나고 나니 특정 서버에서는 동일한 문제(시간이 지나면 새로운 CUDA context를 잡지 못하는 문제)가 발생하는 것을 확인할 수 있었다. 결국 컨테이너 자체에 시스템 주요 자원에 대한 접근 권한을 부여하는 privileged = true 옵션으로 해결할 수 있었다.
그리고 중간중간 개발해보면서 느낀건데 CUDA context가 포함되는 도커 컨테이너는 생각보다 안정성이 많이 떨어진다(지속적인 유지 개발 보수가 필수적임).
MIG 도입은 필수인가?
그렇게 추론 메모리를 확 줄여놓으니 여유가 좀 생겼다. 그러다보니 이제 여유가 생긴만큼 서버 GPU에 도커 컨테이너를 여럿 올릴 수 있게 된다. 그러나 단순히 컨테이너 개를 단일 GPU에 물려서 사용하게 되면 기대하는 만큼의 throughput이 나오지 않을 수 있다(라는 추측). 기존에는 개발 서버로 Blackwell이 아닌 서버 GPU에서 사용했었고, 이때도 꽤나 높은 배수의 추론 속도를 보여주었다. 그런데 테스트용으로 컨테이너 하나만 띄워서 수행해보니 거의 2배 이상의 속도라는 어마무시한 결과가 나왔다.
그래서 이번에는 과연 MIG(Multi-Instance GPU)가 얼만큼의 효율을 보일지에 대해 궁금해졌다. 실제로 NVIDIA의 MIG는 하나의 GPU를 독립 인스턴스로 분리가 가능하며, 동일 GPU를 할당받은 서로 다른 컨테이너끼리의 독립적인 메모리 대역폭, 캐시, 컴퓨팅 자원을 가진다는 점에서 안정성이 높아 많이 사용되는 것으로 알고 있다.
하지만 실제로 테스트했을 때, 과연 GPU 인스턴스를 명시적으로 분리해서 사용하는 것이 얼마나 효과적인가에 의문이 생겼고, 워크플로우 방식과 서비스 구조에 따라 최적의 방식이 아닐 수도 있겠다는 생각을 했다.
MIG 적용하지 않았을 경우 Throughput 비교
테스트를 위해 이번에는 4개를 아예 동일 GPU에 물려서 동시 분석 요청을 진행하였고, 동시 분석 개수에 따른 처리 속도는 다음과 같았다. 이때는 MIG로 GPU끼리의 인스턴스를 따로 분리하지 않고 진행하였다. 각 케이스마다 3번씩 돌려서 평균값을 측정하였다.
또한 해당 파트에는 단순히 분석 시간만 포함되어 있지 않고, 실제로 접속 가능한 네트워크에서 API 호스팅 서버로 요청을 보내고 결과값을 리턴받는 시간까지 포함하였다. (요청 → 분석 → 응답)
| 1개 | 2개 | 3개 | 4개 | 기존 서버 |
|---|---|---|---|---|
MIG 적용했을 경우의 Throughput 비교
MIG는 다음과 같이 적용해볼 수 있다. 우선, MIG를 적용하기 위해서는 GPU가 MIG가 가능한 상태임을 알아야하므로 nvidia-smi를 확인해봄.
위쪽과 같이 MIG에 대해서 N/A라고 명시되어 있는 경우에는 MIG를 지원하지 않는 디바이스이고, 아래와 같이 MIG가 초기에 “Disabled”라고 명시되어 있는 경우에는 MIG를 지원하는 디바이스라고 보면 된다. 대부분의 블랙웰/서버용 GPU는 MIG를 지원한다.
만약 MIG를 허용할 수 있는 상태라면 sudo nvidia-smi -i {gpu_id} -mig 1를 통해 활성화시킬 수 있다. 정상적으로 Enable된 경우 “Enabled MIG Mode for GPU 0000:xx:00.0” 이런 식으로 메시지가 뜬다. Reboot가 필요하다는 말이 없었기 때문에 reboot를 하진 않았다.
nvidia-smi 로 확인해보니 정상적으로 Enable된 것을 확인하였다.
지금부터가 메인 과정인데, GPU 종류에 따라 분리 가능한 인스턴스 프로필이 따로 있다. 내가 원하는 만큼 GPU를 할당해서 사용하는 방식이 아니라, 96G의 GPU면 24G씩 4개, 28G씩 2개 이런 식으로 분리하는 등 미리 정해진 프로필 목록에 대한 인스턴스 분리가 가능하다.
nvidia-smi mig -lgip를 통해 적용 가능한 인스턴스 종류들이 확인이 가능한데, 대개 Blackwell GPU는 균등하게 개의 개별 인스턴스로 나누는 것이 일반적이다.
대강 보니 me, gfx 등의 옵션이 붙어있는데 나는 딱히 필요없는 옵션이라 그냥 기본 인스턴스로 테스트하기로 결정. 4개의 개별 인스턴스로 분리하면 되기 때문에 프로필 ID 14를 적용하면 된다.
sudo nvidia-smi mig -i {gpu_id} -cgi 14,14,14,14 -C
이제는 nvidia-smi를 하면 MIG devices의 각 인스턴스들도 함께 볼 수 있다. nvidia-smi -L을 하면 내부에서 각 인스턴스별 UUID를 확인할 수 있는데, 이전에는 단순히 gpu_id를 오름차순으로 매핑해서 사용했다면, 이제는 UUID 단위로 관리할 수 있다.
아무튼 이렇게 설정을 완료하고 속도 측정을 해보았다.
| 1개 | 2개 | 3개 | 4개 | 기존 서버 |
|---|---|---|---|---|
MIG, 그다지 좋지 않을지도
결과는 생각보다 속도 측면에서는 유리하지 않을 수 있다는 점이었다. 물론 인스턴스를 분리해서 사용하게 되면 메모리 부족 이슈나, NVML, CUDA 의존성이 개별 인스턴스 단위로 돌아간다는 점에서 서비스를 여러 개 띄웠을 때의 안정성은 훨씬 올라갈 수 있다. 하지만 만약 각 서비스 별로 돌아가는 시간, 연산 속도 그리고 프레임워크를 기준으로 많이 겹치지 않는 선에서 인스턴스를 분리하는 것이 오히려 속도를 낮추는 원인이 되었다. 일례로, 서버용 GPU를 단일 인스턴스로 MIG를 수행하지 않았을 때 Legacy Server에 비해 약 2배 이상의 처리 속도를 보여주었지만 , 반대로 MIG를 수행하자마자 Legacy Server에 비해 단일 처리에 대해서는 속도가 현저히 줄어드는 것을 볼 수 있었다 . 겨우 0.3 정도의 배수 차이가 얼마나 크냐고 물어볼 수 있는데, 데이터를 1만 시간 처리한다고 가정하면 대략 53시간 정도의 속도 차이가 발생한다. 10만 시간의 데이터를 처리하면 거의 3주에 가까운 시간이 더 소모된다. 실제로 GPU 개수에 따라 10만 시간의 데이터를 처리하기 위해 걸리는 시간을 비교해보면 다음과 같다:
비록 단일 인스턴스에 여러 서비스를 띄우고 동시에 처리하는 과정에서 추론 속도에 손실이 날 수 있지만, 의외로 MIG로 인스턴스를 분리했을때 단순 연산 속도가 느려지는 정도가 생각보다 더 큰 것을 확인할 수 있었다.
우리가 사용할 서비스에서는 앞서 간단하게 설명했던 바와 같이 두 파트의 API가 있었는데, 하나는 지속적인 배치 작업으로 연산량을 길게 잡아먹는 친구였고, 나머지 하나는 짧은 시간 내에 응답을 리턴하는 구조였다. 실제로 이 두 API가 경합을 벌일 일은 많지 않을 것 같아, 결론적으로는 MIG를 사용하지 않는 편이 훨씬 유리했다.
결론
AI 모델 효율화는 단순히 모델 하나의 문제로 끝나는 것이 아니라 전체 시스템 설계와 운영 환경까지 고려해야 하는 종합적인 작업이다.
우선 모델 자체 효율화 측면에서는 Flash Attention과 파트별 모델 분리가 매우 큰 효과를 냈다. 메모리 사용량을 절반 수준으로 줄이면서도 성능 손실이 없는 구조를 확보할 수 있었고, 이는 이후 인프라 설계 전반에 유연성을 제공했다. 또한 모델 로드 방식이나 데이터 전처리 최적화처럼 사소해 보이지만 GPU 메모리와 처리 속도에 치명적인 영향을 주는 요소도 많았다. 실제 서비스 환경에서는 이런 작은 비효율 하나가 전체 성능을 크게 떨어뜨릴 수 있음을 다시 확인했다.
운영 환경 측면에서는 예상치 못한 GPU 인식 손실 문제, 디코딩 hanging error 등 여러 이슈들을 겪으면서, NVML·CUDA·Docker runtime·cgroups 등등이 서로 유기적으로 얽혀있으며 호스팅 서버 환경에 따라 새로운 이슈가 발견되는 일이 많다는 사실을 알게 되었다.
MIG 실험에서는, “더 분리하면 더 좋아진다”는 단순한 기대가 성립하지 않을 수도 있다는 점이 확인되었다. MIG를 적용하면 자원 고립성과 안정성은 올라가지만, 실제 워크로드 패턴에 따라 연산 효율은 오히려 저하될 수 있다. 특히 이번 서비스처럼 CPU–GPU 전처리 비율, API 특성, 배치 vs 단건 요청 비중이 명확히 다른 환경에서는 MIG를 적용하지 않은 단일 인스턴스 방식이 훨씬 더 높은 실제 처리량을 제공했다.
결국 인프라 구성은 절대적인 정답이 존재하는 문제가 아니라, 서비스 요구사항과 데이터 패턴, 모델 구조, 운영 방식까지 모두 고려한 최적화의 문제임을 다시 한 번 확인했다. 남들이 다 사용한답시고 불필요하게 무거운 서비스를 모듈화해서 얹히는 순간, 효용성은 떨어지고 유지보수만 어려운 일이 될 것이다. 그리고 과정에서 얻은 가장 큰 교훈은 다음 한 줄로 요약된다:
AI 효율화는 모델·코드·운영환경·GPU 세팅까지 모두 포함하는 총체적 시스템 엔지니어링이다.
앞으로도 모델 자체 성능뿐만 아니라 운영 환경 안정성, 적합한 GPU 구조, 전처리 파이프라인 등을 모두 함께 고민하는 방향으로 서비스 구조를 발전시켜 나갈 계획이다.