본문으로 건너뛰기

© 2026 Molayo

Zenn헤드라인2026. 06. 19. 21:30

GPU 사용률이 100%인데 생성이 진행되지 않는다. 단일 GPU에 vLLM과 추론 서비스를 공존시켜 겪은 시행착오

요약

단일 GPU 환경에서 vLLM, reranker, embedding 프로세스를 동시에 운용할 때 발생하는 커널 행(Hang) 현상과 그 원인을 분석합니다. MPS(Multi-Process Service) 미설정으로 인한 프로세스 간 연산 경합이 GPU 사용률은 100%지만 실제 연산은 멈추는 현상을 유발함을 밝힙니다.

핵심 포인트

  • GPU 사용률 100%임에도 전력 소모가 낮다면 커널 행(Hang) 의심 필요
  • VRAM 할당량 분리만으로는 물리적 연산 코어(SM)의 경합을 막을 수 없음
  • 다중 프로세스 GPU 공유 시 MPS(Multi-Process Service) 활성화 필수
  • MPS 미사용 시 타임 슬라이싱 방식의 컨텍스트 전환 오류로 데드락 발생 가능

발생한 일

단일 GPU 상에서 RAG의 추론 기반을 구동하고 있다. 구성은 대략 다음과 같다.

  • vLLM (LLM 추론, 투기적 디코딩 (Speculative Decoding) 포함. Docker 컨테이너로 운용)
  • reranker (검색 결과의 재순위화, TensorRT로 FP8 양자화. 호스트에서 운용)
  • embedding (벡터화. 호스트에서 운용)

이것들을 1장의 GPU에 공존시키고, VRAM만 할당량으로 나누어 운용하고 있었다. 여러 프로세스가 GPU를 공유하므로 MPS (Multi-Process Service)를 사용할 것을 전제로 하고 있었다. 평소에는 문제없이 작동한다. 그런데, 30명 동시 접속·1분간의 부하 테스트를 연속으로 돌리면, 3회차쯤에서 처리가 멈추는 경우가 있었다.

멈추는 방식이 묘했다. 에러가 나지 않는다. 예외(Exception)도 발생하지 않는다. 로그에는 Running: 51 reqs라고 표시된 채로, 생성 처리량(Throughput)만 0.0 tokens/s가 된다. 요청은 받고 있는데, 단 1토큰도 앞으로 나아가지 않는다.

다만, 매번 그런 것은 아니다. 30명 동시 연속 테스트에서 3번에 1번 정도의 빈도로 멈춘다. 매번은 아니지만, 무시할 수 있는 빈도도 아니다. 이 '가끔 발생하는' 성질이 나중에 원인 규명을 길어지게 만들었다.

nvidia-smi를 보고 알게 된 것

멈춘 상태에서 nvidia-smi를 보면 다음과 같았다.

GPU-Util: 100%
소비 전력: 94W / 300W
GPU KV 캐시 사용률: 절반 정도 (아직 여유가 있음)

GPU 사용률은 100%인데, 소비 전력은 94W밖에 되지 않는다. 정말로 연산이 돌아가고 있다면 200W 이상은 나와야 한다. 즉, GPU는 '비지(Busy)' 상태라고 보고하고 있지만, 실제로는 계산을 하고 있지 않다. 어떤 커널(Kernel)이 실행 상태인 채로 완료되지 않고 기다리고 있다. 이른바 커널의 행(Hang)이다.

KV 캐시에는 아직 절반 정도 여유가 있다. 메모리 부족이나 OOM(Out of Memory)이 아니므로, 메모리 계통의 문제는 제외할 수 있었다.

처음에 의심하여 제외했던 것

투기적 디코딩 (Speculative Decoding)의 토큰 수를 늘려두었기에 처음에는 그것을 의심했지만, 이것은 아니라고 다시 생각했다. 투기적 디코딩은 드래프트(Draft)를 몇 토큰 내보내어 검증할 뿐인 처리이며, 그 자체로 여러 주체의 대기를 유발하지는 않는다. 데드락(Deadlock)의 구조와는 거리가 있다.

데드락은 본래 여러 주체가 서로의 해제를 기다릴 때 발생한다. 단일 모델의 추론 처리 내부에서만은 일어나기 어렵다. 그렇다면 '여러 주체'가 있는 곳은 어디인가라고 생각하면, 답은 공존하고 있는 별도의 프로세스였다.

원인: 단일 GPU 상의 경합

VRAM을 할당량으로 나누더라도, 나누어지는 것은 메모리의 위치뿐이다. 실제로 계산하는 GPU 코어 (SM)와 명령을 흘려보내는 CUDA 스트림은 물리적으로 1개의 GPU를 공유한다.

MPS를 사용하지 않는 상태라면, 여러 프로세스가 동일한 GPU를 호출할 때 GPU는 타임 슬라이스 (Time Slicing) 방식으로 컨텍스트를 전환하며 처리한다. 프로세스 A의 차례, B의 차례, A의 차례... 와 같이 짧게 전환한다. 평소에는 이것으로 돌아간다. 하지만 무거운 커널이 길게 GPU를 점유하거나, 서로 다른 프로세스의 커널이 나쁜 타이밍에 겹치면, 전환이 제대로 이루어지지 못하고 막히게 된다.

확인해 보니, GPU의 Compute Mode는 Default였고, MPS가 기동되어 있지 않았다. 평소에는 MPS를 활성화하여 운용할 계획이었으나, 이 환경에서는 기동하는 것을 잊고 있었다. 3개의 프로세스가 MPS 없이 1장의 GPU를 쟁탈하고 있는 상태였다.

FP8 reranker가 트리거가 되었을 가능성

여기서부터는 단정이 아니라 유력한 가설로서 기술한다.

이번 경합에서 특히 의심스러운 것은 FP8로 양자화한 reranker다. reranker는 TensorRT로 구동하고 있으며, FP8화를 통해 처리 시간을 크게 단축했다 (내 환경에서는 63ms에서 29ms까지 단축했다).

고속화했다는 것은, 동일한 처리를 더 짧고 날카롭게 GPU에 던지고 있다는 뜻이기도 하다. 검색할 때마다 호출되는 reranker가 버스트(Burst)적으로 GPU를 순간적으로 강하게 붙잡는다. 그 순간이 vLLM 측의 지속적인 추론 커널과 충돌한다.

게다가 TensorRT의 커널과 vLLM 측의 커널은 GPU 사용 방식의 스타일이 다르다. 최적화되는 방식도, 컨텍스트를 유지하는 방식도 다르다. 이 이질적인 두 종류의 커널이 MPS 없는 타임 슬라이스에서 거칠게 전환되면 스케줄링이 파탄 나기 쉽다.

reranker를 FP8로 빠르게 만든 것 자체는 좋은 개선이었지만, 그 날카로운 액세스 패턴이 공존하는 다른 추론 서비스와의 경합을 일으키기 쉽게 만들었을 가능성이 있다. 빠르게 만들기 위한 최적화가 다른 곳에서 문제를 낳게 된 것이다.

이것은 가설일 뿐이며, 확증된 것은 아니다. 검증하려면 reranker를 느린 버전으로 되돌리고 동일한 부하를 가해, 행(hang) 현상이 사라지는지 확인하면 된다. 다만 다른 방법으로 해결했기에 거기까지는 추적하지 않았다.

MPS를 활성화했다. 한때는 해결된 것처럼 보였다.

MPS 데몬(daemon)을 기동했다.

nvidia-cuda-mps-control -d

기동 시 /var/log/nvidia-mps에 쓸 수 없다는 경고가 나오지만, 로그가 남지 않을 뿐 동작에는 영향을 주지 않는다.

모든 서비스를 재시작한 후 동일한 부하 테스트를 수행한 결과, 3번에 1번꼴로 발생하던 행(hang) 현상이 거의 나타나지 않게 되었다. 체감상으로는 30번에 1번 정도로 빈도가 낮아졌다. 이것으로 해결되었다고 생각했다.

그런데 횟수를 거듭하자 다시 똑같은 행(hang) 현상이 발생했다. 빈도는 크게 줄었지만, 제로(0)가 되지는 않았다. MPS를 도입했는데도 완전히 사라지지 않는다는 점이 묘했다.

진짜 원인: Docker 컨테이너가 호스트의 MPS에 연결되어 있지 않았다

재발한 상태에서 nvidia-smi의 프로세스 목록을 Type 컬럼까지 포함하여 확인했다. MPS가 적용되고 있는 프로세스는 Type이 M+C (MPS + Compute)로 표시된다. 적용되지 않고 있다면 C로 남아 있다.

2432307 M+C python ← reranker (호스트)

2432575 M+C python ← embedding (호스트)

2469844 C VLLM::EngineCore ← vLLM (컨테이너)

호스트에서 동작하는 reranker와 embedding은 M+C, 즉 MPS를 경유하고 있다. 하지만 Docker 컨테이너에서 동작하는 vLLM만 C인 상태로 MPS 외부에서 동작하고 있었다.

MPS는 호스트의 /tmp/nvidia-mps라는 파이프(pipe)를 통해 프로세스와 데몬이 통신한다. 호스트의 프로세스는 이 파이프를 볼 수 있지만, Docker 컨테이너 안에는 /tmp/nvidia-mps가 존재하지 않는다. 따라서 컨테이너 내의 vLLM은 MPS 데몬에 연결될 방법이 없었다.

이로써 빈도 변화를 설명할 수 있다. MPS가 전혀 없을 때는 3개의 프로세스가 제멋대로 GPU를 쟁탈하기 때문에 3번에 1번이라는 높은 빈도로 멈췄었다. MPS 데몬을 기동하자 3개 프로세스 중 2개(reranker, embedding)가 교통정리되어 경합 확률이 크게 낮아졌고, 30번에 1번 정도로 줄어들었다. 하지만 vLLM만 외부에서 방치된 상태였기에 제로가 될 수는 없었다. "해결된" 것처럼 보였던 것은 2/3가 정리되어 빈도가 낮아졌을 뿐, 근본적인 문제는 해결되지 않았던 것이다.

컨테이너를 MPS에 연결하기까지의 우회로

vLLM 컨테이너를 MPS에 연결하는 것은 생각보다 손이 많이 갔다. 차례대로 벽에 부딪혔다.

  • 파이프를 마운트하기

먼저 컨테이너에 MPS 파이프를 전달한다.

-v /tmp/nvidia-mps:/tmp/nvidia-mps

-e CUDA_MPS_PIPE_DIRECTORY=/tmp/nvidia-mps

이를 추가하여 기동했더니, 이번에는 모델 로드 단계에도 진입하지 못하고 기동 도중에 멈춰버렸다.

  • UID 맞추기

멈춘 원인은 UID 불일치였다. MPS 서버는 UID 1000(일반 사용자)으로 동작하고 있었다. 호스트의 reranker와 embedding은 동일한 UID를 사용하므로 연결된다. 하지만 Docker 컨테이너는 기본적으로 root(UID 0)로 동작하기 때문에, MPS의 UID 대조 과정에서 거부되어 연결 대기 상태로 멈춰 있었다.

컨테이너를 동일한 UID로 동작시킨다.

--user

그러자 이번에는 컨테이너 내에 UID 1000인 사용자 정의가 없어, getpwuid(): uid not found: 1000 에러와 함께 종료되었다.

  • /etc/passwd 전달하기

호스트의 /etc/passwd를 읽기 전용으로 마운트하여 컨테이너 내에서 UID 1000을 해석할 수 있도록 했다.

-v /etc/passwd:/etc/passwd:ro

이번에는 PermissionError: /home/agata 에러로 종료되었다. UID가 해석된 결과, 각종 라이브러리가 홈 디렉토리인 /home/agata에 캐시를 쓰려고 시도했으나, 컨테이너에 해당 디렉토리가 없어 쓸 수 없었던 것이다.

  • 캐시를 쓸 수 있는 곳으로 돌리기

Triton, PyTorch Inductor, vLLM의 캐시 출력 경로를 컨테이너 내에서 쓸 수 있는 /tmp 하위로 지정했다.

-e HOME=/tmp

-e TRITON_CACHE_DIR=/tmp/triton_cache

-e TORCHINDUCTOR_CACHE_DIR=/tmp/torch_cache

-e XDG_CACHE_HOME=/tmp/.cache

-e VLLM_CACHE_ROOT=/tmp/vllm_cache

이것으로 마침내 기동되었다. nvidia-smi를 확인하니 vLLM의 Type이 M+C로 되어 있었다.

2500076 M+C VLLM::EngineCore ← 드디어 MPS 하위로 들어갔다.

3개 프로세스 모두 M+C가 되었다. 이로써 모든 프로세스가 MPS를 경유하여 GPU를 사용하는 상태가 되었다.

속도는 어떻게 되었을까

MPS를 모든 프로세스에 적용한 상태에서 다시 측정했다. 단독 이용(1개 연결) 비교.

지표MPS 도입 전MPS 전체 적용 후
응답 시작 (TTFT 중앙값)148ms143ms
생성 속도524자/초565자/초 전후

성능이 떨어지기는커녕, 오히려 약간 빨라졌다.

고부하 측면에서는 효과가 더욱 명확하게 나타났다. 30명 동시 접속 시, 응답 시작의 99퍼센타일(p99, 가장 느린 1%)이 기존의 2300ms 전후에서 1800ms대로 단축되었다. 60명 동시 접속 시에도 응답 시작의 중앙값이 0.56초에서 0.47초 전후로 개선되었다. vLLM이 MPS 외부에서 GPU를 점유하여 reranker를 대기시키던 상태가 해소되었고, 3개의 서비스가 대등하게 교통정리되는 구조가 된 결과라고 생각한다. 행(Hang) 현상이 사라졌을 뿐만 아니라, 혼잡 시 응답의 편차까지 작아졌다.

여기서 한 가지 깨달은 점이 있다. MPS 전체 적용 후의 단독 생성 속도는 이전에 측정하여 대외적으로 공개했던 수치(524자/초)보다 빠르다. 즉, 과거에 그 수치를 측정했을 때는 vLLM에 MPS가 적용되지 않고 있었다. 저확률의 행(Hang)을 안고 운영 및 측정을 했던 기간이 있었다는 뜻이다. 연속 부하 테스트를 여러 번 수행해 오면서, 우연히 치명적인 타이밍을 밟지 않았을 뿐 운이 좋았을 수도 있다. 재현성이 100%가 아닌 문제일수록 이처럼 간과되기 쉽다.

내압 시험을 통한 확인

MPS를 모든 프로세스에 적용한 후, 정말로 행(Hang)이 사라졌는지 단계적인 부하 시험을 통해 확인했다.

동시 접속 수를 1, 3, 5, 10, 20, 30, 40, 50, 60, 70, 80으로 나누어, 각 조건당 30회씩, 1회당 60초의 연속 부하로 실행했다. 총 330회. 결과는 다음과 같다 (TTFT와 생성 속도는 각 조건의 중앙값).

동시 접속 수횟수에러TTFT 중앙값TTFT p99 (최대)생성 속도 (중앙)
1300148ms245ms521자/초
3300176ms424ms347자/초
5300176ms450ms284자/초
10300229ms789ms199자/초
20300294ms1390ms125자/초
30300354ms1993ms89자/초
40300382ms2673ms79자/초
50300427ms3388ms67자/초
60300474ms3990ms61자/초
70300532ms4402ms52자/초
80300599ms4875ms45자/초

330회 모두 에러 0이었으며, 행(Hang)은 단 한 번도 재현되지 않았다. 빈도 추이를 보면, MPS가 없을 때 3번에 1번, MPS를 부분 적용했을 때 30번에 1번까지 떨어졌던 행(Hang)이, vLLM까지 MPS에 포함시킨 이번에는 330회 동안 0회가 되었다. 특히 의미 있는 점은, 과거에 멈추곤 했던 30명 동시 접속 구간을 30회 연속으로 사고 없이 통과했다는 것이다. 원래의 행(Hang)은 '30번에 1번' 정도의 빈도였으므로, 만약 동일한 문제가 남아 있었다면 이 30회 중 어딘가에서 발생했을 것이다. 하지만 단 한 번도 일어나지 않았다.

또 하나 데이터로서 좋았던 점은, 부하를 높여갈 때의 동작이 매끄러웠다는 것이다. 동시 접속 수가 늘어남에 따라 응답 시작은 148ms에서 599ms로, 생성 속도는 521자/초에서 45자/초로 둘 다 정직하게 변화한다. 어느 지점에서 갑자기 무너지는 '절벽'이 없다. 한계에 가까워져도 성능이 급락하는 것이 아니라 완만하게 느려질 뿐인 동작을 보여준다. 상용으로 운영하는 시스템으로서 이러한 '절벽이 없는' 특성은 안심할 수 있는 요소가 된다.

참고로 처리량 측면에서 보면, 혼잡한 조건에서는 1분당 약 600~700건의 응답을 반환하고 있다. 단일 GPU로 대략 이 정도 양을 처리하고 있는 시스템이다.

예상보다 높은 부하인 80명 동시 접속 상황에서도 30회 연속으로 안정적으로 완주했다. 안전 측면에서 충분한 여유를 가지고 작동한다는 것을 확인할 수 있었다. 또한 80명의 고부하 대역에서는 응답이 느린 상위 몇 %에서 reranker 대기 현상이 나타나기 시작한다. 이는 단순히 '기다리는' 것일 뿐 시스템이 멈추는 것은 아니며, 평소 체감 성능에 해당하는 중앙값(Median)에는 영향을 주지 않는다. 다만 더 높은 부하를 목표로 한다면 reranker의 증강이 다음 단계가 될 것이라는 시사점도 얻을 수 있었다.

각 조건 30회, 총 330회의 시험량은 '30회 중 1회'라는 기존 빈도에 비해 충분히 의미 있는 양이다. 발생률이 그대로라면 330회를 실행했을 때 기대값으로 약 11회는 발생해야 하지만, 실제로는 0회였다. 이를 통해 저확률의 행(Hang) 현상이 사라졌다고 상당히 높은 확신을 가지고 말할 수 있다. 과거에 확실히 겪었던 구간을 330회 통과하는 동안 단 한 번도 재현하지 않았다.

요약

  • 단일 GPU에 여러 추론 서비스를 공존시키면, VRAM을 나누더라도 SM(Streaming Multiprocessor)과 CUDA 스트림은 공유된다. MPS(Multi-Process Service)를 사용하지 않는 타임 슬라이싱(Time-slicing) 공유 방식에서는 커널(Kernel)이 좋지 않은 타이밍에 겹치면 행(Hang)이 발생할 수 있다.

  • 증상은 에러 없이 정지하는 형태다. nvidia-smi에서 "GPU-Util 100% · 소비 전력 낮음 · 생성 0 · 메모리 여유 있음" 상태라면, 메모리 부족이 아니라 커널 행(Kernel Hang)을 의심해야 한다.

  • MPS는 "데몬을 실행하면 모두에게 적용된다"는 뜻이 아니다. 각 프로세스가 MPS 파이프에 연결되어 있어야 한다. 특히 Docker 컨테이너는 호스트의 /tmp/nvidia-mps를 마운트하고, UID를 맞추며, 사용자 정의 및 캐시 출력 경로까지 설정하지 않으면 데몬이 실행 중이더라도 MPS 외부에서 동작한다.

  • nvidia-smi의 Type 열이 M+C인지 C인지에 따라 해당 프로세스가 MPS를 경유하는지 알 수 있다. 이 부분을 확인하기 전까지는 절반만 적용되고 있다는 사실을 깨닫지 못했다.

  • FP8화로 가속화된 reranker의 날카로운 액세스 패턴(Access Pattern)이 경합(Contention)의 트리거가 되었을 가능성이 있다 (최적화가 다른 레이어에서 문제를 일으키는 사례).

  • 에러를 내뱉지 않고 멈추는 문제는 원인에 도달하기까지 과정이 길다. 이번에는 GPU-Util과 소비 전력의 격차를 확인한 것, 그리고 MPS가 모든 프로세스에 적용되고 있는지를 Type 열로 확인한 덕분에 원인에 도달할 수 있었다.

이번에는 테스트 환경에서 겪었지만, 이것이 운영 환경(Production)이고 사용자가 접속 중인 상황에서 발생했다면 매우 곤란했을 것이다. 에러 로그조차 남지 않기 때문에, 모니터링을 하고 있더라도 "왜인지 응답이 돌아오지 않는" 상태가 한동안 지속된다. 프로세스는 살아있기 때문에 헬스 체크(Health Check) 방식에 따라 통과해 버릴 수도 있다. MPS를 부분적으로 도입하여 빈도가 30회에 1회까지 낮아졌다면 더욱 놓치기 쉽다. 빈도가 낮아지면 "고쳐졌다"고 착각하기 쉽지만, 0이 아니라면 운영 환경에서는 반드시 현상화된다. 연속 부하 테스트를 반복하지 않았다면 고객 환경에서 처음으로 겪었을 가능성이 높다. 낮은 확률의 문제를 출하 전에 찾아내기 위해 단계적인 내압 시험을 거듭하고 있다.

보충

MPS 데몬은 서버를 재부팅하면 사라진다. 운영 환경에서 운용하려면 systemd 등을 통해 자동 실행을 설정해야 하며, 컨테이너 측의 MPS 연결 설정(파이프 마운트, UID, 캐시 출력 경로)도 실행 스크립트에 고정해 두지 않으면 재부팅 후 경합이 재발한다.

필자는 군마현 도미오카시의 주식회사 아가타(Agata)에서 사내 문서용 RAG + AI 챗봇을 개발 및 운용하고 있습니다. 본 기사의 내용도 해당 서비스의 추론 기반에서 발생한 사건입니다.

공식 사이트: https://agata-corp.com/

데이터 센터: https://tomioka-dc.agata-corp.com/

보도 자료: https://prtimes.jp/main/html/rd/p/000000015.000125274.html

Discussion

AI 자동 생성 콘텐츠

본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0