[Gunicorn] 모델 preload 주의 사항 (프로세스 간 리소스 공유 이슈) - 100-hours-a-week/5-yeosa-wiki GitHub Wiki

1. preload 이용시, 전역 상태 주의

  • preload_app = True를 쓰기 때문에 다음을 반드시 주의해야 합니다:
항목 권장사항
모델/인퍼런스 관련 객체 app.state가 아닌 lazy load 함수 내부에서 로컬 변수로 관리
글로벌 캐시 or 공유 객체 redis 등 외부 캐시로 위임, 프로세스 간 상태 공유 금지
환경설정 or 모델 경로 마스터 프로세스에서 로딩 → 참조만 하는 방식이면 문제 없음

a. 유의사항: 모델 전역 로딩 시, 연산마다 COW 발생

# app.py
app = FastAPI()
app.state.model = torch.load("model.pt")  # preload 시 마스터에서 로딩됨 → fork 이후 충돌 가능
  • 모델을 전역에서 로딩하면 COW 문제 발생
    • 모델의 텐서는 model.eval()을 통해 self.training을 False로 바꾸기만 하더라도 드롭아웃이나 배치 정규화 등의 버퍼나 상태가 수정되어 COW 페이지 복사 유발.
  • 모델은 추론 과정에서 in-place 연산이 발생한다.
    • in-place 연산은 새로 메모리를 만들지 않고 기존 메모리를 수정하는 연산.
  • 이때, 가중치 자체를 고치는 것은 아니고 메모리 버퍼를 수정하는데 메모리 버퍼를 수정하면 해당 메모리 페이지가 자식 프로세스로 COW됨.
    • 이는 가중치와 중간 버퍼가 같은 메모리 페이지에 있기 때문에 버퍼에만 수정이 발생해도 해당 페이지 전체가 자식 프로세스에 복사되는 현상.
  • 이로 인해 preload를 통한 메모리 절약 효과가 거의 없고 첫 요청 처리에 COW가 발생해서 lazy loading 문제가 발생.

b. PyTorch 커뮤니티와 공식 문서에서도 명시

  • PyTorch 개발자들도 preload 후 fork를 피하라고 권장한다.

"If you’re using multiprocessing with fork, don’t preload models into the parent process. It may lead to unexpected memory copies due to in-place operations or lazy evaluation."

— PyTorch Discuss


2. lazy load 방안

a. lazy load란

  • lazy load: 요청이 들어오면 모델 로딩
from fastapi import Request

def get_model(request: Request):
    if not hasattr(request.app.state, "model"):
        request.app.state.model = torch.load("model.pt")  # 워커 프로세스 내에서 로딩됨
    return request.app.state.model

b. lazy load 단점

  • 위와 같이 실제 요청을 받았을때, 로딩하게 하므로써 워커 프로세스마다 따로 로딩됨.
    • 그러나 첫 요청의 처리 시간이 크게 증가하는 문제 존재. (cold start)

3. 의존성 주입 (Depends)

a. 의존성 주입이란

  • 의존성 주입도 방법이 될 수 있음.
    • Depends는 FastAPI의 의존성 주입 시스템으로, 함수에 필요한 공통 자원을 인자로 주입해준다. 이때, 해당 자원은 FastAPI가 자동으로 관리/캐시/생성
def get_model():
    model = torch.load("model.pt")
    return model

@app.post("/predict")
def predict(data: Input, model=Depends(get_model)):
    return model(data)

b. 의존성 주입 단점

  • 그러나 이는 전역 캐시가 아닌 요청 컨텍스트에서만 유지되는 캐시여서 요청마다 모델 로딩 오버헤드가 발생하는 문제는 여전함.

  • 이를 좀더 개선하는 방법은 FastAPI가 지원하는 의존성 주입을 요청 스코프가 아니라 앱 스코프로 관리하는 것

    from functools import lru_cache
    
    @lru_cache(maxsize=1)
    def get_model():
        return torch.load("model.pt")
    
    @app.post("/predict")
    def predict(..., model=Depends(get_model)):
        ...
    
  • lru_cache 어노테이션을 활용하면 초기 한 번 로딩하고 이후에는 FastAPI에서 캐시된 결과를 주입해줌. 여기서는 사이즈를 1로 설정해서 싱글톤 캐싱에 해당.


4. 더 나은 해결 방안: FastAPI 이벤트 훅 사용(Warm-up)

from fastapi import FastAPI

app = FastAPI()

@app.on_event("startup")
def load_model_at_startup():
    app.state.model = torch.load("model.pt")
  • preload 앱 환경에서도 fork() 이후 워커에서 이 함수가 실행됨
  • 워커마다 로컬 메모리에 안전하게 모델 로드

5. 더 나은 해결 방안2: —post-fork 훅 사용

  • gunicorn에서 워커가 fork된 이후 특정 코드 실행하는 옵션도 존재함.