Por qué devolver una referencia de objeto Java es mucho más lento que devolver una primitiva


Estamos trabajando en una aplicación sensible a la latencia y hemos estado microbenchmarking todo tipo de métodos (utilizando jmh). Después de microbenchmarking un método de búsqueda y estar satisfecho con los resultados, implementé la versión final, solo para encontrar que la versión final era 3 veces más lenta de lo que acababa de comparar.

El culpable era que el método implementado estaba devolviendo un objeto enum en lugar de un int. Aquí está una versión simplificada de la código de referencia:

@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Thread)
public class ReturnEnumObjectVersusPrimitiveBenchmark {

    enum Category {
        CATEGORY1,
        CATEGORY2,
    }

    @Param( {"3", "2", "1" })
    String value;

    int param;

    @Setup
    public void setUp() {
        param = Integer.parseInt(value);
    }

    @Benchmark
    public int benchmarkReturnOrdinal() {
        if (param < 2) {
            return Category.CATEGORY1.ordinal();
        }
        return Category.CATEGORY2.ordinal();        
    }


    @Benchmark
    public Category benchmarkReturnReference() {
        if (param < 2) {
            return Category.CATEGORY1;
        }
        return Category.CATEGORY2;      
    }


    public static void main(String[] args) throws RunnerException {
            Options opt = new OptionsBuilder().include(ReturnEnumObjectVersusPrimitiveBenchmark.class.getName()).warmupIterations(5)
                .measurementIterations(4).forks(1).build();
        new Runner(opt).run();
    }

}

Los resultados de referencia para arriba:

# VM invoker: C:\Program Files\Java\jdk1.7.0_40\jre\bin\java.exe
# VM options: -Dfile.encoding=UTF-8

Benchmark                   (value)   Mode  Samples     Score     Error   Units
benchmarkReturnOrdinal            3  thrpt        4  1059.898 ±  71.749  ops/us
benchmarkReturnOrdinal            2  thrpt        4  1051.122 ±  61.238  ops/us
benchmarkReturnOrdinal            1  thrpt        4  1064.067 ±  90.057  ops/us
benchmarkReturnReference          3  thrpt        4   353.197 ±  25.946  ops/us
benchmarkReturnReference          2  thrpt        4   350.902 ±  19.487  ops/us
benchmarkReturnReference          1  thrpt        4   339.578 ± 144.093  ops/us

Simplemente cambiando el tipo de retorno de la función cambió el rendimiento por un factor de casi 3.

Pensé que la única diferencia entre devolver un objeto enumerado versus un entero es que uno devuelve un valor de 64 bits (referencia) y el otro devuelve un valor de 32 bits. Uno de mis colegas estaba adivinando que devolver la enumeración agregaba gastos adicionales debido a la necesidad de rastrear la referencia para potencial GC. (Pero dado que los objetos enum son referencias finales estáticas, parece extraño que tenga que hacer eso).

¿Cuál es la explicación de la diferencia de rendimiento?


UPDATE

Compartí el proyecto maven aquí para que cualquiera pueda clonarlo y ejecutar el benchmark. Si alguien tiene el tiempo / interés, sería útil ver si otros pueden replicar los mismos resultados. (He replicado en 2 máquinas diferentes, Windows 64 y Linux 64, ambos utilizando versiones de Oracle Java 1.7 JVMs). @ZhekaKozlov dice que no vio ninguna diferencia entre los métodos.

Para ejecutar: (después de clonar el repositorio)

mvn clean install
java -jar .\target\microbenchmarks.jar function.ReturnEnumObjectVersusPrimitiveBenchmark -i 5 -wi 5 -f 1
Author: Sam Goldberg, 2015-04-06

2 answers

TL; DR: Usted no debe poner una confianza ciega en nada.

Lo primero es lo primero: es importante verificar los datos experimentales antes de saltar a las conclusiones de ellos. Simplemente afirmar que algo es 3 veces más rápido/más lento es extraño, porque realmente necesita hacer un seguimiento de la razón de la diferencia de rendimiento, no solo confiar en los números. Esto es especialmente importante para nano-puntos de referencia como usted tiene.

Segundo, los experimentadores deben entender claramente lo que en su ejemplo particular, usted está devolviendo el valor de los métodos @Benchmark, pero ¿puede estar razonablemente seguro de que los llamantes externos harán lo mismo para primitive y la referencia? Si se hace esta pregunta, entonces se dará cuenta de que básicamente está midiendo la infraestructura de prueba.

Al grano. En mi máquina (i5-4210U, Linux x86_64, JDK 8u40), la prueba produce:

Benchmark                    (value)   Mode  Samples  Score   Error   Units
...benchmarkReturnOrdinal          3  thrpt        5  0.876 ± 0.023  ops/ns
...benchmarkReturnOrdinal          2  thrpt        5  0.876 ± 0.009  ops/ns
...benchmarkReturnOrdinal          1  thrpt        5  0.832 ± 0.048  ops/ns
...benchmarkReturnReference        3  thrpt        5  0.292 ± 0.006  ops/ns
...benchmarkReturnReference        2  thrpt        5  0.286 ± 0.024  ops/ns
...benchmarkReturnReference        1  thrpt        5  0.293 ± 0.008  ops/ns

Está bien, por lo que las pruebas de referencia aparecen 3 veces más lento. Pero espera, utiliza un antiguo JMH (1.1.1), vamos a actualizar a la última actual (1.7.1):

Benchmark                    (value)   Mode  Cnt  Score   Error   Units
...benchmarkReturnOrdinal          3  thrpt    5  0.326 ± 0.010  ops/ns
...benchmarkReturnOrdinal          2  thrpt    5  0.329 ± 0.004  ops/ns
...benchmarkReturnOrdinal          1  thrpt    5  0.329 ± 0.004  ops/ns
...benchmarkReturnReference        3  thrpt    5  0.288 ± 0.005  ops/ns
...benchmarkReturnReference        2  thrpt    5  0.288 ± 0.005  ops/ns
...benchmarkReturnReference        1  thrpt    5  0.288 ± 0.002  ops/ns

Oops, ahora son apenas más lentos. Por cierto, esto también nos dice que la prueba está vinculada a la infraestructura. Vale, ¿podemos ver lo que realmente sucede?

Si construyes los puntos de referencia, y miras a tu alrededor lo que llama exactamente tus métodos @Benchmark, entonces verás algo como:

public void benchmarkReturnOrdinal_thrpt_jmhStub(InfraControl control, RawResults result, ReturnEnumObjectVersusPrimitiveBenchmark_jmh l_returnenumobjectversusprimitivebenchmark0_0, Blackhole_jmh l_blackhole1_1) throws Throwable {
    long operations = 0;
    long realTime = 0;
    result.startTime = System.nanoTime();
    do {
        l_blackhole1_1.consume(l_longname.benchmarkReturnOrdinal());
        operations++;
    } while(!control.isDone);
    result.stopTime = System.nanoTime();
    result.realTime = realTime;
    result.measuredOps = operations;
}

Que l_blackhole1_1 tiene un método consume, que "consume" los valores (ver Blackhole para la justificación). Blackhole.consume tiene sobrecargas para referenciasy primitivas, y eso por sí solo es suficiente para justificar la diferencia de rendimiento.

Hay una razón por la que estos métodos se ven diferentes: están tratando de ser lo más rápido posible para sus tipos de argumento. No necesariamente exhiben las mismas características de rendimiento, a pesar de que tratamos de igualarlas, de ahí el resultado más simétrico con JMH más nuevo. Ahora, incluso puede ir a -prof perfasm para ver el código generado para sus pruebas y ver por qué el el rendimiento es diferente, pero eso está más allá del punto aquí.

Si realmente quiere entender cómo el retorno de la primitiva y / o referencia difiere en cuanto al rendimiento, tendría que introducir un gran zona gris aterradora de benchmarking de desempeño matizado. Por ejemplo, algo como esta prueba:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(5)
public class PrimVsRef {

    @Benchmark
    public void prim() {
        doPrim();
    }

    @Benchmark
    public void ref() {
        doRef();
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    private int doPrim() {
        return 42;
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    private Object doRef() {
        return this;
    }

}

...que produce el mismo resultado para primitivas y referencias:

Benchmark       Mode  Cnt  Score   Error  Units
PrimVsRef.prim  avgt   25  2.637 ± 0.017  ns/op
PrimVsRef.ref   avgt   25  2.634 ± 0.005  ns/op

Como dije anteriormente, estas pruebas requieren el seguimiento de la razones de los resultados. En este caso, el código generado para ambos es casi el mismo, y eso explica el resultado.

Prim:

                  [Verified Entry Point]
 12.69%    1.81%    0x00007f5724aec100: mov    %eax,-0x14000(%rsp)
  0.90%    0.74%    0x00007f5724aec107: push   %rbp
  0.01%    0.01%    0x00007f5724aec108: sub    $0x30,%rsp         
 12.23%   16.00%    0x00007f5724aec10c: mov    $0x2a,%eax   ; load "42"
  0.95%    0.97%    0x00007f5724aec111: add    $0x30,%rsp
           0.02%    0x00007f5724aec115: pop    %rbp
 37.94%   54.70%    0x00007f5724aec116: test   %eax,0x10d1aee4(%rip)        
  0.04%    0.02%    0x00007f5724aec11c: retq  

Ref:

                  [Verified Entry Point]
 13.52%    1.45%    0x00007f1887e66700: mov    %eax,-0x14000(%rsp)
  0.60%    0.37%    0x00007f1887e66707: push   %rbp
           0.02%    0x00007f1887e66708: sub    $0x30,%rsp         
 13.63%   16.91%    0x00007f1887e6670c: mov    %rsi,%rax     ; load "this"
  0.50%    0.49%    0x00007f1887e6670f: add    $0x30,%rsp
  0.01%             0x00007f1887e66713: pop    %rbp
 39.18%   57.65%    0x00007f1887e66714: test   %eax,0xe3e78e6(%rip)
  0.02%             0x00007f1887e6671a: retq   

[sarcasmo] ¡Mira lo fácil que es! [/sarcasmo]

El patrón es: cuanto más simple es la pregunta, más tienes que trabajar para hacer una respuesta plausible y confiable.

 145
Author: Aleksey Shipilev,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2015-04-08 11:31:43

Para aclarar el concepto erróneo de referencia y memoria algunos han caído en (@Mzf), vamos a sumergirnos en la Especificación de la Máquina Virtual Java. Pero antes de ir allí, una cosa debe ser aclarada - un objeto nunca puede ser recuperado de la memoria, solo sus campos pueden. De hecho, no hay ningún opcode que realice una operación tan extensa.

Este documento define la referencia como un tipo de pila (de modo que puede ser un resultado o un argumento para instrucciones que realizan operaciones en la pila) de 1a categoría - la categoría de tipos que toman una sola palabra de pila (32 bits). Véase el cuadro 2.3 Una lista de Tipos de Pila Java.

Además, si la invocación del método se completa normalmente de acuerdo con la especificación, un valor aparecido desde la parte superior de la pila se empuja a la pila de métodos invocador (sección 2.6.4).

Su pregunta es qué causa la diferencia de tiempos de ejecución. Capítulo 2 prólogo respuestas:

Detalles de implementación que no forman parte de la especificación de la Máquina Virtual Java limitaría innecesariamente la creatividad de los ejecutores. Por ejemplo, el diseño de memoria de áreas de datos en tiempo de ejecución, el algoritmo de recolección de basura utilizado, y cualquier optimización interna de las instrucciones de la Máquina Virtual Java (por ejemplo, traducirlos a código máquina) se dejan a la discreción del implementador.

En otras palabras, porque no hay tal cosa como una pena de performace con respecto al uso de la referencia se indica en el documento por razones lógicas (eventualmente es solo una palabra de pila como int o float son), te quedas con la búsqueda del código fuente de su implementación o nunca averiguarlo en absoluto.

En extensión, en realidad no siempre debemos culpar a la implementación, hay algunas pistas que puede tomar al buscar sus respuestas. Java define instrucciones separadas para manipular números y referencias. Las instrucciones de manipulación de referencia comienzan con a (p. ej. astore, aload o areturn) y son las únicas instrucciones permitidas para trabajar con referencias. En particular, puede estar interesado en ver la implementación de areturn.

 6
Author: user35443,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2015-04-06 15:34:22