Умножение матриц через std :: vector в 10 раз медленнее, чем numpy - PullRequest
2 голосов
/ 01 апреля 2020

Хотя известно, что использование вложенных std::vector для представления матриц - плохая идея , давайте пока воспользуемся им, поскольку он гибкий и многие существующие функции могут обрабатывать std::vector.

Я думал, что в небольших случаях разницу в скорости можно игнорировать. Но оказалось, что vector<vector<double>> в 10 + раз медленнее , чем numpy.dot().

Пусть A и B - матрицы с размером size x size , Предполагая, квадратные матрицы просто для простоты. (Мы не намерены ограничивать обсуждение случаем квадратных матриц.) Мы инициализируем каждую матрицу детерминистическим c способом и, наконец, вычисляем C = A * B.

Мы определяем «время вычисления» как время прошло только для расчета C = A * B. Другими словами, различные накладные расходы не включены.

Python3 код

import numpy as np
import time
import sys

if (len(sys.argv) != 2):
    print("Pass `size` as an argument.", file = sys.stderr);
    sys.exit(1);
size = int(sys.argv[1]);

A = np.ndarray((size, size));
B = np.ndarray((size, size));

for i in range(size):
    for j in range(size):
        A[i][j] = i * 3.14 + j
        B[i][j] = i * 3.14 - j

start = time.time()
C = np.dot(A, B);
print("{:.3e}".format(time.time() - start), file = sys.stderr);

C ++ код

using namespace std;
#include <iostream>
#include <vector>
#include <chrono>

int main(int argc, char **argv) {

    if (argc != 2) {
        cerr << "Pass `size` as an argument.\n";
        return 1;
    }
    const unsigned size = atoi(argv[1]);

    vector<vector<double>> A(size, vector<double>(size));
    vector<vector<double>> B(size, vector<double>(size));

    for (int i = 0; i < size; ++i) {
        for (int j = 0; j < size; ++j) {
            A[i][j] = i * 3.14 + j;
            B[i][j] = i * 3.14 - j;
        }
    }

    auto start = chrono::system_clock::now();

    vector<vector<double>> C(size, vector<double>(size, /* initial_value = */ 0));
    for (int i = 0; i < size; ++i) {
        for (int j = 0; j < size; ++j) {
            for (int k = 0; k < size; ++k) {
                C[i][j] += A[i][k] * B[k][j];
            }
        }
    }

    cerr << scientific;
    cerr.precision(3);
    cerr << chrono::duration<double>(chrono::system_clock::now() - start).count() << "\n";

}

код C ++ (многопоточный)

Мы также написали многопоточную версию кода C ++, поскольку numpy.dot() автоматически рассчитывается параллельно .

Вы можете получить все коды из GitHub .

Результат

C++ версия в 10+ раз медленнее, чем Python 3numpy) версия.

matrix_size: 200x200
--------------- Time in seconds ---------------
C++ (not multithreaded): 8.45e-03
         C++ (1 thread): 8.66e-03
        C++ (2 threads): 4.68e-03
        C++ (3 threads): 3.14e-03
        C++ (4 threads): 2.43e-03
               Python 3: 4.07e-04
-----------------------------------------------

matrix_size: 400x400
--------------- Time in seconds ---------------
C++ (not multithreaded): 7.011e-02
         C++ (1 thread): 6.985e-02
        C++ (2 threads): 3.647e-02
        C++ (3 threads): 2.462e-02
        C++ (4 threads): 1.915e-02
               Python 3: 1.466e-03
-----------------------------------------------

Вопрос

Есть ли способ ускорить реализацию C ++?


Оптимизации, которые я пробовал

  1. порядок вычисления свопа -> максимум в 3,5 раза быстрее (не код numpy, но код C ++)

  2. оптимизация 1 плюс частичная развертка -> максимум в 4,5 раза быстрее, , но это может быть сделано только тогда, когда заранее известно size Нет. Как указано в этот комментарий , size не является обязательным закончили быть известным. Мы можем просто ограничить максимальное значение l oop переменных развернутых циклов и обработать оставшиеся элементы обычными циклами. См., Например, моя реализация .

  3. оптимизация 2, плюс минимизация вызова C[i][j] путем введения простой переменной sum -> максимально в 5,2 раза быстрее , Реализация здесь . Этот результат подразумевает, что std::vector::operator[] является неузнаваемо медленным.

  4. оптимизация 3, плюс флаг g ++ -march=native -> максимум в 6,2 раза быстрее (кстати, мы используем -O3 конечно.)

  5. Оптимизация 3, плюс уменьшение вызова оператора [] путем введения указателя на элемент A, поскольку элементы A последовательно доступны в развернутый л oop. -> Максимум в 6,2 раза быстрее и немного быстрее, чем оптимизация 4. Код показан ниже.

  6. g ++ -funroll-loops флаг для развертывания for циклов -> нет изменить

  7. g ++ #pragma GCC unroll n -> без изменений

  8. g ++ -flto флаг для включения оптимизации времени соединения -> без изменений

  9. блочный алгоритм -> без изменений

  10. транспонировать B, чтобы избежать кэширования пропустить -> без изменений

  11. длинная линейная std::vector вместо вложенной std::vector<std::vector>, порядок вычисления свопов, алгоритм блокировки и частичная развертка -> максимум в 2,2 раза быстрее

  12. Оптимизация 1, плюс P GO (оптимизация по профилю) -> в 4,7 раза быстрее

  13. Оптимизация 3 , плюс P GO -> то же самое, что Оптимизация 3

  14. Оптимизация 3, плюс спецификация g ++ c __builtin_prefetch() -> то же самое, что Оптимизация 3


Текущий статус

(о * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *) * * * * * * * * 11 * * 11 * * 11 * * 11 * *. Но давайте приведем некоторые коды, все из которых являются функциями, вызываемыми из многопоточной версии кода C ++.

Оригинальный код ( GitHub )

void f(const vector<vector<double>> &A, const vector<vector<double>> &B, vector<vector<double>> &C, unsigned row_start, unsigned row_end) {
    const unsigned j_max = B[0].size();
    const unsigned k_max = B.size();
    for (int i = row_start; i < row_end; ++i) {
        for (int j = 0; j < j_max; ++j) {
            for (int k = 0; k < k_max; ++k) {
                C[i][j] += A[i][k] * B[k][j];
            }
        }
    }
}

Текущий лучший код ( GitHub )

Это реализация описанной выше оптимизации 5.

void f(const vector<vector<double>> &A, const vector<vector<double>> &B, vector<vector<double>> &C, unsigned row_start, unsigned row_end) {

    static const unsigned num_unroll = 5;

    const unsigned j_max = B[0].size();
    const unsigned k_max_for_unrolled_loop = B.size() / num_unroll * num_unroll;
    const unsigned k_max = B.size();

    for (int i = row_start; i < row_end; ++i) {
        for (int k = 0; k < k_max_for_unrolled_loop; k += num_unroll) {
            for (int j = 0; j < j_max; ++j) {
                const double *p = A[i].data() + k;
                double sum;
                sum = *p++ * B[k][j];
                sum += *p++ * B[k+1][j];
                sum += *p++ * B[k+2][j];
                sum += *p++ * B[k+3][j];
                sum += *p++ * B[k+4][j];
                C[i][j] += sum;
            }
        }
        for (int k = k_max_for_unrolled_loop; k < k_max; ++k) {
            const double a = A[i][k];
            for (int j = 0; j < j_max; ++j) {
                C[i][j] += a * B[k][j];
            }
        }
    }

}

Мы перепробовали много оптимизаций с тех пор, как впервые опубликовали этот вопрос. Мы потратили целых два дня на борьбу с этой проблемой и, наконец, пришли к тому, что у нас больше нет идей, как оптимизировать текущий лучший код. Мы сомневаемся, что более сложные алгоритмы, такие как Strassen's , сделают это лучше, поскольку случаи, которые мы обрабатываем, не велики, и каждая операция на std::vector настолько дорогая, что, как мы видели, просто сокращает вызов [] хорошо улучшили производительность.

Мы (хотим) верить, что мы можем улучшить ее, хотя.

1 Ответ

1 голос
/ 02 апреля 2020

Матричное умножение относительно легко оптимизировать. Однако, если вы хотите получить достойное использование процессора, это становится непросто, потому что вам нужны глубокие знания используемого вами оборудования. Шаги для реализации быстрого ядра matmul следующие:

  1. Использовать SIMDInstructions
  2. Использовать блокировку реестра и извлекать сразу несколько данных
  3. Оптимизировать для ваших строк чаш ( в основном L2 и L3)
  4. Распараллелить ваш код для использования нескольких потоков

Под этим линком очень хороший ресурс, объясняющий все неприятные детали: https://gist.github.com/nadavrot/5b35d44e8ba3dd718e595e40184d03f0

Если вам нужны более глубокие советы, оставьте комментарий.

...