A "mágica" da JVM atrapalha a influência que um programador exerce sobre as micro-otimizações em Java? Recentemente, li em C ++ algumas vezes que a ordem dos membros dos dados pode fornecer otimizações (concedidas, no ambiente de microssegundos) e presumi que as mãos de um programador estão atadas quando se trata de diminuir o desempenho do Java?
Aprecio que um algoritmo decente fornece maiores ganhos de velocidade, mas uma vez que você tenha o algoritmo correto, é mais difícil ajustar o Java devido ao controle da JVM?
Caso contrário, as pessoas poderiam dar exemplos de quais truques você pode usar em Java (além de simples sinalizadores de compilador).
java
c++
performance
latency
user997112
fonte
fonte
Respostas:
Certamente, no nível de micro otimização, a JVM fará algumas coisas sobre as quais você terá pouco controle, em comparação com C e C ++, especialmente.
Por outro lado, a variedade de comportamentos do compilador com C e C ++ terá um impacto negativo muito maior na sua capacidade de realizar micro otimizações de qualquer tipo de maneira vagamente portátil (mesmo nas revisões do compilador).
Depende de que tipo de projeto você está aprimorando, quais ambientes você está direcionando e assim por diante. E, no final, isso realmente não importa, já que você está obtendo resultados melhores em algumas ordens de magnitude com otimizações de algoritmos / estrutura de dados / design de programas.
fonte
As micro-otimizações quase nunca valem o tempo, e quase todas as fáceis são feitas automaticamente por compiladores e tempos de execução.
Há, no entanto, uma importante área de otimização em que C ++ e Java são fundamentalmente diferentes, e é o acesso à memória em massa. O C ++ possui gerenciamento manual de memória, o que significa que você pode otimizar o layout de dados e os padrões de acesso do aplicativo para fazer uso total de caches. Isso é bastante difícil, um pouco específico para o hardware em que você está executando (portanto, os ganhos de desempenho podem desaparecer em diferentes hardwares), mas, se bem feito, pode levar a um desempenho absolutamente impressionante. Claro que você paga por isso, com o potencial de todos os tipos de bugs horríveis.
Com uma linguagem de coleta de lixo como Java, esse tipo de otimização não pode ser feito no código. Alguns podem ser feitos pelo tempo de execução (automaticamente ou através da configuração, veja abaixo), e outros não são possíveis (o preço que você paga por estar protegido contra erros de gerenciamento de memória).
Os sinalizadores do compilador são irrelevantes em Java porque o compilador Java quase não faz otimização; o tempo de execução faz.
E, de fato, os tempos de execução do Java têm uma infinidade de parâmetros que podem ser ajustados, principalmente no que diz respeito ao coletor de lixo. Não há nada "simples" nessas opções - os padrões são bons para a maioria dos aplicativos e, para obter um melhor desempenho, você precisa entender exatamente o que as opções fazem e como o aplicativo se comporta.
fonte
Os microssegundos se acumulam se ultrapassarmos milhões a bilhões de coisas. Uma sessão vtune / micro-otimização pessoal do C ++ (sem melhorias algorítmicas):
Tudo além de "multithreading", "SIMD" (escrito à mão para vencer o compilador) e a otimização de patches de 4 valências eram otimizações de memória em nível micro. Além disso, o código original a partir dos tempos iniciais de 32 segundos já foi bastante otimizado (complexidade algorítmica teoricamente ideal) e esta é uma sessão recente. A versão original muito antes desta sessão recente levou mais de 5 minutos para ser processada.
A otimização da eficiência da memória pode ajudar muitas vezes de várias vezes a ordens de magnitudes em um contexto de thread único e mais em contextos multithread (os benefícios de um representante de memória eficiente geralmente se multiplicam com vários threads no mix).
Sobre a importância da micro-otimização
Fico um pouco agitado com essa ideia de que as micro-otimizações são uma perda de tempo. Concordo que é um bom conselho geral, mas nem todos o fazem incorretamente com base em palpites e superstições, e não em medições. Feito corretamente, não produz necessariamente um micro impacto. Se pegarmos o próprio Embree (núcleo de raytracing) da Intel e testarmos apenas o BVH escalar simples que eles escreveram (não o pacote de raios que é exponencialmente mais difícil de vencer) e tentarmos superar o desempenho dessa estrutura de dados, pode ser o mais experiência humilhante mesmo para um veterano acostumado a criar perfis e ajustar códigos por décadas. E é tudo por causa das micro otimizações aplicadas. A solução deles pode processar mais de cem milhões de raios por segundo quando vi profissionais industriais trabalhando no rastreamento de raios que podem '
Não há como adotar uma implementação direta de um BVH com apenas um foco algorítmico e obter mais de cem milhões de interseções de raios primários por segundo contra qualquer compilador otimizador (mesmo o próprio ICC da Intel). Um simples nem sempre recebe um milhão de raios por segundo. É preciso soluções de qualidade profissional para obter, com frequência, alguns milhões de raios por segundo. É preciso uma micro otimização no nível da Intel para obter mais de cem milhões de raios por segundo.
Algoritmos
Eu acho que a micro-otimização não é importante, desde que o desempenho não seja importante no nível de minutos a segundos, por exemplo, ou de horas a minutos. Se pegarmos um algoritmo horrível, como a classificação por bolhas, e usá-lo sobre uma entrada em massa como exemplo, e depois compará-lo com uma implementação básica da classificação por mesclagem, a primeira pode levar meses para ser processada, e a última, talvez 12 minutos, como resultado de complexidade quadrática versus linearitmica.
A diferença entre meses e minutos provavelmente fará com que a maioria das pessoas, mesmo aquelas que não trabalham em campos críticos de desempenho, considere o tempo de execução inaceitável se exigir que os usuários esperem meses para obter um resultado.
Enquanto isso, se compararmos a classificação de mesclagem direta não micro otimizada com a classificação rápida (que não é de todo o algoritmo superior à classificação por mesclagem e oferece apenas melhorias em nível micro para a localidade de referência), a classificação rápida micro otimizada pode terminar em 15 segundos em oposição a 12 minutos. Fazer com que os usuários esperem 12 minutos pode ser perfeitamente aceitável (horário da pausa para o café).
Eu acho que essa diferença é provavelmente insignificante para a maioria das pessoas entre, digamos, 12 minutos e 15 segundos, e é por isso que a micro-otimização é frequentemente considerada inútil, pois geralmente é apenas a diferença entre minutos e segundos, e não minutos e meses. A outra razão pela qual acho que é inútil é que muitas vezes é aplicada a áreas que não importam: alguma pequena área que nem é louca e crítica, que produz uma diferença questionável de 1% (que pode muito bem ser apenas ruído). Mas para as pessoas que se preocupam com esse tipo de diferença de tempo e estão dispostas a medir e fazer o que é certo, acho que vale a pena prestar atenção pelo menos aos conceitos básicos da hierarquia de memória (especificamente os níveis superiores relacionados a falhas de página e falhas de cache) .
Java deixa muito espaço para boas micro-otimizações
Ufa, desculpe - com esse tipo de discurso de lado:
Um pouco, mas não tanto quanto as pessoas possam pensar, se você fizer o que é certo. Por exemplo, se você estiver processando imagens, em código nativo com SIMD manuscrito, multithreading e otimizações de memória (padrões de acesso e possivelmente até representação dependendo do algoritmo de processamento de imagem), é fácil processar centenas de milhões de pixels por segundo por 32- pixels RGBA de bit (canais de cores de 8 bits) e às vezes até bilhões por segundo.
É impossível chegar perto em Java, se você disser, criou um
Pixel
objeto (isso por si só aumentaria o tamanho de um pixel de 4 bytes para 16 em 64 bits).Mas você poderá se aproximar muito mais se evitar o
Pixel
objeto, usar uma matriz de bytes e modelar umImage
objeto. O Java ainda é bastante competente lá, se você começar a usar matrizes de dados antigos simples. Eu tentei esse tipo de coisa antes em Java e fiquei bastante impressionado, desde que você não crie um monte de pequenos objetos pequenininhos em todos os lugares que sejam 4 vezes maiores que o normal (ex: use emint
vez deInteger
) e comece a modelar interfaces em massa como umaImage
interface, nãoPixel
interface. Atrevo-me a dizer que o Java pode rivalizar com o desempenho do C ++ se você estiver repetindo dados antigos simples e não objetos (grandes matrizesfloat
, por exemplo, nãoFloat
).Talvez ainda mais importante que o tamanho da memória seja que uma matriz
int
garanta uma representação contígua. Uma matriz deInteger
não. A contiguidade geralmente é essencial para a localidade de referência, pois significa que vários elementos (ex: 16ints
) podem caber em uma única linha de cache e potencialmente ser acessados juntos antes da remoção com padrões de acesso à memória eficientes. Enquanto isso, um únicoInteger
pode estar oculto em algum lugar da memória, sendo irrelevante a memória circundante, apenas para ter essa região de memória carregada em uma linha de cache apenas para usar um único número inteiro antes da remoção, em vez de 16 números inteiros. Mesmo se tivéssemos uma sorte maravilhosa e envolventeIntegers
estavam bem próximos um do outro na memória, só podemos encaixar 4 em uma linha de cache que pode ser acessada antes da remoção como resultado deInteger
ser quatro vezes maior, e esse é o melhor cenário.E há muitas micro-otimizações disponíveis desde que estamos unificados sob a mesma arquitetura / hierarquia de memória. Os padrões de acesso à memória não importam qual linguagem você usa, conceitos como ladrilhos / bloqueios de loop geralmente podem ser aplicados com muito mais frequência em C ou C ++, mas eles beneficiam o Java da mesma forma.
A ordem dos membros de dados geralmente não importa em Java, mas isso é principalmente uma coisa boa. Em C e C ++, preservar a ordem dos membros dos dados geralmente é importante por razões de ABI, para que os compiladores não mexam nisso. Os desenvolvedores humanos que trabalham lá precisam tomar cuidado para organizar coisas como os membros dos dados em ordem decrescente (maior para o menor) para evitar desperdiçar memória no preenchimento. Com o Java, aparentemente, o JIT pode reordenar os membros para você em tempo real para garantir o alinhamento adequado, minimizando o preenchimento, portanto, desde que seja o caso, ele automatiza algo que os programadores C e C ++ comuns podem fazer mal e acabam desperdiçando memória dessa maneira ( que não está apenas desperdiçando memória, mas muitas vezes desperdiçando velocidade, aumentando o passo entre as estruturas de AoS desnecessariamente e causando mais falhas de cache). Isto' É uma coisa muito robótica reorganizar os campos para minimizar o preenchimento; portanto, idealmente, os humanos não lidam com isso. O único momento em que o arranjo de campo pode ser importante para que um humano saiba o arranjo ideal é se o objeto for maior que 64 bytes e estivermos organizando campos com base no padrão de acesso (não no preenchimento ideal) - nesse caso pode ser um empreendimento mais humano (requer a compreensão de caminhos críticos, alguns dos quais são informações que um compilador não pode prever sem saber o que os usuários farão com o software).
A maior diferença para mim em termos de uma mentalidade otimizada entre Java e C ++ é que o C ++ pode permitir que você use objetos um pouco (pequenino) um pouco mais que o Java em um cenário crítico de desempenho. Por exemplo, o C ++ pode agrupar um número inteiro em uma classe sem sobrecarga (comparada em todo o lugar). O Java precisa ter essa sobrecarga de preenchimento de estilo de ponteiro de metadados + alinhamento por objeto, e é por isso que
Boolean
é maior queboolean
(mas, em troca, fornece benefícios uniformes de reflexão e a capacidade de substituir qualquer função não marcada comofinal
para cada UDT).É um pouco mais fácil em C ++ controlar a contiguidade dos layouts de memória em campos não homogêneos (ex: intercalar flutuações e ints em uma matriz por uma estrutura / classe), pois a localidade espacial geralmente é perdida (ou pelo menos o controle é perdido) em Java ao alocar objetos através do GC.
... mas geralmente as soluções de maior desempenho as dividem de qualquer maneira e usam um padrão de acesso SoA sobre matrizes contíguas de dados antigos simples. Portanto, para as áreas que precisam de desempenho máximo, as estratégias para otimizar o layout da memória entre Java e C ++ geralmente são as mesmas, e muitas vezes você precisa demolir essas pequenas interfaces orientadas a objetos em favor de interfaces no estilo de coleção que podem fazer coisas como hot / divisão de campo frio, representantes de SoA, etc. Representantes não homogêneos de AoSoA parecem meio impossíveis em Java (a menos que você tenha usado apenas uma matriz bruta de bytes ou algo parecido), mas esses são casos raros em que ambosos padrões de acesso seqüencial e aleatório precisam ser rápidos e, simultaneamente, ter uma mistura de tipos de campos para campos quentes. Para mim, a maior parte da diferença na estratégia de otimização (no tipo geral de nível) entre essas duas é discutível se você está buscando o desempenho máximo.
As diferenças variam um pouco mais se você simplesmente busca um desempenho "bom" - não é possível fazer o mesmo com objetos pequenos como
Integer
vs.int
pode ser um pouco mais uma PITA, especialmente com a maneira como interage com genéricos . É um pouco mais difícil de apenas construir uma estrutura de dados genérico como um alvo de otimização central em Java que funciona paraint
,float
, etc., evitando esses UDTs maiores e caros, mas muitas vezes as maioria das áreas de desempenho crítico vai exigir mão-rolando suas próprias estruturas de dados mesmo assim, é irritante para código que busca um bom desempenho, mas não um desempenho máximo.Sobrecarga de objeto
Observe que a sobrecarga do objeto Java (metadados e perda da localidade espacial e perda temporária da localidade temporal após um ciclo inicial da GC) geralmente é grande para coisas realmente pequenas (como
int
vs.Integer
) que estão sendo armazenadas aos milhões em alguma estrutura de dados que é amplamente contíguo e acessado em loops muito apertados. Parece haver muita sensibilidade sobre esse assunto, então devo esclarecer que você não quer se preocupar com sobrecarga de objetos para grandes objetos como imagens, apenas objetos minúsculos como um único pixel.Se alguém tiver dúvidas sobre essa parte, sugiro fazer uma referência entre somar um milhão aleatório
ints
versus um milhão aleatórioIntegers
e fazer isso repetidamente (oIntegers
reorganizará na memória após um ciclo inicial de GC).Ultimate Trick: Design de interface que deixa espaço para otimizar
Portanto, o truque final em Java, como eu vejo, se você estiver lidando com um local que lida com uma carga pesada sobre objetos pequenos (por exemplo: a
Pixel
, um vetor de 4, uma matriz 4x4, umParticle
, possivelmente até um,Account
se tiver apenas alguns objetos pequenos campos) é evitar o uso de objetos para essas pequenas coisas e usar matrizes (possivelmente encadeadas) de dados antigos simples. Os objectos em seguida tornar-se interfaces de recolha comoImage
,ParticleSystem
,Accounts
, um conjunto de matrizes ou vectores, etc. aqueles individuais podem ser acedidos pelo índice de, por exemplo, Este é também um dos truques de design finais em C e C ++, uma vez que mesmo sem que a sobrecarga objecto básico e memória desarticulada, modelar a interface no nível de uma única partícula impede as soluções mais eficientes.fonte
user204677
foi embora. Que ótima resposta.Há uma área intermediária entre a micro-otimização, por um lado, e uma boa escolha de algoritmo, por outro.
É a área de acelerações de fator constante e pode produzir ordens de magnitude.
O modo como faz isso é cortando frações inteiras do tempo de execução, como 30%, 20% do que resta, 50% disso e outras várias iterações, até que quase não resta nada.
Você não vê isso em pequenos programas de demonstração. Onde você vê isso, está em grandes programas sérios, com muitas estruturas de dados de classe, em que a pilha de chamadas costuma ter muitas camadas de profundidade. Uma boa maneira de encontrar as oportunidades de aceleração é examinando amostras em tempo aleatório do estado do programa.
Geralmente, as acelerações consistem em coisas como:
minimizar chamadas para
new
agrupar e reutilizar objetos antigos,reconhecer as coisas que estão sendo feitas que estão lá por uma questão de generalidade, em vez de serem realmente necessárias,
revisando a estrutura de dados usando diferentes classes de coleta que têm o mesmo comportamento big-O, mas aproveitam os padrões de acesso realmente usados,
salvar dados que foram adquiridos por chamadas de função em vez de chamar novamente a função (é uma tendência natural e divertida dos programadores assumir que funções com nomes mais curtos são executadas mais rapidamente).
tolerar uma certa inconsistência entre estruturas de dados redundantes, em vez de tentar mantê-las totalmente consistentes com eventos de notificação,
etc etc.
Mas é claro que nenhuma dessas coisas deve ser feita sem antes demonstrar problemas ao coletar amostras.
fonte
O Java (tanto quanto sei) não oferece controle sobre locais variáveis na memória; portanto, é mais difícil evitar coisas como compartilhamento falso e alinhamento de variáveis (você pode realizar uma aula com vários membros não utilizados). Outra coisa que eu acho que você não pode tirar proveito são instruções como
mmpause
, mas essas coisas são específicas da CPU e, portanto, se você precisar, o Java pode não ser a linguagem a ser usada.Existe a classe Unsafe que oferece flexibilidade de C / C ++, mas também com o perigo de C / C ++.
Pode ajudá-lo a olhar para o código de montagem que a JVM gera para seu código
Para ler sobre um aplicativo Java que analisa esse tipo de detalhe, consulte o código Disruptor lançado pela LMAX
fonte
É muito difícil responder a essa pergunta, porque depende da implementação da linguagem.
Em geral, há muito pouco espaço para essas "micro otimizações" atualmente. O principal motivo é que os compiladores aproveitam essas otimizações durante a compilação. Por exemplo, não há diferença de desempenho entre operadores de pré-incremento e pós-incremento em situações em que suas semânticas são idênticas. Outro exemplo seria, por exemplo, um loop como este, no
for(int i=0; i<vec.size(); i++)
qual se poderia argumentar que, em vez de chamar osize()
função membro durante cada iteração, seria melhor obter o tamanho do vetor antes do loop e então comparar com essa variável única e, assim, evitar a função de uma chamada por iteração. No entanto, há casos em que um compilador detectará esse caso bobo e armazenará em cache o resultado. No entanto, isso só é possível quando a função não tem efeitos colaterais e o compilador pode ter certeza de que o tamanho do vetor permanece constante durante o loop, por isso apenas se aplica a casos razoavelmente triviais.fonte
const
métodos nesse vetor, tenho certeza de que muitos compiladores de otimização descobrirão isso.Além das melhorias nos algoritmos, certifique-se de considerar a hierarquia de memória e como o processador a utiliza. Existem grandes benefícios na redução das latências de acesso à memória, depois de entender como o idioma em questão aloca memória para seus tipos e objetos de dados.
Exemplo de Java para acessar uma matriz de 1000 x 1000 ints
Considere o código de exemplo abaixo - ele acessa a mesma área de memória (uma matriz de 1000x1000 de entradas), mas em uma ordem diferente. No meu mac mini (Core i7, 2,7 GHz), a saída é a seguinte, mostrando que percorrer o array por linhas mais do que duplica o desempenho (média acima de 100 rodadas cada).
Isso ocorre porque a matriz é armazenada de modo que colunas consecutivas (ou seja, valores int) sejam colocadas adjacentes na memória, enquanto linhas consecutivas não. Para o processador realmente usar os dados, ele precisa ser transferido para seus caches. A transferência de memória é feita por um bloco de bytes, chamado de linha de cache - carregar uma linha de cache diretamente da memória introduz latências e, portanto, diminui o desempenho de um programa.
Para o Core i7 (ponte de areia), uma linha de cache contém 64 bytes, portanto, cada acesso à memória recupera 64 bytes. Como o primeiro teste acessa a memória em uma sequência previsível, o processador busca previamente os dados antes de serem realmente consumidos pelo programa. No geral, isso resulta em menos latência nos acessos à memória e, portanto, melhora o desempenho.
Código da amostra:
fonte
A JVM pode e muitas vezes interfere, e o compilador JIT pode mudar significativamente entre as versões. Algumas micro otimizações são impossíveis em Java devido a limitações de idioma, como ser amigável ao hyperthreading ou a coleção SIMD dos processadores Intel mais recentes.
Recomenda-se a leitura de um blog altamente informativo sobre o assunto de um dos autores do Disruptor :
Sempre se deve perguntar por que se preocupar em usar Java se você deseja micro-otimizações, existem muitos métodos alternativos para acelerar uma função, como usar JNA ou JNI para passar para uma biblioteca nativa.
fonte