
더 많은 스레드, 더 낮은 처리량: AI 서버가 스케줄러와 싸울 때
요약
AI 추론 서버에서 스레드 과다 할당(thread oversubscription)으로 인해 발생하는 성능 저하 문제를 다룹니다. 동시성을 높였음에도 GPU는 유휴 상태이고 CPU 사용률만 100%에 도달하며 지연 시간이 급증하는 현상을 분석합니다.
핵심 포인트
- 스레드 과다 할당은 CPU가 실제 연산 대신 스케줄링에 자원을 낭비하게 만듭니다.
- 동시성(Concurrency)을 무작정 높이는 것이 처리량 향상으로 이어지지 않을 수 있습니다.
- CPU 사용률이 높더라도 GPU가 유휴 상태라면 스케줄링 병목을 의심해야 합니다.
- 성능 문제 진단 시 하드웨어 텔레메트리와 프로세스 스레드 수 확인이 필수적입니다.
우리의 추론 (inference) 서버는 24개의 CPU 코어, 대부분 유휴 상태인 GPU, 그리고 단 10개의 동시 요청만을 가지고 있었습니다.
어찌 된 일인지, 평균 지연 시간 (latency)이 8초를 초과했습니다.
그것은 불가능한 일이어야 했습니다.
우리는 임베딩 (embedding) 및 분류 (classification) 요청을 내부 Python 추론 서비스로 전달하는 Rails 백그라운드 워커 (background worker) 시스템을 실행하고 있었습니다. 백그라운드 큐 (queue)가 증가함에 따라, 우리는 합리적인 팀이라면 누구나 할 법한 일을 했습니다. 바로 백그라운드 워커의 동시성 (concurrency)을 높이고 추론 웹 서버를 스케일 업 (scale up) 하는 것이었습니다.
처리량 (throughput)이 선형적으로 확장되는 대신, 시스템은 멈춰버렸습니다. 지연 시간은 밀리초 (milliseconds) 단위에서 8초 이상으로 급증했고, 백그라운드 큐는 밀려들었으며, 서버의 CPU 사용률은 100%에 도달했습니다. 하지만 GPU 텔레메트리 (telemetry)를 살펴보니, GPU는 작업을 기다리며 사실상 유휴 상태였습니다.
CPU는 행렬 곱셈 (multiplying matrices)에 시간을 쓰고 있지 않았습니다. 대신 다음에 어떤 스레드 (thread)를 실행할지 결정하는 데 시간을 쓰고 있었습니다. 더 깊이 파고든 결과, 우리는 숨겨진 성능 저하 요인인 **스레드 과다 할당 (thread oversubscription)**의 희생자라는 것을 깨달았습니다.
이해할 수 없었던 벤치마크 (Benchmark)
병목 현상 (bottleneck)을 진단하기 위해, 우리는 추론 서비스를 격리하고 통제된 부하 테스트 (load test)를 수행했습니다. 우리는 10개의 동시 요청이라는 완만한 동시성을 시뮬레이션하여, Python 추론 서버에 총 100개의 API 요청을 보냈습니다.
개별적이고 격리된 추론 요청은 약 912밀리초가 소요되었지만, 단 10개의 요청만 동시 부하를 주었음에도 평균 응답 시간은 8초 이상으로 늘어났습니다. 설상가상으로 CPU 코어는 완전히 포화 상태였지만, GPU 사용률은 15%에 불과했습니다.
건강한 시스템이라면 CPU가 100%일 때 처리량이 최대화되어야 합니다. 하지만 여기서는 CPU가 거의 아무런 결과물도 내지 못하면서 엄청나게 힘들게 일하고 있었습니다.
초기 베이스라인 벤치마크는 다음과 같았습니다:
| 지표 (Metric) | 베이스라인 (Baseline) |
|---|---|
| 처리량 (Throughput) | 7.28 req/s |
| ... |
우리의 초기 가설
우리는 그것이 다음과 같다고 생각했습니다:
- GPU 포화 (GPU Saturation): 모델이 우리 하드웨어에 비해 너무 무거웠다.
- 불충분한 워커 (Insufficient Workers): 더 많은 작업을 큐에 넣기 위해 Rails의 동시성 (Concurrency)을 높여야 했다.
- 느린 모델 (Slow Model): 임베딩 (Embedding) 알고리즘 자체가 본질적으로 느렸다.
- 네트워크 오버헤드 (Network Overhead): Rails와 추론 (Inference) API 사이의 데이터 전송이 지연되고 있었다.
결과적으로, 이 중 어느 것도 원인이 아니었습니다.
CPU 추적: 240개 스레드의 발견
우리는 부하 테스트 (Load test) 중에 추론 (Inference) 서버에 접속하여 htop을 실행해 CPU가 실제로 무엇을 하고 있는지 확인했습니다. 우리가 본 것은 24개의 개별 CPU 코어 (48개의 논리 스레드)를 나타내는 빨간색과 초록색 막대들이 절대적인 한계치까지 치솟아 있는 모습이었습니다.
그다음, Python 추론 프로세스의 활성 스레드 (Active thread) 수를 확인했습니다:
# 서비스 PID에 대한 경량 프로세스(스레드) 수 계산
ps -o nlwp -p <PID>
출력 결과는 240이었습니다.
단 10개의 동시 웹 요청을 처리하는 단일 프로세스가 240개의 활성 스레드를 생성한 것입니다. 이 스레드들은 어디에서 온 것일까요?
우리의 Python 코드는 단순했습니다. Uvicorn을 사용하는 표준 FastAPI 애플리케이션이었습니다. 우리는 수동으로 스레드를 생성하지 않았습니다. 그저 PyTorch를 사용하여 sentence-transformer 모델을 로드하고 추론 (Inference)을 실행하고 있었을 뿐입니다:
# 겉보기에는 무해해 보이는 엔드포인트 (Endpoint)
@app.post("/embed")
def embed(payload: TextPayload):
...
범인은 우리의 애플리케이션이 아니었습니다. 그 아래에 쌓여 있는 네이티브 라이브러리 (Native libraries) 스택이었습니다.
네이티브 라이브러리가 당신을 속이는 이유
왜 240개의 스레드가 실행되고 있었는지 이해하려면, PyTorch 아래에 있는 아키텍처 계층 (Architectural layers)을 살펴봐야 합니다.
Python에서 model.encode()를 호출하면, 요청은 CPU에 도달하기 전 수직적인 추상화 계층 (Stack of abstractions)을 타고 내려갑니다:
요청 (Request)
│
FastAPI / Uvicorn
...
가장 상단에는 동시 웹 연결을 처리하는 FastAPI/Uvicorn이 있습니다. 그 아래에는 신경망 그래프 실행 (Neural Network Graph Execution)을 조정하는 PyTorch가 있습니다. 하지만 PyTorch 자체가 실제로 핵심적인 행렬 산술 (Matrix Arithmetic, 예: 행렬 곱셈)을 수행하는 것은 아닙니다. 이를 위해 PyTorch는 저수준의 고도로 최적화된 네이티브 C 및 C++ 수학 라이브러리에 작업을 위임합니다.
- BLAS (Basic Linear Algebra Subprograms): 저수준 벡터 및 행렬 연산을 위한 표준화된 API 사양입니다.
- OpenBLAS / MKL (Intel Math Kernel Library): BLAS API의 구체적이고 고도로 최적화된 구현체입니다. 이들은 수작업으로 작성된 어셈블리 코드, CPU 벡터 확장 (AVX-512 등), 그리고 내부 스레딩 (Internal Threading)을 사용하여 물리적 실리콘의 한계치까지 행렬 연산을 실행합니다.
- OpenMP (Open Multi-Processing): 이러한 수학 라이브러리들이 루프를 분할하고 여러 스레드에 걸쳐 행렬 계산을 병렬화하기 위해 사용하는 동시성 엔진 (Concurrency Engine)입니다.
이러한 네이티브 라이브러리들은 단일 프로그램이 전용 머신에서 실행되며 단일 계산을 위해 모든 코어를 활용할 것으로 기대되는 고성능 컴퓨팅 (HPC, High-Performance Computing) 환경을 위해 설계되었습니다. MKL과 OpenMP는 호스트의 코어 수를 조회하고 그에 맞춰 워커 스레드 풀 (Worker Thread Pool)을 생성합니다. 이들은 자신들이 하드웨어를 독점한다고 가정합니다.
이러한 동작 방식은 개발자의 워크스테이션에서 실행되는 Jupyter Notebook에는 완벽하지만, 웹 서버나 백그라운드 작업 실행기 (Background Job Runner)에서는 적대적입니다. 웹 서버는 별도의 프로세스나 스레드를 사용하여 독립적인 요청들을 동시에 처리함으로써 확장 (Scale)합니다.
만약 Uvicorn이나 Rails가 24코어 서버에서 10개의 워커 (Worker)를 실행한다면, 각 워커 프로세스는 자신만의 PyTorch 호출을 수행합니다. PyTorch는 OpenMP에 작업을 위임하고, OpenMP는 요청당 24개의 스레드를 독립적으로 생성합니다. 어떤 워커도 서로의 존재를 알지 못합니다.
이 과정을 10개의 동시 요청에 대해 반복하면, 수학적으로 계산되는 수치는 가혹합니다:
10개의 동시 요청 (Concurrent Requests)
× 요청당 24개 스레드 (Threads per Request)
= 24개의 물리적 코어 (Physical Cores)를 두고 경쟁하는 240개의 활성 스레드 (Active Threads)
이러한 중복의 규모를 시각화하면 다음과 같습니다:
네이티브 라이브러리(Native libraries)들은 자신들만 존재한다고 가정함으로써 우리를 속였습니다. 전체 코어 수(Total core count)를 기본값으로 설정함으로써, 이들은 멀티태스킹 시스템 처리량(Throughput)을 희생시키면서 단일 작업 속도(Single-task speed)를 최적화했습니다. 협력하는 대신, 스레드들은 서로 싸우기 시작했습니다.
동시성의 비용: 스케줄러 경합 (Scheduler Contention)
24개의 물리적 CPU 코어(Physical CPU cores)에서 240개의 활성 스레드(Active threads)가 실행 시간을 달라고 아우성칠 때, 운영체제(OS)는 개입할 수밖에 없습니다. OS 스케줄러(OS scheduler)는 CPU 시간을 조각내야 하며, CPU 레지스터(Registers)에 스레드를 끊임없이 넣었다 뺐다(Swap in and out) 해야 합니다.
이것은 **스레드 오버서브스크립션 (Thread oversubscription)**이라고 알려져 있으며, **스케줄러 경합 (Scheduler contention)**이라는 거대한 오버헤드(Overhead)를 유발합니다.
모든 컨텍스트 스위칭 (Context switch)은 애플리케이션이 요청하지 않은 작업입니다. 스케줄러는 레지스터를 저장하고, 다른 스레드의 상태를 복구하며, CPU는 완전히 다른 워크로드 (Workload)를 실행하기 시작합니다. 캐시 (Caches)의 효율성은 떨어지고, 메모리를 다시 가져와야 하며, 프로세서(Processor)는 행렬을 곱하는 데 쓰는 시간보다 행렬을 곱할 준비를 하는 데 더 많은 시간을 소비하게 됩니다.
캐시가 끊임없이 무효화(Invalidated)되면, CPU 코어는 부동 소수점 연산 (Floating-point math)을 수행하는 대신 메인 메모리 (RAM)에서 데이터를 가져오기를 기다리는 데 대부분의 시간을 보냅니다. CPU는 "바쁘지만", 당신의 코드를 실행하는 것이 아니라 자신의 메타데이터 (Metadata)를 관리하느라 바쁜 상태가 됩니다.
우리의 경우, 스케줄러 경합 (Scheduler contention)이 너무 심각하여 240개의 스레드를 조정하는 데 드는 오버헤드가 행렬 연산 (Matrix math)을 병렬화함으로써 얻는 이점을 완전히 상쇄해 버렸습니다. 각각 24개의 스레드에서 실행되려고 시도한 10개의 요청은 912ms가 소요되었습니다. 만약 이들이 각각 단일 스레드에서 순차적으로 실행되었다면, 훨씬 더 짧은 시간 안에 완료되었을 것입니다.
해결책: 전역 동시성 조정 (Global Concurrency Coordination)
해결책은 직관에 어긋났지만 믿을 수 없을 정도로 간단했습니다. 네이티브 라이브러리 (Native libraries)가 내부 연산을 병렬화하는 것을 강제로 중단시켜야 했습니다. 우리는 각 요청이 정확히 **하나의 스레드 (One thread)**에서 실행되도록 하여, 웹 서버의 동시성 모델 (Concurrency model, 10개의 워커 프로세스)이 물리적 하드웨어 한계와 일치하도록 만들고자 했습니다.
우리는 딥러닝 라이브러리가 임포트(Import)되기 전, 애플리케이션 진입점(Entry point)의 최상단에 다음과 같은 설정을 추가했습니다:
import os
# OpenMP 스레드 제한
...
이 변수들을 1로 설정함으로써, PyTorch와 그 기반이 되는 수학 라이브러리들이 요청당 단일 스레드에서 연산을 순차적으로 실행하도록 지시했습니다.
그 후 서버를 재시작하고 동일한 10개의 동시 요청 부하 테스트를 수행했습니다.
결과는 천지차이였습니다:
| 지표 (Metric) | 기준점 (기본 스레드) | 최적화됨 (1개 스레드) | 개선 사항 |
|---|---|---|---|
| 처리량 (Throughput) | 7.28 req/s | 11.45 req/s | +57% |
| ... |
스레드 수를 240개에서 10개로 줄임으로써 처리량은 57% 증가했고, 평균 지연 시간 (Average latency)은 거의 4초 가까이 감소했으며, CPU 사용률은 절반으로 줄었습니다.
CPU는 더 이상 스래싱 (Thrashing)을 일으키지 않았습니다. CPU 사이클은 레지스터에 스레드를 넣었다 뺐다 하는 대신 행렬 곱셈 (Matrix multiplication)을 실행하는 데 사용되었습니다.
멀티 스레드 수학 라이브러리가 실제로 유용한 경우
이것이 OMP_NUM_THREADS=1이 항상 정답이라는 뜻은 아닙니다. 이는 전적으로 당신의 애플리케이션 동시성 모델에 기반한 설계 결정 (Design decision)입니다.
만약 오프라인 배치 작업 (offline batch job)을 실행하거나, 모델을 학습시키거나, 한 번에 하나의 작업만 처리하는 싱글 스레드 데몬 (single-threaded daemon)을 실행 중이라면, PyTorch가 사용 가능한 모든 코어를 사용하기를 원할 것입니다. 이러한 시나리오 (데이터 수준의 동시성, data-level concurrency)에서는 MKL이 행렬 계산을 병렬화하도록 두는 것이 프로세스를 가능한 한 빠르게 실행하게 만듭니다.
하지만, 웹 서버를 실행하거나 많은 독립적인 요청을 병렬로 처리하는 백그라운드 워커 (background workers)를 실행 중이라면 (요청 수준의 동시성, request-level concurrency), 각 프로세스는 정확히 1개의 스레드를 사용해야 합니다. 운영 체제 (Operating system)가 이미 프로세스 수준에서 코어 전체에 걸쳐 작업을 병렬화하고 있기 때문입니다. 내부 라이브러리 스레드를 추가하는 것은 단지 스케줄러 (scheduler)의 주의를 끌기 위해 서로 싸우게 만들 뿐입니다.
만약 동일한 서버에서 두 가지 워크로드를 모두 실행한다면, 각각 별도로 구성해야 합니다. 예를 들어, 웹 서버에는 OMP_NUM_THREADS=1을 유지하되, 배치 처리 워커에서는 코어 수만큼 확장할 수 있도록 허용하십시오.
더 넓은 공학적 교훈
스택의 모든 계층이 각자 독립적으로 최적화하려고 시도하고 있었습니다. 그들 중 어느 것도 틀리지 않았습니다. 하지만 함께했을 때, 그들은 비효율적이었습니다.
현대적인 웹 프레임워크와 백그라운드 워커는 여러 개의 격리된 프로세스나 스레드를 실행함으로써 확장하도록 설계되었습니다. 네이티브 수학 라이브러리 (Native math libraries) 또한 여러 스레드를 실행함으로써 확장하도록 설계되었습니다. 만약 이 두 계층을 조정하지 않는다면, 이들은 스케줄러의 주의를 끌기 위해 서로 싸울 것입니다.
시스템은 모든 구성 요소가 개별적으로 최적화된다고 해서 빨라지는 것이 아닙니다. 모든 구성 요소가 협력할 때 빨라집니다.
서버를 수평적으로 확장 (scale horizontally)하거나 더 큰 GPU를 구매하기 전에, 프로세스의 스레드 수를 점검하십시오. 최고의 성능 최적화가 항상 더 빠른 코드를 작성하는 것에서 오는 것은 아닙니다. 때로는 단순히 라이브러리들이 서로 싸우는 것을 멈추게 하는 것만으로도 얻을 수 있습니다.
마법은 없습니다. 단지 시스템이 있을 뿐입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기