Уменьшить доступ к отдельным элементам, чем для списков

Я только начал использовать Numpy и заметил, что итерация через каждый элемент массива Numpy составляет ~ 4 раза медленнее, чем выполнение того же, но со списком списков. Теперь я знаю, что это побеждает цель Numpy, и я должен, если возможно, векторизовать функцию. Мой вопрос, хотя почему он на 4 раза медленнее. Это кажется довольно большой суммой.

Я провел тесты ниже, используя %timeit

import numpy as np
b = np.eye(1000)
a = b.tolist()

%timeit b[100][100] #1000000 loops, best of 3: 692 ns per loop
%timeit a[100][100] #10000000 loops, best of 3: 70.7 ns per loop
%timeit b[100,100] #1000000 loops, best of 3: 343 ns per loop
%timeit b.item(100,100) #1000000 loops, best of 3: 297 ns per loop

Я попытался использовать dis.dis, чтобы увидеть, что происходит под капотом, но получил:

TypeError: don't know how to disassemble method-wrapper objects

Затем я попытался посмотреть исходный код Numpy, но не смог определить, какой файл соответствует доступу элемента массива. Мне любопытно, что объясняет дополнительные накладные расходы, и что еще более важно, как понять это для себя в будущем. Похоже, что python не может быть легко скомпилирован с кодом C, чтобы я мог видеть разницу. Но есть ли способ увидеть, какой байтовый код генерируется для каждой строки, чтобы понять смысл различий?

Ответы

Ответ 1

Вкратце: получение элемента из массива NumPy требует создания новых объектов Python, в то время как это не относится к спискам. Кроме того, индексирование более немного сложнее для массивов NumPy, чем списки, которые могут добавить некоторые дополнительные служебные данные.


Чтобы повторить, операции NumPy, которые вы указали, выполняют следующие действия:

  • b[100][100] возвращает строку 100 из b в качестве массива, а затем получает значение в индексе 100 этой строки, возвращая значение как объект (например, тип np.int64).
  • b[100,100] возвращает значение в строке 100 и столбце 100 напрямую (первый промежуточный массив не возвращается).
  • b.item(100,100) делает то же самое, что и выше b[100,100], за исключением того, что значение преобразуется в родной тип Python и возвращается.

Теперь из этой операции (1) является самым медленным, поскольку для этого требуются две последовательные операции индексирования NumPy (я объясню, почему это медленнее индексации списка ниже). (2) выполняется быстрее, поскольку выполняется только одна операция индексации. Операция (3) возможно медленнее, поскольку это вызов метода (в Python они обычно медленны).

Почему доступ к списку еще быстрее, чем b[100,100]?

Создание объекта

Списки Python представляют собой массивы указателей на объекты в памяти. Например, список [1, 2, 3] не содержит эти целые числа напрямую, а указатели на адреса памяти - это все целые объекты. Чтобы получить элемент из списка, Python просто возвращает ссылку на объект.

Массивы NumPy не являются коллекциями объектов. Массив np.array([1, 2, 3]) является просто непрерывным блоком памяти с битами, установленными для представления этих целых значений. Чтобы получить целое число из этого массива, новый объект Python должен быть сконструирован в памяти отдельно от массива. Например, объект np.int64 может быть возвращен операцией индексирования: этот объект ранее не существовал и должен был быть создан.

Сложность индексирования

Две другие причины, по которым a[100][100] (получение из списка) быстрее, чем b[100,100] (получение из массива), таковы:

  • Операционный код байта BINARY_SUBSCR выполняется при индексировании обоих списков и массивов, но оптимизирован для списка Python.

  • Внутренняя C-функция, обрабатывающая целочисленную индексацию для списков Python, очень короткая и простая. С другой стороны, индексирование NumPy намного сложнее и выполняется значительное количество кода для определения используемого типа индексации, чтобы можно было вернуть правильное значение.

Ниже описаны шаги для доступа к элементам в списке и массиве с a[100][100] и b[100,100].

Bytecode

Для четырех списков и массивов запускаются те же четыре кода кода байт-кода:

  0 LOAD_NAME                0 (a)           # the list or array
  3 LOAD_CONST               0 (100)         # index number (tuple for b[100,100])
  6 BINARY_SUBSCR                            # find correct "getitem" function
  7 RETURN_VALUE                             # value returned from list or array

Примечание: если вы начинаете индексирование цепей для многомерных списков, например. a[100][100][100], вы начинаете повторять эти инструкции байткода. Это не происходит для массивов NumPy с использованием индексации кортежей: b[100,100,100] использует только четыре команды. Вот почему зазор в таймингах начинает закрываться по мере увеличения количества измерений.

Поиск правильной функции "getitem"

Функции для доступа к спискам и массивам различны, и в каждом случае необходимо найти правильный. Эта задача обрабатывается BINARY_SUBSCR opcode:

w = POP();                                            // our index
v = TOP();                                            // our list or NumPy array
if (PyList_CheckExact(v) && PyInt_CheckExact(w)) {    // do we have a list and an int?
    /* INLINE: list[int] */
    Py_ssize_t i = PyInt_AsSsize_t(w);
        if (i < 0)
             i += PyList_GET_SIZE(v);
        if (i >= 0 && i < PyList_GET_SIZE(v)) {
             x = PyList_GET_ITEM(v, i);               // call "getitem" for lists
             Py_INCREF(x);
        }
        else
            goto slow_get;
     }
     else
       slow_get:
         x = PyObject_GetItem(v, w);                  // else, call another function
                                                      // to work out what is needed
     Py_DECREF(v);
     Py_DECREF(w);
     SET_TOP(x);
     if (x != NULL) continue;
     break;

Этот код оптимизирован для списков Python. Если функция увидит список, она быстро вызовет функцию PyList_GET_ITEM. Теперь этот список можно получить по требуемому индексу (см. Следующий раздел ниже).

Однако, если он не видит список (например, у нас есть массив NumPy), он принимает путь "slow_get". Это, в свою очередь, вызывает другую функцию PyObject_GetItem, чтобы проверить, какая функция "getitem" отображает объект:

PyObject_GetItem(PyObject *o, PyObject *key)
{
    PyMappingMethods *m;

    if (o == NULL || key == NULL)
        return null_error();

    m = o->ob_type->tp_as_mapping;
    if (m && m->mp_subscript)
        return m->mp_subscript(o, key);
    ...

В случае массивов NumPy правильная функция находится в mp_subscript в структуре PyMappingMethods.

Обратите внимание на дополнительные вызовы функций, прежде чем можно будет вызвать правильную функцию "получить". Эти вызовы добавляют накладные расходы для b[100], хотя сколько будет зависеть от того, как был скомпилирован Python/NumPy, системной архитектуры и т.д.

Получение из списка Python

Выше было видно, что вызывается функция PyList_GET_ITEM. Это короткая функция, которая по существу выглядит так: *:

PyList_GetItem(PyObject *op, Py_ssize_t i)
{
    if (!PyList_Check(op)) {                            // check if list
        PyErr_BadInternalCall();
        return NULL;
    }
    if (i < 0 || i >= Py_SIZE(op)) {                    // check i is in range
        if (indexerr == NULL) {
            indexerr = PyUnicode_FromString(
                "list index out of range");
            if (indexerr == NULL)
                return NULL;
        }
        PyErr_SetObject(PyExc_IndexError, indexerr);
        return NULL;
    }
    return ((PyListObject *)op) -> ob_item[i];           // return reference to object
}

* PyList_GET_ITEM - фактически макроформа этой функции, которая делает то же самое, минус проверку ошибок.

Это означает, что получение элемента в индексе i в списке Python относительно просто. Внутренне Python проверяет, является ли тип элемента таким, является ли i в правильном диапазоне для списка, а затем возвращает ссылку на объект в списке.

Получение из массива NumPy

В отличие от этого, NumPy должен выполнять гораздо больше работы, прежде чем можно вернуть значение по запрошенному индексу.

Массивы могут быть проиндексированы различными способами, а NumPy должен решить, какая именно программа индекса необходима. Различные процедуры индексирования обрабатываются в основном кодом mapping.c.

Все, что используется для индексации массивов NumPy, проходит через функцию prepare_index, которая начинает разбор индекса и сохраняет информацию о вещании, количество измерений и т.д. Вот сигнатура вызова для функции:

NPY_NO_EXPORT int
prepare_index(PyArrayObject *self, PyObject *index,
              npy_index_info *indices,
              int *num, int *ndim, int *out_fancy_ndim, int allow_boolean)

 /* @param the array being indexed
  * @param the index object
  * @param index info struct being filled (size of NPY_MAXDIMS * 2 + 1)
  * @param number of indices found
  * @param dimension of the indexing result
  * @param dimension of the fancy/advanced indices part
  * @param whether to allow the boolean special case 
  */

Функция должна выполнять множество проверок. Даже для относительно простого индекса, такого как b[100,100], необходимо сделать много информации, чтобы NumPy мог вернуть ссылку (представление) в правильное значение.

В заключение, для функции "getitem" для NumPy требуется больше времени, и функции, обрабатывающие индексирование массивов, обязательно более сложны, чем единственная функция для списков Python.

Ответ 2

Когда numpy возвращает элемент из одной позиции в массиве, он должен преобразовать значение внутреннего значения типа C (float, double и т.д.) в скалярное значение типа Python (int, long, float). Затем он возвращает ссылку на значение типа Python. Это преобразование занимает некоторое время.

Интересно, что такая же неэффективность также ухудшает производительность по-другому. У меня был список Python, который я индексировал в использование значений индекса, которые поступали из массива numpy. Это же преобразование происходит для создания целочисленного значения Python, необходимого для индексации в список Python. Мне пришлось переписать мой алгоритм с промежуточным массивом собственных целых чисел Python.