Java: el bucle desenrollado manualmente sigue siendo más rápido que el bucle original. ¿Por qué?

13

Considere los siguientes dos fragmentos de código en una matriz de longitud 2:

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

y

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

Supongo que el rendimiento de estas dos piezas debería ser similar después de un calentamiento suficiente.
He comprobado esto utilizando el marco de micro-evaluación comparativa JMH como se describe, por ejemplo, aquí y aquí y observé que el segundo fragmento es más del 10% más rápido.

Pregunta: ¿por qué Java no ha optimizado mi primer fragmento utilizando la técnica básica de desenrollado de bucle?
En particular, me gustaría entender lo siguiente:

  1. Puedo producir fácilmente un código que es óptimo para los casos de 2 filtros y todavía puede trabajar en caso de otra serie de filtros (imaginar un constructor sencilla):
    return (filters.length) == 2 ? new FilterChain2(filters) : new FilterChain1(filters). ¿Puede JITC hacer lo mismo y si no, por qué?
  2. ¿Puede JITC detectar que ' filtros.length == 2 ' es el caso más frecuente y producir el código óptimo para este caso después de un calentamiento? Esto debería ser casi tan óptimo como la versión desenrollada manualmente.
  3. ¿Puede JITC detectar que una instancia en particular se usa con mucha frecuencia y luego generar un código para esta instancia específica (para la cual sabe que el número de filtros es siempre 2)?
    Actualización: obtuve una respuesta de que JITC funciona solo a nivel de clase. Ok lo tengo.

Idealmente, me gustaría recibir una respuesta de alguien con un profundo conocimiento de cómo funciona JITC.

Detalles de ejecución de referencia:

  • Probado en las últimas versiones de Java 8 OpenJDK y Oracle HotSpot, los resultados son similares
  • Indicadores Java utilizados: -Xmx4g -Xms4g -server -Xbatch -XX: CICompilerCount = 2 (obtuve resultados similares sin los indicadores elegantes también)
  • Por cierto, obtengo una relación de tiempo de ejecución similar si simplemente la ejecuto varios miles de millones de veces en un bucle (no a través de JMH), es decir, el segundo fragmento siempre es claramente más rápido

Salida de referencia típica:

Punto de referencia (filterIndex) Modo Cnt Puntuación Error Unidades
LoopUnrollingBenchmark.runBenchmark 0 avgt 400 44.202 ± 0.224 ns / op
LoopUnrollingBenchmark.runBenchmark 1 avgt 400 38.347 ± 0.063 ns / op

(La primera línea corresponde al primer fragmento, la segunda línea - a la segunda.

Código de referencia 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);
    }
}
Alejandro
fuente
1
El compilador no puede garantizar que la longitud de la matriz sea 2. Sin embargo, no estoy seguro de que la desenrolle incluso si pudiera.
marstran
1
@Setup(Level.Invocation): no estoy seguro de que ayude (ver el javadoc).
GPI
3
Como no hay garantía en ningún lugar de que la matriz sea siempre de longitud 2, los dos métodos no están haciendo lo mismo. ¿Cómo podría entonces JIT permitirse cambiar el primero en el segundo?
Andreas
@Andreas, le sugiero que responda la pregunta, pero explique por qué JIT no puede desenrollarse en este caso en comparación con otro caso similar en el que puede
Alexander
1
@Alexander JIT puede ver que la longitud de la matriz no puede cambiar después de la creación, porque el campo es final, pero JIT no ve que todas las instancias de la clase obtendrán una matriz de longitud 2. Para ver eso, tendría que sumergirse en el createLeafFilters()método y analice el código lo suficientemente profundo como para saber que la matriz siempre tendrá 2 longitudes. ¿Por qué crees que el optimizador JIT se sumergiría tanto en tu código?
Andreas

Respuestas:

10

TL; DR La razón principal de la diferencia de rendimiento aquí no está relacionada con el desenrollado del bucle. Es más bien el tipo de especulación y los cachés en línea .

Estrategias de desenrollamiento

De hecho, en la terminología de HotSpot, dichos bucles se tratan como contados y, en ciertos casos, JVM puede desenrollarlos. Aunque no en tu caso.

HotSpot tiene dos estrategias de desenrollado de bucle: 1) desenrollar al máximo, es decir, eliminar el bucle por completo; o 2) pegar varias iteraciones consecutivas juntas.

El desenrollado máximo se puede hacer, solo si se conoce el número exacto de iteraciones .

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

En su caso, sin embargo, la función puede regresar antes de la primera iteración.

El desenrollado parcial probablemente podría aplicarse, pero la siguiente condición interrumpe el desenrollado:

  // 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;
  }

Dado que en su caso el recuento de viajes esperado es inferior a 2, HotSpot supone que no vale la pena desenrollar incluso dos iteraciones. Tenga en cuenta que la primera iteración se extrae en pre-loop de todos modos ( optimización de pelado de bucle ), por lo que el desenrollado no es muy beneficioso aquí.

Tipo de especulación

En su versión desenrollada, hay dos invokeinterfacecódigos de bytes diferentes . Estos sitios tienen dos perfiles de tipo distintos. El primer receptor es siempre Filter1, y el segundo receptor es siempre Filter2. Entonces, básicamente tiene dos sitios de llamadas monomórficas, y HotSpot puede alinear perfectamente ambas llamadas, lo que se denomina "caché en línea", que tiene una proporción de aciertos del 100% en este caso.

Con el bucle, solo hay un invokeinterfacecódigo de bytes y solo se recopila un perfil de tipo. HotSpot JVM ve que filters[j].isOK()se llama 86% de veces con Filter1receptor y 14% de veces con Filter2receptor. Esta será una llamada bimórfica. Afortunadamente, HotSpot también puede incluir llamadas bimórficas en línea especulativamente. Alinea ambos objetivos con una rama condicional. Sin embargo, en este caso, la proporción de aciertos será como máximo del 86%, y el rendimiento sufrirá debido a las correspondientes ramas erróneas a nivel de arquitectura.

Las cosas serán aún peores si tienes 3 o más filtros diferentes. En este caso isOK(), será una llamada megamórfica que HotSpot no puede en línea. Por lo tanto, el código compilado contendrá una verdadera llamada de interfaz que tiene un mayor impacto en el rendimiento.

Más información sobre la línea especulativa en el artículo The Black Magic of (Java) Method Dispatch .

Conclusión

Para inlinear llamadas virtuales / de interfaz, HotSpot JVM recopila perfiles de tipo por código de byte de invocación. Si hay una llamada virtual en un bucle, solo habrá un perfil de tipo para la llamada, sin importar si el bucle está desenrollado o no.

Para obtener lo mejor de las optimizaciones de llamadas virtuales, necesitaría dividir manualmente el bucle, principalmente con el propósito de dividir los perfiles de tipo. HotSpot no puede hacer esto automáticamente hasta ahora.

apangin
fuente
Gracias por la gran respuesta. Solo para completar: ¿conoce alguna técnica JITC que pueda producir código para una instancia específica?
Alexander
@Alexander HotSpot no optimiza el código para una instancia específica. Utiliza estadísticas de tiempo de ejecución que incluyen contadores por código de bytes, perfil de tipo, probabilidades de ramificación, etc. Si desea optimizar el código para un caso específico, cree una clase separada para él, ya sea manualmente o con generación dinámica de código de bytes.
apangin 01 de
13

El bucle presentado probablemente pertenece a la categoría de bucles "no contados", que son bucles para los cuales el recuento de iteraciones no puede determinarse en tiempo de compilación ni en tiempo de ejecución. No solo por el argumento de @Andreas sobre el tamaño de la matriz, sino también por el condicional aleatorio break(que solía estar en su punto de referencia cuando escribí esta publicación).

Los compiladores de vanguardia no los optimizan agresivamente, ya que desenrollar bucles no contados a menudo implica duplicar también la condición de salida de un bucle, lo que solo mejora el rendimiento del tiempo de ejecución si las optimizaciones posteriores del compilador pueden optimizar el código desenrollado. Consulte este documento de 2017 para obtener detalles donde hacen propuestas sobre cómo desenrollar tales cosas también.

De esto se deduce que su suposición no sostiene que usted hizo una especie de "desenrollado manual" del bucle. Lo está considerando como una técnica básica de desenrollado de bucle para transformar una iteración sobre una matriz con salto condicional a una &&expresión booleana encadenada. Consideraría que este es un caso bastante especial y me sorprendería encontrar que un optimizador de puntos calientes realice una refactorización compleja sobre la marcha. Aquí están discutiendo lo que realmente podría hacer, tal vez esta referencia es interesante.

Esto reflejaría más de cerca la mecánica de un desenrollado contemporáneo y quizás aún no se acerca al aspecto del código de máquina desenrollado:

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

Estás concluyendo que debido a que un código se ejecuta más rápido que otro código, el bucle no se desenrolló. Incluso si lo hiciera, aún podría ver la diferencia de tiempo de ejecución debido al hecho de que está comparando diferentes implementaciones.

Si desea obtener más certeza, existe el analizador / visualizador jitwatch de las operaciones Jit reales, incluido el código de máquina (github) (diapositivas de presentación) . Si finalmente hay algo que ver, confiaría en mis propios ojos más que cualquier opinión sobre lo que JIT puede o no hacer en general, ya que cada caso tiene sus detalles. Aquí se preocupan por la dificultad de llegar a declaraciones generales para casos específicos en lo que respecta a JIT y proporcionan algunos enlaces interesantes.

Dado que su objetivo es un tiempo de ejecución mínimo, a && b && c ...es probable que el formulario sea el más eficiente, si no desea depender de la esperanza de desenrollar el bucle, al menos más eficiente que cualquier otra cosa presentada todavía. Pero no puedes tener eso de forma genérica. Con la composición funcional de java.util.Function hay una gran sobrecarga de nuevo (cada función es una clase, cada llamada es un método virtual que necesita despacho). Quizás en tal escenario podría tener sentido subvertir el nivel de idioma y generar código de bytes personalizado en tiempo de ejecución. Por otro lado, una &&lógica requiere ramificación en el nivel de código de byte y puede ser equivalente a if / return (que tampoco se puede generar sin sobrecarga).

güriösä
fuente
solo un pequeño adendum: un bucle contado en el mundo JVM es cualquier bucle que "se ejecuta" sobre int i = ....; i < ...; ++icualquier otro bucle no lo es.
Eugene