示例代码
我稍微重写了您的示例代码来研究这个问题。这是我着陆的地方,我将在下面的答案中使用它:
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 https://github.com/python/cpython/commit/3c2fbdb) are:
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 https://github.com/python/cpython/blob/6fd916862e1a93b1578d8eabdefc3979a4d4af62/Modules/_ctypes/_ctypes.c#L4213-L4232。但是,如果您使用分配sh[:] = l
,那么就是Array_ass_subscript就可以了 https://github.com/python/cpython/blob/6fd916862e1a93b1578d8eabdefc3979a4d4af62/Modules/_ctypes/_ctypes.c#L4398-L4453.
同样,这里重要的是热循环。让我们看看它们。
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
vs. Array_ass_item
.
事实上,如果你改变代码Array_init
to use 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
还是慢了一点,但也慢不了多少。
换句话说,大部分开销是由较慢的热循环引起的,并且主要是由的代码PySequence_SetItem环绕Array_ass_item https://github.com/python/cpython/blob/1364858e6ec7abfe04d92b7796ae8431eda87a7a/Objects/abstract.c#L1584-L1609.
乍一看,这段代码可能看起来开销很小,但事实并非如此。
PySequence_SetItem
实际上调用整个Python机制来解决__setitem__
方法并调用它。
This 最终在调用中解决Array_ass_item
,但只有在大量间接级别之后(直接调用Array_ass_item
将完全绕过!)
穿过兔子洞,调用序列看起来有点像这样:
-
s->ob_type->tp_as_sequence->sq_ass_item
指着slot_sq_ass_item https://github.com/python/cpython/blob/cca9b8e3ff022d48eeb76d8567f297bc399fec3a/Objects/typeobject.c#L5790-L5803.
-
slot_sq_ass_item
呼叫call_method https://github.com/python/cpython/blob/cca9b8e3ff022d48eeb76d8567f297bc399fec3a/Objects/typeobject.c#L1439-L1471.
-
call_method
呼叫PyObject_Call https://github.com/python/cpython/blob/1364858e6ec7abfe04d92b7796ae8431eda87a7a/Objects/abstract.c#L2149-L2175
- 一直如此,直到我们最终到达
Array_ass_item
..!
换句话说,我们有 C 代码Array_init
这就是调用 Python 代码(__setitem__
)在热循环中。那很慢。
Why ?
现在,为什么Python使用PySequence_SetItem
in Array_init
并不是Array_ass_item
in Array_init
?
这是因为如果这样做的话,它将绕过 Python 领域中向开发人员公开的钩子。
确实,你can拦截呼叫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);
使用这个会调用__setitem__
once带有切片参数(在Python 2上,它会调用__setslice__
)。我们仍然会执行 Python hooks,但我们只执行一次而不是 N 次。
使用这段代码,性能变为:
sct_init
12.24651838419959
sct_subscript
10.984305887017399
ct_init
12.138383641839027
ct_subscript
11.79078131634742
Test
OK
其他管理费用
我认为其余的开销可能是由于发生的元组实例化造成的打电话时__init__在数组对象上 https://github.com/python/cpython/blob/6fd916862e1a93b1578d8eabdefc3979a4d4af62/Lib/multiprocessing/sharedctypes.py#L66(注意*
,以及事实Array_init
期望一个元组args
)——这大概与n
以及。
确实,如果你更换sh[:] = l
with sh[:] = tuple(l)
在测试用例中,则性能结果变为almost完全相同的。和n = 100000
:
sct_init
11.538272527977824
sct_subscript
10.985187001060694
ct_init
11.485244687646627
ct_subscript
10.843198659364134
Test
OK
可能还有一些较小的事情发生,但最终我们正在比较两个截然不同的热循环。没有理由期望它们具有相同的性能。
我认为尝试打电话可能会很有趣Array_ass_subscript
from Array_init
不过,对于热循环并查看结果!
基线速度
现在,回答第二个问题,关于分配共享内存。
请注意,分配实际上并没有成本shared记忆。正如上面的结果所示,使用与不使用共享内存之间没有显着差异。
查看 Numpy 代码(np.arange
is 在这里实施 https://github.com/numpy/numpy/blob/eeba2cbfa4c56447e36aad6d97e323ecfbdade56/numpy/core/src/multiarray/multiarraymodule.c#L2912-L2930),我们终于可以理解为什么它比sct.RawArray
: np.arange
似乎没有调用Python“用户空间”(即不打电话给PySequence_GetItem
or PySequence_SetItem
).
这不一定能解释all差异,但您可能想从那里开始调查。