Работает быстрее чем struct.pack с cython - PullRequest
2 голосов
/ 29 января 2020

Я пытаюсь сделать лучше, чем struct.pack.

Если взять конкретный c случай упаковки целых чисел, то через ответ на на этот вопрос я могу упаковать список целых чисел в pack_ints.pyx:

# cython: language_level=3, boundscheck=False
import cython

@cython.boundscheck(False)
@cython.wraparound(False)
def pack_ints(int_col):

    int_buf = bytearray(4*len(int_col))
    cdef int[::1] buf_view = memoryview(int_buf).cast('i')

    idx: int = 0
    for idx in range(len(int_col)):
        buf_view[idx] = int_col[idx]


    return int_buf

С этим тестовым кодом в i python:

from struct import pack 
import pyximport; pyximport.install(language_level=3) 
import pack_ints 

amount = 10**7 
ints = list(range(amount)) 

res1 = pack(f'{amount}i', *ints) 
res2 = pack_ints.pack_ints(ints) 
assert(res1 == res2) 

%timeit pack(f'{amount}i', *ints)  
%timeit pack_ints.pack_ints(ints)      

я получаю:

304 ms ± 2.18 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
212 ms ± 6.54 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Я пытался набрать int_buf как array('b'), но не сделал не вижу улучшения.

Есть ли другой способ улучшить это или использовать cython по-другому, чтобы ускорить эту операцию?

Ответы [ 2 ]

3 голосов
/ 01 февраля 2020

Этот ответ пытается дать оценку того, насколько ускорение может дать параллельная версия. Однако, поскольку эта задача ограничена пропускной способностью памяти (Python -integer-объекты занимают как минимум 32 байта и могут быть разбросаны в памяти, поэтому будет много пропусков кэша), мы не должны ожидать многого.

Первая проблема заключается в том, как обрабатывать ошибки (элемент не является целым числом или значение слишком велико). Я буду придерживаться следующей стратегии / упрощения: когда объект

  • не является целым числом,
  • является отрицательным целым числом,
  • или целым числом>> 2 ^ 30

он будет приведен к специальному номеру (-1), который сигнализирует о том, что что-то пошло не так. Разрешение только неотрицательных целых чисел <2^30 облегчает мою жизнь, поскольку мне приходится переопределять PyLong_AsLongAndOverflow, чтобы избежать ошибок, связанных с поднятием, и в противном случае обнаружение переполнений часто бывает громоздким (однако, см. Версию в конце ответ за более сложный подход).

Макет памяти целочисленного объекта Python можно найти здесь :

struct _longobject {
    PyObject_VAR_HEAD
    digit ob_digit[1];
};

Member ob_size / macro Py_SIZE сообщает нам, сколько 30-битных цифр используется в представлении целого числа (ob_size отрицательно для отрицательного целого числа).

Таким образом, мое простое правило переводится в следующий C -код (я использую скорее C, чем Cython, поскольку это более простой / более естественный способ использования Python C -API):

#include <Python.h>

// returns -1 if vv is not an integer,
//            negative, or > 2**30-1
int to_int(PyObject *vv){ 
   if (PyLong_Check(vv)) {
       PyLongObject * v = (PyLongObject *)vv;
       Py_ssize_t i = Py_SIZE(v);
       if(i==0){
           return 0;
       }
       if(i==1){//small enought for a digit
           return v->ob_digit[0];
       }
       //negative (i<0) or too big (i>1)
       return -1;
   }
   return -1;
}

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

void convert_list(PyListObject *lst, int *output){
    Py_ssize_t n = Py_SIZE(lst);
    PyObject **data = lst->ob_item;
    #pragma omp parallel for
    for(Py_ssize_t i=0; i<n; ++i){
        output[i] = to_int(data[i]);
    }
}

Не так много, чтобы сказать - PyListObject -API используется для параллельного доступа к элементам списка. Это можно сделать, потому что в функции to_int нет функции подсчета ссылок / гонок.

Теперь, объединяя все это вместе с Cython:

%%cython -c=-fopenmp --link-args=-fopenmp
import cython

cdef extern from *:
    """
    #include <Python.h>

    int to_int(PyObject *vv){ 
       ... code
    }

    void convert_list(PyListObject *lst, int *output){
        ... code
    }
    """
    void convert_list(list lst, int *output)

@cython.boundscheck(False)
@cython.wraparound(False)
def pack_ints_ead(list int_col):
    cdef char[::1] int_buf = bytearray(4*len(int_col))
    convert_list(int_col, <int*>(&int_buf[0]))
    return int_buf.base

Одна важная деталь: convert_list не должно быть nogil (потому что это не так)! Omp-потоки и Python -потоки (на которые влияет GIL) - это совершенно разные вещи.

Можно (но не обязательно) освобождать GIL для omp-операций при использовании объектов с buffer-protocol - потому что эти объекты блокируются через буферный протокол и не могут быть изменены из разных Python -threads. У list нет такого механизма блокировки, и, таким образом, если GIL был освобожден, список можно было бы изменить в других потоках, и все наши указатели могли бы стать недействительными.

Так что теперь к таймингу (с немного большим списком) :

amount = 5*10**7 
ints = list(range(amount)) 


%timeit pack(f'{amount}i', *ints)  
# 1.51 s ± 38.9 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit pack_ints_DavidW(ints) 
# 284 ms ± 3.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit pack_ints_ead(ints) 
# 177 ms ± 11.8 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

Кстати, отключение распараллеливания для pack_ints_ead приводит к продолжительности работы 209 мс.

Таким образом, учитывая незначительное улучшение ок. 33%, я бы выбрал более надежное решение DavidW.


Вот реализация с несколько иным способом сигнализации неправильных значений:

  • , а не целочисленный объект приводит к -2147483648 (т. Е. 0x80000000) - наименьшее отрицательное значение, которое может хранить 32-битное целое.
  • целых чисел >=2147483647 (т. Е. >=0x7fffffff) будет отображено / сохранено как 2147483647 - наибольшее положительное число, которое может хранить 32-битное целое число.
  • целые числа <=-2147483647 (то есть <=0x80000001) будет отображено / сохранено как -2147483647
  • все остальные целые числа отображаются на их правильное значение .

Основным преимуществом является то, что он работает правильно для большего диапазона целочисленных значений. Этот алгоритм дает почти то же время выполнения (возможно, на 2-3% медленнее), что и первая простая версия:

int to_int(PyObject *vv){ 
   if (PyLong_Check(vv)) {
       PyLongObject * v = (PyLongObject *)vv;
       Py_ssize_t i = Py_SIZE(v);
       int sign = i<0 ? -1 : 1;
       i = abs(i);
       if(i==0){
           return 0;
       }
       if(i==1){//small enought for a digit
           return sign*v->ob_digit[0];
       }
       if(i==2 && (v->ob_digit[1]>>1)==0){
           int add = (v->ob_digit[1]&1) << 30;
           return sign*(v->ob_digit[0]+add);
       }
       return sign * 0x7fffffff;
   }
   return 0x80000000;
}
2 голосов
/ 31 января 2020

Когда я запускаю свой код из исходного вопроса, я ускоряюсь примерно в 5 раз.

Когда я запускаю ваш код здесь, я вижу результаты, о которых вы сообщаете плюс важное предупреждение при компиляции Этап , который, я думаю, вы игнорируете:

warning: pack_ints.pyx:13:17: Index should be typed for more efficient access

Я не уверен, почему он не подбирает тип правильно, но исправить это, вы должны изменить определение i обратно на код, который я изначально написал:

cdef int i
# not "i: int"

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

...