示例代码
我稍微重写了您的示例代码来调查这个问题。这是我登陆的地方,我将在下面的答案中使用它:
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 会完全绕过!)
通过兔子洞,调用序列看起来有点像这样:
换句话说,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)。
这不一定能解释所有的区别,但您可能想从那里开始调查。