当前位置: 首页 > 后端技术 > Python

官方PythonWorkshop:彻底去除GIL真的可行吗?

时间:2023-03-25 19:50:09 Python

作者:?ukaszLanga与nogil的作者SamGross进行了一次会议。nogil是Python3.9的一个分支,删除了GIL。这是一份非正式会议纪要。对Sam工作的快速总结表明,以他的方式删除GIL是可行的,即生成的Python解释器性能良好,并可扩展到更多CPU内核。为了最终取得积极成果,还有其他看似无关的口译工作。目前还不可能将Sam的更改合并到CPython中,因为他的更改是针对3.9分支进行的,以允许用户针对当前的pip可安装库和C扩展测试nogil解释器。如果要合并nogil,就得在主分支的基础上进行改动(目前主分支是3.11的计划)。不要指望Python3.11会删除GIL。将Sam的工作合并到CPython本身将是一个艰巨的过程,但这只是所需要的一部分:在CPython删除GIL之前为社区制定一个良好的向后兼容迁移计划。这些都还没有计划好,所以我们认为时机还不成熟。在谈到如此巨大的变化时,有人提到了Python4。核心开发人员目前没有发布Python4的计划,事实上恰恰相反:我们正在积极避免发布Python4,因为从Python2到3的过渡对社区来说已经足够困难了。现在考虑或担心Python4肯定还为时过早。介绍nogilSam发布了他的代码,以及一篇解释该项目的动机和设计的详细文章。Nogil代码地址:https://github.com/colesbury/...他的设计可以总结如下:为了线程安全,将Python内置的allocatorpymalloc替换为mimalloc,使用无锁读写字典和其他集合对象,同时提高效率(堆内存布局允许在不维护显式列表的情况下查找GC跟踪的对象)用偏向引用计数替换非原子急切引用计数:将每个对象绑定到创建它的线程(称为所有者线程);当对象在所有者线程中使用时,使用快速非原子本地引用计数;当对象在其他线程中使用时,速度较慢但原子共享引用计数;为了加速跨线程的对象访问(因为它会被原子共享引用计数减慢),引入了两种技术:一些特殊的对象是不朽的,这意味着它们的引用计数永远不会被评估也不会被释放:这个包括单例对象,如None、True、False、小整数和常驻字符串,以及静态分配的内置类型PyTypeObjects;其他全局可访问的对象使用延迟引用计数(deferredreferencecounting),例如顶级函数、代码对象和模块;它们不是不朽的,并不总是在程序的生命周期内存活;将循环垃圾收集器调整为单线程stop-the-world垃圾收集器:等待所有线程挂在安全点(任何字节码边界);不等待阻塞在I/O中的线程(使用PyEval_ReleaseThread,相当于释放当前Python中的GIL);高效构建立即释放的对象列表:得益于mimalloc,GC跟踪的对象存储在单独的轻量级堆中;将全局进程的MRO缓存迁移到本地线程,避免查找MRO时发生争用;缓存失效仍然是全局的;将内置集合类对象修改为线程安全的。Sam的设计文档包含这些设计元素的详细信息,包括有关线程状态和GILAPI的信息,以及对解释器和字节码的其他修改(将堆栈VM替换为带累加器的寄存器VM;用于优化函数调用的堆栈框架;其他更改ceval.c;标记指针的使用;LOAD_ATTR、LOAD_METHOD、LOAD_GLOBAL操作码的线程安全元数据;等)。我建议您完整阅读它。Python猫注:上面出现的“stop-the-world”有时缩写为“STW”。这是大多数垃圾收集器的工作机制,也就是说当垃圾收集器工作时,其他所有线程都暂时挂起,以保证引用对象的准确更新,缺点是影响程序性能;“MRO”是“methodresolutionorder”的缩写,即“类方法解析顺序”,意思是在所有基类中查找成员方法的顺序。pyperformance基准套件的早期基准测试,作为概念验证,nogil解释器比3.9快10%。据估计,移除GIL会导致解释器所有更改的性能降低9%,这主要是由于引用计数偏差和引用计数延迟所致。换句话说,具有来自nogil的所有更改但不删除GIL本身的Python3.9可以快19%。然而,这并没有解决多核可扩展性问题。顺便说一句,nogil的一些更改,例如将C调用堆栈与Python调用堆栈解耦,已在Python3.11中实现。事实上,我们对当前主要分支的初步基准测试表明,Python3.11在单线程性能方面比nogil快16%。需要更多基准测试,尤其是使用LarryHastings在测试Gilectomy时使用的基准测试(当时基于Python3.5,后来移植到3.6alpha1)。蟒猫注:gilectomy是两个单词GILsurgery的组合,而surgery是医学术语“切除术”。可见这个项目的用意和nogil是一样的!这是5-6年前的一个项目,作者在PyCon大会上分享过几次。但是这个项目导致Python整体性能下降,最后无疾而终。作者在PyCon的gilectomy项目分享:2015年分享:https://www.youtube.com/watch...2016年分享:https://www.youtube.com/watch...2017年分享:https://www.youtube.com/watch...Sam提醒我们,在没有GIL的情况下,Python上的用户程序的可扩展性实际上取决于最终代码。如果不进行测试,就无法预测您的代码在没有GIL的情况下将如何运行。因此,给出一个数字说没有GIL的Python快x倍是不负责任的。会议期间向Sam提出的问题为了清楚起见,此处的问题已根据会议内容重新排序。答案是从山姆的答案中转述的,他在阅读草稿后同意了。请注意,核心团队成员可能对其中一些主题有其他观点。问:哪些已知风险阻止了nogil项目被合并到CPython中?当前的代码库已经证明了其技术可行性。它有效,并且比普通的CPython解释器和Gilectomy项目更具可扩展性和性能。我在这个项目上投入了将近两年的全职工作。这完全取决于社区对C扩展进行返工的程度,以确保它们不会让解释器彻底崩溃。然后,剩下的长尾供社区以既正确又可扩展的方式在应用程序中采用自由线程。这两个是最大的挑战,但我们必须乐观面对。问:你打算如何改进你的工作?对提交顺序有什么建议吗?您将如何使您的工作与主分支保持同步?Sam目前正在重构他的工作,最初基于3.9.0a3,它将匹配3.9.7最终版本。这项工作的一部分是将提交重构为逻辑单元,以更好地说明需要更改的内容(更改位置和原因)。没有计划将这项工作转移到主分支(未来的3.11),因为这个分支太不稳定了。相比之下,3.9有大量已发布的pip可安装库和C扩展可用于测试。这允许Sam评估项目如何与真实世界的第三方代码一起运行。基于main进行更改将花费大量时间,这些时间本可以用于改进无GIL解释器,因此现在基于main分支开始还为时过早。拆分工作然后合并它是可行的,但必须记住,许多更新需要链接在一起才能提高性能。单独地,它们会导致(暂时的?)性能下降。核心开发人员请注意:我们目前无法合并对3.9分支所做的更改。在项目的这个阶段使用3.9是有意义的,但关键是将其拆分成可消耗的块,并将它们一个一个地合并到主分支中。一点一点地做很可能会影响性能,但这是唯一现实的集成方式。Q:是否可以只引入registerVM和compiler,不做其他改动?在不更改引用计数或GIL的情况下使用注册VM是否有任何特别的困难?VM使用延迟/不朽引用计数。它可以转换为仅使用经典引用计数,但最终结果的效率尚不清楚(例如,出于性能原因,堆栈上的所有对象都使用惰性引用计数)。Q:与上一个问题相反的问题:只引入nogil而不使用新的registerVM有什么难点?虽然新的VM只提高了性能,而没有提高准确性,但它也提高了可扩展性,允许无GIL的Python充分利用CPU内核而不会发生争用。所以可以使用3.11解释器,但最好保留一些寄存器VM设计思想,这对可扩展性和线程安全很重要。这是很多工作。但是将注册VM更新为与主分支相同(并修复遗留错误)也需要大量工作。两种选择都是可行的。问:对于不希望其代码由其他线程并行运行的C扩展有什么建议吗?在适应新的自由线程环境之前,CPython不会为他们提供一些API来弥合差距吗?这需要时间。目标是逐步采用,最终推广到大多数C扩展。GIL可以作为解释器启动时的一个选项。如果没有开启GIL,C扩展不支持新的运行方式,可能会产生警告,也可能无法导入。Python社区必须调整C扩展以使其适应无GIL模型。作为概念验证的nogil项目,它默认使用no-GIL模式并接受任何C扩展。如果是CPython采用的,那么一开始就应该默认开启GIL(需要在启动Python时使用-Xnogil来关闭GIL),这样第三方库才能适配。然后,在几个版本之后,默认切换回无GIL模式。移植所有东西并不容易(并行很难),但在大多数情况下并不难,特别是对于包装外部库的C扩展。核心开发人员请注意:有大量未开源的“暗物质”Python代码(和C扩展)。我们需要小心不要破坏它们,因为它们的用户可能无法进行所需的更改,或向我们上游报告问题。特别是,一些C扩展使用GIL来保护它们自己的内部状态。这是一个大问题,并且可能是采用无GILPython的一大障碍。Q:会不会增加一个PEP-489“slot”,C扩展可以用它来表示支持nogil,这样当遇到不支持nogil的库时,就不会导入?很多人也提到这可能是个好主意,但我不完全确定那是什么意思。选择无GIL模式并不能保证没有错误。相反,我们默认运行所有扩展(这是nogil现在所做的)。不兼容的扩展可以使用PyInit模块的代码主动询问解释器是否启用了GIL,如果没有,则在导入时生成警告甚至异常。问:在运行时启用nogil是一个长期可行的选择,还是一个过渡功能?理想的结局是CPython不再有GIL,句号。但是,预计社区会经历一段漫长的适应期。我们希望在从Python2过渡到Python3时避免损坏。准确地说,我们希望过渡尽可能顺利,即使这意味着需要更长的时间。Q:确认一下,最终状态只是nogil,不支持再次启用GIL吗?我们还不确定。理想的结局是只有一个没有GIL的Python,但尚不清楚这是否可能。问:如果这些功能标志要持续很长时间,是否意味着我们需要显着增加测试矩阵?是的,测试矩阵需要加倍。然而,测试无GIL版本可能是经典GIL版本是否工作的一个很好的预测指标。有必要偶尔(每晚?)在启用GIL的情况下运行测试。核心开发人员注意事项:如果不进行测试,代码将以加速的速度降级。在CPython中,由于运行时要求(例如当测试引用泄漏时),我们不会对每个更改都运行所有测试,但是如果更改导致日常测试失败,我们会立即回滚更改,因为测试已经失败的构建点之后,很可能会出现其他的回归问题。问:您如何看待多个并行运行的Python解释器,每个解释器一个GIL?PythonCat注意:为了让您了解这个问题的背景,PEP-554提议实现多个解释器来解决GIL问题。这是在2017年提出的,并受到了很多关注。2019年翻译了《Has the Python GIL been slain?》来介绍一下。不过,该提案仍处于草案状态,具体的发展状况尚不明朗。这与no-GIL提议既互补又竞争。无GIL解释器也支持共同解释器。目前尚不清楚多口译员计划是否会奏效。使用nogil,您无需担心跨线程共享对象,也无需担心与C扩展的兼容性,因为使用多解释器,没有状态是真正全局的,因此需要特殊隔离。对于可变对象,在多个解释器之间传递时需要某种形式的序列化/反序列化。解释器可能会添加对不可变对象的特殊支持,但如果它们不是已知不可变的内置类型,则用户代码将需要调整这些对象。这是受PyTorch相关工作的启发,它使用某种形式的多解释器。由于我最感兴趣的用例实际上是科学数据(PyTorch训练工作流),因此直接高效地共享数据的能力对于多线程性能至关重要。对于多个解释器,这种共享只能在C扩展级别打开,与没有GIL的Python相比,会导致更多的C/C++代码使用。问:你详细介绍了字典和列表的实现。队列、集合、数组等其他变量类型是如何实现的?nogil是一个开发中的项目。字典和列表是最发达的,因为它们在解释器的内部工作中无处不在。同样,队列的开发已经完成,但其他类型的开发还没有完成。集合是下一个要涵盖的大事。队列非常重要,因为它被concurrent.futures和asyncio用于并发线程之间的通信。队列比字典和列表更简单,并且使用细粒度锁定而不是无锁读取。其他对象可能需要组合。这项工作很棘手,因为在获取和释放锁时需要小心,例如Py_DECREF是可重入的。也可以考虑使用更“粗粒度”的锁,当然,这些锁有死锁的风险。Q:nogil对mimalloc的依赖程度如何?如果我们将它作为一个编译时选项,无论有没有它,是否可以使用平台的malloc而不是没有C预处理器地狱的低性能构建?mimalloc不仅仅是为了线程安全。既要实现字典的无锁读取,又要实现高效的GCtracing。mimalloc的维护者对明确支持CPython很感兴趣,并且很乐意进行必要的更改以实现这一目标。据说malloc的其他实现也对CPython有稳定的支持:Facebook使用的jemalloc,谷歌使用的tcmalloc,尽管集成度较低,更像是默认分配器的直接替代品。(PythonCat注:上面提到的mimalloc来自Microsoft)核心开发者注:ChristianHeimes和PabloGalindoSalgado正在评估CPython使用mimalloc。早期测试没有平均回归(几何平均值),大多数基准测试表现更好,少数表现稍差。还有一些问题需要评估:mimalloc的API和ABI的稳定性;许可;跨所有CPython支持的平台的可移植性,例如stdatomic.h仅在C11中可用;集成分析和检测工具(Valgrind、asan、ubsan等);可能是其他人。问:您的项目与Larry的Gilectomy有何相似之处?你能利用他的项目吗?在设计的顶层,这两个项目是相似的:延迟引用计数、细粒度锁定、返回借用引用的挑战。不重用Gilectomy的代码。问:你说你的项目在顶层类似于Larry的Gilectomy。他的项目也是基于惰性引用计数。然而,他在Gilectomy上的表现很差,而你的“nogil”表现不错。你认为这种差异是什么?切换到基于寄存器的编译器和其他优化,例如mimalloc提供的无锁字典读取和使用惰性引用计数来避免争用,对于nogil的可扩展性和性能至关重要。而且,在某些情况下,Python本身变得更快。例如,函数调用在Python3.9中比在Python3.5中快得多。让它支持扩展肯定需要比预期更多的工作。问:是否可以在无GIL模式下添加(不兼容的)C扩展或将其删除?顾名思义,GIL是全局锁。为了保护任何共享数据,需要在所有线程上启用它,包括不兼容扩展所在的线程。在已经运行的进程中,从无GIL解释器切换到使用GIL的解释器(反之亦然)是很棘手的。最佳实践是在启动时选择:要么在进程中启用GIL,要么不启用它。如果C扩展未标记为兼容,则发出警告或导入失败。或者,您可以在访问C扩展时“停止世界”,但这并不能达到移除GIL试图达到的目的。核心开发人员请注意:到目前为止,还有其他想法需要深入探讨。一种想法是将GIL变成“一次写入,多次读取”的锁。在这种情况下,无GIL模式将获得“多次读取”锁,也就是说,不会阻止其他新代码执行相同的操作。历史遗留的代码将获得一个“单写”锁,阻止所有其他线程执行,直到锁被释放。这种设计需要保留用于获取/释放GIL的API,nogil已经这样做了,以便通知GC线程在I/O上被阻塞。问:是否可以将函数标记为非线程安全的(例如使用装饰器)并在运行代码时让nogil锁定它以防止其他线程调用它?(有点像临时GIL)如果担心其他线程正在访问状态,则需要锁定每次访问。这在装饰器级别并不是特别可行。如前所述,有条件地为不安全代码启用GIL很难实现。Q:用自己的锁替换GIL会比较困难。使用nogil,您认为与线程相关的问题会增加吗?没有把握。CAPI扩展至少有一种好的设计模式:它们通常具有相似的结构,并在单一结构中保持共享状态。目前,Pybind11似乎离这种模式最远,因此用它编写的C扩展可能需要大量更改。许多复杂的C扩展已经不得不处理锁定和多线程,因为它们的目的是尽可能多地释放GIL,例如numpy。因此,也许令人惊讶的是,这些项目可能更容易迁移。下一步在这次会议之后,核心开发人员讨论了将nogil纳入主要项目的可行性,以及这对社区意味着什么。毫无疑问,必须非常小心地进行如此巨大的变化。在做决定之前,我们觉得先导入它的部分代码会更可行。特别是,mimalloc看起来很有趣,并且已经有一个开放的拉取请求(https://github.com/python/cpy…)来探索引入它。可以在那里找到指向基准的链接。在个人层面上,我们对Sam所做的工作印象深刻,并邀请他加入CPython项目。我很高兴地报告说他很感兴趣,我会指导他以帮助他成为核心开发人员。Guido和NeilSchenmauer将帮助我复习解释器中我不熟悉的部分。