La «magie» de la JVM empêche-t-elle l'influence d'un programmeur sur les micro-optimisations en Java? J'ai récemment lu en C ++ parfois l'ordre des données des membres peut fournir des optimisations (accordées, dans l'environnement de microsecondes) et je présumais que les mains d'un programmeur sont liées quand il s'agit de réduire les performances de Java?
J'apprécie qu'un algorithme décent offre des gains de vitesse plus importants, mais une fois que vous avez le bon algorithme, Java est-il plus difficile à modifier en raison du contrôle JVM?
Sinon, les gens pourraient-ils donner des exemples des astuces que vous pouvez utiliser en Java (en plus des simples drapeaux de compilation).
java
c++
performance
latency
user997112
la source
la source
Réponses:
Bien sûr, au niveau de la micro-optimisation, la JVM fera certaines choses sur lesquelles vous aurez peu de contrôle par rapport au C et au C ++ en particulier.
D'un autre côté, la variété des comportements du compilateur avec C et C ++ en particulier aura un impact négatif beaucoup plus important sur votre capacité à faire des micro-optimisations de toute sorte de manière vaguement portable (même entre les révisions du compilateur).
Cela dépend du type de projet que vous modifiez, des environnements que vous ciblez, etc. Et en fin de compte, cela n'a pas vraiment d'importance car vous obtenez de quelques ordres de grandeur de meilleurs résultats de toutes façons optimisations algorithmiques / structure de données / conception de programme.
la source
Les micro-optimisations ne valent presque jamais le temps, et presque toutes les faciles sont effectuées automatiquement par les compilateurs et les runtimes.
Il existe cependant un domaine d'optimisation important où C ++ et Java sont fondamentalement différents, à savoir l'accès à la mémoire en bloc. C ++ dispose d'une gestion manuelle de la mémoire, ce qui signifie que vous pouvez optimiser la disposition des données de l'application et les modèles d'accès pour utiliser pleinement les caches. C'est assez difficile, quelque peu spécifique au matériel que vous utilisez (donc les gains de performances peuvent disparaître sur différents matériels), mais si cela est fait correctement, cela peut conduire à des performances absolument à couper le souffle. Bien sûr, vous payez pour cela avec le potentiel de toutes sortes de bugs horribles.
Avec un langage récupéré comme Java, ce type d'optimisations ne peut pas être fait dans le code. Certains peuvent être effectués par le runtime (automatiquement ou via la configuration, voir ci-dessous), et certains ne sont tout simplement pas possibles (le prix à payer pour être protégé contre les bogues de gestion de la mémoire).
Les drapeaux du compilateur ne sont pas pertinents en Java car le compilateur Java ne fait presque aucune optimisation; le runtime le fait.
Et en effet, les runtimes Java ont une multitude de paramètres qui peuvent être modifiés, en particulier concernant le garbage collector. Il n'y a rien de "simple" dans ces options - les valeurs par défaut sont bonnes pour la plupart des applications, et pour obtenir de meilleures performances, vous devez comprendre exactement ce que font les options et le comportement de votre application.
la source
Les micro-secondes s'additionnent si nous bouclons des millions à des milliards de choses. Une session vtune / micro-optimisation personnelle en C ++ (pas d'améliorations algorithmiques):
Tout en dehors du "multithreading", "SIMD" (écrit à la main pour battre le compilateur), et l'optimisation du patch à 4 valences étaient des optimisations de mémoire au niveau micro. De plus, le code d'origine à partir des temps initiaux de 32 secondes était déjà un peu optimisé (complexité algorithmique théoriquement optimale) et il s'agit d'une session récente. La version originale bien avant cette récente session a pris plus de 5 minutes à traiter.
L'optimisation de l'efficacité de la mémoire peut souvent aider de plusieurs fois à des ordres de grandeur dans un contexte à un seul thread, et plus dans des contextes multithreads (les avantages d'un représentant de mémoire efficace se multiplient souvent avec plusieurs threads dans le mélange).
Sur l'importance de la micro-optimisation
Je suis un peu agité par cette idée que les micro-optimisations sont une perte de temps. Je conviens que c'est un bon conseil général, mais tout le monde ne le fait pas incorrectement en se basant sur des intuitions et des superstitions plutôt que sur des mesures. Fait correctement, il ne produit pas nécessairement un micro impact. Si nous prenons le propre Embree d'Intel (noyau de lancer de rayons) et testons uniquement le BVH scalaire simple qu'ils ont écrit (pas le paquet de rayons qui est exponentiellement plus difficile à battre), puis essayons de battre les performances de cette structure de données, cela peut être un plus une expérience humiliante même pour un vétéran habitué au profilage et au réglage du code pendant des décennies. Et tout cela grâce aux micro-optimisations appliquées. Leur solution peut traiter plus de cent millions de rayons par seconde lorsque j'ai vu des professionnels de l'industrie du raytracing qui peuvent '
Il n'y a aucun moyen de prendre une implémentation simple d'un BVH avec seulement une focalisation algorithmique et d'en tirer plus de cent millions d'intersections de rayons primaires par seconde contre tout compilateur d'optimisation (même le propre ICC d'Intel). Un simple n'obtient souvent même pas un million de rayons par seconde. Il faut des solutions de qualité professionnelle pour obtenir souvent même quelques millions de rayons par seconde. Il faut une micro-optimisation au niveau Intel pour obtenir plus de cent millions de rayons par seconde.
Des algorithmes
Je pense que la micro-optimisation n'est pas importante tant que les performances ne sont pas importantes au niveau des minutes à secondes, par exemple, ou des heures à minutes. Si nous prenons un algorithme horrible comme le tri à bulles et que nous l'utilisons sur une entrée de masse comme exemple, puis le comparons à une implémentation même de base du tri par fusion, le premier peut prendre des mois à traiter, le dernier peut-être 12 minutes, par conséquent de la complexité quadratique vs linéaireithmique.
La différence entre les mois et les minutes va probablement amener la plupart des gens, même ceux qui ne travaillent pas dans des domaines critiques pour les performances, à considérer le temps d'exécution comme inacceptable s'il nécessite que les utilisateurs attendent des mois pour obtenir un résultat.
Pendant ce temps, si nous comparons le tri par fusion simple et non micro-optimisé au tri rapide (qui n'est pas du tout supérieur sur le plan algorithmique au tri par fusion, et ne propose que des améliorations au niveau micro pour la localité de référence), le tri rapide micro-optimisé pourrait se terminer dans 15 secondes au lieu de 12 minutes. Faire patienter 12 minutes pourrait être parfaitement acceptable (type de pause-café).
Je pense que cette différence est probablement négligeable pour la plupart des gens entre, disons, 12 minutes et 15 secondes, et c'est pourquoi la micro-optimisation est souvent considérée comme inutile car elle ne ressemble souvent qu'à la différence entre les minutes et les secondes, et non les minutes et les mois. L'autre raison pour laquelle je pense que cela est inutile est qu'il est souvent appliqué à des zones qui n'ont pas d'importance: une petite zone qui n'est même pas bouclée et critique, ce qui donne une différence discutable de 1% (qui peut très bien être simplement du bruit). Mais pour les personnes qui se soucient de ces types de différences de temps et qui sont prêtes à mesurer et à bien faire, je pense qu'il vaut la peine de prêter attention au moins aux concepts de base de la hiérarchie de la mémoire (en particulier les niveaux supérieurs relatifs aux défauts de page et aux échecs de cache) .
Java laisse beaucoup de place à de bonnes micro-optimisations
Ouf, désolé - avec ce genre de diatribe de côté:
Un peu mais pas autant que les gens pourraient penser si vous le faites correctement. Par exemple, si vous effectuez un traitement d'image, en code natif avec SIMD manuscrit, multithreading et optimisations de mémoire (modèles d'accès et éventuellement même représentation en fonction de l'algorithme de traitement d'image), il est facile de croiser des centaines de millions de pixels par seconde pendant 32- pixels RGBA (canaux couleur 8 bits) et parfois même des milliards par seconde.
Il est impossible de se rapprocher de Java si vous dites que vous avez créé un
Pixel
objet (cela seul ferait gonfler la taille d'un pixel de 4 octets à 16 sur 64 bits).Mais vous pourriez être en mesure de vous rapprocher beaucoup plus si vous évitiez l'
Pixel
objet, utilisiez un tableau d'octets et modélisiez unImage
objet. Java est encore assez compétent si vous commencez à utiliser des tableaux de données anciennes et simples. J'ai déjà essayé ce genre de choses en Java et j'ai été très impressionné à condition que vous ne créiez pas un tas de petits objets minuscules partout qui soient 4 fois plus gros que la normale (ex: utilisezint
au lieu deInteger
) et que vous commenciez à modéliser des interfaces en vrac comme unImage
interface, pasPixel
interface. Je me risquerais même à dire que Java peut rivaliser avec les performances C ++ si vous faites une boucle sur de vieilles données simples et non sur des objets (énormes tableaux defloat
, par exemple, nonFloat
).Peut-être encore plus important que les tailles de mémoire est qu'un tableau de
int
garantit une représentation contiguë. Un tableau deInteger
ne fonctionne pas. La contiguïté est souvent essentielle pour la localité de référence, car elle signifie que plusieurs éléments (ex: 16ints
) peuvent tous s'insérer dans une seule ligne de cache et être potentiellement accessibles ensemble avant l'expulsion avec des modèles d'accès à la mémoire efficaces. Pendant ce temps, un seulInteger
peut être bloqué quelque part dans la mémoire, la mémoire environnante n'étant pas pertinente, uniquement pour que cette région de mémoire soit chargée dans une ligne de cache uniquement pour utiliser un seul entier avant l'expulsion, par opposition à 16 entiers. Même si nous avons été merveilleusement chanceux et entourésIntegers
étaient tous les uns à côté des autres en mémoire, nous ne pouvons insérer que 4 dans une ligne de cache accessible avant l'expulsion car elleInteger
est 4 fois plus grande, et c'est dans le meilleur des cas.Et il y a beaucoup de micro-optimisations à réaliser car nous sommes unifiés sous la même architecture / hiérarchie de mémoire. Peu importe la langue que vous utilisez, les modèles d'accès à la mémoire importent, des concepts comme le tuilage / blocage de boucle peuvent généralement être appliqués beaucoup plus souvent en C ou C ++, mais ils bénéficient tout autant à Java.
L'ordre des membres des données n'a généralement pas d'importance en Java, mais c'est surtout une bonne chose. En C et C ++, la préservation de l'ordre des membres des données est souvent importante pour des raisons ABI afin que les compilateurs ne s'en occupent pas. Les développeurs humains qui y travaillent doivent faire attention à faire des choses comme organiser leurs membres de données dans l'ordre décroissant (du plus grand au plus petit) pour éviter de gaspiller de la mémoire lors du remplissage. Avec Java, le JIT peut apparemment réorganiser les membres pour vous à la volée afin d'assurer un alignement correct tout en minimisant le remplissage, donc à condition que ce soit le cas, il automatise quelque chose que les programmeurs C et C ++ moyens peuvent souvent mal faire et finissent par gaspiller de la mémoire de cette façon ( ce qui ne fait pas que gaspiller de la mémoire, mais souvent une perte de vitesse en augmentant inutilement la foulée entre les structures AoS et en provoquant plus de ratés de cache). Il' C'est une chose très robotique de réorganiser les champs pour minimiser le rembourrage, donc idéalement, les humains ne s'en occupent pas. La seule fois où l'agencement des champs peut avoir une importance qui nécessite qu'un humain connaisse l'arrangement optimal est si l'objet est plus grand que 64 octets et que nous organisons les champs en fonction du modèle d'accès (pas de remplissage optimal) - auquel cas il pourrait être une entreprise plus humaine (nécessite la compréhension des chemins critiques, dont certains sont des informations qu'un compilateur ne peut pas anticiper sans savoir ce que les utilisateurs feront du logiciel).
La plus grande différence pour moi en termes de mentalité d'optimisation entre Java et C ++ est que C ++ pourrait vous permettre d'utiliser un peu (minuscule) les objets plus que Java dans un scénario critique en termes de performances. Par exemple, C ++ peut encapsuler un entier dans une classe sans aucune surcharge (référencée partout). Java doit avoir cette surcharge de style de pointeur de métadonnées + alignement par objet, c'est pourquoi il
Boolean
est plus grand queboolean
(mais en échange, il offre des avantages uniformes de réflexion et la possibilité de remplacer toute fonction non marquée commefinal
pour chaque UDT).Il est un peu plus facile en C ++ de contrôler la contiguïté des dispositions de mémoire sur des champs non homogènes (ex: entrelacement flottants et entiers dans un tableau via une structure / classe), car la localité spatiale est souvent perdue (ou du moins le contrôle est perdu) en Java lors de l'allocation d'objets via le GC.
... mais souvent les solutions les plus performantes les séparent de toute façon et utilisent un modèle d'accès SoA sur des tableaux contigus d'anciennes données simples. Donc, pour les domaines qui nécessitent des performances optimales, les stratégies pour optimiser la disposition de la mémoire entre Java et C ++ sont souvent les mêmes, et vous obligeront souvent à démolir ces minuscules interfaces orientées objet au profit d'interfaces de style collection qui peuvent faire des choses comme hot / division de champ froid, représentants SoA, etc. Les représentants AoSoA non homogènes semblent plutôt impossibles en Java (sauf si vous venez d'utiliser un tableau brut d'octets ou quelque chose comme ça), mais ce sont pour de rares cas où les deuxles modèles d'accès séquentiel et aléatoire doivent être rapides tout en ayant simultanément un mélange de types de champs pour les champs chauds. Pour moi, la majeure partie de la différence de stratégie d'optimisation (au niveau général) entre ces deux est théorique si vous atteignez des performances de pointe.
Les différences varient un peu plus si vous recherchez simplement de "bonnes" performances - ne pas pouvoir faire autant avec de petits objets comme
Integer
vsint
peut être un peu plus d'un PITA, en particulier avec la façon dont il interagit avec les génériques . Il est un peu plus difficile de créer une seule structure de données générique en tant que cible d'optimisation centrale en Java qui fonctionne pourint
,float
etc., tout en évitant les UDT plus grandes et coûteuses, mais souvent les zones les plus critiques en termes de performances nécessiteront de rouler à la main vos propres structures de données réglé pour un but très spécifique de toute façon, donc ce n'est ennuyeux que pour le code qui recherche de bonnes performances mais pas des performances de pointe.Overhead d'objet
Notez que la surcharge des objets Java (métadonnées et perte de localité spatiale et perte temporaire de localité temporelle après un cycle GC initial) est souvent importante pour les choses qui sont vraiment petites (comme
int
vsInteger
) qui sont stockées par millions dans une structure de données qui est largement contiguë et accessible en boucles très serrées. Il semble y avoir beaucoup de sensibilité à ce sujet, donc je dois préciser que vous ne voulez pas vous soucier de la surcharge des objets pour les gros objets comme les images, juste des objets vraiment minuscules comme un seul pixel.Si quelqu'un doute de cette partie, je suggérerais de faire un point de référence entre résumer un million au hasard
ints
contre un million au hasardIntegers
et le faire à plusieurs reprises (leIntegers
remaniement en mémoire après un premier cycle de GC).Astuce ultime: des conceptions d'interface qui laissent la place à l'optimisation
Donc, l'astuce Java ultime telle que je la vois si vous avez affaire à un endroit qui gère une lourde charge sur de petits objets (ex: a
Pixel
, un vecteur à 4 vecteurs, une matrice 4x4, unParticle
, peut-être même unAccount
s'il ne dispose que de quelques petits champs) est d'éviter d'utiliser des objets pour ces petites choses et d'utiliser des tableaux (éventuellement enchaînés) de vieilles données simples. Les objets deviennent alors des interfaces de collecte commeImage
,ParticleSystem
,Accounts
, une collection de matrices ou des vecteurs, etc. individuels sont accessibles par index, par exemple Ceci est aussi l' une des astuces de conception ultime en C et C ++, puisque même sans que les frais généraux d'objets de base et mémoire disjointe, la modélisation de l'interface au niveau d'une seule particule empêche les solutions les plus efficaces.la source
user204677
est parti. Une si bonne réponse.Il y a une zone médiane entre la micro-optimisation, d'une part, et le bon choix d'algorithme, d'autre part.
C'est le domaine des accélérations à facteur constant, et il peut donner des ordres de grandeur.
Pour ce faire, il faut interrompre des fractions entières du temps d'exécution, comme 30%, puis 20% de ce qui reste, puis 50%, et ainsi de suite pendant plusieurs itérations, jusqu'à ce qu'il ne reste presque plus rien.
Vous ne voyez pas cela dans les petits programmes de style démo. Où vous le voyez, c'est dans de gros programmes sérieux avec beaucoup de structures de données de classe, où la pile d'appels est généralement profonde de plusieurs couches. Un bon moyen de trouver les opportunités d'accélération consiste à examiner des échantillons aléatoires de l'état du programme.
Généralement, les accélérations se composent de choses comme:
minimiser les appels à
new
en regroupant et en réutilisant d'anciens objets,reconnaître les choses qui sont faites qui sont en quelque sorte là pour la généralité, plutôt que d'être réellement nécessaires,
réviser la structure des données en utilisant différentes classes de collecte qui ont le même comportement big-O mais tirent parti des modèles d'accès réellement utilisés,
enregistrer les données qui ont été acquises par des appels de fonction au lieu de ré-appeler la fonction, (C'est une tendance naturelle et amusante des programmeurs de supposer que les fonctions ayant des noms plus courts s'exécutent plus rapidement.)
tolérer une certaine incohérence entre les structures de données redondantes, au lieu d'essayer de les garder entièrement cohérentes avec les événements de notification,
etc.
Mais bien sûr, rien de tout cela ne devrait être fait sans qu'il soit d'abord démontré qu'il y a des problèmes en prélevant des échantillons.
la source
Java (pour autant que je sache) ne vous donne aucun contrôle sur les emplacements des variables en mémoire, vous avez donc plus de mal à éviter des choses comme le faux partage et l'alignement des variables (vous pouvez compléter une classe avec plusieurs membres inutilisés). Une autre chose dont je ne pense pas que vous puissiez tirer parti est des instructions telles que
mmpause
, mais ces choses sont spécifiques au CPU et donc si vous pensez que vous en avez besoin, Java n'est peut-être pas le langage à utiliser.Il existe la classe Unsafe qui vous donne la flexibilité de C / C ++ mais aussi avec le danger de C / C ++.
Cela peut vous aider à regarder le code assembleur que la JVM génère pour votre code
Pour en savoir plus sur une application Java qui examine ce genre de détails, consultez le code Disruptor publié par LMAX
la source
Il est très difficile de répondre à cette question, car cela dépend des implémentations du langage.
En général, il y a très peu de place pour de telles «micro-optimisations» de nos jours. La raison principale est que les compilateurs profitent de telles optimisations lors de la compilation. Par exemple, il n'y a pas de différence de performances entre les opérateurs pré-incrément et post-incrément dans les situations où leur sémantique est identique. Un autre exemple serait par exemple une boucle comme celle-ci
for(int i=0; i<vec.size(); i++)
où l'on pourrait argumenter qu'au lieu d'appeler lesize()
fonction membre lors de chaque itération il serait préférable d'obtenir la taille du vecteur avant la boucle puis de comparer par rapport à cette variable unique et d'éviter ainsi la fonction d'un appel par itération. Cependant, il existe des cas dans lesquels un compilateur détectera ce cas idiot et mettra en cache le résultat. Cependant, cela n'est possible que lorsque la fonction n'a pas d'effets secondaires et que le compilateur peut être sûr que la taille du vecteur reste constante pendant la boucle, de sorte qu'elle ne s'applique qu'à des cas assez triviaux.la source
const
méthodes sur ce vecteur, je suis sûr que de nombreux compilateurs d'optimisation le comprendront.Outre les améliorations des algorithmes, assurez-vous de tenir compte de la hiérarchie de la mémoire et de la façon dont le processeur s'en sert. Il y a de gros avantages à réduire les latences d'accès à la mémoire, une fois que vous comprenez comment la langue en question alloue la mémoire à ses types de données et objets.
Exemple Java pour accéder à un tableau de 1000 x 1000 pouces
Considérez l'exemple de code ci-dessous - il accède à la même zone de mémoire (un tableau 1000x1000 d'entiers), mais dans un ordre différent. Sur mon mac mini (Core i7, 2,7 GHz), la sortie est la suivante, montrant que la traversée du tableau par lignes fait plus que doubler les performances (moyenne sur 100 tours chacune).
Cela est dû au fait que le tableau est stocké de telle sorte que les colonnes consécutives (c'est-à-dire les valeurs int) sont placées adjacentes en mémoire, contrairement aux lignes consécutives. Pour que le processeur utilise réellement les données, elles doivent être transférées dans ses caches. Le transfert de mémoire se fait par un bloc d'octets, appelé ligne de cache - le chargement d'une ligne de cache directement depuis la mémoire introduit des latences et diminue ainsi les performances d'un programme.
Pour le Core i7 (pont de sable), une ligne de cache contient 64 octets, donc chaque accès à la mémoire récupère 64 octets. Étant donné que le premier test accède à la mémoire dans une séquence prévisible, le processeur prélèvera les données avant qu'elles ne soient réellement consommées par le programme. Globalement, cela se traduit par moins de latence sur les accès mémoire et améliore ainsi les performances.
Code d'échantillon:
la source
La JVM peut interférer et souvent, et le compilateur JIT peut changer considérablement entre les versions.Certaines micro-optimisations sont impossibles en Java en raison de limitations linguistiques, telles que l'hyper-threading friendly ou la dernière collection SIMD des processeurs Intel.
Il est recommandé de lire un blog très informatif sur le sujet, rédigé par l'un des auteurs de Disruptor :
Il faut toujours se demander pourquoi s'embêter à utiliser Java si vous voulez des micro-optimisations, il existe de nombreuses méthodes alternatives pour accélérer une fonction comme utiliser JNA ou JNI pour passer sur une bibliothèque native.
la source