新的一年,新气象。在编程界也有很多的好消息,其中一个是来自于大家都熟悉的Python语言,在圣诞节前夕祝Python 3.13的分支收到了一个CPython核心开发人员的提交,该提交用来给Python增加JIT。对于一个解释语言来说该功能的意义是巨大的,Java、C#等语言或者熟悉PyPy的同学可能知道JIT带来的好处,其他同学可能对此还有疑问,那么虫虫就来给打家解释一下JIT,其工作原理,以及JIT给Python带来的巨大好处。
概述JITJIT是英文“Just-In-Time Compilation,”的缩写,它是一种动态编译技术,使用该技术在代码第一次运行时按需进行编译,然后可以把一些运行时结果以字节码缓存(操作码Opcode)的方式持久化下来。这是一个非常强大的技术,可极大的提高程序执行的效率,比如绝大多数的Java都以JIT方式提高其性能。
其实Python中某些项目已经使用曲线的方式使用这种技术,比如PyPy高性能的法宝就是JIT。
Python字节码从技术上讲,Python 编译器已经支持JIT,因为它从Python代码编译为字节码。通常说的JIT编译器时,指的是发出机器代码的编译器。它与AOT(提前)编译器形成对比,例如GNU C编译器、GCC或Rust编译器rustc,生成机器代码一次并作为二进制可执行文件分发。
当运行Python代码时,它首先被编译成字节码。关于Python字节码距离真正的CPU执行的机器码差还很明显:
对CPU没有任何意义,需要特殊的字节码解释器循环来执行。
高级的,相当于1000条机器指令。
与类型无关。
是跨平台的。
示例解释对于一个非常简单的Python函数f()定义一个变量a并赋值1
def func(): a = 1 return a它编译为5个字节码指令,可以通过运行dis.dis查看之:
对于这个函数,Python 3.11编译成指令LOAD_CONST, STORE_FAST, LOAD_CONST, 和RETURN_VALUE。当函数由用C编写的大规模循环运行时,这些指令将被解释。
如果要在Python中编写一个与C中的循环等效的非常粗略的Python评估循环,它会看起来像这样:
import dis def interpret(func): stack = [] variables = {} for instruction in dis.get_instructions(func): if instruction.opname == "LOAD_CONST": stack.append(instruction.argval) elif instruction.opname == "LOAD_FAST": stack.append(variables[instruction.argval]) elif instruction.opname == "STORE_FAST": variables[instruction.argval] = stack.pop() elif instruction.opname == "RETURN_VALUE": return stack.pop()def func(): a = 1return a如果给这个解释器来执行测试函数,对打印出前面同样的结果:
print(interpret(func))
这个带有大switch/if-else语句的循环是CPython解释器循环工作原理的等效版本,尽管是简化版本。CPython用C语言编写并由C编译器编译。为了简单起见,我们用Python构建了这个功能。
对于解释器,每次运行该函数时,func它必须循环遍历每条指令并将字节码名称(称为操作码)与每个if语句进行比较。这种比较和循环本身都会增加执行的开销。如果运行该函数10000次并且字节码永远不会改变(因为它们是不可变的),那么这种开销也是不必要的。按顺序生成代码而不是每次调用函数时都判断该循环会更有效。这就是JIT 的作用。
JIT 编译器有多种类型。Python中的JIT除了PyPy之外,Numba、Pyston和Pyjion 也都支持JIT。
Pyjion JIT解决方案:
和现有这些JIT不同,Python 3.13的提议则给Python语言层增加一种新型的JIT,复制和修补JIT(copy-and-patch JIT)。
复制和修补JIT从未听说过复制和修补JIT?大多数人估计估计没有听过过,我也是看到这个提交时候才查出来的。这是一个中专门针对动态语言运行时的快速算法。
同样按照实例化的方法通过扩展解释器循环并将其重写为JIT来解释什么是复制和修补JIT。之前,解释器循环做了两件事,首先解释(查看字节码)然后执行(运行指令)。当然,也可以分离这些任务,让解释器输出指令而不执行它们。
复制和修补JIT的想法是复制每个命令的指令并填充该字节码参数(或patch)的空白。这是一个重写的示例,保持循环非常相似,但每次都会附加一个带有要执行的Python代码的代码字符串:
原始函数的结果是:
def f():stack = []variables = {}stack.append(1)variables["a"] = stack.pop()stack.append(variables["a"])return stack.pop()f()这次,代码是连续的,不需要循环来执行。在此可以存储结果字符串并根据需要多次运行它:
compiled_function = compile(copy_and_patch_interpret(func), filename="<string>", mode="exec")print(exec(compiled_function))print(exec(compiled_function))print(exec(compiled_function))那有什么意义呢?好吧,生成的代码做了同样的事情,但它应该运行得更快。
意义和“完整”JIT编译器相比,这种为每个字节码编写指令并修补值的技术有优点也有缺点。完整的JIT编译器通常会编译高级字节码,例如LOAD_FAST转换为IL(中间语言)中的较低级指令。由于每种CPU架构都有不同的指令和功能,因此编写一个将高级代码直接转换为机器代码并支持32位和64位CPU、以及Apple的ARM架构以及所有其他版本的ARM。相反,大多数JIT首先编译为IL,这是一个通用的类似机器代码的指令集。 这些指令类似于:
“PUSH A 64-bit integer”, “POP a 64-bit float”, “MULTIPLY the values on the stack”
然后,JIT可以在运行时将IL编译为机器代码,方法是发出特定于CPU的指令并将它们存储在内存中以便稍后执,比如yjion项目就是实现这种方法:
一旦有了IL,就可以对代码运行各种有趣的优化,例如常量传播和循环提升。“完整”JIT 的一大缺点是,一次编译为IL,然后再次编译为机器代码的过程很慢。 它不仅速度慢,而且占用大量内存。最近一片论文的研究“Python遇到JIT编译器:简单实现和比较评估”中数据表明,基Java的Python JIT(如GraalPy和Jython)的启动时间可能比普通CPython长100倍,并且消耗需要额外的GBRAM来编译。
选择复制和修补是因为从字节码到机器代码的编译是作为一组“模板”完成的,然后将这些“模板”缝合在一起并在运行时使用正确的值进行修补。这意味着普通Python用户不会在其Python运行时内运行这种复杂的JIT编译器架构。 Python编写自己的IL和JIT也是不合理的,因为有很多现成可用的工具,例如 LLVM和ryuJIT。但完整的JIT需要将这些内容与Python捆绑在一起,并增加所有额外的开销。
复制和修补JIT则只需要在从源代码编译CPython的机器上安装LLVM JIT工具,对于大多数人来说,这意味着为python-org构建和打包CPython 的CI机器。
工作原理Python的复制和修补编译器的工作原理是将一些新的API 扩展到 Python 3.13 的API。这些更改使得可插入优化器能够在CPython运行时被发现并控制代码的执行方式。这个新的JIT是这个新架构的可选优化器。
当从源代码编译CPython时,可以提供一个标志--enable-experimental-jit到配置脚本。这将为Python字节码生成机器代码模板。这是通过首先复制每个字节码的C代码来实现的,例如最简单的LOAD_CONST:
frame->instr_ptr = next_instr;next_instr += 1;INSTRUCTION_STATS(LOAD_CONST);PyObject *value;value = GETITEM(FRAME_CO_CONSTS, oparg);Py_INCREF(value);stack_pointer[0] = value;stack_pointer += 1;DISPATCH();该字节码的指令首先由C编译器编译成一个小型共享库,然后存储为机器代码。 因为有一些变量通常在运行时确定,例如oparg,C代码编译时留下的参数为0。 然后有一个需要填充的0值的列表,称为空洞。为了LOAD_CONST,有2个空洞需要填补,即oparg和下一条指令:
static const Hole _LOAD_CONST_code_holes[3] = {{0xd, HoleKind_X86_64_RELOC_UNSIGNED, HoleValue_OPARG, NULL, 0x0},{0x46, HoleKind_X86_64_RELOC_UNSIGNED, HoleValue_CONTINUE, NULL, 0x0},};然后,所有机器代码都作为字节序列存储在文件中jit_stencil.h它是由新的构建阶段自动生成的。反汇编代码作为注释存储在每个字节码模板上方,其中JIT_OPARG和 JIT_CONTINUE是要填补的洞:
新的JIT编译器在激活时会将每个字节码的机器代码指令复制到一个序列中,并用代码对象中该字节码的参数替换每个模板的值。生成的机器代码存储在内存中,然后每次运行Python 函数时,都会直接执行该机器代码。
性能说了这么多,当然最重要要的一点是,它能让Python代码更快么?
最初的基准测试显示平均性能提高2-9%,不同平台差异很大有的还慢13%,有些则快47%。
看起来改善也好像很有限,没有太大的提高。
然而,这这是JIT的开始,如果继续改善,一切都是可能。有了0到1,1到100还成问题么?
总结对解释性语言的性能提高是个棘手的问题,而通过提前编译似乎是一个方向。Python 3.11的自适应解释器是朝着正确方向迈出的一步,但Python还需要走得更远才能看到性能的阶跃变化。
虽然第一个1版本的JIT宝宝的没有严重削弱任何基准测试,但它为一些巨大的优化打开了大门,当然除了性能问题外,可能在其他功能方面也能带来一些有益的进步,尚需要持续观察探索。