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

时间:2015-11-22 09:56:38

标签: 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]

输出结果为:

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。这些时间之间有几个数量级。

3 个答案:

答案 0 :(得分: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

请注意,我添加了两个不使用共享内存的测试用例(而是使用常规的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_SetItemArray_ass_item

确实,如果您将Array_init的代码更改为使用Array_ass_item而不是PySequence_SetItemif (-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后才能完全绕过!)< / p>

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

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

为什么?

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

那是因为如果确实如此,它将绕过在Python-land中暴露给开发人员的钩子。

实际上,可以通过继承数组并覆盖sh[:] = ...(Python 2中的__setitem__)来拦截对__setslice__的调用。它将被调用一次,索引的参数为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期望{{1}的元组的事实}}) - 这可能也会与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_ass_subscript进行热循环并查看结果可能会很有趣!

基线速度

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

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

查看Numpy代码(Array_initimplemented here),我们终于可以理解为什么它比np.arange快得多: sct.RawArray没有出现调用Python“user-land”(即不调用np.arangePySequence_GetItem)。

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

答案 1 :(得分:3)

不是答案(the accepted answer很好地解释了这一点),但对于那些正在寻找解决方法的人,请查看以下方法:不要使用RawArray s切片赋值运算符

正如the accepted answer中所述,RawArray的切片赋值运算符并没有利用您在相同类型的C样式数组的两个包装器之间进行复制的事实。尺寸。但是RawArray实现了缓冲协议,因此您可以将其包装在a memoryview中以便在&#34;更加原始的&#34;中访问它。方式(并且它会使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包装器复制的时间不到复制RawArray正常切片分配所需时间的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代码将浪费在数据复制上花费的微不足道的时间。

答案 2 :(得分:0)

这应该是一条评论,但我的声誉不高:-(

从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’