Files
2026_TD1_DEV51_Qualite_Algo/COMPTE_RENDU.md
T
2026-10-07 12:37:15 +02:00

14 KiB

Compte rendu — Profiling et optimisation de l'analyseur de mots

R5.A.04 — Qualité algorithmique

Les optimisations ont ramené le temps réel médian du programme de 2,81 s à 0,11 s, sur une comparaison de cinq exécutions par version sans instrumentation. Le rapport produit et les statistiques affichées restent identiques. L'empreinte du rapport est toujours 1d53de903efd2b7c.

1. Objectif et méthode de mesure

Le programme génère un texte pseudo-aléatoire avec une graine fixe (20261006), normalise ses lignes, compte les mots hors mots vides, les classe et calcule des statistiques. Le jeu de données contient 250 000 mots, répartis sur 21 746 lignes, dont 5 866 mots distincts retenus.

L'objectif est de réduire le temps d'exécution en conservant les résultats. Les mesures et les choix décrits ci-dessous proviennent de notes.md et des profils enregistrés dans out/. Une campagne complémentaire a été réalisée pour mesurer le temps global et le pic mémoire des versions initiale et finale dans les mêmes conditions.

Deux compilations sont utilisées :

  • gcc -O0 pour mesurer le temps réel d'exécution ;
  • gcc -O0 -pg pour obtenir le profil plat et le graphe d'appels avec gprof.

La première mesure consignée dans les notes donne 2,858 s sans -pg, contre 6,354 s avec -pg. L'instrumentation a donc un coût important. Les gains de performance sont calculés entre exécutables compilés sans -pg, en conservant -O0 pour observer l'effet des changements algorithmiques. Les captures intermédiaires dont les options de compilation ne sont pas précisées ne servent pas au calcul des gains.

Les deux premières optimisations ont fait l'objet de mesures ponctuelles. Pour les trois suivantes, les valeurs retenues sont les médianes de cinq exécutions par version, en alternant les versions avant et après modification.

2. Analyse du profil initial

Le profil initial fait apparaître les coûts suivants. Le temps propre correspond au temps passé dans la fonction elle-même, hors fonctions qu'elle appelle.

Fonction Nombre d'appels Part du temps propre
longueur 36 009 819 36,36 %
egales 308 701 874 26,32 %
chercher_mot 150 182 18,18 %
trier_entrees 1 5,26 %
longueur_moyenne 5 867 3,35 %

Le graphe d'appels permet d'expliquer ces résultats :

  • Recherche des mots. chercher_mot parcourt les entrées une à une. Ses 150 182 recherches provoquent 296 656 203 appels à egales. Le problème vient du nombre de comparaisons effectuées.
  • Statistiques. ecart_type_longueur recalcule la même moyenne pour chaque mot distinct. Avec l'appel effectué dans main, cela représente 5 867 calculs de moyenne et 34 415 822 appels à longueur depuis longueur_moyenne.
  • Parcours des chaînes. normaliser_ligne recalcule la longueur de la ligne à chaque caractère. ajouter recherche également la fin de la destination à chaque ajout, notamment lorsque le rapport grandit.
  • Tri. Le tri à bulles effectue systématiquement n(n - 1)/2 comparaisons, soit 17 202 045 appels à doit_echanger pour 5 866 entrées.

Le nombre d'appels doit être interprété avec le travail effectué par appel : parcourir un rapport déjà long coûte davantage que parcourir un mot court. Les modifications sont donc évaluées par un nouveau chronométrage, en complément du profil.

3. Pistes d'amélioration, classées par gain attendu

Dans les complexités indiquées, n désigne le nombre de mots distincts et L la longueur d'une ligne. Les mots conservés sont limités à 31 caractères.

Priorité Piste Gain attendu et justification
1 Remplacer la recherche linéaire par une recherche dichotomique, puis étudier le hachage Très élevé face à la version initiale : éviter des centaines de millions de comparaisons.
2 Calculer la moyenne une seule fois Très élevé : supprimer un calcul identique répété pour chaque entrée et passer de O(n²) à O(n).
3 Éviter les recalculs de longueur des lignes et des chaînes construites Élevé : supprimer les parcours répétés de chaînes longues.
4 Remplacer le tri à bulles par un tri en O(n log n) Élevé une fois les autres coûts réduits : diminuer les 17 millions de comparaisons du tri.
5 Mémoriser la longueur des mots Plus limité isolément : après la correction de la moyenne, les statistiques ne font plus que 11 732 calculs de longueur sur des mots courts.

La priorité porte ainsi sur la réduction du nombre de calculs. Les fonctions egales et doit_echanger peuvent conserver leur comportement pendant que les algorithmes qui les utilisent changent.

4. Optimisations réalisées

4.1. Recherche dichotomique

Une première version maintient le tableau dans l'ordre alphabétique pendant le comptage. La recherche élimine la moitié des candidats à chaque étape : elle passe de O(n) à O(log n). L'insertion d'un nouveau mot reste en O(n), car elle nécessite de décaler des entrées, mais les occurrences suivantes n'entraînent aucun déplacement.

Le nombre de comparaisons de chaînes dans la recherche passe de 296 656 203 à 1 718 447, soit environ 173 fois moins. Cette étape est conservée dans l'historique ; la version finale utilise le hachage.

4.2. Calcul unique de la moyenne

main calcule la moyenne une seule fois, puis la transmet à ecart_type_longueur. La boucle de calcul de la variance réutilise cette valeur. Le nombre d'appels à longueur_moyenne passe de 5 867 à 1, et le calcul des statistiques passe de O(n²) à O(n), sans changer la formule.

4.3. Table de hachage pour le comptage

Un index de 40 001 cases associe chaque mot à son entrée dans le tableau. Lors d'une collision, le programme compare les mots puis examine la case suivante. Les nouveaux mots sont ajoutés en fin de tableau, sans décalage. La limite de 20 000 mots distincts maintient l'index rempli à moins de 50 %.

La recherche et l'insertion deviennent O(1) en moyenne pour ces mots de longueur bornée, avec un pire cas en O(n) en cas de nombreuses collisions. L'index est libéré après le comptage, avant le tri qui déplace les entrées.

La recherche effectue désormais 153 101 comparaisons, environ 11,2 fois moins qu'avec la dichotomie. Le gain global est cependant de 6,4 % à cette étape : la recherche était déjà optimisée et les autres traitements représentaient l'essentiel du temps restant.

4.4. Longueurs mémorisées et parcours des chaînes

Trois changements suppriment les appels restants à longueur :

  • le champ taille de Entree reçoit la longueur déjà calculée pendant le découpage du mot ; la moyenne et la variance lisent directement ce champ ;
  • normaliser_ligne parcourt la ligne jusqu'à ligne[i] == '\0', ce qui ramène sa complexité de O(L²) à O(L) ;
  • ajouter reçoit une position de fin qui avance pendant la copie des nouveaux caractères. Le rapport et les mots du vocabulaire sont ainsi construits sans reparcourir leur préfixe à chaque ajout.

Les 1 599 863 appels restants à longueur disparaissent et la fonction est supprimée. Le gain mesuré concerne cet ensemble de changements ; il ne peut pas être attribué au seul stockage de la longueur des mots.

4.5. Tri par tas

Le tri à bulles est remplacé par un tri par tas. Le tas place à sa racine l'entrée qui doit apparaître le plus tard dans le classement. Cette entrée est placée à la fin de la partie non triée, puis le tas est rétabli.

Le tri est en O(n log n), y compris dans le pire cas, avec O(1) de mémoire supplémentaire. doit_echanger reste inchangée : fréquence décroissante, puis ordre alphabétique croissant. Les échanges déplacent les entrées entières, avec leur longueur mémorisée.

Les appels à doit_echanger passent de 17 202 045 à 129 602, soit environ 133 fois moins. Les comparaisons alphabétiques passent de 12 172 087 à 32 813.

Ces cinq étapes correspondent aux commits be387e2, d3f1987, 57aaec4, 1c4405c et 57c9ce9.

5. Temps mesurés et gains obtenus

Mesures par étape issues des notes

Chaque gain est relatif à la version immédiatement précédente. Les mesures ont été refaites à chaque étape, ce qui explique les petits écarts entre le temps « après » d'une ligne et le temps « avant » de la suivante. Les pourcentages sont ceux consignés dans les notes ; les temps affichés sont arrondis.

Modification Avant Après Réduction du temps Profil obtenu
Recherche dichotomique 2,80 s 1,45 s ≈ 48 % profil_apres.txt
Moyenne calculée une seule fois 1,46 s 0,88 s ≈ 40 % profil_apres_moyenne.txt
Table de hachage 0,888 s 0,831 s 6,4 % profil_apres_hachage.txt
Longueurs et parcours des chaînes 0,832 s 0,362 s 56,5 % profil_apres_longueurs.txt
Tri par tas 0,367 s 0,113 s 69,3 % profil_apres_tri.txt

Le profil final est donc out/profil_apres_tri.txt. Le fichier out/profil_apres.txt correspond uniquement à la première optimisation.

Comparaison globale complémentaire

Pour compléter les notes, la version initiale du commit 55320bd et la version finale ont été compilées avec GCC 16.2.1 sous Linux x86_64, avec -O0 et sans -pg. Cinq exécutions par version ont été réalisées en alternance. /usr/bin/time a mesuré les durées et le maximum de mémoire résidente (RSS). Les résultats bruts sont conservés dans mesures_bilan.csv.

Mesure médiane sur cinq exécutions Version initiale Version finale
Temps réel 2,81 s 0,11 s
Temps utilisateur 2,80 s 0,11 s
Pic mémoire résident 6 664 Kio 6 668 Kio

Le rapport des temps donne une accélération d'environ 25,5, soit une réduction d'environ 96,1 %. Ces valeurs restent des mesures sur ce jeu de données et cette machine ; les temps de cette campagne sont relevés au centième de seconde.

6. Validation des résultats

Après chaque étape, le rapport et la sortie console ont été comparés à la version précédente, octet par octet. La campagne complémentaire compare également directement les versions initiale et finale. Les résultats suivants sont conservés :

Résultat Valeur commune aux versions initiale et finale
Lignes analysées 21 746
Mots au total 250 000
Mots distincts retenus 5 866
Longueur moyenne 6,712
Écart-type 1,873
Bigramme le plus fréquent me, 17 607 fois, souvent suivi de n
Mot le plus fréquent rane, 287 occurrences
Empreinte du rapport 1d53de903efd2b7c

Les compilations avec -Wall -Wextra -Wpedantic n'ont produit aucun avertissement. Les validations consignées dans les notes comprennent également des exécutions avec et sans -pg, ainsi que sous AddressSanitizer et UndefinedBehaviorSanitizer, sans erreur détectée.

Des tests temporaires ont couvert les collisions de hachage, les doublons, les limites de capacité, les mots tronqués à 31 caractères et les ajouts jusqu'à la limite d'un tampon. Le tri a été comparé à qsort sur des tableaux de 0 à 20 000 entrées, aléatoires, déjà triés ou inversés, avec des fréquences égales. La conservation des longueurs après tri a aussi été vérifiée.

7. Consommation mémoire et limites

L'empreinte du rapport et l'empreinte mémoire sont deux mesures différentes. La première est conservée, mais deux optimisations augmentent la mémoire allouée sur cette machine :

Structure ajoutée ou agrandie Mémoire supplémentaire
Index de hachage : 40 001 entiers de 4 octets 160 004 octets, soit environ 156 Kio
Champ taille : 4 octets pour chacune des 20 000 entrées allouées 80 000 octets, soit environ 78 Kio
Total supplémentaire pendant le comptage 240 004 octets, soit environ 234 Kio

L'index est libéré avant le tri, qui ne nécessite pas de tableau auxiliaire. Les pics RSS mesurés sont proches et fluctuent : de 6 504 à 6 672 Kio pour la version initiale, et de 6 652 à 6 672 Kio pour la version finale. Le RSS mesure la mémoire résidente de l'ensemble du processus, et non la somme des tailles demandées à malloc et calloc.

Cette proximité des pics ne permet donc pas d'affirmer que la consommation mémoire est strictement identique. Au sens de la mémoire allouée, la contrainte de conservation mentionnée dans le README n'est pas respectée par le hachage et la mémorisation des longueurs : ces choix constituent un compromis entre temps d'exécution et mémoire.

8. Analyse du profil final et suite possible

Dans le profil final, est_mot_vide effectue encore 12 045 671 appels à egales, sur 12 198 772 appels au total. La recherche linéaire dans la liste des mots vides constitue donc une prochaine piste d'optimisation, par exemple avec une structure de recherche adaptée. Cette piste n'a pas été mise en œuvre dans la version présentée.

Le profil final ne cumule que 0,11 s de temps échantillonné, avec une granularité de 0,01 s. Ses pourcentages doivent être interprétés avec prudence : une ligne à 0,00 s ne signifie pas que la fonction ne coûte rien. Les nombres d'appels et les chronométrages répétés complètent cette lecture.

9. Commandes de reproduction

Depuis la racine du dépôt, les commandes suivantes créent des exécutables dans un dossier séparé et produisent un nouveau profil final :

mkdir -p out/validation
git show 55320bd:analyseur.c > out/validation/analyseur_initial.c
gcc -O0 out/validation/analyseur_initial.c -o out/validation/analyseur_initial
gcc -O0 analyseur.c -o out/validation/analyseur_final
gcc -O0 -pg analyseur.c -o out/validation/analyseur_pg
cd out/validation
/usr/bin/time -f 'real=%e s; RSS_max=%M Kio' ./analyseur_initial
/usr/bin/time -f 'real=%e s; RSS_max=%M Kio' ./analyseur_final
./analyseur_pg
gprof -b ./analyseur_pg gmon.out > profil_apres.txt

Pour comparer les performances, répéter les deux exécutions sans -pg cinq fois chacune, en alternant leur ordre, puis retenir les médianes. Chaque exécution doit afficher l'empreinte 1d53de903efd2b7c.