Ruby 使用 Ruby FFI 调用 Go 函数:十倍效率提升

gingerhot for Railstack · January 19, 2018 · Last by conphi replied at February 15, 2018 · 11311 hits
Topic has been selected as the excellent topic by the admin.

注:本文根据几篇网文和 GitHub 资源综合编译,链接在文末给出。

本文主要介绍:

  • 如何使用 Go 编写和生成共享库(shared library)
  • 然后如何使用 Ruby FFI 对这些共享库进行调用

环境要求

  1. Ruby 环境,本文使用 2.3.4p301
  2. Golang 版本最低 1.5 或以上,本文使用 go1.9.2 darwin/amd64

什么是共享库

共享库和静态库都属于库文件,可以理解为编译好的程序,但是自己没有执行入口,供其它程序调用来执行。

可参考:http://tldp.org/HOWTO/Program-Library-HOWTO/shared-libraries.html

先比较一下 fibonacci 计算的执行速度

作为类型语言的 Go 对于 Ruby 的优势更多的在 CPU 密集计算方面,所以我们先来比较一下 Ruby 和 Go 实现的 fibonacci 函数的效率。为了简化比较的考虑,我们采用同样的算法。

# fib.ruby
def fib(n)
  return n if n <= 1
  fib(n - 1) + fib(n - 2)
end

puts fib(40)
// fib.go
package main

import "fmt"

func fib(n uint) uint {
    if n <= 1 {
        return n
    }
    return fib(n-1) + fib(n-2)
}

func main() {
    fmt.Println(fib(40))
}

Ruby 的用时:

$ time ruby fib.rb
102334155

real    0m14.027s
user    0m13.827s
sys 0m0.067s

直接运行 go run 执行的用时:

$ time go run fib.go
102334155

real    0m1.691s
user    0m0.957s
sys 0m0.362s

可以看到 Go 编译 + 运行的时间也要 10 倍于 Ruby 的速度。下面是直接执行 Go 二进制程序的速度(直接执行使用 go build fib.go 编译生成的 fib 二进制文件,相比 go run 省去了编译时间):

$ time ./fib
102334155

real    0m0.753s
user    0m0.741s
sys 0m0.007s

差不多是 Ruby 程序的 18 倍。可以看到执行效率的差距还是很大的。

使用 Go 编写和生成共享库

使用 Go 编写共享库和写普通的 Go 程序差别不大,主要是在代码里 import 一个名为 "C" 的伪包,然后在执行 go build 编译的时候加上 -buildmode=c-shared 参数。这样在编译的时候会自动调用 Cgo 进行相应的操作。有关 Cgo 的一些细节,可以阅读 C? Go? Cgo!

我们先来写一下作为共享库的 fibonacci 函数:

// fib_lib.go
package main

import "C"

//export fib
func fib(n uint) uint {
    if n <= 1 {
        return n
    }
    return fib(n-1) + fib(n-2)
}

func main() {}

注:可调用的函数是通过添加注释 //export fib 来实现的,因此这行注释是必须的。

然后我们来编译生成共享库,因为我是在 macOS 上执行,macOS 和 Linux 生成的命令略有不同,分别是:

# macOS
go build -buildmode=c-shared -ldflags -s -o fib.dylib fib_lib.go  
# Linux
go build -buildmode=c-shared -o fib.so fib_lib.go  

从 Ruby 调用 Go 函数

我们使用 FFI 这个 gem 提供的接口来从 Ruby 调用外部函数。有关 Foreign Function InterfaceFFI Gem 可以点击相应链接了解。我们先来安装 FFI Gem:

gem install ffi

然后我们使用 Ruby FFI 来调用我们用 Go 编写的 fib 函数:

# go-fib.rb
require "ffi"

module Fib
  extend FFI::Library
  # 下面是我在 macOS 上运行的写法,如果在 Linux 上调用的文件为 "fib.so"
  ffi_lib "fib.dylib"
  attach_function :fib, [:uint], :uint
end

puts Fib.fib(40)

这里需要关注的是 attach_function 这个函数的参数,分别是要从外部引用的 函数的名称, 该函数的参数类型的数组 以及 该函数的返回值类型。这个可以和我们写的 Go 的函数对应起来:

func fib(n uint) uint
:fib, [:uint], :uint

Ruby 的类型和外部函数的类型对照可以参考 FFI Gem 的 wiki

运行比较

接下来我们比较一下 Ruby 原生的 fibonacci 和通过 FFI 调用 Go 共享库的运行效率:

$ time ruby fib.rb
102334155

real    0m13.673s
user    0m13.600s
sys 0m0.042s
$ time ruby go_lib.rb
102334155

real    0m0.883s
user    0m0.843s
sys 0m0.032s

可以看出差距也在 15 倍左右。

参考资源

  1. https://qiita.com/achm/items/679b3f3af2cf377f0f02
  2. https://c7.se/go-and-ruby-ffi/
  3. https://github.com/draffensperger/go-interlang

本文主要翻译自 1,同时参考了 2,3。

勉为编译,不妥之处还请多多指教。

请问 Windows 有解决方案吗,DLL?

这么说用 Ruby 也可以比 Go 快十倍...

a, b = 0, 1
40.times { a, b = b, a+b }
puts a

0.07s user 0.04s system 90% cpu 0.116 total

Reply to luikore #2

这里原文有句话大致是说:“为了简化比较的考虑,采用同样的算法”,略过漏译了,已补上。

所以也可以用 Go 写您同样的算法来比较:

# tail_fib.rb
a, b = 0, 1
40.times { a, b = b, a+b }
puts b
// tail_fib.go
package main

import "fmt"

func main() {
    a, b := 0, 1
    for i := 0; i < 40; i++ {
        a, b = b, a+b
    }
    fmt.Println(b)
}

Ruby 的用时:

$ time ruby tail_fib.rb
165580141

real    0m0.095s
user    0m0.067s
sys 0m0.021s

Go 版本二进制的用时:

$ time ./tail_fib
165580141

real    0m0.005s
user    0m0.001s
sys 0m0.004s

不错 学习了

还是讲一下真实场景吧,只跑 fib 体现不了什么

Reply to nouse #5
  1. 这是一篇译文,通过 fib 介绍一下基础概念和用法。
  2. 抛砖引玉,大家可以深入挖掘,讨论一些真实场景。

这个 fib 是不公平的,Ruby 的整数是自动扩展的,而 Go 并不是。

Ruby 代码

require 'benchmark'

def fib(n)
    a, b = 0, 1
    n.times { a, b = b, a+b }
    b
end

Benchmark.bmbm do |x|
    x.report("fib 100000"){ fib(100000) }
end

得到结果

Rehearsal ----------------------------------------------
fib 100000   0.182119   0.047440   0.229559 (  0.233000)
------------------------------------- total: 0.229559sec

                 user     system      total        real
fib 100000   0.176441   0.049568   0.226009 (  0.229628)

而 Go 代码

package fib

import (
    "math/big"
    "testing"
)

func fib(n int) *big.Int {
    var a, b, c big.Int
    b.SetInt64(1)
    for i := 0; i < n; i++ {
        c.Add(&a, &b)
        a.Set(&b)
        b.Set(&c)
    }
    return &b
}

func BenchmarkFib(b *testing.B) {
    for i := 0; i < b.N; i++ {
        fib(100000)
    }
}

得到结果

Running tool: /usr/local/go/bin/go test -benchmem -run=^$ nouse/fib -bench ^BenchmarkFib$

goos: darwin
goarch: amd64
pkg: nouse/fib
BenchmarkFib-4            30      40015030 ns/op     3053390 B/op        653 allocs/op
PASS
ok      nouse/fib   1.250s
Success: Benchmarks passed.

40015030 ns = 0.04s,可以看到在这项 benchmark 上 Go 只比 Ruby 快 4-5 倍

Reply to nouse #7

Ruby 代码不变:

# big_fib.rb
a, b = 0, 1
100000.times { a, b = b, a+b }
#puts b

Go 和你一样使用大数的包:

// big_fib.go
package main

import (
    "math/big"
)

func main() {
    a := big.NewInt(0)
    b := big.NewInt(1)

    for i := 0; i < 100000; i++ {
        a.Add(a, b)
        a, b = b, a
    }
}

Ruby 的结果:

time ruby big_fib.rb

real    0m0.409s
user    0m0.279s
sys 0m0.120s

Go 的结果:

time ./big_fib

real    0m0.046s
user    0m0.038s
sys 0m0.005s
huacnlee mark as excellent topic. 22 Jan 10:53

那个 ./fib 文件有多大?

我电脑上 ./fib 1.8M, fib.dylib 817K。

贴下你在文章中提到的递归的 fib 在几个语言下的跑分供参考,CPU 是非满血版的 i5-8250U,机器是 4.9.76-1-MANJARO #1 SMP PREEMPT Wed Jan 10 20:17:16 UTC 2018 x86_64 GNU/Linux

ruby 2.3.3


•100% ➜ time ruby fib.rb
102334155
ruby fib.rb  11.28s user 0.01s system 99% cpu 11.303 total

ruby 2.5.0

•100% ➜ time ruby fib.rb
102334155
ruby fib.rb  9.56s user 0.01s system 99% cpu 9.570 total

crystal 0.24.1

•100% ➜ time crystal fib.rb 
102334155
crystal fib.rb  1.70s user 0.10s system 106% cpu 1.681 total

go run

•100% ➜ time go run fib.go
102334155
go run fib.go  0.84s user 0.08s system 95% cpu 0.963 total

go build

•100% ➜ time ./fib
102334155
./fib  0.65s user 0.00s system 99% cpu 0.660 total

crystall 速度不错,而且直接可以拿 ruby 版本的代码跑,很爽 😀

另外 ruby 是有性能提升啊,不过任重而道远

你这比较的事情也不一样,ruby fib.rb 做的事情还包括编译和载入 Ruby 运行时,和 go 比你要把 go build 的时间加在一起。

当然了,算 fib 100000 还是这样快:

require 'matrix'
def fib n
  m = Matrix[[0, 1], [1, 1]] ** n
  v = m * Vector[0, 1]
  v[1]
end

t = Time.now
fib 100000
puts Time.now - t
Reply to luikore #15

问题在于,Ruby 本身就是脚本语言,Go 是编译型的语言。大家都知道这一点,所以这样比较本身是没有问题的。更有意义的数据当然是比较 Ruby 原生和使用了 Go 共享库的性能。

至于哪一种算法算 fib 更快,则是另外一个问题了。

你这样跑 benchmark 一点说服力都没有

我觉得文章的重点是怎么通过 FFI 调用 c share lib 吧。

他们跟你说的是两码事,其实我在 OJ 的时候发现了这些问题,用 for in 跟 while 完全性能是两码事,原因具体看红放大镜书里面有解释,while 效率是比较高的。

他们用 times 的方式处理,a, b 是不会再在一个堆栈继续创建对象的,而你的 fib(n) 里面的 n 是继续创建对象的。在 Perl 入门经典那本书,就建议用 Memoize.pm 模块优化,消耗内存换性能,个人观点其实也就是预 malloc。Rubygems 里面也有 Memoize gem,可以搜搜看。

怎么说呢,这个其实不是脚本语言和编译型语言的区别的问题,但是如果不打算非常认知到底什么原因,然而这样理解暂时也不是问题,也没毛病。

这个应该是 C++ / Go 这种接近 Raw 处理的语言处理东西的方式的区别,Ruby 一个 for in 其实代入了一个 block 到 each,然后每个对象创建产生了大量的系统调用,本来 Ruby 创建对象就有大量的调用过程、还有方法调用也是耗时的事情,还有 GC 比起 Go 的内存管理差很远的,区别就在这里。

C++ 写一个算法,大概 O 几 基本上内心都有个数,但是 Ruby / Python 实际上掺和了 Object 的一些调用,一些语法也具有隐式处理。

所以本质上是没有可比性的,要说服的话,你应该找那种 Go / C++ 和 Ruby 在调用上用了几个指令接近的方式,但是我只能说,不太可能,我看了 Go 的源码,底层很多实现感觉几乎是拿着 Intel / AMD 处理器的文档对着优化过的。。。

详看它的运行时 https://github.com/golang/go/tree/master/src/runtime

Reply to luikore #15

是的,Go 没有自带 matrix 库,要手写。这样看来算 fibonacci 还是 Ruby 最快。

你这样 benchmark 不公平啊,对 Ruby 计算了编译时间,对 Go 却忽略了编译时间。

要么都算,要么都不算。

你可以参考下 https://benchmarksgame.alioth.debian.org/ 网站的 benchmark, 里面的 Ruby 程序都是预编译好 (编译成 .yarv), 并且程序跑的时间足够长,让编译时间影响更小甚至不计编译时间的

@luikore 我觉得这正是两种语言的差别所在,因为 Ruby 作为解释型语言就是这样运行的啊。感觉这个就是见仁见智了。

@jakit @nouse 大家倒是可以继续讨论。

不是这样运行的啊...

Ruby 是先从源文件编译到字节码,再解释字节码执行。这是可以分离执行的,就和 javac 然后 java 一样. 平时为了方便不分离而已。

Reply to luikore #23

这样的话是说的通的。所以我在 #4 给你的数据不够公平和精确。

Go 很好,Go 很吊,但还是准备去玩 crystal,因为写 Go 会手抽筋!

crystal 类似 ruby 的语法,写起来爽快,况且现在支持静态编译了 crystal build src/app.cr --release --link-flags -static 跟 Go 一样编译成无依赖单文件。

感觉有点多此一举了,为什么不直接使用 golang 或者 elixir 呢?

本来一个好好的分享,硬是歪楼到了 Ruby 和 Golang 的性能比较

dyld: mach-o, but built for simulator (not macOS)
Could not open library 'libfib.dylib': dlopen(libfib.dylib, 5): image not found

为什么我的扩展打不开。 macos 10.12 好像有坑,lz 版本多少

果断升级到 high sierra,好了

Reply to xyuchen #30

请问原来的文章好在哪里?论证方法科学?带来的负面后果有没有讲清楚?这样改成用 Go 以后 fib 会溢出都没有考虑到,还不如直接优化代码效果更明显。内存如何管理,以后文件描述符,数据库,网络连接等等如何管理也是一大问题。

有兴趣可以翻翻以前的论坛文章,以前是如何一阵风鼓吹 NoSQL,现在又如何?

@jakit ,查了下,memorize 早就停止开发了,最新的,基本上不怎么维护的 gem 叫做 memoist

Reply to zw963 #34

那个是 ActiveSupport::Memoizable 的拆分版

其实占用内存还并不是最优解,也没有解决问题而是绕开了问题,以前有一个 256M 内存的机器,没挂 swap 的情况下 cpan 一下,在 extract 索引包的时候就炸了,OOM 错误。

真正要解决本质问题,我觉得要么就从 Ruby 更高效的调用方法去写,比如 for in / times 改成 while,还有对象避免 GC 对象复用。要么干脆 C 去用扩展来实现,至少可预算大 O 复杂度,虽然不如汇编能看出来跑了几个指令。

文章的思路算是 Go extension 了

但是你都这么来实现了,为什么项目一开始就不去用 Go 来写呢?

提一个题外话,用动态的系统(这里指比如 RubyVM)去写一些需要持久运行的东西(比如 server),一来行为不好确定(未来后天行为变了呢),二来出 bug 了要动态反查,如果要把 Perl / PHP 加入讨论,那更恶心了,我读了 Perl DBI / DBM 部分的源码,那真心是狗呕一样的一坨,self“内置”了很多东西,正如 PHP CI 框架 $this 隐晦了 class_loader,很多东西变得隐晦、不可预测,几乎只有写代码的那个人,自己才清楚这个模块结构是怎样的。

但是,动态虽绕开了“显”问题带来了“隐”问题,但总不可能让你把方案都完全确定了再写代码。如果用静态的系统去实现一些方案不确定的东西(姑且称为“隐”问题),有很多方案上需要细化动态处理的东西,你需要拿静态去模拟和抽象,静态的系统会被这些尚未探索明确的问题卡住,而且调整起来也很困难除非足够人手(带来的人力成本)。

我輩只是想说,这种事情其实是矛盾的,只能说各有千秋。

很多 Cpp 程序猿,可能后续会把 lua 学了以便自己在某些开发上能减轻负担。很多脚本程序猿,可能后续会把 C 学了写扩展,一个道理。Java 这种有商业公司养得白白胖胖的可以另外说(不过最近鉴于 Oracle 的事情,有点不好说了)。

光吃零食,肚子还是饿,可能还是会吃点正餐填一下肚子的。光吃正餐,嘴巴有点馋,可能还是会吃一点零食满足嘴馋。爱怎么吃怎么吃。

觉得 Ruby 薯片吃不饱,想把 Go 饭团搅进去充饥也是没问题的。

你没把 Go 的编译时间和编译到二进制的时间算上? 我以前记得国外有本书上说,所有相同程序的运行的步骤所要时间都是一样的;天天搞那些这个语言比那个快几秒,慢几秒的问题;当你还在研究时间的时候,别人的空间大厦早就建好了;当你还在找提高 20%part 的时候,世界早就以 80% 的 part 来演化了;找你喜欢的,满足你的口味的语言,才是最重要的。

37 Floor has deleted

关于效率是个老生常谈的话题了,我以前看过 go,一看语法就不是自己喜欢的,相对于 ruby 少了点人性。我并不对 ruby 完全满意,但纵观这些编程语言,也只能亲爱于 ruby 了,ruby 语言中的那些“智能”只不过少许用了点统计学的东西,就像现在所谓的“人工智能”,其实什么都不是,有的国外教授为了论文把“统计学”换了个说法而已;物理架构都不一样,也导致了根本无法发展为人类智能,举个 kids 的例子,就像直线和圆在几何意义上不一样。 上面的可能说得跑偏了,在硬件都一样的情况下,效率是靠算法提高的,语言只是工具,用什么都无所谓。

You need to Sign in before reply, if you don't have an account, please Sign up first.