Deliveroo分享从Ruby迁移到Rust提升17倍性能

栏目: Ruby · 发布时间: 5年前

内容简介:本文介绍我们在没有中断生产运营情况下是如何将生产系统的第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的替代品。


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

浪潮之巅(上册)

浪潮之巅(上册)

吴军 / 人民邮电出版社 / 2013-5-1 / 35.00元

《浪潮之巅(第2版)(上册)》不是一本科技产业发展历史集,而是在这个数字时代,一本IT人非读不可,而非IT人也应该阅读的作品。一个企业的发展与崛起,绝非只是空有领导强人即可达成。任何的决策、同期的商业环境,都在都影响着企业的兴衰。《浪潮之巅》不只是一本历史书,除了讲述科技顶尖企业的发展规律,对于华尔街如何左右科技公司,以及金融风暴对科技产业的冲击,也多有着墨。此外,《浪潮之巅》也着力讲述很多尚在普......一起来看看 《浪潮之巅(上册)》 这本书的介绍吧!

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具