usar - ¿Por qué devolver una referencia de objeto Java es mucho más lento que devolver una primitiva
reflection java 8 (2)
Estamos trabajando en una aplicación sensible a la latencia y hemos realizado microbenchmarking de todo tipo de métodos (usando jmh ). Después de realizar una microbenchmarking de un método de búsqueda y estar satisfecho con los resultados, implementé la versión final, solo para descubrir que la versión final era 3 veces más lenta de lo que acababa de comparar.
El culpable fue que el método implementado estaba devolviendo un objeto
enum
lugar de un
int
.
Aquí hay una versión simplificada del 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 lo anterior:
# 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
Solo cambiar el tipo de retorno de la función cambió el rendimiento en un factor de casi 3.
Pensé que la única diferencia entre devolver un objeto enum 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 suponía que devolver la enumeración agregaba una sobrecarga adicional debido a la necesidad de rastrear la referencia para un GC potencial. (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?
ACTUALIZAR
Compartí el proyecto Maven here para que cualquiera pueda clonarlo y ejecutar el punto de referencia. Si alguien tiene el tiempo / interés, sería útil ver si otros pueden replicar los mismos resultados. (Me he replicado en 2 máquinas diferentes, Windows 64 y Linux 64, ambas usando versiones de Oracle Java 1.7 JVM). @ZhekaKozlov dice que no vio ninguna diferencia entre los métodos.
Para ejecutar: (después del repositorio de clonación)
mvn clean install
java -jar ./target/microbenchmarks.jar function.ReturnEnumObjectVersusPrimitiveBenchmark -i 5 -wi 5 -f 1
Para borrar la idea errónea de referencia y memoria en la que algunos han caído (@Mzf), profundicemos en la especificación de máquina virtual Java. Pero antes de ir allí, hay que aclarar una cosa: un objeto nunca se puede recuperar de la memoria, solo sus campos pueden hacerlo. De hecho, no existe un código de operación que realice una operación tan extensa.
Este documento define la referencia como un tipo de pila (por lo que puede ser un resultado o un argumento para las instrucciones que realizan operaciones en la pila) de la primera categoría, la categoría de tipos que toman una sola palabra de pila (32 bits). Ver tabla 2.3 .
Además, si la invocación del método se completa normalmente de acuerdo con la especificación, un valor extraído de la parte superior de la pila se inserta en la pila del invocador del método (sección 2.6.4).
Su pregunta es qué causa la diferencia de los tiempos de ejecución. Capítulo 2 prólogo respuestas:
Los detalles de implementación que no forman parte de la especificación de Java Virtual Machine limitarían innecesariamente la creatividad de los implementadores. Por ejemplo, el diseño de la memoria de las áreas de datos de 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, traducirlas al código de la máquina) quedan a discreción del implementador.
En otras palabras, debido a que en el documento no se establece una penalización de rendimiento con respecto al uso de la referencia por razones lógicas (eventualmente es solo una palabra de pila como
int
o
float
), le queda buscar el código fuente de su implementación o nunca descubrirlo en absoluto.
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 referencias comienzan con
a
(p.
astore
,
astore
,
aload
o
areturn
) y son las únicas instrucciones que pueden trabajar con referencias.
En particular, puede estar interesado en ver la implementación de
areturn
.
TL; DR: No debes poner la confianza CIEGO en nada.
Primero 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 los que tienes.
En segundo lugar, los experimentadores deben comprender claramente qué controlan y qué no controlan.
En su ejemplo particular, está devolviendo el valor de los métodos de
@Benchmark
, pero ¿puede estar razonablemente seguro de que las personas que llaman afuera harán lo mismo para primitivo y la referencia?
Si se hace esta pregunta, se dará cuenta de que básicamente está midiendo la infraestructura de prueba.
Hasta el punto. En mi máquina (i5-4210U, Linux x86_64, JDK 8u40), la prueba arroja:
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
Bien, entonces las pruebas de referencia aparecen 3 veces más lentas. Pero espera, usa un JMH antiguo (1.1.1), vamos a actualizar a la última versión 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
Vaya, ahora son apenas más lentos. Por cierto, esto también nos dice que la prueba está vinculada a la infraestructura. De acuerdo, ¿podemos ver lo que realmente sucede?
Si construye los puntos de referencia y observa lo que llama exactamente sus métodos
@Benchmark
, verá 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 de
consume
, que "consume" los valores (ver
Blackhole
para la justificación).
Blackhole.consume
tiene sobrecargas para
references
y
primitives
, y eso solo es suficiente para justificar la diferencia de rendimiento.
Hay una razón por la cual estos métodos se ven diferentes: están tratando de ser lo más rápidos posible para sus tipos de argumento.
No exhiben necesariamente las mismas características de rendimiento, aunque intentemos igualarlas, de ahí el resultado más simétrico con el 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 rendimiento es diferente, pero eso va más allá del punto aquí.
Si realmente quiere entender cómo devolver el primitivo y / o la referencia difiere en cuanto al rendimiento, necesitaría ingresar a una gran zona gris aterradora de evaluación comparativa de rendimiento matizada. 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 un seguimiento de los motivos de los resultados. En este caso, el código generado para ambos es casi el mismo, y eso explica el resultado.
remilgado:
[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
árbitro:
[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 tiene que trabajar para hacer una respuesta plausible y confiable.