Интерпретация и штраф за динамическую отправку в 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,

Другие вопросы по тегам