JMH: Todo lo que necesitas saber sobre el Java Microbenchmark Harness
1. Introduction
Este artículo rápido se centra en JMH (Java Microbenchmark Harness). Primero, nos familiarizaremos con la API y aprenderemos sus conceptos básicos. Luego, veremos algunas mejores prácticas que debemos considerar al escribir microbenchmark. Simplemente, JMH se encarga de cosas como el calentamiento de la JVM (Java Virtual Machine) y los caminos de optimización del código, haciendo que la actividad de benchmarking sea lo más simple posible.
2. Getting Started
Para empezar con Maven, necesitamos declarar las dependencias en el pom.xml
:
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.37</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.37</version>
</dependency>
Las versiones más recientes de JMH Core y JMH Annotation Processor se pueden encontrar en Maven Central.
A continuación, debemos agregar el JMH Annotation Processor a la configuración del Maven Compiler Plugin. Este paso es crítico porque, sin él, podríamos obtener el error Unable to find the resource: /META-INF/BenchmarkList
:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>17</source>
<target>17</target>
<annotationProcessorPaths>
<path>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.37</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
Ahora, vamos a crear un benchmark simple utilizando la anotación @Benchmark
, que requiere que el método al que se aplica tenga el modificador public
. La clase que contiene los métodos de benchmark también debe declararse como public
:
@Benchmark
public void init() {
// Do nothing
}
Luego, añadimos la clase main
que iniciará el proceso de benchmarking:
public class BenchmarkRunner {
public static void main(String[] args) throws Exception {
org.openjdk.jmh.Main.main(args);
}
}
Sin embargo, este main
solo funcionará si ejecutamos nuestro proyecto Maven usando exec:exec
. Si usamos exec:java
en su lugar y falta la anotación @fork
o es diferente de @fork(0)
, podemos obtener el error Could not find or load main class org.openjdk.jmh.runner.ForkedMain
. Hay dos maneras de solucionar este problema. La primera es agregar este código al método main
antes de ejecutar org.openjdk.jmh.Main.main(args)
:
URLClassLoader classLoader = (URLClassLoader) BenchmarkRunner.class.getClassLoader();
StringBuilder classpath = new StringBuilder();
for (URL url : classLoader.getURLs()) {
classpath.append(url.getPath()).append(File.pathSeparator);
}
System.setProperty("java.class.path", classpath.toString());
Básicamente, este código recupera y establece dinámicamente el classpath para la Máquina Virtual de Java en tiempo de ejecución. Esto ayuda a asegurar que todas las clases y recursos requeridos estén disponibles durante la ejecución. Alternativamente, podemos usar una solución basada únicamente en pom.xml
:
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>build-classpath</id>
<goals>
<goal>build-classpath</goal>
</goals>
<configuration>
<includeScope>runtime</includeScope>
<outputProperty>depClasspath</outputProperty>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<configuration>
<mainClass>BenchmarkRunner</mainClass>
<systemProperties>
<systemProperty>
<key>java.class.path</key>
<value>${project.build.outputDirectory}${path.separator}${depClasspath}</value>
</systemProperty>
</systemProperties>
</configuration>
</plugin>
Esta pom.xml
logra un objetivo similar al anterior código Java. Ambas soluciones son equivalentes en que aseguran que todas las dependencias de ejecución requeridas estén incluidas en el classpath, pero el enfoque de pom.xml
utiliza las capacidades de construcción y ejecución de Maven para automatizar este proceso dentro del ciclo de vida de construcción, mientras que el código Java lo hace programáticamente en tiempo de ejecución.
Ahora ejecutar BenchmarkRunner
ejecutará nuestro benchmark, que podría ser algo menos útil. Una vez que la ejecución esté completa, se presentará una tabla 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. Types of Benchmarks
JMH soporta diferentes tipos de benchmarks: Throughput, AverageTime, SampleTime, y SingleShotTime. Estos se pueden configurar mediante la anotación @BenchmarkMode
:
@Benchmark
@BenchmarkMode(Mode.AverageTime)
public void init() {
// Do nothing
}
La tabla resultante mostrará 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. Configuring Warmup and Execution
Usando la anotación @Fork
, podemos configurar cómo se lleva a cabo la ejecución del benchmark: el parámetro value
controla cuántas veces se ejecutará el benchmark, y el parámetro warmup
controla cuántas veces se ejecutará un benchmark antes de que se recojan los resultados, por ejemplo:
@Benchmark
@Fork(value = 1, warmups = 2)
@BenchmarkMode(Mode.Throughput)
public void init() {
// Do nothing
}
Esto instruye a JMH a ejecutar dos forks de calentamiento y descartar resultados antes de pasar a la verdadera medición del benchmarking.
Además, se puede usar la anotación @Warmup
para controlar el número de iteraciones de calentamiento. Por ejemplo, @Warmup(iterations = 5)
le dice a JMH que cinco iteraciones de calentamiento serán suficientes, en comparación con el predeterminado de 20.
5. State
Ahora examinemos cómo realizar una tarea de benchmarking más indicativa de un algoritmo de hashing utilizando State
. Supongamos que decidimos agregar protección adicional contra ataques de diccionario a una base de datos de contraseñas al hashear la contraseña varias 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 benchmark 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í, el campo iterations
se llenará con los valores apropiados de la anotación @Param
por parte de JMH cuando se pase al método benchmark. El método anotado con @Setup
se invoca antes de cada invocación del benchmark y crea un nuevo Hasher
, asegurando aislamiento.
Cuando la ejecución haya terminado, 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. Dead Code Elimination
Cuando ejecutamos microbenchmarks, es muy importante estar conscientes de las optimizaciones. De lo contrario, pueden afectar los resultados del benchmark de una manera muy engañosa.
Para concretar este tema, 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();
}
Esperaríamos que el costo de la creación de objetos sea más alto que no hacer nada en absoluto. Sin embargo, si ejecutamos los benchmarks:
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
Al parecer, encontrar un lugar en el TLAB, crear e inicializar un objeto es casi gratis. Solo con ver estos números, sabríamos que algo no cuadra aquí.
Aquí, somos víctimas de la eliminación de código muerto. Los compiladores son muy buenos optimizando el código redundante. De hecho, eso es exactamente lo que hizo el compilador JIT aquí.
Para prevenir esta optimización, debemos de alguna manera engañar al compilador e hacerle pensar que el código está siendo utilizado 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();
}
También, podemos permitir que el Blackhole consuma el objeto:
@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public void blackHole(Blackhole blackhole) {
blackhole.consume(new Object());
}
Hacer que Blackhole
consuma el objeto es una manera de convencer al compilador JIT de no aplicar la optimización de eliminación de código muerto. De todos modos, si ejecutamos estos benchmarks de nuevo, 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. Constant Folding
Consideremos otro ejemplo:
@Benchmark
public double foldedLog() {
int x = 8;
return Math.log(x);
}
Los cálculos basados en constantes pueden devolver el mismo resultado exacto, independientemente del número de ejecuciones. Por lo tanto, existe una buena posibilidad de que el compilador JIT reemplace la llamada a la función logaritmo con su resultado:
@Benchmark
public double foldedLog() {
return 2.0794415416798357;
}
Esta forma de evaluación parcial se llama constante folding. En este caso, el folding constante evita por completo la llamada a Math.log
, que era el objetivo del benchmark.
Para evitar el folding 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 benchmarks uno contra otro:
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 log
está realizando un trabajo serio en comparación con foldedLog
, lo cual es sensato.
8. Conclusion
Este tutorial se centró y mostró el harness de microbenchmarking de Java. Al implementar correctamente JMH, los programadores pueden asegurar que sus benchmarks son precisos y que reflejan fielmente el rendimiento del código bajo evaluación. Utilizando las herramientas y técnicas discutidas en este artículo, puedes crear benchmarks que no solo sean precisos, sino también significativos en el contexto de optimizaciones de rendimiento en Java.
Algunos consejos prácticos para programadores especializadas en Java incluyen:
- Siempre incluye bucles de calentamiento: Permiten que la JVM optimice el código antes de que se recojan los resultados.
- Usa el objeto Blackhole: Es útil para evitar la eliminación de código muerto cuando estás realizando benchmarks.
- Define claramente tu estado: Utiliza el objeto de estado adecuadamente para garantizar que tus pruebas sean aisladas y representativas.
Adoptando estas prácticas, puedes aprovechar al máximo el Java Microbenchmark Harness y conseguir resultados que realmente importan para la optimización de tu código.