揭开共享类型性能的神秘面纱

cel*_*cel 13 python

在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]
Run Code Online (Sandbox Code Playgroud)

输出是:

10 loops, best of 3: 30.4 ms per loop
100 loops, best of 3: 9.65 ms per loop
Run Code Online (Sandbox Code Playgroud)

有两件事让我困惑:

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

Tho*_*zco 17

示例代码

我重新编写了一些示例代码来研究这个问题.这是我降落的地方,我将在下面的答案中使用它:

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
Run Code Online (Sandbox Code Playgroud)

请注意,我添加了两个不使用共享内存的测试用例(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")
Run Code Online (Sandbox Code Playgroud)

检测结果

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

sct_init
2.844902500975877
sct_subscript
0.9383537038229406
ct_init
2.7903486443683505
ct_subscript
0.978101353161037

Test
OK
Run Code Online (Sandbox Code Playgroud)

有趣的是,如果你改变n,结果会线性扩展.例如,使用n = 100000(大10倍),你会得到几乎慢10倍的东西:

sct_init
30.57974253082648
sct_subscript
9.48625904135406
ct_init
30.509132395964116
ct_subscript
9.465419146697968

Test
OK
Run Code Online (Sandbox Code Playgroud)

速度差异

最后,速度差异在于通过将每个值从Numpy数组(l)复制到新数组(sh)来调用初始化数组的热循环.这是有道理的,因为我们注意到速度随阵列大小线性变化.

当您将Numpy数组作为构造函数参数传递时,执行此操作的函数是Array_init.但是,如果你分配使用sh[:] = l,那Array_ass_subscript就是完成工作.

同样,这里重要的是热循环.我们来看看他们.

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;
}
Run Code Online (Sandbox Code Playgroud)

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;
}
Run Code Online (Sandbox Code Playgroud)

事实证明,大部分速度差异在于使用PySequence_SetItemvs Array_ass_item..

实际上,如果您更改代码而不是Array_init使用(),并重新编译Python,则新结果将变为:Array_ass_itemPySequence_SetItemif (-1 == Array_ass_item((PyObject *)self, i, v))

sct_init
11.504781467840075
sct_subscript
9.381130554247648
ct_init
11.625461496878415
ct_subscript
9.265848568174988

Test
OK
Run Code Online (Sandbox Code Playgroud)

还是有点慢,但不是很多.

换句话说,大部分开销是由较慢的热循环引起的,并且主要是由包裹的代码PySequence_SetItemArray_ass_item引起的.

这段代码在首次阅读时可能看起来很小,但实际上并非如此.

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

最终会在一次调用中解决Array_ass_item,但只能在大量间接级别之后(直接调用Array_ass_item将完全绕过!)

穿过兔子洞,呼叫序列看起来像这样:

  • s->ob_type->tp_as_sequence->sq_ass_item指向slot_sq_ass_item.
  • slot_sq_ass_item打电话给call_method.
  • call_method 打电话给 PyObject_Call
  • 并且一直持续到我们最终到达Array_ass_item......!

换句话说,我们有一个C代码,Array_init__setitem__在热循环中调用Python代码().那很慢.

为什么?

现在,为什么Python中使用PySequence_SetItemArray_init,而不是Array_ass_itemArray_init

那是因为如果它这样做,它将绕过在Python-land中暴露给开发人员的钩子.

实际上,您可以sh[:] = ...通过继承数组和覆盖拦截调用__setitem__(__setslice__在Python 2中).它将被调用一次,带有slice索引的参数.

同样,定义自己的__setitem__也会覆盖构造函数中的逻辑.它将被调用N次,索引的整数参数.

这意味着如果Array_init直接调用Array_ass_item,则会丢失一些东西:__setitem__不再在构造函数中调用,并且您将无法再覆盖该行为.

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

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

 return PySequence_SetSlice((PyObject*)self, 0, PyTuple_GET_SIZE(args), args);
Run Code Online (Sandbox Code Playgroud)

使用它将使用slice参数调用__setitem__ 一次(在Python 2上,它将调用__setslice__).我们仍然通过Python钩子,但我们只做了一次而不是N次.

使用此代码,性能变为:

sct_init
12.24651838419959
sct_subscript
10.984305887017399
ct_init
12.138383641839027
ct_subscript
11.79078131634742

Test
OK
Run Code Online (Sandbox Code Playgroud)

其他开销

我认为其余的开销可能是由于在调用__init__数组对象时发生的元组实例化(请注意*,以及Array_init需要一个元组的事实args) - 这可能n也会缩放.

事实上,如果你更换sh[:] = lsh[:] = tuple(l)测试用例,然后绩效考核的结果变得几乎相同.用n = 100000:

sct_init
11.538272527977824
sct_subscript
10.985187001060694
ct_init
11.485244687646627
ct_subscript
10.843198659364134

Test
OK
Run Code Online (Sandbox Code Playgroud)

可能还有更小的东西,但最终我们正在比较两个截然不同的热循环.没有理由期望它们具有相同的性能.

我认为尝试Array_ass_subscriptArray_init热循环调用并查看结果可能会很有趣!

基线速度

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

请注意,分配共享内存并不需要花费任何成本.如上面的结果所述,使用共享内存之间没有实质性差异.

纵观NumPy的代码(np.arange这里实现),我们终于可以明白为什么这么多的速度比sct.RawArray:np.arange不会出现使Python的"用户域"呼叫(即没有调用PySequence_GetItemPySequence_SetItem).

这并不一定能解释所有的差异,但你可能想开始在那里进行调查.


Sha*_*ger 7

不是答案(接受的答案很好地解释了这一点),但对于那些寻找如何解决此问题的人,方法如下:不要使用RawArray切片赋值运算符

正如已接受的答案中所指出RawArrays 切片赋值运算符没有利用这样一个事实,即您正在围绕相同类型和大小的 C 样式数组的两个包装器之间进行复制。但是RawArray实现了缓冲区协议,因此您可以将其包装在amemoryview中以“更加原始”的方式访问它(并且它会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
Run Code Online (Sandbox Code Playgroud)

另一个问题上解决这个问题的测试中,使用memoryview包装器进行复制的时间不到使用RawArray正常切片分配进行复制所需时间的 1% 。这里的一个技巧是输出元素的大小np.random.randintnp.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
Run Code Online (Sandbox Code Playgroud)

如您所见,即使您需要先复制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
Run Code Online (Sandbox Code Playgroud)

这让我们减少了RawArray切片分配时间的0.85% ;在这一点上,你基本上是在memcpy高速运行;您实际的 Python 代码的其余部分将淹没在数据复制上花费的极少时间。