Compte rendu

This commit is contained in:
rabot
2026-10-07 12:37:15 +02:00
parent e99abeb267
commit ed2e1cd102
2 changed files with 300 additions and 1 deletions
+289 -1
View File
@@ -1 +1,289 @@
// Piste : réduire le temps d'exécution de `doit_echanger`
# 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`.
+11
View File
@@ -0,0 +1,11 @@
version;execution;temps_reel_s;temps_user_s;temps_systeme_s;pic_rss_kio
initial;1;2.83;2.82;0.00;6664
final;1;0.11;0.11;0.00;6672
final;2;0.11;0.11;0.00;6664
initial;2;2.80;2.80;0.00;6672
initial;3;2.81;2.80;0.00;6564
final;3;0.11;0.11;0.00;6672
final;4;0.11;0.10;0.00;6668
initial;4;2.79;2.79;0.00;6672
initial;5;2.82;2.81;0.00;6504
final;5;0.11;0.11;0.00;6652
1 version execution temps_reel_s temps_user_s temps_systeme_s pic_rss_kio
2 initial 1 2.83 2.82 0.00 6664
3 final 1 0.11 0.11 0.00 6672
4 final 2 0.11 0.11 0.00 6664
5 initial 2 2.80 2.80 0.00 6672
6 initial 3 2.81 2.80 0.00 6564
7 final 3 0.11 0.11 0.00 6672
8 final 4 0.11 0.10 0.00 6668
9 initial 4 2.79 2.79 0.00 6672
10 initial 5 2.82 2.81 0.00 6504
11 final 5 0.11 0.11 0.00 6652