Microbenchmarking con Java

1. Introducción

Este artículo rápido se centra en JMH (Java Microbenchmark Harness). Primero, nos familiarizamos con la API y aprendemos sus conceptos básicos. Luego, veríamos algunas de las mejores prácticas que deberíamos considerar al escribir microbenchmarks.

En pocas palabras, JMH se encarga de cosas como el calentamiento de JVM y las rutas de optimización de código, haciendo que la evaluación comparativa sea lo más simple posible.

2. Comenzando

Para empezar, podemos seguir trabajando con Java 8 y simplemente definir las dependencias:

 org.openjdk.jmh jmh-core 1.19   org.openjdk.jmh jmh-generator-annprocess 1.19 

Las últimas versiones de JMH Core y JMH Annotation Processor se pueden encontrar en Maven Central.

A continuación, cree un punto de referencia simple utilizando la anotación @Benchmark (en cualquier clase pública):

@Benchmark public void init() { // Do nothing }

Luego agregamos la clase principal que inicia el proceso de evaluación comparativa:

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

Ahora, ejecutar BenchmarkRunner ejecutará nuestro punto de referencia posiblemente algo inútil. Una vez que se completa la ejecución, se presenta una tabla de resumen:

# Run complete. Total time: 00:06:45 Benchmark Mode Cnt Score Error Units BenchMark.init thrpt 200 3099210741.962 ± 17510507.589 ops/s

3. Tipos de puntos de referencia

JMH admite algunos puntos de referencia posibles: rendimiento, tiempo medio, tiempo de muestra y tiempo de disparo único . Estos se pueden configurar a través de la anotación @BenchmarkMode :

@Benchmark @BenchmarkMode(Mode.AverageTime) public void init() { // Do nothing }

La tabla resultante tendrá una métrica de tiempo promedio (en lugar de rendimiento):

# Run complete. Total time: 00:00:40 Benchmark Mode Cnt Score Error Units BenchMark.init avgt 20 ≈ 10⁻⁹ s/op

4. Configurar el calentamiento y la ejecución

Mediante el uso de la anotación @Fork , podemos configurar cómo se realiza la ejecución de la prueba comparativa: el parámetro de valor controla cuántas veces se ejecutará la prueba comparativa, y el parámetro de calentamiento controla cuántas veces se ejecutará una prueba comparativa antes de que se recopilen los resultados, por ejemplo :

@Benchmark @Fork(value = 1, warmups = 2) @BenchmarkMode(Mode.Throughput) public void init() { // Do nothing }

Esto indica a JMH que ejecute dos horquillas de calentamiento y descarte los resultados antes de pasar a la evaluación comparativa cronometrada real.

Además, la anotación @Warmup se puede utilizar para controlar el número de iteraciones de calentamiento. Por ejemplo, @Warmup (iteraciones = 5) le dice a JMH que cinco iteraciones de calentamiento serán suficientes, a diferencia de las 20 predeterminadas.

5. Estado

Examinemos ahora cómo se puede realizar una tarea menos trivial y más indicativa de comparar un algoritmo hash utilizando State . Supongamos que decidimos agregar protección adicional contra los ataques de diccionario en una base de datos de contraseñas mediante el hash de la contraseña unos cientos de veces.

Podemos explorar el impacto en el rendimiento utilizando un objeto State :

@State(Scope.Benchmark) public class ExecutionPlan { @Param({ "100", "200", "300", "500", "1000" }) public int iterations; public Hasher murmur3; public String password = "4v3rys3kur3p455w0rd"; @Setup(Level.Invocation) public void setUp() { murmur3 = Hashing.murmur3_128().newHasher(); } }

Nuestro método de referencia se verá así:

@Fork(value = 1, warmups = 1) @Benchmark @BenchmarkMode(Mode.Throughput) public void benchMurmur3_128(ExecutionPlan plan) { for (int i = plan.iterations; i > 0; i--) { plan.murmur3.putString(plan.password, Charset.defaultCharset()); } plan.murmur3.hash(); }

Aquí, las iteraciones de campo se completarán con los valores apropiados de la anotación @Param por parte de JMH cuando se pase al método de referencia. El método anotado @Setup se invoca antes de cada invocación del punto de referencia y crea un nuevo Hasher que garantiza el aislamiento.

Cuando finalice la ejecución, obtendremos un resultado similar al siguiente:

# Run complete. Total time: 00:06:47 Benchmark (iterations) Mode Cnt Score Error Units BenchMark.benchMurmur3_128 100 thrpt 20 92463.622 ± 1672.227 ops/s BenchMark.benchMurmur3_128 200 thrpt 20 39737.532 ± 5294.200 ops/s BenchMark.benchMurmur3_128 300 thrpt 20 30381.144 ± 614.500 ops/s BenchMark.benchMurmur3_128 500 thrpt 20 18315.211 ± 222.534 ops/s BenchMark.benchMurmur3_128 1000 thrpt 20 8960.008 ± 658.524 ops/s

6. Eliminación de código muerto

Al ejecutar microbenchmarks, es muy importante estar al tanto de las optimizaciones . De lo contrario, pueden afectar los resultados del índice de referencia de una manera muy engañosa.

Para hacer las cosas un poco más concretas, consideremos un ejemplo:

@Benchmark @OutputTimeUnit(TimeUnit.NANOSECONDS) @BenchmarkMode(Mode.AverageTime) public void doNothing() { } @Benchmark @OutputTimeUnit(TimeUnit.NANOSECONDS) @BenchmarkMode(Mode.AverageTime) public void objectCreation() { new Object(); }

Esperamos que la asignación de objetos cueste más que no hacer nada. Sin embargo, si ejecutamos los puntos de referencia:

Benchmark Mode Cnt Score Error Units BenchMark.doNothing avgt 40 0.609 ± 0.006 ns/op BenchMark.objectCreation avgt 40 0.613 ± 0.007 ns/op

Aparentemente, encontrar un lugar en la TLAB, crear e inicializar un objeto es casi gratis. Con solo mirar estos números, deberíamos saber que algo no cuadra aquí.

Aquí, somos víctimas de la eliminación del código muerto . Los compiladores son muy buenos para optimizar el código redundante. De hecho, eso es exactamente lo que hizo aquí el compilador JIT.

Para evitar esta optimización, de alguna manera deberíamos engañar al compilador y hacerle pensar que el código es usado por algún otro componente. Una forma de lograr esto es simplemente devolver el objeto creado:

@Benchmark @OutputTimeUnit(TimeUnit.NANOSECONDS) @BenchmarkMode(Mode.AverageTime) public Object pillarsOfCreation() { return new Object(); }

Además, podemos dejar que Blackhole lo consuma:

@Benchmark @OutputTimeUnit(TimeUnit.NANOSECONDS) @BenchmarkMode(Mode.AverageTime) public void blackHole(Blackhole blackhole) { blackhole.consume(new Object()); }

Hacer que Blackhole consuma el objeto es una forma de convencer al compilador JIT de que no aplique la optimización de eliminación de código muerto . De todos modos, si volvemos a ejecutar estos puntos de referencia, los números tendrían más sentido:

Benchmark Mode Cnt Score Error Units BenchMark.blackHole avgt 20 4.126 ± 0.173 ns/op BenchMark.doNothing avgt 20 0.639 ± 0.012 ns/op BenchMark.objectCreation avgt 20 0.635 ± 0.011 ns/op BenchMark.pillarsOfCreation avgt 20 4.061 ± 0.037 ns/op

7. Plegado constante

Consideremos otro ejemplo más:

@Benchmark public double foldedLog() { int x = 8; return Math.log(x); }

Los cálculos basados ​​en constantes pueden devolver exactamente el mismo resultado, independientemente del número de ejecuciones. Por lo tanto, existe una gran posibilidad de que el compilador JIT reemplace la llamada a la función de logaritmo con su resultado:

@Benchmark public double foldedLog() { return 2.0794415416798357; }

Esta forma de evaluación parcial se llama plegamiento constante . En este caso, el plegado constante evita por completo la llamada Math.log , que era el punto principal del punto de referencia.

Para evitar el plegado constante, podemos encapsular el estado constante dentro de un objeto de estado:

@State(Scope.Benchmark) public static class Log { public int x = 8; } @Benchmark public double log(Log input) { return Math.log(input.x); }

Si ejecutamos estos puntos de referencia entre sí:

Benchmark Mode Cnt Score Error Units BenchMark.foldedLog thrpt 20 449313097.433 ± 11850214.900 ops/s BenchMark.log thrpt 20 35317997.064 ± 604370.461 ops/s

Aparentemente, el benchmark de registros está haciendo un trabajo serio en comparación con foldedLog , lo cual es sensato.

8. Conclusión

Este tutorial se centró y mostró el arnés de evaluación comparativa micro de Java.

Como siempre, los ejemplos de código se pueden encontrar en GitHub.