Подтвердить что ты не робот

Демистификация эффективности sharectypes

В 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. Между этими таймингами существует несколько порядков.
4b9b3361

Ответ 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).

Это не обязательно объясняет всю разницу, но вы, вероятно, захотите начать там расследование.

Ответ 2

Не ответ (принятый ответ объясняет это довольно хорошо), но для тех, кто ищет, как это исправить, вот как: Не использовать RawArray оператор присваивания slice.

Как отмечено в принятом ответе, оператор присваивания RawArray не использует тот факт, что вы копируете между двумя обертками вокруг C-стиля массивы одинакового типа и размера. Но RawArray реализует буферный протокол, поэтому вы можете обернуть его в a memoryview, чтобы получить доступ к нему "еще более сырым" способом (и это приведет к победе 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

В тестах решение этой проблемы по другому вопросу время копирования с использованием обертки memoryview составляет менее 1% времени, необходимого для копирования с помощью RawArray нормальное назначение среза. Один трюк заключается в том, что размеры элементов вывода 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, чтобы сначала изменить размер элементов, общее время меньше 3% от времени, требуемого с помощью RawArray собственного оператора присваивания slice.

Если вы избежите временной копии, сделав размер 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

что приводит нас к 0,85% времени назначения ; на данный момент вы в основном работаете на скорости memcpy; остальная часть вашего фактического кода Python замалчивает минимальное количество времени, затрачиваемого на копирование данных.

Ответ 3

Это должен быть комментарий, но мне не хватает репутации :-(

Начиная с Python 3.5, общие массивы в Linux создаются как временные файлы, сопоставленные с памятью (см. Https://bugs.python.org/issue30919). Я думаю, это объясняет, почему создание массива Numpy, который создается в памяти, происходит быстрее, чем создание и инициализация большого общего массива. Чтобы заставить Python использовать разделяемую память, обходной путь должен выполнить эти две строки кода (ссылка : при использовании Multiprocessing.Array в разделяемой памяти не осталось места):

from multiprocessing.process import current_process current_process()._config['tempdir] = '/dev/shm