您的位置:首页 > 编程语言 > Ruby

如何使用Rust提高Ruby性能

2017-12-04 11:59 609 查看
摘要:Ruby是一种简单快捷面向对象的脚本语言,而Rust是一种系统编程语言,它有着惊人的运行速度,能够防止段错误,并保证线程安全。本文作者以项目为例,结合大量的编程代码描述了如何借助Rust语言提高Ruby的性能,以下是译文。

几年前,在我的Rails(提供一个纯Ruby的开发环境)应用程序里发现了一些被调用数千次的方法,占了网站页面加载时间的30%以上。这些方法都完全地专注于文件路径名。

除此之外,我还看过一篇博客写“拯救Ruby的Rust”,这表明可以用Rust编写执行慢的Ruby代码,让Ruby变得更快。Rust还提供了一种安全、快速、高效的编写代码的方法。在用Rust语言重写了我的Rails站点上一些效率较低的方法之后,网站页面加载速度比以前快了33%以上。

如果想了解通过FFI集成Rust,那么建议看看上文中提到的那篇博客(“拯救Ruby的Rust”)。当前这篇文章的重点是分享我在过去两年整合Ruby和Rust所得到的经验教训。当方法被调用数千次,它的性能稍有改进都会对项目有很大的影响。


入门

这篇文章的相关代码可以在GitHub上看到,如果打算开始了解Rust和Ruby项目,可以创建一个
ffi_example
 项目,并把下面的代码添加到
Cargo.toml
文件:
[lib]
name = "ffi_example"
crate-type = ["dylib"]

[dependencies]
array_tool = "*"
libc = "0.2.33"
1
2
3
4
5
6
7

添加如下代码到
ffi_example.gemspec
文件:
spec.add_dependency "bundler", "~> 1.12"
spec.add_dependency "rake", "~> 12.0"
spec.add_dependency "ffi", "~> 1.9"
spec.add_development_dependency "minitest", "~> 5.10"
spec.add_development_dependency "minitest-reporters", "~> 1.1"
spec.add_development_dependency "benchmark-ips", "~> 2.7.2"
1
2
3
4
5
6

由于构建的库需要在客户端系统上使用FFI,所以最好将FFI,Rake和Bundler作为常规依赖项包含在内。

用于这篇文章的例子,可以从FasterPath的repo历史记录中获取
basename
方法代码,与
File.basename
进行比较。
请记住,Ruby用C语言实现了这个功能,所以这并不是你通常会用Rust重写的那种方法。
大多数FasterPath都会为Pathname类重写Ruby代码,这就是可以显著提高性能的地方。
我们正在使用File.basename作为纯粹的比较基准。
1
2
3
4

为了简单起见,将所有的Rust代码放入到
src/lib.rs
。这是一个用Rust编写的basename代码副本(可以复制和粘贴它;在这里不再介绍它是如何工作的):
mod rust {
extern crate array_tool;
use self::array_tool::string::Squeeze;
use std::path::MAIN_SEPARATOR;

static SEP: u8 = MAIN_SEPARATOR as u8;

pub fn extract_last_path_segment(path: &str) -> &str {
// Works with bytes directly because MAIN_SEPARATOR is always in the ASCII 7-bit range so we can
// avoid the overhead of full UTF-8 processing.
// See src/benches/path_parsing.rs for benchmarks of different approaches.
let ptr = path.as_ptr();
let mut i = path.len() as isize - 1;
while i >= 0 {
let c = unsafe { *ptr.offset(i) };
if c != SEP { break; };
i -= 1;
}
let end = (i + 1) as usize;
while i >= 0 {
let c = unsafe { *ptr.offset(i) };
if c == SEP {
return &path[(i + 1) as usize..end];
};
i -= 1;
}
&path[..end]
}

pub fn basename(pth: &str, ext: &str) -> String {
// Known edge case
if &pth.squeeze("/")[..] == "/" { return "/".to_string(); }

let mut name = extract_last_path_segment(pth);

if ext == ".*" {
if let Some(dot_i) = name.rfind('.') {
name = &name[0..dot_i];
}
} else if name.ends_with(ext) {
name = &name[..name.len() - ext.len()];
};
name.to_string()
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

这个案例是为了模仿
File.basename
返回结果的方式而写的。这里唯一需要注意的是在
basename
方法开始的地方的边界情况。这有效地使方法在迭代给定输入的时间加倍,并且应该重构到现有的系统中去。

感谢Gleb Mazovetskiy
extract_last_path_segment
是一个有效的贡献。这个方法在其它方法中使用,并在边界情况已知之前就实现了。在本文的后面,将讨论有和没有边界情况下的基准性能的细节。


Rust FFI方法

用于处理字符串显示的打包类的Rust FFI代码如下:
extern crate libc;
use libc::c_char;
use std::ffi::{CStr,CString};

#[no_mangle]
pub extern "C" fn example(c_pth: *const c_char) -> *const c_char {
let pth = unsafe {
assert!(!c_pth.is_null());
CStr::from_ptr(c_pth).to_str().unwrap()
};

let output: String = // YOUR CODE HERE

CString::new(output).unwrap().into_raw()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

Ruby会通过FFI给出一个原始C类,并将其转换成可以在Rust中使用的字符串,然后再转换回来给到Ruby。

这里需要注意的重要的一点是
assert!
。这个
assert!
方法在本文的项目当中并不消耗任何时间,但如果它的求值为false,Rust就会崩溃,带来FFI段错误。所以这个
assert!
方法有了保证
nil
不提供输入是很好的。但Ruby是
nil
友好的,你并不希望段错误发生,所以在这里使用
assert!
方法是不明智的。

现在
nil
在Rust中添加检查并不困难。为我们的代码使用同样的打包行为,我将提供basename的
nil
检查版本。
#[no_mangle]
pub extern "C" fn basename_with_nil(c_pth: *const c_char, c_ext: *const c_char) -> *const c_char {
if c_pth.is_null() || c_ext.is_null() {
return c_pth;
}
let pth = unsafe { CStr::from_ptr(c_pth) }.to_str().unwrap();
let ext = unsafe { CStr::from_ptr(c_ext) }.to_str().unwrap();

let output = rust::basename(pth, ext);

CString::new(output).unwrap().into_raw()
}
1
2
3
4
5
6
7
8
9
10
11
12

当执行这段代码的时候,如果Ruby给出一个
nil
,不做任何操作将其返回,系统就会理解这是
nil
。结果证明这样编写是有效的。

所以在这种情况下,Rust方法可以返回一个String类型或者
nil
给到Ruby。Ruby甚至不会注意到这完全违背了Rust的类型强制设计; 因为在Rust语言中,只处理一种类型,就是从
libc::c_char
c_char


注意,用一种几乎不耗费时间的方法,做一个
nil
 guard稍微安全一点;然而,这使得我们的方法增加了4%的运行时间(这个时间在没有边界情况下变得慢下来)。如果用Ruby而不是Rust实现nil guard,又会增加4%的运行时间,总计慢下来8个百分点。

请记住,我们在这里吹毛求疵的东西已经极快。平均结果在+/-3%之间。

如果用Ruby提供的
File.basename
实现相同的类型安全:
def self.basename(pth, ext = '')
pth = pth.to_path if pth.respond_to? :to_path
raise TypeError unless pth.is_a?(String) && ext.is_a?(String)
// Call original Rust FFI implementation without nil guards here
end
1
2
3
4
5

会比原来用Rust的实现慢17%左右。

我们甚至还没有将性能与Ruby的C语言实现进行比较。为了使代码完全兼容,这使得我们每种Type Safety Guard类型都要去实现。


释放内存

更糟糕的是,即使在学习进程的这一点上,我们也不知道垃圾回收被调用时内存中发生了什么。这需要对在线文档和博客进行更多的研究,以帮助了解这里面发生了什么。

根据我的经验,我会告诉你,通过挖掘可用的资源,现在并没有完全弄清楚垃圾回收被调用时内存中究竟发生了什么,但是我会给出输入的信息。

据称,当使用FFI时,如果没有实现自己释放内存的方法,那么FFI会尝试调用C的
free
方法。在与一些Rust社团的讨论中,真的不希望
free
被Rust代码以这样的方式调用,这通常是未定义的行为或未知发生的事情,或者可能已经发生。因此从几个方面建议你用Rust实现一个方法,它将负责收回原先由Rust所给出的内存所有权,并由Rust去释放它。需要告诉Ruby在代码执行完成时调用这个方法。

在FFI中,很容易链接到自己的“free”方法并手动调用它。或者可以让Ruby通过AutoPointer或ManagedStruct自动执行垃圾收集器。FFI Wiki或Rust
Omnibus提供了很好的例子。

如果正在优化的代码方法被频繁的调用,那么实现这些代码的代价就是值得的。但是,如果正在优化的代码速度已经很快,那么这个性能优化的代价就相当高昂,在内存能够正确地提供服务的情况下,我的方法会多花费大约40%的时间。

这其中的原因很大程度上是因为FFI有一部分是用Ruby编写的,大部分是用C语言编写的,用Ruby-land处理逻辑的时间越多,那么从纯C语言或Rust语言的性能得到的益处就越少。

在这之后,当所有这些琐碎的事情加起来,花费的时间比节省的时间还要多的时候,我感到沮丧。那时候,我决定应该避免用Ruby 编写FFI,并尝试去寻求一个纯粹的Rust解决方案。

有两个这样的解决方案:一个叫ruru,另一个叫Helix。在这两者之间,出于以下原因,我最终选择了ruru。
ruru是用Rust的风格写的, Helix的设计就像用Rust自身写Ruby一样。
ruru非常接近1.0版本,看起来很稳定,而 Helix正处于周期性的快速发展之中,还有很多大的功能尚未开发出来。

切换到ruru之后,我收回了在实现safe guard 类型中失去的所有的时间。这有点跑题了,但如果没有为前面的例子提供Ruby代码,那么将是我的失职。


Ruby FFI用法

为了进行基准测试,在Ruby里添加一些方法。首先执行
lib/ffi_example.rb

require "ffi_example/version"
require "ffi"

module FfiExample
# the example function from earlier but with two parameters
def self.basename_with_pure_input(pth, ext = '')
Rust.basename_with_pure_input(pth, ext)
end

def self.basename_nil_guard(pth, ext = '')
return nil if pth.nil? || ext.nil?
Rust.basename_with_pure_input(pth, ext)
end

def self.basename_with_nil(pth, ext = '')
Rust.basename_with_nil(pth, ext)
end

def self.file_basename(pth, ext = '')
pth = pth.to_path if pth.respond_to? :to_path
raise TypeError unless pth.is_a?(String) && ext.is_a?(String)
Rust.basename_with_pure_input(pth, ext)
end

module Rust
extend FFI::Library
ffi_lib begin
prefix = Gem.win_platform? ? "" : "lib"
"#{File.expand_path("../target/release/", __dir__)}/#{prefix}ffi_example.#{FFI::Platform::LIBSUFFIX}"
end

attach_function :basename_with_pure_input, [ :string, :string ], :string
attach_function :basename_with_nil, [ :string, :string ], :string
end
private_constant :Rust
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

Ruby在标准库中有Fiddle,通过外部函数接口直接调用外部C函数。但是,它基本上没有入门文档,缺少很多功能。这很可能是为什么FFI早就写出来了,但只有少量的文档,它仍然缺乏帮助初学者的条件,让他们可以对发生的事情有充分的了解。

这个
ffi
提供了一些帮助,使得我们能够编写可以跨多个操作系统运行的代码。上面的
ffi_lib
方法需要指向Rust建立的动态库。所以当运行
cargo
build –release
的时候,它将在target/release中创建库,并且这种扩展取决于操作系统。上述的begin/ end 代码块适用于Windows,Mac和Linux等操作系统。


ruru入门

Ruru现在相当直接地加入到我们的项目中。首先将其添加到
Cargo.toml
文件。
[dependencies]
ruru = "0.9.3"
array_tool = "*"
libc = "0.2.33"
1
2
3
4

然后在外部crate库中使用ruru ,并加入到
src/lib.rs
文件。
#[macro_use]
extern crate ruru;
use ruru::{RString,Class,Object};
1
2
3

Ruru有一些不错的宏,这些宏有助于我们的方法与特定的类一起工作。首先,定义一个想要创建的类,然后在宏中定义方法,将它们与Ruby类关联起来。
class!(RuruExample);

methods!(
RuruExample,
_itself,
fn pub_basename(pth: RString, ext: RString) -> RString {
RString::new(
&rust::basename(
pth.ok().unwrap_or(RString::new("")).to_str(),
ext.ok().unwrap_or(RString::new("")).to_str()
)[..]
)
}
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14

methods!
宏中,首先选择要使用的类。下一个环节是将在
methods!
宏块内使用的变量来引用Ruby版本的 
self
。由于在这个项目里根本就不使用它,在它前面加下划线
_itself


Ruby有用C语言实现的自己的类系统,每个类都有一个用
VALUE
设置的类标识。Ruru将这些类中的某些类戏称为Rust类,所以对于Ruby的
String
类,我们使用
RString
类。

当用
methods!
宏编写方法时,知道宏的作用域内的方法不能相互调用是非常重要的。因此,要重复使用的任何方法都必须在宏之外编写,并在那里调用它们。另外,当创建动态库时,可能很容易出现命名冲突,所以将额外的字符添加到方法名中是极好的,这样就不会混淆了。接下来会演示…

为了使方法可以从Ruby中调用,必须首先让Ruby调用Rust代码来获取实例化的对象。
#[allow(non_snake_case)]
#[no_mangle]
pub extern "C" fn Init_ruru_example(){
Class::new("RuruExample", None).define(|itself| {
itself.def_self("basename", pub_basename);
});
}
1
2
3
4
5
6
7

前面的
Init_
目的是遵循Ruby的惯例,允许直接从库文件中导入一个Ruby C语言风格的编译库。

因此,需要重命名
Cargo.toml
文件中的库以避免与ruby中名为
ffi_example
的名称发生冲突,并将
target/release
路径添加到加载路径,然后应该可以直接请求
require
"ruru_example"
来请求它(如果命名了
ruru_example
库)。再加载ruru Rust代码就好像它是用Ruby写的。

有关将C语言代码与Ruby相连的更深入的了解,请阅读用于编写C语言扩展的文档。

加载代码的另一种方法是直接使用Fiddle来调用它。在这个例子中,仍然使用FFI的动态库辅助方法。
require 'fiddle'

library = Fiddle.dlopen(
begin
prefix = Gem.win_platform? ? "" : "lib"
"#{File.expand_path("../target/release/", __dir__)}/#{prefix}ffi_example.#{FFI::Platform::LIBSUFFIX}"
end
)

Fiddle::Function.
new(library['Init_ruru_example'], [], Fiddle::TYPE_VOIDP).
call
1
2
3
4
5
6
7
8
9
10
11
12

现在将代码加载到Ruby中,一切都按预期运行。


基准测试

在之前包含的gemspec中,包含了
benchmark-ips
。为了对方法进行基准测试,首先放入一个Rakefile使得命令行执行变得更简单。
# Rakefile
require "bundler/gem_tasks"
require "rake/testtask"

Rake::TestTask.new(:test) do |t|
t.libs << "test"
t.libs << "lib"
t.test_files = FileList["test/**/*_test.rb"]
end

Rake::TestTask.new(:bench) do |t|
t.libs = %w[lib test]
t.pattern = 'test/**/*_benchmark.rb'
end

task :default => :test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

现在在
test/benches/basename_benchmark.rb
创建基准测试。
require 'test_helper'
require 'benchmark/ips'

BPATH = '/home/gumby/work/ruby.rb'

Benchmark.ips do |x|
x.report('Ruby\'s C impl') do
File.basename(BPATH)
File.basename(BPATH, '.rb')
end

x.report('with pure input') do
FfiExample.basename_with_pure_input(BPATH)
FfiExample.basename_with_pure_input(BPATH, '.rb')
end

x.report('ruby nil guard') do
FfiExample.basename_nil_guard(BPATH)
FfiExample.basename_nil_guard(BPATH, '.rb')
end

x.report('rust nil guard') do
FfiExample.basename_with_nil(BPATH)
FfiExample.basename_with_nil(BPATH, '.rb')
end

x.report('with type safety') do
FfiExample.file_basename(BPATH)
FfiExample.file_basename(BPATH, '.rb')
end

x.report('through ruru') do
RuruExample.basename(BPATH, '')
RuruExample.basename(BPATH, '.rb')
end

x.compare!
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

在运行上述基准测试之前,先从
basename
方法中评估边界情况。边界情况仅仅是为了通过Ruby
Spec Suite。按照文件路径中可接受的标准,不需要将多个斜杠压缩到一个(从
///
/
),操作系统会很好地识别路径。

现在运行基准测试
rake bench
产生以下输出结果(确保
cargo
build --release
在运行基准测试之前运行):

注:Ruby 2.4.2和Rust 1.23.0
Warming up --------------------------------------
Ruby's C impl    41.849k i/100ms
with pure input    31.766k i/100ms
ruby nil guard    29.974k i/100ms
rust nil guard    31.812k i/100ms
with type safety    27.103k i/100ms
through ruru    41.124k i/100ms
Calculating -------------------------------------
Ruby's C impl    683.942k (± 1.5%) i/s -      3.432M in   5.018615s
with pure input    480.551k (± 1.6%) i/s -      2.414M in   5.025184s
ruby nil guard    443.185k (± 2.6%) i/s -      2.218M in   5.008595s
rust nil guard    489.863k (± 1.9%) i/s -      2.450M in   5.002297s
with type safety    382.805k (± 1.7%) i/s -      1.924M in   5.028345s
through ruru    667.268k (± 2.6%) i/s -      3.372M in   5.057512s

Comparison:
Ruby's C impl:   683941.9 i/s
through ruru:   667268.5 i/s - same-ish: difference falls within error
rust nil guard:   489863.3 i/s - 1.40x  slower
with pure input:   480551.2 i/s - 1.42x  slower
ruby nil guard:   443185.2 i/s - 1.54x  slower
with type safety:   382805.2 i/s - 1.79x  slower
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

不是Ruby或ruru的方法,而是FFI版本。现在可以看出细微变化的差别。使用ruru,能够获得C语言的性能,而不必担心编写C语言代码的相关风险。

如果一个方法没有被多次调用,那么做这些更改就不会在整体基准测试中发现任何差异。但是,对于那些被过度使用的方法,这些更改确实起了作用。

另一个关于在Ruby中对比Rust语言和C语言的有趣的事实是,CPU的缓存量可能会影响到Rust的结果。更多的CPU缓存将提高Rust的性能,可以超越C语言。

这是在FasterPath项目中,其他一些开发人员和我自己之间所观察到的信息。目前还没有对这些数据进行集中编目,但是将来应该会有一个系统做这件事。


总结

Ruru和Helix不是功能完备的系统。在ruru系统中,我观察到了在整个系统中整数,字符串和数组完美地工作,以及从ruru到Ruby的新对象的初始化过程。

在写这篇文章的时候,ruru和Helix都没有实现的一个领域是允许Ruby的垃圾收集器处理从Rust代码生成的Ruby对象。原因可能是Rust语言存在VALUE属性,但Ruby GC(垃圾收集器)不知道如何释放它。当从Rust的
Pathname.entries
目录条目中调用
Pathname.new
时发现这个问题,这导致了基准测试期间的段错误,而不是测试套件(足够在退出之前触发GC)。跟踪这个问题是ruru#75helix#50

Ruby现在已经是一种成熟的语言了,Rust语言还很年轻并在不断地发展中。ruru和Helix可能还需要一段时间才能完全兼容Ruby。这一切都取决于社团的发展和参与。

未来是如此美好。与此同时,已经有了大量可以完成的工作。鼓励大家涉足这些强大的领域,分享所学到的东西,为他人和将来的自己做好文档,不久的将来,会让年轻的开发人员更好地实现他们在性能编程方面的目标。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: