Большая разница в производительности при суммировании чисел и чисел в Cython против NumPy

Я суммирую каждый элемент в одномерном массиве, используя либо Cython, либо NumPy. При суммировании целых чисел Cython работает на ~20% быстрее. При суммировании чисел с запятой Cython работает примерно в 2,5 раза медленнее. Ниже приведены две простые функции.

#cython: boundscheck=False
#cython: wraparound=False

def sum_int(ndarray[np.int64_t] a):
    cdef:
        Py_ssize_t i, n = len(a)
        np.int64_t total = 0

    for i in range(n):
        total += a[i]
    return total 

def sum_float(ndarray[np.float64_t] a):
    cdef:
        Py_ssize_t i, n = len(a)
        np.float64_t total = 0

    for i in range(n):
        total += a[i]
    return total

Задержки

Создайте два массива по 1 миллиону элементов каждый:

a_int = np.random.randint(0, 100, 10**6)
a_float = np.random.rand(10**6)

%timeit sum_int(a_int)
394 µs ± 30 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

%timeit a_int.sum()
490 µs ± 34.2 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

%timeit sum_float(a_float)
982 µs ± 10.8 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

%timeit a_float.sum()
383 µs ± 4.42 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Дополнительные баллы

  • NumPy превосходит (с большим отрывом) результаты с плавающей точкой и даже превосходит собственную целочисленную сумму.
  • Разница в производительности для sum_float то же самое с boundscheck а также wraparound директивы отсутствуют. Зачем?
  • Преобразование массива целых чисел в sum_int на указатель C (np.int64_t *arr = <np.int64_t *> a.data) повышает производительность еще на 25%. Делать это для поплавков ничего не делает

Главный вопрос

Как я могу получить ту же производительность в Cython с плавающей точкой, что и с целыми числами?

РЕДАКТИРОВАТЬ - Просто считать медленный?!?

Я написал еще более простую функцию, которая просто подсчитывает количество итераций. Первый хранит счет как int, последний как double.

def count_int():
    cdef:
        Py_ssize_t i, n = 1000000
        int ct=0

    for i in range(n):
        ct += 1
    return ct

def count_double():
    cdef:
        Py_ssize_t i, n = 1000000
        double ct=0

    for i in range(n):
        ct += 1
    return ct

Сроки подсчета

Я запускал их только один раз (боюсь кеширования). Не знаю, если цикл на самом деле выполняется для целого числа, но count_double имеет ту же производительность, что и sum_float сверху. Это безумие...

%timeit -n 1 -r 1 count_int()
1.1 µs ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)

%timeit -n 1 -r 1 count_double()
971 µs ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)

1 ответ

Решение

Я не собираюсь отвечать на все ваши вопросы, а только (на мой взгляд) самые интересные.

Давайте начнем с вашего примера подсчета:

  1. компилятор может оптимизировать цикл for в целочисленном случае - результирующий двоичный файл ничего не вычисляет - он только должен вернуть значение, предварительно рассчитанное на этапе компиляции.
  2. Это не относится к двойному случаю, потому что из-за ошибок округления результат не будет 1.0*10**6 и потому что Cython компилируется в IEEE 754 (не -ffast-math) режим по умолчанию.

Об этом следует помнить при взгляде на свой код Cython: компилятору не разрешается переставлять суммирования (IEEE 754), и поскольку результат первых суммирований необходим для следующего, есть только одна длинная строка, в которой все операции Подождите.

Но самое важное понимание: numpy делает не то же самое, что ваш код на Cython:

>>> sum_float(a_float)-a_float.sum()
2.9103830456733704e-08

Да, никто не сказал numpy (в отличие от вашего кода Cython), что сумма должна быть рассчитана следующим образом

((((a_1+a2)+a3)+a4)+...

И Numpy воспользоваться этим двумя способами:

  1. он выполняет парное суммирование (вид), что приводит к меньшей ошибке округления.

  2. он вычисляет сумму в блоках (код на python немного сложен для понимания, вот соответствующий шаблон и далее список используемых функций pairwise_sum_DOUBLE)

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

a1  + a9 + .....  = r1 
a2  + a10 + ..... = r2
..
a8  + a16 +       = r8

----> sum=r1+....+r8

Преимущество такого рода суммирования: результат a2+a10 не зависит от a1+a9 и эти оба значения могут быть рассчитаны одновременно (например, конвейерная обработка) на современных процессорах, что приводит к ускорению, которое вы наблюдаете.


Что бы это ни стоило, на моей машине cython-integer-sum медленнее, чем numpy's.

Необходимость учета шага массива numpy (который известен только во время выполнения, см. Также этот вопрос о векторизации) предотвращает некоторые оптимизации. Обходной путь должен использовать представления памяти, для которых вы можете ясно дать понять, что данные являются непрерывными, то есть:

def sum_int_cont(np.int64_t[::1] a):

Что приводит к значительному ускорению на моей машине (фактор 2):

%timeit sum_int(a_int)
2.64 ms ± 46.8 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

%timeit sum_int_cont(a_int)
1.31 ms ± 19 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

%timeit a_int.sum()
2.1 ms ± 105 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Это правда, что в этом случае использование просмотров памяти для удвоений не приводит к ускорению (не знаю почему), но в целом упрощает жизнь оптимизатора. Например, объединение варианта просмотра памяти с -ffast-math Параметры компиляции, которые допускают ассоциативность, приводят к производительности, сравнимой с numpy:

%%cython -c=-ffast-math
cimport numpy as np
def sum_float_cont(np.float64_t[::1] a):
    cdef:
        Py_ssize_t i, n = len(a)
        np.float64_t total = 0

    for i in range(n):
        total += a[i]
    return total

И сейчас:

>>> %timeit sum_float(a_float)
3.46 ms ± 226 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
>>> %timeit sum_float_cont(a_float)
1.87 ms ± 44 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
>>> %timeit a_float.sum()
1.41 ms ± 88.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Листинг pairwise_sum_DOUBLE:

/*
 * Pairwise summation, rounding error O(lg n) instead of O(n).
 * The recursion depth is O(lg n) as well.
 * when updating also update similar complex floats summation
 */
static npy_double
pairwise_sum_DOUBLE(npy_double *a, npy_uintp n, npy_intp stride)
{
    if (n < 8) {
        npy_intp i;
        npy_double res = 0.;
        for (i = 0; i < n; i++) {
            res += (a[i * stride]);
        }
        return res;
    }
    else if (n <= PW_BLOCKSIZE) {
        npy_intp i;
        npy_double r[8], res;

        /*
         * sum a block with 8 accumulators
         * 8 times unroll reduces blocksize to 16 and allows vectorization with
         * avx without changing summation ordering
         */
        r[0] = (a[0 * stride]);
        r[1] = (a[1 * stride]);
        r[2] = (a[2 * stride]);
        r[3] = (a[3 * stride]);
        r[4] = (a[4 * stride]);
        r[5] = (a[5 * stride]);
        r[6] = (a[6 * stride]);
        r[7] = (a[7 * stride]);

        for (i = 8; i < n - (n % 8); i += 8) {
            r[0] += (a[(i + 0) * stride]);
            r[1] += (a[(i + 1) * stride]);
            r[2] += (a[(i + 2) * stride]);
            r[3] += (a[(i + 3) * stride]);
            r[4] += (a[(i + 4) * stride]);
            r[5] += (a[(i + 5) * stride]);
            r[6] += (a[(i + 6) * stride]);
            r[7] += (a[(i + 7) * stride]);
        }

        /* accumulate now to avoid stack spills for single peel loop */
        res = ((r[0] + r[1]) + (r[2] + r[3])) +
              ((r[4] + r[5]) + (r[6] + r[7]));

        /* do non multiple of 8 rest */
        for (; i < n; i++) {
            res += (a[i * stride]);
        }
        return res;
    }
    else {
        /* divide by two but avoid non-multiples of unroll factor */
        npy_uintp n2 = n / 2;
        n2 -= n2 % 8;
        return pairwise_sum_DOUBLE(a, n2, stride) +
               pairwise_sum_DOUBLE(a + n2 * stride, n - n2, stride);
    }
}
Другие вопросы по тегам