Archive for the ‘Técnicas de programação’ Category.

Concepts e templates condicionais

Concepts

O comitê de padronização do C++ox decidiu excluir concepts da proposta de evolução corrente da linguagem. Dessa forma uma importante ferramenta foi deixada de lado.

Na proposta discutida, concepts seria uma forma de garantir que determinado parâmetro de uma template implementa métodos específicos (obedecendo a um padrão) ou possui determinados tipos. Enfim, uma forma de garantir que uma template só irá ser instanciada se o tipo passado possuir certas características. Suponha que uma template necessite de um parâmetro tipo que possua o método mm. Atualmente, como não é possível especificar um concept, o compilador irá dar um erro de compilação durante a instanciação da template reclamando da falta do método mm. O problema é que uma template repassa seus parâmetros e instancia outra que instancia outra e assim por diante, de modo que a mensagem de erro costuma não ser muito clara, e possivelmente em uma template bem distante da original…

Um outro problema relacionado é quando queremos redefinir uma operação para um grupo de classes que não são da mesma hierarquia mas possuem métodos semelhantes – na STL, os containers, por exemplo, possuem sempre os métodos begin() e end() retornando iteradores. Se quisermos redefinir “operator <<” para imprimir um container teremos de fazer um operador para cada um dos tipos: vector, map, list etc. Uma forma mais simples de se fazer isso seria especificando um concept exigindo que os iteradores estejam presentes em uma template mais genérica.

Exemplo

#include <cstdlib>
#include <iostream>
#include <map>
#include <vector>

using namespace std;

template <class A, class B>
ostream& operator << ( ostream& o, const pair<A,B>& n ) {
  return o << "(" << n.first << ", " << n.second << ")";
}

template <class T>
ostream& operator << ( ostream& o, const vector<T>& v ) {
  o << "[ ";
  for( typename vector<T>::const_iterator i = v.begin(); i != v.end(); ++i )
    cout << *i << " ";
  return o << "]";
}

template <class K, class T>
ostream& operator << ( ostream& o, const map<K,T>& m ) {
  o << "[ ";
  for( typename map<K,T>::const_iterator i = m.begin(); i != m.end(); ++i )
    cout << *i << " ";
  return o << "]";
}

int main(int argc, char *argv[])
{
  vector<int> v;
  map<int, string> m;

  v.push_back( 0 );
  v.push_back( 1 );
  v.push_back( 2 );

  m[0] = "zero";
  m[1] = "um";
  m[2] = "dois";

  cout << v << endl << m << endl;

  system("PAUSE");
  return EXIT_SUCCESS;
}

Na linha 8 criamos uma template para imprimir o tipo pair<A,B> redefinindo o operador “<<”. Nas linhas 13 e 21 fizemos o mesmo para imprimir vector<T> e map<K,T>. O código acima produz a seguinte saída:

[ 0 1 2 ]
[ (0, zero) (1, um) (2, dois) ]
Pressione qualquer tecla para continuar. . .

Simulando concepts

Podemos simular o uso de um concept. Para tanto, basta entendermos como funciona o compilador na hora de instanciar uma template. Em um primeiro momento, o compilador procura casar os parâmetros passados para a template com as templates candidatas. Há regras para desambiguidade se mais de uma casar, e o compilador só dará uma mensagem de erro se não for possível escolher uma.

Se uma template casa, mas dentro de seu código ela usa algo (um método ou um tipo) que não está definido para o tipo usado na instanciação será gerado um erro de compilação dizendo “mm não definido para tipo T”. Por outro lado, se um cabeçalho de uma template exigir algo do parâmetro que ele não tenha não será gerado um erro: apenas essa template não casará. O nome desse comportamento do compilador é “Substitution Failure Is Not An Error” ou simplesmente SFINAE (grato, Felipe Magno de Almeida).

Portanto, podemos exigir no cabeçalho de uma template que um determinado método (ou tipo) esteja presente para que a template case. As técnicas para fazer isso são um pouco confusas, mas vamos lá. No exemplo anterior gostaríamos de definir uma única template para o operador “<<”  que sirva para qualquer classe que tenha suporte a iteradores. Aqui o pulo do gato: templates de classes e estruturas permitem o uso de parâmetros default (templates de funções não!). Basta portanto criarmos um parâmetro default (que não será nunca usado) e que dependa da existência de um tipo ou método (nesse caso devemos tomar o endereço do método). Por exemplo, podemos criar uma template que só será instanciada se o tipo passado possuir o tipo interno “const_iterator” (todos os containers têm esse tipo interno):

template <class V, class Ok = typename V::const_iterator>
struct CheckItr {};

CheckItr< vector<int> > teste1;
CheckItr< int > teste2;

No código acima, a template CheckItr recebe um parâmetro V e um parâmetro Ok cujo default é V::const_iterator. Se quisermos fazer a verificação, esse parâmetro deve ser sempre preenchido automaticamente, ou seja, não devemos fornecer um tipo para ele. Dessa forma, a linha 4 do exemplo acima irá compilar normalmente instanciando “CheckItr< vector<int>, vector<int>::const_iterator >” ao passo que a linha 5 irá dar um erro de compilação pois int não possui o tipo const_iterator definido internamente – int nem é classe!

Agora a solução do exemplo: o operador “<<” é redefinido o tempo todo para “ostream&” e algum outro tipo. Se criarmos uma template genérica como abaixo teremos um grande problema de ambiguidade:

template <class T>
ostream& operator << ( ostream& o, const T& v );

Mas nós podemos exigir que o parâmetro T da template acima seja capaz de instanciar CheckItr – note que se ele não for capaz de instanciar o compilador irá apenas instanciar ou procurar outro operador “<<”. Dessa forma resolveríamos o problema de ter apenas uma template para todos os tipos que implementam iteradores. Mas como fazer isso? Se fosse uma função, seria simples, pois novamente poderíamos colocar um parâmetro default (parâmetro da função , não da template… template para funções não permitem parâmetros default). Veja:

template <class T>
void imprime( const T& v, CheckItr<T>* p = NULL ) {
  for( typename T::const_iterator i = v.begin(); i != v.end(); ++i )
    cout << *i << " ";
}

Se a função imprime for chamada passando-se um vector, map, list ou qualquer outro objeto cuja classe defina const_iterator não haverá problema, ela será instanciada (novamente não se deve passar o parâmetro default). Se o tipo passado não possuir const_iterator, o compilador irá tentar casar com outra função imprime – se não conseguir, aí sim será gerado um erro de compilação. Infelizmente, não podemos definir parâmetros default para o operador “<<”, de modo que teremos de adotar uma outra estratégia para contornar essa limitação. A solução sugerida é colocar a verificação no parâmetro ostream&. Para tanto, definimos em CheckItr um tipo interno “ostream“, e o usamos no cabeçalho do operador “<<”. Segue:

template <class V, class Ok = typename V::const_iterator >
struct CheckItr {
  typedef ::ostream ostream;
};

template <class T>
ostream& operator << ( typename CheckItr<T>::ostream& o, const T& v ) {
  o << "[ ";
  for( typename T::const_iterator i = v.begin(); i != v.end(); ++i )
    cout << *i << " ";
  return o << "]";
}

Assim essa template somente será instanciada se o parâmetro T puder instanciar CheckItr. Se isso ocorrer, o tipo CheckItr<T>::ostream existe, e a template casou. Ou seja, somente se T possui const_iterator definido que essa template para o operador “<<” será utilizada.

Segue o exemplo completo:

#include <cstdlib>
#include <iostream>
#include <map>
#include <vector>

using namespace std;

template <class V, class Ok = typename V::const_iterator >
struct CheckItr {
  typedef ::ostream ostream;
};

template <class A, class B>
ostream& operator << ( ostream& o, const pair<A,B>& n ) {
  return o << "(" << n.first << ", " << n.second << ")";
}

template <class T>
ostream& operator << ( typename CheckItr<T>::ostream& o, const T& v ) {
  o << "[ ";
  for( typename T::const_iterator i = v.begin(); i != v.end(); ++i )
    cout << *i << " ";
  return o << "]";
}

int main(int argc, char *argv[])
{
  vector<int> v;
  map<int, string> m;

  v.push_back( 0 );
  v.push_back( 1 );
  v.push_back( 2 );

  m[0] = "zero";
  m[1] = "um";
  m[2] = "dois";

  cout << v << endl << m << endl;

  system("PAUSE");
  return EXIT_SUCCESS;
}

Dessa forma a template acima atenderá todos os containers da STL e também qualquer classe que tenha definido o tipo const_iterator. Espera-se que se uma classe define const_iterator ela defina também begin() e end() - não fizemos isso no exemplo, mas isso pode ser verificado em CheckItr<T>.  Para verificar se um tipo possui um método a maneira mais simples é obter o endereço desse método usando o operador “&”. O retorno desse operador é uma constante, de modo que pode ser usado como parâmetro de uma template.

template <class V, typename V::const_iterator (V::*begin)() const = &V::begin,
                   typename V::const_iterator (V::*end)() const = &V::end >
struct CheckItr {
  typedef ::ostream ostream;
};

A verificação acima já checa se existe o tipo const_iterator por causa do valor de retorno de begin() e end(). Note que tomamos o cuidado de especificar uma variável ponteiro para membro constante de V, pois begin() é um método sobrecarregado e nesses casos o C++ exige informação de contexto.

“You shall not pass!”

Lambda Calculus – Parte 2

Expressões Lambda

Para criarmos uma expressão que retorne um functor iremos usar templates. Para isso precisaremos redefinir todos os operadores – aritméticos, lógicos, relacionais etc. Por exemplo, para podermos escrever uma expressão “2*x + 1” precisamos redefinir “*” e “+”. No entanto, embora possamos redefinir “*” para Var e int, o operador “+” precisa ser redefinido para o functor retornado por “2*x” e int. Como a princípio a expressão do lado esquerdo pode ser qualquer coisa (a do lado direito também), não temos como saber a priori qual o seu tipo. Por outro lado, não podemos redefinir o operador “+” para tipos genéricos sob o risco de fazer parar de funcionar outras templates. Assim, precisamos distinguir o que é uma expressão lambda de outros tipos do programa. A melhor forma de fazer isso é criar uma template Lambda que apenas encapsula um functor qualquer, e redefine o operador “()” para um parâmetro qualquer. Pensando dessa forma, a própria variável x pode ser do tipo Lambda, sob a forma de especialização de templates. O tipo Lambda será então apenas um envelope para expressões/functores, repassando para a expressão/functor a tarefa de executar a operação desejada.

template <class Functor>
struct Lambda {
  const Functor f;

  Lambda( const Functor& f ): f(f) {}

  template <class T>
  T operator()( const T& x ) const { return f( x ); }
};

struct Var {
  template <class T>
  T operator()( const T& x ) const { return x; }
};

template <>
struct Lambda< Var > {
  Var f;

  template <class T>
  T operator()( const T& x ) const { return x; }
};

typedef Lambda< Var > ParamX;

ParamX x;

Assim podemos redefinir os operadores para atuarem apenas em tipos Lambda< T >.

Para podermos encadear os operadores devemos primeiro transformá-los em functores com uma interface única, de modo que a chamada a um operador possa ser capturada em uma template (a STL usa essa técnica). Para tanto, criaremos uma struct com uma template para uma função estática (que pode ser chamada sem um objeto) que recebe dois parâmetros e retorna a aplicação do operador a estes parâmetros.

struct Adicao {
  template <class A, class B>
  static A aplica( A a, B b ) { return a + b; }
};

struct Multiplicacao {
  template <class A, class B>
  static A aplica( A a, B b ) { return a * b; }
};

struct Subtracao {
  template <class A, class B>
  static A aplica( A a, B b ) { return a - b; }
};

struct Divisao {
  template <class A, class B>
  static A aplica( A a, B b ) { return a / b; }
};

Dessa forma, com uma única template, que chamaremos de Oper, podemos encadear os operadores:

template <class O, class A, class B>
class Oper {
    private:
      const A a;
      const B b;

    public:
      Oper( const A& a, const B& b ): a(a), b(b) {}

      template <class T>
      T operator()( const T& x ) const { return O::aplica( a(x), b(x) ); }
};

Essa template possui dois campos, a e b, que são eles mesmos functores – e é exatamente isso que irá permitir que uma expressão seja representada por functores encadeados. Por exemplo, para representar x + x podemos escrever o seguinte código:

int main(int argc, char *argv[]) {
  Oper< Adicao, ParamX, ParamX > expr( x, x );

  cout << expr( 10 ) << endl;

  return EXIT_SUCCESS;
}

Esse código produz como saída 20. Mas o que queremos é poder realmente escrever x + x. Para tanto basta escrever uma template para o operador + com parâmetros Lambda< T >.

template <class A, class B>
inline Lambda< Oper< Adicao, A, B > > operator + ( const Lambda<A>& a, const Lambda<B>& b ) {
  return Oper< Adicao, A, B  >( a.f, b.f );
}

template <class A, class B>
inline Lambda< Oper< Multiplicacao, A, B > > operator * ( const Lambda<A>& a, const Lambda<B>& b ) {
  return Oper< Multiplicacao, A, B  >( a.f, b.f );
}

template <class A, class B>
inline Lambda< Oper< Subtracao, A, B > > operator - ( const Lambda<A>& a, const Lambda<B>& b ) {
  return Oper< Subtracao, A, B  >( a.f, b.f );
}

template <class A, class B>
inline Lambda< Oper< Divisao, A, B > > operator / ( const Lambda<A>& a, const Lambda<B>& b ) {
  return Oper< Divisao, A, B  >( a.f, b.f );
}

Com isso podemos já escrever uma expressão mais complexa, como por exemplo x*x + x.

Para um mínimo de funcionalidade, precisamos lidar com constantes nas expressões lambda. Para tanto precisamos de uma classe que guarde o valor de uma constante de qualquer tipo e funcione como um functor para um parâmetro, retornando o valor dessa constante qualquer que seja o valor desse parâmetro. Além disso, precisamos de templates para os operadores lidarem com constantes e variáveis, para todos os operadores (abaixo fizemos apenas para “+”).

template <class C>
class Constante {
  private:
    const C n;

  public:
    Constante( const C& n ): n(n) {}

    template <class T>
    C operator()( const T& ) const { return n; }
};

template <class A, class B>
inline Lambda< Oper< Adicao, A, Constante<B> > > operator + ( const Lambda<A>& a, const B& b ) {
  return Oper< Adicao, A, Constante<B> >( a.f, b );
}

template <class A, class B>
inline Lambda< Oper< Adicao, Constante<B>, A > > operator + ( const A& a, const Lambda<B>& b ) {
  return Oper< Adicao, Constante<B>, A >( a, b.f );
}

Assim, já podemos escrever o seguinte código passando uma expressão lambda para uma função:

template <class Functor>
void imprime( int a, int b, Functor f ) {
  for( int i = a; i <= b; i++ )
    cout << f(i) << " ";

  cout << endl;
}

int main(int argc, char *argv[]) {
  imprime( 0, 10, x*x + x + 1 );

  return EXIT_SUCCESS;
}

Esse programa produz como saída o seguinte: 1 3 7 13 21 31 43 57 73 91 111

Na Parte 3 dessa série veremos como lidar com atribuições. Nesse link aqui você pode baixar o código completo desse post.

“I left alone my mind was blank/I needed time to think to get the memories from my mind”

Lambda Calculus – Parte 1

Criando funções on-the-fly

Expressões lambda e cálculo lambda são abstrações muito utilizadas em teoria da computação. E são uma ótima forma de se definir funções on-the-fly, ou seja, no meio do código. Aqui vou mostrar uma pequena introdução sobre como fazer isso em C++, e em outros posts irei aperfeiçoar a idéia. Na biblioteca Boost existe uma boa implementação de expressões lambda, mas ela é pouco didática para se estudar. Melhor a gente ter uma idéia de como implementar primeiro e depois usar a biblioteca.

Um exemplo de uma expressão lambda é dado a seguir:

λxy.x+y
(λxy.x+y)( 2, 4 )

Na linha 1 está uma expressão que define uma função (sem nome e sem tipos) que recebe dois parâmetros x e y e retorna a soma deles. A idéia central aqui é função sem nome e sem tipos. Na linha 2 essa expressão é avaliada para os argumentos 2 e 4, do tipo int, retornando 6.

A principal utilidade de expressões lambda é para substituir functores nas funções da STL, por exemplo for_each, permitindo que a função a ser passada como parâmetro seja definida on-the-fly. As vantagens desse tipo de definição são duas: a definição da função fica perto do seu uso, e desempenho, pois tudo é expandido inline.

Para se criar expressões lambda em C++ podemos fazer algumas simplificações. A primeira delas é abolir a letra λ e a segunda é fixar os nomes dos parâmetros. Nessa primeira parte do post, irei criar apenas expressões lambda de um parâmetro, que se chamará x. Além disso, as expressões lambda não terão tipo – o que nos indica que serão templates. Uma expressão lambda “x + 1″ deve retornar um functor que soma 1 ao argumento passado na chamada dele. Assim, (x + 1)( 10 ) deve retornar 11. O código a seguir ilustra onde queremos chegar.

#include <iostream>
#include <vector>
#include <lambda>

using namespace std;

struct Imprime {
  ostream& o;

  Imprime( ostream& o ): o(o) {}

  template <class T>
  void operator () ( T n ) {
    o << n << " ";
  }
};

int main(int argc, char *argv[]) {
  vector<int> a;

  a.push_back( 1 );
  a.push_back( 2 );
  a.push_back( 3 );
  a.push_back( 4 );
  a.push_back( 5 );

  for_each( a.begin(), a.end(), x = x*x+1 );

  for_each( a.begin(), a.end(), Imprime( cout ) );
}

Saída:

2 5 10 17 26

A expressão “x = x*x+1” retornou um functor que calcula esse valor, e ele foi passado como parâmetro para a função for_each, produzindo o efeito desejado nos elementos do vetor. O functor Imprime na verdade não está ali por acaso: ele servirá de idéia base para a construção de expressões lambda. Seu operador de chamada de função é uma template, e pode ser utilizado para qualquer tipo que possa ser passado para um ostream através do operador “<<”. Ou seja, ele é um functor que encapsula a expressão cout << x << ” “.

Se fizermos um operador “<<” para ostream e um tipo novo para representar x (Var por exemplo) e se esse operador ao invés de imprimir retornar um objeto do tipo Imprime, poderemos criar uma expressão lambda.

Primeira Expressão Lambda:

#include <iostream>
#include <vector>
#include <lambda>

using namespace std;

struct Imprime {
  ostream& o;

  Imprime( ostream& o ): o(o) {}

  template <class T>
  void operator () ( T n ) {
    o << n << " ";
  }
};

struct Var {};
Var x;

Imprime operator << ( ostream& o, Var ) {
  return Imprime( o );
}

int main(int argc, char *argv[]) {
  vector<int> a;

  a.push_back( 1 );
  a.push_back( 2 );
  a.push_back( 3 );
  a.push_back( 4 );
  a.push_back( 5 );

  for_each( a.begin(), a.end(), x = x*x+1 );

  for_each( a.begin(), a.end(), cout << x );
}

É essa a idéia central: representar através de functores sem tipo as operações que compõem uma expressão lambda. A expressão é montada através de operadores
redefinidos, conforme podemos ver nas linhas 21 e 36.

No próximo post, mostro como encadear as expressões.

“Se a experiência funcionou na primeira tentativa, tem algo errado.”

Java mais rápido que C++?

Deixemos as “religiões” de lado…

C++ é mais rápido que Java? É uma briga de foice… quase uma briga de torcida organizada! O defensores de ambas as linguagens estão armados até os dentes com os mais variados argumentos. O assunto suscita discussões apaixonadas e intermináveis, com acusações de ambos os lados de imparcialidade nos testes. É possível encontrar diversos benchmarks que fazem a vantagem pender para uma ou outra linguagem. Nesse aqui, por exemplo, o vencedor é o Java. Nesse outro, usando os mesmos testes os resultados são francamente favoráveis ao C++.

O que quero passar aqui é que essa pergunta praticamente não procede. Já vai longe o tempo em que Java era somente interpretado. Hoje existe o JIT, o que torna Java uma linguagem compilada. E comparar duas linguagens compiláveis significa comparar compiladores, mais especificamente, otimizadores de código. São poucas as construções que podem interferir no desempenho e que não podem ser otimizadas. A verdadeira discussão é sobre qual é o melhor otimizador.

E aí as notícias não são nada boas para os defensores do desempenho superior do C++. SUN e IBM derramaram milhões de dólares em suas máquinas virtuais, cuja parte mais importante é justamente o otimizador. O resultado foi surpreendente. As últimas JVMs da IBM e da SUN conseguem executar algunas exemplos de programas Java com desempenho superior aos compiladores C++ disponíveis, em particular o tão popular e querido g++. Em algumas situações específicas conseguem até superar o compilador Intel, reconhecidamente um dos compiladores mais otimizados do mercado.

Mas se agora a discussão é somente sobre qual é o melhor otimizador, faz sentido falar em C++ mais rápido que Java? A verdade é que sim, faz sentido. Java possui algumas características que tornam determinadas construções mais lentas do que as equivalentes em C++. Por outro lado, C++ não possui nenhuma característica que o torne obrigatoriamente mais lento que nenhuma linguagem – pelo contrário, inlines, templates e functores acabam tornando o código mais fácil de ser otimizado. Por outro lado, o abuso de ponteiros e aliases torna o trabalho do otimizador bem mais difícil – problema que o Java não possui.

Mas que características são essas que tornam o Java mais lento? Não são muitas. Mas a verificação de limites de índices de array é uma que realmente faz diferença. O Java, por definição da linguagem, não deve permitir acesso fora dos limites de um array. C++, ao contrário, estabelece claramente que nenhuma verificação é feita neste caso, ficando a cargo do programador garantir que nenhum acesso ilegal será feito. Essa é uma situação onde não há nenhum otimizador que dê jeito, pois os testes para verificar se os índices estão dentro dos limites do array têm de ser feitos em tempo de execução e consomem ciclos de CPU. O Java somente pode dispensar os testes de limites de array se o compilador puder deduzir que não haverá acesso fora dos limites.

Antes de sair por aí gritando “urra!” e “ahá! eu não disse?”, vamos analisar alguns pontos. Alguém pode argumentar que esses testes são necessários. De fato, muitas vezes o são, e o C++ possui tipos abstratos de dados que substituem arrays e fazem esse tipo de verificação em tempo de execução. Mas a questão não é essa. É que em C++ é possível usar ou não esse tipo de verificação, ao passo que em Java não se tem essa opção. Em C++ o programador pode decidir se quer ou não realizar testes de limites de array.

Me lembro da época em que programava em Pascal, onde enquanto se estava depurando o programa compilávamos com a opção “range check error” ligada, mas quando os testes indicavam que o programa estava ok, nós a desligávamos. Nesse ponto o Java trata todo programa como se estivesse em fase de testes – ou todo programador como irresponsável. Diferentemente do C++ onde podemos adotar a mesma estratégia que adotávamos em Pascal.

O Java precisa desse teste por uma questão de princípios: um dos objetivos iniciais da linguagem era a criação de applets, pequenos trechos de código que executam no browser. É necessário limitar a possibilidade dos applets causarem danos, de modo que podemos dizer que a JVM não confia no código que lhe é passado.

O preço que se paga por essa decisão podemos avaliar no exemplo abaixo, onde é mostrada uma multiplicação de matrizes 1000×1000 em Java. O código foi feito da maneira mais direta possível, na verdade fora a parte da inicialização os comandos são os mesmos em Java e em C++, tirados dos posts anteriores sobre otimização. Acho que ninguém pode questionar esse código, dizendo que ele favorece uma ou outra linguagem.

Código original sem nenhuma otimização:

public class Teste {

  static final int N = 1000;

  public static void main(String[] args) {
    double a[][] = new double[N][N],
           b[][] = new double[N][N],
           c[][] = new double[N][N];

    java.util.Random rn = new java.util.Random();

    for( int i = 0; i < N; i++ )
      for( int j = 0; j < N; j++ ) {
        a[i][j] = rn.nextInt()/(rn.nextInt()+0.1);
        b[i][j] = rn.nextInt()/(rn.nextInt()+0.1);
        c[i][j] = 0;
      }  

    for( int i = 0; i < N; i++ )
      for( int j = 0; j < N; j++ )
        for( int k = 0; k < N; k++ )
          c[i][j] += a[i][k] * b[k][j];
  }
}

Detalhes:

Esse código foi executado com duas variações de linha de comando:

java Teste
java -server -Xbatch Teste

A primeira roda no modo client, que não realiza muitas das otimizações do JIT e por isso é um pouco mais lenta. Já a segunda usa o modo server, o que se traduz em um maior consumo de memória e de tempo para inicialização, porém executa mais rápido (algumas vezes bem mais rápido!). No nosso teste os resultados estão no gráfico a seguir, mostrando também os resultados obtidos para o C++ nos posts anteriores.

Multiplicação de Matrizes

Multiplicação de Matrizes

De cara vemos que o compilador Intel C++ é realmente insuperável… 44.48 x mais rápido que o código Java client. Mas mesmo comparando com o g++, vemos que o código C++ é 1.73x mais rápido que o Java client e 1,64x mais rápido que o Java server. Essa diferença pode ser creditada ao inúmeros testes de limites de array que o Java faz e que o C++ não: a cada acesso a um elemento de um array o Java irá verificar se o(s) índice(s) estão dentro dos limites. Embora que, analisando o programa fonte, ambas as linguagens fazem parte do teste quando comparam “i < N”, “j < N” e “k < N” em cada iteração dos comandos for, pois N é justamente o limite superior do array. Só o teste se i, j e k são maiores que zero (o limite inferior de cada array) é que o C++ não faz – mas esse teste é dispensável se a variável de índice for unsigned int. Ou seja, ainda há espaço para o otimizador do Java recuperar parte do tempo perdido.

Mas que tal se realizarmos as mesmas otimizações que fizemos nos posts anteriores? A primeira delas é inverter a ordem dos loops para tirar proveito da arquitetura do cache da CPU.

Código invertendo a ordem dos loops:

public class Teste {

  static final int N = 1000;

  public static void main(String[] args) {
    double a[][] = new double[N][N],
           b[][] = new double[N][N],
           c[][] = new double[N][N];

    java.util.Random rn = new java.util.Random();

    for( int i = 0; i < N; i++ )
      for( int j = 0; j < N; j++ ) {
        a[i][j] = rn.nextInt()/(rn.nextInt()+0.1);
        b[i][j] = rn.nextInt()/(rn.nextInt()+0.1);
        c[i][j] = 0;
      }  

    for( int i = 0; i < N; i++ )
      for( int k = 0; k < N; k++ )
        for( int j = 0; j < N; j++ )
          c[i][j] += a[i][k] * b[k][j];
  }
}

Ótimas notícias!!! Parece que agora o otimizador Java compreendeu que não precisa testar os limites do array. Usando o Java server o tempo praticamente se iguala ao g++: 2.77 segundos. Na verdade, 0.02 segundos mais rápido que o g++ com opção “-march=pentium4″. Quando compilamos com o g++ usando a opção “-march=k8″, o tempo do g++ cai para 2.30 segundos (o tempo para esta versão do programa com a opção “-march=k8″ não apareceu em nenhum post anterior). Por último, a opção Java client é desastrosa:  11.37 segundos…

Mas vamos terminar o serviço. Nossa última otimização do código C++ foi feita nesse post, e consiste em criar uma função para executar o loop mais interno. A vantagem dessa função é que ela fixa duas linhas  e um elemento das matrizes, facilitando o trabalho do otimizador.

Código com função auxiliar:

public class Teste {

  static final int N = 1000;

  static void multiplicaPorLinha( double[] ci,  double aik, double[] bk ) {
    for( int j = 0; j < N; j++ )
      ci[j] += aik * bk[j];  
  }    

  public static void main(String[] args) {
    double a[][] = new double[N][N],
           b[][] = new double[N][N],
           c[][] = new double[N][N];

    java.util.Random rn = new java.util.Random();

    for( int i = 0; i < N; i++ )
      for( int j = 0; j < N; j++ ) {
        a[i][j] = rn.nextInt()/(rn.nextInt()+0.1);
        b[i][j] = rn.nextInt()/(rn.nextInt()+0.1);
        c[i][j] = 0;
    }  

    for( int i = 0; i < N; i++ )
      for( int k = 0; k < N; k++ )  
        multiplicaPorLinha( c[i], a[i][k], b[k] );
  }
}

Voilà!! Agora o Java ficou realmente competitivo: 2.00 segundos. Essa versão compilada com o g++ apresenta tempos de 1.80 e 1.62 segundos para as opções “-march=k8″ e “-march=core2″, respectivamente (essa última disponível somente a partir de versões mais recentes do g++). Cabe ainda dizer que, se incluirmos manualmente no código do C++ o teste de limite de array o tempo não se altera! O otimizador do g++ saca que o teste é desnecessário…

Nesse quesito específico o otimizador do C++ consegue superar bastante o otimizador do Java, mesmo que seja incluído manualmente o teste de limites de array no código. Não tem muito jeito, é uma restrição da linguagem… embora mesmo assim o impacto tenha sido pequeno após as otimizações (desnecessárias no caso do compilador Intel…). A propósito, essa última otimização surpreendentemente torna o código gerado pelo compilador Intel mais lento.

Conclusão:

Embora o Java tenha algumas restrições na especificação da linguagem que podem prejudicar o desempenho, esse ponto não é tão fraco assim – nada que um pouco de ajuda ao compilador não minimize. No último teste, apenas 14% mais lento que o g++, e no segundo teste se iguala ao g++ opção “-march=pentium4″. Em termos de desempenho, a questão é mesmo uma briga de otimizadores. A linguagem em si não é mais lenta nem mais rápida, apenas o otimizador não está dando conta do recado (ainda). E use sempre que possível a opção “-server” no Java!

Segue um gráfico comparativo dos experimentos:

Multiplicação de Matrizes em C++ e Java - tempo em segundos

Multiplicação de Matrizes em C++ e Java - tempo em segundos

Em outro post irei mostrar uma situação onde o Java ganha (e bem!) do C++… pois é, já existe isso!!!

“Fé inabalável só o é a que pode encarar de frente a razão (…)”

Realmente otimizando C++

Otimização Final – g++

No post Quer velocidade no C++? Faça a coisa certa…, mostrei como podemos ajudar o otimizador do g++ sem escovar bits. Agora, mostro que tipo de escovação de bits é aceitável, e produz bons resultados. Note que ainda assim, o tempo final continua maior que o do compilador Intel.

Basicamente, podemos ajudar o compilador a identificar partes dos loops que contém dados invariantes. O novo programa, ao invés de ter os três loops aninhados, possui uma chamada a uma função para fazer a multiplicação por linha. No entanto, essa função é inline, de modo que é expandida no código. A princípio, isso não deveria melhorar o desempenho, porém o fato de passarmos parâmetros dá uma dica importante ao compilador: os parâmetros não mudam. Nem sempre o otimizador consegue descobrir isso.

#include <cstdlib>

const int N = 1000;
typedef double Linha[N];

inline void multiplicaPorLinha( Linha& ci, const double aik, const Linha& bk ) {
  for( int j = 0; j < N; j++ )
    ci[j] += aik * bk[j];
}

int main(int argc, char *argv[])
{
  Linha * const a = new Linha[N],
        * const b = new Linha[N],
        * const c = new Linha[N];

  srand(1);
  for( int i = 0; i < N; i++ )
    for( int j = 0; j < N; j++ ) {
      a[i][j] = rand()/(rand()+0.1);
      b[i][j] = rand()/(rand()+0.1);
      c[i][j] = 0;
    }

  for( int i = 0; i < N; i++ )
    for( int k = 0; k < N; k++ )
      multiplicaPorLinha( c[i], a[i][k], b[k] );

  delete [] a;
  delete [] b;
  delete [] c;
}

Essa otimização sozinha já reduz o tempo de 2.79 segundos para 2.22 segundos. Mas além disso, se estivermos amarrados ao Dev-Cpp, podemos trocar a linha de comando para otimizar para o K8 ao invés do Pentium 4 – o motivo é que quase todas as novas CPUs, mesmo as Intel, são compatíveis com o K8, e o código que o g++ gera para o K8 é melhor que o gerado para o Pentium 4, especialmente por causa do conjunto de instruções sse3. Linha de comando:

-O3 -funroll-loops -fprefetch-loop-arrays -march=k8 -msse3

Resultado: 1.80 segundos. Por último, se você instalar o g++ 4.40 (para Windows, MingW), pode usar a seguinte linha de argumentos:

-O3 -funroll-loops -fprefetch-loop-arrays -march=core2 -msse4

Agora sim para Intel Core 2. Resultado: 1.62 segundos… 10x mais rápido que o programa com opção -O3, 1.62 x mais rápido que o post anterior, e 11% mais rápido que para o K8.

Infelizmente essa otimização faz com que o compilador Intel perca umpouco de desempenho: agora fica com 1.2 segundos, o dobro do tempo anterior, embora ainda mais rápido que o g++. Não consegui descobrir porque isso ocorreu… :(

Rio 2016!!!!