Python高性能编程第3版1了解高性能Python-4如何成为高性能程序员5:Python 的未来


1.4 如何成为高性能程序员

编写高性能代码只是长期成功项目中高性能的一部分。团队远比速度提升和复杂的解决方案更重要。这其中有几个关键因素–良好的结构、文档、可调试性和共享标准。

假设你创建了一个原型。你没有对它进行彻底测试,也没有让团队对它进行审核。它看起来确实 “足够好”,并被推向生产。由于它从未以结构化的方式编写,因此缺乏测试,也没有文档记录。突然间,又有一段会造成惰性的代码需要其他人来支持,而管理层往往无法量化团队的成本。

由于这种解决方案难以维护,因此往往会一直无人问津–它从未被重组过,也没有测试来帮助团队重构它,更没有人喜欢去碰它,因此只能由一个开发人员来维持它的运行。这可能会在压力大的时候造成可怕的瓶颈,并引发重大风险:如果该开发人员离开项目,会发生什么?

通常情况下,如果管理团队不了解难以维护的代码所造成的持续惰性,就会出现这种开发方式。证明从长远来看,测试和文档可以帮助团队保持较高的工作效率,也有助于说服管理者分配时间来 “清理 ”这些原型代码。

在研究环境中,在迭代各种想法和不同数据集时,使用糟糕的编码实践创建许多 Jupyter 笔记本是很常见的。我们总是希望在稍后阶段 “把它写好”,但这种情况从未出现过。最后,虽然得到了一个可行的结果,但却缺少重现、测试和信任结果的基础架构。同样,风险因素很高,对结果的信任度也很低。
以下是一个对你有帮助的通用方法:

    1. 让它发挥作用
      首先,建立一个足够好的解决方案。建立一个 “一扔了之 ”的解决方案是非常明智的,它可以作为解决方案的原型,以便在第二个版本中使用更好的结构。在编码之前做一些前期规划总是明智的,否则,你会发现 “我们通过一下午的编码节省了一个小时的思考时间”。在某些领域,这被称为 “两次测量,一次裁剪”。
    1. 确保正确
      接下来,你要添加一个强大的测试套件,并提供文档和明确的可重现性说明,以便其他团队成员能够接手。这也是讨论代码意图、提出解决方案时面临的挑战以及构建工作版本过程中的注意事项的好机会。当需要重构、修复或重建代码时,这将对未来的团队成员有所帮助。
    1. 快速开发
      最后,您可以专注于剖析、编译或并行化,并使用现有的测试套件来确认新的、更快的解决方案是否仍能按预期运行。

1.4.1 良好的工作实践

有几个 “必备条件”–文档、良好的结构和测试是关键。

一些项目级别的文档可以帮助你保持一个简洁的结构。它还会在将来帮助你和你的同事。如果跳过这一部分,没人会感谢你(包括你自己)。将这部分内容写入顶层的 README 文件是一个明智的起点;如果需要,以后还可以扩展到 docs/ 文件夹中。

解释项目的目的、文件夹中的内容、数据的来源、哪些文件是关键文件,以及如何运行所有文件,包括如何运行测试。

对于临时存储有用的命令、函数默认值或其他使用代码的智慧、技巧或窍门,NOTES 文件也是一个不错的解决方案。虽然这些信息最好能放在文档中,但在这些信息(希望)进入文档之前,有一个记录板来保存这些信息,对于不遗忘重要的小信息是非常有价值的。

Micha 还建议使用 Docker。一个顶级的 Dockerfile 可以向未来的自己解释,你需要从操作系统中获得哪些库才能让这个项目成功运行。它还消除了在其他机器上运行代码或将其部署到云环境中的困难。通常,在继承新代码时,仅仅是让它运行起来玩一玩就会成为一大障碍。Dockerfile 可以消除这一障碍,让其他开发人员立即开始与你的代码交互。

添加 tests/ 文件夹并添加一些单元测试。我们更喜欢使用 pytest 作为现代的测试运行器,因为它基于 Python 内置的 unittest。开始时只需几个测试,然后将它们建立起来。进而使用覆盖率工具,它将报告测试实际覆盖了多少行代码–这将有助于避免令人讨厌的意外。

如果你继承的是传统代码,而且缺乏测试,那么一项高价值的活动就是在前面添加一些测试。一些 “集成测试 ”可以检查项目的整体流程,并确认通过特定的输入数据可以得到特定的输出结果,这将有助于你在随后进行修改时保持理智。

每当代码中出现问题时,就添加一个测试。被同一个问题咬两次是没有价值的。

在代码中为每个函数、类和模块添加文档说明,这对你总是有帮助的。尽量对函数实现的功能进行有用的描述,并尽可能包含一个简短的示例来演示预期输出。如果你想获得灵感,可以看看 numpy 和 scikit-learn 的文档说明。

每当你的代码变得太长(例如函数长度超过一个屏幕)时,你就可以对代码进行重构,使其变得更短。较短的代码更易于测试和支持。

在开发测试时,请考虑遵循测试驱动开发方法。当你明确知道需要开发什么,手头又有可测试的示例时,这种方法就会变得非常高效。

您可以编写测试、运行测试、观察测试失败,然后添加函数和必要的最小逻辑来支持您编写的测试。当所有测试都能正常运行时,你就大功告成了。通过提前确定函数的预期输入和输出,你会发现函数逻辑的实现相对简单。

如果不能提前定义测试,自然就会产生这样的问题:你真的了解你的函数需要做什么吗?如果不了解,你能以高效的方式正确编写吗?如果您正处于创作过程中,并且正在研究您还不太了解的数据,那么这种方法就不太管用了。

一定要使用源控制–只有当你在不方便的时候覆盖了一些关键内容时,你才会感谢自己。养成频繁提交的习惯(每天,甚至每 10 分钟提交一次),并每天推送到版本库。

遵守 PEP 8 编码标准。更妙的是,在提交前的源代码控制钩子上采用 black(有主见的代码格式化工具),这样它就能帮你按照标准重写代码。使用 flake8 对代码进行润色,以避免其他错误。

创建与操作系统隔离的环境会让你的工作更轻松。Ian 喜欢使用 Anaconda,而 Micha 则喜欢将 pyenv 与 virtualenv 结合使用,或者直接使用 Docker。两者都是明智的解决方案,比使用操作系统的 Python 全局环境要好得多!

请记住,自动化是你的朋友。减少手工操作意味着减少错误发生的机会。自动构建系统、与自动测试套件运行程序的持续集成以及自动部署系统将乏味和容易出错的任务转化为任何人都可以运行和支持的标准流程。建立持续集成工具包(如在代码检查到代码库时自动运行测试)决不是浪费时间,因为这将加快并简化未来的开发工作。

“艰苦创业 ”是我们的学习方式–如果我们将思维外包给 GenAI 系统,那么我们总会得到一个答案,有时甚至可能是正确的。通过创造性地找出自己的解决方案,你将继续在头脑中建立新的模式,而不是强化那些已经存在于更广阔世界中的模式。这对你有好处。举个简单的例子,GitHub Copilot 为 Ian 写了一个简单的正则表达式–但这并不是 Ian 会写的正则表达式,所以花了点时间才弄明白。事后看来,如果能多想一会儿,写出一个 “符合伊恩思考这个问题的方式 ”的解决方案,而不是从一个随机的网络打字员那里引入一个解决方案,那会更快。

在早期项目之间建立库是节省复制粘贴解决方案的好方法。复制和粘贴代码片段很有诱惑力,因为这样做很快,但随着时间的推移,你会拥有一系列略有不同但基本相同的解决方案,每个解决方案都很少或根本没有测试,因此会有更多的错误和边缘情况影响你的工作。有时,后退一步,寻找机会编写第一个库,可以为团队带来重大胜利。

最后,请记住,可读性远比聪明更重要。对于你和你的同事来说,短小复杂且难以阅读的代码片段将很难维护,因此人们会害怕接触这些代码。取而代之的是,编写一个较长、易于阅读的函数,并用有用的文档来说明它将返回什么,同时辅以测试来确认它是否如你所期望的那样工作。

1.4.2 优化团队而非代码块

在构建解决方案时,有很多方法会浪费时间。在最坏的情况下,也许你研究的是错误的问题或使用的是错误的方法;也许你走在正确的道路上,但开发过程中的一些杂事拖慢了你的进度;也许你没有估算出可能会阻碍你前进的真实成本和不确定因素。又或者,你误解了利益相关者的需求,花时间构建了一个功能或解决了一个实际上并不存在的问题。

确保您解决的是一个有用的问题至关重要。找到一个拥有尖端技术和大量整洁缩略语的酷项目可能会非常有趣,但不太可能带来其他项目成员所欣赏的价值。如果你所在的组织正试图带来积极的变化,那么你就应该把重点放在那些起阻碍作用的问题上,因为这些问题的解决会带来明显的积极结果。

在找到潜在的有用问题后,值得反思的是–我们能实现有意义的变革吗?仅仅修复问题背后的 “技术 ”并不能改变现实世界。解决方案需要部署和维护,需要被人类用户采用。如果技术解决方案遇到阻力或障碍,那么你的工作将一事无成。

在确定这些阻力并不令人担忧之后,您是否已经估算了您能够产生的潜在影响?如果你发现问题的某个部分可以产生 100 倍的影响,那就太好了!这部分问题对组织的日常工作是否有意义?如果你能对一个每年只有几个小时的问题产生 100 倍的影响,那么这项工作(很可能)是没有用的。如果你能对一个每天都在伤害团队的问题做出 1% 的改进,那么你就是英雄。

估算你所提供的价值的一种方法是思考当前状态的成本和未来状态的潜在收益(当你写出解决方案时)。如何量化成本和改进?将估算与金钱挂钩(因为 “时间就是金钱”,而我们都在燃烧时间)是一种很好的方法,可以找出你会产生什么样的影响,以及如何将其传达给同事。这也是对潜在项目选项进行优先排序的好方法。

一旦找到了有用且有价值的问题,就需要确保以合理的方式解决它们。遇到棘手的问题并立即决定使用棘手的解决方案可能是明智之举,但从简单的解决方案入手,了解其行之有效或行不通的原因,可以迅速获得宝贵的见解,为解决方案的后续迭代提供参考。怎样才能最快、最简单地学到有用的东西?

伊恩曾与一些客户合作,这些客户的NLP管道近乎复杂,但对其实际运作信心不足。经过审查,我们发现一个团队建立了一个复杂的系统,但却忽略了上游数据注释不完善的问题,而这个问题正困扰着 NLP ML 流程。通过改用简单得多的解决方案(不使用深度神经网络,使用老式的 NLP 工具),我们发现了问题所在,并对数据进行了一致的重新标注;只有这样,我们才能建立起更复杂的解决方案,因为上游问题已经被合理地消除了。

您的团队是否向利益相关者清楚地传达了其成果?团队内部沟通是否清晰?缺乏沟通很容易给团队的进步带来令人沮丧的代价。

回顾一下您的协作实践,检查诸如频繁的代码审查等流程是否到位。忽略代码审查很容易 “节省时间”,却忘了你让同事(和你自己)带着未经审查的代码离开,而这些代码可能在解决错误的问题,或者可能包含错误,而这些错误可以在产生更严重的影响之前被新的眼睛发现。

参考资料

1.4.3 远程办公

自 COVID-19 大流行以来,我们见证了向完全远程和混合实践的转变。虽然有些组织试图让团队回到现场,但由于最佳实践已得到合理的理解,大多数组织都采用了混合或完全远程实践。

远程意味着我们可以生活在任何地方,招聘和合作者的范围也会更广,无论是局限于相似的时区还是完全不受时区限制。一些组织已经注意到,Python、pandas、scikit-learn 等开源项目与分布在全球各地、成员很少见面的团队合作得非常成功。

加强沟通是至关重要的,而且往往必须发展 “文档第一 ”的文化。有些团队甚至说,“如果我们的聊天工具(如 Slack)上没有记录,那么它就没有发生过”–这意味着每个决定最终都要写下来,以便交流和搜索。

长期完全远程工作也很容易让人感到孤立无援。定期与团队成员进行交流(即使你们不是在同一个项目上工作),以及在没有安排的时间里进行更高层次的交流(或者只是谈谈生活!),对于感受团队的联系和团队的一员来说非常重要。

1.4.4 关于良好笔记本实践的一些想法

如果你正在使用 Jupyter 笔记本,你可能会发现它们非常适合可视化交流,但也容易让人偷懒。如果你发现自己在笔记本中留下了冗长的函数,那么可以将它们提取到 Python 模块中,然后添加测试。
在 IPython 或 QtConsole 中练习原型化代码;将代码行转化为 Notebook 中的函数,然后将其从 Notebook 中提取出来,转化为一个模块,并辅以测试。最后,如果封装和数据隐藏有用,可以考虑用类来封装代码。

在 Notebook 中大量使用断言语句,以检查函数是否按预期运行。在 Notebook 中测试代码并不容易,在将函数重构为独立模块之前,assert 检查是一种简单的方法,可以增加一定程度的验证。在将代码提取到模块并编写合理的单元测试之前,您不应该信任这些代码。

使用断言语句检查代码中的数据是不可取的。断言某些条件是否满足是一种简单的方法,但它并不是 Python 的习惯用法。为了让其他开发人员更容易阅读您的代码,请检查您的预期数据状态,然后在检查失败时引发一个适当的异常。如果函数遇到意外值,常见的异常就是 ValueError。Pandera 库就是一个测试框架的例子,它以 Pandas 和 Polars 为重点,用于检查数据是否符合指定的约束条件。

您可能还想在 Notebook 结尾添加一些合理性检查–逻辑检查、raise 和 print的混合物,以证明您刚刚生成的正是所需的内容。六个月后,当你再看这段代码时,你会感谢自己让它很容易就能看到它自始至终都在正确运行!

使用Notebook的一个困难是如何与源代码控制系统共享代码。它是你的救星,能帮助你与同事协作。

1.5 Python 的未来

在本文发表时,有两个有趣的进展正在发生–全局解释器锁最终可能会被移除,同时可能会添加一个即时编译器。实验工作正在进行中,但目前还不清楚这些变化将如何影响 Python 解释器未来的生产版本。

1.5.1 GIL

正如在 “计算单元 ”中所讨论的,全局解释器锁 (GIL: global interpreter lock) 是一种标准的内存锁定机制,不幸的是,它可以使多线程代码以最差的单线程速度运行。GIL 的作用是确保一次只能有一个线程修改一个 Python 对象,因此如果一个程序中的多个线程试图修改同一个对象,它们实际上每次只能修改一个。

这大大简化了 Python 的早期设计,但随着处理器数量的增加,编写多核代码的负担也越来越重。GIL 是 Python 引用计数垃圾回收机制的核心部分。

2023 年,Python 决定研究构建无 GIL 版本的 Python,除了长期使用的 GIL 构建外,该版本仍支持线程。由于第三方库(如 NumPy、Pandas、scikit-learn)编译的 C 代码依赖于当前的 GIL 实现,因此外部库需要进行一些代码调整,以支持 Python 的两种构建,并在较长时期内转向无 GIL 构建。没有人希望重蹈 Python 2 向 Python 3 过渡 10 年的覆辙!

Python 增强提案 PEP 703 以科学和人工智能应用为重点描述了该提案。该领域的主要问题是,在 CPU 密集型代码和 10-100 个线程的情况下,GIL 的开销会大大减少并行化的机会。如果改用本书中介绍的标准解决方案(如多处理),就会带来大量的开发人员开销和通信开销。这些方案都无法在不付出巨大努力的情况下最大限度地利用计算机资源。

本 PEP 指出了需要控制的非原子对象修改问题,以及新的线程安全的小对象内存分配器。
我们可以期待无 GIL 版本的 Python 从 2028 年开始普遍可用–如果在这一过程中没有发现重大阻碍的话。

1.5.2 JIT 吗

从 Python 3.13 开始,我们预计几乎每个人都会使用的主 CPython 将内置一个即时编译器 (JIT: just-in-time)。
这种 JIT 遵循 2021 年的一种设计,称为 “复制和修补”,最早用于 Lua 语言。相比之下,在 PyPy 和 Numba 等技术中,分析器会发现速度较慢的代码段(又称热点),然后编译一个机器代码版本,将该代码块与该机器 CPU 可用的任何专用功能相匹配。这样就能获得非常快速的代码,但编译过程在早期可能会比较昂贵。
复制和修补 “过程与对比方法略有不同。在构建 Python 可执行文件时(通常由 Python 软件基金会构建),LLVM 编译器工具链用于构建一组预定义的 “模板”。这些模板是 Python 虚拟机中关键操作码的半编译版本。之所以称为 “模板”,是因为它们有 “漏洞”,稍后会被填上。
在运行时,当发现一个热点–通常是一个数据类型不会改变的循环–你就可以使用一组与操作码相匹配的模板,并通过粘贴相关变量的内存地址来填补 “漏洞”。这有望比编译每个已识别的热点要快得多;它可能不是最佳的,但希望能在不进行缓慢的分析和编译的情况下带来显著的收益。

在 Python 的主要版本中,JIT 的实现经历了几个演变阶段:

  • 3.11 引入了自适应类型专用解释器,速度提高了 10-25%。
  • 3.12 引入了内部清理和特定领域语言来创建解释器,以便在构建时进行修改。
  • 3.13 引入了一个热点检测器,利用复制和修补 JIT 在专用类型上进行构建。
    值得注意的是,虽然在 Python 3.13 中引入 JIT 是一大进步,但它不太可能影响我们的 Pandas、NumPy 和 SciPy 代码,因为在内部,这些库通常使用 C 和 Cython 来预编更快的解决方案。JIT 将对任何编写本地 Python,尤其是数值 Python 的人产生影响。

1.6 小结

我们已经了解了 Python 的底层组件及其更广泛的生态系统,思考了如何使用 Python 高效地开发科学代码,并触及了当前 Python 核心语言的一些变化,这些变化可能会在未来几年从根本上提高 Python 的效率。现在,让我们来了解一下剖析,以便快速找出瓶颈所在,从而将时间集中在正确的地方。