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

gingerhot for Railstack · 2018年01月19日 · 最后由 conphi 回复于 2018年02月15日 · 10064 次阅读
本帖已被管理员设置为精华贴

注:本文根据几篇网文和 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

luikore 回复

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

所以也可以用 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

不错 学习了

gingerhot 回复

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

nouse 回复
  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 倍

nouse 回复

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 将本帖设为了精华贴。 01月22日 10:53

那个 ./fib 文件有多大?

huacnlee 回复

我电脑上 ./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 是有性能提升啊,不过任重而道远

gingerhot 回复

你这比较的事情也不一样, 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
luikore 回复

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

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

gingerhot 回复

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

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

gingerhot 回复

他们跟你说的是两码事,其实我在 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

luikore 回复

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

gingerhot 回复

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

要么都算, 要么都不算.

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

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

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

gingerhot 回复

不是这样运行的啊...

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

luikore 回复

这样的话是说的通的。所以我在 #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 版本多少

ad583255925 回复

果断升级到 high sierra ,好了

xyuchen 回复

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

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

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

zw963 回复

那个是 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 饭团搅进去充饥也是没问题的。

gingerhot 回复

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

38 楼 已删除

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

需要 登录 后方可回复, 如果你还没有账号请 注册新账号