Сравнение Python, Numpy, Numba и С++ для матричного умножения

В программе, над которой я работаю, мне нужно многократно умножать две матрицы. Из-за размера одной из матриц эта операция занимает некоторое время, и я хотел бы узнать, какой метод будет наиболее эффективным. Матрицы имеют размеры (m x n)*(n x p), где m = n = 3 и 10^5 < p < 10^6.

За исключением Numpy, который, как я полагаю, работает с оптимизированным алгоритмом, каждый тест состоит из простой реализации умножения матрицы:

Матричное умножение

Ниже приведены мои различные реализации:

Python

def dot_py(A,B):
    m, n = A.shape
    p = B.shape[1]

    C = np.zeros((m,p))

    for i in range(0,m):
        for j in range(0,p):
            for k in range(0,n):
                C[i,j] += A[i,k]*B[k,j] 
    return C

Numpy

def dot_np(A,B):
    C = np.dot(A,B)
    return C

Numba

Код такой же, как у Python, но он компилируется как раз вовремя перед использованием:

dot_nb = nb.jit(nb.float64[:,:](nb.float64[:,:], nb.float64[:,:]), nopython = True)(dot_py)

До сих пор каждый вызов метода был синхронизирован с использованием модуля timeit 10 раз. Лучший результат сохраняется. Матрицы создаются с помощью np.random.rand(n,m).

С++

mat2 dot(const mat2& m1, const mat2& m2)
{
    int m = m1.rows_;
    int n = m1.cols_;
    int p = m2.cols_;

    mat2 m3(m,p);

    for (int row = 0; row < m; row++) {
        for (int col = 0; col < p; col++) {
            for (int k = 0; k < n; k++) {
                m3.data_[p*row + col] += m1.data_[n*row + k]*m2.data_[p*k + col];
            }
        }
    }

    return m3;
}

Здесь mat2 - это настраиваемый класс, который я определил, и dot(const mat2& m1, const mat2& m2) является функцией друга для этого класса. Он запускается с использованием QPF и QPC из Windows.h, и программа скомпилируется с помощью MinGW с помощью команды g++. Опять же, лучшее время, полученное от 10 исполнений, сохраняется.

Результаты

Результаты

Как и ожидалось, простой код Python работает медленнее, но он по-прежнему превосходит Numpy для очень маленьких матриц. Для большинства случаев Numba оказывается на 30% быстрее, чем Numpy.

Я удивлен результатами С++, где умножение занимает почти на порядок больше времени, чем с Numba. Фактически, я ожидал, что они возьмут такое же количество времени.

Это приводит к моему основному вопросу: это нормально, а если нет, то почему С++ медленнее, чем Numba? Я только начал изучать С++, чтобы я мог что-то делать неправильно. Если да, то какая будет моя ошибка или что я могу сделать для повышения эффективности моего кода (кроме выбора лучшего алгоритма)?

РЕДАКТИРОВАТЬ 1

Вот заголовок класса mat2.

#ifndef MAT2_H
#define MAT2_H

#include <iostream>

class mat2
{
private:
    int rows_, cols_;
    float* data_;

public: 
    mat2() {}                                   // (default) constructor
    mat2(int rows, int cols, float value = 0);  // constructor
    mat2(const mat2& other);                    // copy constructor
    ~mat2();                                    // destructor

    // Operators
    mat2& operator=(mat2 other);                // assignment operator

    float operator()(int row, int col) const;
    float& operator() (int row, int col);

    mat2 operator*(const mat2& other);

    // Operations
    friend mat2 dot(const mat2& m1, const mat2& m2);

    // Other
    friend void swap(mat2& first, mat2& second);
    friend std::ostream& operator<<(std::ostream& os, const mat2& M);
};

#endif

Изменить 2

Как и многие другие, с помощью флага оптимизации отсутствовал элемент, который соответствует Numba. Ниже приведены новые кривые по сравнению с предыдущими. Кривая, помеченная v2, была получена путем переключения двух внутренних циклов и показала улучшение на 30-50%.

Результаты v2

Ответ 1

Определенно используйте -O3 для оптимизации. Это превращает векторизации, что должно значительно ускорить ваш код.

Предполагается, что Numba уже сделает это.

Ответ 2

Что я рекомендую

Если вам нужна максимальная эффективность, вы должны использовать специальную библиотеку линейной алгебры, классикой которой является BLAS/LAPACK. Существует ряд реализаций, например. Intel MKL. То, что вы пишете, НЕ собирается вытеснять гипер-оптимизированные библиотеки.

Матричная матричная умножение будет выполняться под dgemm: d означает double, ge для общего и mm для матричной матрицы. Если ваша проблема имеет дополнительную структуру, более конкретную функцию можно вызвать для дополнительного ускорения.

Обратите внимание, что Numpy dot ALREADY вызывает dgemm! Вероятно, вы не будете делать лучше.

Почему ваш С++ медленный

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

Ответ 3

В вашей текущей реализации, скорее всего, компилятор не может автоматически векторизовать самый внутренний цикл, потому что его размер равен 3. Также доступен m2 с помощью "прыгающего" способа. Переключение петель так, чтобы итерация по p находилась в самом внутреннем цикле, заставит ее работать быстрее (col не сделает "прыгающий" доступ к данным), а компилятор должен иметь возможность работать лучше (autovectorize).

for (int row = 0; row < m; row++) {
    for (int k = 0; k < n; k++) {
        for (int col = 0; col < p; col++) {
            m3.data_[p*row + col] += m1.data_[n*row + k] * m2.data_[p*k + col];
        }
    }
}

На моей машине исходная реализация С++ для элементов p = 10 ^ 6 с флагами g++ dot.cpp -std=c++11 -O3 -o dot занимает 12ms, а выше реализация с обведенными циклами принимает 7ms.