来自这个 commit: https://github.com/ruby/ruby/commit/ed935aa5be0e5e6b8d53c3e7d76a9ce395dfa18b, 已经有人迫不及待的开玩了: https://www.johnhawthorn.com/2018/02/playing-with-ruby-jit-mjit/
感想:
我这按它的脚本,是这样的
ceclinux@ceclinux-pc ~/ruby 973 trunk ? time ./ruby --disable-gems --jit --jit-wait --jit-verbose=1 15a.rb ✔ 10248 18:46:44
JIT success (55.3ms): block in <main>@15a.rb:13 -> /tmp/_ruby_mjit_p2514u0.c
JIT success (183.4ms): [email protected]:1 -> /tmp/_ruby_mjit_p2514u1.c
{:result=>600}
Successful MJIT finish
./ruby --disable-gems --jit --jit-wait --jit-verbose=1 15a.rb 4.84s user 0.03s system 99% cpu 4.880 total
ceclinux@ceclinux-pc ~/ruby 972 trunk ? time ./ruby --disable-gems 15a.rb 1 ↵ 10250 18:47:17
{:result=>600}
./ruby --disable-gems 15a.rb 9.08s user 0.00s system 99% cpu 9.087 total
一倍差距算是很大了把?
known issues: Performance is decreased when Google Chrome is running。这就很搞笑了
Rails 的类过多的方法也阻碍了 JIT 的优化空间,具体需要 @luikore 来讲解下,推上 discourse 的人在跟 kokubun 讨论 jit-friendly 的策略,见 https://twitter.com/k0kubun/status/960112559343878144 这 thread
MJIT 这个优化还是要先讨论一下 https://github.com/vnmakarov/ruby/tree/rtl_mjit_branch 这个 branch,在 Ruby Issue 上是 Feature #12589。2017 年 RubyKaigi 上最后一个大演讲就是这个。
演讲见此:https://www.youtube.com/watch?v=qpZDw-p9yag (各位可能要熟悉一下毛式英语)
Vladimir Makarov 在 Redhat 里工作,开发 gcc 了 20 年。来优化 Ruby 解释器上手就是一套连招,可以说是相当厉害,一个叫 RTL,一个叫 MJIT。在此之前 JIT 在 MRI 上的尝试也有一些,这里就主要说一下这个实现的不同。
_______ _________________
|header |-->| minimized header|
|_______| |_________________|
| MRI building
--------------|----------------------------------------
| MRI execution
|
_____________|_____
| | |
| ___V__ | CC ____________________
| | |----------->| precompiled header |
| | | | |____________________|
| | | | |
| | MJIT | | |
| | | | |
| | | | ____V___ CC __________
| |______|----------->| C code |--->| .so file |
| | |________| |__________|
| | |
| | |
| MRI machine code |<-----------------------------
|___________________| loading
首先,MJIT 和 YARV 的实现是解耦的。这一点很重要,Rubinius 支持 JIT 了一段时间又不支持了,很大程度上就是和虚拟机耦合在一起的 JIT 实现会把开发进度拖入泥潭。一方面,Ruby 语言本身还在发展,在 JIT 开发过程中会不会牵制语言。对于对于调试的难度也有很大的上升。而 MJIT 可以很容易开关,类似于外挂在 YARV 上,这是非常好的。
RTL 指的是 Register Trasfer Language。gcc 的中间语言就是表示为这种形式。vnmakarov 用这玩意作为 Ruby 的中间语言,来替代 YARV 的 ISEQ 设计。RTL 比起基于栈的中间语言设计,执行速度和对内存的消耗都更少,给予更大的优化空间。
这一套连招打完对于性能的提升可以说是非常恐怖的。我们看一下 benchmark:
v2 | base | rtl | mjit | mjit-cl | omr | jruby9k | jruby9k-d | graal-22 | |
---|---|---|---|---|---|---|---|---|---|
while | 1.0 | 1.11 | 1.82 | 387.29 | 9.28 | 1.06 | 2.3 | 2.89 | 2.35 |
nest-while | 1.0 | 1.11 | 1.71 | 4.97 | 3.97 | 1.05 | 1.38 | 2.58 | 1.66 |
nest-ntimes | 1.0 | 1.02 | 1.13 | 2.19 | 2.43 | 1.01 | 0.94 | 0.97 | 2.19 |
ivread | 1.0 | 1.13 | 1.31 | 13.67 | 9.48 | 1.13 | 2.42 | 2.99 | 2.33 |
ivwrite | 1.0 | 1.18 | 1.78 | 15.01 | 7.59 | 1.13 | 2.52 | 2.93 | 1.97 |
aread | 1.0 | 1.03 | 1.44 | 19.69 | 7.03 | 0.98 | 1.79 | 3.53 | 2.17 |
awrite | 1.0 | 1.09 | 1.42 | 13.09 | 7.45 | 0.96 | 2.18 | 3.74 | 2.55 |
aref | 1.0 | 1.13 | 1.67 | 25.73 | 10.17 | 1.09 | 1.87 | 3.69 | 3.71 |
aset | 1.0 | 1.51 | 2.68 | 23.45 | 17.82 | 1.47 | 3.61 | 4.49 | 6.33 |
const | 1.0 | 1.09 | 1.53 | 27.53 | 10.15 | 1.05 | 2.98 | 3.89 | 3.01 |
const2 | 1.0 | 1.12 | 1.31 | 26.13 | 10.06 | 1.09 | 3.05 | 3.81 | 2.41 |
call | 1.0 | 1.14 | 1.54 | 5.53 | 4.75 | 0.9 | 2.18 | 4.99 | 2.86 |
fib | 1.0 | 1.21 | 1.43 | 4.16 | 3.81 | 1.1 | 2.17 | 5.03 | 2.26 |
fannk | 1.0 | 1.05 | 1.1 | 1.1 | 1.1 | 0.99 | 1.71 | 2.32 | 1.02 |
sieve | 1.0 | 1.3 | 1.72 | 3.34 | 3.36 | 1.27 | 1.49 | 2.42 | 2.02 |
mandelbrot | 1.0 | 0.94 | 1.11 | 2.08 | 2.11 | 1.08 | 0.96 | 1.56 | 2.45 |
meteor | 1.0 | 1.24 | 1.27 | 1.71 | 1.71 | 1.16 | 0.9 | 0.92 | 0.54 |
nbody | 1.0 | 1.05 | 1.14 | 2.73 | 3.07 | 1.26 | 0.97 | 2.31 | 2.14 |
norm | 1.0 | 1.13 | 1.09 | 2.52 | 2.49 | 1.15 | 0.91 | 1.45 | 1.62 |
trees | 1.0 | 1.14 | 1.23 | 2.3 | 2.21 | 1.2 | 1.41 | 1.53 | 0.78 |
pent | 1.0 | 1.13 | 1.24 | 1.71 | 1.7 | 1.13 | 0.6 | 0.8 | 0.33 |
red-black | 1.0 | 1.01 | 0.94 | 1.3 | 1.14 | 0.88 | 0.98 | 2.52 | 1.03 |
bench | 1.0 | 1.16 | 1.18 | 1.54 | 1.57 | 1.15 | 1.28 | 2.75 | 1.81 |
GeoMean. | 1.0 | 1.12 | 1.39 | 6.18 | 4.02 | 1.09 | 1.59 | 2.48 | 1.83 |
可以说是脚踩 Graal,吊打 JRuby,3x3 目标立刻实现。三倍是什么?提升六倍也可以啊。
去年演讲结束后,就有提问在会上劝进 Matz 要不要合并。Matz 的意思是很明确的,说这玩意好应该合并,然后就基本上钦定硬点会后这半年来社区 JIT 的努力方向。
但这次合并到主干的代码和 RTL-MJIT 还是区别很大的,Issue 为 Feature #14235。首先合并的不是 Vladimir 完整的 fork,而是一个由 Kokubun 拉出来的分支,这个分支叫做 YARV-MJIT。只包括 MJIT 的部分,只在 JIT 中使用 RTL,而没有在 IR 上完全用 RTL。
这是因为,RTL 版本的 MJIT 要通过回归测试得到合并,短时间内要做的工作太多了。而且 Ruby 还在发展,要 RTL 的开发也完全跟上难度很大。合并的理由主要是 trunk 上很多优化工作和 MJIT 重复了,不要把一件事做两遍。另外就是这个提交的 issue 和 YARV-MJIT 开发主分支上还有一些区别,去掉了一些激进的优化,这使得性能比 YARV-JIT 更慢一点。至少能用了。
benchmark 大约是比 2.0 提升了 2 倍左右,比完全体的 RTL-MJIT 的 6.18 倍还是慢了不少。但这个合并至少确认了 MRI 的 JIT 开发方向,减少了大家做的许多其他尝试。但这个优化在回归上还是有一些问题,也有一些 bug。即使合并了,也需要在加入特定参数才会开启,2.6.0 里并不会默认启用。
另外就是标题里这个「开始开发了」,其实不太对,开发了快一年了,这次是要开始合并到 Ruby 主干了。
可以說是腳踩 Graal,吊打 JRuby,3x3 目標立刻實現。三倍是什麼?提升六倍也可以啊。
其實那個 Benchmark, Nv 和 CSeaton 已經回覆了,如果讓 Graal / JVM Warm Up, 不計算一開始的 Startup 時間,TruffleRuby 還是快最快的 MJIT 3 倍左右。
理論上那個是比較舊版的 TruffleRuby, 新版本和剛剛才開源的 SubstrateVM 會更加快。當然,Trade off 是 Memory Usage。
很可惜整個 Ruby 社區對 Java / JVM / Oracle 很有保留。
JRuby 主要是和 MRI 有诸多不兼容的地方。一方面没有引入类似 JNI 的机制做 C 扩展的引用,另一方面像是 Ruby 这几个版本非常重要的 Fiber 特性,一直不跟进。这对 JRuby 的推广有很大的障碍。但是反过来说,Ruby 社区为了保留灵活的演进,在几次劝进下都拒绝搞标准化。事实上最后搞了一个标准化 Ruby ISO,基于那个实现了 mruby,社区反映也是不温不火,两边都有苦难言啊。
话说这个实现也糙了一点。。。把 hot block 找出来然后 C 化然后 compile 而且还放在 /tmp。重点是,这样一来就依赖 gcc / clang 了。
我期待的好像不是这样的,有点失望。我期待它能本身提供编译的功能,就是 ruby file.rb 就已经能从 YARV -> 机器码,而不依赖外部工具。不过,实现到那样的 level,估计 Rubinius 会便利一些,后端天然的有 LLVM 垫背。
如果 MJIT 实现了即时 YARV -> Native Code 即时存储到内存空间预备即时调用,我觉得它干脆做成下一个 LLVM 了。
刚好相反,rails 太臃肿了,IO 开销实际上是很小的一部分,服务器真正做的部分只有通过调度、读 request、写 respose。
而你的 HTTP parsing、业务处理(rails 本身有大量的对象映射、抽象、创建和销毁等等)才是最大的问题。这些属于计算密集问题。
mjit 在类型明确一些的 block 是更容易被 jit 编译的,但像 AR(Active Record) 则不然,一层层的抽象几乎不尽相同的数据,动态的元编程动态嵌入方法和属性,阻碍了 old gen code 的形成。
动态的元编程动态嵌入方法和属性,阻碍了 old gen code 的形成。
這個正正是 Truffle 的強項。而且我才剛發現,TruffleRuby 已經支援 Nokogiri 及 OpenSSL。距離能夠真正運行 Rails 日子不遠,十分期待。
我期待它能本身提供编译的功能,就是 ruby file.rb 就已经能从 YARV -> 机器码,而不依赖外部工具。
这不叫 JIT 这叫 AOT。尝试的话 RubyMotion 的 Ruby 就是 AOT 编译的。这些尝试 Ruby 社区都有做过,优缺点也都是很明显的。
rails 太臃肿了,I/O 开销实际上是很小的一部分... 而你的 HTTP parsing、业务处理才是最大的问题。
我们拿比 Rails 轻量很多的 Sinatra 和 mysql2 裸驱动,不用 ActiveRecord 出来跑个 benchmark 好了。
Web Server | Basic | View | View + DB |
---|---|---|---|
WEBrick | 273 req/s | 116 req/s | 111 req/s |
Thin | 1597 req/s | 174 req/s | 139 req/s |
Unicorn | 605 req/s | 121 req/s | 121 req/s |
为什么 Thin 在 Basic 的时候碾压其他 Server,因为它 I/O 异步了。为什么 View 和 DB 又变差了呢?因为读取模板和查询数据库是同步的。我写 midori 的时候,把每个请求的栈分配都弄到极轻量,还用了 C extension 做 HTTP Parser,单跑 Hello World 的时候也就比 Thin 再快一倍,而加任何一个 I/O 操作就慢十倍。
谁是「很小的一部分」谁是「很大的一部分」就是这么简单的问题,哪来什么刚好相反。但是只关注 I/O 会限制 Ruby 在 Web 服务器以外别的领域的发展。所以 MJIT 还是非常必要搞的,没人讨厌快,但不希望快是以牺牲其他东西为代价,比如 Rubinius 把代码写到自己也维护不动。而 MJIT 所做的就是这一点。
为什么 Thin 在 Basic 的时候碾压其他 Server,因为它 I/O 异步了。为什么 View 和 DB 又变差了呢?因为读取模板和查询数据库是同步的。
你给出来的数据这很明显,Web 框架帮你找到路径之后调用你的业务逻辑肯定会有 View 渲染和 DB 调用,你看看 View 和 View + DB 的差异并不大,说明 DB IO 效率高了去了。
服务器做的事情,是把 数据 read,然后交给单个 task 线程去处理对应的路径反射你的 Controller 渲染你的 View(太长不写)。你看没有 View 效率多高,最差的情况拖了 6x。让一个性能很好的 Thin 拖得只剩下跟其它一样的水平。
异步不异步是另外的一个问题,服务器本身 schedule socket queue 是可以异步的。
但是你一个 Controller 作的事情比如你写了:
难不成你把 Controller 这三件事情分别异步成三个线程,甚至然后做完了再通知去让服务器 response 吗?像移动客户端有些 lib 设置了超时,等你异步做完这些事情,再 write 我客户端估计都已经 close 了。
先不讨论异步,Webrick 的 parse 是 Ruby 写的,比如 WEBrick::HTTPUtils::FormData
拿 Ruby 讨论 IO 就是耍流氓
如果你说用 Go / C++ 在流处理的时候碰到瓶颈,还是值得讨论的。
2018.3.29 更新:
但是只关注 I/O 会限制 Ruby 在 Web 服务器以外别的领域的发展
这一点是认可的,这是在开发通用编程语言,不是开发针对性的工具。
Ruby 就算没有 JIT,跑得比 C++ / Go 慢,也是值得用的,因为至少这样的工具好用而且编码也舒服,良好的 Ruby idiom 和 style 也是很好维护,无需编译,写起来也先行轻松。
等待数据库处理的时间是数据库本身的问题了,不属于 Ruby 管的范围,你们写 Rails 难不成:
class xxxController
def xxx
@data
Thread.new { // 数据库 -> @data }
end
end
你确定 View 能渲染出 @data 的内容,你确定渲染的时候 数据库已经读完了?
好,如果你 join 是吧,你可以让这次 puma task processor 的线程等待你执行完毕,但是,这就同步了呀
难不成在一次 html page request(Socket connection)中(也就是非 Ajax),你能告诉我怎么能先 write html content 给浏览器(就是 View)再等数据库异步回调再 write 数据的方式吗?
所以说不要把问题混为一谈,数据库查询的耗时不能用应用层的语言解决,能做的就是不要让 CPU 干等着,转去做别的任务。目前已有的 Thread 已经能一定程度解决问题,但是 Thread 切换支出大、共享内存容易出 bug,并发性能比 go node 等有很大差距,所以在研究引入新的并发模型。
这些问题不是 MJIT 对应的,MJIT 想解决的是运算性能,如果你的应用是计算密集,并且没有多少 IO 开销(例如查数据库),那么可以从 MJIT 获益良多。但是一般 web 应用 IO 占到一半左右,从 MJIT 获益多少要实测才知道。更快的执行速度当然是更好的,例如序列化速度可以加快,这对一些结构复杂的 API 服务很有用。
其实我的回复只是告诉 #7 其实 rails 并没有被 IO 卡,puma 的异步处理还是很优秀的。
希望 mjit 能在 rails 这块庞大的 monolith 发挥作用,随着 SPA 开发模型、前端独立岗位的出现,还是亟需的。
「让一个性能很好的 Thin 拖得只剩下跟其它一样的水平」少开玩笑了,Thin 的优化可以说是非常差了,和其他 Web 服务器比起来最大的区别就是把 I/O 包装成了异步。
puma 只处理 Web 服务器的 I/O,应用层调用 DB 也是 I/O,其背后的调用逻辑和
Thread.new { // 数据库 -> @data }
还真的区别不大,Rails 的实现就是类似于 GCD 一样做了个线程池,很明显这个方法是问题的根源。
你确定 View 能渲染出
@data
的内容,你确定渲染的时候 数据库已经读完了?
当然是可以的,但是这里就不是依靠 Thread 或者什么 join 来解决的问题。这就是并发和并行最大的区别,吞吐量和请求处理时间也不是一个概念。你试试用 em-synchrony 替换掉这个部分,就会得到数倍的性能改善。em-synchrony 也是纯 Ruby 实现的,按理说,往里面加一层东西应该会更慢才对,为什么反而快了好几倍呢?
你对并发和并行的理解太混淆了,以及对 I/O 是什么理解很有问题。View 层和 DB 层都不是在 Ruby 里面处理的,为什么同样调用 mysql2 的驱动,用 goroutine 和用 Ruby 性能差别这么多,问题就出在这里,调用的方法不对。而不是什么别的。
但 GCC 的 RTL 和 Markorov 的 RTL 确切来说不是同一套东西,虽然都属于寄存器机指令...
现在 Ruby 的字节码指令比较接近栈机指令,栈机指令特征是很多操作数都是栈上 implicit 的,总长度可以比较短。典型的栈机指令是类似这样的:
push a
push b
add # 在栈上 pop 两个元素, 相加, 然后压回栈上
而寄存器机指令,其中的寄存器也不是指 CPU 里的寄存器,直接理解为局部变量好了。典型的寄存器机指令是类似这样的:
c = add a, b
其中 a, b, c 都是代表局部变量位置的整数。
稍微跑题一下:寄存器机指令的好处是总指令数会变少,但指令编码后会比栈机指令长 (例如 JVM bytecode 编译到 Dalvik bytecode 占空间会变多 30% 左右). 就用上面两个例子来说,如果用 1 个字节代表 opcode, 那最终编码出来占用空间就是 3 + sizeof(a) + sizeof(b) 字节,而寄存器机指令编码后占用空间是 1 + sizeof(a) + sizeof(b) + sizeof(c) 字节,sizeof(c) 往往是占空间大于 2 字节的,不然就得受诸如局部变量不能超过 512 个之类的限制...
但 YARV 和 JVM 字节码不太一样的地方是,因为 YARV 的指令是 Direct threading 的,也就是说 opcode 已经用内存地址替换了。直接放内存地址可以提高分支预测的命中,从而提高执行速度。
再稍微跑题一下:也有人说 direct threading 是 folk lore, 以现在 CPU 强大的分支预测和推测执行,用 call threaded 也相差无几。当然推测执行的副作用不加清理的话,也会带来类似 spectre 的 bug...
回题:内存地址本身占空间巨大,本来一字节或者不到一字节能表示的 opcode 在 64 位平台就变成了 8 字节,所以 Ruby 用栈机的空间优势其实不大... 所以替换成寄存器字节码的空间损失并不大,甚至能省一些内存。而从寄存器代码生成 JIT 的 C 代码比栈机代码更直接一些,也更容易让 C 编译器优化一些。
现在是逐步增强的方式,先上 mjit, 再上 YARV 转译的 RTL mjit (这时就是 源代码 -> YARV 字节码 -> RTL 中间表示 -> C 代码 -> 编译成动态链接库 -> 载入 的复杂流程...).
未来会不会把 YARV 替换掉,或者把 C 换成别的中间表示来缩短这个 JIT 的流程,就再说咯。
补充一条 Vladimir Makarov ("No Russian") 总结他演讲内容的文章 https://developers.redhat.com/blog/2018/03/22/ruby-3x3-performance-goal/
你最后说的那个,别说,还真有:facebook 的 BigPipe
https://molezzz.github.io/ruby/2014/07/12/bigpipe-with-ruby.html
嗯,这个在 HTTP 1.1+ 之后有了长连接,chucked 是可以做的,如果你不使用 Rails,垂直 Puma 直接编程应该可以
Rails dispatch 到特定控制器、特定方法的时候,假设是在 A 线程里面的,但是你开一个线程 B 去处理数据库读出来,如果不 join,A 可能已经返回给客户端了。
你说的 BigPipe 得改造 Rails 的响应方式。
在博文中,已经明显这样的处理了:
get '/chunked' do
response['Transfer-Encoding'] = 'chunked'
stream :keep_open do |out|
out << erb(:bigpipe)
sleep 2
out << '<script>Bigpipe.puts("pagelat1","这是第一块")</script>'
sleep 5
out << '<script>Bigpipe.puts("pagelat2","这是第二块")</script>'
out.close
end
end
如果你非得讨论这个,你还可以上 HTTP/2 呢,HTTP/2 可以并行长连接,而且第一次握手之后的都是跟 WebSocket 类似的流,而且服务端和客户端还可以做互相的 RPC 反向推送,可以认为就没有谁是服务端谁是客户端的区别了。
HTTP/2 比 你说的这 BigPipe 本质上就是 HTTP 1.1 chucked 流先进多了去了
所以重新审题,我说的是 Rails 在 React 响应 Action 那里分配一个线程 task 处理 controller method 的业务
现在共享内存还容易出 bug 么?用 Rails 写 web 的话没啥必要写入共享内存吧,除非是 Rails 或者 Puma 自己的 bug。
确实,这个 JIT 有点粗糙了,JVM 的 JIT,V8 的 JIT 等等这些 JIT 可是直接内存生成不依赖其他编译器 (GCC,LLVM 等),希望 Ruby 以后可以慢慢改进吧,毕竟人手有限,直接生成 JIT 没那么简单做出来