有没有一种纯Python的方式来释放GIL以用于纯函数?

11

我想我可能漏掉了什么,这看起来很正确,但我找不到一种方法来做到这一点。

假设您在Python中有一个纯函数:

from math import sin, cos

def f(t):
    x = 16 * sin(t) ** 3
    y = 13 * cos(t) - 5 * cos(2*t) - 2 * cos(3*t) - cos(4*t)
    return (x, y)

是否有内置的功能或库,可以在函数执行期间提供某种包装器以释放GIL?

我想到的是类似于以下的东西:

from math import sin, cos
from somelib import pure

@pure
def f(t):
    x = 16 * sin(t) ** 3
    y = 13 * cos(t) - 5 * cos(2*t) - 2 * cos(3*t) - cos(4*t)
    return (x, y)

我为什么认为这可能会有用?

因为多线程目前只对I/O绑定的程序有吸引力,但一旦它们变成长时间运行的函数,多线程也将成为吸引人的选择。像下面这样做:

from math import sin, cos
from somelib import pure
from asyncio import run, gather, create_task

@pure  # releases GIL for f
async def f(t):
    x = 16 * sin(t) ** 3
    y = 13 * cos(t) - 5 * cos(2 * t) - 2 * cos(3 * t) - cos(4 * t)
    return (x, y)


async def main():
    step_size = 0.1
    result = await gather(*[create_task(f(t / step_size))
                            for t in range(0, round(10 / step_size))])
    return result

if __name__ == "__main__":
    results = run(main())
    print(results)

当然,multiprocessing 提供了 Pool.map 函数,可以实现类似的功能。然而,如果该函数返回非基本数据类型或复杂数据类型,则工作线程必须将其序列化,主进程必须反序列化并创建新对象,从而创建必要的副本。使用线程时,子线程传递指针,主线程只需拥有该对象即可。速度更快(而且更清洁?)。
为了将这个问题与我几周前遇到的一个实际问题联系起来:我正在进行一个强化学习项目,其中涉及构建一个类似象棋的游戏的人工智能。为此,我模拟了 AI 自己对局超过 100,000 次,每次返回生成的状态序列(一个 numpy 数组)。生成这些游戏是在一个循环中运行的,并且每次使用这些数据来创建一个更强的 AI 版本。在这里,为每个游戏在主进程中重新创建 ("malloc") 状态序列是瓶颈所在。我尝试重用现有对象,但由于许多原因,这是不好的想法,效果不佳。
编辑:这个问题与 How to run functions in parallel? 不同,因为我不仅仅是寻找任何一种并行执行代码的方法(我知道可以通过多种方式实现,例如通过 multiprocessing)。我正在寻找一种让解释器知道当这个函数在并行线程中执行时不会发生任何问题的方式。

1
理论上,你可以通过编写C扩展来禁用GIL。但我无法真正预测其后果。这样做会导致不安全、未定义的行为。例如,谁知道那些sin、cos函数到底做了什么,而没有GIL它们是否能正常工作呢?核心问题在于Python速度较慢,其线程支持非常差劲。也许你应该转换到其他语言? - freakish
Python 是 Python,你不知道 t 是什么。如果它是一个单精度浮点数,释放和重新获取 GIL 的成本将高于执行 5 次三角函数操作和几个基本数学操作的成本。即使在 C++ 中,你没有 GIL 问题,为这样小的任务创建新的 future 也没有意义。当然,两个 AI 之间的象棋比赛则另当别论,但你需要 Tensorflow 来完成这项任务。GIL 不适用于在 GPU 上运行的代码。 - MSalters
这回答解决了你的问题吗?如何并行运行函数? - mkrieger1
@freakish 我同意,仅仅释放GIL而没有进一步的考虑是开启潘多拉魔盒。我想到的第一种“安全”方式是创建一个新的作用域,在其中函数被执行时不访问其父级,并且然后丢弃该作用域,除了返回变量。不过我没有深入思考这个问题,特别是涉及到import的情况。我认为可能已经有一个库可以解决这个问题,但我不知道。 - FirefoxMetzger
@MSalters 是的,我已经在使用Tensorflow来计算移动了。它并不能解决所有问题,原因有几个:(a) GPU不擅长分支,而像这样的游戏分支非常多;这确实是CPU工作。(b) 游戏内的预测是顺序的(由于高分支因子),(c) 你可以同时批处理运行游戏,但在多进程应用程序中对它们进行批处理是痛苦的(当前方法),(d) 允许每个进程共享GPU会浪费宝贵的GPU内存(权重/参数的副本)。 - FirefoxMetzger
显示剩余3条评论
1个回答

15
有没有一种使用纯Python释放GIL的方法来处理纯函数?
简而言之,答案是否定的,因为这些函数在GIL操作的层面上并不是纯粹的。
GIL不仅用于保护对象免受Python代码的并发更新,其主要目的是防止CPython解释器在访问和更新全局和共享数据时执行数据竞争(这是未定义行为,即C内存模型中禁止使用的,其中CPython执行)。这包括Python可见的单例,如None、True和False,以及所有全局变量,如模块、共享字典和缓存。然后有它们的元数据,如引用计数和类型对象,以及实现内部使用的共享数据。
考虑所提供的纯函数:
def f(t):
    x = 16 * sin(t) ** 3
    y = 13 * cos(t) - 5 * cos(2*t) - 2 * cos(3*t) - cos(4*t)
    return (x, y)

dis 工具 显示解释器在执行函数时执行的操作:

>>> dis.dis(f)
  2           0 LOAD_CONST               1 (16)
              2 LOAD_GLOBAL              0 (sin)
              4 LOAD_FAST                0 (t)
              6 CALL_FUNCTION            1
              8 LOAD_CONST               2 (3)
             10 BINARY_POWER
             12 BINARY_MULTIPLY
             14 STORE_FAST               1 (x)
             ...

为了运行代码,解释器必须访问全局符号sincos以便调用它们。它访问整数2、3、4、5、13和16,这些都是缓存的,因此也是全局的。在出现错误时,它会查找异常类以实例化适当的异常。即使这些全局访问不修改对象,它们仍然涉及写入,因为它们必须更新引用计数
在没有同步的情况下,无法安全地从多个线程执行任何操作。虽然理论上可能修改Python解释器以实现真正的纯函数,不访问全局状态,但这将需要对内部进行重大修改,影响与现有C扩展的兼容性,包括广受欢迎的科学扩展。这最后一点是为什么去除GIL如此困难的主要原因。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接