Microbenchmarking avec Java

1. Introduction

Cet article rapide est axé sur JMH (le harnais Java Microbenchmark). Tout d'abord, nous nous familiarisons avec l'API et apprenons ses bases. Ensuite, nous verrions quelques bonnes pratiques à prendre en compte lors de la rédaction de microbenchmarks.

En termes simples, JMH prend en charge des tâches telles que le préchauffage de la JVM et les chemins d'optimisation du code, rendant ainsi l'analyse comparative aussi simple que possible.

2. Premiers pas

Pour commencer, nous pouvons continuer à travailler avec Java 8 et définir simplement les dépendances:

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

Les dernières versions du JMH Core et du JMH Annotation Processor se trouvent dans Maven Central.

Ensuite, créez un benchmark simple en utilisant l' annotation @Benchmark (dans n'importe quelle classe publique):

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

Ensuite, nous ajoutons la classe principale qui démarre le processus d'analyse comparative:

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

Maintenant, exécuter BenchmarkRunner exécutera notre benchmark sans doute quelque peu inutile. Une fois l'analyse terminée, un tableau récapitulatif est présenté:

# 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 de références

JMH prend en charge certains benchmarks possibles: Throughput, AverageTime, SampleTime et SingleShotTime . Ceux-ci peuvent être configurés via l' annotation @BenchmarkMode :

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

La table résultante aura une métrique de temps moyenne (au lieu du débit):

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

4. Configuration du préchauffage et de l'exécution

En utilisant l' annotation @Fork , nous pouvons configurer le déroulement de l'exécution du benchmark: le paramètre value contrôle combien de fois le benchmark sera exécuté et le paramètre warmup contrôle le nombre de fois qu'un benchmark sera exécuté avant que les résultats ne soient collectés, par exemple :

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

Cela demande à JMH d'exécuter deux fourches d'échauffement et de rejeter les résultats avant de passer à l'analyse comparative en temps réel.

En outre, l' annotation @Warmup peut être utilisée pour contrôler le nombre d'itérations de préchauffage. Par exemple, @Warmup (iterations = 5) indique à JMH que cinq itérations de préchauffage suffiront, par opposition à la valeur par défaut 20.

5. État

Examinons maintenant comment une tâche moins triviale et plus indicative de benchmarking d'un algorithme de hachage peut être effectuée en utilisant State . Supposons que nous décidions d'ajouter une protection supplémentaire contre les attaques par dictionnaire sur une base de données de mots de passe en hachant le mot de passe plusieurs centaines de fois.

Nous pouvons explorer l'impact sur les performances en utilisant un objet 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(); } }

Notre méthode de référence ressemblera alors à:

@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(); }

Ici, les itérations de champ seront remplies avec les valeurs appropriées de l' annotation @Param par le JMH lorsqu'elle est transmise à la méthode de référence. La méthode annotée @Setup est appelée avant chaque appel du benchmark et crée un nouveau Hasher assurant l'isolation.

Lorsque l'exécution est terminée, nous obtiendrons un résultat similaire à celui ci-dessous:

# 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. Élimination du code mort

Lors de l'exécution de microbenchmarks, il est très important d'être conscient des optimisations . Sinon, ils peuvent affecter les résultats de référence de manière très trompeuse.

Pour rendre les choses un peu plus concrètes, prenons un exemple:

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

Nous nous attendons à ce que l'attribution d'objets coûte plus cher que de ne rien faire du tout. Cependant, si nous exécutons les 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

Apparemment, trouver une place dans le TLAB, créer et initialiser un objet est presque gratuit! Rien qu'en regardant ces chiffres, nous devrions savoir que quelque chose ne s'additionne pas tout à fait ici.

Ici, nous sommes victimes de l'élimination du code mort . Les compilateurs sont très bons pour optimiser le code redondant. En fait, c'est exactement ce que le compilateur JIT a fait ici.

In order to prevent this optimization, we should somehow trick the compiler and make it think that the code is used by some other component. One way to achieve this is just to return the created object:

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

Also, we can let the Blackhole consume it:

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

Having Blackhole consume the object is a way to convince the JIT compiler to not apply the dead code elimination optimization. Anyway, if we run theses benchmarks again, the numbers would make more sense:

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

Let's consider yet another example:

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

Calculations based on constants may return the exact same output, regardless of the number of executions. Therefore, there is a pretty good chance that the JIT compiler will replace the logarithm function call with its result:

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

Cette forme d'évaluation partielle est appelée pliage constant . Dans ce cas, le pliage constant évite complètement l' appel Math.log , qui était tout le but du benchmark.

Afin d'éviter un pliage constant, nous pouvons encapsuler l'état constant à l'intérieur d'un objet d'état:

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

Si nous exécutons ces benchmarks les uns contre les autres:

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

Apparemment, le benchmark du journal fait un travail sérieux par rapport au foldedLog , ce qui est raisonnable.

8. Conclusion

Ce tutoriel s'est concentré sur et a présenté le harnais de micro-analyse comparative de Java.

Comme toujours, des exemples de code peuvent être trouvés sur GitHub.