Introducción a JMH: Microbenchmark en Java

0
9464

Hoy veremos con un ejemplo como podemos medir el rendimiento de nuestros métodos Java utilizando el framework de benchmarking JMH.

0. Índice de contenidos

1. Introducción

En el tutorial de hoy veremos el framework de microbenchmarking JMH, el cual nos permite contruir, ejecutar y analizar nano/micro/mili/macro benchmarks escritos en Java y otros lenguajes de la JVM. La primera versión de este framework fue lanzada en el año 2013, desarrollado por el mismo personal de Oracle que implemtó el JIT (compilación en tiempo de ejecución).

El benchmarking es una técnica para medir el rendimiento de un sistema o componente del mismo, y que nos permitirá intentar predecir como se comportará cuando cambiemos el entorno. Para ello deberemos ejecutar uno o más benchmarks (o comparativas).

Las principales características de JMH son:

  • Usado adecuadamente (ya veremos luego cómo), ayuda a mitigar peculiaridades de la VM.
  • Permite ejecutar muchos benchmarks bajo diferentes condiciones.
  • Dispone de una organización interna para clasificar problemas rápidamente y dar prioridad a los más urgentes.
  • Da soporte a cualquier lenguaje de la JVM: Java, Scala, Groovy, Kotlin o cualquier «cosa» llamable desde Java (Nashorn,…).

Los requisitos principales de JMH son:

  • Que el proyecto que contenga nuestros métodos a probar esté configurado con maven.
  • Utilizar la anotación @Benchmark en nuestros métodos de prueba.

En la página oficial de JMH podemos encontrar una serie de recomendaciones para obtener los mejores resultados posibles, ya que como ellos mismos nos advierten, a pesar de tratarse de un framework que facilita mucho las cosas, no nos librará mágicamente de las trampas de benchmarking, únicamente se comprometen a que sea más fácil evitarlas, pero no a evitarlas por completo.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 15’ (2 GHz Intel Core i7, 8 GB 1333 MHz DDR3)
  • br

  • Sistema Operativo: Mac OS X Yosemite 10.10.5
  • Entorno de desarrollo: Eclipse Java EE IDE, Mars Release (4.5.0)
  • JMH 1.10.5
  • Apache Maven 3.3.3

3. Ejemplo de uso

Pues como siempre, lo más fácil es que veamos un ejemplo sencillo de como usar este framework para hacernos una idea de lo que podemos hacer con él.

Siguiendo las recomendaciones oficiales, crearemos un proyecto de benchmarking desde cero. Para ello ejecutamos el siguiente comando maven desde un terminal, habiéndonos situado primero en nuestro workspace:

$ mvn archetype:generate \
          -DinteractiveMode=false \
          -DarchetypeGroupId=org.openjdk.jmh \
          -DarchetypeArtifactId=jmh-java-benchmark-archetype \
          -DgroupId=com.autentia.tutoriales.jmh \
          -DartifactId=ejemploJMH \
          -Dversion=1.0

Como vemos se usa un arquetipo en particular que se encargará de preparar nuestro pom con todas las configuraciones y dependencias necesarias para obtener un buen proyecto JMH. Este arquetipo puede sufrir cambios, por lo que siempre es recomendable visitar la página oficial del framework antes de ejecutarlo.

En nuestro caso, vamos a implementar un proyecto Java, pero se pueden crear proyectos en otros lenguajes de la JVM modificando el archetype artifact ID. Aquí podemos ver una lista de las alternativas.

Como vemos en la imagen, hemos creado nuestro proyecto (ejemploJMH) en nuestro directorio de trabajo.

Proyecto de benchmarking

El siguiente paso no es otro que importar el proyecto maven recién creado a nuestro IDE, en este caso Eclipse. Por tanto seguimos el procedimiento habitual, abrimos la pestaña File y selecccionamos Import…, nos vamos al directorio Maven y seleccionamos Existing Maven Projects. Localizamos el directorio de nuestro proyecto creado anteriormente y seleccionamos el pom.xml.

Importar proyecto Maven

Al abrir el proyecto, si echamos un vistazo a nuestro pom, podremos ver que ya están incluidas las dependencias y configuraciones necesarias para un proyecto con JMH.

pom.xml
<dependencies>
	<dependency>
		<groupId>org.openjdk.jmh</groupId>
		<artifactId>jmh-core</artifactId>
		<version>${jmh.version}</version>
	</dependency>
            ...

Lo que debemos hacer ahora es crear algunos ficheros Java en los que definir los benchmarks. Para nuestro ejemplo, vamos a crear una clase Factorial que contenga dos métodos para calcular el factorial de un número, uno lo calculará con un bucle for, y el otro obtendrá el factorial de manera recursiva.

Factorial.java
package com.autentia.tutoriales.jmh;

public class Factorial {

    public static int getFactorialFor(int number) {
        int factorial = 1;
        for (int i = 2; i <= number; i++) {
            factorial *= i;
        }
        return factorial;
    }

    public static int getFactorialRec(int number) {
        if (number == 0) {
            return 1;
        } else {
            return number * getFactorialRec(number - 1);
        }
    }

}

Si echamos un vistazo al directorio src/main/java, vemos que se nos ha generado una clase MyBenchMark que nos guía en la manera de generar un método de prueba de rendimiento. Sin embargo, nosotros vamos a crearnos una clase propia enfocada a probar el rendimiento de nuestros métodos de la clase Factorial para así poder compararlos. Por tanto, podemos borrar dicha clase y evitar que se muestre en nuestros resultados. La clase de prueba quedaría de la siguiente forma:

BenchmarkFactorial.java
package com.autentia.tutoriales.jmh;

import java.util.concurrent.TimeUnit;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;

public class BenchmarkFactorial {

    private static final int SMALL_NUMBER = 4;

    private static final int LONG_NUMBER = 30;

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.MICROSECONDS)
    public int testFactorialWithLoopForWithSmallNumber() {
        return Factorial.getFactorialFor(SMALL_NUMBER);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.MICROSECONDS)
    public int testFactorialWithRecursiveMethodWithSmallNumber() {
        return Factorial.getFactorialRec(4);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.MICROSECONDS)
    public int testFactorialWithLoopForWithLongNumber() {
        return Factorial.getFactorialFor(LONG_NUMBER);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.MICROSECONDS)
    public int testFactorialWithRecursiveMethodWithLongNumber() {
        return Factorial.getFactorialRec(LONG_NUMBER);
    }
}

Cabe destacar que los métodos de benchmark devuelven el resultado de la llamada a los métodos de Factorial, esto se ha hecho para evitar la eliminación de código muerto que realiza JMH como optimización para el compilador.

Vemos que los métodos de pruebas de rendimiento están anotados con @Benchmark. Además, se han incluido otras dos anotaciones, @BenchmarkMode y @OutputTimeUnit. En el punto 5 de este tutorial se explican los argumentos que se le pueden pasar a estas anotaciones, pero básicamente, en este caso estamos diciendo que el modo de test es de medida de la media del tiempo de ejecución de los métodos y que la unidad de medida serán microsegundos.

Ahora sólo nos queda ejecutar nuestros Benchmarks. Lo haremos por línea de comandos a través de la terminal, siguiendo las indicaciones oficiales del framework. El primer paso será construir el proyecto con el comando habitual de maven, habiéndonos posicionado en el directorio de nuestro proyecto:

$ cd ejemploJMH/
$ mvn clean install

Y ejecutamos los benchmarks de la siguiente forma:

$ java -jar target/benchmarks.jar

Llegados a este punto, os recomiendo que os entretengáis con otras cosas como por ejemplo bucear entre el gran catálogo de tutoriales de adictos al trabajo, ya que la ejecución tardará una buena cantidad de minutos.

¿Ya ha terminado? Genial, pues veremos una tabla de resultados parecida a la de la siguiente imagen:

Resultados del test

En vista de los resultados, podemos decir que calcular el factorial a través de un bucle for es más eficiente que con un método recursivo. Además, al aumentar el valor pasado por parámetro se nota un aumento de tiempo más considerable en dicho método recursivo que en el método con un bucle.

4. Alternativas de uso

En este ejemplo hemos visto como integrar JMH en un proyecto creado desde cero, ya que es lo que nos recomiendan sus creadores. Aún así, existe la posibilidad de integrarlo en un proyecto ya existente, incluyendo las dependencias y configuraciones necesarias en el pom de esta forma,. Adem´s, se facilita la posibilidad de configurar los benchmarks desde el propio IDE u otros métodos de build. Pero repetimos, nos aseguran que los resultados serán menos fiables.

Otra alternativa para ejecutar nuestros benchmarks es mediante un método main incluido en alguna clase de nuestro proyecto, por ejemplo la clase donde se alojan nuestros métodos benchmarks, e incluir un código de este estilo:

BenchmarkFactorial.java
...
Options opt = new OptionsBuilder()
                .include(".*" + YourClass.class.getSimpleName() + ".*")
                .forks(1)
                .build();
new Runner(opt).run();
...

5. Otras configuraciones

JMH nos ofrece un amplio abanico de configuraciones para nuestros benchmarks:

  • Modos de test. Mediante la anotación @BenchmarkMode podemos elegir entre los siguientes modos:
    • Mode.Throughput: Calcula el número de operaciones en una unidad de tiempo.
    • Mode.AverageTime: Calcula la media del tiempo de ejecución.
    • Mode.SampleTime: Calcula cuanto tarda en ejecutarse un método.
    • Mode.SingleShotTime: Ejecuta un método cada vez.
    • Cualquier conjunto de estos modos: Se pueden elegir varios de estos modos, por lo que el test se ejecutará varias veces.
    • Mode.All: Todos los modos, ejecutado uno detrás de otro.
  • Unidades de tiempo. Se puede especificar la unidad de tiempo mediante la anotación @OutputTimeUnit, cuyo argumento será de los tipos Java java.util.concurrent.TimeUnit.
  • Estados de los argumentos de test.Los métodos de test pueden aceptar argumentos. La anotación @State definir el ámbito en que una clase dada estará disponible. Ya que JMH permite ejecutar las pruebas en múltiples hilos de manera simultánea, hay que elegir el argumento correcto para esta anotación entre las siguientes:
  • Preparación y limpieza de estado.Al igual que JUnit tenemos anotaciones para inicializar el estado antes de ejecutar m´todos de test y para limpiar el estado tras su ejecución. Para JMH son @Setup y @TearDown.é/li>
  • Eliminación de código muerto.Se trata de un problema extendido entre los que realizan pruebas de microbenchmark. Para evitar problemas, se recomienda hacer que los métodos de prueba devuelvan siempre algo, es decir, evitar métodos void, tal y como hemos visto en el ejemplo.
  • Anotaciones de control de pruebas.Algunos de los parámetros de JMH se pueden definir a través de las siguientes anotaciones:
    • @Fork: Número de iteraciones a ejecutar.
    • @Measurement: Nos permite proporcionar los parámetros de una fase de pruebas en particular.
    • @Warmup: Nos permite proporcionar los parámetros para la fase de preparación.
    • @Threads: Número de hilos a usar para una prueba.

6. Conclusiones

JMH es una framework que nos permite realizar pruebas de microbenchmarking de una manera muy sencilla y devolviendo resultados muy fiables si seguimos las recomendaciones oficiales. Ponemos medir nuestros test con unidades de tiempo que van desde los segundos hasta los nanosegundos y nos permite configurar múltiples opciones a la hora de ejecutar nuestros tests. Os recomiendo estudiar todas estas opciones y ver cuales se ajustan más a vuestras necesidades.

En este tutorial hemos visto un ejemplo para comparar la eficiencia de dos métodos encargados de realizar la misma operación de dos maneras distintas, pero podemos usar JMH para realizar todo tipo de pruebas de rendimiento de nuestros métodos Java.

Por tanto, recomiendo utilizar este framework para estudiar nuestras aplicaciones sobretodo en puntos críticos, para localizar cuellos de botella, etc. Y recordad que ¡siempre es mejor medir que suponer!

7. Referencias

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad