параллельная декомпозиция SVD с деосами openMP работает не так, как ожидалось

Недавно я закодировал параллельную процедуру декомпозиции SVD, основанную на алгоритме «односторонних вращений Якоби». Код работает правильно, но очень медленно. На самом деле он должен использовать параллелизм во внутреннем цикле for for(int g=0;g<n;g++), но, закомментировав директиву #pragma omp paralell for, я могу оценить лишь незначительное снижение производительности. Другими словами, нет заметного ускорения при параллельной работе (код работает параллельно с 4 потоками).

Примечание 1: почти вся работа сосредоточена в трех следующих циклах с относительно большими матрицами A и V.

for(h=0;h<N;h++)
{
    p+=A[h+N*i]*A[h+N*j];//columns dot product:Ai * Aj
    qi+=A[h+N*i]*A[h+N*i];// ||Ai||^2
    qj+=A[h+N*j]*A[h+N*j];// ||Aj||^2
}

а также

double Ahi,Vhi;
for(h=0;h<N;h++)//...rotate Ai & Aj (only columns i & j are changend)
{
    Ahi=A[h+N*i];
    A[h+N*i]=cs*A[h+N*i]+sn*A[h+N*j];
    A[h+N*j]=-sn*Ahi+cs*A[h+N*j];
}
//store & update rotation matrix V (only columns i & j are updated)
for(h=0;h<N;h++)
{
    Vhi=V[h+N*i];
    V[h+N*i]=cs*V[h+N*i]+sn*V[h+N*j];
    V[h+N*j]=-sn*Vhi+cs*V[h+N*j];
}

Там должен быть использован весь параллелизм, но это не так. И я не могу понять, почему.

Примечание 2: То же самое происходит как на платформах Windows (компилятор cygWin), так и на платформах Linux (GCC).

Примечание 3: матрицы представлены основными массивами столбцов.

Поэтому я ищу помощи в выяснении того, почему параллелизм не используется. Я что-то пропустил? В параллели есть какие-то скрытые накладные расходы, которые я не вижу?

Большое спасибо за любое предложение

int sweep(double* A,double*V,int N,double tol)
{
static int*I=new int[(int)ceil(0.5*(N-1))];
static int*J=new int[(int)ceil(0.5*(N-1))];
int ntol=0;
for(int r=0;r<N;r++) //fill in i,j indexes of parallel rotations in vectors     I & J
{
    int k=r+1;
    if (k==N)
    {
    for(int i=2;i<=(int)ceil(0.5*N);i++){
        I[i-2]=i-1;
        J[i-2]=N+2-i-1;
        }
    }
    else
    {
        for(int i=1;i<=(int)ceil(0.5*(N-k));i++)I[i-1]=i-1;
        for(int i=1;i<=(int)ceil(0.5*(N-k));i++)J[i-1]=N-k+2-i-1;
        if(k>2)
        {
            int j=(int)ceil(0.5*(N-k));
            for(int i=N-k+2;i<=N-(int)floor(0.5*k);i++){
                I[j]=i-1;
                J[j]=2*N-k+2-i-1;
                j++;
            }
        }
    }

    int n=(k%2==0)?(int)floor(0.5*(N-1)):(int)floor(0.5*N);

    #pragma omp parallel for schedule(dynamic,5) reduction(+:ntol)  default(none)   shared(std::cout,I,J,A,V,N,n,tol)
    for(int g=0;g<n;g++)
    {
        int i=I[g];
        int j=J[g];
        double p=0;
        double qi=0;
        double qj=0;
        double cs,sn,q,c;
        int h;
        for(h=0;h<N;h++)
        {
            p+=A[h+N*i]*A[h+N*j];//columns dot product:Ai * Aj
            qi+=A[h+N*i]*A[h+N*i];// ||Ai||^2
            qj+=A[h+N*j]*A[h+N*j];// ||Aj||^2
        }
        q=qi-qj;
        if(p*p/(qi*qj)<tol) ntol++; //if Ai & Aj are orthogonal enough...
        else                        //if Ai & Aj are not orthogonal enough then... rotate them
        {
            c=sqrt(4*p*p+q*q);
            if(q>=0){
                cs=sqrt((c+q)/(2*c));
                sn=p/(c*cs);
            }
            else{
                sn=(p>=0)?sqrt((c-q)/2/c):-sqrt((c-q)/2/c);
                cs=p/(c*sn);
            }
            //...rotate Ai & Aj (only columns i & j are changend)
            double Ahi,Vhi;
            for(h=0;h<N;h++)
            {
                Ahi=A[h+N*i];
                A[h+N*i]=cs*A[h+N*i]+sn*A[h+N*j];
                A[h+N*j]=-sn*Ahi+cs*A[h+N*j];
            }
            //store & update rotation matrix V (only columns i & j are updated)
            for(h=0;h<N;h++)
            {
                Vhi=V[h+N*i];
                V[h+N*i]=cs*V[h+N*i]+sn*V[h+N*j];
                V[h+N*j]=-sn*Vhi+cs*V[h+N*j];
            }
        }
    }
}
if(2*ntol==(N*(N-1)))return(1);//if each columns of A is orthogonal enough     to each other stop sweep

return(0);
}

person Teol11    schedule 17.02.2015    source источник
comment
Как вы измеряли производительность? Как выглядят результаты?   -  person Vladimir F    schedule 18.02.2015
comment
Прежде чем я прочитаю ваш код более подробно, можете ли вы подтвердить, что у вас нет состояния гонки? Параллельная версия дает правильный результат? Являются ли h+N*i и h+N*j уникальными для каждого потока?   -  person Z boson    schedule 18.02.2015
comment
Можете ли вы также указать, какое у вас оборудование, и какие варианты вы компилируете?   -  person Z boson    schedule 18.02.2015
comment
Что касается производительности, я просто позволяю коду работать с матрицами 1000x1000 (мой целевой размер). Последовательный код занял 64 секунды, параллельный 49 секунд. Я могу исключить условия гонки, так как параллельные операции одновременно выполняются над разными столбцами матриц A и V (как указывает Z-бозон, i и j уникальны для каждого потока), и я проверил результаты даже для небольшого проблемы (не 1000х1000). Мое оборудование представляет собой четырехъядерный процессор Intel i7, и я скомпилировал его с помощью g++ -fopen -msse4 -O3. Я запускаю тест на Ubuntu (intel i3 и те же параметры компиляции): никакого существенного выигрыша от параллелизма.   -  person Teol11    schedule 18.02.2015
comment
Для полноты подпрограмма развертки вызывается много раз (например, в моем тесте с размерностью 1000x1000, 28 раз), прежде чем матрица A будет факторизована SVD, пока она не вернет 1.   -  person Teol11    schedule 18.02.2015
comment
Вы знаете временную сложность этого алгоритма? Если это O (n ^ 2), то, вероятно, это связано с пропускной способностью памяти, и несколько потоков не сильно помогут. Какой уровень СВД в BLAS (уровень 1, 2 или 3)?   -  person Z boson    schedule 18.02.2015
comment
Я думаю, вы настроили это, но каково постоянство, когда вы меняете schedule(dynamic,5) на schedule(static)?   -  person Z boson    schedule 18.02.2015


Ответы (2)


Благодаря замечаниям по Z-бозону мне удалось написать гораздо более эффективное параллельное разложение SVD. Он работает во много раз быстрее, чем исходный, и я думаю, его еще можно улучшить, используя SIMD-инструкции. Выкладываю код, вдруг кому пригодится. Все тесты, которые я провел, дали мне правильные результаты, в любом случае гарантии на его использование нет. Мне очень жаль, что у меня не было времени правильно прокомментировать код для лучшей понятности.

int sweep(double* A,double*V,int N,double tol,int M, int n)
{
/********************************************************************************************
This routine performs a parallel "sweep" of the SVD factorization algorithm for the matrix A.
It implements a single sided Jacobi rotation algorithm, described by Michael W. Berry,
Dani Mezher, Bernard Philippe, and Ahmed Sameh in "Parallel Algorithms for the Singular Value
Decomposition".
At each sweep the A matrix becomes a little more orthogonal, until each column of A is orthogonal
to each other within a give tolerance. At this point the sweep() routine returns 1 and convergence
is attained.

Arguments:

A   : on input the square matrix to be orthogonalized, on exit a more "orthogonal" matrix
V   : on input the accumulated rotation matrix, on exit it is updated with the current rotations
N   : dimension of the matrices.
tol :tolerance for convergence of orthogonalization. See the explainations for SVD() routine
M   : number of blocks (it is calculated from the given block size n)
n   : block size (number of columns on each block). It should be an optimal value according to the hardware available

returns : 1 if in the last sweep convergence is attained, 0 if not and one more sweep is necessary

Author : Renato Talucci 2015
*************************************************************************************************/

#include <math.h>
int ntol=0;
//STEP 1 : INTERNAL BLOCK ORTHOGONALISATION
#pragma omp paralell for reduction(+:ntol) shared(A,V,n,tol,N) default(none)
for(int a=0;a<M;a++)
{
    for(int i=a*n;i<a*n+imin(n,N-a*n)-1;i++)
    {
        for(int j=i+1;j<a*n+imin(n,N-a*n);j++)
        {
            double p=0;
            double qi=0;
            double qj=0;
            double cs,sn,q,c;
            for(int h=0;h<N;h++)
            {
                p+=A[h+N*i]*A[h+N*j];//columns dot product:Ai * Aj
                qi+=A[h+N*i]*A[h+N*i];// ||Ai||^2
                qj+=A[h+N*j]*A[h+N*j];// ||Aj||^2
            }
            q=qi-qj;
            if((p*p/(qi*qj)<tol)||(qi<tol)||(qj<tol))ntol++; //if Ai & Aj are orthogonal enough...
            else                        //if Ai & Aj are not orthogonal enough then... rotate them
            {
                c=sqrt(4*p*p+q*q);
                if(q>=0){
                    cs=sqrt((c+q)/(2*c));
                    sn=p/(c*cs);
                }
                else{
                    sn=(p>=0)?sqrt((c-q)/2/c):-sqrt((c-q)/2/c);
                    cs=p/(c*sn);
                }
                //...rotate Ai & Aj
                double Ahi,Vhi;
                for(int h=0;h<N;h++)
                {
                    Ahi=A[h+N*i];
                    A[h+N*i]=cs*A[h+N*i]+sn*A[h+N*j];
                    A[h+N*j]=-sn*Ahi+cs*A[h+N*j];
                }
                //store & update rotation matrix V (only columns i & j atre updated)
                for(int h=0;h<N;h++)
                {
                    Vhi=V[h+N*i];
                    V[h+N*i]=cs*V[h+N*i]+sn*V[h+N*j];
                    V[h+N*j]=-sn*Vhi+cs*V[h+N*j];
                }
            }

        }
    }
}

//STEP 2 : PARALLEL BLOCK MUTUAL ORTHOGONALISATION
static int*I=new int[(int)ceil(0.5*(M-1))];
static int*J=new int[(int)ceil(0.5*(M-1))];
for(int h=0;h<M;h++)
{
    //fill in i,j indexes of blocks to be mutually orthogonalized at each turn
    int k=h+1;
    if (k==M)
    {
        for(int i=2;i<=(int)ceil(0.5*M);i++){
            I[i-2]=i-1;
            J[i-2]=M+2-i-1;
        }
    }
    else
    {
        for(int i=1;i<=(int)ceil(0.5*(M-k));i++)I[i-1]=i-1;
        for(int i=1;i<=(int)ceil(0.5*(M-k));i++)J[i-1]=M-k+2-i-1;
        if(k>2)
        {
            int j=(int)ceil(0.5*(M-k));
            for(int i=M-k+2;i<=M-(int)floor(0.5*k);i++){
                I[j]=i-1;
                J[j]=2*M-k+2-i-1;
                j++;
            }
        }

    }
    int ng=(k%2==0)?(int)floor(0.5*(M-1)):(int)floor(0.5*M);

    #pragma omp parallel for schedule(static,5) shared(A,V,I,J,n,tol,N,ng) reduction(+:ntol) default(none)
    for(int g=0;g<ng;g++)
    {
        int block_i=I[g];
        int block_j=J[g];
        for(int i=block_i*n;i<block_i*n+imin(n,N-block_i*n);i++)
        {
            for(int j=block_j*n;j<block_j*n+imin(n,N-block_j*n);j++)
            {
                double p=0;
                double qi=0;
                double qj=0;
                double cs,sn,q,c;
                int h;
                for(h=0;h<N;h++)
                {
                    p+=A[h+N*i]*A[h+N*j];//columns dot product:Ai * Aj
                    qi+=A[h+N*i]*A[h+N*i];// ||Ai||^2
                    qj+=A[h+N*j]*A[h+N*j];// ||Aj||^2
                }
                q=qi-qj;
                if((p*p/(qi*qj)<tol)||(qi<tol)||(qj<tol))ntol++; //if Ai & Aj are orthogonal enough...
                else                        //if Ai & Aj are not orthogonal enough then... rotate them
                {
                    c=sqrt(4*p*p+q*q);
                    if(q>=0){
                        cs=sqrt((c+q)/(2*c));
                        sn=p/(c*cs);
                    }
                    else{
                        sn=(p>=0)?sqrt((c-q)/2/c):-sqrt((c-q)/2/c);
                        cs=p/(c*sn);
                    }
                    //...rotate Ai & Aj
                    double Ahi,Vhi;
                    for(h=0;h<N;h++)
                    {
                        Ahi=A[h+N*i];
                        A[h+N*i]=cs*A[h+N*i]+sn*A[h+N*j];
                        A[h+N*j]=-sn*Ahi+cs*A[h+N*j];
                    }
                    //store & update rotation matrix V (only columns i & j atre updated)
                    for(h=0;h<N;h++)
                    {
                        Vhi=V[h+N*i];
                        V[h+N*i]=cs*V[h+N*i]+sn*V[h+N*j];
                        V[h+N*j]=-sn*Vhi+cs*V[h+N*j];
                    }
                }

            }
        }


    }
}
if(2*ntol==(N*(N-1)))return(1);//if each columns of A is orthogonal enough to each other stop sweep
return(0);
}

int SVD(double* A,double* U,double*V,int N,double tol,double* sigma)
{
/********************************************************************************************
This routine calculates the SVD decomposition of the square matrix A [NxN]

                                A = U * S * V'

Arguments :
A       :   on input NxN square matrix to be factorized, on exit contains the
            rotated matrix A*V=U*S.
V       :   on input an identity NxN matrix, on exit is the right orthogonal matrix
            of the decomposition A = U*S*V'
U       :   NxN matrix, on exit is the left orthogonal matrix of the decomposition A = U*S*V'.
sigma   :   array of dimension N. On exit contains the singular values, i.e. the diagonal
            elements of the matrix S.
N       :   Dimension of the A matrix.
tol     :   Tolerance for the convergence of the orthogonalisation of A. Said Ai and Aj any two
            columns of A, the convergence is attained when Ai*Aj / ( |Ai|*|Aj| ) < tol for each i,j=0,..,N-1 (i!=j)

The software returns the number of sweeps needed for convergence.

NOTE : ALL MATRICES ARE ASSUMED TO HAVE COLUMN MAJOR ORDERING I.E. M(i,j)=M[i+N*j]

Author: Renato Talucci 2015
*************************************************************************************************/

int n=24;//this is the dimension of block submatrices, you shall enter an optimal value for your hardware
int M=N/n+int(((N%n)!=0)?1:0);
int swp=0;//sweeps counter
int converged=0;
while(converged==0) {
    converged=sweep(A,V,N,tol,M,n);
    swp++;
}
#pragma omp parallel for default(none) shared(sigma,A,U,N)
for(int i=0;i<N;i++)
{
    double si=0;
    for(int j=0;j<N;j++) si+=A[j+N*i]*A[j+N*i];
    si=sqrt(si);
    for(int k=0;k<N;k++) U[k+N*i]=A[k+N*i]/si;
    sigma[i]=si;
}

return(swp);
}

Примечание: если какой-то пользователь предпочитает оставить A без изменений при выходе, достаточно вычислить U=A*V перед входом в цикл while, и вместо передачи матрицы A процедуре Swee() передать полученную матрицу U. Матрица U должна быть ортонормирована вместо A после сходимости развертки(), а директива #pragma omp должна включать U в общие переменные вместо A.

Примечание 2: если вам нужно (как и мне) разложить на множители последовательность матриц A (k), каждую из которых A (k) можно рассматривать как возмущение предыдущей A (k-1), можно рассматривать как матрицу Якоба. в многошаговом решателе Ньютона драйвер можно легко модифицировать для обновления с A(k-1) до A(k) вместо вычисления A(k) с самого начала. Вот код:

int updateSVD(double* DA,double* U,double*V,int N,double tol,double* sigma)
{
/********************************************************************************************
Given a previously factorization 

                        A(k-1) = U(k-1) * S(k-1) * V(k-1)'

and given a perturbation DA(k) of A(k-1), i.e.

                                    A(k) = A(k-1) + DA(k)

this routine calculates the SVD factorization of A(k), starting from the factorization of A(k-1)

Arguments:

DA      :   on input NxN perturbation matrix, unchanged on exit
U       :   on input NxN orthonormal left matrix of the previous (k-1) factorization, on exit 
            orthonormal right matrix of the current factorization
V       :   on input NxN orthonormal right matrix of the previous (k-1) factorization, on exit 
            orthonormal right matrix of the current factorization
N       :   dimension of the matrices
tol     :   Tolerance for the convergence of the orthogonalisation of A. Said Ai and Aj any two
            columns of A, the convergence is attained when Ai*Aj / ( |Ai|*|Aj| ) < tol for each i,j=0,..,N-1 (i!=j)
sigma   :   on input, array with the N singular values of the previuos factorization, on exit 
            array with the N singular values of the current factorization
NOTE : ALL MATRICES ARE ASSUMED TO HAVE COLUMN MAJOR ORDERING I.E. M(i,j)=M[i+N*j]   

Author: Renato Talucci 2015
*************************************************************************************************/


for(int i=0;i<N;i++) for(int j=0;j<N;j++) U[i+N*j]*=sigma[j];
int n=24; //this is the dimension of block submatrices, you shall enter an optimal value for your hardware
matmat_col_col(DA,V,U,N,n); //U =U(k-1)*S(k-1) + DA(k)*V(k-1) = A(k)*V(k-1) 
int M=N/n+int(((N%n)!=0)?1:0);  //number of blocks
int swp=0;//sweeps counter
int converged=0;
while(converged==0) {
    converged=sweep(U,V,N,tol,M,n);
    swp++;
}

#pragma omp parallel for default(none) shared(sigma,U,N)
for(int i=0;i<N;i++)
{
    double si=0;
    for(int j=0;j<N;j++) si+=U[j+N*i]*U[j+N*i];
    si=sqrt(si);
    for(int k=0;k<N;k++) U[k+N*i]=U[k+N*i]/si;
    sigma[i]=si;
}

return(swp);
}

Наконец, подпрограмма matmat_col_col(DA,V,U,N,n) является произведением параллельной блочной матрицы. Вот код:

inline int imin(int a,int b) {return((a<=b)?a:b);}

void matmat_col_col(double* A,double* B,double*C,int  N,int n)
/********************************************************************************************
square matrix block product NxN :

                            C = C + A * B

n is the optimal block dimension
N is the dimension of the matrices

NOTE : ALL MATRICES ARE ASSUMED TO HAVE COLUMN MAJOR ORDERING M(i,j) = M[i+N*j]

Author: Renato Talucci 2015
*************************************************************************************************/
{
 int M=N/n+int(((N%n)!=0)?1:0);
 #pragma omp parallel for shared(M,A,B,C)
 for(int a=0;a<M;a++)
 {
     for(int b=0;b<M;b++)
     {
         for(int c=0;c<M;c++)
         {
             for(int i=a*n;i<imin((a+1)*n,N);i++)
             {
                 for(int j=b*n;j<imin((b+1)*n,N);j++)
                 {
                     for(int k=c*n;k<imin((c+1)*n,N);k++)
                     {
                          C[i+N*j]+=A[i+N*k]*B[k+N*j];
                     }

                 }

             }
         }
     }
 }
return;
}

Я надеюсь, что из-за большого количества копипастов не было создано никаких опечаток.

Было бы неплохо, если бы кто-нибудь мог улучшить код.

person Teol11    schedule 23.02.2015
comment
Хорошая работа! Не могли бы вы добавить таблицу времени до и после вашего улучшения. Вы также должны сравнить свой результат с библиотекой BLAS. Intel MKL - лучший (для процессоров Intel), если он у вас есть, в противном случае попробуйте Eigen. Вы также должны точно указать, какое у вас оборудование. - person Z boson; 27.02.2015
comment
Учитывая мою тестовую матрицу 1000x1000, первый код занял 64 секунды. После распараллеливания понадобилось еще 49 секунд. Новый код занимает 2 (две) секунды. Подчеркну, что это всего лишь показательные выступления. Из-за нехватки времени я мог сравнить свой код только с готовой и, вероятно, очень плохо оптимизированной версией OpenBlas, используя процедуру lapack_e dgesvd(). Для той же матрицы выполнение задания заняло 9 секунд. Аппаратное обеспечение — Intel i5-2400 Quad 3,10 ГГц + Windows XP, 4 потока. - person Teol11; 02.03.2015
comment
@ Teol11 Teol11 Похоже, что эта ветка какое-то время бездействовала, но если вы все еще читаете, у вас есть представление о том, насколько большой должна быть матрица, чтобы OpenMP приносил пользу вашему коду? Мне нужно что-то для ускорения разложения матрицы 7х6, которая мне кажется маленькой. Но не обвиняйте меня в том, что я хватаюсь за соломинку. :-) Спасибо. - person bob.sacamento; 10.04.2018

FLOPS для разложения по единичным значениям (SVD) должен идти как O(N^3), тогда как читается как O(N^2). Это означает, что можно распараллелить алгоритм и обеспечить его хорошее масштабирование в зависимости от количества ядер.

Однако ваша реализация SVD ограничена пропускной способностью памяти, что означает, что она не может хорошо масштабироваться с количеством ядер для системы с одним сокетом. Каждый из ваших трех вложенных циклов в настоящее время проходит через весь диапазон N, что приводит к удалению большей части данных из кеша при следующем повторном использовании.

Чтобы быть привязанным к вычислениям, вам придется изменить свой алгоритм так, чтобы вместо работы с длинными полосами/точечными произведениями размера N он использовал мозаику цикла для максимизации n^3 (где n - размер блока) вычисления в кэше размером n^2.

Вот статья о параллельном вычислении SVD из поиска Google. что написано в аннотации

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

Я использовал циклическую мозаику/блоки для достижения хорошего масштабирования с количеством ядер для разложения холецкого.

Обратите внимание, что использование плиток/блоков также улучшит производительность вашего однопоточного кода.

person Z boson    schedule 18.02.2015
comment
Спасибо за ваш анализ. Это выходит далеко за рамки моих навыков программиста на С++...-) - person Teol11; 18.02.2015