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

19-02-15 banq
                   

本文介绍我们在没有中断生产运营情况下是如何将生产系统的第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};

class!(HelloWorld);
methods!(
    HelloWorld,
    _itself,

    fn hello(name: RString) -> RString {
        RString::new(format!("Hello {}", name.unwrap().to_string()))
    }
);

#[allow(non_snake_case)]
#[no_mangle]
pub extern "C" fn Init_ruby_rust_demo() {
    let mut class = Class::new("RubyRustDemo", None);
    class.define(|itself| itself.def_self("hello", hello) );
}

这是伟大的,如果你需要的是通过一些基本的类型(如String,Fixnum,Boolean如果你需要传递大量的数据,等等),用你的方法,但不是很大。在这种情况下,您可以传递整个Order对象,然后您需要调用该对象上需要的每个字段以将其移动到Rust中:

pub struct RustUser {
    name: String,
    address: Address,
}

pub struct Address {
    pub country: String,
    pub city: String,
}

class!(User);

impl VerifiedObject for User {
    fn is_correct_type<T: Object>(object: &T) -> bool {
        object.send("class").send("name").try_convert_to::<RString>().to_string() == "User"
    }

    fn error_message() -> &'static str {
        "Not a valid request"
    }
}

methods!(
    // .. some code skipped

    fn hello(user: AnyObject) -> Boolean {
        let name = user.send("name").try_convert_to::<RString>().unwrap().to_string();
        let ruby_address = user.send("address");
        let country = ruby_address.send("country").try_convert_to::<RString>().unwrap().to_string();
        let city = ruby_address.send("city").try_convert_to::<RString>().unwrap().to_string();
        let address = Address {
            country,
            city
        };
        let rust_user = RustUser {
            name,
            address
        };
        do_something_with_user(&rust_user);
        Boolean::new(true)
    }
)

你可以在这里看到很多例行和重复的代码,也缺少适当的错误处理。在查看此代码之后,它提醒我们这看起来很像手动解析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
}

class!(HelloWorld);
rutie_serde_methods!(
    HelloWorld,
    _itself,
    ruby_class!(Exception),

    // Notice that the argument has our defined type `User`, and the return type is plain bool
    fn hello_user(user: User) -> bool {
        do_something_with_user(&user);
        true
    }
);

您可以看到代码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 do an incremental roll-out
    if rust_enabled? && Feature.enabled?(:rust_travel_time)
        RustTravelTime.get(from_location, to_location, options)
    else
        RubyTravelTime.get(from_location, to_location, options)
    end
  end
end

还有一个主开关(在这种情况下rust_enabled?),它允许我们只通过一个功能标记来关闭所有Rust代码。

由于Ruby和Rust类的实现API大致相同,我们能够使用相同的测试来测试它们,这使我们对实现的质量更有信心。

RSpec.describe TravelTime do
  shared_examples "travel_time" do
    let(:from_location) { build(:location) }
    let(:to_location) { build(:location) }
    let(:options) { build(:travel_time_options) }

    it 'returns correct travel time' do
      expect(TravelTime.get(from_location, to_location, options)).to eq(123.45)
    end
  end

  context "ruby implementation" do
    before do
      Feature.disable!(:rust_travel_time)
    end

    include "travel_time"
  end

  context "rust implementation" do
    before do
      Feature.enable!(:rust_travel_time)
    end

    include "travel_time"
  end
end

同样非常重要的是,在任何时候,我们都可以关闭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的替代品。

                   

1