对 4 万行 Python 代码进行类型检查的路径。 第2部分

今天,我们将发布有关 Dropbox 如何组织数百万行 Python 代码的类型控制的材料翻译的第二部分。

对 4 万行 Python 代码进行类型检查的路径。 第2部分

阅读第一部分

官方类型支持 (PEP 484)

2014 年 Hack Week 期间,我们在 Dropbox 上使用 mypy 进行了第一次认真的实验。Hack Week 是由 Dropbox 主办的为期一周的活动。 在此期间,员工可以做任何想做的事情! Dropbox 一些最著名的技术项目就是在此类活动中开始的。 通过这次实验,我们得出的结论是 mypy 看起来很有前途,尽管该项目尚未准备好广泛使用。

当时,标准化 Python 类型提示系统的想法正在酝酿之中。 正如我所说,自 Python 3.0 以来,可以对函数使用类型注释,但这些只是任意表达式,没有定义的语法和语义。 在程序执行期间,这些注释在很大程度上被忽略。 Hack Week 之后,我们开始致力于语义标准化。 这项工作导致了出现 第 484 章 (Guido van Rossum、Łukasz Langa 和我合作编写了这份文件)。

我们的动机可以从两个方面来看。 首先,我们希望整个Python生态系统能够采用一种通用的方法来使用类型提示(Python中使用的术语,相当于“类型注释”)。 考虑到可能的风险,这比使用许多相互不兼容的方法要好。 其次,我们想与 Python 社区的许多成员公开讨论类型注释机制。 这种愿望部分是因为我们不想在广大 Python 程序员眼中看起来像“背弃了该语言的基本思想”。 它是一种动态类型语言,称为“鸭子类型”。 在社区中,一开始,对静态类型的想法不免产生了一些怀疑的态度。 但在明确静态类型不再是强制性的(并且在人们意识到它实际上有用之后),这种情绪最终减弱了。

最终采用的类型提示语法与 mypy 当时支持的非常相似。 PEP 484 于 3.5 年随 Python 2015 一起发布。 Python 不再是一种动态类型语言。 我喜欢将此事件视为 Python 历史上的一个重要里程碑。

开始迁移

2015 年底,Dropbox 创建了一个三人团队来开发 mypy。 他们包括吉多·范罗苏姆、格雷格·普莱斯和大卫·费舍尔。 从那一刻起,事态开始迅速发展。 mypy 成长的第一个障碍是性能。 正如我上面所暗示的,在项目的早期,我考虑过将 mypy 实现翻译成 C,但这个想法现在被划掉了。 我们一直坚持使用 CPython 解释器运行系统,这对于 mypy 等工具来说不够快。 (PyPy 项目是一个带有 JIT 编译器的替代 Python 实现,也没有帮助我们。)

幸运的是,一些算法的改进在这里为我们提供了帮助。 第一个强大的“加速器”是增量检查的实施。 这一改进背后的想法很简单:如果自上次运行 mypy 以来所有模块的依赖项都没有更改,那么我们可以在处理依赖项时使用上次运行期间缓存的数据。 我们只需要对修改的文件和依赖它们的文件执行类型检查。 Mypy 甚至更进一步:如果一个模块的外部接口没有改变,mypy 就认为导入这个模块的其他模块不需要再次检查。

在注释大量现有代码时,增量检查对我们有很大帮助。 关键是,这个过程通常涉及 mypy 的多次迭代运行,因为注释会逐渐添加到代码中并逐渐改进。 mypy 的第一次运行仍然非常慢,因为它有很多依赖项需要检查。 然后,为了改善这种情况,我们实现了远程缓存机制。 如果 mypy 检测到本地缓存可能已过期,它会从集中存储库下载整个代码库的当前缓存快照。 然后,它使用此快照执行增量检查。 这使我们在提高 mypy 性能方面又迈出了一大步。

这是 Dropbox 快速、自然地采用类型检查的时期。 到 2016 年底,我们已经拥有大约 420000 行带有类型注释的 Python 代码。 许多用户热衷于类型检查。 越来越多的开发团队正在使用 Dropbox mypy。

那时一切看起来都很好,但我们还有很多事情要做。 我们开始定期进行内部用户调查,以找出项目的问题领域并了解哪些问题需要首先解决(这种做法至今在公司仍在使用)。 很明显,最重要的是两项任务。 首先,我们需要更多的代码类型覆盖率,其次,我们需要 mypy 更快地工作。 很明显,我们加速 mypy 并将其实施到公司项目中的工作还远未完成。 我们充分认识到这两项任务的重要性,并着手解决它们。

更高的生产力!

增量检查使 mypy 更快,但该工具仍然不够快。 许多增量检查持续大约一分钟。 其原因是周期性进口。 这可能不会让任何使用过用 Python 编写的大型代码库的人感到惊讶。 我们有数百个模块,每个模块都间接导入所有其他模块。 如果导入循环中的任何文件发生更改,mypy 必须处理该循环中的所有文件,通常还处理从该循环导入模块的任何模块。 其中一个循环就是臭名昭著的“依赖关系缠结”,它给 Dropbox 带来了很多麻烦。 一旦这个结构包含数百个模块,在直接或间接导入许多测试的同时,它也被用于生产代码中。

我们考虑了“解开”循环依赖的可能性,但我们没有资源来做到这一点。 有太多我们不熟悉的代码。 结果,我们想出了一种替代方法。 我们决定让 mypy 快速工作,即使存在“依赖关系缠结”。 我们使用 mypy 守护进程实现了这一目标。 守护进程是一个服务器进程,它实现了两个有趣的功能。 首先,它将整个代码库的信息存储在内存中。 这意味着每次运行 mypy 时,您不必加载与数千个导入依赖项相关的缓存数据。 其次,他在小结构单元的层面上仔细分析了职能与其他实体之间的依赖关系。 例如,如果函数 foo 调用一个函数 bar,那么就存在依赖性 foobar。 当文件更改时,守护程序首先单独处理已更改的文件。 然后,它会查看该文件的外部可见更改,例如更改的函数签名。 守护进程仅使用有关导入的详细信息来仔细检查那些实际使用修改后的函数的函数。 通常,使用这种方法,您只需检查很少的函数。

实现所有这些并不容易,因为最初的 mypy 实现主要集中于一次处理一个文件。 我们必须处理许多边缘情况,这些情况的发生需要在代码发生变化的情况下进行重复检查。 例如,当为类分配新的基类时,就会发生这种情况。 一旦我们完成了我们想要的事情,我们就能够将大多数增量检查的执行时间减少到几秒钟。 这对我们来说似乎是一次巨大的胜利。

生产力更高!

与我上面讨论的远程缓存一起,mypy 守护进程几乎完全解决了程序员频繁运行类型检查、对少量文件进行更改时出现的问题。 然而,在最不利的用例中,系统性能仍远未达到最佳状态。 mypy 的干净启动可能需要 15 分钟以上。 这远远超出了我们的预期。 随着程序员继续编写新代码并向现有代码添加注释,情况每周都会变得更糟。 我们的用户仍然渴望更高的性能,但我们很高兴能半途而废。

我们决定回到早期关于 mypy 的想法之一。 即,将Python代码转换为C代码。 使用 Cython(一个允许您将 Python 编写的代码转换为 C 代码的系统)进行实验并没有给我们带来任何明显的加速,因此我们决定重新考虑编写自己的编译器。 由于 mypy 代码库(用 Python 编写)已经包含所有必要的类型注释,因此我们认为尝试使用这些注释来加速系统是值得的。 我很快创建了一个原型来测试这个想法。 在各种微基准测试中,它的性能提高了 10 倍以上。 我们的想法是使用 Cython 将 Python 模块编译为 C 模块,并将类型注释转换为运行时类型检查(通常类型注释在运行时被忽略,仅由类型检查系统使用)。 我们实际上计划将 mypy 实现从 Python 转换为一种设计为静态类型的语言,该语言看起来(并且在大多数情况下工作)与 Python 完全相同。 (这种跨语言迁移已经成为 mypy 项目的传统。最初的 mypy 实现是用 Alore 编写的,然后出现了 Java 和 Python 的语法混合)。

专注于 CPython 扩展 API 是不失去项目管理功能的关键。 我们不需要实现虚拟机或 mypy 所需的任何库。 此外,我们仍然可以访问整个Python生态系统和所有工具(例如pytest)。 这意味着我们可以在开发过程中继续使用解释的 Python 代码,从而使我们能够继续以非常快速的模式进行代码更改和测试,而不是等待代码编译。 可以说,我们坐在两把椅子上看起来做得很好,我们很喜欢它。

我们称之为 mypyc 的编译器(因为它使用 mypy 作为分析类型的前端),结果是一个非常成功的项目。 总体而言,我们在没有缓存的情况下频繁运行 mypy,实现了大约 4 倍的加速。 开发 mypyc 项目的核心花费了 Michael Sullivan、Ivan Levkivsky、Hugh Hahn 和我自己组成的小团队大约 4 个日历月的时间。 这个工作量比用 C++ 或 Go 重写 mypy 所需的工作量要少得多。 而且我们对项目所做的更改比用另一种语言重写时要少得多。 我们还希望能够将 mypyc 提升到其他 Dropbox 程序员可以使用它来编译和加速他们的代码的水平。

为了达到这种性能水平,我们必须应用一些有趣的工程解决方案。 因此,编译器可以通过使用快速的低级 C 结构来加速许多操作,例如,将已编译的函数调用转换为 C 函数调用。 而且这样的调用比调用解释函数要快得多。 一些操作(例如字典查找)仍然涉及使用来自 CPython 的常规 C-API 调用,这在编译时仅稍微快一些。 我们能够消除解释对系统造成的额外负载,但在本例中,这仅在性能方面带来了很小的收益。

为了识别最常见的“慢”操作,我们执行了代码分析。 有了这些数据,我们尝试调整 mypyc 以便它为此类操作生成更快的 C 代码,或者使用更快的操作重写相应的 Python 代码(有时我们根本没有足够简单的解决方案来解决该问题或其他问题) 。 重写 Python 代码通常比让编译器自动执行相同的转换更容易解决问题。 从长远来看,我们希望实现其中许多转换的自动化,但当时我们专注于以最小的努力加速 mypy。 在实现这一目标的过程中,我们走了一些捷径。

待续...

亲爱的读者! 当您得知 mypy 项目的存在时,您对它的印象如何?

对 4 万行 Python 代码进行类型检查的路径。 第2部分
对 4 万行 Python 代码进行类型检查的路径。 第2部分

来源: habr.com

添加评论