ROG:高性能 Go 實現
本文根據字節跳動服務框架團隊研發工程師在 CloudWeGo 技術沙龍暨三週年慶典中演講內容《ROG——高性能 Go 實現》整理。
作者|不願意透露姓名的小劉市民
ROG 之緣起
ROG 的誕生是因爲我們一部分業務使用 Rust 重寫之後,獲得了非常好的收益,比如 AVG、CPU、MEM、P99,這些數據表現非常好,大約節省了接近 50% 的 CPU,內存大大降低。這個性能數據讓人眼紅,因此團隊考慮既然 Rust 有這麼好的性能,我們有沒有辦法提升一下用戶在 Go 上的性能?
在和一些用戶的對接中我們發現,讓用戶把 Go 業務通過 Rust 重寫,難度其實非常大。很多用戶會抱怨 Rust 的一些問題讓他們很痛苦,比如,Rust 生命週期太複雜,泛型系統太複雜,報錯看不懂,編程速度慢等等。因爲這一系列問題,所以讓用戶把原來的 Go 項目通過 Rust 重寫,對於用戶來說是很難推動的事情。
於是,我們就有了一個大膽的想法,如果我們可以像使用 Rust 那樣的編譯技術去生成性能更好的可執行文件,同時使用 Rust 重寫 Go 的 Runtime 和 GC 這兩個核心組件,再通過幾乎零開銷的 FFI(Foreign Function Interface) 方式來支持 Rust 和 Go 之間的互調,是不是可以讓用戶 Go 的源碼也能達到接近 Rust 的性能。這就是我們的初衷,因此有了 ROG 這個項目。
ROG 進展
我們目前測試了一些簡單的場景,比如快排和二分、Simple Lisp。這些都是通過 time 命令來計算兩個二進制文件執行所需要的時間。目前在快排、二分上,Go 的執行需要 5.97s,ROG 的執行需要 4.12s,在 Simple Lisp 這個項目上,Go 需要 8.17s,ROG 執行只需要 7.09s。
從以上幾個基本數字來看,在一些簡單的場景下,ROG 會比 Go 性能好很多。但這只是一些非常簡單的 case。如果面對一些非常複雜的 case 呢?比如在複雜的微服務場景下,ROG 會有怎樣的性能領先?
ROG 在上個季度剛好能夠支撐 Kitex Benchmark 跑起來,目前我們完成了一次壓測。
我們使用 Kitex 官方的 Benchmark 工具完成了簡單的 RPC 調用測試(https://github.com/cloudwego/kitex-benchmark)。
目前,我們只測試了連接數 100,測試包大小 1024kb 的體積。在這個測試中,Go 的 QPS 可以達到 27W,ROG 28W。雖然 ROG 的 QPS 只比 Go 領先了一點,但是 P99 上有很大的提升。我們在測試過程中發現了 ROG 還有很多可以挖掘能力,只是還需要進一步優化。
架構設計
通過剛纔幾個性能場景測試,我們發現 ROG 相比 Go 在不同的場景下,多多少少有一些領先。但是爲什麼 ROG 相比於 Go 會有這樣的領先呢?早期我們其實也經歷過 ROG 測試結果比 Go 還差 50% 的狀態。所以想先給大家介紹一下我們的設計架構。
從圖中可以看出,首先會有一個 ROG 的前端來處理用戶的 Go 源碼,在前端經歷 Parser 解析後生成 AST(Abstract Syntax Tree),做符號解析,每個函數,每個類型的符號。然後進行類型檢查,分析出函數的簽名以及每個變量的類型。這是一套非常常見的前端處理流程。
在經歷這個過程之後,會產生一箇中間語言叫做 MIR(Rust's Mid-level Intermediate Representation),之後會基於 MIR 去做一些前端時的優化,比如編譯時計算、常量傳播計算、逃逸分析(能夠分析出哪些變量應該被逃逸到堆上去)、Inliner、SROA,以及對於特定 Go 函數的優化。
在這些優化算法處理之後,會生成一份 LLVM IR(Intermediate Representation),之後把它交給 ROG 後端。ROG 後端是我們自己魔改的一個 LLVM 版本。在 LLVM codegen 階段我們給每個函數插入了一些對應的 Stack Check 以及對應的 STW(Stop The World) Checkpoint 指令,同時生成相應的 GC Barrier。
優化好之後就生成一份比較高質量的二進制代碼了。這是對於 Go 語言的處理,而對於 Go 的 Runtime & GC 這部分,我們基本上完全是重寫的。通過 Rust 重寫之後,我們把這些代碼通過自己維護的一個 Rust 版本去構建、打包好,調成對應的 LLVM 文件,最後和用戶的 Go 代碼連接起來,形成一個最終的二進制文件。這就是我們的編譯流程。
收益來源
這個編譯架構爲什麼相比 Go 或多或少有些性能優化呢?有哪些領先點?
其實領先點主要來源於三個部分。
第一部分,編譯優化。因爲 ROG 利用了 LLVM 積累多年的編譯優化算法,能夠生成一些性能更好的代碼,而 Go 的編譯優化會爲編譯速度做出一定犧牲。
第二部分,ROG 提供了跨語言 LTO(Link Time Optimization) 以及 FFI,通過幾乎零開銷的方式調用 Rust 提供的方法,因此在一些需要更高性能的場景,用戶可以使用 Rust 開發,由 ROG 進行編譯並進行調用。而 Go 對於 FFI 會使用 CGO,並且 CGO 會存在一些 overhead。
第三部分,Runtime & GC。ROG 完全使用 Rust 重寫,再通過上面提供的 FFI 來保證調用的性能,而 Go 的 Runtime & GC 則是完全使用 Go 原生實現的。單純從語言的表達能力上限來說,Go 遠不如 Rust,所以如果我們通過 Rust 來重寫 Runtime & GC 這兩部分組件,理論上會比 Go 擁有更好的性能。
面臨的挑戰
介紹完性能來源之後,可能很多人會有疑問,貌似我們的主要性能受益都是來自於 LLVM。LLVM 本身優化已經做得很好了,我們做的是不是就是非常簡單地把一個 Go 源碼翻譯到 LLVM 就行了呢?
其實整個事情並沒有那麼簡單,在這一年裏,我們踩過非常多的坑。以下舉幾個簡單的例子。
Go Runtime
如果大家之前瞭解過 TinyGo,就會發現 TinyGo 的思路和 ROG 非常接近——TinyGo 也是把 Go 的源碼給翻譯到 LLVM。我們可以回想下在使用 TinyGo 的時候遇到過什麼問題。
首先,TinyGo 需要用戶手動通過 runtime.Gosched 這個函數來進行協作調度,所以它對用戶代碼是有影響的。如果用戶沒有在關鍵的地方去插入這個函數調度,會對它的調度產生影響。另外,TinyGo 本身也不支持多線程,並且缺少相應的 channel timer reflect 等 lib 的支持。
而 ROG 把這些問題都解決了,ROG 會在編譯階段插入代碼,完成協作式調度,並且 ROG 設計的本身也是爲了高性能,所以自然會對多線程進行支持,並且 ROG 對於 channel timer reflect 全部都重寫。對於我們來說,解決 TinyGo 的不足的過程也相當艱難,畢竟重寫整個 Runtime & GC 等是一個非常大的工作量。
Safety FFI
假設如果我們要在 Go 提供 FFI,當用戶寫出這樣的代碼會發生什麼事情?
左邊這張圖是用戶寫的一份 Go 代碼,裏面有函數。rog_test(a *int32) 這個函數可能就是 FFI 提供的一個外部函數。如果用戶直接去調用這個外部函數,而 rog test 本身是由 Rust 實現的,如右圖,當我們寫出這樣的代碼的時候,會發生什麼事情?
因爲 rust_tup 被 Rust Allocator 管理,Go GC 無法掃描到這個變量,所以這個變量 a 也無法被 GC 掃描到,而 “a” 這個變量是被 Go 的 Allocator 管理的,所以如果 a 無法被 GC 掃描到,那麼 a 就會被 free 掉。但是這個時候, rust_tup 仍然會持有變量a
的指針,在 Go 那邊相當於是一個對外內存引用了 Go 的一個對象,但是因爲 Go 掃不到這個對象,所以這個對象就被 free 掉了,但是對外內存仍然引用這個指針。
當我們提供 FFI 的時候,很有可能會面臨這樣的情況。這種情況該怎麼處理?在 ROG 這邊,我們就會通過一個模改的 Rust 編譯器,提供一個 Managed Chekcer 去限制用戶寫出這樣的代碼,在編譯器階段保證用戶不會寫出這樣的代碼,保證 FFI 的安全性。這是 ROG 解決這個問題的思路。
Roadmap & 未來規劃
CGO
目前 ROG 雖然能跑過 Kitex Benchmark,並在內部一些服務上做了測試,但它仍有很多功能需要改進,比如 CGO。CGO 是 Go 語言用來提供 FFI 的一種方案,但 ROG 的 FFI 是通過一種非常簡單粗暴的方式提供的。目前 ROG 的 FFI 需要用戶手工去標記 ROG,寫上 rog:linkname 標記。這樣我們在鏈接時才能鏈接上對應的符號。而 CGO 可以讓用戶簡單的直接在 Go 文件的一個註釋裏寫上 C 代碼 import C,通過 import c 這個 package 來進行調用。
從 FFI 來說,CGO 會比現在的 ROG 方便很多,而且已有很多的開源庫,以及字節內部一些服務,他們也在使用 CGO。我們在未來會支持 CGO,兼容 CGO 的表達方式,提供 ROG 需要的 FFI,生成 ROG 需要的 FFI 代碼進行調用。
宏 / 編譯器生成代碼
Rust 宏在我看來是一個非常強的功能,因爲 Rust 宏可以簡單地在每個 Rust 進行標記,申明這個結構可以提供 Serialize(序列化)和 Deserialize(反序列化)這兩種方法。這樣就可以在編譯時爲它生成序列化和反序列化的代碼,直接進行調用,而不需要像 Go 原始的 JSON,它有反射開銷。而這種反射開銷在需要高性能的序列化場景會有很大的性能開銷。爲了解決 Go 的反射開銷,sonic 做出了 JIT 反感,而 JIT 對開發 sonic 的開發者來說,負擔是非常大的。
那麼如果我們可以把 Rust 宏的理念引入到 ROG 中,會有什麼樣的體驗?
首先,更好的開發體驗。以 Kitex 舉例,我們可以直接在編譯時,通過宏爲每個 IDL 生成 clint 的代碼,這樣就不需要用戶去手動調用一些 main 去生成。
其次,更高效的序列化。像 JSON 這種序列化,我們可以通過類似 Rust 宏的方式在編譯時生成好序列化和反序列化所需要的代碼,直接調用,這樣就可以省掉反式的開銷。
關於宏帶來的案例,我們還在繼續探索中,之後我們會基於宏做一些更好更方便的嘗試。這也是我們對於宏的規劃。但是不得不提到的是,宏的出現會對 Go 本身有一定的影響,因此可能只會通過註釋的方式去提供,保證對 Go 語法的兼容性;並且只會在 JSON 等序列化這些地方進行一些替換,保證用戶的開發體驗不會受到影響。
開源
ROG 未來肯定會進行開源,並且貢獻到社區。
目前我們的想法是 2024 年先在公司內部完成一些業務的試用,能夠穩定地上生態環境,並且能夠取得一定的收益。在這些都穩定並且處理好 Go 本身大部分特性問題之後,纔會將其開源。因此如果順利,最早可能會需要等到 2025 年的第二季度纔會去準備開源工作。歡迎大家保持關注~
項目地址
GitHub:https://github.com/cloudwego
官網:www.cloudwego.io
三週年演講 PPT 下載鏈接:https://github.com/cloudwego/community/tree/main/meetup/2024-09-21
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/HzC530bm346SihLaqF8E6w