Tag Archives: Optimization

Ruby 之“Schwartzian 變換”

Ruby 的 Array#sort 和 Array#sort_by 在實現上有微妙差異。Array#sort 通過在每次迭代過程中直接比較元素来進行排序,而 Array#sort_by 在排序之餘還進行了一種優化,試圖減少計算元素“键”時帶來的開銷。

這裡所謂的“键”是用來衡量两个元素孰大孰小的一個標準,在元素是標量類型(即並非兩個或兩個以上域的複合結構)的情況下,鍵通常就是元素本身的值的大小;在元素是兩個或兩個以上域的複合結構時,鍵則通常是其域值的一個組合。比如,字符串可以看作是擁有 n 個字符域的複合結構,而在進行字符串排序時常用到的字典順序,就可以看作是所有字符域組合之下得出的鍵。

鍵的計算有時是昂貴的,而特定的排序算法在迭代過程中往往都需要多次獲取同一個元素的鍵,這就使得冗餘的鍵計算給整個排序帶來了不可磨滅的開銷。Array#sort_by 採用的優化技巧,實際上就是緩存優化(Memoization)的一個特例。所有元素的鍵都事先被 Array#sort_by 計算好,並和元素本身關聯在一起,儲存到另一個表中。之後對該表中計算好的鍵進行排序就同時排列了與之關聯的元素本身。最後,拋棄表中的鍵而返回所有元素本身,排序就完成了。這個優化在 Perl 編程中被反復地運用,最終成為了一個廣為人知的編程手法——“Schwartzian 變換”。在 Lisp 編程中,同樣的手法被稱為“decorate-sort-undecorate”。

Ruby 可以如下般實現 Schwartzian 變換:

class Array
  def my_sort
    map { |e| [ e, e.key ] }.sort { |a, b| a[1] <=> b[1] }.map { |e| e[0] }
  end
end

這和 Perl 的參考實現如出一轍(感謝 Ruby 的發散性!)——

@sorted = map  { $_->[0] }
          sort { $a->[1] cmp $b->[1] }
          map  { [$_, foo($_)] }
               @unsorted;

可以簡單地來測試一下實際執行時的效率差別。首先給 Fixnum 打一個猴子補丁:

class Fixnum
  def key
    sleep 0.1
    self
  end
end

如此就可以在排列 Fixnum 時通過 Fixnum#key 這個“昂貴”的函數來獲取鍵。睡眠線程的語句只是為了偽造函數的開銷。

require 'benchmark'
include Benchmark

array = [3, 1, 2, 4]

bm(10) do |b|
  b.report('sort')    { array.sort { |a, b| a.key <=> b.key } }
  b.report('my_sort') { array.my_sort                         }
  b.report('sort_by') { array.sort_by { |e| e.key }           }
end

筆者本機輸出:

                user     system      total        real
sort        0.000000   0.000000   0.000000 (  1.000057)
my_sort     0.000000   0.000000   0.000000 (  0.400023)
sort_by     0.000000   0.000000   0.000000 (  0.400023)

可見 Array#sort 相比於 Array#sort_by 和 Array#my_sort 慢了許多。由於鍵計算函數的開銷是通過睡眠線程偽造的,所以雖然掛鐘時間不是零,但用戶時間加系統時間卻是零。

當然,Schwarzian 變換只是針對昂貴鍵計算排序的一種手法。很多時候我們都僅僅是在排列簡單的鍵,比如 Fixnum (或其它整型數據類型)本身,那附帶了 Schwarzian 變換的 Array#sort_by 由於多做了緩存鍵的工作,效率上自然就不如 Array#sort 了。

Ruby 1.9 虚拟机 YARV

我们来简单的看一下提升 Ruby 1.9 性能的大功臣——YARV。

YARV,全称是 Yet Another Ruby VM,VM 的全称是 Virtual Machine。这个名称,翻译为中文,就是“又一个 Ruby 虚拟机”。这种以 `Yet Another’ 形式开头的命名是一种黑客行话,在有英语语感的程序员看来,这种命名有三分幽默诙谐之感,在业内十分受欢迎。

Ruby 的虚拟机,就和 Oracle 的 JVM(Java 虚拟机)、微软的 CLR(.NET) 一样,是一种由软件实现的虚拟的机器构架,有着自己的指令集,并可以像物理机器一样执行指令。这种针对某个(家族)语言设计的虚拟机被称为进程虚拟机,因为它只是给操作系统上某一个进程(解释器)服务的;与之并列的是系统虚拟机,它需要分享整个系统的物理硬件资源,比如我们 VirtualBox 下的 Guest OS,就是一种虚拟一整个操作系统的技术。

为什么 Ruby 需要虚拟机?大多数程序员都是人类,人类写的代码,通常是按照人类思考的方式去对机器下达命令,但并不一定有着最高的执行效率。同样效果的代码,在很高程度上都存在着另一份更高效的途径,而我们把找寻并迈向这个途径的过程称为优化。对于编译性语言,我们可以设计一个具有自动优化(同时从源代码层、汇编层)的编译器来一次性最优化我们的代码,并编译为机器码;而对于解释性语言,由于我们是在动态地解释脚本,所以不能把代码一次性地优化并编译为机器码。

Ruby 1.8 的解释器被称为 Matz’s Ruby Interpreter(MRI),它解析 Ruby 源文件,并生成一个语法树,然后直接在这个语法树上进行动态求值。这样的过程是毫无优化的。目前主流的解决方案大致可分为两种,其一是采用预编译(Ahead-of-time Compilation),其二是采用即时编译(Just-in-time Compilation)。就 Ruby 1.9.2 的 YARV 来说,它只有前者。所谓预编译,顾名思义,就是预先把代码编译。什么,我之前不是说过“不能把代码一次性地优化并编译为机器码”?是的,的确是这样,但这里的“预编译”不是编译为机器码,而是编译为一种中间代码,通常被称为字节码。字节码和机器码的不同在于,前者是平台无关的,且具备动态性,是由解释器(虚拟机)动态解析的,而不像机器码是直接交给底层的硬件(CPU)去执行。在把可供人类阅读的源代码编译为字节码的过程中,我们就可以进行各式各样的优化,最后产生的字节码,在大部分场合下都比原来的源代码更加高效。

题外话: YARV 的作者 Sasada Koichi 也曾尝试过引入 JIT 即时编译,进一步提高 YARV 的性能,但 YARV 的主页很久没有更新了,也不知进展如何。JIT 本身是一个十分复杂、很难实现的技术,可能对他来说并不是一个人能实现的了的。也许,Ruby 2.0 会……

YARV 就是这样的一个虚拟机,它忠心耿耿地优化着我们所写下的 Ruby 代码,几十年如一日,在大量的 Benchmark 测试下,展示了 Ruby 1.9 几近于 Ruby 1.8 两倍的性能。

在 Ruby 1.9 的官方实现(CRuby)中,有了一个内置的类,叫做 RubyVM。它提供给了用户一些在运行时获取 YARV 状态的实用接口,这里介绍几个比较重要的类和方法。

RubyVM::InstructionSequence,这是一个 YARV 指令序列的类,它可以用来动态地编译、反汇编、执行 Ruby 代码。

instrs = RubyVM::InstructionSequence.compile <<-START
  a = 0
  a = a + 1
  a += 2
  p a
START

puts instrs.disassemble

这里我们先编译了四行简单的 Ruby 代码,并建立了一个 RubyVM::InstructionSequence 对象。之后我们把反汇编后的指令序列按照 Ruby 提供的可供人类阅读的方式打印了出来,输出:

== disasm: <RubyVM::InstructionSequence:@>==========
local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1] s1)
[ 2] a
0000 trace            1                                               (   1)
0002 putobject        0
0004 setlocal         a
0006 trace            1                                               (   2)
0008 getlocal         a
0010 putobject        1
0012 opt_plus
0013 setlocal         a
0015 trace            1                                               (   3)
0017 getlocal         a
0019 putobject        2
0021 opt_plus
0022 setlocal         a
0024 trace            1                                               (   4)
0026 putnil
0027 getlocal         a
0029 send             :p, 1, nil, 8, 
0035 leave

可以看到,四行 Ruby 代码在 YARV 层实际上就是这样的指令序列。我们可以通过 RubyVM::INSTRUCTION_NAMES 来打印出 YARV 所有的指令:

["nop", "getlocal", "setlocal", "getspecial", "setspecial", "getdynamic", "setdy
namic", "getinstancevariable", "setinstancevariable", "getclassvariable", "setcl
assvariable", "getconstant", "setconstant", "getglobal", "setglobal", "putnil",
"putself", "putobject", "putspecialobject", "putiseq", "putstring", "concatstrin
gs", "tostring", "toregexp", "newarray", "duparray", "expandarray", "concatarray
", "splatarray", "checkincludearray", "newhash", "newrange", "pop", "dup", "dupn
", "swap", "reput", "topn", "setn", "adjuststack", "defined", "trace", "definecl
ass", "send", "invokesuper", "invokeblock", "leave", "finish", "throw", "jump",
"branchif", "branchunless", "getinlinecache", "onceinlinecache", "setinlinecache
", "opt_case_dispatch", "opt_checkenv", "opt_plus", "opt_minus", "opt_mult", "op
t_div", "opt_mod", "opt_eq", "opt_neq", "opt_lt", "opt_le", "opt_gt", "opt_ge",
"opt_ltlt", "opt_aref", "opt_aset", "opt_length", "opt_succ", "opt_not", "opt_re
gexpmatch1", "opt_regexpmatch2", "opt_call_c_function", "bitblt", "answer"]

值得注意的还有一个方法:

p RubyVM::InstructionSequence.compile_option

输出:

{:inline_const_cache=>true, :peephole_optimization=>true, :tailcall_optimization
=>false, :specialized_instruction=>true, :operands_unification=>false, :instruct
ions_unification=>false, :stack_caching=>false, :debug_level=>0}

这其实是 YARV 使用的一些优化方法。就目前来说,有很多高级优化技术,YARV 都还没有使用上,可见 Ruby 的性能提升还是有很大空间的。

那些和语言有关的坛坛罐罐(一)

从这一篇文章开始,我们来关注在用 C 语言编程时的一些和算法无关、语言有关的、一寸一地的得失问题。这些问题大多数无伤大雅,并不对程序真实效率有多大影响,但可当作学习汇编思想的一种题材。大多数情况下,这些概念和理论能应用于所有语言,但由于编译器和汇编器的实现千姿万态,严谨起见,我们在这里只考虑 ANSI C(C89)标准,并且使用 GNU Compiler Collection(GCC) 4.4.3——一个优化性能极高的编译器,测试环境是 x86-64 Linux(Ubuntu)。

在这一章中,我们来看看每一个 C 程序都会有的内容——程序入口点有什么值得注意的细节。首先我们来编译一个空的源文件:

$ cat > 1.c
$ gcc -O3 -S -masm=intel 1.c

-O3 选项告诉 GCC 我们要打开所有优化模式;-S 选项告诉 GCC 我们要生成汇编代码,而不是可执行文件;-masm=intel 告诉 GCC 生成的汇编语言要使用 Intel 语法,而不是默认的 AT&T 语法;1.c 是我们的源文件名。输出如下:

	.file	"1.c"
	.intel_syntax noprefix
	.ident	"GCC: (Ubuntu 4.4.3-4ubuntu5) 4.4.3"
	.section	.note.GNU-stack,"",@progbits

我们可以看到,一个空的 C 源文件经过 GCC 编译后,会生成这样 4 行汇编代码。这 4 行会以不同的形式出现在所有经 GCC 编译的程序代码中,前两行是初始代码,后两行是结束代码。

我们给源文件添加一个 main 函数:

void main() {
}

编译器输出:

	.file	"1.c"
	.intel_syntax noprefix
	.text
	.p2align 4,,15
.globl main
	.type	main, @function
main:
.LFB0:
	.cfi_startproc
	rep
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (Ubuntu 4.4.3-4ubuntu5) 4.4.3"
	.section	.note.GNU-stack,"",@progbits

可以看到在初始代码和结束代码之间,又增加了不少代码。我们关心的是 Main 函数内部的代码:

	.cfi_startproc
	rep
	ret
	.cfi_endproc

.cfi_startproc 和 .cfi_endproc 分别是 dwarf2 CFI 的初始过程和结束过程指令,它们隐藏了一些 CFI 有关的操作。rep 之所以出现在这里是因为 GCC 尝试避免分歧跳转跟着 ret 指令时发生 CPU 预测器无法得知 ret 的目标地址的问题(这个问题实际只存在于 x86-64)。ret 是从当前过程中返回的指令。这就是一个最简单的 main 函数内部的三个步骤:CFI 初始操作 – 返回 – CFI 结束操作。由于第一个和最后一个步骤永远伴随着函数,我们大可将注意力集中在这两个步骤之间的代码,也就是 main 函数的实际内容。

现在我们让 main 函数变成更规范、我们更熟悉的形式:

int main() {
    return 0;
}

这时,我们之前提到的第一个步骤和第二个步骤之间的代码就变成了:

	xor	eax, eax
	ret

我们可以看到,生成的代码比之前多了一行。eax 是一个通用的寄存器,根据 cdesl 调用约定(即 C 语言调用约定),在函数返回时,返回值必须保存在 eax 寄存器中,交给调用者处理。xor 是异或运算指令,自己异或自己并保存结果到自己,自己自然就成了 0,这是在汇编层把一个寄存器设为 0 的最快的方法(它的操作码在很多平台上都比 mov eax, 0 指令短)。所以,当 main 函数有返回值时,程序的代码是比没有时长的。

我们再给 main 函数加上常见的参数列表:

int main(int argc, char **argv, char **envp) {
    return 0;
}

输出后你会发现汇编代码与之前相比没有改变。如果我们把 return 0 改为:

    return argc;

输出就改变了:

	mov	eax, edi
	ret

edi 是保存了 argc 的寄存器,mov 使 edi 的值传送到了 eax,实现了 return argc 的功能。由此可见,main 参数的传递是由调用者完成的(在这里 main 函数的调用者是装载器),并没有影响程序代码长度。

  我们现在去掉 GCC 的 -O3 选项,即不进行任何优化,编译带三个参数、返回 argc 的 main 函数代码,发现输出为:

	push	rbp
	.cfi_def_cfa_offset 16
	mov	rbp, rsp
	.cfi_offset 6, -16
	.cfi_def_cfa_register 6
	mov	DWORD PTR [rbp-4], edi
	mov	QWORD PTR [rbp-16], rsi
	mov	QWORD PTR [rbp-24], rdx
	mov	eax, DWORD PTR [rbp-4]
	leave
	ret

你一定震撼了。多出来的这些代码,是函数调用和返回时的常规代码,进行栈指针偏移、局部变量入栈等操作。由于我们这段代码根本没有必要使用栈内存(没有调用其它函数),GCC 在优化的时候就把我们的程序当作了一串没有非局部跳转的指令,省略了这些操作,直接返回,达到了未优化时的同样效果。这是一种最常见的编译器优化技术——死代码消除法(dead code elimination),根据上下文排除了不必要、不会影响程序运行的代码。这里只是死代码消除法的冰山一角而已。

好了,关于程序入口点,差不多就是这些内容。我们可以在不需要告诉操作系统程序运行的结果时让 main 函数不返回任何值,虽然并不规范,但确实无伤大雅,节省了一行汇编代码。同时,也不要担心写入了形式参数会增大生成的程序体积的问题——编译器强大的优化在很多时候比人类刻意去写的所谓“优化”后的汇编代码更优,这种简单的“死代码消除”对它来说实在是小菜一碟,它会在运行速度和代码体积之间进行合理的折衷(也可以通过用户传递的选项手动设置)。