Benchmark : conversion de long en byte[]
J’utilise beaucoup Kafka ces derniers temps, et dans Kafka, beaucoup de choses sont des tableaux de bytes, même les headers !
Comme j’ai de nombreux composants qui s’échangent des messages, j’ai ajouté des headers pour aider au suivi des messages, et entre autres un header timestamp
qui a comme valeur System.currentTimeMillis()
.
Il m’a donc fallut transformer un long
en tableau de byte; d’une manière très naïve, j’ai codé ça : String.valueOf(System.currentTimeMillis()).getBytes()
. Mais instancier une String
à chaque création de header ne me semble pas très optimale!
En cherchant un peu, Guava a une solution basée sur les opérations binaires (bitwise calculation) via la classe Longs
, ainsi que Kafka via son LongSerializer
. On peut aussi utiliser un ByteBuffer
pour réaliser la conversion.
Pour comparer les trois, rien de mieux que JMH – The Java Microbenchmark Harness. Cet outil nous permet d’écrire des micro-benchmarks pertinents en tenant compte des caractéristiques internes de la JVM. Il offre aussi des outils intégrés pour analyser les performances de nos tests (profiler, disassembler, …). Si vous ne connaissez pas JMH, vous pouvez vous référer à cet article : INTRODUCTION À JMH – JAVA MICROBENCHMARK HARNESS.
Le benchmark
Tout d’abord, j’ai configuré le benchmark avec un State au Thread pour que le setup soit joué pour chaque Thread. Entre autre, je créé un ByteBuffer
par thread pour comparer une implémentation avec et sans ré-utilisation du buffer.
@State(Scope.Thread) // other JMH annotations ... public class LongToByteArray { private static final LongSerializer LONG_SERIALIZER = new LongSerializer(); long timestamp; ByteBuffer perThreadBuffer; @Setup public void setup() { timestamp = System.currentTimeMillis(); perThreadBuffer = ByteBuffer.allocate(Long.BYTES); } // benchmark methods }
Ensuite, j’implémente une méthode de benchmark pour chaque manière de convertir un long
en byte[]
. J’ai implémenté deux algorithmes différents pour ByteBuffer
: l’un avec une instanciation d’un buffer à chaque conversion, et l’autre avec un recyclage d’un buffer existant en utilisant le ByteBuffer
instancié dans la phase de setup du benchmark.
@Benchmark public byte[] testStringValueOf() { return String.valueOf(timestamp).getBytes(); } @Benchmark public byte[] testGuava() { return Longs.toByteArray(timestamp); } @Benchmark public byte[] testKafkaSerde() { return LONG_SERIALIZER.serialize(null, timestamp); } @Benchmark public byte[] testByteBuffer() { ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES); buffer.putLong(timestamp); return buffer.array(); } @Benchmark public byte[] testByteBuffer_reuse() { perThreadBuffer.putLong(timestamp); byte[] result = perThreadBuffer.array(); perThreadBuffer.clear(); return result; }
Le benchmark complet est accessible ici.
Les résultats
Tous les tests ont été exécutés sur mon laptop : Intel(R) Core(TM) i7-8750H 6 coeurs (12 avec hyperthreading) – Ubuntu 19.10.
La version de Java utilisé est openjdk version "11.0.7" 2020-04-14
.
Benchmark Mode Cnt Score Error Units LongToByteArray.testByteBuffer avgt 5 4,429 ± 0,204 ns/op LongToByteArray.testByteBuffer_reuse avgt 5 5,655 ± 0,793 ns/op LongToByteArray.testGuava avgt 5 6,422 ± 0,428 ns/op LongToByteArray.testKafkaSerde avgt 5 9,103 ± 1,515 ns/op LongToByteArray.testStringValueOf avgt 5 39,660 ± 4,372 ns/op
Première constatation : mon intuition était bonne, instancier une String
pour chaque conversion est très mauvais, 4 à 10 fois plus long que toutes les autres implémentations. Quand on regarde le résultat de la conversion, on comprend pourquoi. En utilisant une String
on ne convertit plus un nombre sur 64bit mais une chaîne de caractères où chaque caractère (chaque chiffre du nombre) est codé sur un byte. Donc on ne compare pas exactement la même chose puisque le résultat de la conversion via une String
va donner un tableau de 13 bytes, alors qu’un Long
est encodé en 8 bytes, ce que nous donne bien la conversion via Guava, Kafka ou un ByteBuffer.
D’une manière surprenante Kafka, qui est connu pour sa performance, a une implémentation plus lente que Guava ou que celle via un ByteBuffer
.
Les résultats obtenus via ByteBuffer
sont surprenants, l’instanciation d’un ByteBuffer
pour chaque conversion est plus performante que la réutilisation d’un existant (qui nécessite un clean du buffer).
Analyse un peu plus poussée
Laissons de côté l’implémentation via une String
et essayons de mieux comprendre les différences entre les autres implémentations.
Pour cela je vais utiliser les capacités de profiling de JMH via l’option -prof
.
Si on profile les allocations mémoire via -prof gc
on a les résultats suivants :
LongToByteArray.testByteBuffer avgt 5 4,492 ± 0,708 ns/op LongToByteArray.testByteBuffer:·gc.alloc.rate avgt 5 4635,903 ± 712,889 MB/sec LongToByteArray.testByteBuffer_reuse avgt 5 5,798 ± 1,139 ns/op LongToByteArray.testByteBuffer_reuse:·gc.alloc.rate avgt 5 ≈ 10⁻⁴ MB/sec LongToByteArray.testGuava avgt 5 6,939 ± 0,899 ns/op LongToByteArray.testGuava:·gc.alloc.rate avgt 5 3000,818 ± 376,613 MB/sec LongToByteArray.testKafkaSerde avgt 5 9,317 ± 0,842 ns/op LongToByteArray.testKafkaSerde:·gc.alloc.rate avgt 5 4467,791 ± 405,897 MB/sec
On voit bien l’intérêt de la réutilisation du ByteBuffer
: il n’y a aucune allocation mémoire, alors qu’en créant un nouveau buffer pour chaque conversion, on a 4 GB/s d’allocation mémoire !
Par contre, les allocations mémoires sont proches pour les trois autres implémentations, cela ne nous donne donc pas beaucoup plus d’informations.
Essayons maintenant de profiler le CPU avec -prof perf
qui va utiliser l’outil perf pour profiler l’application.
Les résultats ne sont pas facilement compréhensibles (pour les voir c’est ici), quelques constatations :
- La ré-utilisation d’un
ByteBuffer
semble impliquer beaucoup plus de branches CPU, c’est peut-être la cause de la différence de performance. - L’implémentation Kafka semble impliquer plus de branches CPU que celle de Guava malgré qu’elle effectue moins d’instructions. À cause de ces branches, moins d’instructions peuvent être réalisées par cycle CPU. C’est certainement la raison qui fait que l’implémentation Guava est plus efficace.
Pour finir, par curiosité, j’ai été regarder le code de HeapByteBuffer.putLong()
, c’est l’implémentation utilisée via ByteBuffer
car je ne fais pas d’allocation directe. Celle-ci utilise une méthode Unsafe.putLongUnaligned()
. Unsafe
est connu pour ses implémentations hautement performantes (mais ne doit pas être utilisé par tout le monde), ici, de plus, cette méthode est annotée par @HotSpotIntrinsicCandidate
ce qui veut dire qu’un intrinsic existe certainement pour elle et peut expliquer sa différence de performance avec les autres implémentations. Un intrinsic peut être vu comme un bout de code natif, optimisé pour votre OS / architecture CPU, que la JVM va substituer à l’implémentation Java de la méthode selon certaines conditions.
Conclusion
Attention à ce que vous mesurez, l’implémentation via une String
ne génère pas le même tableau de bytes que les autres, et est donc beaucoup moins performante.
La réutilisation d’un ByteBuffer
n’est pas toujours la meilleure solution, car le coût de recyclage n’est pas anodin. Les allocations sont peu coûteuses au sein de la JVM, et parfois il vaut mieux allouer qu’exécuter plus d’instructions.
Follow the force, read the code 😉
Bien que JMH soit un super outil, il faut des compétences techniques et beaucoup de temps pour analyser totalement ses résultats. Même si les différences constatées ne sont pas totalement expliquées; je suis quand même content de ma petite expérimentation 😉