Ответ 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
, который выполняет задание.
Опять же, здесь важны горячие циклы. Посмотрите на них.
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
Все еще немного медленнее, но не намного.
Другими словами, большая часть служебных данных вызвана медленным горячим циклом и в основном вызвана кодом, который PySequence_SetItem
обертывается вокруг Array_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
, который вызывает код Python (__setitem__
) в горячем цикле. Это медленно.
Почему?
Теперь, почему Python использует PySequence_SetItem
в Array_init
, а не Array_ass_item
в Array_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);
Используя это, вы вызовете в __setitem__
один раз аргумент среза (на Python 2 он вызовет __setslice__
). Мы все еще проходим через Python-крючки, но делаем это один раз вместо N раз.
Используя этот код, производительность становится:
sct_init
12.24651838419959
sct_subscript
10.984305887017399
ct_init
12.138383641839027
ct_subscript
11.79078131634742
Test
OK
Другие накладные расходы
Я думаю, что остальная часть накладных расходов может быть вызвана созданием кортежа при вызове __init__
объекта массива (примечание *
, и тот факт, что 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_ass_subscript
из Array_init
для горячего цикла и увидеть результаты, хотя!
Базовая скорость
Теперь, к вашему второму вопросу, относительно распределения разделяемой памяти.
Обратите внимание, что нет никакой платы за выделение разделяемой памяти. Как отмечено в приведенных выше результатах, существенной разницы между использованием разделяемой памяти нет.
Глядя на код Numpy (np.arange
реализован здесь), мы можем, наконец, понять, почему он намного быстрее, чем sct.RawArray
: np.arange
, как представляется, не вызывает вызовы на "пользовательскую землю" Python (т.е. нет вызова PySequence_GetItem
или PySequence_SetItem
).
Это не обязательно объясняет всю разницу, но вы, вероятно, захотите начать там расследование.