[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된 이후 특정 코드 실행하는 옵션도 존재함.