Pyjion的代码质量一例 [20160221]

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

5 条评论