Почему умножение матриц выполняется быстрее с помощью numpy, чем с ctypes в Python?

Я пытался найти самый быстрый способ умножения матриц и пробовал 3 разных способа:

  • Реализация на чистом питоне: никаких сюрпризов.
  • Реализация Numpy с использованием numpy.dot(a, b)
  • Взаимодействие с C с использованием модуля ctypes в Python.

Это код C, преобразованный в разделяемую библиотеку:

#include <stdio.h>
#include <stdlib.h>

void matmult(float* a, float* b, float* c, int n) {
    int i = 0;
    int j = 0;
    int k = 0;

    /*float* c = malloc(nay * sizeof(float));*/

    for (i = 0; i < n; i++) {
        for (j = 0; j < n; j++) {
            int sub = 0;
            for (k = 0; k < n; k++) {
                sub = sub + a[i * n + k] * b[k * n + j];
            }
            c[i * n + j] = sub;
        }
    }
    return ;
}

И код Python, который его вызывает:

def C_mat_mult(a, b):
    libmatmult = ctypes.CDLL("./matmult.so")

    dima = len(a) * len(a)
    dimb = len(b) * len(b)

    array_a = ctypes.c_float * dima
    array_b = ctypes.c_float * dimb
    array_c = ctypes.c_float * dima

    suma = array_a()
    sumb = array_b()
    sumc = array_c()

    inda = 0
    for i in range(0, len(a)):
        for j in range(0, len(a[i])):
            suma[inda] = a[i][j]
            inda = inda + 1
        indb = 0
    for i in range(0, len(b)):
        for j in range(0, len(b[i])):
            sumb[indb] = b[i][j]
            indb = indb + 1

    libmatmult.matmult(ctypes.byref(suma), ctypes.byref(sumb), ctypes.byref(sumc), 2);

    res = numpy.zeros([len(a), len(a)])
    indc = 0
    for i in range(0, len(sumc)):
        res[indc][i % len(a)] = sumc[i]
        if i % len(a) == len(a) - 1:
            indc = indc + 1

    return res

Могу поспорить, что версия, использующая C, была бы быстрее ... и я бы проиграл! Ниже мой тест, который, кажется, показывает, что я либо сделал это неправильно, либо numpy работает тупо быстро:

benchmark

Я хотел бы понять, почему версия numpy быстрее, чем версия ctypes, я даже не говорю о чистой реализации Python, поскольку это отчасти очевидно.


person Charles Menguy    schedule 04.05.2012    source источник
comment
Хороший вопрос - оказывается, что np.dot () также быстрее, чем простая реализация GPU на C.   -  person user2398029    schedule 14.11.2012
comment
Одна из самых важных вещей, замедляющих вашу наивную математику программирования, - это шаблон доступа к памяти. b[k * n + j]; внутри внутреннего цикла (поверх k) имеет шаг n, поэтому при каждом доступе он касается другой строки кэша. И ваш цикл не может автоматически векторизоваться с помощью SSE / AVX. Решите эту проблему, перенеся b вперед, что требует O (n ^ 2) времени и окупается сокращением пропусков кеш-памяти, пока вы выполняете O (n ^ 3) загрузок из b. однако наивная реализация без блокировки кеша (иначе говоря, замощение циклов).   -  person Peter Cordes    schedule 28.08.2017
comment
Поскольку вы используете int sum (по какой-то причине ...), ваш цикл может фактически векторизоваться без -ffast-math, если внутренний цикл обращался к двум последовательным массивам. Математика FP не ассоциативна, поэтому компиляторы не могут переупорядочивать операции без -ffast-math, но целочисленная математика ассоциативна (и имеет меньшую задержку, чем добавление FP, что помогает, если вы не собираетесь оптимизировать свой цикл с несколькими аккумуляторами или другой задержкой. скрытие вещей). float - ›int затраты на преобразование примерно такие же, как и у FP add (фактически с использованием FP add ALU на процессорах Intel), поэтому в оптимизированном коде этого не стоит.   -  person Peter Cordes    schedule 28.08.2017


Ответы (6)


Я не слишком знаком с Numpy, но исходник находится на Github. Часть точечных продуктов реализована в https://github.com/numpy/numpy/blob/master/numpy/core/src/multiarray/arraytypes.c.src, который, как я предполагаю, переведен в конкретные реализации C для каждого типа данных. Например:

/**begin repeat
 *
 * #name = BYTE, UBYTE, SHORT, USHORT, INT, UINT,
 * LONG, ULONG, LONGLONG, ULONGLONG,
 * FLOAT, DOUBLE, LONGDOUBLE,
 * DATETIME, TIMEDELTA#
 * #type = npy_byte, npy_ubyte, npy_short, npy_ushort, npy_int, npy_uint,
 * npy_long, npy_ulong, npy_longlong, npy_ulonglong,
 * npy_float, npy_double, npy_longdouble,
 * npy_datetime, npy_timedelta#
 * #out = npy_long, npy_ulong, npy_long, npy_ulong, npy_long, npy_ulong,
 * npy_long, npy_ulong, npy_longlong, npy_ulonglong,
 * npy_float, npy_double, npy_longdouble,
 * npy_datetime, npy_timedelta#
 */
static void
@name@_dot(char *ip1, npy_intp is1, char *ip2, npy_intp is2, char *op, npy_intp n,
           void *NPY_UNUSED(ignore))
{
    @out@ tmp = (@out@)0;
    npy_intp i;

    for (i = 0; i < n; i++, ip1 += is1, ip2 += is2) {
        tmp += (@out@)(*((@type@ *)ip1)) *
               (@out@)(*((@type@ *)ip2));
    }
    *((@type@ *)op) = (@type@) tmp;
}
/**end repeat**/

Похоже, что это вычисляет одномерные скалярные произведения, то есть на векторах. За несколько минут просмотра Github мне не удалось найти источник матриц, но возможно, что он использует один вызов FLOAT_dot для каждого элемента в матрице результатов. Это означает, что цикл в этой функции соответствует вашему самому внутреннему циклу.

Одно различие между ними состоит в том, что «шаг» - разница между последовательными элементами во входных данных - явно вычисляется один раз перед вызовом функции. В вашем случае шага нет, и смещение каждого ввода вычисляется каждый раз, например. a[i * n + k]. Я ожидал, что хороший компилятор оптимизирует это до чего-то похожего на шаг Numpy, но, возможно, он не может доказать, что шаг является константой (или он не оптимизируется).

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

Numpy также содержит код для выбора реализации определенных операций, включая «точку», из различных базовых реализаций. Например, он может использовать библиотеку BLAS. Из обсуждения выше похоже, что используется CBLAS. Это было переведено с Фортрана на C. Я думаю, что реализация, используемая в вашем тесте, будет найдена здесь: http://www.netlib.org/clapack/cblas/sdot.c.

Обратите внимание, что эта программа была написана машиной для чтения другой машиной. Но внизу видно, что он использует развернутый цикл для обработки 5 элементов за раз:

for (i = mp1; i <= *n; i += 5) {
stemp = stemp + SX(i) * SY(i) + SX(i + 1) * SY(i + 1) + SX(i + 2) * 
    SY(i + 2) + SX(i + 3) * SY(i + 3) + SX(i + 4) * SY(i + 4);
}

Этот фактор развертывания, вероятно, был выбран после профилирования нескольких. Но одно теоретическое преимущество этого заключается в том, что между каждой точкой ветвления выполняется больше арифметических операций, а у компилятора и ЦП больше выбора в отношении того, как их оптимально планировать, чтобы получить как можно больше конвейерной обработки команд.

person Edmund    schedule 04.05.2012
comment
Я снова ошибся, похоже, что вызываются подпрограммы в Numpy под /linalg/blas_lite.c. первый daxpy_ - это развернутый внутренний цикл для скалярных произведений в числах с плавающей запятой, и он основан на коде, полученном ДОЛГОЕ время назад. Посмотрите комментарий там: постоянное умножение на вектор плюс вектор. использует развернутые циклы для приращений, равных единице. джек донгарра, линпак, 11.03.78. изменено 12/3/93, объявления array (1) изменены на array (*) - person John Lyon; 04.05.2012
comment
Я предполагаю, что ни один из этих алгоритмов на самом деле не используется для чисел с плавающей запятой, двойных, одинарных или двойных сложных. NumPy требует ATLAS, у которого есть собственные версии daxpy и dgemm. Есть версии для поплавка и сложного; для целых чисел и т. д. NumPy, вероятно, использует связанный с вами шаблон C. - person Juno Woods; 20.08.2012

NumPy использует оптимизированный, тщательно настроенный метод BLAS для умножения матриц (см. Также: ATLAS). Специфической функцией в этом случае является GEMM (для общего умножения матриц). Вы можете найти оригинал, выполнив поиск по dgemm.f (он находится в Netlib).

Между прочим, оптимизация выходит за рамки оптимизации компилятора. Выше Филипп упомянул Медный мастер Виноград. Если я правильно помню, это алгоритм, который используется в большинстве случаев умножения матриц в ATLAS (хотя комментатор отмечает, что это может быть алгоритм Штрассена).

Другими словами, ваш алгоритм matmult - тривиальная реализация. Есть более быстрые способы сделать то же самое.

person Juno Woods    schedule 20.08.2012
comment
Кстати, np.show_config() показывает, с каким лапаком / бласом он связан. - person denis; 27.02.2013
comment
Вы и Филип делаете правильный вывод (проблема в том, что реализация OP медленная), но я предполагаю, что NumPy использует алгоритм Штрассена или какой-то его вариант, а не Coppersmith-Winograd, который имеет такие большие константы, что обычно не используется на практике. - person Huck Bennett; 20.04.2013

Язык, используемый для реализации определенных функций, сам по себе является плохим показателем производительности. Часто решающим фактором является использование более подходящего алгоритма.

В вашем случае вы используете наивный подход к умножению матриц, как учат в школе, который находится в O (n ^ 3). Однако вы можете сделать намного лучше для определенных видов матриц, например квадратные матрицы, запасные матрицы и так далее.

Взгляните на алгоритм Копперсмита – Винограда (умножение квадратной матрицы в O (n ^ 2.3737)) для хорошей отправной точки при быстром умножении матриц. Также см. Раздел «Ссылки», в котором перечислены некоторые указатели на еще более быстрые методы.


Чтобы получить более простой пример поразительного прироста производительности, попробуйте написать быстрый strlen() и сравнить его с реализацией glibc. Если вам не удастся победить его, прочтите strlen() исходный код glibc, он содержит довольно хорошие комментарии.

person Philip    schedule 04.05.2012
comment
+1 Для использования нотации большого О и анализа (я всегда помню наивный метод n ^ 3 против Strassen alg с примерно n ^ 2,8). Опять же, хороший способ проверить скорость алгоритма - это большой, а не язык. - person Juan Antonio Gomez Moriano; 20.05.2015
comment
Вероятно, что более важно в этом случае, наивный C matmul OP не блокируется кешем и даже не переносит один из входов. Он перебирает строки в одной матрице и столбцы в другой, когда они обе находятся в строчном порядке, поэтому он получает массовые промахи кеша. (Транспонирование - это O (n ^ 2), работающее впереди, чтобы скалярные произведения векторных строк * столбцов выполняли последовательные обращения, что также позволяет им автоматически векторизоваться с помощью SSE / AVX / любого другого, если вы используете -ffast-math.) - person Peter Cordes; 28.08.2017

Numpy также является высоко оптимизированным кодом. Об этом есть эссе в книге Beautiful Code.

Типы ctypes должны пройти динамический перевод с C на Python и обратно, что добавляет некоторые накладные расходы. В Numpy большинство матричных операций выполняются полностью внутри него.

person Keith    schedule 04.05.2012
comment
Сам Numpy не является оптимизированным кодом. Он использует оптимизированный код, например, ATLAS. - person Juno Woods; 20.08.2012

Люди, написавшие NumPy, очевидно, знают, что делают.

Есть много способов оптимизировать умножение матриц. Например, порядок обхода матрицы влияет на шаблоны доступа к памяти, которые влияют на производительность.
Хорошее использование SSE - еще один способ оптимизации, который, вероятно, использует NumPy.
Может быть больше способов, которые разработчики NumPy знаю, а я нет.

Кстати, вы скомпилировали свой код C с оптимизацией?

Вы можете попробовать следующую оптимизацию для C. Она работает параллельно, и я полагаю, что NumPy делает что-то в том же направлении.
ПРИМЕЧАНИЕ. Работает только для четных размеров. Приложив дополнительные усилия, вы можете снять это ограничение и сохранить прирост производительности.

for (i = 0; i < n; i++) {
        for (j = 0; j < n; j+=2) {
            int sub1 = 0, sub2 = 0;
            for (k = 0; k < n; k++) {
                sub1 = sub1 + a[i * n + k] * b[k * n + j];
                sub1 = sub1 + a[i * n + k] * b[k * n + j + 1];
            }
            c[i * n + j]     = sub;
            c[i * n + j + 1] = sub;
        }
    }
}
person ugoren    schedule 04.05.2012
comment
Да, я пробовал использовать разные уровни оптимизации при компиляции, но это не сильно изменило результат по сравнению с numpy - person Charles Menguy; 04.05.2012
comment
Хорошая реализация умножения превзойдет любой уровень оптимизации. Я полагаю, что никакая оптимизация не будет значительно хуже. - person ugoren; 04.05.2012
comment
Этот ответ делает много предположений о том, что делает Numpy. Тем не менее, он практически не выполняет какие-либо из них из коробки, вместо этого перекладывая работу в библиотеку BLAS, когда она доступна. Производительность умножения матриц сильно зависит от реализации BLAS. - person Fred Foo; 22.11.2012

Наиболее распространенная причина преимущества Фортрана в скорости в числовом коде, afaik, заключается в том, что язык упрощает обнаружение псевдонимы - компилятор может определить, что умножаемые матрицы не используют одну и ту же память, что может помочь улучшить кэширование (нет необходимости быть уверенным, что результаты немедленно записываются обратно в «общую» память). Вот почему C99 ввел ограничение.

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

person andrew cooke    schedule 04.05.2012