Python 성능 최적화(feat. HYPERCONNECT Tech Blog) - 100-hours-a-week/5-yeosa-wiki GitHub Wiki
참고글 : https://hyperconnect.github.io/2023/05/30/Python-Performance-Tips.html
1. 불필요한 import 제거하기
a. 문제점
import math
from math import sqrt
-
위와 같이 import를 하게 되면 모듈이 로딩되어 메모리 사용량이 늘어남.
-
from math import sqrt를 하면 math 모듈 전체가 로딩되는 것은 동일하나, 로컬 네임스페이스에 math와 sqrt가 모두 등록됨
import math print(dir()) # 현재 네임스페이스에 뭐가 있나? # ['__builtins__', 'math'] from math import sqrt print(dir()) # ['__builtins__', 'sqrt']
-
tensorflow처럼 큰 모듈들을 불필요하게 import하면 메모리 사용량의 낭비가 있을 수 있다.
b. 대안
가. Lazy import
def some_heavy_computation():
import numpy as np # 이 시점까지 로딩을 미룸
...
- 초기 로딩 시간을 줄일 수 있으나, 우리 서비스의 경우, 차라리 초기에 로딩을 하여 이후 서빙 속도를 빠르게 하는 편이 낫다.
나. importlib
def load_backend(name):
return importlib.import_module(name)
backend = load_backend("torch" if use_gpu else "tensorflow")
- 조건에 따라 필요한 상황이 될 때 로딩하도록 할 수 있다.
c. 적용 방안
- 대안 1, 2 모두 메모리 절약보다는 타이밍 최적화에 가깝다.
- 우리 서비스에서는 초기 로딩에 시간이 더 걸리는 편이 낫다. 필요할 때 로딩부터 하면 ux에 좋지 않다.
- 다만 불필요한 모듈을 import하지 않음으로써 메모리 낭비를 줄이는 방안이 적절하다.
2. GC 발동 조건 튜닝하기
a. GC는 언제 사용되는가?
-
파이썬에서는 객체의 메모리 해제를 위해 GC를 이용한다.
- 기본적으로 reference counting이 0이 되면 메모리에서 삭제하고, 추가로 reference cycle이 있는 객체의 메모리 회수를 위해 GC를 수행
-
GC는 모든 객체에 대해 reference의 graph를 그리며, 접근 불가능한 cycle을 찾기에 느리다. GC를 튜닝하여 GC를 활용한 메모리 해제를 하면서도 시간이 오래 걸리는 GC는 최소화할 수 있다.
import gc gc.get_threshold() # (700, 10, 10)
-
객체는 gen0, gen1, gen2에 포함된다.
- 3개로 분리되는 이유는 계속 참조되는 객체는 GC가 검토하지 않게 하기 위해서!
- GC가 불렸지만 해제되지 않은 객체는 ‘지속되는 객체’라고 판단하여 gen0 → gen1로 이동
- 다시 GC가 불렸지만 해제되지 않으면 gen1 → gen2로 이동.
-
결국 gen0 → gen1 → gen2로 갈수록 GC에 의해 해제되지 않은 지속성 있는 객체들이 위치
- 이 객체들은 이후에도 해제될 가능성이 적다고 판단하여 덜 호출된다.
-
700, 10, 10은 각각 threshold 0, 1, 2
- threshold0은 ‘객체 생성 수-객체 해제 수’가 700을 넘으면 gen0에 대해 GC 호출
- gen0 GC가 10회 호출되면 gen1 GC가 호출되고, gen1의 GC가 10번 호출
- long_lived_pending(gen2 중 재검사 필요한 객체 수) / long_lived_total(gen2의 모든 객체 수)이 25%를 넘으면 gen2 GC가 호출
b. 적용 방안
- gen2 GC가 발생하면 요청 처리 지연 시간이 길어질 수 있다. 그렇기에 gc.set_threshold(700, 0, 99999999)과 같이 gen2 GC가 불리지 않게 할 수 있다.
- 서버 로딩 후, warnup()으로 서버의 테스크들을 실행한다. 그리고 gc.freeze()를 호출하여 GC에 의해 해제되지 않을 객체들을 설정한다.
- warmup이 필요한 이유 : gc.freeze()는 gen2에 있는 객체들을 해제한다. 그런데 서버를 띄우고 바로 호출하면 모든 객체가 reference count가 0이라 GC까지 가지도 않고 바로 해제될 수 있다.(위치도 gen0에 있다)
3. 배열을 numpy ndarray로 구현하기
a. Python List vs Numpy ndarray
- list는 값이 아닌 “포인터의 배열”. 즉, 배열에 각 값을 가리키는 포인터가 저장되어 있는 것
- 값은 메모리에 연속적이지 않게 저장되어 있다.
- 그래서 배열의 값을 연속적으로 이용하려 할 때, 캐시 locality가 낮고 캐시 미스가 자주 발생한다.
- array와 numpy는 배열에 “값”이 저장되어 있다. 그리고 연속된 메모리 상에 저장된다.
- 그러나 python 연산(sum 등)을 이용하는 경우, 값(primitive value)이 아니라 “python 객체”를 이용
- 이 때문에 값 → 파이썬 객체로 변환하는 과정이 필요해 지연 시간이 오히려 늘게 된다.
- Numpy 배열을 np.sum과 같이 넘파이 연산을 하면 지연 시간을 단축할 수 있다.
- numpy는 c로 구현되어 있어서 연산을 위해 python 객체로 변환할 필요가 없기도 하고 c 구현이라 속도가 빠르다.
- C는 왜 파이썬보다 속도가 빠를까?
- 파이썬은 인터프리터 언어로 실행 시, 한 줄씩 읽으며 실행하기에 느리다. (런타임에 한 줄씩 읽으며 타입 검사)
- C는 컴파일러 언어로, 미리 전체를 머신코드로 변환하여 빠르다. (컴파일 타임에 타입 고정)
b. 적용 방안
- 가능하다면 배열은 np.ndarray를 사용하고 연산은 넘파이에서 제공하는 연산을 이용한다.
- 넘파이 배열을 이용하는데 연산은 파이썬 연산을 이용하면 오히려 느리다!
4. 배열의 직렬화 및 역직렬화
a. 요약
- 직렬화, 역직렬화 패키지로 유명한 marshal을 이용할 때, list는 데이터가 메모리 상에 여기저기 흩어져 있어 직렬화/역직렬화에 걸리는 오버헤드가 크다(데이터를 메모리에서 수집)
- array, ndarray는 연속 메모리를 이용하므로 직렬화/역직렬화에서 속도 상 이점을 가질 수 있다.
b. 적용 방안
- cpu 서버와 gpu 서버 간 이미지 전송에서 payload에 이미지를 넣기 위해 바이너리로 변환(직렬화) 및 수신한 서버에서 역직렬화 과정이 발생할 수 있다.
- 이때, 이미지를 list가 아닌 array, ndarray로 가지고 있었으면 직렬화/역직렬화에서 지연 시간을 줄일 수 있다.
- 만약 하나의 서버 안에서 프로세스 분리를 하는 경우에도 동일한 효과를 얻을 수 있다.
5. 멀티 프로세스 주의
a. 요약
-
멀티 프로세스 이용은 주의해야 한다.
- 코드 실행 중간에 프로세스를 생성하게 되면 spawn 방식의 경우 오버헤드가 크다(프로세스를 0부터 만든다).
- fork 방식의 경우 각 프로세스마다 스레드가 너무 많을 수 있고 리소스가 데드락에 걸리는 문제가 발생할 수 있다(부모 프로세스를 복제하여 자식 프로세스를 만든다).
-
특히 주의해야 할 부분: pytorch/numpy 스레드 풀 활용 & 데드락 발생
[pytorch/numpy 스레드 풀 활용]
- fork() 시 메모리 상태가 복사되어 pytorch/numpy 내부 C 라이브러리 전역 상태가 복사된다.
- 이때 이용 중인 내부 스레드 풀 상태도 그대로 가져와서 새 프로세스에서 스레드 이용시, 상태가 꼬일 수 있다.
[데드락 발생]
- 부모 프로세스는 자식 프로세스의 작업을 기다리는데 자식 프로세스는 부모 프로세스로부터 복사된 스레드 풀 상태(락, 조건 변수 등)에 의해 작업을 못하고 블로킹.(데드락)
- fork() 시 메모리 상태가 복사되어 pytorch/numpy 내부 C 라이브러리 전역 상태가 복사된다.
-
프로세스 간에는 메모리를 공유하지 않기 때문에 데이터 전송에서 오버 헤드가 발생한다.
b. 적용 방안
- 우리 서비스는 멀티 프로세스 구현 시, 데이터 전송 오버헤드가 클 것(이미지이기 때문)
- GIL 제약 없는 작업을 위해 멀티 프로세스를 해야 한다면 전송 오버헤드를 줄이도록 노력할 것
- 멀티 프로세스는 서버 실행 시, 미리 띄워서 오버헤드 줄이고 리소스 복제 꼬이는 것 막을 것
- pytorch/numpy에서 스레드풀 활용으로 GIL 영향 없이 프로세스의 각 스레드가 vCPU를 두고 경합 벌이게 된다.
- ”프로세스 수를 제한”하거나 “라이브러리의 활용 스레드 수를 제한”할 것
6. pytorch/numpy 활용 시, 멀티스레드/멀티 프로세스 주의
a. 요약
- pytorch/numpy는 멀티 스레드를 활용하고 디폴트로 이용하는 스레드 수는 차이가 조금 있지만 보통 4개다.
- 4개의 멀티 스레드에서 각 스레드가 해당 라이브러리를 사용하면 각각 워커 스레드 4개씩을 이용하게 되고, 4개의 워커 스레드에서 C 코드로 인해 GIL이 풀려서 동시에 실행되게 된다.
- 결국 16개의 스레드가 경합을 벌이게 되고 빈번한 context switching으로 오버 헤드가 커진다.
- 멀티 프로세스에서도 동일한 문제가 발생하며, 실행 도중에 프로세스를 fork로 늘린 경우에는 부모 프로세스의 스레드 풀 상태 복사로 인해 자식 프로세스에서 스레드 풀이 꼬일 수 있다.
b. 적용 방안
- 우리 서비스에서 pytorch/numpy를 이용하는 테스크가 있다.
- 해당 테스크들은 모두 run_in_executor로 여러 스레드를 활용하도록 구성했다.
- 그러면 ‘이용하도록 한 스레드 * 4’로 너무 많은 스레드를 이용해서 경합이 심하므로, torch.set_num_threads를 통해 1개의 스레드만 사용하도록 수정하였다.
7. 멀티 프로세스 전략: gunicorn으로 동일 프로세스 여러 개 vs 작업이 다른 프로세스들
a. Gunicorn으로 동일 프로세스 여러 개 실행
가. 개요
- 주로 웹 요청 처리용 웹 서버(FastAPI, Flask 등)를 위한 방식
- Gunicorn이 멀티 워커 (worker) 프로세스를 띄움
- 각 워커는 동일한 코드를 실행, 독립된 프로세스
- 일반적으로 CPU 수에 맞게 병렬 처리
gunicorn -w 4 app.main:app --bind 0.0.0.0:8000
나. 장점
항목 | 설명 |
---|---|
병렬 처리 | GIL의 영향을 피하고, 멀티코어 활용 |
Fault Isolation | 한 워커 다운돼도 나머지는 정상 |
설정 편리 | gunicorn으로 워커 수 조절만 하면 됨 |
코드 공유 | 모든 워커가 동일 코드, 유지보수 용이 |
다. 단점
항목 | 설명 |
---|---|
작업 분리가 불가 | 워커 간 역할 구분이 어려움 |
상태 공유 불가 | 프로세스 간 메모리 공유가 되지 않음 |
리소스 조절 어려움 | 워커마다 고정된 자원 사용 (모델 로딩 등 중복 메모리 낭비 가능) |
b. 서로 다른 작업을 수행하는 개별 프로세스들
가. 개요
- 각 프로세스가 전혀 다른 역할을 수행
- 예:
api_server.py
,model_worker.py
,scheduler.py
- 예:
- 보통 마이크로서비스 또는 분리된 파이프라인 단위 처리에 사용
- FastAPI 앱도 있지만, 일반 Python script일 수도 있음
나. 장점
항목 | 설명 |
---|---|
역할 분리 | 모델 서버, API 서버, 백그라운드 처리를 분리 가능 |
리소스 맞춤 조절 | 모델 서버만 GPU, API 서버는 CPU 등 구체적 리소스 조절 가능 |
확장성 | 작업 단위로 독립 배포/스케일링 가능 |
장애 격리 | 특정 작업 죽어도 전체 영향 없음 |
다. 단점
항목 | 설명 |
---|---|
구성 복잡도 증가 | supervisor, systemd, Docker 등 별도 관리 체계 필요 |
통신 필요 | 프로세스 간 RPC, HTTP, gRPC 등으로 통신해야 함 |
중복 코드 증가 | 동일한 유틸 코드가 여러 프로세스에 중복될 수 있음 |
c. 선택 기준
기준 | 권장 전략 |
---|---|
단일 API 서버를 효율적으로 병렬 처리하고 싶음 | ✅ Gunicorn 워커 여러 개 |
서로 다른 기능/작업을 분리해서 운영하고 싶음 | ✅ 작업별 개별 프로세스 |
모델마다 메모리 많이 차지하고, 독립 관리하고 싶음 | ✅ 개별 프로세스로 분리 |
리버스 프록시(Nginx)로 하나의 API처럼 보이게 하고 싶음 | 두 전략 조합 가능 |
배포/모니터링 단순화가 중요함 | Gunicorn 쪽이 쉬움 |
d. 예시 시나리오
가. Gunicorn 다중 워커 구조 (단일 API 서버, 대량 요청 대응)
┌────────────┐
│ Nginx │
└────┬───────┘
↓
┌───┴────┐
│Gunicorn│
└┬──┬──┬─┘
↓ ↓ ↓
API API API (FastAPI 인스턴스 4개)
나. 역할 분리 구조 (작업 종류별 프로세스 분리)
[client]
↓
[Nginx]
↓
┌────────────┬────────────┬────────────┐
│ API 서버 │ 모델 서버 │ 백그라운드 워커 │
│ (FastAPI) │ (GPU 사용) │ (Celery 등) │
└────────────┴────────────┴────────────┘
e. 결론
전략 | 핵심 목적 |
---|---|
Gunicorn 다중 워커 | 동일한 API 서버를 병렬 처리하여 Throughput 향상 |
역할 기반 분리 프로세스 | 기능 또는 리소스 단위로 서버 구성하고 독립 운영 |
- 실무에서는 이 두 전략을 병행하는 경우도 많음
- 예 : FastAPI API 서버는 Gunicorn으로 병렬 처리, 모델 서버는 GPU 별도 프로세스로 분리.
f. 워커 수 설정
가. "워커 1개 vs 스레드 여러 개"가 CPU를 어떻게 쓰는가?
구성 | GIL 영향 | CPU 사용 효율 |
---|---|---|
워커 1개, 스레드 1개 | ✅ GIL 있음 | ❌ 싱글코어만 사용 |
워커 1개, 스레드 N개 | ✅ GIL 있음 | ❌ CPU 코어 여러 개 있어도 1개만 실질 사용 |
워커 N개 (N = 코어 수) | ✅ 워커별 독립 | ✅ 멀티코어 활용 가능 |
- 이유: GIL(Global Interpreter Lock)
- CPython에서는 GIL 때문에 한 프로세스 안에서는 하나의 Python 스레드만 실행 가능
- Python 스레드를 100개 파도, 동시에 바이트코드를 실행하는 건 단 하나뿐
- 예외: GIL을 해제하는 연산
- NumPy, PyTorch 같은 C 확장 모듈은 GIL을 잠시 해제하고 내부적으로 멀티스레딩을 사용할 수 있음
- 이 경우 한 워커 안에서도 멀티코어 일부 활용이 가능하지만:
- GIL을 잠시 벗어나는 동안만 해당됨
- 대부분의 로직이 Python 바이트코드 기반이라면 여전히 GIL에 묶임
다. 결론
- 워커 수 1개는 곧 CPU 1개만 실질적으로 사용하는 것
- 스레드를 늘려도 GIL 때문에 병렬성은 거의 없음멀티코어 CPU를 활용하려면 워커 수를 늘려야 함
- 즉, FastAPI/Gunicorn/Uvicorn 환경에서 CPU 병렬성 확보는 반드시 "프로세스 수"로 해야함
g. Gunicorn 관련 실무 팁
-
Gunicorn은 기본적으로 멀티프로세싱 기반 병렬화 전략
-
워커 수가 1이면, 본질적으로 병렬 처리 불가하고, 스레드 수를 늘려도 큰 효과 없음 (GIL 때문에)
-
실무에서는 코어 수만큼 워커를 설정하는 것이 기본
gunicorn -w 4 -k uvicorn.workers.UvicornWorker app:app
-
권장 워커 수 = CPU 코어 수 * 2 + 1 (일반적인 가이드)
7. pydantic 최소화 및 pydantic v2 이용
a. 개요
-
바닐라 클래스는 객체에 값을 할당할 뿐, 타입 체크, 검증, 변환은 하지 않는다.
-
pydantic은 타입 검사, 자동 변환, 유효성 검증 등을 한다.
→ fastAPI는 입력 검증에서 pydantic을 핵심 엔진으로 사용한다.
- 즉, 요청을 받으면 request.json()을 자동으로 pydantic으로 파싱 및 검증해준다.
-
그래서 api 입출력은 pydantic을 이용하고 내부 로직에서는 dataclass나 NamedTuple, 바닐라 클래스를 이용하는게 적절하다.
-
pydantic v2에서는 rust로 컴파일된 파서를 이용해서 속도가 훨씬 빠르고 메모리 사용량도 대폭 감소했다.
b. 적용 방안
- 서비스에서 api 입출력 외에 pydantic을 이용하는 부분이 있다면 dataclass, NamedTuple, 바닐라 클래스를 테스트해보고 최적의 방안으로 대체한다.
- api 입출력 속도를 높이기 위해 pydantic v2로 마이그레이션한다. 이를 위해서는 fastapi 버전을 올려야 할 수 있다. 추가적으로 기존과 호환 안 되는 코드들 리팩토링도 필요할 수 있다.
8. Pandas DataFrame 사용 자제
a. 요약
- 데이터 프레임은 열별로 ndarray를 가지기 때문에 생성에 많은 시간이 소요된다.
- 그리고 행 단위 연산이 많을 경우에도 여러 ndarray를 이용해야 해서 시간이 많이 소요된다.
- 그러나 열단위 연산은 빠르기 때문에 데이터 프레임 생성에 생기는 오버헤드보다 열 단위 연산으로 인한 시간 단축 이득이 클 때 사용하는 게 적절하다.
b. 적용 방안
- 기억하기로는 판다스를 이용하는 부분이 현재까지는 없는데, 추후 도입하게 된다면 열 단위 연산이 많은 부분인 경우에만 생성 오버헤드를 상쇄할 만큼의 시간적 이득이 발생하는지 테스트하고 도입 결정하기
- 이외의 경우에는 python primitive를 이용하거나 넘파이 이용하기 (가장 추천하는 조합은 넘파이 배열 + 넘파이 연산)
9. json 대신 orjson or ujson와 같으 서드파티 패키지 이용하기
a. 요약
- orjson과 ujson의 핵심 로직이 rust나 c로 구현되어 있고 최적화가 잘 되어 있어 빠르다.
b. 적용 방안
- json 문자열 요청을 받아서 파싱할 때, json 모듈 말고 orjson 또는 ujson을 활용하기
- 백엔드에서 요청을 받을 때와 gpu 서버에서 요청을 받을 때 모두 적용 가능하다.
10. low latency가 매우 중요하다면 클래스 대신 dict를 사용하자.
a. 요약
- dict는 단순한 C 기반 해시 테이블이라 속도가 훨씬 빠르다. 그러나 명확한 타입/구조를 표현하는데에는 클래스나 적합하다.
b. 적용 방안
- 구조 및 유지 보수가 중요하지 않은 부분이 있다면 선택적으로 class → dict 변환도 가능할 듯 하다.
11. 메모리 이용량을 줄이기 위해 클래스에 slot 설정하기
a. 요약
- 클래스는 기본적으로 동적 해시 테이블인 __dict__를 가진다. 그러나 __slot__을 설정하면 동적 해시 테이블이 존재하지 않는다. 이로 인해 메모리 이용량 제한이 가능하다
b. 적용 방안
- 우리 서비스에서 클래스를 이용하는 부분이 많다 __slot__을 적용함으로써 메모리 이용량을 줄이자.
12. 파이썬 3.11은 클래스 생성 속도가 빠르다
a. 요약
- 3.10에 비해 class와 dataclass의 생성 속도가 1.7배 가량 빨라졌다.
b. 적용 방안
- 3.11로의 마이그레이션은 현재 우리 서비스에서 이용하는 라이브러리 중 지원하지 않는 라이브러리들이 있을 가능성이 높아 현실적으로 쉽지 않을 것 같다.
13. 그 외
a. 병목 식별 툴: line profiler
$ pip install line_profiler
@profile
def slow_function(a, b, c):
...
$ kernprof -lv main.py
-
포스트맨으로 실행-서버 실행 환경
from fastapi import FastAPI import numpy as np from line_profiler import LineProfiler app = FastAPI() def heavy_func(n): a = np.random.random((n, n)) b = np.random.random((n, n)) return np.sum(a + b) profiler = LineProfiler() wrapped_heavy_func = profiler(heavy_func) @app.get("/api") async def api(): result = wrapped_heavy_func(100) profiler.print_stats() return result