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 라이브러리 전역 상태가 복사된다.
      • 이때 이용 중인 내부 스레드 풀 상태도 그대로 가져와서 새 프로세스에서 스레드 이용시, 상태가 꼬일 수 있다.

    [데드락 발생]

    • 부모 프로세스는 자식 프로세스의 작업을 기다리는데 자식 프로세스는 부모 프로세스로부터 복사된 스레드 풀 상태(락, 조건 변수 등)에 의해 작업을 못하고 블로킹.(데드락)
  • 프로세스 간에는 메모리를 공유하지 않기 때문에 데이터 전송에서 오버 헤드가 발생한다.

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