Fast API
benchmark

2023-10-17 round 22 기준
- JSON : JSON serialization - {"message":"Hello, World!"}
- 1-query : DB에 단일 행 조회 후 JSON 응답 - {"id":3217,"randomNumber":2149}
- 20-query : DB에 복수 행 조회 후 JSON 응답 - 동시성과 관련된 조금 더 복잡한 조건이 있음
- Fortunes : DB에서 Unix fortune cookie 메시지가 포함된 모든 행(12개)을 가져와서 HTML 템플릿으로 응답
- Updates : DB에 복수 행을 업데이트 - 행을 가져와서 메모리에 올리고 객체를 수정, 동시성 등 세부 조건 있음
- Plain text : 일반 텍스트로 응답 - Hello, World
정리
- fastAPI가 JSON 반환 시에는 spring이나 기타 python 서버, nodejs express, nodejs보다 성능이 높다.
- spring webflux와 같은 spring async framework와의 비교도 필요할듯..?
- 하지만 golang에 비해서는 아직 성능 차이가 크다.
- 복잡한 db 조회가 필요한 경우 spring이 압도적
fastAPI 구조
- fastAPI는 ASGI framework인 starlette을 한번 wrapping한 ASGI framework
- startlette과 함께 pydantic을 함께 wrapping해 사용성을 증가시킴
- starlette은 내부적으로 uvicorn ASGI Server를 사용
- uvicorn은 내부적으로 uvloop와 httptools를 사용해 속도를 향상시킴
- uvloop은 node.js의 비동기IO에 사용되는 libuv를 기반으로 cython으로 작성된 event loop
[웹 프레임워크] FastAPI - starlette - [웹 서버] Uvicorn - uvloop - libuv


💡 pip install uvicorn으로 uvicorn을 설치하게 되면 uvloop이 같이 설치되지 않는다.
즉, event loop로 기본 asyncio의 event loop를 사용
- uvloop의 성능이 asynio보다 2~3x 빠르다고 한다.
- pip install uvicorn[standard]로 설치해야 uvloop도 포함되어 설치 </aside>
FastAPI concurrency/parallelism
기본적으로 FastAPI는 concurrency를 제공
- coroutine/event loop 등을 이용해 특정 작업이 I/O wait를 하게 되는 경우 다른 작업을 처리한 뒤 돌아올 수 있다.
- **async/await** 키워드를 통해 concurrency를 쉽게 구현할 수 있다.
# await를 사용하려면 해당 함수에 **async** 명령어를 붙여야 한다.
async def get_burgers(number: int):
# Do some asynchronous stuff to create the burgers
return burgers
---
# 호출 내부에서 await하는 부분이 있는 경우에 **async** 명령어로 **path operation functions**을 정의
@app.get('/burgers')
**async** def read_burgers():
# get_burgers 함수가 끝날 때까지 해당 작업을 기다려야 한다는 의미
burgers = await get_burgers(2)
return burgers
위와 같이 작성하게 되면 여러 요청이 한번에 들어오더라도 하나의 요청에서 get_burgers에서 계속 대기하는 것이 아닌 다른 요청으로 넘어가서 처리할 수 있다.(single process 기준)
concurrency의 단점
- 대부분의 작업이 CPU bounded 작업인 경우에는 concurrency의 효과가 거의 없다.
- cpu bounded 작업이 많은 경우 **parallelism**이 필요
import asyncio
import time
from fastapi import FastAPI
app = FastAPI()
# cpu bounded job
# ex) model prediction
def cpu_bounded():
q = []
for i in range(10_000_000):
q.append(i)
# I/O bounded job
async def io_bounded():
await asyncio.sleep(0.01)
@app.get("/")
async def entrypoint():
ts = time.time()
cpu_bounded()
print(f"cpu bounded : {int((time.time() - ts) * 1000)}ms")
ts = time.time()
await io_bounded()
print(f"i/o bounded : {int((time.time() - ts) * 1000)}ms")
**output**
cpu bounded : 343ms
cpu bounded : 334ms
cpu bounded : 336ms
cpu bounded : 322ms
**i/o bounded : 994ms
i/o bounded : 659ms
i/o bounded : 322ms**
cpu bounded : 328ms
cpu bounded : 331ms
cpu bounded : 321ms
cpu bounded : 326ms
cpu bounded : 341ms
cpu bounded : 337ms
**i/o bounded : 1989ms
i/o bounded : 1660ms
i/o bounded : 1328ms
i/o bounded : 1007ms
i/o bounded : 680ms
i/o bounded : 338ms
i/o bounded : 10ms**
- 여러 요청에 대해 한번에 들어오는 경우 I/O bounded job는 concurrency 처리가 될 수 있다.
- 하지만 cpu bounded job은 계속 process를 점유하고 있기 때문에 I/O bound job에서 오랜 시간이 걸리게 된다.

concurrency + parallelism : Gunicorn with Uvicorn
**Gunicorn with Uvicorn Worker**를 사용해서 parallelism도 동시에 사용할 수 있다.
- Gunicorn : worker process를 관리하는 process manager
- WSGI(Web Server Gateway Interface) 표준 application server
- fastAPI는 ASGI(Asynchronus Server Gateway Interface) 표준을 사용하기 때문에 Gunicorn과 호환되지 않는다.
- Gunicorn은 여러 uvicorn 서버를 관리하고, 요청이 들어온 traffic을 uvicorn으로 전달해주는 역할
- Uvicorn은 ASGI이면서 Gunicorn의 호환 가능한 worker class이므로 가능
gunicorn main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:80
- Uvicorn으로도 여러 worker를 띄울 수 있지만 Gunicorn에 비해 worker process를 handling하는데에 많은 제약이 존재
- 따라서 Gunicorn을 사용하는 것을 권장
uvicorn main:app --host 0.0.0.0 --port 8080 --workers 4
다음 명령어를 통해 gunicorn으로 실행해 workers의 개수를 증가시켜서 수행
gunicorn main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker
**output**
cpu bounded : 341ms
i/o bounded : 11ms
cpu bounded : 346ms
i/o bounded : 11ms
cpu bounded : 341ms
i/o bounded : 11ms
cpu bounded : 336ms
i/o bounded : 11ms
cpu bounded : 339ms
i/o bounded : 11ms
cpu bounded : 340ms
i/o bounded : 10ms
cpu bounded : 341ms
i/o bounded : 11ms
cpu bounded : 337ms
i/o bounded : 11ms
cpu bounded : 337ms
i/o bounded : 11ms
cpu bounded : 333ms
i/o bounded : 11ms
def/async def Path operation functions
fastAPI에서는 def를 사용하면 외부 쓰레드풀에서 해당 코드를 실행
- 다른 async fw에서 cpu bound로만 이루어진 함수에 대해 def path operation function을 사용해 약간의 성능 향상을 하는 경우가 존재
- fast API에서는 def가 외부 쓰레드풀을 사용하면서 오히려 성능 저하가 발생할 수 있다.
- 따라서 path operation functions 내에 blocking I/O가 존재하는게 아닌 이상 **async def**를 사용하는 것이 좋다
def에서 사용하는 외부 쓰레드풀은 AnyIO의 thread pool로 기본 thread pool은 40으로 설정되어 있다.
gunicorn with uvicorn의 threads 개수
현재까지의 내용을 정리해봤을 때,
- def path operation에서는 사전에 생성된 AnyIO threadpool에서 동작을 수행
- async def path operation에서는 mainThread + event loop 기반으로 동작을 수행
또한 uvicorn에서는 기본적으로 worker의 threads를 직접적으로 생성할 수 없다.
- AnyIO threadpool의 수를 인위적으로 조정할 수는 있음
하지만 gunicorn에서는 workers의 thread개수를 조정할 수 있다.
- gunicorn의 threads 개수는 각 workers가 생성될 때 threadpool의 개수
- gunicorn은 WSGI 기반이므로, workers들이 여러 쓰레드를 갖고 요청을 처리할 수 있게 한다.
- 각 workers의 threads 개수가 1개 보다 많은 경우 기본적으로 gthread worker type을 사용
- gunicorn에서는 workers에 IO작업이 많은 경우 해당 threads의 개수를 조정함으로써 성능을 향상시킬 수 있다.
<aside> 💡 gunicorn with uvicorn을 사용할 때, gunicorn을 사용해 uvicorn의 thread 개수를 조정하면 안 된다.
- 오히려 성능이 저하되고 최악의 경우에는 context에 안전하지 않은 코드가 있는 경우 문제가 발생할 수 있다.
- 참고 : https://github.com/tiangolo/fastapi/issues/551#issuecomment-584308118 </aside>
성능테스트
def + cpu bound
[SpawnProcess-4] [AnyIO worker thread(active : 2)] def entrypoint : 845ms
[SpawnProcess-4] [AnyIO worker thread(active : 4)] def entrypoint : 1780ms
[SpawnProcess-4] [AnyIO worker thread(active : 6)] def entrypoint : 1826ms
[SpawnProcess-4] [AnyIO worker thread(active : 3)] def entrypoint : 2319ms
[SpawnProcess-4] [AnyIO worker thread(active : 5)] def entrypoint : 2435ms
[SpawnProcess-4] [AnyIO worker thread(active : 7)] def entrypoint : 2185ms
[SpawnProcess-4] [AnyIO worker thread(active : 8)] def entrypoint : 2306ms
[SpawnProcess-4] [AnyIO worker thread(active : 8)] def entrypoint : 2132ms
[SpawnProcess-4] [AnyIO worker thread(active : 10)] def entrypoint : 1892ms
[SpawnProcess-4] [AnyIO worker thread(active : 10)] def entrypoint : 1767ms
- client-side avg time : 3238ms
- cpu bound job인 상태에서 thread의 context switching 비용 때문에 더 많은 시간이 소요
async def + cpu bound
[SpawnProcess-1] [MainThread(active : 1, task : 3)] async def entrypoint : 360ms
[SpawnProcess-1] [MainThread(active : 1, task : 5)] async def entrypoint : 340ms
[SpawnProcess-1] [MainThread(active : 1, task : 4)] async def entrypoint : 341ms
[SpawnProcess-1] [MainThread(active : 1, task : 3)] async def entrypoint : 341ms
[SpawnProcess-1] [MainThread(active : 1, task : 8)] async def entrypoint : 331ms
[SpawnProcess-1] [MainThread(active : 1, task : 7)] async def entrypoint : 333ms
[SpawnProcess-1] [MainThread(active : 1, task : 6)] async def entrypoint : 334ms
[SpawnProcess-1] [MainThread(active : 1, task : 5)] async def entrypoint : 330ms
[SpawnProcess-1] [MainThread(active : 1, task : 4)] async def entrypoint : 332ms
[SpawnProcess-1] [MainThread(active : 1, task : 3)] async def entrypoint : 326ms
- client-side avg time(asyncio event loop) : 3004ms
- 내부에서는 event loop의 task queue에 쌓여있었기 때문에 서버 내부에서의 실행시간은 낮게 나왔지만 client-side의 소요시간은 비슷하게 나왔다.
def + blocking IO
[SpawnProcess-1] [AnyIO worker thread(active : 2)] def entrypoint with blocking io : 1003ms
[SpawnProcess-1] [AnyIO worker thread(active : 3)] def entrypoint with blocking io : 1004ms
[SpawnProcess-1] [AnyIO worker thread(active : 4)] def entrypoint with blocking io : 1007ms
[SpawnProcess-1] [AnyIO worker thread(active : 5)] def entrypoint with blocking io : 1004ms
[SpawnProcess-1] [AnyIO worker thread(active : 6)] def entrypoint with blocking io : 1004ms
[SpawnProcess-1] [AnyIO worker thread(active : 7)] def entrypoint with blocking io : 1003ms
[SpawnProcess-1] [AnyIO worker thread(active : 8)] def entrypoint with blocking io : 1005ms
[SpawnProcess-1] [AnyIO worker thread(active : 9)] def entrypoint with blocking io : 1003ms
[SpawnProcess-1] [AnyIO worker thread(active : 10)] def entrypoint with blocking io : 1005ms
[SpawnProcess-1] [AnyIO worker thread(active : 11)] def entrypoint with blocking io : 1008ms
- client-side avg time(asyncio event loop) : 1011ms
- blocking IO가 발생하는 경우에는 각 thread별로 blocking이 되기 때문에 client-side에서도 동일하게 1s 정도의 대기가 발생
async def + blocking IO
[SpawnProcess-1] [MainThread(active : 1, task : 3)] async def entrypoint with blocking io : 1005ms
[SpawnProcess-1] [MainThread(active : 1, task : 11)] async def entrypoint with blocking io : 1001ms
[SpawnProcess-1] [MainThread(active : 1, task : 10)] async def entrypoint with blocking io : 1003ms
[SpawnProcess-1] [MainThread(active : 1, task : 9)] async def entrypoint with blocking io : 1000ms
[SpawnProcess-1] [MainThread(active : 1, task : 8)] async def entrypoint with blocking io : 1003ms
[SpawnProcess-1] [MainThread(active : 1, task : 7)] async def entrypoint with blocking io : 1004ms
[SpawnProcess-1] [MainThread(active : 1, task : 6)] async def entrypoint with blocking io : 1002ms
[SpawnProcess-1] [MainThread(active : 1, task : 5)] async def entrypoint with blocking io : 1005ms
[SpawnProcess-1] [MainThread(active : 1, task : 4)] async def entrypoint with blocking io : 1004ms
[SpawnProcess-1] [MainThread(active : 1, task : 3)] async def entrypoint with blocking io : 1004ms
- client-side avg time : 5073ms
- blocking IO가 발생하는 경우 mainThread의 event loop에서 blocking이 걸리기 때문에 task queue에 작업이 쌓이게 된다.
- 따라서 서버 내부에서의 실행 시간은 낮게 나왔지만 client-side에서는 시간이 매우 오래 걸리게 된다.
결론
- cpu bound job이 대부분인 경우 async def와 def 간의 큰 성능 차이는 없지만 def의 경우 context switching 비용이 발생
- → async def 사용
- blocking IO job이 대부분인 경우 def가 훨씬 좋은 성능을 보여줌
- workers의 개수를 늘려도 blocking IO가 존재하면 연속으로 받을 수 있는 요청의 개수가 workers의 개수로 제한된다.
- threads의 개수를 늘려도 결국 mainThread에서 실행되기 때문에 큰 성능 향상은 없다.
참고
Server Workers - Gunicorn with Uvicorn - FastAPI
Concurrency and async / await - FastAPI
How to Optimize FastAPI for ML Model Serving
ThreadPoolExecutor vs ProcessPoolExecutor in Python - Super Fast Python
[fastapi] uvicorn, fastapi 비동기 메커니즘 이해
FastAPI 톺아보기 - 부제: python 백엔드 봄은 온다
FastAPI는 왜 fast한가요? - FastAPI 개발자에게 물어봤습니다.
'Python(파이썬)' 카테고리의 다른 글
| python gunicorn worker (1) | 2023.12.06 |
|---|---|
| Python Fast api advanced setting (0) | 2023.12.06 |
| python event loop (0) | 2023.12.06 |
| python Global Interpreter Lock(GIL) (1) | 2023.12.06 |