【问题标题】:Demystifying sharedctypes performance揭秘 sharedctypes 性能
【发布时间】:2016-02-24 12:37:57
【问题描述】:

在 python 中,可以在多个进程之间共享 ctypes 对象。但是我注意到分配这些对象似乎非常昂贵。

考虑以下代码:

from multiprocessing import sharedctypes as sct
import ctypes as ct
import numpy as np

n = 100000
l = np.random.randint(0, 10, size=n)

def foo1():
    sh = sct.RawArray(ct.c_int, l)
    return sh

def foo2():
    sh = sct.RawArray(ct.c_int, len(l))
    sh[:] = l
    return sh

%timeit foo1()
%timeit foo2()

sh1 = foo1()
sh2 = foo2()

for i in range(n):
    assert sh1[i] == sh2[i]

输出是:

10 loops, best of 3: 30.4 ms per loop
100 loops, best of 3: 9.65 ms per loop

有两件事让我感到困惑:

  • 与传递 numpy 数组相比,为什么显式分配和初始化要快得多?
  • 为什么在 python 中分配共享内存如此昂贵? %timeit np.arange(n) 只需要 46.4 µs。这些时间之间有几个数量级。

【问题讨论】:

标签: python


【解决方案1】:

示例代码

我稍微重写了您的示例代码来调查这个问题。这是我登陆的地方,我将在下面的答案中使用它:

so.py:

from multiprocessing import sharedctypes as sct
import ctypes as ct
import numpy as np

n = 100000
l = np.random.randint(0, 10, size=n)


def sct_init():
    sh = sct.RawArray(ct.c_int, l)
    return sh

def sct_subscript():
    sh = sct.RawArray(ct.c_int, n)
    sh[:] = l
    return sh

def ct_init():
    sh = (ct.c_int * n)(*l)
    return sh

def ct_subscript():
    sh = (ct.c_int * n)(n)
    sh[:] = l
    return sh

请注意,我添加了两个不使用共享内存的测试用例(而是使用常规的 ctypes 数组)。

timer.py:

import traceback
from timeit import timeit

for t in ["sct_init", "sct_subscript", "ct_init", "ct_subscript"]:
    print(t)
    try:
        print(timeit("{0}()".format(t), setup="from so import {0}".format(t), number=100))
    except Exception as e:
        print("Failed:", e)
        traceback.print_exc()
    print

print()

print ("Test",)
from so import *
sh1 = sct_init()
sh2 = sct_subscript()

for i in range(n):
    assert sh1[i] == sh2[i]
print("OK")

测试结果

使用 Python 3.6a0(特别是 3c2fbdb)运行上述代码的结果是:

sct_init
2.844902500975877
sct_subscript
0.9383537038229406
ct_init
2.7903486443683505
ct_subscript
0.978101353161037

Test
OK

有趣的是,如果您更改n,结果会线性缩放。例如,使用 n = 100000(大 10 倍),你得到的东西几乎慢了 10 倍:

sct_init
30.57974253082648
sct_subscript
9.48625904135406
ct_init
30.509132395964116
ct_subscript
9.465419146697968

Test
OK

速度差

最后,速度差异在于调用热循环以通过将 Numpy 数组 (l) 中的每个值复制到新数组 (sh) 来初始化数组。这是有道理的,因为我们注意到速度与数组大小成线性关系。

当您将 Numpy 数组作为构造函数参数传递时,执行此操作的函数是 Array_init。但是,如果您使用sh[:] = l 分配,那么它就是Array_ass_subscript that does the job。

同样,这里重要的是热循环。让我们看看它们。

Array_init 热循环(较慢):

for (i = 0; i < n; ++i) {
    PyObject *v;
    v = PyTuple_GET_ITEM(args, i);
    if (-1 == PySequence_SetItem((PyObject *)self, i, v))
        return -1;
}

Array_ass_subscript热循环(更快):

for (cur = start, i = 0; i < otherlen; cur += step, i++) {
    PyObject *item = PySequence_GetItem(value, i);
    int result;
    if (item == NULL)
        return -1;
    result = Array_ass_item(myself, cur, item);
    Py_DECREF(item);
    if (result == -1)
        return -1;
}

事实证明,大部分速度差异在于使用PySequence_SetItem 与Array_ass_item。

确实,如果您将Array_init 的代码更改为使用Array_ass_item 而不是PySequence_SetItem (if (-1 == Array_ass_item((PyObject *)self, i, v))),然后重新编译Python,新的结果变为:

sct_init
11.504781467840075
sct_subscript
9.381130554247648
ct_init
11.625461496878415
ct_subscript
9.265848568174988

Test
OK

还是慢了一点,但不是很多。

换句话说,大部分开销是由较慢的热循环引起的,主要是由the code that PySequence_SetItem wraps around Array_ass_item引起的。

此代码在第一次阅读时可能看起来开销很小,但实际上并非如此。

PySequence_SetItem 实际上调用整个 Python 机器来解析 __setitem__ 方法并调用它。

这最终在调用 Array_ass_item 时解决,但只有在大量间接级别之后(直接调用 Array_ass_item 会完全绕过!)

通过兔子洞,调用序列看起来有点像这样:

  • s-&gt;ob_type-&gt;tp_as_sequence-&gt;sq_ass_item 指向slot_sq_ass_item。
  • slot_sq_ass_item 呼叫call_method。
  • call_method 呼叫PyObject_Call
  • 一直如此,直到我们最终到达Array_ass_item..!

换句话说,Array_init 中有 C 代码,它在热循环中调用 Python 代码 (__setitem__)。这很慢。

为什么?

现在,为什么 Python 在 Array_init 中使用 PySequence_SetItem 而在 Array_init 中不使用 Array_ass_item?

那是因为如果这样做,它将绕过在 Python 领域向开发人员公开的钩子。

确实,您可以通过对数组进行子类化并覆盖__setitem__(Python 2 中的__setslice__)来拦截对sh[:] = ... 的调用。它将被调用一次,并带有一个slice 参数作为索引。

同样,定义自己的__setitem__ 也会覆盖构造函数中的逻辑。它将被调用 N 次,并带有一个整数参数作为索引。

这意味着如果Array_init 直接调用Array_ass_item,那么你会失去一些东西:__setitem__ 将不再在构造函数中被调用,并且你将无法再覆盖该行为。

现在我们可以尝试一直保持更快的速度,同时仍然暴露相同的 Python 钩子吗?

好吧,也许,在Array_init 中使用这段代码,而不是现有的热循环:

 return PySequence_SetSlice((PyObject*)self, 0, PyTuple_GET_SIZE(args), args);

使用它会调用__setitem__一次,并带有一个切片参数(在 Python 2 上,它会调用__setslice__)。我们仍然通过 Python 钩子,但我们只做一次而不是 N 次。

使用这段代码,性能变成:

sct_init
12.24651838419959
sct_subscript
10.984305887017399
ct_init
12.138383641839027
ct_subscript
11.79078131634742

Test
OK

其他开销

我认为其余的开销可能是由于when calling __init__ on the array object 发生的元组实例化(注意*,以及Array_init 期望args 的元组这一事实)——这可能与n 也是。

确实,如果在测试用例中将sh[:] = l 替换为sh[:] = tuple(l),那么性能结果几乎 完全相同。与n = 100000:

sct_init
11.538272527977824
sct_subscript
10.985187001060694
ct_init
11.485244687646627
ct_subscript
10.843198659364134

Test
OK

可能还有一些较小的事情正在发生,但最终我们将比较两个截然不同的热循环。没有理由期望它们具有相同的性能。

我认为尝试从Array_init 调用Array_ass_subscript 进行热循环并查看结果可能会很有趣!

基线速度

现在,关于分配共享内存的第二个问题。

请注意,分配共享内存并没有真正的成本。如上面的结果所示,使用共享内存与不使用共享内存之间没有本质区别。

查看 Numpy 代码(np.arange 是 implemented here),我们终于可以理解为什么它比 sct.RawArray 快得多了:np.arange 似乎没有调用 Python “user-土地”(即不致电PySequence_GetItem 或PySequence_SetItem)。

这不一定能解释所有的区别,但您可能想从那里开始调查。

【讨论】:

  • 感谢非常详细的分析!你说In other words... it looks like the correct behavior here is the slower one! - 这个我没有得到。这是否意味着切片启动会产生意想不到的效果?
  • @cel 我的回答在这里并不理想。较慢的行为是正确的,但较快的行为也是如此。我添加了一些细节,并提出了一个具有更快和正确行为的实现(尽管对于继承ctypes数组和的用户来说,这仍然是一个重大变化。 t 在他们的 __setitem__ 方法中处理切片;我怀疑这会在 Python 中改变——尤其是考虑到解决方法的存在)。
  • @cel 还添加了另一个注释,说明为什么基线与例如基线相比如此缓慢。 np.arange。如果这对您来说太慢了,我建议您考虑在 C 中实现热循环以减少您可以减少的角落。
  • @ThomasOrozco 这可能是我见过的最好的答案之一。阅读它,我一定学到了一千件事。衷心的 +1!
  • 小修正:PyArray_Arange 从来不是多线程的。您在链接到的代码中看到的是,在运行它时会丢弃 GIL。所以:PyArray_Arange 本身只使用一个线程,但如果你的 Python 程序有其他线程,它们可以在 PyArray_Arange 运行的同时运行。
【解决方案2】:

不是答案(the accepted answer 很好地解释了这一点),但对于那些寻找如何解决此问题的人来说,方法如下:不要使用 RawArrays 切片赋值运算符。。 p>

正如the accepted answer 中所述,RawArrays 切片赋值运算符没有利用您在两个包装器之间复制相同类型和大小的 C 样式数组这一事实。但是RawArray 实现了缓冲区协议,因此您可以将其包装在a memoryview 中以“更原始”的方式访问它(这将使Foo2 获胜,因为您只能在构造对象之后执行此操作,不作为建设的一部分):

def foo2():
    sh = sct.RawArray(ct.c_int, len(l))
    # l must be another buffer protocol object w/the same C format, which is the case here
    memoryview(sh)[:] = l
    return sh

在测试solving this problem on another question 中,使用memoryview 包装器复制的时间不到使用RawArrays 正常切片分配复制所需时间的1%。 这里的一个技巧是np.random.randint的输出元素的大小是np.int,而在64位系统上,np.int是64位,所以在64位Python上,你需要另一轮复制来强制它的大小正确(或者您需要将RawArray 声明为与np.int 的大小匹配的类型)。即使您确实需要制作该临时副本,使用memoryview 仍然便宜得多:

>>> l = np.random.randint(0, 10, size=100000)
>>> %time sh = sct.RawArray(ct.c_int, len(l))
Wall time: 472 µs  # Creation is cheap

>>> %time sh[:] = l
Wall time: 14.4 ms  # TOO LONG!

# Must convert to numpy array with matching element size when c_int and np.int don't match
>>> %time memoryview(sh)[:] = np.array(l, dtype=np.int32)
Wall time: 424 µs

如您所见,即使您需要先复制np.array 来调整元素大小,总时间也不到使用RawArray 自己的切片赋值运算符所需时间的3%。

如果您通过使RawArray 的大小与源匹配来避免临时副本,则成本会进一步下降:

# Make it 64 bit to match size of np.int on my machine
>>> %time sh = sct.RawArray(ct.c_int64, len(l))
Wall time: 522 µs  # Creation still cheap, even at double the size

# No need to convert source array now:
>>> %time memoryview(sh)[:] = l
Wall time: 123 µs

这使我们减少了 RawArray 切片分配时间的 0.85%;此时,您基本上以memcpy 的速度运行;其余的实际 Python 代码将淹没在数据复制上花费的微不足道的时间。

【讨论】:

    【解决方案3】:

    这应该是评论,但我没有足够的声誉:-(

    从 Python 3.5 开始,Linux 中的共享数组被创建为映射到内存的临时文件(请参阅https://bugs.python.org/issue30919)。我认为这解释了为什么创建在内存中创建的 Numpy 数组比创建和初始化大型共享数组更快。 要强制 Python 使用共享内存,一种解决方法是执行这两行代码(参考No space left while using Multiprocessing.Array in shared memory):

    from multiprocessing.process import current_process
    current_process()._config['tempdir'] = '/dev/shm'
    

    【讨论】:

    • 别担心。对现有答案的增强也可能算作答案。您将无法提供格式良好的代码示例作为注释。因此,将其发布为答案似乎对我来说很合适。
    猜你喜欢
    • 2011-05-19
    • 2011-06-19
    • 2017-07-29
    • 2012-01-25
    • 1970-01-01
    • 1970-01-01
    • 2014-07-23
    • 2013-01-30
    • 2016-03-07
    相关资源
    最近更新 更多