关于 Python JIT 的后续进展
Python 程序的性能 近年来一直是该语言开发的主要关注点;Faster CPython 项目 便是这一努力的重要组成部分。其中一个子项目是为语言添加一个实验性即时编译器(JIT); 去年在 PyCon US 大会上,项目成员 Brandt Bucher 介绍了 该复制与补丁 JIT 编译器。在PyCon US 2025上,他接着发表了一场题为“关于为CPython构建JIT编译器,他们没有告诉你的事情”的演讲,描述了他希望在开始该项目时就了解的一些事情。然而,有一个显而易见的问题存在,即微软停止了对该项目的支持并解雇了其Faster CPython团队的大部分成员。
布赫在演讲中仅暗示了这一事件,并在其他场合明确表示,无论后果如何,他都将继续致力于JIT编译器的开发。他在五月份发表演讲时提到,自己从事 Python 开发已有八年,其中作为核心开发者六年,担任微软 CPython 性能工程团队成员四年,并已在 JIT 编译器项目上工作了两年。尽管微软团队常被视为 Faster CPython 项目的核心,但其实只是其中一部分;“我们的团队与微软外部的许多人合作”。

Faster CPython成果
该项目在最近几个Python版本中取得了显著成果。其工作首次出现在2022年的Python 3.11版本中,相较于3.10版本,平均提速25%,具体取决于工作负载;“无需修改代码,只需升级Python即可正常运行”。此后几年中,性能进一步提升:Python 3.12 比 3.11 快 4%,而 3.13 较 3.12 提升了 7%。将于 10 月发布的 Python 3.14 将比前一版本快约 8%。
总体而言,这意味着 Python 在不到四年时间里速度提升了近 50%,他说道。项目使用的基准测试中约 93% 的性能有所提升;其中近半数(46%)提升超过 50%,20% 的基准测试提升超过 100%。这些并非简单的微基准测试,基准测试代表实际工作负载; 例如,Pylint 的速度提升了 100%。
所有这些提升均未借助 JIT 技术;它们源自团队一直在推进的其他改动,且采取了“一种全面提升 Python 性能的整体方法”。这些改动对性能产生了显著影响,且以社区可维护的方式实现。“这就是当企业资助 Python 核心开发时发生的事情,”他说,“这真是件特别的事。”在幻灯片中,这段话后跟着一个哭泣的表情符号 😢,伴随着尴尬的笑声。
接着,他给出了一个“鸭子类型”(duck typing)的示例,并在整个演讲中多次提及。该示例围绕一个鸭子模拟器展开,该模拟器会接受一个鸭子的迭代器(iterator),对每个鸭子发出“呱呱”声,然后打印出声音。作为额外功能,如果鸭子具有一个评估为 true 的 “echo” 属性,它会将声音翻倍:
def simulate_ducks(ducks):
for duck in ducks:
sound = duck.quack()
if duck.echo:
sound += sound
print(sound)
这与两个产生不同声音的类相关联:
class Duck:
echo = False
def quack(self):
return “Quack!”
class RubberDuck:
echo = True
def __init__(self, loud):
self.loud = loud
def quack(self):
if self.loud:
return “SQUEAK!”
return “Squeak!”
他逐步演示了simulate_ducks()函数中循环的执行过程。他展示了由解释器生成的基于栈的Python虚拟机字节码,并逐步解析了循环中的一次迭代,描述了栈以及鸭子和声音局部变量的变化。这一过程自Python诞生以来几乎未变。
特殊化
3.11 解释器引入了专门的字节码,其中部分字节码操作会被修改为假设其使用特定类型——该类型基于对代码执行过程的多次观察后确定。由于 Python 是动态语言,解释器始终需要能够回退到,例如查找适用于这些类型的正确二进制运算符。但在循环运行几次后,它可以假设“sound += sound”将对字符串进行操作,因此可以切换到一个具有该显式操作快速路径的字节码。“你实际上拥有可以处理任何情况的字节码,但它为实际对象、数据结构和内存布局的形状内联了快速路径。”
所有这些都构成了JIT编译器的底层,该编译器使用专用的字节码解释器,并且可以被视为同一管道的一部分,布赫尔表示。然而,JIT编译器在Python的任何构建版本中默认都是禁用的。如他在去年的演讲中所描述的,专用的字节码指令会被进一步分解为微操作,这些微操作是“单个字节码指令内更小的操作单元”。将字节码转换为微操作的过程完全自动化,因为字节码本身就是以微操作为单位定义的,“因此这一转换步骤由机器自动生成且速度极快”,他说道。
微操作可以进行优化,这基本上就是生成它们的全部意义,他解释道。通过观察执行微操作时遇到的不同类型和值,可以发现可应用的优化方案。部分微操作可被更高效的版本替换,另一些则可被移除,因为它们“执行的是完全冗余的工作,且我们可证明移除它们不会改变语义”。他展示了一张对应于鸭子循环的微操作列表,逐步替换并移除了接近25%的微操作,这与JIT的3.14版本所做的工作相符。
JIT随后会将微操作逐一翻译为机器码,但采用的是复制并修补的机制。每个微操作的机器码模板在CPython编译时生成;这与微操作本身以表格驱动方式生成的机制有一定相似性。由于模板并非手动编写,修复解释器其余部分的微操作中的错误也会同时修复JIT中的错误;这有助于提升JIT的可维护性,同时也降低了参与其开发的门槛,Bucher表示。
区域选择
在介绍完这些背景后,他转向了JIT编译器开发中常被忽视的“有趣部分”,首先是区域选择。此前,他展示了一系列需要转换为机器码的微操作,但并未说明该列表是如何生成的;“我们最初是如何得到这个列表的?”
JIT编译器并非从这样的序列开始,而是从类似于他的鸭子模拟的代码开始。基于该代码的运行时活动,需要回答几个问题。第一个问题是:“我们想要编译什么?”如果某段代码只运行几次,它就不是JIT编译的好候选对象,但运行次数很多的代码就是。另一个问题是编译的位置。函数可以单独编译,也可以内联到其调用者中,而这些调用者可以被编译。
何时编译代码?需要在过早编译(浪费努力,因为代码实际上并未频繁运行)与过晚编译(可能不会使程序更快)之间找到平衡。最后一个问题是“为什么?”,他说;只有在明确编译能提升代码效率时,编译才有意义。“如果他们使用了非常动态的代码模式或做了一些我们实际上编译得不太好的奇怪事情,那么可能不值得。”
一种可行的方法是编译整个函数,这被称为“一次一个方法”或“方法JIT”。这“与我们对编译器的理解自然契合”,因为这是许多提前编译器的工作方式。因此,当JIT分析simulate_ducks()时,可以直接整体编译整个函数(for循环),但还有其他优化机会。如果它识别出循环大部分时间操作的是Duck对象,可以将quack()函数内联到其中:
for duck in ducks:
if duck.__class__ is Duck:
sound = “Quack!”
else:
sound = duck.quack()
...
如果 RubberDuck 对象也很多,该类的 quack() 方法也可以被内联。同样,duck.echo 的属性查找也可以在一种或两种情况下被内联,但他表示,这开始变得有些复杂;“这并不总是很容易理解,尤其是当你在编译时它正在运行的时候”。
与此同时,如果 ducks 不是列表,而是一个 生成器 呢?在简单情况下,如果生成器中只有一个 yield 表达式,它与列表的情况差异不大,但如果生成器中包含多个 yield 表达式和循环,情况也会变得难以理解。这会形成一种优化障碍,而此类代码并不罕见,尤其在异步编程场景中。
另一种技术,也是目前CPython JIT中使用的方法,是使用“跟踪JIT”而非方法JIT。该技术会记录程序执行的线性跟踪信息,从而利用这些信息做出优化决策。如果第一个鸭子是鸭子,代码可以像之前一样进行优化,即基于类进行条件判断并内联声音赋值。接下来是查找duck.echo,但受保护分支中的代码具有完美的类型信息;它已经知道正在处理一只鸭子,因此知道echo为false,并且可以将其移除,剩下:
for duck in ducks:
if duck.__class__ is Duck:
sound = “Quack!”
print(sound)
“这相当高效。如果你只有一个鸭子的列表,你只需要做最基本的工作来让所有鸭子发出叫声。”
代码仍需处理鸭子不是鸭子的情况,但无需编译该部分;如果类守护条件为假,可以直接将其发回解释器。不过,如果代码还处理橡胶鸭对象,最终该 else 分支会变得“热门”,因为它被频繁调用。
此时可以重新启用跟踪功能,查看代码的执行情况。如果假设其中大部分是无声的RubberDuck对象,生成的代码可能如下:
elif duck.__class__ is RubberDuck:
if self.loud: ...
sound = “Squeak!Squeak!”
print(sound)
else: ...
未指定的两个分支在执行时将直接返回常规解释器。由于跟踪具有完美的类型信息,它知道 echo 为真,因此声音应被重复,但无需实际使用 “+=” 来获取结果。因此,该函数现在具有最小必要的代码,可让鸭子或非响亮的 RubberDuck 发出叫声。如果其他分支在某个时候开始变热,跟踪可以再次用于进一步优化。
跟踪 JIT 方法的一个缺点是它可能会编译相同代码的副本,例如 “print(sound)”。在 ‘非常分支的代码’ 中,布彻说,“这些跟踪尾部的一些内容可能会被重复很多次”。虽然有减少重复的方法,但这是该技术的一个缺点。
另一种选择区域的技术称为“元跟踪”,但他没有时间详细介绍。他建议与会者向自己选择的大语言模型(LLM)询问“关于‘第一个 Futamura 投影’,不要像我一样拼错,它不是‘Futurama’”,Bucher 说道,全场发出了一些笑声。
内存管理
JIT编译器“对内存做了一些非常奇怪的事情”。C程序员熟悉可读(或只读)数据,例如const数组,而可读写数据是正常情况。内存可以使用malloc()动态分配,但这种内存无法被执行;由于JIT编译器需要能够读取、写入和执行的内存,它需要“大杀器”: mmap()。“如果你知道正确的魔法咒语,你可以用这些秘密标志和数字悄悄地对它说话”,以获得可读、可写和可执行的内存:
char *data = mmap(NULL, 4096,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
需要注意的是,mmap() 返回的内存是以页面大小的块形式提供的,大多数系统中为 4KB,但可能更大。如果 JIT 代码长度为 4 字节,这可能造成浪费,因此需要谨慎管理。获得内存后,他问道,如何实际执行它?事实证明“C 允许我们做一些疯狂的事情”:
typedef int (*function)(int);
((function)data)(42);
第一行代码创建了一个名为“function”的类型定义,该类型是一个指向函数的指针,该函数接受一个整数参数并返回一个整数。第二行代码将数据指针转换为该类型,然后调用该函数并传入参数42(并忽略返回值)。“这很奇怪,但它确实有效。”
他指出,“可执行数据”这个术语应该让人们警觉;“如果你是Rust程序员,这就是我们所说的‘不安全代码’,”他笑着说。能够向可执行的内存写入数据是一件“可怕的事情;最好的情况是自掘坟墓,最坏的情况是重大安全漏洞”。因此,操作系统通常要求内存不处于这种状态。他表示,内存应首先映射为可读写状态,然后填充数据,再通过mprotect()切换为可读可执行状态;如果后续需要修改数据,可以在这两种状态之间来回切换。
调试与性能分析
当使用Python性能分析器对代码进行性能分析时,已编译的代码应调用所有相同的性能分析钩子。目前最简单的方法是不要对安装了性能分析钩子的代码进行即时编译(JIT)。在 Python 的最新版本中,性能分析是通过使用专用的自适应解释器来实现的,该解释器会将某些字节码转换为经过instrumentation的版本,这些版本会调用性能分析钩子。如果跟踪遇到其中一个经过instrumentation的字节码,它可以关闭该部分代码的 JIT,但仍可在其他未进行性能分析的代码部分中运行。
相关问题出现在有人为已JIT编译的代码启用性能分析时。此时Python需要尽快退出JIT代码。这通过在“已知安全点”之前插入特殊_CHECK_VALIDITY微操作来实现,这些安全点可跳出JIT代码并返回解释器。该微操作会检查一个位标志;如果该标志设置,则执行流程将退出JIT代码。该位标志在启用性能分析时设置,但也会在执行可能改变JIT优化的代码时使用(例如类属性的更改)。
由此自然衍生出的一个功能是支持“Python调试器的奇特特性”。JIT 代码是基于跟踪所见内容生成的,但运行 pdb 的用户可以通过各种方式彻底改变该状态(例如“duck = Goose()”)。有效性标志也可用于避免此类问题。
对于原生性能分析器和调试器(如 perf 和 GDB),需要通过 JIT 帧展开栈并交互操作 JIT 帧,但“简而言之,这非常非常复杂”。此类工具在不同平台上存在多种实现,它们的工作方式各不相同,且各自拥有用于以不同格式注册调试信息的专用 API。项目成员已意识到这一问题,但正在努力确定需要支持的工具以及它们实际所需的支持级别。
展望未来
当前 Python 版本为 3.13;可通过使用 –enable-experimental-jit 标志将其 JIT 编译到其中。对于已发布测试版的 Python 3.14(将于 10 月正式发布),Windows 和 macOS 版本已内置 JIT,但需通过在环境中设置 PYTHON_JIT=1 来启用。他不建议在生产代码中启用它,但团队很乐意听到任何使用结果:显著的性能提升或下降、错误、崩溃等。其他平台或自行构建二进制文件的人可以使用与 3.13 相同的标志启用 JIT。
对于目前处于预Alpha阶段的3.15版本,他们正在重点解决两个GitHub问题: “在 JIT 编译器中支持堆栈展开”和“使 JIT 线程安全”。第一个问题他之前曾提到过,与对原生调试器和性能分析器的支持有关。第二个问题很重要,因为CPython的自由线程构建似乎进展顺利,正朝着成为默认构建的方向发展——参见PEP 779 (“支持自由线程 Python 的标准”),该提案最近已被指导委员会接受。Faster CPython开发者认为,将JIT线程安全化并不困难;“这需要一些工作,而且在自由线程环境中确定哪些优化仍然安全需要一段时间来摸索”。然而,这两个问题都超出了他的专业领域,因此他希望拥有相关技能的人愿意提供帮助。
此外,3.15分支中当然还有大量持续的性能优化工作。他特别指出,快速进展,尤其是大型项目,将取决于资源的可用性。他幻灯片上的文字变为加粗,并咳嗽了一声以进一步强调这一点。
在总结时,他建议查阅PEP 659(“自适应解释器的专业化”)和PEP 744(“即时编译”)以获取更多信息。对于那些更愿意观看视频而非阅读文字的人,他推荐了2023年他演讲的视频(由LWN报道并已在上文链接) 关于专用的自适应解释器以及2024年关于添加JIT编译器的演讲视频。今年演讲的YouTube视频也已发布。
[感谢Linux基金会提供的旅行赞助,使我能够前往匹兹堡参加PyCon US。]
本文文字及图片出自 Following up on the Python JIT
看到Python在性能方面的一系列优化,我感到非常高兴。我一直觉得,解决与Python相关的问题时,必须以一种非常特定的方式(多进程)设计应用程序,必要时将部分代码重写为其他语言,并希望在选择多进程后永远不需要共享内存模型并发,这种做法有些奇怪。这些项目风险曾让我考虑其他语言更适合大规模工程,但Python本身拥有太多优势,不应被归类为小众语言。
Python是优秀的入门语言,但掌握它后应学习另一种注重性能的语言。
但一旦你掌握了它,就应该学习另一种注重性能的语言
问题是,它在足够好直到不再足够好。然后你就会遇到很多麻烦。
问题是,它在足够好直到不再足够好。然后你就会遇到很多麻烦。
> 或许吧。或许不是。
Python 之所以有任何“性能”优势,唯一的原因是任何严肃的工作负载都会调用非 Python 库来完成实际工作。
坦白说,我不知道你们这些人怎么了。我用Python编程已有多年,主要做网页开发,其性能通常不是我需要特别担心的事——当然不会到要放弃Python转用编译型语言的程度。当然,这因人而异。
我写的是“你应该学习另一种注重性能的语言”,不是说你应该停止使用Python。
> 说实话,我不知道你们这些人是怎么回事。
具体指的是哪群人?
> 我用Python编程已经很多年了,主要是做网页相关的工作,它的性能通常不是我需要特别担心的事
你的工作负载只是冰山一角。
举个例子。几年前我接手了一段Python 2代码,该代码会执行一系列外部命令并解析输出结果以生成数据文件,供PHP仪表盘类网页应用使用。运行时间通常约为~30秒。当主流Linux发行版最终放弃Python 2时,该代码需要迁移到更现代的版本。不幸的是,输入数据并非 Unicode 兼容,这让 Python 3 非常不满意,于是我在沮丧中说“算了”,用 Perl 重新编写了它。尽管算法完全相同,但运行时间降至约 3 秒——快了整整一个数量级。
这一运行时性能的巨大提升使我能够从异步调用切换到同步调用(无需状态跟踪和其他复杂操作),从而构建出一个更简单、更健壮且性能更优的系统。
Python 确实有其优势。但它也有其局限性。
我试图提前回答你的问题。为了实现合理的性能,你需要选择多处理作为并发范式(你可能有一个工作负载,其中异步/等待可以帮助,但最好直接选择多处理)。因此,这限制了你的设计。现在,如果你在使用一个没有现成资源池的协议(比如IMAP),并且需要在第三方应用程序(如pgbouncer)中进行资源共享,那么你将不得不自己编写资源池,或者放弃资源池功能。这些都是GIL(全局解释器锁)的问题。
由于缺乏即时编译(JIT),你在 Python 中进行任何计算都会遇到性能问题。这就是为什么许多 Python 工作负载实际上由 Fortran 支持,甚至不是 C。但为什么纯 Python 代码库无法与这种性能竞争?因为这项工作至今尚未完成。此外,多语言代码库会引发一系列包装问题,而解释型语言本不应面临这些问题。
这并非编译与否的问题。JVM字节码虽为解释型但支持JIT编译。Python在JVM上运行时也不存在这些问题。PyPy已修复部分问题。这是CPython运行时的问题。我熟悉多种语言,且在其他条件相同的情况下,这些风险是选择Python的理由,而试图辩称其他条件不相同,只是语言争论的范畴。
虽然我同意你的评论,且无权质疑Knuth,但我认为你的回应在此处并不合理。
我们确定这是Knuth的本意吗?
Knuth所说的“过早优化”是什么意思?我认为他指的是在算法上过于精明或设计层级过多。
我怀疑他指的是验证你的核心架构方法或数据结构是否合理,或者更贴切地说,选择一种具有适合任务属性的语言。
当然,我可以选择用 Python 来构建一个实时操作系统内核。但这是一个糟糕的选择。我很快就会涉及到那些非恶意的优化内容。
反之亦然。我可以选择用 C 语言来编写一个包含简单 CRUD 逻辑的 6 页网页应用程序。但这也是一个糟糕的选择。
正如Facebook(Meta)所示,你可以在用PHP等解释型语言编写了行星级社交媒体平台后,再自行开发编译器。但这无疑是一项庞大的工程,若能避免则最好避免。若你从一开始就计划打造Facebook,我认为PHP(或Python)将是个糟糕的选择。我的意思是,我想好处可能是,在你们被迫构建一个编译器后,我们其他人可能会得到一个快速的Python JIT编译器。编写编译器很有趣。但这不是我们想要表达的重点。
我基本上同意你的观点。我同意是因为我们的项目很少会发展到Facebook的规模。如果它们确实发展到那个规模,我们可以负担得起一支编译器开发团队。但我希望那些编译器开发者不要试图选择Python作为编译器的语言,并告诉我“过早优化是万恶之源”。
Python对于我们大多数人需要做的大多数事情来说已经足够快。如果遇到瓶颈,我们可以通过调用更快的实现来解决少数确实需要更快的场景。我们太过频繁地选择Rust、容器、K8S和WASM,只因我们渴望达到谷歌级别的规模。实际上,我们更应该使用Python(或类似的生产力与性能权衡的语言)。因此,我基本上同意你的观点。
然而,“它足够用,直到不再足够用”这句话也蕴含着智慧。Python 并非万能。当我设计下一个 3D 游戏引擎时,我不会首先选择 Python。
_我们过于频繁地选择 Rust、容器、K8S 和 WASM,只因我们希望达到 Google 级别的规模。实际上,我们更应该使用 Python(或类似的生产力与性能权衡方案)。因此,我基本上同意你的观点。
另一方面,Python真的方便开发吗?我认为它在运行时之前几乎不进行任何检查的模型相当不便,因为这意味着开发过程中的大部分测试必须通过实际运行程序来完成,而不是依赖编译器在早期捕获我的错误。
从理论上讲,你最终会得到一个在正常情况下运行的程序,然后你“只需”修复所有异常处理逻辑;但在实践中,往往更简单的方法是此时重写为另一种语言。
> 实际上,此时往往更简单的方法是重写为另一种语言。
“始终计划将第一个版本丢弃——这几乎是不可避免的” 🙂
祝好,
Wol
但Rust具有快速编辑/无需运行循环的特性,而在Python中,你通常需要在那个阶段测试运行你的应用程序。更不用说,你需要按照Python的约定来编写Python代码,而这些约定与Rust的约定并不兼容,如果你从一种语言开始,然后重写为另一种语言。
有
我不同意 Python 更容易从糟糕的规格文档快速生成可运行程序的评估,这正是我的核心观点。
动态语言常被宣称的“优势”——更快的迭代周期——完全依赖于省略编译时间,却忽视了在严格的编译型语言中,编译器会在你犯错时直接指出问题,从而缩短迭代周期。
更不用说,更严格的类型系统在早期设计阶段尤为有用,因为你需要通过它来识别规范中遗漏的假设和不变量。要证明这一点,看看许多OpenAPI文件或类似模式是如何因动态语言项目中动态类型系统的意外疏忽而变得破损的(例如,一个字段有时有一种类型,有时是另一种类型,有时被省略,有时为空,等等)。
你目前根本没有假设和不变量。你有一个预言家(一个人类),他会告诉你你展示的内容是更接近还是更远离他想象中的样子。
如果你指的是客户,我建议在长期支持计划确定前,尽量避免向他们展示任何运行中的内容,因为一旦客户看到运行中的系统(甚至只是视觉上看起来在运行的系统,即使底层业务逻辑尚未完善),要说服他们该系统仍需改进将极为困难。
请记住,这是在一种规格的背景下,这种规格看起来像是“我的工作中有些令人讨厌的事情;让计算机以我喜欢的方式来做这些事情”,而不是一种你可以从中工作的合理规格。如果你不定期向客户展示你的进展,你根本不知道他们想要什么。
但这正是我的观点,到那时你不得不发布Python版本,而之前有人声称这个版本“只是用于原型开发”。这就是为什么我不想把这个版本展示给客户,因为到那时我将被迫支持这种完全无法维护的语言。
为了减少Python维护负担(并将其转移到其他语言),你可以逐步将代码中稳定的部分移植到其他语言。一旦你确信已提取了与代码库某一区域相关的所有需求,就可以将其封装为Python可访问的接口,并停止维护Python版本。你无需等到整个项目完成后再对组件进行此操作。
那种完全无法维护的语言
一种策略是让未完成的部分看起来未完成。例如, instead of a polished icon from the designer (that you may indeed already have), use a crayon-like representation in the meantime (probably better if the developers make it themselves at that point).
> 你错过了重点——直到你运行代码并看到它做什么,你才不知道它做的事情是否有用。你必须运行代码才能确定是否走在正确的轨道上。
那么,为什么我的前老板(我们说的是50年前的事)在没有计算机的情况下花了六个月时间编程,而当计算机出现后,程序由秘书(那些第一次就能完美复制的人)输入时,它却完美运行了?
今天我们需要运行和测试的主要原因,是因为我们过于依赖第三方代码,而你无法信任作者是否(a)已妥善文档化,或(b)已彻底检查过代码中的错误。仅仅通过打印代码、仔细阅读代码,并使用启用最大警告的代码检查器/编译器运行代码,就能对程序产生惊人的改进。
任何合格的程序员都应将此视为常规操作,但即使在今天,我所接触的代码中,仍有大量代码显然未经过此类处理。这令人震惊,也令人沮丧。
祝好,
Wol
当规格书上写着“制作一个让$boss满意的程序”时,你根本无法进行良好的软件工程;你只能运行代码,将“展示一个让我满意的程序”转化为更正式的规格书,从那里你才能开始你前老板所做的过程。
不——你今天需要运行和测试的主要原因在于,当前给出的规格是“展示它在做某事,我会告诉你是否正确”。
似乎不太可能每个人都像你的前老板一样,每次都能一次成功。他的能力以及整个行业中所有秘书和其他程序员的能力,是遵循正态分布曲线,还是真的是一条垂直线,准确率达到100%?
至于你暗示的观点,即过去的人在运行代码前会更加谨慎,这与过去的人适应并调整自己的行为以匹配计算机的限制和运行成本一样奇怪。这与19世纪的人在旅行到其他国家时不坐飞机的原因一样奇怪。他们做不到,所以他们不做。
> 似乎不太可能每个人都像你的前老板一样,每次都能一次成功。他的能力以及整个行业中所有秘书和其他程序员的能力,是遵循正态分布曲线,还是真的是一条垂直线,达到100%的准确性?
我怀疑这就是我所说的“Word效应”。Word导致了专业素养的严重下滑,因为它让经理(及其他员工)能够自行撰写信件,但他们缺乏排版技巧和让信件易读的实际能力。那个时代充斥着难以阅读的糟糕作品。(如今虽仍不尽如人意,但远没有那么糟糕。大多数人对这些事情有更好的感觉,但他们仍然不知道规则,并且会搞砸……)
那个时代的程序员都非常擅长做这类事情。我记得学校里的同学曾带着一沓打孔卡去当地的海洋研究所,利用他们的计算机空闲时间运行程序。是的,你说得对,他们这么做是因为他们必须这样做。但是……
我怀疑这正是Khim提到的那条定律。所有这些现代编程工具实际上会*阻碍*生产力,但它们会让经验不足/能力较弱(甚至包括能力较强的人)误以为这些工具能提供帮助。
即使以法恩兹的例子来说——我敢肯定,花半小时和老板聊聊“你到底想实现什么”,就能节省许多瀑布式开发的时间。但老板认为你的时间不如他自己的时间宝贵,因此不会花那点时间和你沟通,尽管这可能导致他要花更多时间查看并拒绝你因误解而产生的成果。是的,我确实经历过这种情况,有个老板无法接受我无法理解他的要求,而我也不愿意浪费自己的时间去搞错……
归根结底,如果老板自己都不知道想要什么,他又怎么指望你知道呢?我知道应对一个无能的老板很困难,但这就是问题的核心……
祝好,
沃尔
这是因为老板的时间越难安排,你想要的时间就越贵;一个2分钟的演示环节可以在其他会议之间挤出时间,而一个30分钟的环节则需要老板承诺参加一个完整的会议,用那段时间取代他们本应做的一些其他工作。
我在使用IDE时为Python代码添加了类型提示,发现这确实帮助很大。IDE可以提示我当我向函数传递了错误的变量、输入了拼写错误,或试图访问该类型对象上不存在的属性/方法时。
类型提示作为可选功能,可以在完全静态类型化和动态类型的便利性之间提供一种有用的折中方案。在原型设计新函数时,有时能省略类型声明会很方便,这样可以先验证基本思路是否可行,而无需像编译C/C++/Rust代码时那样必须完全指定所有细节。等新Python函数设计较为成熟后,我再通过添加最终的类型提示来正式化它,这样IDE就能为我检查现有和未来的调用者。
类型提示是可选的这一事实意味着,你可以逐步将它们添加到现有代码库中,从某些目标函数开始添加提示,而无需一次性转换整个代码库,从而开始获得一些好处。我可能仍然不会用 Python 编写大型代码库,但有了类型提示,它们至少是可管理的,并且使我的中等规模脚本更好、更易于维护。
> 我们确定这是 Knuth 的本意吗?
> 我们确定这是 Knuth 的本意吗?Knuth 所谓的“过早优化”指的是什么?
他实际上是这样说的:(https://dl.acm.org/doi/10.1145/356635.356640)
> 许多当今软件工程师普遍认同的观点认为应忽视小规模效率;但我认为这只是对那些只顾眼前利益、忽视长远后果的程序员滥用优化行为的过度反应,这些程序员无法调试或维护他们所谓的“优化”程序。在成熟的工程学科中,12%的提升(且易于实现)绝不会被视为微不足道;我认为软件工程也应秉持相同观点。[…] 我不愿局限于那些剥夺我这类效率提升的工具。
>
> 效率的圣杯必然导致滥用。程序员浪费大量时间思考或担忧程序中非关键部分的速度,而这些效率提升的尝试在考虑调试和维护时实际上会产生强烈负面影响。我们_应该_在97%的情况下忽略这些微小效率:过早优化是万恶之源。
>
>然而,我们不应错过那关键的3%的机会。优秀的程序员不会被这种论调麻痹,他会明智地仔细审查关键代码;但仅在该代码被明确识别后才进行审查。
他并非认为性能不重要——他明确表示不应忽视性能。他主张采取更审慎的方法:大多数代码无需过度优化,但部分代码确实需要,且重要的是具备识别这些瓶颈并进行优化的工具。过早优化是指跳过识别步骤直接进行优化,但另一种极端做法同样糟糕。
在这篇论文中,他特别提到了诸如展开循环、将“while”语句改为“go to”以减少控制流指令数量等优化方式。你应该“从一个结构良好的程序开始,然后使用经过充分理解的、可以机械应用的变换”。如今,大多数语言都配备了能够自动应用这些变换的优化编译器,因此我们无需为了性能而牺牲源代码的可读性。
我认为这是 Python 的一个显著弱点:一旦你识别出程序的瓶颈,这些瓶颈无法通过人类或编译器直接转换为高效代码,正如实现一个不错的 JIT 时遇到的困难所示。你必须用另一种编程语言重写这些瓶颈,这远非理想之选。
听起来Python还使得识别这些瓶颈变得困难:这篇文章指出启用Python调试器会禁用JIT,并暗示原生调试器在短期内无法与JIT良好兼容,因此无法准确测量程序的实际性能。而JIT的非确定性特性使得即使在最佳情况下,性能分析也变得困难。编译型语言在识别瓶颈并通过增量更改进行优化方面通常要好得多,因为源代码与机器代码之间存在更直接的对应关系;我认为这比未优化的代码的基线性能更重要。
但Knuth讨论的优化幅度是12%。使用Python而非编译型语言的性能开销通常超过1000%——我猜他会对一位优秀的软件工程师竟会考虑这种方案感到震惊。(至少,我猜他在50年前写下这段话时会如此。现代台式机可能比当时的超级计算机快1,000,000%,这或许会改变人们对权衡关系的看法。)
Knuth 谈论的是 12% 级别的优化。使用 Python 而不是编译型语言的性能开销通常超过 1000%
我怀疑Python开发速度能达到宣称的程度,尤其与拥有良好错误信息和工具链的现代语言(如Rust)相比(而非C/C++那混乱的构建系统和错误信息)。
但即便如此,完全不花时间学习Python也可能节省大量时间,这或许足以抵消在少数几个足够小的程序上使用Python所需的几小时学习成本。
生成的 Python 代码无法调试;它充满了怪癖和 bug,这些问题混杂在一起,让你无法分辨某段代码是纯粹的 bug,还是为了让另一段代码正常工作而产生的必要副作用,而且还伴随着大量糟糕的命名。
Python代码为我和同事的薪水买单。我们能完成任务。客户满意。新客户源源不断。管理层喜欢我们的团队,因为我们的表现远超预算预测,且这种情况已持续多年。就我们而言,Python完全没问题。
很好的描述,这也是我在工作中常做的事。我的许多同事并非软件工程师,但他们能用shell和Perl进行脚本编写,我鼓励他们编写代码解决问题,这能立即为他们带来价值。如果需要将这些代码推广为持续运行的系统,我可以将他们的原型作为起点,按照公司规范和软件工程实践进行重构。
> 他主张采取更审慎的方法:大多数代码无需过度优化,但部分代码确实需要,因此拥有识别瓶颈并进行优化的工具至关重要。过早优化是指跳过识别步骤直接进行优化,但另一种极端做法同样糟糕。
顺便说一句,“过早优化”只是一个特例:许多开发者不喜欢测试。他们只想(重)写代码。
“哦,看:这段代码不够优化。我可以轻松让它运行得更快!”不管它离任何关键路径有多远,这次重写都不会带来任何用户可见的改进。有时开发者甚至不会在孤立环境中对重写代码进行微基准测试……
我认为我讨论过Knuth这段话的大多数人都准确理解了它的含义。至于这是否真的阻止了他们追求重写代码的自私快感,那就是另一个问题了 🙂
是的:这也是为什么很多Python软件不需要转换为其他语言——甚至不需要任何即时编译。因为关键路径已经用其他语言实现(在理想世界中,混合不同语言会更容易)。
PyO3
> 编译型语言在识别瓶颈并通过增量更改进行优化方面通常更胜一筹,因为源代码与机器代码之间存在更直接的对应关系;
嗯……你确定吗?调试优化后的C代码简直是徒劳无功。
是的,作为一名在高性能环境中以C语言为生的人,机器码的对应关系只有在极少数情况下与性能相关,除非我正在研究一些非常紧凑的代码片段。更多时候,这更多是关于方法——你正在做的事情,或者可能不应该做的事情,而不是机器如何实现的细节。
我这么说,是因为我发现优化很有趣。但这种情况并不常见。
> 更常见的是,这更多关乎方法——你正在做的事情,或者可能不该做的事情,而不是机器如何具体实现的细节。
我发现这一点非常正确, 初始代码往往能运行多年,但初始开发者在解决问题时可能采取了迂回的方式。一旦明确输出预期,重构代码可能只需用一个循环替代多次遍历数据集,或用哈希键查找替代复杂表达式,有时通过重新格式化输入数据以简化查找过程也能实现。最终结果是让计算机做更少的工作,这几乎总是更快,正如你所说,不需要深入的微架构知识,只需要基本的比例感。
哈哈!
我的第二份工作,老板找到我,说:“我们有个项目,六周内必须完成。你能做到吗?”我回答:“我会尽全力尝试。”我给自己定下五周的截止日期,以便在程序输出结果后,给团队其他成员留出时间完成剩余工作。
四周后,程序运行了。预计运行时间?六周!接下来的三天我疯狂优化程序——最终在第五周星期三早上交了程序(然后请了病假 :-)。团队其他成员按时完成了任务。
我基本上就是按照你说的做了……
祝好,
Wol
如果你一开始就知道要开发Facebook,我认为PHP(或Python)会是个糟糕的选择。
需要注意的是,Facebook早已重写了PHP运行时,并扩展了PHP语言以添加类型系统。他们最初使用PHP到C++的转译器,随后在2010年左右开发了自己的PHP即时编译虚拟机(HHVM),并支持一种添加静态类型的PHP方言——称为“Hack”(似乎在2014年左右开源)。
> 我们并不进行大量计算,只是为用户整理数据。因此不存在单一瓶颈,根据火焰图分析,优化峰值最多只能提升7%。
如果不进行大量计算,为何编程语言会成为瓶颈?
在等待存储、网络、数据库、用户输入等情况下,所有语言都是平等的。在这种情况下,优化需要重新架构数据和缓存,而这通常与语言无关。不过,高级语言使尝试不同设计和策略变得更加容易、安全和快速。
我不会说 Python 使更改设计和策略变得安全或容易。这正是你需要一个编译器来告诉你,在重构过程中你无意中破坏了哪些地方。
我写的是“实验”。显然,解释型语言在编译时捕获的错误要少得多,需要更多的测试覆盖率。在重构时,你绝对需要大量的测试覆盖率,而这往往永远不够。在不常见的场景中引入回归确实不好,但这并不妨碍你在实验新设计以解决常见使用场景中的性能瓶颈时进行尝试。它只会后来反过来咬你,但这并非性能特有的问题,而是所有解释型语言的本质,这是为了实现更快开发和原型设计循环必须付出的代价。
当我提到“更安全”时,我特指内存损坏和C/C++——这些问题通常是耗时最长的缺陷,其严重程度往往高出其他问题几个数量级。除了Python和C/C++外,确实存在许多其他选项,且某些语言在特定用例下可能比它们更优。
与低级语言相比,Python在原型设计上节省时间,不仅因为你需要编写更少的代码,还因为有非常高级的库和方法可供选择,尤其是在I/O和并发方面。
> 尤其是在I/O和并发方面。
除了inotify,不知为何。当然,这是在2015年,当时Python 3的大规模迁移仍在如火如荼地进行中,但我能找到的inotify库要么被包裹在过于庞大的框架中(例如Twisted),要么被维护者在Python 2上放弃(可能直到asyncio建立?)。
Python 是一门优秀的编程语言,我也很享受用它进行编程。
但所有我参与的较大规模项目最终都遇到了性能和/或多线程问题。毫无例外。
这就是我今天不再使用 Python 处理任何可能超过 1kLOC 规模的项目的主要原因。
而性能提升 50% 或 100% 其实帮助不大,因为编译型语言要快得多。
我在某些项目中通过 Cython 实现了接近编译型语言的性能。但既然存在类型系统更优的编译型语言,我为何还要用 Cython 编写代码?
使用C++时使用PyCXX
一旦你精通英语并能阅读医学文献,你基本上就是一名医生,应该尝试其他领域。
附言:我尚未遇到任何人能真正声称完全掌握一门主要编程语言。
> Python 有太多自己的优点,不应该被归类为某种小众语言。
将有史以来最受欢迎的编程语言之一描述为“小众”是相当愚蠢的。
如果 Python 是小众的,那么用什么形容词来描述 Golang 或 C#?玩具或业余语言?
如果以流行度为标准,那么“玩具”或“非专业”的东西总是更受欢迎。到处都是。
按照这种逻辑,脑残语言(Brainfuck)必须是史上最严肃的语言之一。
不。A 蕴含 B 与 B 蕴含 A 是不同的。
我明白这些例子不是实际代码。但JIT如何处理类型变化?例如,在这种实现中:
> JIT 如何检测到它应该切换回解释器?
> 如果在几次迭代后,我修改了 quack 方法的定义,比如
生成的代码有一个守护标志,用于切换执行回解释模式,而类赋值操作符具有(生成的)设置器来控制此标志。
这一技术最早由 90 年代初的 Squeak 编译器开创,随后 Java 在其 Hotspot JIT 中采用该技术进行去虚拟化,最后 V6 JavaScript JIT 进一步扩展了该技术以支持完全动态的 JavaScript。
感谢,这就是我缺失的解释。
关于“分支代码”与追踪JIT存在兼容性问题的评论,似乎忽略了某些常见用例也会采用这种方式。PyPy同样使用追踪JIT,我们在此过程中也遇到过问题。
我们的示例是解析邮件箱文件的代码。基本上,我们正在循环遍历邮件头并进行处理,而追踪 JIT 则开始为邮件头可能出现的每种顺序创建特殊化版本。生成的 JIT 代码速度更快,但内存使用量增长得更快,因此对于固定的内存量,我们最好并行运行更多非 JIT Python 解释器。
这个问题同样适用于任何类型的解释器或解析操作,即在扫描输入数据时根据输入内容选择多个函数中的一个。我们真正想要的是一个特殊的 STOP_JIT_HERE 标记,将其放置在循环的开始/结束处,以阻止 JIT 编译器对该部分进行追踪并生成大量很少使用的追踪结果。据我所记得,当时 PyPy 开发者对这种 hack 不感兴趣,但也许在此期间,技术已经进步到可以解决这个问题。
原始性能很重要,但在某些上下文中,每GB内存使用的性能也至关重要。
(不过,现在想想,这个问题因GIL的存在而加剧,因为并行处理必须在单独的进程中进行。如果能在单个Python进程中使用线程进行并行处理,那么JIT可以将很少使用的跟踪共享给所有线程,内存使用量可能就不会那么成问题。不过,我认为我更愿意牺牲一些性能以换取已知的有限内存使用量。)
Mozilla 的 Spidermonkey 在追踪技术上投入了大量资源,但最终不得不转向更传统的实现方式,因为追踪爆炸问题无法解决。
然而,JS 的性能期望远高于 Python:JS 面临的竞争更加激烈,你绝不能比竞争对手慢太多。Python 的情况截然不同;它永远不会很快,用户也无法轻松切换到更好的替代实现,因此在有限情况下通过追踪获得微小性能提升可能是可行方案。
我认为确实是 TraceMonkey 团队成员指出了为什么追踪 JIT 在实际中效果不佳: “你脱离了跟踪”。
但根本上,人们想要的是“更快的 Python”,而不是“Python JIT”,尽管人们习惯于 JIT 带来巨大的速度提升,但后者未必是实现前者的最有效方式。
显然,超块调度是一种解决跟踪爆炸问题的方案,但据我所知,目前还没有任何生产编译器实现过它。
我在线上看到的关于“超块调度”的提及,都指的是“将条件基本块转换为预测执行”,这只在特定情况下才有意义,而在传统CPU架构上,由于其有限的预测执行支持,必须非常谨慎地应用。它并不能解决所有跟踪爆炸问题。
我认为,随着Julia的成熟,这些优化Python的努力现在已经过时了。它“像Python一样运行,像C一样高效”。https://julialang.org/.
它不仅非常适合科学计算,还拥有出色的包系统(1)、绘图库(2)和Web框架(3)。
早期Julia的延迟问题如今大多已成为过去。
多分派机制带来的可组合性促进了高度模块化的包生态系统。
1. https://docs.julialang.org/en/v1/stdlib/Pkg/
2. https://makie.org/website/
3. https://genieframework.com/
鉴于现有的 Python 代码量,即使人们明天完全停止使用 Python 转而使用 Julia(这不会发生,抱歉),加快 Python 的运行速度仍然有价值。我的意思是,现在有了 Ocaml,C 语言已经过时了,但不知何故,人们仍然对更快的 C 编译器感兴趣 😛
我发现 Julia 远不如 Python 友好。我最早遇到的问题(当时我正在考虑用它来教授数学课程)是默认整数类型为 int64,这会导致常见的静默截断问题。
我认为,一个旨在成为用户友好型 Python 替代品的语言不应在 2^63 处静默包裹整数。你可以使用大整数,但为什么不默认使用大整数,然后在需要时选择64位整数呢?
Julia的一个酷炫功能是REPL模式。
以下是一个示例模式,该模式支持任意精度算术运算:https://github.com/MasonProtter/ReplMaker.jl?tab=readme-o…
> 需要注意的是,通过 mmap() 分配的内存是以页面大小的块形式分配的,大多数系统中为 4KB,但可能更大。如果 JIT 代码的长度为 4 字节,这可能会造成浪费,因此需要谨慎管理。一旦获得该内存,他问道,如何实际执行它?事实证明“C 允许我们做一些疯狂的事情”:
>
> typedef int (*function)(int);
> ((function)data)(42);
>
> 第一行创建了一个名为“function”的类型定义,该类型是一个指向函数的指针,该函数接受一个整数参数并返回一个整数。第二行将数据指针转换为该类型,然后调用该函数并传入参数42(忽略返回值)。“这很奇怪,但它有效。”
这并不总是成立。大多数情况下是这样,但有几种情况例外。
首先,在某些架构中,间接跳转地址的低位(以及函数指针的低位)用于指示处理器应使用的执行模式。例如,在32位Arm架构中,LSB为1表示T32/Thumb模式,为0表示A32/Arm模式;在32位MIPS架构中,同样通过LSB区分MIPS32和microMIPS32(或被microMIPS32取代的MIPS16e)。
其次,某些ABI使用函数描述符来表示语言级函数指针。在此,函数指针并非指向待执行的指令,而是指向包含此类指针以及一个或多个其他指针的结构体,通常是某种库级全局指针。这种情况在PA-RISC、Itanium和64位PowerPC上使用其ELF ABI的第1版时存在(第2版取消了这一特性,而大多数现代发行版在64位PowerPC上使用第2版), 但在嵌入式环境中,其他多个指令集架构(ISA)也存在类似的ABI变体(有时称为“FDPIC”,即“函数描述符位置独立代码”),因为这允许在无MMU系统中在进程间共享单个库代码副本。
goto *
BX指令
Thumb-2 也是如此。当使用互操作分支指令(如 `bx`、`blx`、`ldr pc`、`pop {pc}` 等)时,地址的最低位(LSB)决定 CPU 是否切换到 A32 或 T32 模式。当使用非互操作分支指令(如`b`、`bl`、`mov pc`等)时,CPU保持当前模式,地址的最低 1-2 位 LSB 将被替换为 0。
在 C 语言中,指向 Thumb 函数的函数指针的最低有效位(LSB)会被设置为 1(因此它们并非内存中指令的实际地址),编译器会生成 `blx` 指令。
根据在 GCC 和 Clang 中的快速测试,计算跳转指针的最低有效位(LSB)并未被设置。如果函数处于 Thumb 模式,编译器将生成 `orr r0, #1; bx r0`(在交错分支前设置 LSB)或 `mov pc, r0`(非交错分支)。不允许在不同函数之间进行计算跳转,而且我认为单个函数不能混合使用 A32 和 T32 指令(除非使用内联汇编等),因此编译器可以安全地假设它不会切换模式。
orr.w r3, r1, #1 bx r3
-marm