Declaração de vários arrays com 64 elementos 1000 vezes mais rápido do que declarar array de 65 elementos

91

Recentemente, percebi que declarar um array contendo 64 elementos é muito mais rápido (> 1000 vezes) do que declarar o mesmo tipo de array com 65 elementos.

Aqui está o código que usei para testar isso:

public class Tests{
    public static void main(String args[]){
        double start = System.nanoTime();
        int job = 100000000;//100 million
        for(int i = 0; i < job; i++){
            double[] test = new double[64];
        }
        double end = System.nanoTime();
        System.out.println("Total runtime = " + (end-start)/1000000 + " ms");
    }
}

Este é executado em cerca de 6 ms, se eu substituir new double[64]com new double[65]leva aproximadamente 7 segundos. Esse problema se torna exponencialmente mais grave se o trabalho for espalhado por mais e mais threads, que é de onde meu problema se origina.

Esse problema também ocorre com diferentes tipos de matrizes, como int[65]ou String[65]. Esse problema não ocorre com strings grandes:, String test = "many characters";mas começa a ocorrer quando é alterado paraString test = i + "";

Eu queria saber por que esse é o caso e se é possível contornar esse problema.

Sipko
fonte
3
Desatento: System.nanoTime()deve ser preferido System.currentTimeMillis()para benchmarking.
rocketboy de
4
Eu só estou curioso ? Você está no Linux? O comportamento muda com o sistema operacional?
bsd
9
Como diabos essa pergunta teve um downvote ??
Rohit Jain de
2
FWIW, vejo discrepâncias de desempenho semelhantes se eu executar este código com em bytevez de double.
Oliver Charlesworth de
3
@ThomasJungblut: Então, o que explica a discrepância no experimento do OP?
Oliver Charlesworth de

Respostas:

88

Você está observando um comportamento que é causado pelas otimizações feitas pelo compilador JIT de seu Java VM. Esse comportamento é reproduzível acionado com matrizes escalares de até 64 elementos e não é acionado com matrizes maiores que 64.

Antes de entrar em detalhes, vamos dar uma olhada mais de perto no corpo do loop:

double[] test = new double[64];

O corpo não tem efeito (comportamento observável) . Isso significa que não faz diferença fora da execução do programa se esta instrução é executada ou não. O mesmo é válido para todo o loop. Portanto, pode acontecer que o otimizador de código traduza o loop em algo (ou nada) com o mesmo comportamento funcional e de temporização diferente.

Para benchmarks, você deve pelo menos seguir as duas diretrizes a seguir. Se você tivesse feito isso, a diferença teria sido significativamente menor.

  • Aqueça o compilador JIT (e otimizador) executando o benchmark várias vezes.
  • Use o resultado de cada expressão e imprima-o no final do benchmark.

Agora vamos entrar em detalhes. Não é surpresa que haja uma otimização que é acionada para matrizes escalares não maiores que 64 elementos. A otimização faz parte da análise Escape . Ele coloca pequenos objetos e pequenos arrays na pilha em vez de alocá-los no heap - ou, melhor ainda, otimizá-los inteiramente. Você pode encontrar algumas informações sobre isso no seguinte artigo de Brian Goetz escrito em 2005:

A otimização pode ser desabilitada com a opção de linha de comando -XX:-DoEscapeAnalysis. O valor mágico 64 para matrizes escalares também pode ser alterado na linha de comando. Se você executar seu programa da seguinte maneira, não haverá diferença entre arrays com 64 e 65 elementos:

java -XX:EliminateAllocationArraySizeLimit=65 Tests

Dito isso, desencorajo fortemente o uso de tais opções de linha de comando. Duvido que faça uma grande diferença em uma aplicação realista. Eu só o usaria se estivesse absolutamente convencido da necessidade - e não com base nos resultados de alguns pseudo benchmarks.

nosid
fonte
9
Mas por que o otimizador está detectando que a matriz de tamanho 64 é removível, mas não 65
ug_
10
@nosid: Embora o código do OP possa não ser realista, ele está claramente desencadeando um comportamento interessante / inesperado na JVM, que pode ter implicações em outras situações. Acho que é válido perguntar por que isso está acontecendo.
Oliver Charlesworth
1
@ThomasJungblut Não acho que o loop seja removido. Você pode adicionar "int total" fora do loop e adicionar "total + = test [0];" ao exemplo acima. Em seguida, imprimindo o resultado, você verá que total = 100 milhões e ainda é executado em menos de um segundo.
Sipko de
1
A substituição na pilha trata da substituição do código interpretado pelo compilado em tempo real, em vez de substituir a alocação de heap pela alocação de pilha. EliminateAllocationArraySizeLimit é o tamanho limite dos arrays considerados escalares substituíveis na análise de escape. Portanto, o ponto principal de que o efeito é devido à otimização do compilador está correto, mas não é devido à alocação da pilha, mas devido à fase de análise de escape não perceber que a alocação não é necessária.
kiheru de
2
@Sipko: Você está escrevendo que o aplicativo não está escalando com o número de threads. Isso é uma indicação de que o problema não está relacionado às micro otimizações que você está perguntando. Eu recomendo olhar para o quadro geral em vez das partes pequenas.
nosid de
2

Existem várias maneiras pelas quais pode haver uma diferença, com base no tamanho de um objeto.

Como nosid afirmou, o JITC pode estar (provavelmente está) alocando pequenos objetos "locais" na pilha, e o corte de tamanho para "pequenos" arrays pode ser de 64 elementos.

Alocar na pilha é significativamente mais rápido do que alocar no heap e, mais precisamente, a pilha não precisa ser coletada como lixo, portanto, a sobrecarga de GC é bastante reduzida. (E para este caso de teste, a sobrecarga de GC é provavelmente de 80-90% do tempo total de execução.)

Além disso, uma vez que o valor é alocado na pilha, o JITC pode realizar a "eliminação do código morto", determinar que o resultado do newnunca seja usado em qualquer lugar e, depois de garantir que não haja efeitos colaterais que seriam perdidos, elimine a newoperação inteira , e então o próprio loop (agora vazio).

Mesmo que o JITC não faça alocação de pilha, é inteiramente possível que objetos menores que um certo tamanho sejam alocados em um heap de maneira diferente (por exemplo, de um "espaço" diferente) do que objetos maiores. (Normalmente, isso não produziria diferenças de tempo tão dramáticas, no entanto.)

Hot Licks
fonte
Atrasado para este tópico. Por que a alocação na pilha é mais rápida do que a alocação no heap? De acordo com alguns artigos, a alocação na pilha leva cerca de 12 instruções. Não há muito espaço para melhorias.
Vortex
@Vortex - A alocação para a pilha requer 1-2 instruções. Mas isso é para alocar um frame de pilha inteiro. O frame da pilha deve ser alocado de qualquer maneira para ter uma área de registro de salvamento para a rotina, de forma que quaisquer outras variáveis ​​alocadas ao mesmo tempo são "livres". E como eu disse, a pilha não requer GC. A sobrecarga de GC para um item de heap é muito maior do que o custo da operação de alocação de heap.
Hot Licks