当前位置: 首页 > 工具软件 > Pyjion > 使用案例 >

pyjion python3.6_Pyjion的代码质量一例 [20160221]

李胡媚
2023-12-01

上一篇文章简单介绍了一下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边界一样。

 类似资料: