Почему перечисление выполняется медленнее, если не указывать ключевое слово 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
.