Benchmark : conversion de long en byte[]

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 😉

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.