從20秒到0.5秒:一個使用Rust語言來優化Python效能的案例
導讀:Python 被很多網際網路系統廣泛使用,但在另外一方面,它也存在一些效能問題,不過 Sentry 工程師分享的在關鍵模組上用另外一門語言 Rust 來代替 Python 的情況還是比較罕見,也在 Python 圈引發了熱議,高可用架構小編將文章翻譯轉載如下。
Sentry 是一個幫助線上業務進行監控及錯誤分析的雲服務,它每月處理超過十億次錯誤。我們已經能夠擴充套件我們的大多數系統,但在過去幾個月,Python 寫的 source map 處理程式已經成為我們效能瓶頸所在。(譯者:source map 就是將壓縮或者混淆過的程式碼與原始程式碼的對應表)
從上週開始,基礎設施團隊決定調查 source map 處理程式的效能瓶頸。——我們的 Javascript 客戶端已經成為我們最受歡迎的程式,其中一個原因是我們通過 source map 反混淆 JavaScript 的能力。然而,處理操作不是沒有代價的。我們必須獲取,解壓縮,反混淆然後反向擴張,使 JavaScript 堆疊跟蹤可讀。
當我們在 4 年前編寫了原始處理流水線時,source map 生態系統才剛剛開始演化。隨著它成長為一個複雜而成熟的 source map 處理程式,我們花了很多時間用 Python 來處理問題。
截至昨天,我們通過 Rust 模組替換我們老的 Python 的 souce map 處理模組,大大減少了處理時間和我們的機器上的 CPU 利用率。
為了解釋這一切,我們需要先理解 source map 和用 Python 的缺點。
Python 的 Source Maps
隨著我們的使用者的應用程式變得越來越複雜,他們的 source map 也越來越複雜。在 Python 中解析 JSON 本身是足夠快的,因為它們只是字串而已。問題在於反序列化
將 source map token 反序列化的問題使得我們為基本 Python 物件支付巨大的成本。另外,所有這些物件都參與引用計數和垃圾收集,這進一步增加了開銷。處理 30MB source map 使得單個 Python 程序在記憶體中擴充套件到〜 800MB,執行數百萬次記憶體分配,並使垃圾收集器非常忙碌(譯者注:token 是短生命週期物件,有新生代就好多了,這時候就體現出我大 Java 的優勢了)。
由於這種反序列化需要物件頭和垃圾回收機制,我們能在 Python 層做改進的空間非常小。
Rust 的 Source Maps
在調查發現問題在於 Python 的效能缺陷後,我們決定嘗試 Rust source map 解析器的效能,這是為我們的 CLI 工具編寫的。在將 Rust 解析器應用於問題很大的 source map 之後,其表明單獨使用該庫進行解析可以將處理時間從 > 20 秒減少到 < 0.5 秒。這意味著即使忽略任何優化,只是將 Python 解析器替換為 Rust 解析器就可以緩解我們的效能瓶頸。
我們證明 Rust 確實更快後,就清理了一些 Sentry 內部 API,以便我們可以用新的庫替換原來的實現。這個 Python 庫命名為 libsourcemap,是我們自己的 Rust source map 的一個薄包裝。
優化結果
部署該庫後,專門用於 source map 處理的機器壓力大大降低。
最糟糕的 source map 處理時間減少到原來的十分之一。
更重要的是,平均處理時間減少到〜 400 ms。
JavaScript 是我們最受歡迎的專案語言,這種變化達到了將所有事件的端到端處理時間減少到〜 300 ms。
在 Python 中 嵌入 Rust
有很多方法可以暴露 Rust 庫給 Python。我們選擇將 Rust 程式碼編譯成一個 dylib,並提供一些 ol’C 函式,通過 CFFI 和 C 標頭檔案暴露給 Python。有了 C 語言標頭檔案,CFFI 生成一些 shim( shim 是一個小型的函式庫,用於透明地攔截 API 呼叫,修改傳遞的引數、自身處理操作、或把操作重定向到其他地方),可以呼叫 Rust。這樣,libsourcemap 可以開啟在執行時從 Rust 生成的動態共享庫。
這個過程有兩個步驟。第一個是在 setup.py 執行時配置 CFFI 的構建模組:
在構建模組之後,標頭檔案通過 C 前處理器來處理,以便擴充套件巨集( CFFI 本身無法執行的過程)。此外,這將告訴 CFFI 在哪裡放置生成的 shim 模組。所有完成的之後,載入模組:
下一步是編寫一些包裝器程式碼來為 Rust 物件提供一個 Python API,這樣能夠轉發異常。這發生在兩個過程中:首先,確保在 Rust 程式碼中,我們儘可能使用結果物件。此外,我們需要處理好 panic,以確保他們不會跨越 DLL 邊界。第二,我們定義了一個可以儲存錯誤資訊的幫助結構 ; 並將其作為 out 引數傳遞給可能失敗的函式。
在 Python 中,我們提供了一個上下文管理器:
我們有一個特定錯誤類( special_errors)的字典,但如果沒有找到具體的錯誤,將會拋一個通用的 SourceMapError。
從那裡,我們實際上可以定義 source map 的基類:
在 Rust 中暴露 C ABI
我們從包含一些匯出函式的 C 頭開始,如何從 Rust 匯出它們? 有兩個工具:特殊的# [no_mangle] 屬性和 std :: panic 模組 ; 提供了 Rust panic 處理器。我們自己建立了一些 helper 來處理這個:一個函式用來通知 Python 發生了一個異常和兩個異常處理 helper,一個通用的,另一個包裝了返回值。有了這個,包裝方法如下:
boxed_landingpad 的工作方式很簡單。它呼叫閉包,用 panic :: catch_unwind 捕獲 panic,解開結果,並在原始指標中加上成功值。如果發生錯誤,它會填充 err_out 並返回一個 NULL 指標。在 lsm_view_free 中,只需要從原始指標重新構建。
構建擴充套件
要實際構建擴充套件,我們必須在 setuptools 中做一些不太優雅的事情。幸運的是,在這件事上我們沒有花太多時間,因為我們已經有一個類似的工具來處理。
這個做法最方便的部分是原始碼用 cargo 編譯,二進位制安裝最終的 dylib,消除任何終端使用者使用 Rust 工具鏈的需要。
那些做得好,那些沒做好?
我在 Twitter 上被問到:“ Rust 會有什麼替代品?”說實話,Rust 很難替代。原因是,除非你想用效能更好的語言重寫整個 Python 元件,否則只能使用本機擴充套件。在這種情況下,對語言的要求是相當苛刻的:它不能有一個侵入式執行時,不能有一個 GC,並且必須支援 C ABI。現在,我認為適合的語言是 C,C++ 和 Rust。
哪方面工作的好:
- 結合 Rust 和 Python 與 CFFI。有一些替代品,連結到 libpython,但構建更復雜。
- 在老一些的 CentOS 版本使用 Docker 來構建可移植的 Linux 容器。雖然這個過程是乏味的,然而不同的 Linux 發興版和核心之間的穩定性的差異使得 Docker 和 CentOS 成為可接受的構建解決方案。
- Rust 生態系統。我們使用 crates.io 的 serde 反序列化和 base64 庫,兩個庫工作非常好。此外,mmap 支援使用由社群 memmap 提供的另一庫。
哪方面工作的不好:
- 迭代和編譯時間真的可以更好。我們每次更改字元時都編譯模組和標頭檔案。
- setuptools 步驟非常脆弱。我們可能花了更多的時間來使 setuptools 工作。幸運的是,我們以前做過一次,所以這次更容易。
雖然 Rust 對我們的工作幫助很大,毫無疑問,有很多需要改進。特別是,用於匯出 C ABI(並使其對 Python 有用)的基礎設施應該有很大改進空間。編譯時間也不是很長(譯者的話,不是很長的意思是可能夠我沏杯茶,懷念 go 的編譯速度)。希望增量編譯將有所幫助。
下一步
其實我們還有更多的改進空間。我們可以以更高效的格式啟動快取,比如一組儲存在記憶體中的結構體而不是使用解析 JSON。特別是,如果與檔案系統快取配對,我們幾乎可以完全消除載入的成本,因為我們平分了索引,這可以使用 mmap 非常有效。
鑑於這個好的結果,我們很可能會評估 Rust 更多在未來處理一些 CPU 密集型的業務。然而,對於大多數其他操作,程式花更多的時間等待 IO。
小結
雖然這個專案取得了巨大的成功,但是我們只花了很少的時間來實現。它降低了我們的處理時間,它也將幫助我們水平擴充套件。Rust 一直是這個工作的完美工具,因為它允許我們將昂貴的操作使用本地庫完成,而且不必使用 C 或 C ++(這不太適合這種複雜的任務)。雖然很容易在 Rust 中編寫 source map 解析器,但是使用 C / C++ 來完成的話,程式碼更多,且沒那麼有意思。
我們確實喜歡 Python,並且是許多 Python 開源計劃的貢獻者。雖然 Python 仍然是我們最喜歡的語言,但我們相信在合適的地方使用合適的語言。Rust 被證明是這項工作的最佳工具,我們很高興看到 Rust 和 Python 將來會帶給我們什麼。
文章出處:高可用架構