Почему перечисление выполняется медленнее, если не указывать ключевое слово start?

Я заметил следующее нечетное поведение при выборе времени enumerate с указанным параметром по умолчанию start:

In [23]: %timeit enumerate([1, 2, 3, 4])
The slowest run took 7.18 times longer than the fastest. This could mean that an intermediate result is being cached 
1000000 loops, best of 3: 511 ns per loop

In [24]: %timeit enumerate([1, 2, 3, 4], start=0)
The slowest run took 12.45 times longer than the fastest. This could mean that an intermediate result is being cached 
1000000 loops, best of 3: 1.22 µs per loop

Итак, примерно 2-кратное замедление для случая, когда start указано.

Байт-код, выданный для каждого случая, на самом деле не указывает ничего, что способствовало бы значительной разнице в скорости. Например, после изучения различных вызовов с помощью dis.dis дополнительные команды:

18 LOAD_CONST               5 ('start')
21 LOAD_CONST               6 (0)

Эти, наряду с CALL_FUNCTION с 1 ключевым словом, являются единственными отличиями.

Я пробовал отслеживать вызовы, сделанные в CPython ceval с помощью gdb и оба, похоже, используют do_call в CALL_FUNCTION, а не какая-то другая оптимизация, которую я мог обнаружить.

Теперь я понимаю, что enumerate просто создает итератор перечисления, поэтому мы имеем дело с созданием объекта здесь (правильно?). Я просмотрел Objects/enumobject.c, пытаясь определить любые различия, если был указан start. Единственное, что (я полагаю) отличается от того, когда start != NULL, в котором происходит следующее:

if (start != NULL) {
    start = PyNumber_Index(start);
    if (start == NULL) {
        Py_DECREF(en);
        return NULL;
    }
    assert(PyInt_Check(start) || PyLong_Check(start));
    en->en_index = PyInt_AsSsize_t(start);
    if (en->en_index == -1 && PyErr_Occurred()) {
        PyErr_Clear();
        en->en_index = PY_SSIZE_T_MAX;
        en->en_longindex = start;
    } else {
        en->en_longindex = NULL;
        Py_DECREF(start);
    }

Что не похоже на что-то, что приведет к спаду 2x. (Я думаю, не уверен.)

Предыдущие сегменты кода были выполнены на Python 3.5, аналогичные результаты присутствуют и в 2.x.


Вот где я застрял и не могу понять, где искать. Это может быть просто накладными расходами на дополнительные вызовы во втором случае, накапливающимися, но опять же, я не уверен. Кто-нибудь знает, что может быть причиной этого?

Ответ 1

Одна из причин может быть вызвана вызовом PyNumber_Index, когда вы указываете начало в следующей части:

if (start != NULL) {
    start = PyNumber_Index(start);

И если вы посмотрите на функцию PyNumber_Index в модуле abstract.c, вы увидите следующий комментарий на верхнем уровне функция:

/* Return a Python int from the object item.
   Raise TypeError if the result is not an int
   or if the object cannot be interpreted as an index.
*/

Таким образом, эта функция должна проверить, нельзя ли интерпретировать объект как индекс и вернет относительные ошибки. И если вы внимательно посмотрите на источник, вы увидите все эти проверки и ссылки, особенно в следующей части, которая должна выполнить разыменование вложенной структуры для проверки типа индекса:

result = item->ob_type->tp_as_number->nb_index(item);
if (result &&
     !PyInt_Check(result) && !PyLong_Check(result)) {
                         ...

Требуется много времени, чтобы проверить и вернуть результат желания.


Но, как упоминалось в @user2357112, другая и самая важная причина связана с сопоставлением аргументов ключевого слова python.

Если вы используете время без аргумента ключевого слова, вы увидите, что время разности уменьшится примерно на ~ 2 раза:

~$ python -m timeit "enumerate([1, 2, 3, 4])"
1000000 loops, best of 3: 0.251 usec per loop
~$ python -m timeit "enumerate([1, 2, 3, 4],start=0)"
1000000 loops, best of 3: 0.431 usec per loop
~$ python -m timeit "enumerate([1, 2, 3, 4],0)"
1000000 loops, best of 3: 0.275 usec per loop

Разница с позиционным аргументом:

>>> 0.251 - 0.275
-0.024

Кажется, что из-за PyNumber_Index.

Ответ 2

Вероятно, это всего лишь сочетание факторов, способствующих общему спаду.

Аргументы ключевого слова:

Когда Python видит аргумент CALL_FUNCTION, он будет вызывать CALL_FUNCTION, как вы уже указали. После прохождения некоторых предложений if выдается вызов x = do_call(func, pp_stack, na, nk);. Обратите внимание на nk здесь, где содержится суммарный подсчет аргументов ключевого слова (так что в случае enumerate -> kw=1).

В do_call вы увидите следующее if:

if (nk > 0) {
    kwdict = update_keyword_args(NULL, nk, pp_stack, func);
    if (kwdict == NULL)
        goto call_fail;
}

Если число аргументов ключевого слова не равно нулю (nk > 0), вызовите update_keyword_args. Теперь update_keyword_args делает то, что вы ожидаете, if orig_kwdict есть NULL (что он, посмотрите на вызов update_keyword_args), создайте новый словарь:

if (orig_kwdict == NULL)
    kwdict = PyDict_New();

а затем заполнить словарь всеми значениями, находящимися в стеке значений:

while (--nk >= 0) {
// copy from stack

Они, вероятно, вносят значительный вклад в общую задержку.

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

Вы правы в enum_new, если с помощью enumerate([1, 2, 3, 4], start=0) переменная start внутри enum_new будет иметь значение и поэтому be != NULL. В результате предложение if будет оцениваться до True, а код внутри него будет выполняться, добавив время на вызов.

То, что выполняется внутри предложения if, не очень тяжелая работа, но оно вносит вклад в общее время.


Дополнительно:

  • у вас также есть две дополнительные команды байтового кода, которые могут считаться двумя, но они добавляют к общему времени, полученному из-за того, что мы синхронизируем очень быстрые вещи (в диапазоне ns).

  • Опять же, незначительно с общей точки зрения, но, если разбор вызова с помощью kws требует, как и раньше, бит больше времени.

Наконец:

Я мог бы пропустить некоторые вещи, но в целом это некоторые из факторов, которые вместе с этим создают накладные расходы при создании нового объекта перечисления с указанным start.