URL: https://linuxfr.org/users/cykl/journaux/openjdk-8-jep-142-false-sharing Title: OpenJDK 8, JEP 142 & False Sharing Authors: ckyl Date: 2014年04月02日T09:11:23+02:00 Tags: java, cpu, openjdk, jep et inside_openjdk Score: 30 Java 8 est sorti ce mois-ci; tu as même eu droit à une dépêche ici même qui parle des lambdas, la stream API etc. Cependant derrière ces gros changements qui impactent le monde hétérogène des devs Java, il y a des petits changements qui eux servent plutôt aux devs qui font des briques de base, de l'infra ou du code qui va vite. Je te propose donc d'explorer quelques _JDK Enhancement Proposals_ d'OpenJDK. Pour ce premier journal, on commence avec la [JEP 142](http://openjdk.java.net/jeps/142): __Reduce Cache Contention on Specified Fields__ soit l'annotation `@Contended` qui vise à proposer une solution aux problèmes de false sharing. ## C'est quoi le false sharing ? Le false sharing est un problème de performance en environnement parallèle qui est causé par une « leaky abstraction » du matériel. La présentation suivante est extrêmement grossière et ne vise qu'à faire comprendre le problème aux gens ne connaissant pas du tout le domaine. En tant que développeur on aime se représenter la mémoire comme un espace d'adressage continu. Plus on travaille dans un langage de haut niveau plus cela est vrai. Par exemple les problèmes d'alignement sont une notion totalement inconnue pour beaucoup. Cependant dans ce monde idéal la réalité du matériel refait surface périodiquement. Un CPU étant [beaucoup plus rapide](http://www.eecs.berkeley.edu/~rcs/research/interactive_latency.html) que la mémoire vive et le [principe de localité](http://en.wikipedia.org/wiki/Locality_of_reference) ayant été découvert, il dispose de [caches mémoire](http://en.wikipedia.org/wiki/CPU_cache). Ce sont des petits morceaux de mémoire tampon travaillant à une vitesse beaucoup plus proche du CPU. Le pari étant qu'une fois l'accès couteux à la mémoire centrale effectué cette valeur va être réutilisée et on ira alors la chercher dans ce cache et donc gagner beaucoup beaucoup de temps. Le problème du false sharing vient de deux choses: - L'architecture des caches. Un cache est composé d'un certain nombre de lignes de taille fixe (64 octets par exemple). Lorsqu'une modification est faite, elle affecte la ligne entière. - Le CPU doit gérer la cohérence entre ces caches à l’aide d’un [protocole de cohérence](http://en.wikipedia.org/wiki/Cache_coherence). Il s'agit de s'assurer que lorsqu'un CPU/core fait une modification elle sera visible par les autres. Chaque architecture à son propre modèle de cohérence, le X86 étant par exemple particulière fort. Ce modèle est exposé dans les langages de bas niveau, mais les langages de haut niveau décrivent souvent leur propre memory model. Charge au compilateur de traduire celui du langage vers celui de la plate-forme. Si l'on met ces deux choses ensembles, on arrive au false sharing: deux variables théoriquement indépendantes se retrouvent sur la même ligne de cache. Chacune est accédée/modifiée par un CPU distinct, cependant ils doivent passer leur temps à se synchroniser ce qui écroule les performances. Bref notre bel espace mémoire uniforme vient d'en prendre un coup. Coller ou espacer deux variables peut faire varier d'un à plusieurs ordre de grandeur la performance de notre structure de donnée. ## Exemple Commençons avec un benchmark très simple: Une classe avec un seul membre ainsi que de 4 threads. Le premier lit en permanence la valeur de ce membre, les trois autres ne font rien. Le benchmark est écrit avec [JMH](http://openjdk.java.net/projects/code-tools/jmh/) ```java @State(Scope.Benchmark) public static class StateNoFalseSharing { public int readOnly; } @GenerateMicroBenchmark @Group("noFalseSharing") public int reader(StateNoFalseSharing s) { return s.readOnly; } @GenerateMicroBenchmark @Group("noFalseSharing") public void noOp(StateNoFalseSharing s) { } ``` qui nous donne le résultat suivant: ``` Benchmark Mode Samples Mean Mean error Units g.c.Benchmarks.noFalseSharing:noOp avgt 18 0.297 0.002 ns/op g.c.Benchmarks.noFalseSharing:reader avgt 18 0.743 0.003 ns/op ``` Comme on pouvait s'y attendre c'est très rapide et on aura du mal à mesurer quelque chose de plus petit. Maintenant faisons évoluer notre benchmark. Nous ajoutons un deuxième membre qui va être accéder par les trois threads qui ne faisaient rien. Le premier thread ne change absolument pas et si les caches n'étaient pas organisés en ligne il n'y aurait aucune raison que sa performance soit affectée. ```java @State(Scope.Group) public static class StateFalseSharing { int readOnly; volatile int writeOnly; } @GenerateMicroBenchmark @Group("falseSharing") public int reader(StateFalseSharing s) { return s.readOnly; } @GenerateMicroBenchmark @Group("falseSharing") public int writer(StateFalseSharing s) { return s.writeOnly++; } ``` Regardons les résultats: ``` Benchmark Mode Samples Mean Mean error Units g.c.Benchmarks.falseSharing:reader avgt 18 5.038 0.617 ns/op g.c.Benchmarks.falseSharing:writer avgt 18 78.530 3.598 ns/op ``` On vient presque de prendre un facteur 10. Nous pouvons vérifier la disposition mémoire de notre objet `StateBaseline` avec [jol](http://openjdk.java.net/projects/code-tools/jol/) pour voir que nos deux variables ont bien été collées par le compilateur: ``` gist.contended.Benchmarks.StateFalseSharing object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 12 (object header) N/A 12 4 int StateFalseSharing.readOnly N/A 16 4 int StateFalseSharing.writeOnly N/A 20 4 (loss due to the next object alignment) Instance size: 24 bytes (estimated, the sample instance is not available) Space losses: 0 bytes internal + 4 bytes external = 4 bytes total ``` Sans rentrer dans les détails, statistiquement il y a de fortes chances qu'ils se retrouvent dans la même ligne de cache. ## @Contended La solution à notre problème est donc simplement d'espacer ces deux variables quitte à perdre de l'espace. Ça parait simple mais avant OpenJDK 8 cela demande de très sérieusement connaitre/lutter contre la VM. Fort du principe de localité, le comportement logique de la VM est d'essayer d'entasser autant que possible les différents membres comme bon lui semble. Le layout d'un objet peut changer selon beaucoup de critères et l'utilisation d'un GC n'aide pas puisqu'il peut décider de déplacer un peu tout et n’importe quoi (notamment les tableaux utilisés pour padder). Bref trouver une stratégie qui marche, est une source d'amusement inépuisable. Aleksey Shipilёv en a documenté quelques-unes dans un [benchmark JMH](http://hg.openjdk.java.net/code-tools/jmh/file/3b57fecd61bf/jmh-samples/src/main/java/org/openjdk/jmh/samples/JMHSample_22_FalseSharing.java) de même que [Martin Thompson](http://mechanical-sympathy.blogspot.fr/2011/08/false-sharing-java-7.html). La JEP 142 propose d'ajouter une annotation `@Contended` pour identifier les variables, ou classes, qui doivent se retrouver seules sur une ligne de cache pour éviter le false sharing. Essayons de l'utiliser: ```java @State(Scope.Group) public static class StateContended { int readOnly; @Contended volatile int writeOnly; } @GenerateMicroBenchmark @Group("contented") public int reader(StateContended s) { return s.readOnly; } @GenerateMicroBenchmark @Group("contented") public int writer(StateContended s) { return s.writeOnly++; } ``` Vérifions avec jol puis avec JMH ``` gist.contended.Benchmarks.StateContended object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 12 (object header) N/A 12 4 int StateContended.readOnly N/A 16 128 (alignment/padding gap) N/A 144 4 int StateContended.writeOnly N/A 148 4 (loss due to the next object alignment) Instance size: 152 bytes (estimated, the sample instance is not available) Space losses: 128 bytes internal + 4 bytes external = 132 bytes total Benchmark Mode Samples Mean Mean error Units g.c.Benchmarks.contented:reader avgt 18 0.742 0.006 ns/op g.c.Benchmarks.contented:writer avgt 18 70.811 3.572 ns/op ``` On observe que la variable a bien été décalée et on retrouve les performances initiales. ## Limitations `@Contended` est une JEP d'OpenJDK, c'est à dire qu'il ne s'agit pas d'une spécification de Java ou de la JVM. L'annotation se trouve dans un package privé d'Oracle et l'annotation n'est disponible que pour les classes du JDK par défaut (comme beaucoup de chose que le JDK se réserve précieusement). Si on veut l'utiliser, et donc se lier à OpenJDK, il faut passer l'option `-XX:-RestrictContended`. Bien entendu vu l'impact sur la consommation mémoire et la possibilité de réduire l’efficacité du cache il faut bien savoir ce qu'on fait et utiliser avec parcimonie. ## Comment détecter un cas de false sharing Notre exemple était très simple et nous connaissions le problème. Malheureusement dans la vraie vie ce n'est pas aussi évident et il n'existe pas à ma connaissance d'outil simple permettant de détecter le false sharing, quel que soit le langage. On peut suivre les conseils d'[Intel](http://software.intel.com/en-us/articles/avoiding-and-identifying-false-sharing-among-threads) et les appliquer avec leur outil ou avec [perf](http://blog.gmane.org/gmane.comp.linux.papi/month=20120201) mais ça reste assez empirique. Si on garde le principe du false sharing en tête, cela permet de surveiller les mauvais patterns dans les bouts d'infrastructure qui peuvent être affectés. En général il faut que ça commence à aller sérieusement vite, donc structure de données dédiée, pour que ça commence à devenir un problème. ## Cas courants Identiquement à notre exemple un même objet possède deux membres qui sont utilisés par deux threads différents. Ça arrive par exemple qu'en un objet tient des statistiques. Dans ce cas on va annoter le membre avec `@Contended`. On peut avoir aussi le cas de plusieurs instances d'une même classe qui préférerait être chacune être dans sa propre ligne de cache. Dans ce cas on va annoter la classe. Cela fonctionne aussi si l'on met les instances dans un tableau. Cas courant lorsque fait travailler plusieurs threads en parallèle. Le dernier cas est du calcul de type matriciel avec plusieurs threads. Dans ce cas l'annotation ne peut rien et il faut concevoir son algorithme pour en tenir compte (tout comme on itère dans le bon sens). [Dr doobs](http://www.drdobbs.com/parallel/eliminate-false-sharing/217500206) fourni un tel exemple. J’ai essayé de fournir quelques exemples dans le [benchmark](https://gist.github.com/cykl/9924619#file-src_main_java_gist_contended_benchmarks-java). ## Conclusion `@Contended` ne devrait pas changer la vie de grand monde hormis pour celle des gens qui pondent l’infra de service à haute performance. Mais elle ouvre la porte à une revendication de longue date : marier les bénéfices de la JVM avec les besoins des applications haute performance en ouvrant l’accès au matériel et à des techniques contre l’esprit initial de Java mais requises. Cette annotation ne répond absolument pas au besoin de pouvoir contrôler le layout d’un objet ou de choisir quels membres d’un objet doivent être regroupés ensemble. Il ne résout pas non plus les problèmes d’indirection dus aux références pour les objets. Mais la pression monte doucement avec le nombre d’application qui passe en off-the-heap ou en mmap lorsque nécessaire. Enfin le false sharing n'est pas le seul problème liés aux caches. Un autre [exemple](http://danluu.com/3c-conflict/). Et bien entendu exploiter les caches correctement a un impact certain sur les performances d'une application.

AltStyle によって変換されたページ (->オリジナル) /