Python Go Python FastAPI FormData 效能議題

在實作檔案上傳並加解密的服務時,遇到了 user request 數量增多,造成 lead time 大幅提升的問題。服務本身是使用 Python + FastAPI framework 實作,在排除了網路頻寬問題和 server 效能問題後,懷疑是 Python 或 framework 導致延遲時間拉長,所以就決定從此地方著手進行 benchmark 實驗,來觀察瓶頸是發生在何處。

由於最終目的還是希望能找出改善的方式,而既然認為問題是出在 Python 和 framework 上,這次實驗就會先使用 FastAPI 與 aiohttp 寫的 HTTP API service 進行效能比較;此外也使用 Golang 實作的版本來測量不同語言之間的效能落差會到多少。

While implementing a file upload service with encryption and decryption, we encountered an issue where an increase in user requests significantly increased the lead time. The service is built using Python and the FastAPI framework. After ruling out network bandwidth and server performance issues, we suspect that the delay may be due to Python or the framework. Therefore, we decided to start with a benchmarking experiment in this area to identify where the bottleneck occurs.

Since our ultimate goal is to find a way to improve performance, and we believe the issue lies with Python and the framework, this experiment will initially compare the performance of an HTTP API service written using FastAPI and aiohttp. Additionally, we will use a version implemented in Golang to measure the performance differences between languages.

📖 選擇 aiohttp 的原因:aiohttp 充分地利用 Python asyncio 特性來實現,會是不錯的參考對象。

Experiment Environment

Server

4 core & 8G memory

Libraries

Python 3.10.12

FastAPI 0.109.0

aiohttp 3.9.1

Golang 1.21

Testing Tool

https://github.com/tsenart/vegeta

Experiments

Testing scenarios

  • 100MB file 的檔案上傳
  • Sync API
  • RPS: 10/sec. Duration: 10 sec. Total requests: 100

Python FastAPI vs. aiohttp

Python 是使用 Cpython runtime ,其預設是 single thread 的 runtime 架構。其中使用 FastAPI 和 aiohttp 測量的結果如下:

FastAPI

1Requests      [total, rate, throughput]         100, 10.10, 1.99
2Duration      [total, attack, wait]             50.208s, 9.9s, 40.308s
3Latencies     [min, mean, 50, 90, 95, 99, max]  12.928s, 41.037s, 42.505s, 43.851s, 43.96s, 44.04s, 44.049s
4Bytes Out     [total, mean]                     10341963800, 103419638.00
5Success       [ratio]                           100.00%

aiohttp

1Requests      [total, rate, throughput]         100, 10.10, 3.80
2Duration      [total, attack, wait]             26.297s, 9.9s, 16.398s
3Latencies     [min, mean, 50, 90, 95, 99, max]  1.006s, 15.938s, 17.547s, 18.589s, 18.633s, 18.958s, 19.144s
4Bytes Out     [total, mean]                     10341963800, 103419638.00
5Success       [ratio]                           100.00%

從量測數據中可以看到, 以 P50 為例,aiohttp 的效能比起 FastAPI 快了約 60%,可知 FastAPI 的實作在處理 form 類型大檔案上傳的效率並不佳。拉近一點看每個 request 的 lead time 趨勢圖,FastAPI 從最初的 12 sec. 快速提升至約 40 sec. 由於 request 數量增加造成延遲時間累積,因此後續的 request 都會受到前面處理 request 的時間影響,這數據也顯示 corouitne 的數量和 CPU 使用比率偏高導致此現象。類似的現象也反應在 aiohttp 實作上,不過可以看到趨勢線較緩,能夠說明 aiohttp 較能有效地處理 parallel file uploading 的 concurrency 議題。

lead time 趨勢圖

透過 Python profiling tool (pyinstrument) 來查看具體的延遲發生點,可以看到 FastAPI 實際上會呼叫 starlette 的 MultiPartParser 來解析出 Form Data 的欄位內容,而當 Parser 在發現此欄位的內容是 file 型式時,會再呼叫 UploadFile 來讀取資料。其中大多數的 CPU 時間都花費在 await,因此當出現過多的 task (coroutine),就會造成明顯的延遲。

我們從使用不同數量的 core 中來進一步觀察。

📖 Python 環境下,如果要讓 server 能運用多個 core 來 parallel 的處理 request,需要使用 multiprocessing module。而 FastAPI 本身使用 uvicorn web server framework,因此我們可以設定 uvicorn 的 worker 參數來進行實驗。

從數據中發現,當我們增加 1 個 core 使用時,則在這樣的使用情境之下, FastAPI 處理 request 效率提升 50%,這也反應 request 使用 CPU 的比率高,如果是 I/O bound 的話,效果提升不會這麼明顯。

Summary

根據上述的觀察,重新整理一下論點:在 file upload with FormData 的 use case 下,FastAPI 傾向 CPU bound 實作而 aiohttp 則是傾向 I/O bound 實作,而數據顯示 aiohttp 使用的 asyncio 實作方式的較佳。

Python vs. Golang

考量到 Python 本身的效能限制,如果 Python 無法達到我們對於此服務的目標,那使用其他語言實作也是一個方向,因此就來比較一下效率差異。以下為實驗結果,其中 server 皆使用 2 core。

Python FastAPI

1Requests      [total, rate, throughput]         100, 10.10, 3.60
2Duration      [total, attack, wait]             27.749s, 9.901s, 17.848s
3Latencies     [min, mean, 50, 90, 95, 99, max]  1.535s, 16.763s, 18.373s, 20s, 20.156s, 20.578s, 20.628s
4Bytes Out     [total, mean]                     10341963800, 103419638.00
5Success       [ratio]                           100.00%

Golang

1Requests      [total, rate, throughput]         100, 10.10, 8.12
2Duration      [total, attack, wait]             12.308s, 9.901s, 2.408s
3Latencies     [min, mean, 50, 90, 95, 99, max]  702.25ms, 2.103s, 2.037s, 3.402s, 3.504s, 3.971s, 4.147s
4Bytes Out     [total, mean]                     10341963800, 103419638.00
5Success       [ratio]                           100.00%

毫無懸念的,Golang 的效率比起原有的 Python FastAPI 版本提高了超過 80%,除了 Golang compiled language 的優勢之外,goroutine 的設計在處理大檔案上傳的 request 上也有不錯的表現。

結語

Python FastAPI 是個很知名也很優秀的 web framework,不過在處理 file uploading with formdata 的效率並不佳。內文中比較了 Python FastAPI 與 aiohttp 的效能差異,其中 aiohttp 的實作方式顯示出較好的效能表現,提升了將近 60% 速度。另外也比較了 Golang 版本的實作,其效能有超過 80% 的改善。

閒聊

在使用 Python 開發 API server 時,遇到不少效能問題,當然其他語言也會有效能議題,不過 Python 能承受極限更低。 Python 我覺得更適合拿來寫寫自動流程 script 或是不太需要大量 parallel 執行的場景,如果是 Production 的 API service 的話,還是選擇其他語言比較好,不然就會又多費一番工了...🥹
話說這篇文章斷斷續續的也實驗了一兩個禮拜,原本 loading test 一直是使用 Grafana k6,但是它在 large-size file uploading 的測試表現真的很差,底層實作方式使用太多 memory,導致常常讓整個 process 被強制 killed,因此後來改用 vegeta 這套 loading test tool,不過 k6 支援的測試場景還是比較齊全的,除非特殊使用情境,不然 k6 還是很不錯的工具。
這次測試實驗也讓我感受到 API testing 的重要性,該去研究一下如何做好上線前的 API testing 了。