Python 3.13 的 JIT 方案最终确定了,我觉得可以说又新又好。所以深夜水一篇水文,来聊聊这个 JIT 方案
这篇文章可能会有些枯燥,所以如果对此不感兴趣的同学可以直接 x 掉
基础知识
在聊 Python 3.13 具体的实现之前,我们需要来了解下它所采用的 JIT 方案的基础知识
JIT 本身的定义我相信阅读这篇文章的同学已经非常了解了,所以此处不再赘述。JIT 核心分为两大块
- 代码的 profile,以确定热点路径,尽可能的减少 JIT 的 fallback
- 汇编代码的生成
本文主要会聊代码的生成部分
在此之前,Python 生态里一个 JIT 的实现,Pyston/Pypy,他们所采取的方案其实是和 LuaJIT 的方式类似,开发者手写汇编来完成代码的特化,然后依赖 DynASM 执行相关的代码
这种方式主要的缺陷在于
- 手写汇编带来的心智负担
- 对于平台的兼容性
为了给大家一个直观的感受,我给出一个我之前写过的汇编的例子来作为演示
首先,我需要实现的功能很简单,用 C 来描述应该是这样的
1 | int main(int argc, char *argv[], char * envp[]) { |
因为一些尺寸极端敏感的场景,这份 C 代码没有办法直接 link libc,为了尽可能的压缩 binary size,我选择用汇编实现,以下是 X86_64 的 ASM
1 | .global _start |
同时,因为这个功能需要跨平台实现,所以我们需要同时实现 ARM64 的版本,以下是 ARM64 的 ASM
1 | .global _start |
你能发现,X86_64 的寄存器和 ARM 生态完全不一样,这就导致了我们需要为不同的平台写不同的汇编代码,你再考虑下我们需要
- MIPS
- RISC-V
- PowerPC
- …
即便 DynASM 已经对跨平台做了一些抽象,但是直接手写汇编所带来的心智负担还是非常大的
所以,我们需要更现代化的方案,这就是今天要聊到 Copy And Patch。其核心在于利用已有编译器生成的汇编代码,然后对其进行 patch,来完成代码的特化
我们一点点来了解这个方案,首先我们从最基础的一个代码入手
假设我们现在有一个最基础的 C 代码
1 | int add(int a, int b) { |
这个代码没有任何问题,我们可以直接用 gcc 来编译它,然后反汇编,看看它的汇编代码是什么样的
1 | 0000000000000000 <add>: |
最基础的汇编代码,没有问题。
那么我们现在有这样一个场景,我们提供两个函数
- load_left
- load_right
这个两个函数将用于加载我们左右两个操作数,然后我们的代码变成下面这样
1 | int load_left(); |
我们开垦一下汇编
1 | 0000000000000000 <add>: |
我们关注到,汇编中有这样两行奇怪的东西
1 | e: e8 00 00 00 00 callq 0x13 <add+0x13> |
Bingo,熟悉一些基础的程序知识同学应该反应过来了,e8
指令(即 x86 下的 callq 指令)后面的 00 00 00 00
地址,将会在执行时,被 reloc 成为 load_left
和 load_right
的地址。
那么可能有些同学已经反应过来了,如果我们有办法将这段汇编代码中的 e8 00 00 00 00
替换成 e8 xx xx xx xx
,那么我们就可以在这里 patch 上我们的代码了。这里是不是可以作为我们 JIT 的入口了呢?
当然,这里有一个问题,e8
后面的指令地址应该怎么样确定呢?
这里我们可以注意到,程序中有这样的部分 000000000000000f: R_X86_64_PLT32 load_left-0x4
, 这个是一个 ELF 的 Relocation Entry,它的作用是告诉我们,e8
后面的地址,应该是 load_left
的地址,同时,我们也能知道重定向部分的起始 0x0f
.
同样的类型还有很多,比如 R_X86_64_PC32
,R_X86_64_GOTPCREL
等等,这些类型的 Relocation Entry 都可以帮助我们定位到我们需要 patch 的地址,以及帮助我们计算偏移
再举个例子
1 | // 17f: 48 bf 00 00 00 00 00 00 00 00 movabsq $0x0, %rdi |
这里我们可以看到,48 bf 00 00 00 00 00 00 00 00
和 48 be 00 00 00 00 00 00 00 00
后面都有一个 R_X86_64_64
的 Relocation Entry,这个 Relocation Entry 告诉我们,这两个指令后面的地址,应该是 .rodata.str1.1
的地址,同时,我们也能知道重定向部分的起始 0x181
和 0x18b
,这样我们就可以计算出偏移,然后 patch 上我们的代码了
那么这就是整个 copy and patch 的大概过程,我们可以利用编译器生成的汇编代码,然后通过 Relocation Entry 来定位我们需要 patch 的地址,然后 patch 上我们的代码。最终尽可能的简化我们的心智负担
Python 3.13 的 JIT
Python 3.13 目前的 JIT 方案已经确定下来了,它的核心就是 Copy And Patch,现在我们整体来看一下
首先,Python 有一个 Python/executor_cases.h
文件,囊括了我们所有的字节码和对应的操作
比如
1 | case _BINARY_OP_ADD_INT: { |
然后我们新增加了一个 tools/template.c
文件,
1 |
|
其中,_JIT_OPCODE,由编译时传入,作为当前的 opcode,因为这是一个固定值,所以编译器在编译的时候,会 strip 掉其余的分支,只保留当前 opcode 的分支,某种意义上,核心的 switch 部分就编程这样了(以 _BINARY_OP_ADD_INT 为例)
1 | switch(_BINARY_OP_ADD_INT) { |
我们最终能得到这样的汇编
1 | // 0: 55 pushq %rbp |
OK,我们在编译器(目前 Python 选用的 LLVM 系列的工具链,编译器为 clang)开了 O3 编译后得到中间文件后,我们利用 llvm-objdump
和 llvm-readobj
来获取到我们需要的信息(这里其实也是一个非常棒的细节,因为我们要跨很多平台,要处理几种不同的二进制格式,比如 Linux 下 ELF,Windows 下 PE,MacOS 下 Mach-O,所以我们需要一个统一的工具来处理这些二进制格式,而 LLVM 的工具链就是这样的工具)我们能注意到,在上面的代码中,有这样一些重定向条目
1 | // 0000000000000025: R_X86_64_64 _Py_stats |
然后我们就可以根据从工具链中获取到的信息,来定位到我们需要 patch 的地址,然后生成一些运行时 patch 的 flag,最终生成这样一份 C 代码
1 | static const unsigned char _BINARY_OP_ADD_INT_code_body[306] = {0x55, 0x41, 0x57, 0x41, 0x56, 0x41, 0x55, 0x41, 0x54, 0x53, 0x48, 0x83, 0xec, 0x18, 0x48, 0x89, 0x54, 0x24, 0x10, 0x49, 0x89, 0xf7, 0x48, 0x89, 0x7c, 0x24, 0x08, 0x4c, 0x8b, 0x66, 0xf0, 0x48, 0x8b, 0x6e, 0xf8, 0x49, 0xbe, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x49, 0x8b, 0x06, 0x48, 0x85, 0xc0, 0x74, 0x07, 0x48, 0xff, 0x80, 0x88, 0xa4, 0x01, 0x00, 0x48, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4c, 0x89, 0xe7, 0x48, 0x89, 0xee, 0xff, 0xd0, 0x49, 0x89, 0xc5, 0xf6, 0x45, 0x03, 0x80, 0x48, 0xb9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x75, 0x24, 0x49, 0x8b, 0x06, 0x48, 0x85, 0xc0, 0x74, 0x07, 0x48, 0xff, 0x80, 0x78, 0x58, 0x09, 0x00, 0x48, 0x89, 0xcb, 0xff, 0xd1, 0x48, 0x89, 0xd9, 0x48, 0xff, 0x88, 0xc8, 0x15, 0x04, 0x00, 0x48, 0xff, 0x4d, 0x00, 0x74, 0x37, 0x41, 0xf6, 0x44, 0x24, 0x03, 0x80, 0x75, 0x49, 0x49, 0x8b, 0x06, 0x48, 0x85, 0xc0, 0x74, 0x07, 0x48, 0xff, 0x80, 0x78, 0x58, 0x09, 0x00, 0xff, 0xd1, 0x48, 0xff, 0x88, 0xc8, 0x15, 0x04, 0x00, 0x49, 0xff, 0x0c, 0x24, 0x75, 0x2b, 0x48, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4c, 0x89, 0xe7, 0xff, 0xd0, 0xeb, 0x1a, 0x48, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x48, 0x89, 0xef, 0xff, 0xd0, 0x48, 0x89, 0xd9, 0x41, 0xf6, 0x44, 0x24, 0x03, 0x80, 0x74, 0xb7, 0x49, 0x8d, 0x47, 0xf0, 0x4d, 0x85, 0xed, 0x74, 0x2e, 0x49, 0x83, 0xc7, 0xf8, 0x4c, 0x89, 0x28, 0x48, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8b, 0x7c, 0x24, 0x08, 0x4c, 0x89, 0xfe, 0x48, 0x8b, 0x54, 0x24, 0x10, 0x48, 0x83, 0xc4, 0x18, 0x5b, 0x41, 0x5c, 0x41, 0x5d, 0x41, 0x5e, 0x41, 0x5f, 0x5d, 0xff, 0xe0, 0x48, 0x8b, 0x4c, 0x24, 0x08, 0x48, 0x29, 0xc8, 0x48, 0x83, 0xc0, 0xb8, 0x48, 0xc1, 0xe8, 0x03, 0x89, 0x41, 0x40, 0x31, 0xc0, 0x48, 0x83, 0xc4, 0x18, 0x5b, 0x41, 0x5c, 0x41, 0x5d, 0x41, 0x5e, 0x41, 0x5f, 0x5d, 0xc3}; |
最终所有指令编译完成后,最终会生成 jit_stencils.h
文件,被我们其余 CPython 代码引用,编译进我们的二进制中
然后我们来看下,我们的 JIT 是如何工作的
1 | int |
这一部分代码看似很复杂,实际上核心代码很简单,利用 jit_alloc
生成一块内存,然后利用 emit
将我们的汇编代码写入到这块内存中,然后利用 mark_executable
和 mark_readable
将这块内存标记为可执行和可读,最终将这块内存的地址赋值给我们的 executor,这样我们的 executor 就可以执行我们的 JIT 代码了
然后
1 | patches[HoleValue_CODE] = (uint64_t)code; |
这一部分就是将我们提前预置的一些 flag 设定具体的值,以便后续的 patch
然后 patch 核心的部分,就是根据各平台的 LDD 规则来将我们动态的一些地址 patch 到 relocate 的位置
1 | switch (hole->kind) { |
整体上的思路就是差不多这样一些,剩下的就是一些 corner case 的处理,本文先不在展开。大家感兴趣的话,我单独开单篇再来聊一些
总结
我们能发现 Python 3.13 JIT 方案的一个很大的特点是,尽可能的利用了 LLVM 生态的东西,编译器用 clang,编译参数开 -o3 获取最大的性能,二进制用具用 llvm-objdump
和 llvm-readelf
,这样做相较于其余方案的好处非常非常的明显
- clang 的编译器优化能力非常强,能够生成非常高效的代码
- 能够利用 LLVM 生态的工具链,能够更好的处理跨平台的问题
- 避免了人工维护的困境,大部分的改动也能通过自动化的方式生成与集成,避免低级错误的诞生
所以我说 Python 3.13 的 JIT 方案可谓是又新又好