Por que long é mais lento do que int em x64 Java?

90

Estou executando o Windows 8.1 x64 com Java 7 atualização 45 x64 (sem Java de 32 bits instalado) em um tablet Surface Pro 2.

O código a seguir leva 1688ms quando o tipo de i é longo e 109ms quando i é um int. Por que long (um tipo de 64 bits) é uma ordem de magnitude mais lento do que int em uma plataforma de 64 bits com uma JVM de 64 bits?

Minha única especulação é que a CPU demora mais para adicionar um inteiro de 64 bits do que um de 32 bits, mas isso parece improvável. Suspeito que Haswell não usa somadores de propagação de ondulação.

Estou executando isso no Eclipse Kepler SR1, btw.

public class Main {

    private static long i = Integer.MAX_VALUE;

    public static void main(String[] args) {    
        System.out.println("Starting the loop");
        long startTime = System.currentTimeMillis();
        while(!decrementAndCheck()){
        }
        long endTime = System.currentTimeMillis();
        System.out.println("Finished the loop in " + (endTime - startTime) + "ms");
    }

    private static boolean decrementAndCheck() {
        return --i < 0;
    }

}

Editar: Aqui estão os resultados do código C ++ equivalente compilado pelo VS 2013 (abaixo), mesmo sistema. long: 72265ms int: 74656ms Esses resultados estavam no modo de depuração de 32 bits.

No modo de liberação de 64 bits: long: 875ms long long: 906ms int: 1047ms

Isso sugere que o resultado que observei é a estranheza da otimização da JVM, e não as limitações da CPU.

#include "stdafx.h"
#include "iostream"
#include "windows.h"
#include "limits.h"

long long i = INT_MAX;

using namespace std;


boolean decrementAndCheck() {
return --i < 0;
}


int _tmain(int argc, _TCHAR* argv[])
{


cout << "Starting the loop" << endl;

unsigned long startTime = GetTickCount64();
while (!decrementAndCheck()){
}
unsigned long endTime = GetTickCount64();

cout << "Finished the loop in " << (endTime - startTime) << "ms" << endl;



}

Edit: Apenas tentei isso novamente em Java 8 RTM, nenhuma mudança significativa.

Techrocket9
fonte
8
O suspeito mais provável é a sua configuração, não a CPU ou as várias partes da JVM. Você pode reproduzir esta medição com segurança? Não repetir o loop, não aquecer o JIT, usar currentTimeMillis(), executar código que pode ser totalmente otimizado de maneira trivial, etc., cheira a resultados não confiáveis.
1
Eu estava fazendo um benchmarking há um tempo, tive que usar a longcomo contador de loop, porque o compilador JIT otimizou a saída do loop, quando usei um int. Seria necessário examinar a desmontagem do código de máquina gerado.
Sam
7
Este não é um microbenchmark correto, e eu não esperava que seus resultados refletissem a realidade de forma alguma.
Louis Wasserman
7
Todos os comentários repreendendo o OP por não escrever um microbenchmark Java adequado são indizivelmente preguiçosos. Esse é o tipo de coisa que é muito fácil de descobrir se você apenas olhar e ver o que a JVM faz com o código.
tmyklebu
2
@maaartinus: A prática aceita é uma prática aceita porque contorna uma lista de armadilhas conhecidas. No caso de Benchmarks Java adequados, você deseja ter certeza de que está medindo o código otimizado corretamente, não uma substituição na pilha, e deseja ter certeza de que suas medições estão limpas no final. OP encontrou um problema completamente diferente e o benchmark que ele forneceu o demonstrou adequadamente. E, conforme observado, transformar esse código em um benchmark Java adequado não faz com que a estranheza desapareça. E ler o código assembly não é difícil.
tmyklebu

Respostas:

80

Minha JVM faz uma coisa bem direta com o loop interno quando você usa longs:

0x00007fdd859dbb80: test   %eax,0x5f7847a(%rip)  /* fun JVM hack */
0x00007fdd859dbb86: dec    %r11                  /* i-- */
0x00007fdd859dbb89: mov    %r11,0x258(%r10)      /* store i to memory */
0x00007fdd859dbb90: test   %r11,%r11             /* unnecessary test */
0x00007fdd859dbb93: jge    0x00007fdd859dbb80    /* go back to the loop top */

É cheats, difícil, quando você usa ints; primeiro, há algumas coisas complicadas que eu não pretendo entender, mas parece uma configuração para um loop desenrolado:

0x00007f3dc290b5a1: mov    %r11d,%r9d
0x00007f3dc290b5a4: dec    %r9d
0x00007f3dc290b5a7: mov    %r9d,0x258(%r10)
0x00007f3dc290b5ae: test   %r9d,%r9d
0x00007f3dc290b5b1: jl     0x00007f3dc290b662
0x00007f3dc290b5b7: add    $0xfffffffffffffffe,%r11d
0x00007f3dc290b5bb: mov    %r9d,%ecx
0x00007f3dc290b5be: dec    %ecx              
0x00007f3dc290b5c0: mov    %ecx,0x258(%r10)   
0x00007f3dc290b5c7: cmp    %r11d,%ecx
0x00007f3dc290b5ca: jle    0x00007f3dc290b5d1
0x00007f3dc290b5cc: mov    %ecx,%r9d
0x00007f3dc290b5cf: jmp    0x00007f3dc290b5bb
0x00007f3dc290b5d1: and    $0xfffffffffffffffe,%r9d
0x00007f3dc290b5d5: mov    %r9d,%r8d
0x00007f3dc290b5d8: neg    %r8d
0x00007f3dc290b5db: sar    $0x1f,%r8d
0x00007f3dc290b5df: shr    $0x1f,%r8d
0x00007f3dc290b5e3: sub    %r9d,%r8d
0x00007f3dc290b5e6: sar    %r8d
0x00007f3dc290b5e9: neg    %r8d
0x00007f3dc290b5ec: and    $0xfffffffffffffffe,%r8d
0x00007f3dc290b5f0: shl    %r8d
0x00007f3dc290b5f3: mov    %r8d,%r11d
0x00007f3dc290b5f6: neg    %r11d
0x00007f3dc290b5f9: sar    $0x1f,%r11d
0x00007f3dc290b5fd: shr    $0x1e,%r11d
0x00007f3dc290b601: sub    %r8d,%r11d
0x00007f3dc290b604: sar    $0x2,%r11d
0x00007f3dc290b608: neg    %r11d
0x00007f3dc290b60b: and    $0xfffffffffffffffe,%r11d
0x00007f3dc290b60f: shl    $0x2,%r11d
0x00007f3dc290b613: mov    %r11d,%r9d
0x00007f3dc290b616: neg    %r9d
0x00007f3dc290b619: sar    $0x1f,%r9d
0x00007f3dc290b61d: shr    $0x1d,%r9d
0x00007f3dc290b621: sub    %r11d,%r9d
0x00007f3dc290b624: sar    $0x3,%r9d
0x00007f3dc290b628: neg    %r9d
0x00007f3dc290b62b: and    $0xfffffffffffffffe,%r9d
0x00007f3dc290b62f: shl    $0x3,%r9d
0x00007f3dc290b633: mov    %ecx,%r11d
0x00007f3dc290b636: sub    %r9d,%r11d
0x00007f3dc290b639: cmp    %r11d,%ecx
0x00007f3dc290b63c: jle    0x00007f3dc290b64f
0x00007f3dc290b63e: xchg   %ax,%ax /* OK, fine; I know what a nop looks like */

então o próprio loop desenrolado:

0x00007f3dc290b640: add    $0xfffffffffffffff0,%ecx
0x00007f3dc290b643: mov    %ecx,0x258(%r10)
0x00007f3dc290b64a: cmp    %r11d,%ecx
0x00007f3dc290b64d: jg     0x00007f3dc290b640

em seguida, o código de desmontagem para o loop desenrolado, ele próprio um teste e um loop direto:

0x00007f3dc290b64f: cmp    $0xffffffffffffffff,%ecx
0x00007f3dc290b652: jle    0x00007f3dc290b662
0x00007f3dc290b654: dec    %ecx
0x00007f3dc290b656: mov    %ecx,0x258(%r10)
0x00007f3dc290b65d: cmp    $0xffffffffffffffff,%ecx
0x00007f3dc290b660: jg     0x00007f3dc290b654

Portanto, ele é 16 vezes mais rápido para o ints porque o JIT desenrolou o intloop 16 vezes, mas não o desenrolou de forma longalguma.

Para completar, aqui está o código que realmente tentei:

public class foo136 {
  private static int i = Integer.MAX_VALUE;
  public static void main(String[] args) {
    System.out.println("Starting the loop");
    for (int foo = 0; foo < 100; foo++)
      doit();
  }

  static void doit() {
    i = Integer.MAX_VALUE;
    long startTime = System.currentTimeMillis();
    while(!decrementAndCheck()){
    }
    long endTime = System.currentTimeMillis();
    System.out.println("Finished the loop in " + (endTime - startTime) + "ms");
  }

  private static boolean decrementAndCheck() {
    return --i < 0;
  }
}

Os dumps de montagem foram gerados usando as opções -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly. Observe que você precisa mexer na instalação da JVM para que isso funcione para você também; você precisa colocar alguma biblioteca compartilhada aleatória exatamente no lugar certo ou ela irá falhar.

tmyklebu
fonte
8
OK, então o net-net não é que a longversão seja mais lenta, mas sim que a intversão seja mais rápida. Isso faz sentido. Provavelmente, não foi investido tanto esforço para fazer o JIT otimizar as longexpressões.
Hot Licks
1
... perdoe minha ignorância, mas o que é "funrolled"? Não consigo nem procurar o termo no Google corretamente, e isso faz com que seja a primeira vez que pergunto a alguém o que uma palavra significa na internet.
BrianH
1
@BrianDHall gccusa -fcomo opção de linha de comando para "flag", e a unroll-loopsotimização é ativada dizendo -funroll-loops. Eu apenas uso "unroll" para descrever a otimização.
chrylis -cautiouslyoptimistic-
4
@BRPocock: O compilador Java não pode, mas o JIT pode.
tmyklebu
1
Só para ficar claro, não funcionou. Ele o desenrolou E converteu o loop desenrolado para i-=16, que é 16x mais rápido.
Aleksandr Dubinsky
22

A pilha JVM é definida em termos de palavras , cujo tamanho é um detalhe de implementação, mas deve ter pelo menos 32 bits de largura. O implementador de JVM pode usar palavras de 64 bits, mas o bytecode não pode contar com isso e, portanto, as operações com valores longou doubledevem ser tratadas com cuidado extra. Em particular, as instruções de ramificação de inteiro da JVM são definidas exatamente no tipo int.

No caso do seu código, a desmontagem é instrutiva. Este é o bytecode para a intversão compilada pelo Oracle JDK 7:

private static boolean decrementAndCheck();
  Code:
     0: getstatic     #14  // Field i:I
     3: iconst_1      
     4: isub          
     5: dup           
     6: putstatic     #14  // Field i:I
     9: ifge          16
    12: iconst_1      
    13: goto          17
    16: iconst_0      
    17: ireturn       

Observe que a JVM carregará o valor do seu estático i(0), subtrairá um (3-4), duplicará o valor na pilha (5) e o empurrará de volta para a variável (6). Em seguida, ele faz uma comparação com zero e retorna.

A versão com longé um pouco mais complicada:

private static boolean decrementAndCheck();
  Code:
     0: getstatic     #14  // Field i:J
     3: lconst_1      
     4: lsub          
     5: dup2          
     6: putstatic     #14  // Field i:J
     9: lconst_0      
    10: lcmp          
    11: ifge          18
    14: iconst_1      
    15: goto          19
    18: iconst_0      
    19: ireturn       

Primeiro, quando a JVM duplica o novo valor na pilha (5), ela precisa duplicar duas palavras da pilha. No seu caso, é bem possível que isso não seja mais caro do que duplicar um, já que o JVM é livre para usar uma palavra de 64 bits se for conveniente. No entanto, você notará que a lógica do branch é mais longa aqui. A JVM não tem uma instrução para comparar a longcom zero, então ela precisa colocar uma constante 0Lna pilha (9), fazer uma longcomparação geral (10) e então desviar para o valor desse cálculo.

Aqui estão dois cenários plausíveis:

  • A JVM está seguindo exatamente o caminho do bytecode. Nesse caso, ele está fazendo mais trabalho na longversão, empurrando e removendo vários valores extras, e estes estão na pilha gerenciada virtual , não na pilha real de CPU assistida por hardware. Se for esse o caso, você ainda verá uma diferença significativa de desempenho após o aquecimento.
  • A JVM percebe que pode otimizar esse código. Nesse caso, está levando um tempo extra para otimizar algumas das lógicas push / compare praticamente desnecessárias. Se for esse o caso, você verá muito pouca diferença de desempenho após o aquecimento.

Eu recomendo que você escreva um microbenchmark correto para eliminar o efeito de ter o JIT ativado, e também tentar isso com uma condição final que não seja zero, para forçar o JVM a fazer a mesma comparação no intque faz com o long.

chrylis -cautiosamente otimista-
fonte
1
@Katona Não necessariamente. Muito especialmente, as JVMs de HotSpot de Cliente e Servidor são implementações completamente diferentes e Ilya não indicou a seleção de Servidor (Cliente geralmente é o padrão de 32 bits).
chrylis -cautiouslyoptimistic-
1
@tmyklebu O problema é que o benchmark mede várias coisas diferentes ao mesmo tempo. Usar uma condição de terminal diferente de zero reduz o número de variáveis.
chrylis -cautiouslyoptimistic-
1
@tmyklebu O ponto é que o OP tinha a intenção de comparar a velocidade de incrementos, decrementos e comparações em ints vs longs. Em vez disso (presumindo que essa resposta esteja correta), eles estavam medindo apenas comparações e apenas contra 0, que é um caso especial. No mínimo, torna o benchmark original enganoso - parece que mede três casos gerais, quando na verdade mede um caso específico.
yshavit
1
@tmyklebu Não me entenda mal, votei a favor na pergunta, esta resposta e sua resposta. Mas eu discordo da sua afirmação de que @chrylis está ajustando o benchmark para parar de medir a diferença que está tentando medir. O OP pode me corrigir se eu estiver errado, mas não parece que eles estão tentando apenas / principalmente medir == 0, o que parece ser uma parte desproporcionalmente grande dos resultados do benchmark. Parece-me mais provável que o OP esteja tentando medir uma gama mais geral de operações, e essa resposta aponta que o benchmark é altamente inclinado para apenas uma dessas operações.
yshavit
2
@tmyklebu Nem um pouco. Sou totalmente favorável a compreender as causas profundas. Mas, tendo identificado que uma das principais causas raiz é que o benchmark foi distorcido, não é inválido alterar o benchmark para remover a distorção, bem como cavar e entender mais sobre essa distorção (por exemplo, que pode permitir uma distorção mais eficiente bytecode, que pode facilitar o desenrolamento de loops, etc.). É por isso que votei positivamente nesta resposta (que identificou a distorção) e na sua (que analisa a distorção com mais detalhes).
yshavit
8

A unidade básica de dados em uma Java Virtual Machine é a palavra. A escolha do tamanho correto da palavra é deixada após a implementação da JVM. Uma implementação JVM deve escolher um tamanho mínimo de palavra de 32 bits. Ele pode escolher um tamanho de palavra maior para ganhar eficiência. Também não há nenhuma restrição de que uma JVM de 64 bits deve escolher apenas palavras de 64 bits.

A arquitetura subjacente não determina que o tamanho da palavra também seja o mesmo. JVM lê / grava dados palavra por palavra. Esta é a razão por que ele pode ser levando mais tempo para uma longa do que um int .

Aqui você pode encontrar mais informações sobre o mesmo assunto.

Vaibhav Raj
fonte
4

Acabei de escrever um benchmark usando compasso de calibre .

Os resultados são bastante consistentes com o código original: uma aceleração de ~ 12x para usar intover long. Certamente parece que o desenrolamento de loop relatado por tmyklebu ou algo muito semelhante está acontecendo.

timeIntDecrements         195,266,845.000
timeLongDecrements      2,321,447,978.000

Este é o meu código; observe que ele usa um instantâneo recém-criado de caliper, já que não consegui descobrir como codificar em relação à versão beta existente.

package test;

import com.google.caliper.Benchmark;
import com.google.caliper.Param;

public final class App {

    @Param({""+1}) int number;

    private static class IntTest {
        public static int v;
        public static void reset() {
            v = Integer.MAX_VALUE;
        }
        public static boolean decrementAndCheck() {
            return --v < 0;
        }
    }

    private static class LongTest {
        public static long v;
        public static void reset() {
            v = Integer.MAX_VALUE;
        }
        public static boolean decrementAndCheck() {
            return --v < 0;
        }
    }

    @Benchmark
    int timeLongDecrements(int reps) {
        int k=0;
        for (int i=0; i<reps; i++) {
            LongTest.reset();
            while (!LongTest.decrementAndCheck()) { k++; }
        }
        return (int)LongTest.v | k;
    }    

    @Benchmark
    int timeIntDecrements(int reps) {
        int k=0;
        for (int i=0; i<reps; i++) {
            IntTest.reset();
            while (!IntTest.decrementAndCheck()) { k++; }
        }
        return IntTest.v | k;
    }
}
tucuxi
fonte
1

Para que conste, esta versão faz um "aquecimento" bruto:

public class LongSpeed {

    private static long i = Integer.MAX_VALUE;
    private static int j = Integer.MAX_VALUE;

    public static void main(String[] args) {

        for (int x = 0; x < 10; x++) {
            runLong();
            runWord();
        }
    }

    private static void runLong() {
        System.out.println("Starting the long loop");
        i = Integer.MAX_VALUE;
        long startTime = System.currentTimeMillis();
        while(!decrementAndCheckI()){

        }
        long endTime = System.currentTimeMillis();

        System.out.println("Finished the long loop in " + (endTime - startTime) + "ms");
    }

    private static void runWord() {
        System.out.println("Starting the word loop");
        j = Integer.MAX_VALUE;
        long startTime = System.currentTimeMillis();
        while(!decrementAndCheckJ()){

        }
        long endTime = System.currentTimeMillis();

        System.out.println("Finished the word loop in " + (endTime - startTime) + "ms");
    }

    private static boolean decrementAndCheckI() {
        return --i < 0;
    }

    private static boolean decrementAndCheckJ() {
        return --j < 0;
    }

}

Os tempos gerais melhoram cerca de 30%, mas a proporção entre os dois permanece praticamente a mesma.

Hot Licks
fonte
@TedHopp - Eu tentei alterar os limites do loop no meu e ele permaneceu essencialmente inalterado.
Hot Licks de
@ Techrocket9: Eu obtenho números semelhantes ( inté 20 vezes mais rápido) com este código.
tmyklebu
1

Para os registros:

se eu usar

boolean decrementAndCheckLong() {
    lo = lo - 1l;
    return lo < -1l;
}

(alterado "l--" para "l = l - 1l") o desempenho longo melhora em ~ 50%

R.Moeller
fonte
0

Não tenho uma máquina de 64 bits para testar, mas a diferença bastante grande sugere que há mais do que o bytecode um pouco mais longo em ação.

Vejo tempos muito próximos para long / int (4400 vs 4800ms) no meu 1.7.0_45 de 32 bits.

Isso é apenas uma suposição , mas suspeito fortemente que seja o efeito de uma penalidade de desalinhamento de memória. Para confirmar / negar a suspeita, tente adicionar um public static int dummy = 0; antes da declaração de i. Isso empurra i para baixo em 4 bytes no layout de memória e pode torná-lo devidamente alinhado para melhor desempenho. Confirmado que não está causando o problema.

EDITAR: O raciocínio por trás disso é que o VM não pode reordenar os campos em seu lazer adicionando preenchimento para alinhamento ideal, uma vez que isso pode interferir com JNI (Não é o caso).

Durandal
fonte
A VM certamente tem permissão para reordenar os campos e adicionar preenchimento.
Hot Licks
O JNI precisa acessar os objetos por meio desses métodos de acesso lentos e irritantes que usam alguns identificadores opacos de qualquer maneira, pois o GC pode acontecer enquanto o código nativo está em execução. É bastante grátis reordenar campos e adicionar preenchimento.
tmyklebu