Por curiosidade, decidi comparar a minha própria função de multiplicação de matrizes versus a implementação do BLAS ... Fiquei, para dizer, o menos surpreendido com o resultado:
Implementação personalizada, 10 testes de multiplicação de matriz 1000x1000:
Took: 15.76542 seconds.
Implementação do BLAS, 10 tentativas de multiplicação de matriz 1000x1000:
Took: 1.32432 seconds.
Isso está usando números de ponto flutuante de precisão única.
Minha implementação:
template<class ValT>
void mmult(const ValT* A, int ADim1, int ADim2, const ValT* B, int BDim1, int BDim2, ValT* C)
{
if ( ADim2!=BDim1 )
throw std::runtime_error("Error sizes off");
memset((void*)C,0,sizeof(ValT)*ADim1*BDim2);
int cc2,cc1,cr1;
for ( cc2=0 ; cc2<BDim2 ; ++cc2 )
for ( cc1=0 ; cc1<ADim2 ; ++cc1 )
for ( cr1=0 ; cr1<ADim1 ; ++cr1 )
C[cc2*ADim2+cr1] += A[cc1*ADim1+cr1]*B[cc2*BDim1+cc1];
}
Eu tenho duas perguntas:
- Dado que uma multiplicação matriz-matriz diz: nxm * mxn requer n * n * m multiplicações, portanto, no caso acima de 1000 ^ 3 ou 1e9 operações. Como é possível no meu processador de 2.6 GHz o BLAS fazer 10 * 1e9 operações em 1,32 segundos? Mesmo que as multiplicações fossem uma única operação e não houvesse mais nada sendo feito, isso levaria cerca de 4 segundos.
- Por que minha implementação é muito mais lenta?
Respostas:
Um bom ponto de partida é o grande livro The Science of Programming Matrix Computations, de Robert A. van de Geijn e Enrique S. Quintana-Ortí. Eles fornecem uma versão de download grátis.
O BLAS está dividido em três níveis:
O nível 1 define um conjunto de funções de álgebra linear que operam apenas em vetores. Essas funções se beneficiam da vetorização (por exemplo, do uso de SSE).
As funções de nível 2 são operações de vetor de matriz, por exemplo, algum produto de vetor de matriz. Essas funções podem ser implementadas em termos de funções de Nível 1. No entanto, você pode aumentar o desempenho dessas funções se puder fornecer uma implementação dedicada que faça uso de alguma arquitetura de multiprocessador com memória compartilhada.
As funções de nível 3 são operações como o produto matriz-matriz. Novamente, você pode implementá-los em termos de funções de Nível2. Mas as funções de Nível 3 realizam operações O (N ^ 3) em dados O (N ^ 2). Portanto, se sua plataforma tem uma hierarquia de cache, você pode aumentar o desempenho se fornecer uma implementação dedicada otimizada para cache / amigável para cache . Isso é muito bem descrito no livro. O principal impulso das funções do Nível 3 vem da otimização do cache. Esse aumento excede significativamente o segundo aumento do paralelismo e outras otimizações de hardware.
A propósito, a maioria (ou mesmo todas) as implementações de BLAS de alto desempenho NÃO são implementadas em Fortran. ATLAS é implementado em C. GotoBLAS / OpenBLAS é implementado em C e suas partes críticas de desempenho em Assembler. Apenas a implementação de referência do BLAS é implementada em Fortran. No entanto, todas essas implementações BLAS fornecem uma interface Fortran de forma que ele pode ser vinculado ao LAPACK (LAPACK ganha todo o seu desempenho do BLAS).
Compiladores otimizados desempenham um papel menor a este respeito (e para GotoBLAS / OpenBLAS o compilador não importa de todo).
A implementação de IMHO no BLAS usa algoritmos como o algoritmo Coppersmith – Winograd ou o algoritmo Strassen. Não tenho certeza sobre o motivo, mas este é o meu palpite:
Editar / Atualizar:
O artigo novo e inovador para este tópico são os artigos BLIS . Eles são excepcionalmente bem escritos. Para minha palestra "Noções básicas de software para computação de alto desempenho", implementei o produto matriz-matriz seguindo seu artigo. Na verdade, implementei várias variantes do produto matriz-matriz. As variantes mais simples são inteiramente escritas em C simples e têm menos de 450 linhas de código. Todas as outras variantes apenas otimizam os loops
O desempenho geral do produto matriz-matriz depende apenas desses loops. Cerca de 99,9% do tempo é gasto aqui. Nas outras variantes, usei intrínsecos e código assembler para melhorar o desempenho. Você pode ver o tutorial passando por todas as variantes aqui:
ulmBLAS: Tutorial sobre GEMM (produto Matrix-Matrix)
Junto com os papéis do BLIS, torna-se bastante fácil entender como bibliotecas como Intel MKL podem obter tal desempenho. E por que não importa se você usa armazenamento principal de linha ou coluna!
Os benchmarks finais estão aqui (chamamos nosso projeto ulmBLAS):
Benchmarks para ulmBLAS, BLIS, MKL, openBLAS e Eigen
Outra edição / atualização:
Eu também escrevi um tutorial sobre como o BLAS é usado para problemas de álgebra linear numérica, como resolver um sistema de equações lineares:
Fatoração LU de alto desempenho
(Esta fatoração LU é, por exemplo, usada pelo Matlab para resolver um sistema de equações lineares.)
Espero encontrar tempopara estender o tutorial para descrever e demonstrar como realizar uma implementação paralela altamente escalonável da fatoração LU como no PLASMA .Ok, aqui está: Codificando uma Fatoração de LU paralela otimizada de cache
PS: Eu também fiz alguns experimentos para melhorar o desempenho do uBLAS. Na verdade, é muito simples aumentar (sim, brincar com as palavras :)) o desempenho do uBLAS:
Experiências em uBLAS .
Aqui está um projeto semelhante com BLAZE :
Experiências em BLAZE .
fonte
Portanto, em primeiro lugar, o BLAS é apenas uma interface com cerca de 50 funções. Existem muitas implementações concorrentes da interface.
Em primeiro lugar, mencionarei coisas que não estão relacionadas:
A maioria das implementações divide cada operação em matriz de pequena dimensão ou operações de vetor de maneira mais ou menos óbvia. Por exemplo, uma grande multiplicação de matriz de 1000x1000 pode ser quebrada em uma sequência de multiplicações de matriz de 50x50.
Essas operações de pequena dimensão de tamanho fixo (chamadas de kernels) são codificadas no código de montagem específico da CPU usando vários recursos da CPU de seu destino:
Além disso, esses kernels podem ser executados em paralelo entre si, usando vários threads (núcleos de CPU), no padrão de design de redução de mapa típico.
Dê uma olhada no ATLAS, que é a implementação do BLAS de código aberto mais comumente usada. Ele tem muitos kernels concorrentes diferentes e, durante o processo de construção da biblioteca ATLAS, ele faz uma competição entre eles (alguns até são parametrizados, portanto, o mesmo kernel pode ter configurações diferentes). Ele tenta diferentes configurações e, a seguir, seleciona a melhor para o sistema de destino específico.
(Dica: é por isso que se você estiver usando ATLAS, é melhor construir e ajustar a biblioteca manualmente para sua máquina específica do que usar uma pré-construída.)
fonte
Primeiro, existem algoritmos mais eficientes para multiplicação de matrizes do que aquele que você está usando.
Em segundo lugar, sua CPU pode fazer muito mais do que uma instrução por vez.
Sua CPU executa 3-4 instruções por ciclo e, se as unidades SIMD forem usadas, cada instrução processa 4 flutuadores ou 2 duplos. (é claro que este número também não é preciso, já que a CPU normalmente pode processar apenas uma instrução SIMD por ciclo)
Terceiro, seu código está longe de ser ideal:
fonte
restrict
(sem aliasing) é o padrão, ao contrário de C / C ++. (E, infelizmente, ISO C ++ não tem umarestrict
palavra - chave, então você deve usar__restrict__
em compiladores que a fornecem como uma extensão).Não sei especificamente sobre a implementação do BLAS, mas existem alogoritmos mais eficientes para a Multiplicação de Matriz que tem complexidade melhor do que O (n3). Um bem conhecido é Strassen Algorithm
fonte
A maioria dos argumentos para a segunda questão - montador, divisão em blocos etc. (mas não menos do que N ^ 3 algoritmos, eles são realmente superdesenvolvidos) - desempenham um papel. Mas a baixa velocidade do seu algoritmo é causada essencialmente pelo tamanho da matriz e pelo infeliz arranjo dos três loops aninhados. Suas matrizes são tão grandes que não cabem imediatamente na memória cache. Você pode reorganizar os loops de forma que o máximo possível seja feito em uma linha no cache, reduzindo drasticamente as atualizações do cache (BTW, a divisão em pequenos blocos tem um efeito analógico, melhor se os loops sobre os blocos forem organizados de forma semelhante). Segue-se uma implementação de modelo para matrizes quadradas. No meu computador, seu consumo de tempo foi de cerca de 1:10 em comparação com a implementação padrão (como a sua). Em outras palavras: nunca programe uma multiplicação de matriz ao longo do "
Mais uma observação: Esta implementação é ainda melhor no meu computador do que substituir tudo pela rotina BLAS cblas_dgemm (experimente no seu computador!). Mas muito mais rápido (1: 4) está chamando dgemm_ da biblioteca Fortran diretamente. Acho que essa rotina não é de fato Fortran, mas código assembler (não sei o que está na biblioteca, não tenho os fontes). Totalmente incerto para mim é por que cblas_dgemm não é tão rápido, uma vez que, que eu saiba, é apenas um invólucro para dgemm_.
fonte
Esta é uma aceleração realista. Para obter um exemplo do que pode ser feito com o assembler SIMD sobre o código C ++, veja alguns exemplos de funções de matriz do iPhone - elas eram mais de 8x mais rápidas do que a versão C e nem mesmo são montagens "otimizadas" - ainda não há revestimento interno são operações de pilha desnecessárias.
Além disso, seu código não está " restrito correto " - como o compilador sabe que quando ele modifica C, não está modificando A e B?
fonte
-fstrict-aliasing
. Também há uma explicação melhor para "restringir" aqui: cellperformance.beyond3d.com/articles/2006/05/…Com relação ao código original em multiplicação MM, a referência de memória para a maioria das operações é a principal causa do mau desempenho. A memória está funcionando de 100 a 1000 vezes mais lenta que o cache.
A maior parte da velocidade vem do emprego de técnicas de otimização de loop para esta função de loop triplo na multiplicação MM. Duas técnicas de otimização de loop principais são usadas; desenrolando e bloqueando. Com relação ao desenrolamento, desenrolamos os dois loops mais externos e os bloqueamos para reutilização de dados no cache. O desenrolamento do loop externo ajuda a otimizar o acesso aos dados temporariamente, reduzindo o número de referências à memória para os mesmos dados em momentos diferentes durante toda a operação. Bloquear o índice de loop em um número específico ajuda a reter os dados no cache. Você pode escolher otimizar para cache L2 ou cache L3.
https://en.wikipedia.org/wiki/Loop_nest_optimization
fonte
Por muitas razões.
Em primeiro lugar, os compiladores Fortran são altamente otimizados e a linguagem permite que sejam assim. C e C ++ são muito flexíveis em termos de manipulação de array (por exemplo, o caso de ponteiros que se referem à mesma área de memória). Isso significa que o compilador não pode saber com antecedência o que fazer e é forçado a criar um código genérico. No Fortran, seus casos são mais simplificados e o compilador tem melhor controle do que acontece, permitindo que ele otimize mais (por exemplo, usando registradores).
Outra coisa é que o Fortran armazena coisas em colunas, enquanto C armazena dados em linhas. Eu não verifiquei seu código, mas tome cuidado com a forma como você executa o produto. Em C, você deve fazer a varredura de linha: desta forma, você faz a varredura de seu array ao longo da memória contígua, reduzindo as perdas de cache. A falta de cache é a primeira fonte de ineficiência.
Terceiro, depende da implementação do blas que você está usando. Algumas implementações podem ser escritas em assembler e otimizadas para o processador específico que você está usando. A versão netlib foi escrita em fortran 77.
Além disso, você está fazendo muitas operações, a maioria delas repetidas e redundantes. Todas essas multiplicações para obter o índice são prejudiciais para o desempenho. Eu realmente não sei como isso é feito no BLAS, mas existem muitos truques para evitar operações caras.
Por exemplo, você pode retrabalhar seu código desta forma
Experimente, tenho certeza de que você salvará algo.
Em sua pergunta nº 1, o motivo é que a multiplicação de matrizes escala como O (n ^ 3) se você usar um algoritmo trivial. Existem algoritmos que escalam muito melhor .
fonte