Microbenchmarking con Java

1. Introduzione

Questo rapido articolo è incentrato su JMH (Java Microbenchmark Harness). Innanzitutto, acquisiamo familiarità con l'API e apprendiamo le sue basi. Quindi vedremmo alcune best practice che dovremmo considerare quando scriviamo microbenchmark.

In poche parole, JMH si occupa di cose come il riscaldamento della JVM e i percorsi di ottimizzazione del codice, rendendo il benchmarking il più semplice possibile.

2. Introduzione

Per iniziare, possiamo effettivamente continuare a lavorare con Java 8 e definire semplicemente le dipendenze:

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

Le ultime versioni di JMH Core e JMH Annotation Processor sono disponibili in Maven Central.

Successivamente, crea un semplice benchmark utilizzando l' annotazione @Benchmark (in qualsiasi classe pubblica):

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

Quindi aggiungiamo la classe principale che avvia il processo di benchmarking:

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

Ora l'esecuzione di BenchmarkRunner eseguirà il nostro benchmark probabilmente un po 'inutile. Una volta completata la corsa, viene presentata una tabella riassuntiva:

# Run complete. Total time: 00:06:45 Benchmark Mode Cnt Score Error Units BenchMark.init thrpt 200 3099210741.962 ± 17510507.589 ops/s

3. Tipi di benchmark

JMH supporta alcuni possibili benchmark: Throughput, AverageTime, SampleTime e SingleShotTime . Questi possono essere configurati tramite l' annotazione @BenchmarkMode :

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

La tabella risultante avrà una metrica del tempo medio (invece della velocità effettiva):

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

4. Configurazione del riscaldamento e dell'esecuzione

Utilizzando l' annotazione @Fork , possiamo impostare come avviene l'esecuzione del benchmark: il parametro value controlla quante volte verrà eseguito il benchmark e il parametro warmup controlla quante volte un benchmark verrà eseguito a secco prima che i risultati vengano raccolti, ad esempio :

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

Questo indica a JMH di eseguire due fork di riscaldamento e di scartare i risultati prima di passare al benchmarking in tempo reale.

Inoltre, l' annotazione @Warmup può essere utilizzata per controllare il numero di iterazioni di riscaldamento. Ad esempio, @Warmup (iterazioni = 5) dice a JMH che cinque iterazioni di riscaldamento saranno sufficienti, al contrario delle 20 predefinite.

5. Stato

Esaminiamo ora come un compito meno banale e più indicativo di benchmarking di un algoritmo di hashing può essere eseguito utilizzando State . Supponiamo di decidere di aggiungere una protezione extra dagli attacchi del dizionario a un database di password eseguendo l'hashing della password alcune centinaia di volte.

Possiamo esplorare l'impatto sulle prestazioni utilizzando un oggetto 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(); } }

Il nostro metodo di benchmark quindi sarà simile a:

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

Qui, le iterazioni del campo verranno popolate con valori appropriati dall'annotazione @Param da JMH quando viene passata al metodo benchmark. Il metodo annotato @Setup viene richiamato prima di ogni invocazione del benchmark e crea un nuovo Hasher che garantisce l'isolamento.

Al termine dell'esecuzione, otterremo un risultato simile a quello di seguito:

# 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. Eliminazione del codice morto

Quando si eseguono microbenchmark, è molto importante essere consapevoli delle ottimizzazioni . In caso contrario, potrebbero influenzare i risultati del benchmark in modo molto fuorviante.

Per rendere le cose un po 'più concrete, consideriamo un esempio:

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

Ci aspettiamo che l'allocazione degli oggetti costi più che non fare nulla. Tuttavia, se eseguiamo i benchmark:

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

Apparentemente trovare un posto nel TLAB, creare e inizializzare un oggetto è quasi gratuito! Solo guardando questi numeri, dovremmo sapere che qualcosa non torna del tutto qui.

Qui, siamo vittime dell'eliminazione del codice morto . I compilatori sono molto bravi a ottimizzare il codice ridondante. È un dato di fatto, questo è esattamente ciò che ha fatto qui il compilatore JIT.

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; }

Questa forma di valutazione parziale è chiamata ripiegamento costante . In questo caso, il ripiegamento costante evita completamente la chiamata Math.log , che era il punto centrale del benchmark.

Per evitare un ripiegamento costante, possiamo incapsulare lo stato costante all'interno di un oggetto di stato:

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

Se eseguiamo questi benchmark l'uno contro l'altro:

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

Apparentemente, il benchmark dei log sta facendo un lavoro serio rispetto al foldedLog , il che è ragionevole.

8. Conclusione

Questo tutorial si è concentrato e ha mostrato il micro cablaggio di benchmarking di Java.

Come sempre, è possibile trovare esempi di codice su GitHub.