上一篇文章简单介绍了一下Pyjion项目的目标与概况。相信很多同学都很好奇,目前的Pyjion到底效果如何对不对?
那我们就从一个再简单不过的例子来一探究竟。非常感谢@Thomson大大帮忙做实验,下面的实验结果都是拜托他帮忙获得的。
考虑下面的Python代码:
def foo(a, b):
return a + b
它由CPython编译得到的字节码如下:
0 LOAD_FAST 0 (a)
3 LOAD_FAST 1 (b)
6 BINARY_ADD
7 RETURN_VALUE
(Pyjion目前是基于CPython 3.6.0 alpha 1,不过这里用CPython 2.x系列和3.x系列得到的字节码一样,不影响例子)
经过上一篇文章提到的编译流程,Pyjion会生成下面这样的MSIL来表达foo()函数的内容:
// function prologue
ldarg.1
ldc.i4 0x88 // offsetof(PyFrameObject, f_lasti)
conv.i
add
stloc.0
ldarg.1
call METHOD_PY_PUSHFRAME // PyJit_PushFrame
// 0: LOAD_FAST 0 (a)
ldloc.0
ldc.i4 0x0
conv.i
stind.i4
ldarg.1
ldc.i4 0x188 // offsetof(PyFrameObject, f_localsplus) + 0 * sizeof(size_t)
conv.i
add
ldind.i
dup
ldc.i4 0x10 // offsetof(PyObject, ob_refcnt)
conv.i
add
dup
ldind.i4
ldc.i4.1
add
stind.i4
// 3: LOAD_FAST 1 (b)
ldloc.0
ldc.i4 0x3
conv.i
stind.i4
ldarg.1
ldc.i4 0x190 // offsetof(PyFrameObject, f_localsplus) + 1 * sizeof(size_t)
conv.i
add
ldind.i
dup
ldc.i4 0x10 // offsetof(PyObject, ob_refcnt)
conv.i
add
dup
ldind.i4
ldc.i4.1
add
stind.i4
// 6: BINARY_ADD
ldloc.0
ldc.i4 0x6
conv.i
stind.i4
call METHOD_ADD_TOKEN // PyJit_Add
dup
stloc.2
ldc.i4.0
conv.i
bne.un L_success
br L_Raise
L_success:
ldloc.2
// 7: RETURN_VALUE
ldloc.0
ldc.i4 0x7
conv.i
stind.i4
stloc.1
leave L_ret
// default exception handler
L_Raise:
ldarg.1
call METHOD_EH_TRACE // PyJit_EhTrace
L_Reraise:
ldc.i4.0
conv.i
br L_finalRet
// function epilogue
L_ret:
ldloc.1
L_finalRet:
ldarg.1
call METHOD_PY_POPFRAME // PyJit_PopFrame
ret
看起来好像很夸张有没有?
其实完全没有。上面的MSIL,如果用类似C的伪代码表达,会是这个样子:
// emulate generated code in pseudo CPyObject* foo_compiled_code(void* unused, PyFrameObject* frame) {
// function prologue int* lasti = &frame->f_lasti; // updates are needed to keep the frame state available for inspection PyJit_PushFrame(frame); // PyThreadState_Get()->frame = frame;
PyObject* errorCheckLocal;
__try { // conceptual. Not a protected region in MSIL. // 0: LOAD_FAST 0 (a) *lasti = 0;
PyObject* _a = frame->f_localsplus[0];
_a->ob_refcnt++;
// 3: LOAD_FAST 1 (b) *lasti = 3;
PyObject* _b = frame->f_localsplus[1];
_b->ob_refcnt++;
// 6: BINARY_ADD *lasti = 6;
PyObject* _sum = PyJit_Add(_a, _b); // refcnt decrement for _a and _b are inside this call errorCheckLocal = _sum;
if (_sum == NULL) {
goto L_Raise;
} else {
_sum = errorCheckLocal;
}
// 7: RETURN_VALUE *lasti = 7;
PyObject* retValue = _sum;
goto L_ret; // MSIL leave.s instruction, for clearing evaluation stack } __finally { // conceptual. Not a fault handler in MSIL. // default exception handler // for error handling when we have no EH handlers, return NULL.L_Raise:
PyJit_EhTrace(frame);
L_Reraise:
retValue = NULL;
}
// function epilogueL_ret:
PyJit_PopFrame(frame); // PyThreadState_Get()->frame = frame->f_back; return retValue;
}
稍微解释一下:上面的伪代码里,局部变量名有下划线('_')开头的实际上并不在MSIL层面的局部变量,而是在求值栈(evaluation stack)上,而没有下划线开头的则是真正的MSIL层面的局部变量。
伪代码里的 __try { ... } __finally { ... } 并不是MSIL层面上的异常处理,而是逻辑上它是用来实现Python代码的异常处理语义用的。实际涉及的跳转我都在伪代码里用goto来表达了。CPython解释器自身经常通过返回值为NULL来表达要抛异常,Pyjion也完全继承了这个设计。要说有啥不同,那就是Pyjion会在编译时把CPython特别偷懒的“block stack”给展开来,于是就不用到运行时还每次跳出循环或者抛异常都去慢慢展开block stack了。
可以看到,Pyjion生成的MSIL所代表的逻辑,其实就是把CPython解释器中每个字节码的逻辑展开来粘合到一起。这样就消除了解释器循环自身带来的开销,所以肯定是要比CPython原本的解释执行要快。不过在此基础上它并没有做多少优化,而是为了兼容性而尽可能的去模仿CPython解释器原本的行为。例如说所有Python代码里的局部变量都还是跟CPython解释器一样从PyFrameObject的f_localsplus数组访问,最大限度的保证任何想inspect CPython执行状态的功能都还能正常运行。
在伪代码里还可以看到每条CPython字节码处理的开头都有一个对 frame->f_lasti 的赋值。这同样是为了保证严格的兼容性而做的——CPython有许多地方在泄漏解释器的内部状态,例如traceback模块,例如inspect模块,又例如毫无保护的C API,它们都可以去查看Python解释器栈的状态,而这个由 PyFrameObject 构成的栈中很重要的内容就是“当前执行到哪里了”,也就是这个 f_lasti 字段。要想百分百兼容依赖了这些抽象泄漏的众多现有的Python库,要么就得这样死板的实现,否则就得实现得非常非常非常麻烦。
另外可以发现,生成的MSIL里还嵌入着一些native函数调用。Pyjion把这些函数叫做intrinsics,也可以叫做runtime helper function。Pyjion通过这种方式来支持Python字节码里隐含的“复杂操作”,例如那个PyJit_Add()。它的实现长啥样呢?
PyObject* PyJit_Add(PyObject *left, PyObject *right) {
// TODO: Verify ref counting... PyObject *sum;
if (PyUnicode_CheckExact(left) && PyUnicode_CheckExact(right)) {
PyUnicode_Append(&left, right);
sum = left;
}
else {
sum = PyNumber_Add(left, right);
Py_DECREF(left);
}
Py_DECREF(right);
return sum;
}
这其实就跟CPython解释器里的BINARY_ADD字节码的内部实现几乎是一样的,只是把求值栈的操作映射到了MSIL层面上。
而面对这样的runtime helper函数,RyuJIT只能当它们是黑盒子而无法进一步分析与优化,也就无从內联这些函数的调用。
仔细看的同学可能会想:这个例子里参数a与b所指向的对象的引用计数临时增减在a、b都是数字时是可以对消掉的,可以在编译后的代码里优化掉这样的引用计数代码。这就是“自动引用计数”(ARC,automatic reference counting)在编译器里很常见的优化方式。但是正因为Pyjion把引用计数的增加放在了RyuJIT可以分析的MSIL层面,而把引用计数的减少放在了RyuJIT无法分析的runtime helper函数里,最终编译的结果就是没有能消除掉引用计数代码,错失了一个优化机会。
在Windows x86-64上的RyuJIT,最终会把上面的foo()函数例子编译为这样的机器码:
// function prologue
push rdi
push rsi
sub rsp,28h
mov rsi,rdx
lea rdi,[rsi+88h]
mov rcx,rsi
mov rax,offset pyjit!gMETHOD_PY_PUSHFRAME+0x38
call qword ptr [rax]
// 0: LOAD_FAST 0 (a)
xor ecx,ecx
mov dword ptr [rdi],ecx
mov rcx,qword ptr [rsi+188h]
lea rdx,[rcx+10h]
add dword ptr [rdx],1 // ob_refcnt++
// 3: LOAD_FAST 1 (b)
mov dword ptr [rdi],3
mov rdx,qword ptr [rsi+190h]
lea rax,[rdx+10h]
add dword ptr [rax],1 // ob_refcnt++
// 6: BINARY_ADD
mov dword ptr [rdi],6
mov rax,offset pyjit!gMETHOD_ADD_TOKEN+0x38
call qword ptr [rax]
test rax,rax
je L_Raise
// 7: RETURN_VALUE
mov dword ptr [rdi],7
jmp L_ret
// default exception handler
L_Raise:
mov rcx,rsi
mov rax,offset pyjit!gMETHOD_EH_TRACE+0x38
call qword ptr [rax]
L_Reraise:
xor edi,edi
jmp L_finalRet
// function epilogue
L_ret:
mov rdi,rax
L_finalRet:
mov rcx,rsi
mov rax,offset pyjit!gMETHOD_PY_POPFRAME+0x38
call qword ptr [rax]
mov rax,rdi
add rsp,28h
pop rsi
pop rdi
ret
嗯…跟上面的MSIL层面的逻辑几乎完全一样,只是MSIL层面的求值栈和局部变量都被优化到x86-64指令集的寄存器上了,其它就跟伪代码里写的一模一样。
Pyjion要真的让CPython的性能有突飞猛进的发展,还有很长的路要走。
就这个例子来说,其实它的 *lasti = 0 和 *lasti = 3 都是完全冗余的,因为可以假设CPython不会有机会观察到这俩状态——直到下次Pyjion要通过periodic_work进入CPython runtime,或者下次调用可能暴露实现细节的CPython函数 (*)。诸如这样的冗余可以通过更彻底的静态分析来消除掉,只是要实现它就得堆人力和时间了。
而许多能有效提升动态语言性能的技巧,在当前的CPython上都行不通,因为它对自己的内部状态实在没有啥封装可言,内部实现细节泄漏得到处都是。如果能堵上那些抽象泄漏,就可以把隐藏类(hidden class)、多态內联(polymorphic inline caching)、类型推导以及进一步优化一股脑的堆上去了。不幸的是CPython社区就喜欢这些泄漏的抽象,怕是难说服社区接受这种程度的改变——不然大家现在都该在用Pyston或者PyPy了。
另外,Pyjion未来要想进一步提升性能,需要在“哪些东西暴露在MSIL / IR层面“与”哪些东西封装在intrinsics / runtime helper function“之间找到一个更好的平衡。现在因为Pyjion把很多操作都放在了intrinsics里,RyuJIT无法理解也无法优化它们,失去了优化的机会;但如果把太多细节暴露给RyuJIT的话,方法体可能又会太大,让RyuJIT工作得太吃力。如何在两者间找到个好的平衡是门艺术。做得好的话,一些冗余的引用技术更新也应该可以消除掉,那就很爽。
下次有机会再展示一下Pyjion目前已经做了的一种优化——带标记的指针(tagged pointer)。
(*) 这个思路就跟JVM里某些优化可以在两个safepoint之间进行,但不能跨越safepoint边界一样。