Java: il ciclo srotolato manualmente è ancora più veloce del ciclo originale. Perché?


13

Considera i seguenti due frammenti di codice su un array di lunghezza 2:

boolean isOK(int i) {
    for (int j = 0; j < filters.length; ++j) {
        if (!filters[j].isOK(i)) {
            return false;
        }
    }
    return true;
}

e

boolean isOK(int i) {
     return filters[0].isOK(i) && filters[1].isOK(i);
}

Suppongo che le prestazioni di questi due pezzi dovrebbero essere simili dopo un riscaldamento sufficiente.
L'ho verificato utilizzando il framework di micro-benchmarking JMH come descritto ad esempio qui e qui e ho osservato che il secondo frammento è più veloce del 10%.

Domanda: perché Java non ha ottimizzato il mio primo frammento utilizzando la tecnica di srotolamento del ciclo di base?
In particolare, vorrei comprendere quanto segue:

  1. Posso facilmente produrre un codice che è ottimale per i casi di 2 filtri ed ancora può funzionare in caso di un altro numero di filtri (immaginate un semplice costruttore):
    return (filters.length) == 2 ? new FilterChain2(filters) : new FilterChain1(filters). JITC può fare lo stesso e, in caso contrario, perché?
  2. JITC è in grado di rilevare che " filters.length == 2 " è il caso più frequente e di produrre il codice ottimale per questo caso dopo un po 'di riscaldamento? Questo dovrebbe essere quasi ottimale come la versione srotolata manualmente.
  3. JITC è in grado di rilevare che una particolare istanza viene utilizzata molto frequentemente e quindi produrre un codice per questa specifica istanza (per la quale sa che il numero di filtri è sempre 2)?
    Aggiornamento: ho ottenuto la risposta che JITC funziona solo a livello di classe. Ok capito.

Idealmente, vorrei ricevere una risposta da qualcuno con una profonda comprensione di come funziona JITC.

Dettagli della corsa di riferimento:

  • Provato con le ultime versioni di Java 8 OpenJDK e Oracle HotSpot, i risultati sono simili
  • Flag Java usati: -Xmx4g -Xms4g -server -Xbatch -XX: CICompilerCount = 2 (ottenuto risultati simili anche senza i flag di fantasia)
  • A proposito, ottengo un rapporto di runtime simile se lo eseguo semplicemente diversi miliardi di volte in un ciclo (non tramite JMH), ovvero il secondo frammento è sempre chiaramente più veloce

Uscita di riferimento tipica:

Benchmark (filterIndex) Modalità Cnt Punteggio Errore Unità
LoopUnrollingBenchmark.runBenchmark 0 avgt 400 44,202 ± 0,224 ns / op
LoopUnrollingBenchmark.runBenchmark 1 avgt 400 38,347 ± 0,063 ns / op

(La prima riga corrisponde al primo frammento, la seconda riga - alla seconda.

Codice di riferimento completo:

public class LoopUnrollingBenchmark {

    @State(Scope.Benchmark)
    public static class BenchmarkData {
        public Filter[] filters;
        @Param({"0", "1"})
        public int filterIndex;
        public int num;

        @Setup(Level.Invocation) //similar ratio with Level.TRIAL
        public void setUp() {
            filters = new Filter[]{new FilterChain1(), new FilterChain2()};
            num = new Random().nextInt();
        }
    }

    @Benchmark
    @Fork(warmups = 5, value = 20)
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    public int runBenchmark(BenchmarkData data) {
        Filter filter = data.filters[data.filterIndex];
        int sum = 0;
        int num = data.num;
        if (filter.isOK(num)) {
            ++sum;
        }
        if (filter.isOK(num + 1)) {
            ++sum;
        }
        if (filter.isOK(num - 1)) {
            ++sum;
        }
        if (filter.isOK(num * 2)) {
            ++sum;
        }
        if (filter.isOK(num * 3)) {
            ++sum;
        }
        if (filter.isOK(num * 5)) {
            ++sum;
        }
        return sum;
    }


    interface Filter {
        boolean isOK(int i);
    }

    static class Filter1 implements Filter {
        @Override
        public boolean isOK(int i) {
            return i % 3 == 1;
        }
    }

    static class Filter2 implements Filter {
        @Override
        public boolean isOK(int i) {
            return i % 7 == 3;
        }
    }

    static class FilterChain1 implements Filter {
        final Filter[] filters = createLeafFilters();

        @Override
        public boolean isOK(int i) {
            for (int j = 0; j < filters.length; ++j) {
                if (!filters[j].isOK(i)) {
                    return false;
                }
            }
            return true;
        }
    }

    static class FilterChain2 implements Filter {
        final Filter[] filters = createLeafFilters();

        @Override
        public boolean isOK(int i) {
            return filters[0].isOK(i) && filters[1].isOK(i);
        }
    }

    private static Filter[] createLeafFilters() {
        Filter[] filters = new Filter[2];
        filters[0] = new Filter1();
        filters[1] = new Filter2();
        return filters;
    }

    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(args);
    }
}

1
Il compilatore non può garantire che la lunghezza dell'array sia 2. Non sono sicuro che lo srotolerebbe anche se potesse.
Marstran,

1
@Setup(Level.Invocation): non sono sicuro che aiuti (vedi il javadoc).
GPI,

3
Poiché non esiste alcuna garanzia che l'array sia sempre di lunghezza 2, i due metodi non stanno facendo la stessa cosa. Come potrebbe quindi JIT permettersi di cambiare il primo nel secondo?
Andreas,

@Andreas ti suggerisco di rispondere alla domanda, ma elabora il motivo per cui JIT non può srotolarsi in questo caso confrontandolo con un altro caso simile in cui può farlo
Alexander,

1
@Alexander JIT può vedere che la lunghezza dell'array non può cambiare dopo la creazione, perché il campo è final, ma JIT non vede che tutte le istanze della classe otterranno un array di lunghezza 2. Per vederlo, dovrebbe immergersi nel createLeafFilters()metodo e analizzare il codice abbastanza in profondità per apprendere che l'array sarà sempre lungo 2. Perché ritieni che l'ottimizzatore JIT si immergerebbe così profondamente nel tuo codice?
Andreas,

Risposte:


10

TL; DR Il motivo principale della differenza di prestazioni qui non è legato allo srotolamento del loop. È piuttosto la speculazione del tipo e le cache in linea .

Strategie di svolgimento

In effetti, nella terminologia di HotSpot, tali loop vengono trattati come contati e in alcuni casi JVM può srotolarli. Non nel tuo caso però.

HotSpot ha due strategie di srotolamento del loop: 1) srotolare al massimo, ovvero rimuovere del tutto il loop; oppure 2) incollare insieme più iterazioni consecutive.

Lo srotolamento massimo può essere eseguito solo se si conosce il numero esatto di iterazioni .

  if (!cl->has_exact_trip_count()) {
    // Trip count is not exact.
    return false;
  }

Nel tuo caso, tuttavia, la funzione potrebbe tornare presto dopo la prima iterazione.

Lo srotolamento parziale potrebbe essere probabilmente applicato, ma la seguente condizione interrompe lo srotolamento:

  // Don't unroll if the next round of unrolling would push us
  // over the expected trip count of the loop.  One is subtracted
  // from the expected trip count because the pre-loop normally
  // executes 1 iteration.
  if (UnrollLimitForProfileCheck > 0 &&
      cl->profile_trip_cnt() != COUNT_UNKNOWN &&
      future_unroll_ct        > UnrollLimitForProfileCheck &&
      (float)future_unroll_ct > cl->profile_trip_cnt() - 1.0) {
    return false;
  }

Poiché nel tuo caso il conteggio previsto del viaggio è inferiore a 2, HotSpot presume che non sia degno srotolare anche due iterazioni. Si noti che la prima iterazione viene comunque estratta in pre-loop ( ottimizzazione peeling loop ), quindi lo srotolamento non è davvero molto vantaggioso qui.

Speculazione del tipo

Nella versione non srotolata, ci sono due diversi invokeinterfacebytecode. Questi siti hanno due profili di tipo distinti. Il primo ricevitore è sempre Filter1e il secondo ricevitore è sempre Filter2. Quindi, fondamentalmente hai due siti di chiamata monomorfi e HotSpot può perfettamente incorporare entrambe le chiamate - il cosiddetto "cache inline" che in questo caso ha un hit ratio del 100%.

Con il loop, esiste un solo invokeinterfacebytecode e viene raccolto un solo profilo di tipo. HotSpot JVM vede che filters[j].isOK()viene chiamato 86% volte con il Filter1ricevitore e 14% volte conFilter2 ricevitore. Questa sarà una chiamata bimorfa. Fortunatamente, HotSpot è anche in grado di incorporare in modo speculativo le chiamate bimorfe. Inline entrambi gli obiettivi con un ramo condizionale. Tuttavia, in questo caso il tasso di hit sarà al massimo dell'86% e le prestazioni risentiranno dei corrispondenti rami non previsti a livello di architettura.

Le cose andranno anche peggio, se hai 3 o più filtri diversi. In questo caso isOK()sarà una chiamata megamorfica che HotSpot non può in linea affatto. Pertanto, il codice compilato conterrà una vera chiamata di interfaccia che ha un impatto maggiore sulle prestazioni.

Maggiori informazioni sull'allineamento speculativo nell'articolo The Black Magic of (Java) Method Dispatch .

Conclusione

Al fine di incorporare le chiamate virtuali / di interfaccia, HotSpot JVM raccoglie i profili di tipo per il bytecode invoke. Se c'è una chiamata virtuale in un ciclo, ci sarà solo un profilo di tipo per la chiamata, indipendentemente dal fatto che il ciclo sia srotolato o meno.

Per ottenere il meglio dalle ottimizzazioni delle chiamate virtuali, è necessario dividere manualmente il ciclo, principalmente allo scopo di dividere i profili dei tipi. HotSpot non può farlo automaticamente fino ad ora.


Grazie per la magnifica risposta. Solo per completezza: sei a conoscenza di eventuali tecniche JITC che potrebbero produrre codice per un'istanza specifica?
Alexander

@Alexander HotSpot non ottimizza il codice per un'istanza specifica. Utilizza statistiche di runtime che includono contatori per bytecode, profilo del tipo, probabilità di destinazione delle filiali ecc. Se si desidera ottimizzare il codice per un caso specifico, creare una classe separata per esso, manualmente o con generazione dinamica di bytecode.
apangin

13

Il loop presentato probabilmente rientra nella categoria di loop "non conteggiati", che sono loop per i quali il conteggio delle iterazioni non può essere determinato né in fase di compilazione né in fase di esecuzione. Non solo a causa dell'argomento @Andreas sulla dimensione dell'array ma anche a causa del condizionale casuale break(che era nel tuo benchmark quando ho scritto questo post).

I compilatori all'avanguardia non li ottimizzano in modo aggressivo, poiché lo srotolamento di loop non conteggiati comporta spesso la duplicazione anche di una condizione di uscita del ciclo, che migliora quindi le prestazioni di runtime solo se le successive ottimizzazioni del compilatore possono ottimizzare il codice non srotolato. Vedi questo documento del 2017 per i dettagli in cui fanno proposte su come srotolare anche queste cose.

Da ciò segue che la tua supposizione non sostiene che tu abbia fatto una sorta di "srotolamento manuale" del loop. Lo stai considerando una tecnica di srotolamento del ciclo di base per trasformare un'iterazione su un array con interruzione condizionale in &&un'espressione booleana concatenata. Considererei questo un caso piuttosto speciale e sarei sorpreso di trovare un ottimizzatore hot-spot che esegua un refactoring complesso al volo. Qui stanno discutendo cosa potrebbe effettivamente fare, forse questo riferimento è interessante.

Ciò rifletterebbe più da vicino la meccanica di uno srotolamento contemporaneo ed è forse ancora in nessun posto vicino a come apparirebbe il codice macchina srotolato:

if (! filters[0].isOK(i))
{
   return false;
} 
if(! filters[1].isOK(i))
{
   return false;
}
return true;

Stai concludendo che, poiché un pezzo di codice viene eseguito più velocemente di un altro pezzo di codice, il ciclo non viene srotolato. Anche se così fosse, potresti ancora vedere la differenza di runtime a causa del fatto che stai confrontando diverse implementazioni.

Se vuoi ottenere più certezza, c'è l' analizzatore / visualizzatore jitwatch delle operazioni Jit effettive incluso il codice macchina (github) (diapositive della presentazione) . Se alla fine c'è qualcosa da vedere, mi fiderei dei miei occhi più di ogni opinione su ciò che JIT può o non può fare in generale, poiché ogni caso ha le sue specificità. Qui si preoccupano della difficoltà di arrivare a dichiarazioni generali per casi specifici per quanto riguarda la SIC e fornire alcuni collegamenti interessanti.

Poiché il tuo obiettivo è il tempo di esecuzione minimo, il a && b && c ...modulo è probabilmente il più efficiente, se non vuoi dipendere dalla speranza di srotolare, almeno più efficiente di qualsiasi altra cosa ancora presentata. Ma non puoi averlo in modo generico. Con la composizione funzionale di java.util.Function c'è di nuovo un enorme sovraccarico (ogni funzione è una classe, ogni chiamata è un metodo virtuale che richiede l'invio). Forse in uno scenario del genere potrebbe avere senso sovvertire il livello della lingua e generare codice byte personalizzato in fase di esecuzione. D'altra parte, una &&logica richiede anche la ramificazione a livello di codice byte e può essere equivalente a if / return (che non può essere generato anche senza sovraccarico).


solo una piccola adendum: un ciclo contato nella JVM mondo è un qualsiasi ciclo che "corre" su un int i = ....; i < ...; ++iqualsiasi altro ciclo non è.
Eugene,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.