# 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](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](out/profil_avant.txt) 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](out/profil_apres.txt) | | Moyenne calculée une seule fois | 1,46 s | 0,88 s | ≈ 40 % | [profil_apres_moyenne.txt](out/profil_apres_moyenne.txt) | | Table de hachage | 0,888 s | 0,831 s | 6,4 % | [profil_apres_hachage.txt](out/profil_apres_hachage.txt) | | Longueurs et parcours des chaînes | 0,832 s | 0,362 s | 56,5 % | [profil_apres_longueurs.txt](out/profil_apres_longueurs.txt) | | Tri par tas | 0,367 s | 0,113 s | 69,3 % | [profil_apres_tri.txt](out/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](out/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](README.md) 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 : ```bash 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`.