Deliveroo分享從Ruby遷移到Rust提升17倍效能
本文介紹我們在沒有中斷生產運營情況下是如何將生產系統的第1層服務從Ruby遷移到Rust?
在物流演算法團隊中,我們有一個名為Dispatcher的服務,其主要目的是以最佳方式向司機提供訂單。對於每個司機,我們建立了一個時間軸,我們可以預測司機在某個時間點的位置; 知道這一點,我們可以更有效地向司機推薦訂單。
構建每個時間線涉及相當多的計算:使用不同的機器學習模型來預測事件將花費多長時間:斷言某些約束,計算分配成本。計算本身很快,但問題是我們需要做很多這樣的事情:對於每個訂單,我們需要檢查所有可用的司機以確定最好指派給哪位。
Dispatcher的第一個版本主要是用Ruby編寫的:這是公司的首選語言,並且當時我們的規模足夠大。然而,隨著Deliveroo不斷增長,訂單和乘客的數量急劇增加,我們看到排程過程開始花費的時間比以前長得多,我們意識到,在某些時候,不可能在一個時間限制內一步到位I排程一些區域了。我們也知道,如果我們決定實施更高階的演算法,這將更限制我們,因為需要更多的計算時間。
我們嘗試的第一件事是優化當前程式碼(快取一些計算,試圖找到演算法中的錯誤),這沒有多大幫助。很明顯Ruby在這裡是一個瓶頸,我們開始研究替代方案。
為什麼使用Rust?
我們考慮了一些如何解決排程速度問題的方法:
- 選擇具有更好效能特徵的新程式語言並重寫Dispatcher
- 確定最大的瓶頸,重寫程式碼的這些部分,並以某種方式將它們整合到當前程式碼中
我們知道從頭開始重寫是有風險的,因為它可能會引入錯誤,並且切換服務可能會很痛苦,因此我們對這種方法感到不舒服。另一個選擇,找到瓶頸並替換它們,我們已經為程式碼的一部分做了一些事情(我們為Rust實現了Hungarian路由匹配演算法的原生擴充套件),並且效果很好。我們決定嘗試這種方法。
有幾種選擇我們如何將用另一種語言編寫的部分程式碼整合到Ruby中:
- 構建外部服務並提供與之通訊的API
- 構建原生擴充套件
我們很快就放棄了構建外部服務的選項,因為我們需要在每個排程週期中呼叫此外部服務數十萬次,並且通訊的開銷將抵消所有潛在的速度增益,或者我們需要重新實現此服務中排程程式的一個重要部分,幾乎與完全重寫相同。
我們決定它必須是某種原生擴充套件,為此,我們決定使用Rust,因為它為我們勾選了大部分方框:
- 它具有很高的效能(與C相當)
- 它是記憶體安全的
- 它可以用來構建動態庫,可以載入到Ruby中(使用extern "C"介面)
我們的一些團隊成員有Rust的經驗,喜歡這種語言,Dispatcher的一部分已經使用了Rust。我們的策略是逐步替換當前的ruby實現,逐個替換演算法的部分內容。這是可能的,因為我們可以在Rust中實現單獨的方法和類,並從Ruby呼叫它們,而不需要很大的跨語言互動開銷。
如何使Ruby與Rust互動?
有幾種不同的方法可以從Ruby呼叫Rust:
- 使用extern "C"介面在Rust中編寫動態庫,並使用FFI 呼叫它。
- 編寫動態庫,但使用Ruby API註冊方法,這樣就可以直接從Ruby呼叫它們,就像任何其他Ruby程式碼一樣。
使用FFI的第一種方法要求我們在Rust和Ruby中提出一些自定義C類介面,然後在兩種語言中為它們建立包裝器。使用Ruby API的第二種方法聽起來更有前途,因為已經存在使我們的生活更輕鬆的庫:
首先使用Helix:
- 它有一些看起來像在Rust中編寫Ruby的巨集,這對我們來說比我們感到舒服的時候更神奇
- 強制協議沒有很好地記錄,並且不清楚如何將非原始Ruby物件傳遞給Helix方法
- 我們不確定安全性 - 看起來Helix沒有呼叫Ruby方法rb_protect ,這可能會導致未定義的行為
最終,我們決定使用ruru / rutie,但保持Ruby層薄和隔離,以便我們可能在將來切換。我們決定使用Rutie ,這是Ruru 的最新分支,它有更積極的發展。
以下是如何使用ruru / rutie中的一種方法建立類的一個小示例:
#[macro_use] extern crate rutie; use rutie::{Class, Object, RString}; <b>class</b>!(HelloWorld); methods!( HelloWorld, _itself, fn hello(name: RString) -> RString { RString::<b>new</b>(format!(<font>"Hello {}"</font><font>, name.unwrap().to_string())) } ); #[allow(non_snake_<b>case</b>)] #[no_mangle] pub extern </font><font>"C"</font><font> fn Init_ruby_rust_demo() { let mut <b>class</b> = Class::<b>new</b>(</font><font>"RubyRustDemo"</font><font>, None); <b>class</b>.define(|itself| itself.def_self(</font><font>"hello"</font><font>, hello) ); } </font>
這是偉大的,如果你需要的是通過一些基本的型別(如String,Fixnum,Boolean如果你需要傳遞大量的資料,等等),用你的方法,但不是很大。在這種情況下,您可以傳遞整個Order物件,然後您需要呼叫該物件上需要的每個欄位以將其移動到Rust中:
pub struct RustUser { name: String, address: Address, } pub struct Address { pub country: String, pub city: String, } <b>class</b>!(User); impl VerifiedObject <b>for</b> User { fn is_correct_type<T: Object>(object: &T) -> bool { object.send(<font>"class"</font><font>).send(</font><font>"name"</font><font>).<b>try</b>_convert_to::<RString>().to_string() == </font><font>"User"</font><font> } fn error_message() -> &'<b>static</b> str { </font><font>"Not a valid request"</font><font> } } methods!( </font><font><i>// .. some code skipped</i></font><font> fn hello(user: AnyObject) -> Boolean { let name = user.send(</font><font>"name"</font><font>).<b>try</b>_convert_to::<RString>().unwrap().to_string(); let ruby_address = user.send(</font><font>"address"</font><font>); let country = ruby_address.send(</font><font>"country"</font><font>).<b>try</b>_convert_to::<RString>().unwrap().to_string(); let city = ruby_address.send(</font><font>"city"</font><font>).<b>try</b>_convert_to::<RString>().unwrap().to_string(); let address = Address { country, city }; let rust_user = RustUser { name, address }; <b>do</b>_something_with_user(&rust_user); Boolean::<b>new</b>(<b>true</b>) } ) </font>
你可以在這裡看到很多例行和重複的程式碼,也缺少適當的錯誤處理。在檢視此程式碼之後,它提醒我們這看起來很像手動解析JSON或類似的東西。您可以將Ruby中的物件序列化為JSON,然後在Rust中解析它,它的工作原理很好,但您仍需要在Ruby中實現JSON序列化程式。然後我們很好奇,如果我們serde為AnyObject自己實現反序列化器會怎樣:它將接受ruties AnyObject並遍歷型別中定義的每個欄位並呼叫該ruby物件上的相應方法來獲取它的值。有效!
這是相同的方法,但使用我們的serde反序列化器和序列化器:
#[derive(Debug, Deserialize)] pub struct User { pub name: String, pub address: Address, } #[derive(Debug, Deserialize)] pub struct Address { pub country: String, pub city: String } <b>class</b>!(HelloWorld); rutie_serde_methods!( HelloWorld, _itself, ruby_<b>class</b>!(Exception), <font><i>// Notice that the argument has our defined type `User`, and the return type is plain bool</i></font><font> fn hello_user(user: User) -> bool { <b>do</b>_something_with_user(&user); <b>true</b> } ); </font>
您可以看到程式碼hello_user現在有多簡單- 我們不再需要user手動解析。因為它是serde,所以它也可以處理巢狀物件(正如你可以看到的那樣)。我們還添加了一個內建的錯誤處理:如果serde無法“解析”物件,這個巨集將引發我們提供的類的異常(Exception在這種情況下),它還將方法體包裝在panic::catch_unwind ,並且重新在Ruby中將恐慌引發為異常。
使用rutie-serde, 我們可以快速,輕鬆 地實現Ruby和Rust之間的薄介面。
從Ruby遷移到Rust
我們想出了一個逐步用Rust替換Ruby Dispatcher的所有部分的計劃。我們首先使用Rust類替換,這些類沒有依賴於Dispatcher的其他部分並新增功能標誌,類似於:
module TravelTime def self.get(from_location, to_location, options) # in the real world the feature flag would be more granular and enable you to <b>do</b> an incremental roll-out <b>if</b> rust_enabled? && Feature.enabled?(:rust_travel_time) RustTravelTime.get(from_location, to_location, options) <b>else</b> RubyTravelTime.get(from_location, to_location, options) end end end
還有一個主開關(在這種情況下rust_enabled?),它允許我們只通過一個功能標記來關閉所有Rust程式碼。
由於Ruby和Rust類的實現API大致相同,我們能夠使用相同的測試來測試它們,這使我們對實現的質量更有信心。
RSpec.describe TravelTime <b>do</b> shared_examples <font>"travel_time"</font><font> <b>do</b> let(:from_location) { build(:location) } let(:to_location) { build(:location) } let(:options) { build(:travel_time_options) } it 'returns correct travel time' <b>do</b> expect(TravelTime.get(from_location, to_location, options)).to eq(123.45) end end context </font><font>"ruby implementation"</font><font> <b>do</b> before <b>do</b> Feature.disable!(:rust_travel_time) end include </font><font>"travel_time"</font><font> end context </font><font>"rust implementation"</font><font> <b>do</b> before <b>do</b> Feature.enable!(:rust_travel_time) end include </font><font>"travel_time"</font><font> end end </font>
同樣非常重要的是,在任何時候,我們都可以關閉Rust整合,Dispatcher仍然可以工作(因為我們將Ruby實現與Rust一起儲存並繼續新增功能標誌)。
效能改進
當將更大的程式碼塊移動到Rust中時,我們注意到我們正在仔細監視的效能改進。將較小的模組移動到Rust時,我們沒有期待太多的改進:事實上,一些程式碼變得更慢,因為它是在緊密迴圈中呼叫的,並且從Ruby應用程式呼叫Rust程式碼的開銷很小。
在Dispatcher中,排程週期有3個主要階段:
- 載入資料中
- 執行計算,計算任務
- 儲存/傳送作業
載入資料和儲存資料階段幾乎線性地根據資料集大小進行縮放,而計算階段(我們移動到Rust)在其中具有更高階的多項式分量。我們不太擔心載入/儲存資料階段,我們也沒有優先加快這些階段的速度。雖然載入資料和傳送資料仍然是用Ruby編寫的Dispatcher的一部分,但總排程時間顯著減少:例如,在我們較大的一個區域中,它從~4秒下降到0.8秒。
在這0.8秒中,在計算階段,在Rust中花費了大約0.2秒。這意味著0.6秒是載入資料和向車手傳送任務的Ruby / DB開銷。看起來排程週期現在僅快5倍,但實際上,此示例時間內的計算階段從~3.2秒減少到0.2秒,這是17倍的加速 。
請記住,就實現而言,Rust程式碼幾乎是Ruby程式碼的1:1副本,並且我們沒有新增任何額外的優化(如快取,在某些情況下避免複製記憶體),因此仍有空間改善。
結論
我們的專案很成功:從Ruby轉向Rust取得了成功,大大加快了我們的dipatch流程,併為我們提供了更多的空間,我們可以嘗試實現更高階的演算法。
漸進式遷移和細緻特徵標記減輕了專案的大部分風險。我們能夠以更小的增量部件交付它,就像我們通常在Deliveroo中構建的任何其他功能一樣。
Rust已經表現出了很好的效能,並且缺少執行時使得在構建Ruby原生擴充套件時可以很容易地將它用作C的替代品。