Introducción a Java Microbenchmark Harness

Java Microbenchmark Harness

Java Microbenchmark Harness


En este artículo vamos a ver una Introducción a Java Microbenchmark Harness. Veremos su configuración y sus principales características para poder optimizar y sacar el mejor rendimiento posible de nuestras aplicaciones.

¿Qué es Java Microbenchmark Harness o JMH?

Java Microbenchmark Harness o JMH es la mejor manera de analizar y comparar nuestro código teniendo en cuenta optimizaciones de código y el uso de la JVM. Es ideal para ver el comportamiento de nuestra aplicación para diferentes entornos a través de la ejecución de varias ejecuciones de benchmarks.

¿Por qué utilizar Java Microbenchmark Harness?

Vamos a ver motivos por lo que considerar utilizar JMH:

  1. Precisión y control: JMH proporciona una infraestructura robusta y precisa para medir el rendimiento de tu código Java. Proporciona mecanismos para eliminar el ruido y las interferencias externas, lo que te permite obtener mediciones precisas y confiables.
  2. Eliminación de optimizaciones prematuras: JMH se encarga de las optimizaciones del compilador y las JIT (Just-In-Time) para asegurarse de que las mediciones se realicen en condiciones realistas. Esto evita que realices optimizaciones prematuras basadas en mediciones incorrectas o sesgadas.
  3. Configuración sencilla: JMH proporciona una API fácil de usar para configurar y ejecutar pruebas de rendimiento. Te permite especificar los parámetros de las pruebas, como el número de iteraciones, la cantidad de hilos y el tiempo de calentamiento.
  4. Generación automática de código de prueba: JMH puede generar automáticamente el código necesario para ejecutar las pruebas de rendimiento. Esto ahorra tiempo y esfuerzo al escribir código repetitivo y te permite centrarte en el análisis y la interpretación de los resultados.
  5. Integración con herramientas de construcción y marcos de prueba: JMH se puede integrar fácilmente con herramientas de construcción como Maven o Gradle, lo que facilita la ejecución de pruebas de rendimiento como parte de tu proceso de compilación y pruebas. También es compatible con marcos de prueba como JUnit, lo que te permite combinar pruebas de rendimiento con otras pruebas unitarias.

Configuración de JMH

Vamos a ver en introducción a Java Microbenchmark Harness como realizar la configuración en nuestra aplicación.

Desde la versión 12 de la JDK viene incluido, pero hasta esa versión es necesario añadir dependencias manualmente. Por lo que si estas en una versión anterior a la 12 deberas de añadir las siguientes dependencias:

Lo que al añadirlas a nuestro pom.xml quedaría de la siguiente forma:

<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>${jmh-core.version}</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>${jmh-generator-process.version}</version>
</dependency>

Arrancar JMH

Una vez hemos añadido las dependencias necesarias a nuestro proyecto, crear la configuración Java necesaria es tan sencillo como hacer uso de la anotación @Benchmark y crear un main:

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

Una vez hemos añadido la anotación @Benchmark ejecutamos el método y podremos ver que por defecto vamos a tener:

# Blackhole mode: compiler (auto-detected, use -Djmh.blackhole.autoDetect=false to disable)
# Warmup: 5 iterations, 10 s each
# Measurement: 5 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: com.refactorizando.example.detachentity.BenchmarkRunner.init

Y obtendremos un informe similar a la siguiente captura:

Informe JHM simple | Introducción a Java Microbenchmark Harness

Tipos de BenchMark

Podemos aplicar diferentes tipo en función de nuestras necesidades haciendo uso de la anotación @BenchMarkMode:

  • Throughpout: Throughput, ops/time
  • AverageTime: Tiempo promedio, time/op
  • SampleTime: Tiempo de muestreo
  • SingleShotTime: Una única invocación.
  • All: Todos los modos anteriores

Por ejemplo:

@Benchmark
@BenchmarkMode(Mode.SingleShotTime)
public void init() {
    
}

Configurar número de ejecuciones y Warmup

En la ejecución anterior hemos tenido, por defecto, un fork de 5, es decir el número de veces que el benchmark será ejecutado, y un Warmup también de 5 que indica el número de veces que el benchmark se ejecuta antes de recopilar los resultados:

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

Con la anotación @Fork indicamos como van a suceder las ejecuciones:

  • value: Cuantas veces el benchmark será ejecutado.
  • warmup: Cuantas veces el benchmark será ejecutado antes de recopilar resultados.

En el caso anterior estamos diciendo que 2 iteracciones de warm-up serán suficientes, en lugar de las 20 por defecto.

Evaluación de rendimiento con State

Vamos a ver como podemos realizar el rendimiento de un algoritmo haciendo uso de State.

A veces queremos inicializar algunas variables en el código del benchmark, pero no queremos que formen parte del código que se va a analizar. Estas variables se llaman variables «estado» (state). Las variables de estado se declaran en clases especiales de estado, y una instancia de esa clase de estado puede proporcionarse como parámetro al método del benchmark. Vamos a verlo con un ejemplo:

public class MyBenchmark {

    @State(Scope.Thread)
    public static class MyState {
        public int x = 1;
        public int y = 2;
        public int sum ;
    }


    @Benchmark @BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.MINUTES)
    public void testMethod(MyState state) {
        state.sum = state.x + state.y;
    }

}

En el ejemplo anterior hemos añadido una clase estática que se llama MyState, la cual lleva la anotación @State.

El objeto State nos ofrece diferentes Scopes:

  • Thread: Cada hilo que ejecuta la prueba de rendimiento creará su propia instancia del objeto de estado.
  • Group: Cada grupo de hilos que ejecuta la prueba de rendimiento creará su propia instancia del objeto de estado.
  • Benchmark: Todos los hilos que ejecutan la prueba de rendimiento comparten el mismo objeto de estado.

Uso de @Setup y @Teardown con State

Cuando hacemos uso de @State podemos añadir @Setup y @TearDown para realizar configuraciones.

Con @Setup le indicamos a la JMH que el método debe ser llamado para configurar el objeto State antes de comenzar el benchmark. Y con @TearDown se le indica que después del benchmark este método será llamado.

Además dentro de la anotación @Setup podemos tener 3 configuraciones diferentes:

  • Level.Trial: El método se llama una vez por cada vez que se ejecuta por completo la prueba de rendimiento. Una ejecución completa implica un «fork» completo, incluyendo todas las iteraciones de calentamiento y de la prueba de rendimiento.
  • Level.Iteration: El método se llama una vez por cada iteración de la prueba de rendimiento.
  • Level.Invocation: El método se llama una vez por cada llamada al método de la prueba de rendimiento.
public class MyBenchmark {

    @State(Scope.Thread)
    public static class MyState {

        @Setup(Level.Trial)
        public void doSetup() {
           System.out.println("Init .....");
 
            sum = 0;
           
        }

        @TearDown(Level.Trial)
        public void doTearDown() {
            System.out.println("TearDown");
        }

        public int x = 1;
        public int y = 2;
        public int sum ;
    }

    @Benchmark @BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.MINUTES)
    public void testMethod(MyState state) {
        state.sum = state.x + state.y;
    }
}

Optimización de Benchmark

En un principio la configuración de nuestro benchmark debería ser sencillo podemos tener algún problema de optimización debido a la JVM.

Para obtener una major optimización podemos realizar algunas mejoras como las siguientes:

Usar @State para evitar Constant Folding

Uno de los errores al crear pruebas de rendimiento es utilizar valores constantes de variables en cálculos. Cuando realizamos cálculos en esos valores constantes, el resultado de la computación también es constante en todo momento. Para ello la JVM es capaz de identificar dichas llamadas, omitir por completo los cálculos y utilizar directamente el valor calculado.

Por ejemplo:

public static double add() {
    double x = 1;
    double y = 1;

    return x + y;
}

Esto es lo que se llama constant folding, ya que la JVM ha identificado la operación y cambiado el valor a constante.

public static double add() {
    return 2;
}

Para solucionar el problema de constant folding podemos hacer uso de la anotación @State. La anotación @State no permite meter dentro de nuestro método argumentos.

@State(Scope.Benchmark)
public static class Params {
    public double a = 1;
    public double b = 1;
}

@Benchmark
public static double add(Params params) {
    return params.a + params.b;
}

Eliminación de código muerto

Cuando estamos realizando nuestros métodos podemos crear líneas de código que no nos aportan absolutamente nada en nuestro benchmark. En esos casos el compilador se encarga de eliminar esas líneas cuando compilamos, esto es lo que se conoce como dead code.

Vamos a explicar el concepto de dead code con un ejemplo:

@Benchmark
public static double add(Params params) {
    new Object();
    return params.a + params.b;
}

En el siguiente código vamos a hacer uso del objeto Blackhole, el cual permite que se ejecute la instrucción que queremos al compilar aunque sea código que no se usa.

@Benchmark
public static double add(Params params, Blackhole blackhole) {
    blackhole.consume(new Object());
    return params.a + params.b;
}

Incluir y excluir llamadas a métodos haciendo uso de @CompilerControl

En algunas ocasiones puede que necesitemos evitar la compilación de un método en concreto, para ello podemos hacer uso de la anotación @CompilerControl. La anotación @CompilerControl permite al compilador que se compile o no un método del código.

@Benchmark
public static double add(Params params, Blackhole blackhole) {
    return params.a + params.b + someOtherMethod();
}

@CompilerControl(CompilerControl.Mode.INLINE)
private double someOtherMethod(Params params){
    return params.a * params.b;
}

Conclusión

En este artículo sobre introducción a Java Microbenchmark Harness, hemos visto como podemos hacer uso de JMH para poder optimizar y sacar un mejor rendimiento a nuestras aplicaciones.

En resumen, JMH es una herramienta poderosa y confiable para medir el rendimiento en Java. Proporciona una infraestructura sólida, controles precisos y una configuración sencilla, lo que te permite obtener mediciones confiables y realizar análisis exhaustivos del rendimiento de tu código.

Si necesitas más información puedes escribirnos un comentario o un correo electrónico a refactorizando.web@gmail.com o también nos puedes contactar por nuestras redes sociales Facebook o twitter y te ayudaremos encantados!


Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *