Newshot
Account

为 yk 适配 CPython

Translation(model translation, not the source's own text)

为 yk 适配 CPython

今年早些时候,我写过一篇关于 yk 的文章,它可以自动为 C 解释器添加 JIT 编译器。自那以后,我们把 yk 应用到了更多解释器上,修复了各种 bug,并显著提升了性能。十月下旬,我展示了一些数据:在我们的基准测试套件1 上,yklua 比它作为基础的 PUC Lua 解释器快 2 倍。

我本来还挺得意,直到有人把我拉回现实,问了一句:“你怎么知道这对大型解释器也管用?”这是个合理的问题:PUC Lua 至少比 CPython 或 CRuby 小一个数量级2,谁又能说它对大得多的解释器也管用呢?大约一年前我们看过 CPython,但那时 yk 还缺少 CPython 需要的几样显而易见的东西,于是我们很快就放下了。自那以后发生了很多变化,而一次快速的 LLM 实验走得足够远,表明值得再看一次。

令人高兴的是,深入查看之后,CPython 不仅能被适配为 ykcpython3 在 yk 上工作,而且还能带来有意义的性能提升——不仅是相对于普通 CPython,也是相对于 Python 的实验性 JIT4。粗略地说,这是直接在 ykcpython 上工作 3-4 天的成果;此外还单独花了一周半时间修复 yk 的通用 bug,才让我能着手看 ykcpython 本身。

以下是我在 ykcpython 和 AWFY 基准测试套件上短暂探索得到的“最终”数字:

cpythoncpython_jitykcpython
Bounce19.27 ±0.2011.09 ±0.0510.09 ±0.04
CD32.21 ±0.2326.10 ±0.0829.33 ±0.16
DeltaBlue7.43 ±0.026.21 ±0.027.62 ±0.04
Json11.81 ±0.0810.93 ±0.059.98 ±0.03
List15.01 ±0.228.10 ±0.0510.44 ±0.09
Mandelbrot11.66 ±0.286.94 ±0.274.20 ±0.07
NBody18.97 ±0.1312.77 ±0.1210.98 ±0.02
Permute22.97 ±0.3417.95 ±0.1321.04 ±0.05
Queens13.18 ±0.149.65 ±0.118.32 ±0.03
Richards54.80 ±0.2040.56 ±0.4033.18 ±0.17
Sieve23.34 ±0.0617.02 ±0.0713.74 ±0.06
Storage22.03 ±0.1919.94 ±0.0819.67 ±0.04
Towers20.46 ±0.2512.65 ±0.0513.67 ±0.04

在这张表中,cpython 是“普通”的 CPython 3.15.1;cpython_jit 是打开了实验性 JIT 的 CPython 3.15.1;ykcpython 是提交 31b4d387。时间单位为秒(越低越好),均值和 99% 置信区间由 30 次进程执行 bootstrap 得出,运行在 64 核 AMD EPYC 7773X 的 Debian 机器上,处于 performance 模式(可下载可复现实验和原始数据)。每一行中最快的条目以粗体显示。

对这张表的简单总结是:ykcpython 在每个基准测试上都比 cpython 快,除了 DeltaBlue(在那里它大约慢 2.5%);并且在 13 个基准测试中有 8 个比 cpython_jit 快。

这篇文章的目的并不是假装 ykcpython 已经以任何方式准备好供人使用——它绝对没有!也不是假装人们可以期待在更广泛的程序上性能能达到上面的数字——绝对不会!我的看法是,基准测试套件的性能是一道必须跨过的坎,而不是目的本身:一个基准测试表现好的系统在真实世界中可能表现很差;但一个基准测试表现差的系统在真实世界中几乎总是表现很差。

更确切地说,我想用这篇文章来展示为 yk5 适配一个解释器是什么样子:我经历的各个阶段;以及让人感受我新增的略少于 450LoC 的代码是如何足以产生有意义的效果的。幸运的是,我觉得这可能会是一段有用的旅程,所以我记下了足够多的东西,得以把这篇完整的文章写出来。

快速回顾

为 yk 适配 CPython

如果你还没读过之前那篇 yk 文章,这里做一个非常简短的回顾。给定一个经过轻微修改的 C 解释器6作为输入,yk 会输出一个带有元追踪 JIT 编译器的 VM7。yk 的目标不是产生尽可能最快的 JIT 编译语言实现:相反,它的目标是让你熟悉并喜爱的语言实现稍微快一些,同时保持 100% 兼容性。换句话说,一个“ykified”解释器是现有 C 版本解释器的一个分支,带有(希望如此!)少量调整。

与任何元追踪 JIT 编译器一样,yk 会识别来宾语言(例如 Lua 或 Python)中的热循环,并追踪解释器(即记录 C 代码在解释来宾语言时正在执行什么)。然后它优化这些追踪并将其转换为机器码。

yk 要求解释器通过 YkLocation 结构体告诉它来宾语言中潜在循环的位置。可选地——不过如果你想获得良好性能,那绝对是必要的!——解释器作者可以添加优化提示,这些提示嵌入关于该语言或其常见使用习惯的知识,例如“此时这个看似动态的值几乎从不改变”。一般来说,弄清楚把 YkLocations 放在哪里相对容易,但弄清楚把提示放在哪里需要对解释器有深入得多的理解,并且需要仔细测量以了解哪些有帮助、哪些没有帮助。

我为 yk 适配解释器时面临的挑战是,我很少真正了解这个解释器,甚至完全不了解。与其试图一次性完成所有事情,我学会了尽可能简单起步:我尝试做最少的改动,让一些追踪被记录并编译成机器码。这可能比看起来更棘手:我必须理解构建系统8,并弄清楚把一些半合理的 YkLocations 放在哪里。我还学会了确保该语言的测试套件从这第一步开始就能正常工作:此后保持它正常工作不仅容易得多,而且能减少尝试不健全优化的次数。

对于一个简单的解释器,你可能只需改动少于 10LoC(代码行数)就能得到一个基础 JIT 编译器。对于稍大一些的解释器,往往需要更多一些改动。例如,yk 不喜欢在操作码之间使用 goto 的“threaded”解释器,因为这没有给它足够的机会介入并查看发生了什么。幸运的是,这类改动通常不会太大。

找到合适的起点

由于我已经很久没有查看 CPython 的内部结构9,我从自己隐约熟悉的部分开始。后来我发现,这部分现在被称为“tier 1”解释

我没想到 ykcpython 的这个第一版会比 CPython 更快——至少它几乎没包含任何优化提示——但它比我预期的慢得多。它确实在生成 trace,而且这些 trace 执行正确——测试套件通过了,尽管占用的内存比我期望的更多10——但显然有什么地方不对。

幸运的是,没花多久就弄清了原因。4 年前,CPython 在“特化”(specialisation)这个名目下加入了对 quickening 的支持。Quickening 是一种简单而巧妙的技术,用于提升解释器性能:在可能时,它把“通用”操作码(例如“add objects”)重写为“特化”的、更快的操作码(例如“add two ints”),必要时再去优化回通用操作码。我倾向于认为,quickening 提供了 JIT 编译器相当一部分的好处,却不用费心生成机器码11。对 yk 来说,问题在于有时程序的操作码会在特化和非特化操作码之间大量次地来回切换,而我添加 yk 支持的简单方式意味着每次切换都会隐式地丢弃关联的 trace 并创建一个新的 trace。大多数情况下,trace 被使用的时间不够长,无法抵消创建它们的成本。

有一些技术可以克服这个问题,我确实短暂尝试过它们,但后来我想起去年和一些 CPython 开发者的一次对话。快速查看 CPython 内部证实了我的记忆:CPython 也包含一个“tier 2”解释器。这不是一个全新的解释器;更确切地说,它是主解释器循环核心部分的另一种实现,主要面向 CPython 的实验性 JIT 编译器。

一个非常快速的实验随后表明,tier 2 解释器——至少在基本层面上——更适合 yk12。因此我移除了 tier 1 支持,并在 tier 2 解释器中加入了对 yk 的支持。令人高兴的是,tier 2 支持甚至比 tier 1 支持更容易,仅由 80 LoC 组成。为了让你快速了解 yk 支持是什么样子,下面是给循环(的开头)附加 YkLocations13 的那部分代码:


        for (uint32_t i = 0; i < executor->code_size; i++) {
  const _PyUOpInstruction *uop = &executor->trace[i];
  if (_PyUop_Uncached[uop->opcode] == _JUMP_TO_TOP) {
    uint16_t target = uop_get_jump_target(uop);
    if (yk_location_is_null(executor->yk_locs[target])) {
      executor->yk_locs[target] = yk_location_new();
    }
  }
}
      

在本文的剩余部分,我将以 yk 和 tier 2 解释器作为 ykcpython 的起点。

基准测试的对象是什么

在优化任何语言实现时,重要的是要知道它的性能在更广泛的性能版图中处于什么位置。为了了解 ykcpython 表现如何,我使用了另外两个基线:“原版”CPython;以及带有实验性 JIT 的 CPython。我按如下方式配置 cpython:


        CC=clang-21 CXX=clang++-21 \
  ./configure \
  --enable-optimizations \
  --with-lto
      

请注意,我故意为 cpython 和 cpython_jit 使用相同版本的 LLVM,因为后者需要 LLVM-21。ykllvm——yk 中使用的 LLVM 分支——基于比 LLVM-21 晚大约 6 个月的 LLVM 版本。这可能会轻微扭曲比较,但差异足够小,希望影响相当轻微。考虑到这一点,ykcpython 按如下方式配置:


        ../configure \
  --without-computed-gotos \
  --without-tail-call-interp \
  --enable-experimental-jit=interpreter \
  --without-ensurepip
      

我使用了我们 AWFY 基准测试套件的 fork14,只是因为我知道如何不加思索地运行它。

下一个挑战是定义一个或多个指标来报告性能。每个指标都有可能产生误导,而且很多指标难以理解。在我看来,最不糟糕的单一指标是挂钟时间:我的程序从开始到结束需要运行多久?这并不能捕捉我们想知道的一切,但它能捕捉很多信息,而且容易理解。

最重要的是,这个指标隐含地包括启动和预热时间。换句话说,我故意选择一个倾向于偏袒普通解释器并“惩罚”JIT 编译器的计时指标。为了稍微抵消这一点,我们对每个 AWFY 基准测试运行 10 次进程内迭代(即在一个 for 0..10 循环中运行整个基准测试),这样我们也能大致了解优化如何使运行时间更长的程序受益。

本文开头的大型性能表使用的是空闲机器上的自定义基准测试运行器。本文接下来的所有计时都来自我以普通用户身份运行一次 time,并且不太在意机器是否完全空闲:当开始优化解释器时,在效果通常相当大15的情况下,为每个改动在许多基准上做基准测试而要等上一个小时,是不切实际的。

优化解释器分派

优化解释器以进行元追踪有多种方式,每种方式都有优缺点:我在这里展示的优化序列只是其中一种做法16。

让我们从 cpython 上的 mandelbrot 基准测试开始:


        $ time cpython harness.py Mandelbrot 10 500
Starting Mandelbrot benchmark ...
Mandelbrot: iterations=1 runtime: 1166072us
Mandelbrot: iterations=1 runtime: 1163564us
Mandelbrot: iterations=1 runtime: 1166220us
Mandelbrot: iterations=1 runtime: 1164960us
Mandelbrot: iterations=1 runtime: 1166583us
Mandelbrot: iterations=1 runtime: 1166431us
Mandelbrot: iterations=1 runtime: 1168500us
Mandelbrot: iterations=1 runtime: 1168757us
Mandelbrot: iterations=1 runtime: 1165289us
Mandelbrot: iterations=1 runtime: 1170343us
Mandelbrot: iterations=10 average: 1166672us total: 11666719us

Total Runtime: 11666719us
cpython/python harness.py Mandelbrot 10 500  11.67s user 0.01s system 99% cpu 11.687 total
      

换句话说,我以 10 次进程内迭代和参数 500 运行了该基准测试。AWFY 测试框架会打印每次迭代耗时。对于解释器来说,这很常见——但绝对不是普遍现象!——cpython 从一次迭代到下一次的耗时相当一致。

对于这个基准测试,cpython_jit 耗时 6.746s(0.58× CPython),而 ykcpython 耗时高达 13.70s(1.17× CPython)17:


        $ time ykcpython harness.py Mandelbrot 10 500
Starting Mandelbrot benchmark ...
Mandelbrot: iterations=1 runtime: 1488078us
Mandelbrot: iterations=1 runtime: 1353013us
Mandelbrot: iterations=1 runtime: 1354018us
Mandelbrot: iterations=1 runtime: 1350854us
Mandelbrot: iterations=1 runtime: 1351681us
Mandelbrot: iterations=1 runtime: 1351815us
Mandelbrot: iterations=1 runtime: 1351454us
Mandelbrot: iterations=1 runtime: 1352860us
Mandelbrot: iterations=1 runtime: 1352099us
Mandelbrot: iterations=1 runtime: 1352516us
Mandelbrot: iterations=10 average: 1365839us total: 13658388us

Total Runtime: 13658388us
ykcpython/build-yk/python harness.py Mandelbrot 10 500  13.94s user 0.07s system 102% cpu 13.691 total
      

这里有几件有用的事情值得注意。首先,我们可以看到预热正在发生:ykcpython 的第一次迭代比后续迭代慢。在我看来这没问题:yk 并不真正面向运行时间只有零点几秒的程序。其次,102% 的 cpu 和高于挂钟时间的用户时间,是因为 yk 在线程中运行追踪编译。这允许解释器继续执行,从而减少可感知的暂停。

在这种情况下,我做的第一件事是查看 yk 生成的追踪的“高级 IR”(HIR),看看是否合理:


        $ YKD_LOG_IR=/tmp/l1:hir ykcpython harness.py Mandelbrot 10 500
...
$ rg Begin /tmp/l1 | wc -l
11
$ rg Loop /tmp/l1 | wc -l
1
      

日志文件包含 11 条 trace,其中 1 条是“loop” trace。一般来说,loop trace 是 yk 最有效的地方,而非 loop trace 是我们为了获得 loop trace 这一应许之地必须付出的代价。在这种情况下,一个简单的性能剖析显示,大部分执行时间都花在唯一的那条 loop trace 上,这对 yk 来说是一个有希望的开端。

此时,要对 HIR 做比这种高层分析更多的事情很困难,因为输出是 20,772 行这类内容:


        %68: ptr = 0x13D581D4
%69: i8 = 0
store %69, %68
%71: i16 = load %64
%72: i32 = zext %71
%73: i32 = 32767
%74: i32 = and %72, %73
%75: i16 = trunc %74
%76: ptr = 0x13D58218
%77: ptr = load %76
%78: ptr = 0x0
%79: i1 = icmp eq %77, %78
guard false, %79, [%0, %2, %2, %3, %4, %5, %6, %7, %8, %9, %10, %11, %12, %13, %14, %15, %16, %17, %18, %19, %20, %21, %22, %23, %24, %25, %26, %27, %28, %29, %30, %31, %32, %33, %34, %35, %36, %37, %38, %39, %40, %41, %42, %43, %44, %45, %64, %74, %75, %78, %56, %49, %50, %51, %52, %53]
      

即使你习惯了这种语法和思维方式,也会极难看清全局:东西实在太多了。而且这不仅是阅读它的(人类或 AI)的问题:更多 HIR 意味着更多机器码,也就意味着更慢的执行。

我优化解释器时的第一站是处理解释器分派。当解释器执行程序时,它不断问:“当前指令的 opcode 是什么?是 add 还是 sub 还是……?”然后分派到执行相应动作的代码。那 20KLoC 的 HIR 中很可能有很大一部分是解释器从内存加载 opcode 并检查它是什么类型。yk 的 trace 优化器有各种经典编译器技巧,但在这里它们帮不上什么忙,因为在大多数情况下,在同一条指令从内存加载两次之间,会调用一些其他代码,而 yk 必须假设这些代码可能已经修改了那块内存。这不是不必要的保守:Python 程序确实可以修改其函数的指令!

我特别幸运,因为 tier2 解释器使用“executors”,它们是(优化后的)指令的 trace。在给定 executor 的上下文中,这些指令是不可变的:如果 Python 函数的指令发生变化,任何关联的 executor 都将失效。这意味着我们可以给 _PyExecutorObject 附加一个简单的单调递增整数 code ID,并知道对于给定的 code ID,索引 I 处的 opcode 将始终相同。

这让我们可以使用 yk 中最有效的优化提示:幂等函数。本质上,幂等函数表示“对于相同的输入,该函数将始终产生相同的输出”18。这里的“始终”确实就是“始终”!幂等是一个非常强的保证,yk 无法帮你维持:如果你做不到,你的系统会以奇怪的方式出错。

尽管 yk 提供了辅助函数 yk_idempotent_load,我们需要更专门的东西。幸运的是,写出我们需要的东西并不难:


        __attribute__((yk_idempotent))
uint16_t idempotent_load_opcode(uint64_t code_id, const _PyUOpInstruction *pc)
{
    __asm__ volatile("" : "+r"(code_id));
    return pc->opcode;
}
      

不出所料,yk_idempotent 属性告诉 yk,idempotent_load_opcode 是一个幂等函数。这意味着,如果在特定 trace 中,该函数以常量值作为参数被调用,那么 yk 可以移除该幂等函数调用,并用其输出替换。__asm__ 那一行是为了促使 LLVM 不要内联该函数19(如果函数被内联,yk 就不再知道那里有任何幂等的东西)。

使用它只需要修改核心解释器循环中的几行。最重要的几行就是:


        yk_promote(next_uop);
yk_promote_code_id(yk_code_id);
uopcode = idempotent_load_opcode(yk_code_id, next_uop);
      

提升(Promotion,例如 yk_promote)20 的意思是:“这个变量预期是半常量:记录它的具体值,并让 trace 针对该值特化,但插入一个 guard,以防该值之后发生变化”。假设我有 yk_promote(x),之后记录某条 trace 时,x 恰好是 3。该 trace 会记录 guard true(x == 3):如果后续运行时 x 不是 3,guard 就会失败,该 trace 会去优化;如果 guard 成功,我们就知道从此以后 x 肯定是 3。

换句话说,在我们正在记录的 trace 的上下文中,yk 会看到以常量参数调用 idempotent_load_opcode,从而允许它把函数调用替换为常量结果。这样做不只是为了消除一次加载的开销:把常量数据暴露给 yk 的 trace 优化器,能让它更好地优化 trace。这里的效果相当惊人:


        $ time ykcpython harness.py Mandelbrot 10 500
Starting Mandelbrot benchmark ...
Mandelbrot: iterations=1 runtime: 606714us
Mandelbrot: iterations=1 runtime: 485324us
Mandelbrot: iterations=1 runtime: 485994us
Mandelbrot: iterations=1 runtime: 484999us
Mandelbrot: iterations=1 runtime: 483824us
Mandelbrot: iterations=1 runtime: 485431us
Mandelbrot: iterations=1 runtime: 484752us
Mandelbrot: iterations=1 runtime: 486160us
Mandelbrot: iterations=1 runtime: 486125us
Mandelbrot: iterations=1 runtime: 483481us
Mandelbrot: iterations=10 average: 497280us total: 4972804us

Total Runtime: 4972804us
ykcpython/build-yk/python harness.py Mandelbrot 10 500  5.09s user 0.08s system 103% cpu 5.005 total
      

换句话说,这个简单的改动不仅让 ykcpython 快了 2.7×,而且它现在是 cpython 的 0.43×、cpython_jit 的 0.74×!换句话说,一个只改了 30LoC 的简单 PR,就让这个基准比 CPython 快了一倍多,比 CPython 的 JIT 快了 25%。判断这项优化效果的另一种方式是,它把 20,772 行 HIR 输出减少到了 7,648 行21。

代码需要被追踪才能被优化

然而,在我们得意忘形之前,这还不足以让所有基准都神奇地变快。经验告诉我,要在不同基准之间轮换,以避免过度训练22。这次我决定看看 deltablue 基准,我从 yklua 知道它可能是个难啃的目标。ykcpython 的情况比我预期的更糟:cpython 耗时 7.67s,cpython_jit 耗时 6.20s,而 ykcpython(加上我们的幂等改动)耗时 37.51s。哎哟!

我下一步是运行 YKD_LOG=2,它会在 trace 无法编译时打印警告。果然:


        $ ykcpython harness.py DeltaBlue 10 12000
Starting DeltaBlue benchmark ...
yk-warning: stop-tracing-aborted: Trace too long
yk-warning: stop-tracing-aborted: Trace too long
yk-warning: stop-tracing-aborted: Trace too long
yk-warning: stop-tracing-aborted: Trace too long
yk-warning: stop-tracing-aborted: Trace too long
yk-warning: stop-tracing-aborted: Trace too long
DeltaBlue: iterations=1 runtime: 3362822us
yk-warning: trace-compilation-aborted: Unimplemented: '%188 = atomicrmw or ptr %187, i64 16 seq_cst, align 8'
...
      

目前我先跳过“跟踪过长”。本质上,yk 对“这条 trace 看起来太长,可能没什么用”有一个固定限制。这个限制太低了,所以在做这篇文章相关工作之前,我单方面提高了它。具体什么值才正确,我也不知道。事实上,我认为这个粗糙的启发式规则可能需要被更复杂的东西取代:那是另一天的问题了。

让我们转向 “unimplemented” 警告:我还没顾上实现复杂的原子指令,所以一旦遇到其中一条,yk 就别无选择,只能完全放弃该 trace。如果它恰好是某个基准测试的热点部分,那绝对会拖垮性能。在这个案例中,幸运的是,atomicrmw 警告恰好来自那些我们反正本来就不想内联到 trace 里的函数。

tracing 的优点之一是它会积极内联函数,但这可能会内联那些在后续执行中经常失败的检查。例如,我们默认会内联的一个函数是 _PyObject_GC_Link,它检查已分配的对象是否足够多,以使垃圾回收值得被调度。如果不走运,我们可能追踪到循环的某次迭代,而这次迭代恰好调度了垃圾回收:在之后几乎每次执行中,生成的 guard 都会失败,我们就会走稍慢的路径。这里的解决方案是把这类函数标记为 yk_outline:


        __attribute__((yk_outline))
void _PyObject_GC_Link(PyObject *op) { ... }
      

标记为 yk_outline 的函数在 trace 中会变成调用。可能有不少函数都应该标记为 yk_outline,但我标记了两个函数:它们恰好导致了那些中止警告,而且显然我们本来也不想把它们纳入 trace。这样做将执行时间改善到 36.63s(约快 2.5%),所以在这个案例中它们显然不是变慢的主要原因。

就在我开始发愁时,执行性能分析显示,令我惊讶的是,deltablue 只有大约一半的运行时间花在 JIT 编译的代码上,而人们会预期它花 90%+ 的时间。结果发现,主要原因是不是所有 executor 都有 _JUMP_TO_TOP 操作码,因而没有被追踪。

我的

为 yk 适配 CPython

不内联带循环的函数,根本原因在于追踪会展开循环,而这往往不是个好主意。首先,有些循环会执行大量迭代,这会导致适得其反的长 trace。其次,循环的迭代次数常常由动态且不断变化的值控制:展开会让 trace 只特化到其中一个值,从而在其他每个值上性能都不理想。

然而,解释器中有少数函数是我们主动希望展开的。通常,这些函数的循环满足两个性质:它们只运行少量迭代;迭代次数取决于用户程序中很少变化的一个方面。例如,一个名为 unpack_args(int num_args) 的函数包含 for (int i=0; i<num_args; i++),它会是非常好的候选,因为当它在 trace 中的同一位置被调用时,很可能每次都解包相同数量的参数。

因此 yk 提供了 yk_unroll 属性,它告诉 yk:一个否则可能被外提的函数实际上应该被内联,并且它的循环应该被展开。与其把 CPython 里每个函数都翻一遍来找候选,我在 hir 里做了简单的 grep/sort,看看哪些(隐式不内联的)C 函数最常出现在 traces23 中:


        $ YKD_LOG_IR=/tmp/l1:hir ykcpython harness.py Json 10 100
...
$ rg call /tmp/l1|grep "@"|cut -d "@" -f 2|sort|uniq -c|sort -n
   2 PyLong_FromLong
   6 _Py_HandlePending
   6 do_lookup
  24 _PyList_FromStackRefStealOnSuccess
  50 _PyEvalFramePushAndInit
  50 _PyObject_GC_Link
  50 _PyObject_InitInlineValues
 263 PyLong_FromSsize_t
1035 _PyFrame_Initialize.5328
1158 _PyFrame_ClearLocals
1194 bcmp
      

bcmp 是标准 libc 函数:其他所有函数都在 CPython 本身中。并非所有这些函数都会有循环,但大多数很可能有。在这种情况下,我会沿着列表往下(或者,取决于你怎么看,往上)看,查看每个函数中的代码。我对几个基准测试这样做了,以确保覆盖到一些常见函数。

两个最常被调用的 CPython 函数立刻看起来就是不错的候选。_PyFrame_Initialize 是两个中最明显的,几乎正好包含我最喜欢匹配的模式:


        _PyFrame_Initialize(..., int null_locals_from, ...) {
  ...
  for (int i = null_locals_from; i < code->co_nlocalsplus; i++) {
    frame->localsplus[i] = PyStackRef_NULL;
  }
  ...
}
      

_PyFrame_ClearLocals 稍微棘手一些:它包含一个涉及指针运算的 while 循环,但我没花多久就说服自己它是个好候选。

之前我们让 _PyObject_GC_Link 不可内联,所以我们不想把这一点反过来!同样,do_lookup 在字典中查找值,所以我们希望保持外提:它依赖经常变化的动态值。

PyLong_FromSsize_t 和 PyLong_FromLong 更难判断。这些函数的内部循环依赖动态值,但我最好的猜测是它们在实践中往往相当稳定。我未来会持续关注这些函数,因为我对自己最初判断是否正确只有中等信心。

如这些例子所示,我通常对添加 yk_unroll 相当谨慎:放置不当很容易让一个基准测试变好,却让许多其他基准测试变差。因此我倾向于只把一两个函数标记为 yk_unroll,跑基准测试,然后继续。在这个案例中,我做了 5 个小的 PR,最终效果是 json 从 11.52s 降到 10.85s。

间接内联

一般来说,C 中大多数函数调用是直接的:f() 调用一个名为 f 的函数。然而少数函数是间接的:f 是一个通用指针,其值可以是任何兼容的函数。这在解释器中常用于允许运行时分派到类型特定的代码。

为 yk 适配 CPython

默认情况下,yk 无法通过间接函数调用进行内联,因为在不知道究竟调用的是哪个指针的情况下,它无法可靠地重建执行所走的路径24。幸运的是,我们已经有解决这个问题所需的工具:yk_promote!如果 yk 看到带有常量指针的间接函数调用,它就能确切知道正在调用哪个函数,

在我开始这段小旅程之前,我并不完全确定 yk 在像 CPython 这样规模和复杂度的解释器上究竟能不能工作——更别说“工作得好”了!早期阶段也没有完全打消我的担忧:有大量缺失的部分,还有相当多的 bug,需要在 yk 中修复,ykcpython 才能运行起来。ykcpython 确实能工作,而且工作得相当不错,这证明至少有一个大型、复杂的解释器可以相对轻松地被 yk 化——并且它在性能上可以出奇地有竞争力。

当然,我在很多方面都很幸运。得益于好几位人士的努力,yk 已经有了不错的基础。CPython 中的 tier 2 解释器让我的日子比原本可能轻松得多。小型基准测试,比如我在这篇文章里使用的那些,往往会让(元)追踪作为一种 JIT 编译技术显得更有利。而说到把解释器 yk 化,嗯,我已经在这条路上摸爬滚打了好些回。

但我不想完全轻描淡写。3-4 天花在像 CPython 这样的解释器上并不算长。要做得彻底,并把 ykcpython 能暴露给 yk 的全部潜力都榨出来,会需要比这长得多的时间——即使有 LLM 的奇妙能力来加速这一过程也是如此。同样,我只看了少量基准测试:ykcpython 还不太可能神奇地加速大量程序,因为 CPython 还有许多其他部分需要考虑优化。其中很多并不是什么火箭科学,但它是实打实的工作,而且并非所有工作目前都能自动化。

我也还没有(目前还没有!)专门针对 ykcpython 对 yk 做任何性能优化(尽管我修复了几个我以前能够忽略的瓶颈)。特别是,有一件事我完全确信:ykcpython 会生成 yk 尚未有效优化的 HIR 模式:修复这些将免费提升 ykcpython 的性能,可能还不少。即便没有这些,ykcpython 也暴露出 yk 中一些以前从来不成问题的额外瓶颈:每当其中一个被修复,ykcpython 就会神奇地变快。

从某种意义上说,这是 yk 提供的好处之一:对 yk 的改进几乎总是会让每一个被 yk 化的解释器受益。这就是为什么我能在短时间内让 ykcpython 达到不错的速度:我不是从零开始写一个 JIT 编译器,而是在复用一个通用工具包。例如,ykcpython 在 yk 中暴露出的各种缺失部分和 bug,几乎肯定会让下一个解释器的 yk 化稍微容易一点——希望未来对 yk 的改进会让 ykcpython 更加受益!

致谢:Edd Barrett 和 Lukas Diekmann 完成了 yk 的大部分最初工作。Shopify 和 Royal Academy of Engineering 慷慨资助了基础研究。Chris Seaton 是让 yk 工作得以可能的人。

脚注

AWFY 和 CBLG 套件的组合,再加上一些我们从其他来源提炼出来的“真实世界”基准测试。

PUC Lua 的写法与我见过的任何东西都不一样。起初,我被宏的深度嵌套使用和广泛存在的隐式约定压得喘不过气。随着我花更多时间研究它,我开始深深欣赏其代码结构方式中的美:不仅是代码字面上的美感,还有为了让代码运行得快而投入了多少思考。

起名字不是我的强项。

实验性 JIT 可能会被移除,尽管其确切状态我也不清楚。

从技术上说,任何带有 LLVM IR 的解释器都可以。我们现在已经测试过,至少某些 C++ 解释器可以工作,但(比如说)Rust 解释器能否工作,则是未来需要担心的问题。

理解 yk 的另一种方式是,它是面向 LLVM IR 的 RPython(即“PyPy”)。正如这一点所暗示的,yk 深深受惠于 RPython,后者是它的主要灵感来源。

没人喜欢看别人的构建系统。

我与 CPython 的渊源可以追溯到很久以前。我想我在 1997 或 1998 年给 CPython 贡献过一些 RISC OS 补丁,可能是通过别人提交的。我能确定自己参与过的第一个提交是在 1999 年。自那以后,CPython 发生了巨大的变化!

在撰写本文时,yk 从不释放与 trace 关联的内存。我已经实现了修复这一点所需的大部分组件,只差完成最后一部分。

我完全忘了 CPython 中还有这个东西,这很尴尬,因为我过去曾主张把它纳入各种解释器。我承认,成效并不大。

不过它也暴露出 yk 中更多缺失的部分和 bug,我必须先修复它们才能确定。

如果你想知道,这非常快:YkLocation 是一个字长大小的值,除非该位置变热,否则没有其他关联内存。这里的想法是确保冷代码的常见情况成本非常低。

严格来说是一个子集,因为 Havlak 无法在原版 CPython 上开箱即用。

随着解释器变得更成熟,任何优化的效果都会变小,人们确实必须更加小心。ykcpython 还没到那一步!

ykcpython 施加了一个我们大多数人自旧时代以来就没再经历过的限制:在我的机器上链接需要几分钟。对于我们这些习惯了闪电般快速编译的人来说,这可能会让系统有点震惊。幸运的是,正如我们将看到的,少量小改动产生了巨大效果,所以它并不像我最初预想的那么烦人。

我有一个本地补丁,能把链接速度提高 2× 以上,但我还不确信它是正确的。本文描述的任何内容我都没有使用它。

ykcpython 施加了一个我们大多数人自旧时代以来就没再经历过的限制:在我的机器上链接需要几分钟。对于我们这些习惯了闪电般快速编译的人来说,这可能会让系统有点震惊。幸运的是,正如我们将看到的,少量小改动产生了巨大效果,所以它并不像我最初预想的那么烦人。

我有一个本地补丁,能把链接速度提高 2× 以上,但我还不确信它是正确的。本文描述的任何内容我都没有使用它。

我们投入了大量工作,尽可能降低 yk 中的预热成本,但它们永远不会降为零:第一次迭代比后续迭代慢 10%,因为它在做 tracing 等工作。

如果函数在内部改变状态也没问题,前提是(1)它始终保证对相同输入产生相同输出,并且(2)移除该调用及其状态改变不会影响整体正确性。

yk_idempotent 隐含 noinline,但 LLVM 习惯于在没有明显原因的情况下忽略这类请求。__asm__ 技巧让 LLVM 更不容易(注意我措辞很谨慎!)忽略 noinline。

奇怪的是,这适用于一个变量,而不是(仅仅)它的结果。说来话长。

HIR 也几乎以完全相同的系数减少,这只是巧合。HIR 指令并非都有相同的成本,而且其中还有一些形式的重复进一步搅浑了水(如果你真的感兴趣,是 guard bodies)。

换句话说:为了避免触犯 Goodhart 定律。

这并不完全等同于说它们是被调用最频繁的,但作为其替代指标也不算太糟。

想象我们调用一个未知指针 P,随后看到函数 F 中的块 B 已被 trace:我们无法判断 P==F,还是 P 引用了不可 trace 的函数 G,而 G 随后调用了 F。

请注意,yk_indirect_inline 仍然遵循“如果函数包含循环,则不将其视为可追踪”这一启发式规则,因此有时会希望同时使用 yk_indirect_inline 和 yk_unroll。

过去,弄清楚哪些函数是“间接直接”地从 trace 中调用的很麻烦,但现在 LLM 会欣然去处理,并在几分钟内返回准确的答案。

我以前没见过这个问题:看起来 ykllvm 有可能被扩展以避免这种 hacky 的修复,但那是改天再说的问题。

评论

The translation is machine-generated and individual terms may be off. Nothing is rewritten or cut; the original is authoritative.

Published by Lobsters
This site only summarises and aggregates; copyright stays with the original author. Headlines and summaries follow the source — where they differ, the original wins.
Read the original ↗