内容简介:本文介绍我们在没有中断生产运营情况下是如何将生产系统的第1层服务从Ruby迁移到Rust?在物流算法团队中,我们有一个名为Dispatcher的服务,其主要目的是以最佳方式向司机提供订单。对于每个司机,我们建立了一个时间轴,我们可以预测司机在某个时间点的位置; 知道这一点,我们可以更有效地向司机推荐订单。构建每个时间线涉及相当多的计算:使用不同的机器学习模型来预测事件将花费多长时间:断言某些约束,计算分配成本。计算本身很快,但问题是我们需要做很多这样的事情:对于每个订单,我们需要检查所有可用的司机以确定最好
本文介绍我们在没有中断生产运营情况下是如何将生产系统的第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的替代品。
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- Mesa 开始将代码迁移至 GitLab,希望提升开发效率
- 【火炉炼AI】深度学习009-用Keras迁移学习提升性能(多分类问题)
- 【火炉炼AI】深度学习006-移花接木-用Keras迁移学习提升性能
- CVPR 2020 Oral:一行代码提升迁移性能,中科院计算所研究生一作
- 银行核心海量数据无损迁移:TDSQL数据库多源异构迁移方案
- 再无需从头训练迁移学习模型!亚马逊开源迁移学习数据库 Xfer
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
浪潮之巅(上册)
吴军 / 人民邮电出版社 / 2013-5-1 / 35.00元
《浪潮之巅(第2版)(上册)》不是一本科技产业发展历史集,而是在这个数字时代,一本IT人非读不可,而非IT人也应该阅读的作品。一个企业的发展与崛起,绝非只是空有领导强人即可达成。任何的决策、同期的商业环境,都在都影响着企业的兴衰。《浪潮之巅》不只是一本历史书,除了讲述科技顶尖企业的发展规律,对于华尔街如何左右科技公司,以及金融风暴对科技产业的冲击,也多有着墨。此外,《浪潮之巅》也着力讲述很多尚在普......一起来看看 《浪潮之巅(上册)》 这本书的介绍吧!