Por que se (variável1% variável2 == 0) é ineficiente?

179

Eu sou novo em java, e estava executando algum código ontem à noite, e isso realmente me incomodou. Eu estava construindo um programa simples para exibir todas as saídas X em um loop for, e notei uma enorme queda no desempenho quando usei o módulo como variable % variablevs variable % 5000ou outros enfeites. Alguém pode me explicar por que isso é e o que está causando isso? Para que eu possa ser melhor ...

Aqui está o código "eficiente" (desculpe se entendi um pouco de sintaxe, não estou no computador com o código no momento)

long startNum = 0;
long stopNum = 1000000000L;

for (long i = startNum; i <= stopNum; i++){
    if (i % 50000 == 0) {
        System.out.println(i);
    }
}

Aqui está o "código ineficiente"

long startNum = 0;
long stopNum = 1000000000L;
long progressCheck = 50000;

for (long i = startNum; i <= stopNum; i++){
    if (i % progressCheck == 0) {
        System.out.println(i);
    }
}

Lembre-se de que eu tinha uma variável de data para medir as diferenças e, uma vez que ficou longa o suficiente, a primeira levou 50ms enquanto a outra levou 12 segundos ou algo assim. Você pode ter que aumentar stopNumou diminuir progressCheckse o seu PC for mais eficiente que o meu ou não.

Procurei essa pergunta na Web, mas não consigo encontrar uma resposta, talvez não esteja fazendo a pergunta certa.

EDIT: Eu não esperava que minha pergunta fosse tão popular, agradeço todas as respostas. Realizei uma referência em cada metade do tempo gasto, e o código ineficiente demorou consideravelmente mais, 1/4 de segundo versus 10 segundos mais ou menos. É verdade que eles estão usando println, mas ambos estão fazendo a mesma quantidade, então eu não imaginaria que isso distorceria muito, especialmente porque a discrepância é repetível. Quanto às respostas, como sou novo em Java, deixarei os votos decidirem por enquanto qual é a melhor. Vou tentar escolher um até quarta-feira.

EDIT2: Vou fazer outro teste hoje à noite, onde, em vez de módulo, ele apenas incrementa uma variável e, quando atinge progressCheck, ele executa uma e, em seguida, redefine a variável para 0. para uma terceira opção.

EDIT3.5:

Eu usei esse código e abaixo mostrarei meus resultados .. Obrigado a todos pela maravilhosa ajuda! Eu também tentei comparar o valor curto do longo com 0, então todas as minhas novas verificações acontecem sempre "65536" vezes, tornando-o igual em repetições.

public class Main {


    public static void main(String[] args) {

        long startNum = 0;
        long stopNum = 1000000000L;
        long progressCheck = 65536;
        final long finalProgressCheck = 50000;
        long date;

        // using a fixed value
        date = System.currentTimeMillis();
        for (long i = startNum; i <= stopNum; i++) {
            if (i % 65536 == 0) {
                System.out.println(i);
            }
        }
        long final1 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        //using a variable
        for (long i = startNum; i <= stopNum; i++) {
            if (i % progressCheck == 0) {
                System.out.println(i);
            }
        }
        long final2 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();

        // using a final declared variable
        for (long i = startNum; i <= stopNum; i++) {
            if (i % finalProgressCheck == 0) {
                System.out.println(i);
            }
        }
        long final3 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        // using increments to determine progressCheck
        int increment = 0;
        for (long i = startNum; i <= stopNum; i++) {
            if (increment == 65536) {
                System.out.println(i);
                increment = 0;
            }
            increment++;

        }

        //using a short conversion
        long final4 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        for (long i = startNum; i <= stopNum; i++) {
            if ((short)i == 0) {
                System.out.println(i);
            }
        }
        long final5 = System.currentTimeMillis() - date;

                System.out.println(
                "\nfixed = " + final1 + " ms " + "\nvariable = " + final2 + " ms " + "\nfinal variable = " + final3 + " ms " + "\nincrement = " + final4 + " ms" + "\nShort Conversion = " + final5 + " ms");
    }
}

Resultados:

  • fixo = 874 ms (normalmente em torno de 1000ms, mas mais rápido devido a uma potência de 2)
  • variável = 8590 ms
  • variável final = 1944 ms (era ~ 1000ms ao usar 50000)
  • incremento = 1904 ms
  • Conversão curta = 679 ms

Não surpreende o suficiente, devido à falta de divisão, a conversão curta foi 23% mais rápida que a maneira "rápida". Isso é interessante notar. Se você precisar mostrar ou comparar algo a cada 256 vezes (ou por aí), faça isso e use

if ((byte)integer == 0) {'Perform progress check code here'}

UMA NOTA INTERESSANTE FINAL, o uso do módulo na "Variável declarada final" com 65536 (número não muito bonito) foi metade da velocidade (mais lenta) do que o valor fixo. Onde antes estava comparando com a mesma velocidade.

Robert Cotterman
fonte
29
Eu tenho o mesmo resultado, na verdade. Na minha máquina, o primeiro loop é executado em cerca de 1,5 segundos e o segundo é executado em cerca de 9 segundos. Se eu adicionar finalna frente da progressCheckvariável, ambos correm na mesma velocidade novamente. Isso me leva a acreditar que o compilador ou o JIT consegue otimizar o loop quando sabe que progressChecké constante.
marstran
24
A divisão por uma constante pode ser facilmente convertida em multiplicação pelo inverso multiplicativo . A divisão por uma variável não pode. E uma divisão de 32 bits é mais rápido do que uma divisão de 64 bits em x86
phuclv
2
@phuclv notar divisão de 32 bits não é uma questão aqui, é uma operação restante de 64 bits em ambos os casos
user85421
4
@RobertCotterman se você declarar a variável como final, o compilador cria o mesmo bytecode como usar a constante (eclipse / Java 11) ((apesar de usar mais um slot de memória para a variável))
user85421

Respostas:

139

Você está medindo o stub OSR (substituição na pilha) .

O OSR stub é uma versão especial do método compilado, projetado especificamente para transferir a execução do modo interpretado para o código compilado enquanto o método está em execução.

Os stubs OSR não são tão otimizados quanto os métodos regulares, porque precisam de um layout de quadro compatível com o quadro interpretado. Eu já mostrei isso nas seguintes respostas: 1 , 2 , 3 .

Uma coisa semelhante acontece aqui também. Enquanto o "código ineficiente" está executando um loop longo, o método é compilado especialmente para a substituição na pilha dentro do loop. O estado é transferido do quadro interpretado para o método compilado por OSR e esse estado inclui progressChecka variável local. Nesse ponto, o JIT não pode substituir a variável pela constante e, portanto, não pode aplicar certas otimizações, como redução de força .

Em particular, isso significa que o JIT não substitui a divisão inteira pela multiplicação . (Veja Por que o uso GCC multiplicação por um número estranho na implementação divisão inteira? Para o truque asm de um compilador antes-do-tempo, quando o valor é uma constante de tempo de compilação após inlining / constant-propagação, se essas otimizações são habilitados Um literal inteiro inteiro na %expressão também é otimizado gcc -O0, semelhante a aqui onde é otimizado pelo JITer, mesmo em um stub OSR.)

No entanto, se você executar o mesmo método várias vezes, a segunda e a subsequente executarão o código regular (não OSR), que é totalmente otimizado. Aqui está uma referência para provar a teoria ( comparada usando JMH ):

@State(Scope.Benchmark)
public class Div {

    @Benchmark
    public void divConst(Blackhole blackhole) {
        long startNum = 0;
        long stopNum = 100000000L;

        for (long i = startNum; i <= stopNum; i++) {
            if (i % 50000 == 0) {
                blackhole.consume(i);
            }
        }
    }

    @Benchmark
    public void divVar(Blackhole blackhole) {
        long startNum = 0;
        long stopNum = 100000000L;
        long progressCheck = 50000;

        for (long i = startNum; i <= stopNum; i++) {
            if (i % progressCheck == 0) {
                blackhole.consume(i);
            }
        }
    }
}

E os resultados:

# Benchmark: bench.Div.divConst

# Run progress: 0,00% complete, ETA 00:00:16
# Fork: 1 of 1
# Warmup Iteration   1: 126,967 ms/op
# Warmup Iteration   2: 105,660 ms/op
# Warmup Iteration   3: 106,205 ms/op
Iteration   1: 105,620 ms/op
Iteration   2: 105,789 ms/op
Iteration   3: 105,915 ms/op
Iteration   4: 105,629 ms/op
Iteration   5: 105,632 ms/op


# Benchmark: bench.Div.divVar

# Run progress: 50,00% complete, ETA 00:00:09
# Fork: 1 of 1
# Warmup Iteration   1: 844,708 ms/op          <-- much slower!
# Warmup Iteration   2: 105,893 ms/op          <-- as fast as divConst
# Warmup Iteration   3: 105,601 ms/op
Iteration   1: 105,570 ms/op
Iteration   2: 105,475 ms/op
Iteration   3: 105,702 ms/op
Iteration   4: 105,535 ms/op
Iteration   5: 105,766 ms/op

A primeira iteração de divVaré realmente muito mais lenta, devido ao stub OSR ineficientemente compilado. Porém, assim que o método é executado novamente desde o início, a nova versão irrestrita é executada, o que aproveita todas as otimizações disponíveis do compilador.

apangin
fonte
5
Hesito em votar sobre isso. Por um lado, parece uma maneira elaborada de dizer "Você estragou o seu benchmark, leu algo sobre o JIT". Por outro lado, eu me pergunto por que você parece ter tanta certeza de que a OSR foi o principal ponto relevante aqui. Quero dizer, fazer um (micro) benchmark que envolve System.out.printlnquase necessariamente produzirá resultados de lixo, e o fato de que ambas as versões são igualmente rápidas não tem nada a ver com a OSR em particular , tanto quanto eu sei.
Marco13
2
(Estou curioso e gostaria de entender isso. Espero que os comentários não sejam perturbadores, possam excluí-los mais tarde, mas:) O link 1é um pouco dúbio - o loop vazio também pode ser completamente otimizado. O segundo é mais parecido com esse. Mas, novamente, não está claro por que você atribui a diferença ao OSR especificamente . Eu diria: em algum momento, o método é JIT e se torna mais rápido. No meu entender, o OSR apenas faz com que o uso do código final otimizado seja (aproximadamente) "adiado para a próxima passagem de otimização". (continuação ...)
Marco13 29/01/19
1
(continuação :) A menos que você esteja analisando especificamente os logs do ponto de acesso, não é possível dizer se a diferença é causada pela comparação do código JIT e do não-JIT, ou pela comparação do código JIT e OSR-stub. E você certamente não pode dizer isso com certeza quando a pergunta não contém o código real ou uma referência completa da JMH. Então, argumentar que a diferença é causada por OSR parece, para mim, inapropriadamente específico (e "injustificado") comparado a dizer que é causado pelo JIT em geral. (Sem ofensa - Estou pensando ...)
Marco13
4
@ Marco13 há uma heurística simples: sem a atividade do JIT, cada %operação teria o mesmo peso, pois uma execução otimizada só é possível, bem, se um otimizador fez um trabalho real. Portanto, o fato de uma variante de loop ser significativamente mais rápida que a outra prova a presença de um otimizador e prova ainda que ele não conseguiu otimizar um dos loops no mesmo grau que o outro (dentro do mesmo método!). Como essa resposta prova a capacidade de otimizar os dois loops no mesmo grau, deve haver algo que atrapalhou a otimização. E isso é OSR em 99,9% de todos os casos
Holger
4
@ Marco13 Esse foi um "palpite", baseado no conhecimento do HotSpot Runtime e na experiência de analisar problemas semelhantes antes. Um loop tão longo dificilmente poderia ser compilado de outra maneira que não a OSR, especialmente em uma simples referência feita à mão. Agora, quando o OP postou o código completo, só posso confirmar o raciocínio novamente executando o código com -XX:+PrintCompilation -XX:+TraceNMethodInstalls.
apangin
42

No seguimento do comentário @phuclv , verifiquei o código gerado pelo JIT 1 , os resultados são os seguintes:

para variable % 5000(divisão por constante):

mov     rax,29f16b11c6d1e109h
imul    rbx
mov     r10,rbx
sar     r10,3fh
sar     rdx,0dh
sub     rdx,r10
imul    r10,rdx,0c350h    ; <-- imul
mov     r11,rbx
sub     r11,r10
test    r11,r11
jne     1d707ad14a0h

para variable % variable:

mov     rax,r14
mov     rdx,8000000000000000h
cmp     rax,rdx
jne     22ccce218edh
xor     edx,edx
cmp     rbx,0ffffffffffffffffh
je      22ccce218f2h
cqo
idiv    rax,rbx           ; <-- idiv
test    rdx,rdx
jne     22ccce218c0h

Como a divisão sempre leva mais tempo que a multiplicação, o último trecho de código tem menos desempenho.

Versão Java:

java version "11" 2018-09-25
Java(TM) SE Runtime Environment 18.9 (build 11+28)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11+28, mixed mode)

1 - Opções de VM usadas: -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,src/java/Main.main

Oleksandr Pyrohov
fonte
14
Para dar uma ordem de magnitude em "mais lento", para x86_64: imulé 3 ciclos, idivestá entre 30 e 90 ciclos. Portanto, a divisão inteira é entre 10x e 30x mais lenta que a multiplicação inteira.
Matthieu M.
2
Você poderia explicar o que tudo isso significa para os leitores que estão interessados, mas não falam montador?
Nico Haase
7
@NicoHaase As duas linhas comentadas são as únicas importantes. Na primeira seção, o código está executando uma multiplicação de números inteiros, enquanto na segunda seção, o código está executando uma divisão de números inteiros. Se você pensa em multiplicar e dividir manualmente, quando se multiplica, geralmente faz várias multiplicações pequenas e depois um grande conjunto de adições, mas divisão é uma divisão pequena, uma multiplicação pequena, uma subtração e repetição. A divisão é lenta porque você está essencialmente fazendo um monte de multiplicações.
precisa saber é o seguinte
4
@MBraedley obrigado pela sua entrada, mas tal explicação deve ser adicionado à resposta em si e não ser escondido na seção de comentários
Nico Haase
6
@ MBraedley: Mais precisamente, a multiplicação em uma CPU moderna é rápida porque os produtos parciais são independentes e, portanto, podem ser computados separadamente, enquanto cada estágio de uma divisão depende dos estágios anteriores.
Supercat
26

Como outros observaram, a operação geral do módulo exige que uma divisão seja feita. Em alguns casos, a divisão pode ser substituída (pelo compilador) por uma multiplicação. Mas ambos podem ser lentos em comparação com adição / subtração. Portanto, o melhor desempenho pode ser esperado por algo nesse sentido:

long progressCheck = 50000;

long counter = progressCheck;

for (long i = startNum; i <= stopNum; i++){
    if (--counter == 0) {
        System.out.println(i);
        counter = progressCheck;
    }
}

(Como uma pequena tentativa de otimização, usamos um contador de pré-decréscimo aqui, porque em muitas arquiteturas, comparando 0imediatamente após uma operação aritmética, custa exatamente 0 instruções / ciclos de CPU, porque os sinalizadores da ALU já estão configurados adequadamente pela operação anterior. O compilador, no entanto, fará essa otimização automaticamente, mesmo se você escrever if (counter++ == 50000) { ... counter = 0; }.)

Observe que muitas vezes você realmente não quer / precisa de módulo, porque você sabe que seu contador de loop ( i) ou o que quer que seja apenas incrementado em 1, e você realmente não se importa com o restante do módulo, basta ver se o contador de incremento por um atingir algum valor.

Outro 'truque' é usar dois valores / limites de potência, por exemplo progressCheck = 1024;. O módulo de potência de dois pode ser calculado rapidamente via bit a bit and, ou seja if ( (i & (1024-1)) == 0 ) {...}. Isso também deve ser muito rápido e, em algumas arquiteturas, pode superar o explícito counteracima.

JimmyB
fonte
3
Um compilador inteligente inverteria os loops aqui. Ou você pode fazer isso na fonte. O if()corpo se torna um corpo de loop externo, e o material externo ao if()torna-se um corpo de loop interno que executa min(progressCheck, stopNum-i)iterações. Portanto, no início, e sempre que counterchegar a 0, você long next_stop = i + min(progressCheck, stopNum-i);deve configurar um for(; i< next_stop; i++) {}loop. Nesse caso, o loop interno está vazio e, com sorte, deve otimizar completamente, você pode fazer isso na fonte e facilitar o JITer, reduzindo o loop para i + = 50k.
Peter Cordes
2
Mas sim, em geral um balcão é uma técnica eficiente e eficiente para coisas do tipo fizzbuzz / progresscheck.
Peter Cordes
Eu adicionei à minha pergunta, e fez um incremento, a --counteré tão rápido quanto a minha versão de incremento, mas menos code.also era 1 inferior ao que deveria ser, eu sou curioso se ele deve ser counter--para obter o número exato que você quer , não que seja muita diferença
Robert Cotterman
@ PeterCordes Um compilador inteligente imprimiria apenas os números, sem loop. (Eu acho que alguns benchmarks apenas um pouco mais trivial começou a falhar dessa maneira talvez 10 anos atrás.)
Peter - Reintegrar Monica
2
@RobertCotterman Sim, --counterestá desativado por um. counter--fornecerá exatamente o progressChecknúmero de iterações (ou você pode definir, é progressCheck = 50001;claro).
JimmyB 29/01/19
4

Também estou surpreso ao ver o desempenho dos códigos acima. É tudo sobre o tempo gasto pelo compilador para executar o programa de acordo com a variável declarada. No segundo exemplo (ineficiente):

for (long i = startNum; i <= stopNum; i++) {
    if (i % progressCheck == 0) {
        System.out.println(i)
    }
}

Você está executando a operação do módulo entre duas variáveis. Aqui, o compilador deve verificar o valor stopNume progressCheckacessar o bloco de memória específico localizado para essas variáveis ​​todas as vezes após cada iteração, porque é uma variável e seu valor pode ser alterado.

É por isso que após cada compilador de iteração foi para o local da memória para verificar o valor mais recente das variáveis. Portanto, no momento da compilação, o compilador não conseguiu criar um código de bytes eficiente.

No primeiro exemplo de código, você está executando um operador de módulo entre uma variável e um valor numérico constante que não será alterado na execução e no compilador, não sendo necessário verificar o valor desse valor numérico a partir da localização da memória. É por isso que o compilador conseguiu criar código de bytes eficiente. Se você declarar progressCheckcomo uma variável finalou como final staticvariável, no momento do compilador em tempo de execução / tempo de compilação saiba que é uma variável final e seu valor não será alterado, substitua-o progressCheckpor 50000no código:

for (long i = startNum; i <= stopNum; i++) {
    if (i % 50000== 0) {
        System.out.println(i)
    }
}

Agora você pode ver que esse código também se parece com o primeiro exemplo de código (eficiente). O desempenho do primeiro código e, como mencionamos acima, ambos os códigos funcionarão eficientemente. Não haverá muita diferença no tempo de execução dos dois exemplos de código.

Bishal Dubey
fonte
1
Há uma enorme diferença, embora eu estivesse fazendo a operação um trilhão de vezes, portanto, mais de 1 trilhão de operações economizou 89% do tempo para executar o código "eficiente". lembre-se, se você está fazendo isso apenas alguns milhares de vezes, estava falando uma diferença tão pequena que provavelmente não é grande coisa. Quero dizer, mais de 1000 operações, você economizaria um milionésimo de 7 segundos.
Robert Cotterman
1
@Bishal Dubey "Não haverá muita diferença no tempo de execução dos dois códigos." Você leu a pergunta?
Grant Foster
"É por isso que após cada compilador de iteração foi ao local da memória para verificar o valor mais recente das variáveis" - A menos que a variável tenha sido declarada, volatileo 'compilador' não lerá seu valor da RAM repetidamente.
JimmyB