Интерпретация и штраф за динамическую отправку в Python
Я смотрел выступление Брэндона Роудса о Cython - "День EXE уже на нас".
В 09:30 Брэндон упоминает, что для определенного короткого фрагмента кода пропуск интерпретации дал 40% ускорения, в то время как пропуск выделения и отправки дал 574% ускорения (10:10).
Мой вопрос - как это измеряется для конкретного куска кода? Нужно ли вручную извлекать лежащие в основе команды c и затем каким-то образом заставлять среду выполнения их запускать?
Это очень интересное наблюдение, но как мне воссоздать эксперимент?
1 ответ
Давайте посмотрим на эту функцию Python:
def py_fun(i,N,step):
res=0.0
while i<N:
res+=i
i+=step
return res
и используйте ipython-magic для определения времени:
In [11]: %timeit py_fun(0.0,1.0e5,1.0)
10 loops, best of 3: 25.4 ms per loop
Интерпретатор будет проходить через полученный байт-код и интерпретировать его. Однако мы могли бы отключить интерпретатор, используя cython для /cythonizing того же самого кода:
%load_ext Cython
%%cython
def cy_fun(i,N,step):
res=0.0
while i<N:
res+=i
i+=step
return res
Мы получаем скорость до 50% за это:
In [13]: %timeit cy_fun(0.0,1.0e5,1.0)
100 loops, best of 3: 10.9 ms per loop
Когда мы смотрим в созданный c-код, мы видим, что правильные функции вызываются напрямую, без необходимости интерпретации / вызова ceval
, вот после удаления кода шаблона:
static PyObject *__pyx_pf_4test_cy_fun(CYTHON_UNUSED PyObject *__pyx_self, PyObject *__pyx_v_i, PyObject *__pyx_v_N, PyObject *__pyx_v_step) {
...
while (1) {
__pyx_t_1 = PyObject_RichCompare(__pyx_v_i, __pyx_v_N, Py_LT);
...
__pyx_t_2 = __Pyx_PyObject_IsTrue(__pyx_t_1);
...
if (!__pyx_t_2) break;
...
__pyx_t_1 = PyNumber_InPlaceAdd(__pyx_v_res, __pyx_v_i);
...
__pyx_t_1 = PyNumber_InPlaceAdd(__pyx_v_i, __pyx_v_step);
}
...
return __pyx_r;
}
Тем не менее, эта функция Cython обрабатывает объекты Python, а не поплавки в стиле c, поэтому в функции PyNumber_InPlaceAdd
необходимо выяснить, что на самом деле представляют собой эти объекты (целое число, число с плавающей точкой, что-то еще?), и направить этот вызов нужным функциям, которые будут выполнять эту работу.
С помощью Cython мы также можем устранить необходимость в этой диспетчеризации и напрямую вызывать умножение для чисел с плавающей запятой:
%%cython
def c_fun(double i,double N, double step):
cdef double res=0.0
while i<N:
res+=i
i+=step
return res
В этой версии i
, N
, step
а также res
являются двойниками в стиле c и больше не являются объектами Python. Таким образом, больше нет необходимости вызывать диспетчерские функции, такие как PyNumber_InPlaceAdd
но мы можем напрямую позвонить +
-оператор для double
:
static PyObject *__pyx_pf_4test_c_fun(CYTHON_UNUSED PyObject *__pyx_self, double __pyx_v_i, double __pyx_v_N, double __pyx_v_step) {
...
__pyx_v_res = 0.0;
...
while (1) {
__pyx_t_1 = ((__pyx_v_i < __pyx_v_N) != 0);
if (!__pyx_t_1) break;
__pyx_v_res = (__pyx_v_res + __pyx_v_i);
__pyx_v_i = (__pyx_v_i + __pyx_v_step);
}
...
return __pyx_r;
}
И результат:
In [15]: %timeit c_fun(0.0,1.0e5,1.0)
10000 loops, best of 3: 148 µs per loop
Теперь это ускорение почти на 100 по сравнению с версией без переводчика, но с отправкой.
На самом деле, сказать, что диспетчеризация + распределение здесь является узким местом (потому что устранение этого вызвало ускорение почти в 100 раз) - заблуждение: интерпретатор отвечает за более 50% времени работы (15 мс) и отправка и распределение "только" на 10 мс.
Тем не менее, есть больше проблем, чем "интерпретатор" и динамическая диспетчеризация для производительности: Float является неизменным, поэтому каждый раз, когда он изменяется, новый объект должен быть создан и зарегистрирован / незарегистрирован в сборщике мусора.
Мы можем ввести изменяемые числа с плавающей точкой, которые изменены на месте и не нуждаются в регистрации / отмене регистрации:
%%cython
cdef class MutableFloat:
cdef double x
def __cinit__(self, x):
self.x=x
def __iadd__(self, MutableFloat other):
self.x=self.x+other.x
return self
def __lt__(MutableFloat self, MutableFloat other):
return self.x<other.x
def __gt__(MutableFloat self, MutableFloat other):
return self.x>other.x
def __repr__(self):
return str(self.x)
Время (сейчас я использую другую машину, поэтому время немного другое):
def py_fun(i,N,step,acc):
while i<N:
acc+=i
i+=step
return acc
%timeit py_fun(1.0, 5e5,1.0,0.0)
30.2 ms ± 1.12 ms per loop (mean ± std. dev. of 7 runs, 10 loops each
%timeit cy_fun(1.0, 5e5,1.0,0.0)
16.9 ms ± 612 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit i,N,step,acc=MutableFloat(1.0),MutableFloat(5e5),MutableFloat(1
...: .0),MutableFloat(0.0); py_fun(i,N,step,acc)
23 ms ± 254 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
%timeit i,N,step,acc=MutableFloat(1.0),MutableFloat(5e5),MutableFloat(1
...: .0),MutableFloat(0.0); cy_fun(i,N,step,acc)
11 ms ± 66.2 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
Не забудьте переинициализировать i
потому что это изменчиво! Результаты, достижения
immutable mutable
py_fun 30ms 23ms
cy_fun 17ms 11ms
Таким образом, для регистрации / отмены регистрации поплавков требуется до 7 мс (около 20%) (я не уверен, что здесь играет роль что-то другое) в версии с интерпретатором и более 33% в версии без переводчика.
Как это выглядит сейчас:
- 40% (13/30) времени используется переводчиком
- до 33% времени используется для динамической отправки
- до 20% времени используется для создания / удаления временных объектов
- около 1% для арифметических операций
Другой проблемой является локальность данных, которая становится очевидной для проблем, связанных с шириной полосы памяти: современные кэши хорошо работают, если данные обрабатываются линейно один последовательный адрес памяти за другим. Это верно для зацикливания std::vector<>
(или же array.array
), но не для циклического перебора списков Python, поскольку этот список состоит из указателей, которые могут указывать на любое место в памяти.
Рассмотрим следующие скрипты Python:
#list.py
N=int(1e7)
lst=[0]*int(N)
for i in range(N):
lst[i]=i
print(sum(lst))
а также
#byte
N=int(1e7)
b=bytearray(8*N)
m=memoryview(b).cast('L') #reinterpret as an array of unsigned longs
for i in range(N):
m[i]=i
print(sum(m))
они оба создают 1e7
целые числа, первая версия Python-целые числа, а вторая - простые c-ints, которые постоянно помещаются в память.
Интересно, сколько промахов кэша (D) производят эти скрипты:
valgrind --tool=cachegrind python list.py
...
D1 misses: 33,964,276 ( 27,473,138 rd + 6,491,138 wr)
против
valgrind --tool=cachegrind python bytearray.py
...
D1 misses: 4,796,626 ( 2,140,357 rd + 2,656,269 wr)
Это означает, что для целых чисел python в 8 раз больше кешей. Отчасти это связано с тем, что целым числам python требуется более 8 байтов (вероятно, 32 байта, т.е. фактор 4) памяти и (возможно, не 100% уверенности, потому что соседние целые числа создаются после друг друга, поэтому шансы высоки они хранятся где-то в памяти друг за другом, необходимо дальнейшее исследование) некоторые из-за того, что они не выровнены в памяти, как это имеет место для c-целых чисел bytearray
,