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

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

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


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

查看所有标签

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

Domain-Driven Design Distilled

Domain-Driven Design Distilled

Vaughn Vernon / Addison-Wesley Professional / 2016-6-2 / USD 36.99

Domain-Driven Design (DDD) software modeling delivers powerful results in practice, not just in theory, which is why developers worldwide are rapidly moving to adopt it. Now, for the first time, there......一起来看看 《Domain-Driven Design Distilled》 这本书的介绍吧!

HTML 压缩/解压工具
HTML 压缩/解压工具

在线压缩/解压 HTML 代码

CSS 压缩/解压工具
CSS 压缩/解压工具

在线压缩/解压 CSS 代码

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换