Les approches contre la base de code deviennent uniformément lentes

11

Nous travaillons sur une base de code C ++ de taille moyenne (10Mloc) qui, grâce à nos efforts d'optimisation, devient uniformément lente .

Cette base de code est un ensemble de bibliothèques que nous combinons pour les mettre au travail. Lorsque le cadre général de la façon dont ces bibliothèques communiquent a été développé, l'accent a été mis sur les performances et plus tard, lorsque davantage de parties ont été ajoutées, le cadre général n'a pas beaucoup changé. L'optimisation a été effectuée en cas de besoin et à mesure que notre matériel évoluait. Cela a rendu la décision précoce coûteuse apparente seulement beaucoup plus tard. Nous sommes maintenant à un point où de nouvelles optimisations sont beaucoup plus coûteuses car elles nécessiteraient la réécriture de grandes parties de la base de code. Nous nous trouvons à l'approche d'un minimum local indésirable car nous savons qu'en principe le code devrait pouvoir s'exécuter beaucoup plus rapidement.

Existe-t-il des méthodologies réussies qui aident à décider des virages pour prendre en charge l'évolution d'une base de code vers une solution globale et optimisée qui ne soit pas facilement confondue par des opportunités d'optimisation faciles?

ÉDITER

Pour répondre à la question de savoir comment nous profilons actuellement:

Nous n'avons vraiment que 2 scénarios différents sur la façon dont ce code peut être utilisé, tous deux parallèlement embarrassants. Le profilage se fait à la fois avec une horloge murale moyenne sur un grand échantillon d'entrées et des exécutions plus détaillées (coûts d'instruction, erreurs de prédiction de branche et problèmes de mise en cache). Cela fonctionne bien puisque nous fonctionnons exclusivement sur nos machines extrêmement homogènes (un cluster de quelques milliers de machines identiques). Comme nous gardons généralement toutes nos machines occupées la plupart du temps plus rapidement, nous pouvons examiner de nouvelles choses supplémentaires. Le problème est bien sûr que lorsque de nouvelles variations d'entrée apparaissent, elles peuvent être pénalisées pour retard dans la mesure où nous avons supprimé les micro-inefficacités les plus évidentes pour les autres cas d'utilisation, réduisant ainsi éventuellement le nombre de scénarios "fonctionnant de manière optimale".

Benjamin Bannier
la source
10
10Mloc est en fait un énorme projet
BЈовић
1
C'est 10 millions de loc (préfixe SI) compté par sloc. Je l'ai appelé "de taille moyenne" parce que je n'ai aucune idée de ce qui est considéré comme "gros" ici.
Benjamin Bannier
5
à peu près sûr 10 millions est au moins gros partout et probablement énorme la plupart des endroits.
Ryathal
1
Génial, merci @honk Pour 10M LOC, il semble que vous optimisez à un niveau très bas, presque au niveau matériel? La POO traditionnelle (AOS "tableau de structures") est horriblement inefficace sur les caches, avez-vous essayé de réorganiser vos classes pour qu'elles soient SOA (structure de tableaux) afin que les points de données sur lesquels votre code travaille soient cohérents en mémoire? Avec autant de machines rencontrez-vous des blocages de communication ou une synchronisation qui gruge du temps? Dernière question, avez-vous affaire à des volumes élevés de données en streaming ou s'agit-il principalement d'un problème d'opérations complexes sur vos ensembles de données?
Patrick Hughes
1
Lorsque vous avez autant de code, les chances vont de l'excellent au pari qu'il y a de grandes accélérations potentielles du type non local que j'ai mentionné. Cela ne fait aucune différence s'il y a des milliers de threads / processus. Quelques pauses aléatoires vont soit les toucher pour vous, soit me prouver le contraire.
Mike Dunlavey

Réponses:

9

Je ne connais pas d'approche générale à ce problème, mais deux approches quelque peu liées ont bien fonctionné pour moi dans le passé: faute de meilleures conditions, je les ai appelées groupage et optimisation horizontale .

L'approche groupée est une tentative de remplacer un grand nombre d'opérations courtes et rapides par une opération unique, plus lente et hautement spécialisée qui produit finalement le même résultat.

Exemple: Après avoir profilé une opération particulièrement lente de notre éditeur de règles visuelles, nous n'avons découvert aucun "fruit bas": il n'y avait pas une seule opération qui prenait plus de 2% du temps d'exécution, mais l'opération dans son ensemble semblait lente. Cependant, nous avons découvert que l'éditeur envoyait un grand nombre de petites requêtes au serveur. Même si l'éditeur traitait rapidement les réponses individuelles, le nombre d'interactions demande / réponse a eu un effet multiplicateur, donc le temps global de l'opération a été de plusieurs secondes. Après avoir soigneusement catalogué les interactions de l'éditeur au cours de cette opération de longue durée, nous avons ajouté une nouvelle commande à l'interface du serveur. La commande supplémentaire était plus spécialisée, car elle acceptait les données nécessaires pour effectuer un sous-ensemble d'opérations courtes, a exploré les dépendances des données pour comprendre l'ensemble final de données à renvoyer et a fourni une réponse contenant les informations nécessaires pour effectuer toutes les petites opérations individuelles en un seul voyage vers le serveur. Cela n'a pas réduit le temps de traitement dans notre code, mais il a réduit une quantité très importante de latence en raison de la suppression de plusieurs allers-retours client-serveur coûteux.

L'optimisation horizontale est une technique connexe lorsque vous éliminez la «lenteur» qui est finement répartie entre plusieurs composants de votre système à l'aide d'une fonction particulière de votre environnement d'exécution.

Exemple: Après le profilage d'une opération de longue durée, nous avons découvert que nous faisons beaucoup d'appels à travers la limite du domaine d'application (ceci est très spécifique à .NET). Nous n'avons pu éliminer aucun des appels et nous ne pouvions pas les regrouper: ils provenaient à des moments différents de sections très différentes de notre système, et les choses qu'ils demandaient dépendaient des résultats renvoyés des demandes précédentes. Chaque appel a nécessité la sérialisation et la désérialisation d'une quantité relativement faible de données. Encore une fois, les appels individuels étaient de courte durée, mais très nombreux. Nous avons fini par concevoir un schéma qui évitait presque entièrement la sérialisation, en le remplaçant par le passage d'un pointeur à travers la limite du domaine de l'application. Ce fut une grande victoire, car de nombreuses demandes de classes entièrement indépendantes sont devenues instantanément beaucoup plus rapides grâce à l'applicationsolution horizontale .

dasblinkenlight
la source
Merci d'avoir partagé votre expérience, ce sont des optimisations utiles à garder à l'esprit. De plus, comme ils soulèvent les pièces problématiques à un endroit distinct, elles seront beaucoup mieux contrôlées à l'avenir. Dans un sens, ils ont mis en place ce qui aurait dû se produire en premier lieu, maintenant uniquement grâce à des données solides.
Benjamin Bannier
3

Cela a rendu la décision précoce coûteuse apparente seulement beaucoup plus tard. Nous sommes maintenant à un point où de nouvelles optimisations sont beaucoup plus coûteuses car elles nécessiteraient la réécriture de grandes parties de la base de code.

Lorsque vous démarrez cette réécriture, vous devez faire plusieurs choses différemment.

Premier. Et le plus important. Arrêtez "d'optimiser". L '"optimisation" n'a pas beaucoup d'importance. Comme vous l'avez vu, seule la réécriture en gros est importante.

Donc.

Seconde. Comprendre les implications de chaque structure de données et choix d'algorithme.

Troisième. Faire du choix réel de la structure des données et de l'algorithme une question de "liaison tardive". Concevez des interfaces qui peuvent avoir n'importe laquelle de plusieurs implémentations utilisées derrière l'interface.

Ce que vous faites maintenant (réécriture) devrait être beaucoup, beaucoup moins pénible si vous avez défini un ensemble d'interfaces qui vous permet de modifier en gros la structure des données ou l'algorithme.

S.Lott
la source
1
Merci pour votre réponse. Bien que nous ayons encore besoin d'optimiser (ce qui tombe sous 1. et 2. pour moi) j'aime vraiment la pensée organisée derrière 3. En rendant la structure des données, l'algorithme et l'accès définis tardivement et explicites, on devrait être en mesure de maîtriser de nombreux problèmes auxquels nous sommes confrontés. Merci de l'avoir mis dans un langage cohérent.
Benjamin Bannier
Vous n'avez en fait pas besoin d'optimiser. Une fois que vous avez la bonne structure de données, l'optimisation sera une perte d'effort. Le profilage montrera où vous avez une mauvaise structure de données et un mauvais algorithme. S'amuser avec la différence de performance entre ++et +=1sera hors de propos et presque incommensurable. C'est la chose que vous durerez .
S.Lott
1
Tous les mauvais algorithmes ne peuvent pas être trouvés par un raisonnement pur. De temps en temps, il faut s'asseoir et profiler. C'est le seul moyen de savoir si la supposition initiale était correcte. C'est la seule façon d'estimer le coût réel (BigO + const).
Benjamin Bannier
Le profilage révélera de mauvais algorithmes. Tout à fait correct. Ce n'est toujours pas de l '"optimisation". C'est toujours la correction d'un défaut fondamental de conception, car j'apporte un changement de conception. L'optimisation (peaufinage, réglage fin, etc.) sera rarement visible pour le profilage.
S.Lott
3

Une bonne astuce pratique consiste à utiliser votre suite de tests unitaires comme suite de tests de performances .

L'approche suivante a bien fonctionné dans mes bases de code:

  1. Assurez-vous que votre couverture de test unitaire est bonne (vous l'avez déjà fait, non?)
  2. Assurez-vous que votre environnement d'exécution de test signale l'exécution de chaque test individuel . Ceci est important car vous voulez trouver les performances lentes se produisent.
  3. Si un test s'exécute lentement, utilisez-le comme un moyen de plonger et de cibler l'optimisation dans cette zone . L'optimisation avant d'identifier un problème de performances peut être considérée comme prématurée, donc la grande chose à propos de cette approche est que vous obtenez d'abord des preuves concrètes de performances médiocres. Si nécessaire, divisez le test en tests plus petits qui comparent différents aspects afin que vous puissiez identifier où se situe le problème racine.
  4. Si un test s'exécute extrêmement rapidement, c'est généralement bon, bien que vous puissiez alors envisager d'exécuter le test en boucle avec différents paramètres. Cela en fait un meilleur test de performances et augmente également votre couverture de test de l'espace des paramètres.
  5. Écrivez quelques tests supplémentaires qui ciblent spécifiquement les performances, par exemple les temps de transaction de bout en bout ou le temps nécessaire pour exécuter 1 000 applications de règles. Si vous avez des exigences de performances non fonctionnelles spécifiques (par exemple <300 ms de temps de réponse), faites échouer le test s'il prend trop de temps.

Si vous continuez à faire tout cela, alors avec le temps, les performances moyennes de votre base de code devraient s'améliorer de manière organique.

Il serait également possible de suivre les temps de test historiques et de dessiner des graphiques de performances et des régressions ponctuelles au fil du temps dans les performances moyennes. Je ne me suis jamais soucié de cela, principalement parce que c'est un peu difficile de s'assurer que vous comparez comme avec comme lorsque vous changez et ajoutez de nouveaux tests, mais cela pourrait être un exercice intéressant si les performances sont suffisamment importantes pour vous.

mikera
la source
j'aime la technique - intelligente - cependant, c'est de la micro optimisation et s'améliorera au minimum local - cela ne résoudra pas les problèmes architecturaux qui vous permettent d'atteindre les minimums mondiaux
jasonk
@jasonk - vous avez absolument raison. Bien que j'ajouterais que cela peut parfois vous fournir les preuves dont vous avez besoin pour démontrer pourquoi un changement architectural particulier est justifié .....
mikera
1

La réponse de @dasblinkenlight souligne un problème très courant, en particulier avec les grandes bases de code (selon mon expérience). Il peut y avoir de graves problèmes de performances, mais ils ne sont pas localisés . Si vous exécutez un profileur, aucune routine ne prend suffisamment de temps pour attirer l'attention. (En supposant que vous regardez le pourcentage de temps inclusif qui inclut les callees. Ne vous embêtez même pas avec le «temps libre».)

En fait, dans ce cas, le problème réel n'a pas été trouvé par profilage, mais par chance.

J'ai une étude de cas, pour laquelle il y a un bref diaporama PDF , illustrant ce problème en détail et comment le gérer. Le point fondamental est, puisque le code est beaucoup plus lent qu'il ne pourrait l'être, cela signifie (par définition) que le temps supplémentaire est consacré à faire quelque chose qui pourrait être supprimé.

Si vous deviez regarder quelques échantillons à temps aléatoire de l'état du programme, vous simplement voir ce faire l'activité amovible, en raison du pour cent du temps qu'il faut. Il est tout à fait possible que l'activité amovible ne se limite pas à une seule fonction, voire à plusieurs fonctions. Il ne se localise pas de cette façon.

Ce n'est pas un "point chaud".

C'est une description que vous faites de ce que vous voyez, qui s'avère être vraie un grand pourcentage du temps. Cela le rend simple à découvrir, mais sa simplicité dépend de la quantité de réécriture nécessaire.

(Il y a souvent une critique de cette approche, à savoir que le nombre d'échantillons est trop petit pour la validité statistique. C'est ce qui est répondu sur la diapositive 13 du PDF. Brièvement - oui, il y a une grande incertitude dans la "mesure" des économies potentielles, mais 1) la valeur attendue de ces économies potentielles n'est pratiquement pas affectée, et 2) lorsque les économies potentielles $ x $ sont converties en ratio d'accélération de 1 $ / (1-x) $, elles sont fortement biaisées vers des facteurs élevés (bénéfiques).)

Mike Dunlavey
la source
Merci pour votre réponse. Nous ne croyons pas à l'échantillonnage statistique et utilisons l'instrumentation avec valgrind. Cela nous donne de bonnes estimations du coût «personnel» et «inclusif» de la plupart des choses que nous faisons.
Benjamin Bannier
@honk: D'accord. Mais malheureusement, l'instrumentation porte toujours l'idée que les problèmes de performances sont localisés et peuvent donc être trouvés en mesurant la fraction du temps passé dans les routines, etc. Vous pouvez certainement exécuter valgrind, etc., mais si vous voulez un réel aperçu des performances, consultez ce diaporama. .
Mike Dunlavey