Todo sobre el Java Microbenchmark Harness

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.