Comparação entre Java e C++ na Computação Numérica Claudio Schepke, Andrea Schwertner Charão Universidade Federal de Santa Maria Laboratório de Sistemas de Computação Informática/CT - UFSM Campus - 97105-900, Santa Maria, RS {schepke, andrea}@inf.ufsm.br Resumo A computação numérica é uma importante ferramenta para a solução de problemas nas áreas cientı́ficas e da engenharia. Muitas aplicações nestas áreas requerem a resolução de grandes sistemas lineares de forma rápida e eficiente, geralmente através de métodos numéricos iterativos. A programação orientada a objetos torna a implementação desses métodos mais simples, uma vez que permite codificar um algoritmo em um nı́vel de abstração bastante próximo à formulação matemática do método. O presente artigo apresenta uma comparação entre as linguagens Java e C++ para a resolução da equação de Laplace através do método iterativo do Gradiente Conjugado. Tal comparação contribui, principalmente, para ressaltar as vantagens e desvantagens destas linguagens no contexto da computação cientı́fica de alto desempenho. 1. Introdução A computação cientı́fica está geralmente associada à resolução de diversos problemas nas áreas das ciências naturais e exatas, bem como nas engenharias. Exemplos deste tipo de problemas incluem o cálculo do deslocamento estelar, a simulação de determinadas condições climáticas da terra, a descoberta de novos medicamentos, o teste da aerodinâmica de carros e aviões e a determinação de locais viáveis para uma possı́vel extração de petróleo [1]. Todas estas aplicações requerem processamento de alto desempenho, o que depende fortemente da combinação de hardware e software utilizados. A programação orientada a objetos é uma opção para o desenvolvimento de aplicações que resolvem problemas modelados matematicamente. Em particular, este paradigma favorece a construção de programas em um nı́vel de abstração próximo às formulações matemáticas. No entanto, as linguagens que seguem este paradigma possuem diferenças entre si, tanto em relação a seus recursos para o desenvolvimento de programas, como também ao desempenho na execução do código resultante. Este artigo tem por objetivo apresentar uma comparação entre as linguagens Java e C++ no contexto da computação cientı́fica de alto desempenho. Para isso, foi escolhido um problema recorrente em muitas aplicações cientı́ficas: a resolução da equação de Laplace através do método numérico do Gradiente Conjugado. As próximas seções tratam do uso da orientação a objetos aplicada a métodos numéricos, do uso de Java para o alto desempenho e, finalmente, apresentam a descrição da implementação e os resultados obtidos. 2. Uso da orientação a objetos para o cálculo numérico Uma vasta gama de fenômenos fı́sicos pode ser modelada através de equações diferenciais parciais, sendo resolvı́veis numericamente com o auxı́lio de técnicas de discretização. A utilização destas técnicas resulta geralmente em um sistema linear de equações algébricas, cuja solução pode ser determinada através de métodos diretos ou iterativos [7]. A programação eficiente destes métodos de resolução de sistemas não é uma tarefa trivial, mas atualmente pode-se contar com uma variedade de bibliotecas que facilitam sua utilização [12] [3]. No desenvolvimento destas bibliotecas, um dos paradigmas que se revelam adequados é a programação orientada a objetos [8]. A orientação a objetos traz consigo algumas caracterı́sticas interessantes para o desenvolvimento de aplicações de cálculo numérico. Uma das vantagens está no fato de que os problemas podem ser considerados objetos. Desta forma, os dados representam os atributos e os métodos numéricos aplicados representam as funcionalidades. Já o uso de classes facilita a organização do código-fonte. Assim os métodos podem compartilhar caracterı́sticas para a solução de um problema, sem a ne- cessidade de replicação de trechos de código. Também o encapsulamento ajuda na minimização da interdependência entre as partes do código, ocultando detalhes de implementação. 3. Java e Alto Desempenho O desempenho não foi originalmente considerado um fator relevante no desenvolvimento da linguagem Java. De fato, geralmente aponta-se como principais caracterı́sticas desta linguagem a orientação a objetos, o suporte à distribuição, a segurança e a portabilidade. Mesmo assim, mudanças vêm ocorrendo para poder-se usufruir dos pontos fortes da linguagem e, ao mesmo tempo, obter uma melhora da eficiência do código gerado [13]. Em geral, a opção pelo uso de Java ocorre principalmente para programas em rede e em especial à Web. O uso de Java no processamento cientı́fico possui alguns limitantes [5], sendo que um deles é a falta de arrays de várias dimensões com um formato regular. No caso da implementação de uma matriz numérica, é necessário utilizar uma estrutura de vetor de vetores. É importante notar que operações com matrizes de grande porte aplicadas à álgebra linear são algumas das operações mais comuns e com maior custo computacional em aplicações cientı́ficas. Outra caracterı́stica que tem impacto no desempenho de Java é a verificação de exceções, que garante robustez aos programas escritos nesta linguagem. Devido ao tempo destinado à realização dessa tarefa, geralmente ocorre uma perda de desempenho na execução geral de um programa. Java faz a verificação de ponteiros que estejam apontando para regiões de memória não alocadas ou ultrapassando os limites pré-definidos de um vetor. Também o coletor de lixo consome um tempo significativo do perı́odo de execução, mas oferece uma vantagem ao programador, pelo fato dele não precisar liberar explicitamente a memória. Uma terceira caracterı́stica a ser levada em conta no contexto da computação cientı́fica está na não existência de estruturas simples que possibilitam o trabalho com n úmeros complexos e operadores matemáticos especı́ficos. A solução adotada é a utilização de classes especiais para a representação de objetos cujos tipos não estejam definidos como padrão da linguagem e a implementação de métodos especı́ficos, como nas operações utilizadas sobre matrizes e vetores. Para atender as necessidades de determinados grupos de programadores, já foram criados pacotes especializados, como os que possibilitam o trabalho com álgebra linear e funções matemáticas com um alto nı́vel de abstração sobre os dados manipulados [4]. Recentemente, foram feitas algumas melhorias em Java para a geração de código eficiente. Inicialmente, os bytecodes gerados na compilação se tornaram mais especı́ficos, para, em seguida, poderem ser compilados para a arquite- tura nativa na primeira execução do código. Atualmente, os compiladores permitem gerar código de máquina, diretamente do código-fonte Java, para uma determinada arquitetura, sendo possı́vel encontrar vários compiladores para este fim. Uma solução alternativa está na possibilidade de serem feitas chamadas de métodos nativos, implementados em outra linguagem, dentro do código Java (JNI - Java Native Interface). Outra melhoria está na tentativa de diminuição do tempo de comunicação entre processos. Uma das possı́veis formas está na utilização de um código RMI (Remote Method Invocation) mais eficiente [8]. Soluç ões como o RMI assı́ncrono não bloqueiam os processos nas chamadas de funções remotas, algo que permite ”mascarar”o tempo de comunicação no caso de sistemas distribuı́dos. Existem também implementações para uma Máquina Virtual Distribuı́da, que oculta do usuário a existência de vários nós-processadores. Seu uso permite um grande paralelismo, já que os diversos fluxos de código podem ser executados sobre diversos processadores. Ainda na área de paralelismo, uma das idéias mais recentes é a computação em grids [9]. O objetivo, neste caso, é permitir o processamento remoto em máquinas ociosas disponı́veis de diversos lugares, enviando e recebendo tarefas e respostas por rede, para que grandes quantias de dados sejam processadas. O uso de Java na implementação de programas de gerenciamento de grids é um campo de pesquisa e desenvolvimento bastante ativo atualmente. 4. Comparação entre Java e C++ Esta seção visa apresentar uma comparação entre os tempos de execução de duas versões (Java e C++) de uma mesma aplicação. O objetivo desta comparação é apresentar uma diferença prática existente na simulação de um código e, em especial, do método do Gradiente Conjugado, não buscando fazer uma análise teórica do custo das operações, já que estes foram amplamente discutidos [2]. No passado foram feitas diversas comparações com base no tempo de execução de cada uma das operações [11]. É preciso, entretanto, contextualizar as mudanças feitas, tanto na implementação, com o uso de bibliotecas especı́ficas, como na geração de código por parte dos compiladores, analisando as suas principais partes. A comparação feita neste trabalho tem como base uma aplicação cuja finalidade é determinar a solução de um problema modelado pela Equação de Laplace, utilizando o método do Gradiente Conjugado (GC) [7]. Esse método explora as propriedades das matrizes simétricas, positivas, definidas e, em especial, as matrizes esparsas, sendo um dos métodos numéricos iterativos mais utilizados. Suas operações se concentram em torno de operações entre vetores, matrizes e vetores, e escalares com vetores. A cada em que há a aplicação do método do Gradiente Conjugado. Além destas três regiões foi medido o tempo total de execução. Todos esses resultados estão apresentados nas tabelas e no gráfico a seguir. Tabela 1 - Tempos de execução com C++ C++ Inicio Precond Método GC Total 2000 0.05012 0.00442 0.00521 0.06196 20000 0.63963 0.04990 0.07280 0.76465 200000 6.44687 0.49968 0.72616 8.14305 Tabela 2 - Tempos de execução com Java compilado Java Compilado Inicio Precond Método GC Total 2000 0.0105 0.0552 0.0243 0.0908 20000 0.1402 12.1857 0.2198 12.5467 200000 1.0903 1823.1154 1.9792 1826.1864 Tabela 3 - Tempos de execução com Java bytecode Java bytecode Inicio Precond Método GC Total 8 Tempo (s) iteração são feitas duas operações entre matrizes e vetores, três produtos internos e três atualizações de vetor. O método tem como caracterı́stica a rápida convergência. Dentre as bibliotecas existentes para operaç ões com álgebra linear para Java, foi escolhida a biblioteca JMP [10], versão 0.7.1, totalmente implementada nesta linguagem. As principais caracterı́sticas de JMP se referem ao seu uso de matrizes esparsas, o oferecimento de soluç ões paralelas para as operações com matrizes e vetores, especialmente o BLAS (Basic Linear Algebra Subprograms), a disponibilidade de diversos pré-condicionadores, a facilidade para leitura de vetores e matrizes, bem como a possibilidade de decomposição, além de outros recursos para a criação de programas. Já para a implementação em C++, foi escolhida a biblioteca SparseLib++ [6], versão 1.5. O foco principal de SparseLib++ são as estruturas de dados e o suporte computacional a métodos iterativos. A fim de propiciar uma execução eficiente destes métodos, esta biblioteca possui uma vasta gama de formatos de armazenamento, como estruturas para matrizes e vetores compactados, o que pode diminuir o n úmero de operações a serem aplicadas, bem como possibilitam ao programador optar por um formato que melhor se adapta ao problema. Também existem métodos de leitura para diversos formatos de compressão. Para fins de comparação, foram gerados três códigos executáveis: C++, Java bytecode e Java compilado para c ódigo nativo. O código escrito nas duas linguagens é bastante semelhante, mudando apenas a forma como as bibliotecas resolvem o sistema. Para o código C++ foi usado o compilador GNU G++/GCC, versão 3.3.2. Para o código em Java foi utilizado o compilador Javac, com o JDK 1.4.02, e o compilador GNU GCJ/GCC, versão 3.3.2, para a geração de código nativo. Os testes foram realizados em um computador com processador Intel Celeron de 1 GHz e memória de 128 MB. As matrizes utilizadas foram de 2.000 por 2.000, 20.000 por 20.000 e 200.000 por 200.000 elementos. Após as execuções, obteve-se as médias das 10 execuções realizadas para cada caso, sendo que o desvio padrão se apresentou baixo. Em termos de medição dos tempos de execução, o programa foi dividido em três partes. Na primeira parte, é medido o tempo de inicialização das variáveis e o preenchimento dos vetores e matrizes. Em seguida, é calculado o tempo para a fase do pré-condicionamento. O précondicionamento consiste em simplificar a matriz, para que as operações feitas pelos métodos numéricos possam convergir de modo mais rápido. Sem o pré-condicionamento, os métodos seriam mais custosos para a aquisição da solução. Para esta aplicação, o pré-condicionador escolhido foi a fatorização LU incompleta [7]. Por fim, o módulo em que se tem o maior interesse é a parte 2000 0.0953 0.2807 0.069 0.4898 20000 0.7638 16.0573 0.7331 17.6046 200000 7.1611 1893.0888 6.8919 1907.2011 C++ 7 JavaCompilado 6 JavaBytecode 5 4 3 2 1 0 2000 20000 200000 Dimensão da Matriz Figura 1. Comparação entre os tempos de execução do método GC 5. Discussão dos Resultados O desempenho apresentado na comparação entre o método do Gradiente Conjugado, nas três execuções, apresenta bons resultados no uso de Java compilado para código nativo. À medida que aumenta o número de elementos da matriz, aumenta também a relação de eficiência de Java compilado sobre Java bytecode e C++ para o método em questão, ainda que a execução de C++ seja a melhor. Já os tempos de inicialização são semelhantes para Java bytecode e C++, melhorando para o código Java compilado, o que mostra um maior esforço por parte da implementação C++, para a criação das estruturas com as informações. O pré-condicionamento, entretanto, é uma etapa que se mostra bastante penosa para a implementação em Java. O uso de pré-condicionadores é fundamental para uma rápida convergência dos métodos numéricos, já que os mesmos servem para se obter um bom valor inicial para a resposta. Bons pré-condicionadores conseguem diminuir bastante o tempo a ser gasto pelo método iterativo. Todavia, um grande número de operações precisa ocorrer para tanto. Devido às estruturas especiais de armazenamento disponı́veis na biblioteca SparseLib++, no que diz respeito a matrizes esparsas, como também o fator natural do desempenho de C++ ser melhor de modo geral, o tempo do pré-condicionamento apresentou-se bem menor para este caso. 6. Conclusão A utilização da orientação a objetos para operações relacionadas à computação numérica mostra-se como um recurso de programação viável através do uso de algumas bibliotecas. Atualmente existem muitas bibliotecas em C++ voltadas para o uso cientı́fico. Muitas delas são amplamente utilizadas e já foram alvo de otimizações. Este é o caso da SparseLib++, uma biblioteca que possui uma excelente implementação para as operações com matrizes esparsas. O uso de Java como uma linguagem de programação para aplicações cientı́ficas tem-se mostrado como uma opção, apresentando a portabilidade e a abstração orientada a objetos como ponto forte. Mesmo assim, as bibliotecas em Java podem ainda ser consideradas escassas e as existentes são geralmente implementadas para aplicações especı́ficas. No caso da biblioteca JMP, a mesma ainda está em desenvolvimento, sofrendo constantes revisões e incremento de funções. No entanto, seu desempenho ainda se apresenta inferior ao que é exigido pela computação cientı́fica. Cabe destacar que, no momento, existem muitas bibliotecas em desenvolvimento, tentando abranger mais funcionalidades [3]. A otimização de código na compilação, as melhorias na implementação nas funções de baixo nı́vel, bem como as caracterı́sticas a serem melhoradas nas linguagens, poderão apresentar resultados significativos para os pr óximos anos. Todavia, uma solução mais usual pode ser o processamento paralelo para a obtenção de resultados em um tempo computacional menor. Referências [1] G. R. Andrews. Foundations of Multithreaded, Parallel, and Distributed Programming. Addison-Wesley, US, 2001. [2] M. Bergman and P. Sloot. Basic Linear Algebra Subsystems. 1993. Dept. of Comp. Sys., Univ. of Amsterdam, number CAMAS-TR-2.1.2.1, Department of Computer Systems, University of Amsterdam, The Netherlands. [3] R. Boisvert and R. Pozo. JavaNumerics, 2004. http://math.nist.gov/javanumerics. [4] R. F. Boisvert, J. J. Dongarra, R. Pozo, K. A. Remington, and G. W. Stewart. Developing numerical libraries in Java. 1998. http://www.cs.ucsb.edu/conferences/java98/papers/jnt.pdf. [5] G. G. H. Cavalheiro. Princı́pios da Programação Concorrente. ANAIS, Segunda Escola Regional de Alto Desempenho - Sociedade Brasileira de Computação - Instituto de Informática da UFRGS / UNISINOS / ULBRA, 2002. [6] J. Dongarra, R. Pozo, K. Remington, X. Niu, and A. Lumsdaine. A sparse matrix library in C++ for high performance architectures. 1994. ftp://gams.nist.gov/pub/pozo/papers/sparse.ps.Z. [7] J. J. Dongarra, L. S. Duff, D. C. Sorensen, and H. A. Van der Vorst. Numerical Linear Algebra for High-Performance Computers. SIAM, 1998. [8] J. Farley. Java Distributed Computing. O’Reilly, 1998. [9] I. Foster and C. Kesselman. The Grid: Blueprints for a New Computing Infrastructure. Morgan Kaufmann, 1999. [10] B. O. Heimsund. JMP - A sparse matrix library for Java, 2004. http://www.math.uib.no/ bjornoh/jmp/index2.html. [11] A. G. Hoekstra, P. M. A. Sloot, M. J. De Haan, and L. Hertzberger. Time complexity analysis for distributed memory computers - Implementation of a parallel Conjugate Gradient method. 1991. In J. van. Leeuwen, editor, Computing Science in the Netherlands, pages 249-266. Elsevier Science. [12] T. Veldhuizen. The Object-Oriented Numerics Page, 2004. http://www.oonumerics.org/oon. [13] P. Wu, S. Midkiff, J. Moreira, and M. Gupta. Efficient Support for Complex Numbers in Java. 1999. http://www.cs.ucsb.edu/conferences/java99/papers/53wu.pdf.