Матричное умножение Laderman 3x3 с 23 умножениями, стоит ли это?
Возьмем произведение двух матриц 3x3 A*B=C
. Наивно это требует 27 умножений с использованием стандартного алгоритма . Если бы вы были умны, вы могли бы сделать это, используя только 23 умножения, результат, найденный в 1973 году Laderman. Эта техника включает в себя сохранение промежуточных этапов и их правильное сочетание.
Теперь давайте исправим язык и тип, например С++ с элементами double
. Если алгоритм Ладермана был жестко запрограммирован по сравнению с простым двойным циклом, можно ли ожидать, что производительность современного компилятора будет устранять различия в алгоритмах?
Примечания по этому вопросу: Это сайт программирования, и вопрос задается в контексте наилучшей практики для критического во времени внутреннего цикла; это преждевременная оптимизация. Советы по внедрению очень приветствуются в качестве комментариев.
Ответы
Ответ 1
Сроки:
Я сам провел тесты времени, и результаты удивили меня (поэтому, почему я задал этот вопрос в первую очередь). Короче говоря, при стандартном компиляции laderman
на ~ 225% быстрее, но с флагом оптимизации -03
он на 50% медленнее! Мне приходилось добавлять случайный элемент в матрицу каждый раз во время флага -O3
, или компилятор полностью оптимизировал простейшее умножение, принимая время с точностью до нуля. Поскольку алгоритм laderman
был больно проверять/проверять дважды, я отправлю полный код ниже для потомков.
Технические характеристики: Ubuntu 12.04, Dell Prevision T1600, gcc. Процентная разница в таймингах:
-
g++ [2.22, 2.23, 2.27]
-
g++ -O3 [-0.48, -0.49, -0.48]
-
g++ -funroll-loops -O3 [-0.48, -0.48, -0.47]
Код бенчмаркинга вместе с реализацией Laderman:
#include <iostream>
#include <ctime>
#include <cstdio>
#include <cstdlib>
using namespace std;
void simple_mul(const double a[3][3],
const double b[3][3],
double c[3][3]) {
int i,j,m,n;
for(i=0;i<3;i++) {
for(j=0;j<3;j++) {
c[i][j] = 0;
for(m=0;m<3;m++)
c[i][j] += a[i][m]*b[m][j];
}
}
}
void laderman_mul(const double a[3][3],
const double b[3][3],
double c[3][3]) {
double m[24]; // not off by one, just wanted to match the index from the paper
m[1 ]= (a[0][0]+a[0][1]+a[0][2]-a[1][0]-a[1][1]-a[2][1]-a[2][2])*b[1][1];
m[2 ]= (a[0][0]-a[1][0])*(-b[0][1]+b[1][1]);
m[3 ]= a[1][1]*(-b[0][0]+b[0][1]+b[1][0]-b[1][1]-b[1][2]-b[2][0]+b[2][2]);
m[4 ]= (-a[0][0]+a[1][0]+a[1][1])*(b[0][0]-b[0][1]+b[1][1]);
m[5 ]= (a[1][0]+a[1][1])*(-b[0][0]+b[0][1]);
m[6 ]= a[0][0]*b[0][0];
m[7 ]= (-a[0][0]+a[2][0]+a[2][1])*(b[0][0]-b[0][2]+b[1][2]);
m[8 ]= (-a[0][0]+a[2][0])*(b[0][2]-b[1][2]);
m[9 ]= (a[2][0]+a[2][1])*(-b[0][0]+b[0][2]);
m[10]= (a[0][0]+a[0][1]+a[0][2]-a[1][1]-a[1][2]-a[2][0]-a[2][1])*b[1][2];
m[11]= a[2][1]*(-b[0][0]+b[0][2]+b[1][0]-b[1][1]-b[1][2]-b[2][0]+b[2][1]);
m[12]= (-a[0][2]+a[2][1]+a[2][2])*(b[1][1]+b[2][0]-b[2][1]);
m[13]= (a[0][2]-a[2][2])*(b[1][1]-b[2][1]);
m[14]= a[0][2]*b[2][0];
m[15]= (a[2][1]+a[2][2])*(-b[2][0]+b[2][1]);
m[16]= (-a[0][2]+a[1][1]+a[1][2])*(b[1][2]+b[2][0]-b[2][2]);
m[17]= (a[0][2]-a[1][2])*(b[1][2]-b[2][2]);
m[18]= (a[1][1]+a[1][2])*(-b[2][0]+b[2][2]);
m[19]= a[0][1]*b[1][0];
m[20]= a[1][2]*b[2][1];
m[21]= a[1][0]*b[0][2];
m[22]= a[2][0]*b[0][1];
m[23]= a[2][2]*b[2][2];
c[0][0] = m[6]+m[14]+m[19];
c[0][1] = m[1]+m[4]+m[5]+m[6]+m[12]+m[14]+m[15];
c[0][2] = m[6]+m[7]+m[9]+m[10]+m[14]+m[16]+m[18];
c[1][0] = m[2]+m[3]+m[4]+m[6]+m[14]+m[16]+m[17];
c[1][1] = m[2]+m[4]+m[5]+m[6]+m[20];
c[1][2] = m[14]+m[16]+m[17]+m[18]+m[21];
c[2][0] = m[6]+m[7]+m[8]+m[11]+m[12]+m[13]+m[14];
c[2][1] = m[12]+m[13]+m[14]+m[15]+m[22];
c[2][2] = m[6]+m[7]+m[8]+m[9]+m[23];
}
int main() {
int N = 1000000000;
double A[3][3], C[3][3];
std::clock_t t0,t1;
timespec tm0, tm1;
A[0][0] = 3/5.; A[0][1] = 1/5.; A[0][2] = 2/5.;
A[1][0] = 3/7.; A[1][1] = 1/7.; A[1][2] = 3/7.;
A[2][0] = 1/3.; A[2][1] = 1/3.; A[2][2] = 1/3.;
t0 = std::clock();
for(int i=0;i<N;i++) {
// A[0][0] = double(rand())/RAND_MAX; // Keep this in for -O3
simple_mul(A,A,C);
}
t1 = std::clock();
double tdiff_simple = (t1-t0)/1000.;
cout << C[0][0] << ' ' << C[0][1] << ' ' << C[0][2] << endl;
cout << C[1][0] << ' ' << C[1][1] << ' ' << C[1][2] << endl;
cout << C[2][0] << ' ' << C[2][1] << ' ' << C[2][2] << endl;
cout << tdiff_simple << endl;
cout << endl;
t0 = std::clock();
for(int i=0;i<N;i++) {
// A[0][0] = double(rand())/RAND_MAX; // Keep this in for -O3
laderman_mul(A,A,C);
}
t1 = std::clock();
double tdiff_laderman = (t1-t0)/1000.;
cout << C[0][0] << ' ' << C[0][1] << ' ' << C[0][2] << endl;
cout << C[1][0] << ' ' << C[1][1] << ' ' << C[1][2] << endl;
cout << C[2][0] << ' ' << C[2][1] << ' ' << C[2][2] << endl;
cout << tdiff_laderman << endl;
cout << endl;
double speedup = (tdiff_simple-tdiff_laderman)/tdiff_laderman;
cout << "Approximate speedup: " << speedup << endl;
return 0;
}
Ответ 2
Ключ - овладение набором команд на вашей платформе. Это зависит от вашей платформы. Существует несколько методов, и когда вы, как правило, нуждаетесь в максимальной возможной производительности, ваш компилятор будет поставляться с инструментами профилирования, некоторые из которых имеют встроенный оптимизационный намек. Для тончайших операций посмотрите на выход ассемблера и посмотрите, есть ли какие-либо улучшения на этом уровне.
Одновременная команда нескольких команд данных выполняет одну и ту же операцию над несколькими параллельными операндами. Чтобы вы могли взять
double a,b,c,d;
double w = d + a;
double x = a + b;
double y = b + c;
double z = c + d;
и замените его на
double256 dabc = pack256(d, a, b, c);
double256 abcd = pack256(a, b, c, d);
double256 wxyz = dabc + abcd;
Итак, когда значения загружаются в регистры, они загружаются в один 256-битный регистр для некоторой вымышленной платформы с 256-битными регистрами.
Плавающая точка является важным соображением, некоторые DSP могут значительно быстрее работать с целыми числами. Графические процессоры имеют тенденцию быть отличными в плавающей точке, хотя некоторые из них в 2 раза быстрее при одной точности. Случай 3х3 этой проблемы может вписаться в один поток CUDA, поэтому вы можете одновременно выполнить 256 из этих вычислений.
Выберите свою платформу, прочитайте документацию, внедрите несколько разных методов и проведите их настройку.
Ответ 3
Я ожидаю, что основной проблемой производительности будет латентность памяти. A double[9]
обычно составляет 72 байта. Это уже нетривиальная сумма, и вы используете три из них.
Ответ 4
Несмотря на вопрос, упомянутый в С++, я реализовал матричное умножение 3x3 C = A * B в С# (.NET 4.5) и провело некоторые базовые тесты времени на моей 64-битной машине Windows 7 с оптимизацией. 10 000 000 умножений заняли около
- 0,556 секунды с наивной реализацией и
- 0.874 секунд с кодом Laderman из другого ответа.
Интересно, что код Laderman был медленнее, чем наивный. Я не исследовал с профилировщиком, но, я думаю, дополнительные ассигнования более дорогостоящие, чем несколько дополнительных умножений.
Кажется, что современные компиляторы достаточно умен, чтобы делать эти оптимизации для нас, что хорошо. Вот наивный код, который я использовал для вашего интереса:
public static Matrix3D operator *(Matrix3D a, Matrix3D b)
{
double c11 = a.M11 * b.M11 + a.M12 * b.M21 + a.M13 * b.M31;
double c12 = a.M11 * b.M12 + a.M12 * b.M22 + a.M13 * b.M32;
double c13 = a.M11 * b.M13 + a.M12 * b.M23 + a.M13 * b.M33;
double c21 = a.M21 * b.M11 + a.M22 * b.M21 + a.M23 * b.M31;
double c22 = a.M21 * b.M12 + a.M22 * b.M22 + a.M23 * b.M32;
double c23 = a.M21 * b.M13 + a.M22 * b.M23 + a.M23 * b.M33;
double c31 = a.M31 * b.M11 + a.M32 * b.M21 + a.M33 * b.M31;
double c32 = a.M31 * b.M12 + a.M32 * b.M22 + a.M33 * b.M32;
double c33 = a.M31 * b.M13 + a.M32 * b.M23 + a.M33 * b.M33;
return new Matrix3D(
c11, c12, c13,
c21, c22, c23,
c31, c32, c33);
}
где Matrix3D является неизменяемой структурой (только для чтения).
Трудная вещь - придумать правильный тест, где вы измеряете свой код, а не то, что компилятор сделал с вашим кодом (отладчик с кучей лишних вещей или оптимизирован без вашего фактического кода, так как результат никогда не использовался). Обычно я пытаюсь "прикоснуться" к результату, чтобы компилятор не смог удалить тестируемый код (например, проверить элементы матрицы для равенства с 89038.8989384 и выбросить, если они равны). Однако, в конце концов, я даже не уверен, что компилятор сбивает это сравнение с пути:)