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 -O0pour mesurer le temps réel d'exécution ;gcc -O0 -pgpour obtenir le profil plat et le graphe d'appels avecgprof.
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_motparcourt 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_longueurrecalcule la même moyenne pour chaque mot distinct. Avec l'appel effectué dansmain, cela représente 5 867 calculs de moyenne et 34 415 822 appels àlongueurdepuislongueur_moyenne. - Parcours des chaînes.
normaliser_lignerecalcule la longueur de la ligne à chaque caractère.ajouterrecherche également la fin de la destination à chaque ajout, notamment lorsque le rapport grandit. - Tri. Le tri à bulles effectue systématiquement
n(n - 1)/2comparaisons, soit 17 202 045 appels àdoit_echangerpour 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
tailledeEntreereçoit la longueur déjà calculée pendant le découpage du mot ; la moyenne et la variance lisent directement ce champ ; normaliser_ligneparcourt la ligne jusqu'àligne[i] == '\0', ce qui ramène sa complexité de O(L²) à O(L) ;ajouterreç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.