Le site arthur.bebou.netlib.re - retour accueil
Anonyme, en lecture seule : git clone git://bebou.netlib.re/arthur.bebou
Authentifié·e, en écriture : git clone ssh://git@bebou.netlib.re:1459/srv/git/arthur.bebou
Log | Files | Refs | zip | tar.gz |
index.sh (19986B)
1 #! page
2 title: Sed, substitutions et performances
3 author: Arthur Pons
4 description: Explorons les perfs de sed sur des gros fichiers
5 publication: 2023-09-08
6 sectionmd: main
7
8
9 ## Le problème
10
11 Il y a peu une ingénieure de l'Université de Strasbourg est venue me voir avec
12 un problème. Elle travaille avec une équipe de recherche sur un projet d'édition numérique du manuscrit d'un certain George Flohr,
13 alsacien au XVIIIe enrolé dans la guerre d'indépendance des Etats-Unis. A son retour,
14 il rédige tout un journal de bord de son aventure, des lieux qu'il a traversé, des personnes qu'il a rencontré.
15 Il s'agit de ce manuscrit que ce projet d'édition numérique souhaite publier en ligne. Pour cela, les fichiers
16 de transcription et de traduction sont encodés en [TEI](https://fr.wikipedia.org/wiki/Text_Encoding_Initiative). Les
17 nom de lieux sont référencés via des balises qui comportent des identifiants.
18 Par exemple pour Strasbourg :
19
20 Geschehen zu <placeName corresp="https://sws.geonames.org/2973783/about.rdf">Straßburg</placeName> den <date when="1787-06-05">5 J[uni]<lb/>
21
22 Ici `2973783` est l'identifiant [geonames](https://www.geonames.org/) du lieux.
23 L'ingénieure a créé un référentiel pour chacun des lieux. Ce référentiel fait
24 correspondre un identifiant geonames à un identifiant maison. Elle souhaitait
25 donc modifier la valeur des balises `corresp` en substituant l'url geonames à
26 l'identifiant maison correspondant. Nous avons d'abord préparé une table de
27 correspondance qui a, pour Strasbourg par exemple, cette tête :
28
29 flohr_place_00074
30 2973783
31
32 On veut modifier le texte de la pièce pour qu'apparaisse :
33
34 Geschehen zu <placeName corresp="flohr_place_00074">Straßburg</placeName> den <date when="1787-06-05">5 J[uni]<lb/>
35
36 Le texte d'origine est [ici (544Ko)](./text.xml) et le fichier de correspondance
37 [là (8Ko)](./corres).
38
39 ## La solution naïve
40
41 Qui dit substitution dit `sed`. une commande qui ferait l'affaire serait
42
43 sed -i "s;https://sws.geonames.org/2973783/about.rdf;flohr_place_00074;g" text.xml
44
45 > Petit tips, souvenez vous que l'on peut choisir le caractère que l'on veut
46 > comme délimiteur de la commande `s` de sed. Pratique quand on bosse avec des
47 > urls et que l'on ne peut pas utiliser `/`.
48
49 Pour exécuter une commande de ce type pour toutes les paires "ancien id/nouvel id" on
50 peut utiliser xargs. En imaginant que la table de correspondance se trouve dans
51 un fichier nommé `corres` :
52
53 xargs -a corres -d'\n' -n2 sh -c 'sed -i "s;https://sws.geonames.org/$2/about.rdf;$1;g" text.xml' --
54
55 Ce script exécute à la suite toutes les commandes `sed` nécessaires pour faire
56 toutes les substitutions. Il prend environ 1,3s à finir. L'ingénieure s'est
57 ensuite demandée si ce script était plus ou moins rapide et/ou énergivore (deux
58 choses différentes) que celui qu'elle avait l'intention d'écrire. Sans aucune
59 mesure j'ai estimé qu'il était probable qu'il soit très peu optimisé sur ces
60 deux métriques notamment parce qu'il ouvre et ferme le fichier à modifier à
61 chaque substitution, ce qui est notoirement coûteux en temps. Or il y en a 213
62 en tout, il est donc raisonnable de penser qu'il existe d'autres techniques
63 pour aller beaucoup plus vite.
64
65 ## Optimisations en temps
66
67 ### Attention !
68
69 Je tiens à prévenir et que je n'y connais rien en programmation parallèle et je
70 me cantonne volontairement au shell. J'imagine qu'il existe d'autres
71 techniques, plus ou moins coûteuses en code et en temps, bien plus efficaces
72 que ce que je vais développer ci dessous. Aussi, mon manque de compétences dans
73 ce domaine m'empêche de bien comprendre pourquoi telle technique fonctionne
74 mieux ou moins bien qu'une autre. J'ai entre autre du mal à identifier les
75 "bottlenecks". Si vous faite fréquemment du calcul intensif la lecture de ce
76 qui suit risque de vous être désagréable.
77
78 Les mesures de temps on été faites avec `time`, généralement deux trois fois
79 sans particulièrement faire attention à ce que le reste de l'environnement
80 soit équivalent d'une commande à l'autre. A prendre donc avec quelques gros
81 grain de sel.
82
83 J'ai connaissance de GNU parralel sans pour autant l'utiliser. Il est possible
84 que ce soit un outil bien plus approprié qu'xargs pour faire ce qui suit. Je me
85 demande entre autre s'il est possible non seulement de parraléliser les
86 commandes mais aussi de découper l'exécution sur différents morceaux du
87 fichier.
88
89 ### Quelques métriques
90
91 Le texte d'origine en TEI fait 12307 lignes pour 54095 mots. Il y a 213
92 substitutions à faire. Il y a en tout 377 occurrences de ces 213 substitutions
93 puisque certains lieux apparaissent plusieurs fois dans le texte.
94
95 Pour que les optimisations soient plus flagrantes j'ai créé un nouveau fichier
96 qui est 100 fois le texte d'origine. Il y a donc 1 million 230 mille lignes,
97 5 millions 409 milles mots et les 213 commandes de substitutions vont en tout
98 modifier 377 mille occurrences. Pour comparaison, Guerre et Paix c'est 587
99 mille mots.
100
101 ### La parallélisation
102
103 La première optimisation très peu coûteuse à coder en partant de notre script
104 est la parallélisation. Pour le moment le script exécute `sed -i ...`, attend
105 que cela termine puis passe au suivant de manière séquentielle. Forcément,
106 avec un très gros fichier cela prend un très gros bout de temps. En
107 l'occurence plus de 2m10s sur notre gros fichier.
108
109 Les processeurs modernes disposent de plusieurs coeurs. Ils peuvent donc mener
110 plusieurs processus en simultané. A l'aide de l'option `-Pn` de la commande
111 `xargs` nous pouvons dire au script de lancer jusqu'à `n` processus en
112 simultané. Par exemple avec `-P4` c'est quatre substitutions qui se feront en
113 même temps.
114
115 D'expérience je ne sais jamais vraiment combien de processus lancer, j'expérimente
116 pour trouver le "sweet spot". Avec 10 le script passe de 2m10 à 1m42. Sans donner
117 de limite c'est 1m54. On voit que le gain de temps est modéré. Le PC a tendance
118 à légèrement freeze sans pour autant toujours utiliser les processus à 100%.
119 J'imagine que paralléliser beaucoup d'accès mémoire n'est pas particulièrement
120 efficace.
121
122 ### Économiser des accès au fichier
123
124 Pour tester l'hypothèse selon laquelle une bonne partie du temps est passé
125 à ouvrir `text.xml` et à l'écrire, faisons un seul appel à `sed`. Pour cela
126 je manipule le fichier `corres` de façon à en faire un script sed contenant
127 toutes les substitutions :
128
129 s,https://sws.geonames.org/3411865/about.rdf,flohr_place_00010,g;
130 s,https://sws.geonames.org/5106834/about.rdf,flohr_place_00035,g;
131 s,https://sws.geonames.org/3038230/about.rdf,flohr_place_00106,g;
132 ...
133
134 > Pour l'obtenir j'ai fait
135 > `cat corres | paste - - | sed -E
136 > 's+(.*).(.*)+s,https//sws.geonames.org/\2/about.rdf,\1,g;+'`.
137 > Alternativement, et sûrement plus proprement :
138 > `xargs -a corres printf 's;https://sws.geonames.org/%s/about.rdf;%s;g\n'`
139
140 Ainsi on appelle sed une seule fois
141
142 sed -i -f subs.sed text.xml
143
144 Cette technique permet de faire tomber le temps d'exécution à 30s soit
145 une division par 3 ou 4. Notre hypothèse se vérifie bien. Aucun scoop
146 je crois que c'est vraiment le b-a-ba de l'optimisation de calcul.
147
148 ### Combiner les deux
149
150 Dans l'exemple précédent nous utilisons un seul processus. L'un de nos coeurs
151 sera possiblement à 100% mais nous n'utilisons pas la puissance des trois autres.
152 Pour améliorer encore nos performances nous pouvons séparer les commandes de
153 substitutions en quelques gros groupes et les exécuter via quelques appels
154 simultanés à sed. Je ne sais pas bien comment estimer la quantité optimale
155 de processus à lancer ici, j'ai choisi quatre.
156
157 sed -e 'les_50_premières_sub_de_subs.sed' text.xml |
158 sed -e 'les_50_suivantes' |
159 ...
160 ... > res.xml
161
162 Avec cette technique nous tombons à environ 10s. Pour le fichier d'origine on
163 passe de 1,3s avec la première technique à 0,15s avec celle-ci.
164
165 > Dans une ancienne version de ce script j'avais proposé le script suivant
166 >
167 > `xargs -a subs.sed -d'\n' -P4 -n54 sh -c 'sed -i "$*" big.xml' --`
168 >
169 > Il se révèle, pour tout œil un peu entrainé, qu'il n'accomplit pas du tout
170 > la tâche. L'accès concurrentiel de quatre processus à un même fichier pour
171 > y faire des substitutions inplace donne à la fin un fichier dans lequel n'a
172 > été substitué que ce que le dernier processus se terminant aura fait. C'est
173 > lui qui aura écrit le fichier en dernier.
174
175 Si vous avez énormément de substitutions et/ou un gigantesque fichier il peut
176 être intéressant d'avoir un script qui génère automatiquement ce script. Je
177 propose :
178
179 set 50 text.xml
180 sed "1~$1 s/^/sed '/; $1 s/$/' $2 |/; $(($1*2))~$1 s/$/' |/; $ s/$/'/" subs.sed
181 | sh > res.xml
182
183 Attention, l'adressage sed `n~m` n'est pas POSIX. Dans `$1` la valeur de la taille des groupes de commandes de substitutions, dans `$2` le chemin du fichier résultat.
184
185 On me dit que ce serait mieux avec `split`, c'est sûrement vrai. Exemple :
186
187 split -l50 corres CHANGES
188 echo '< big.xml' $( printf 'sed -f%s| ' CHANGES* ) 'cat > new.xml' | sh
189
190 ### Synthèse
191
192 | Méthode | texte d'origine | très gros texte |
193 |---------|-----------------|-----------------|
194 | Un accès fichier par sub | 1,3s | 2m10s |
195 | Parallélisation d'un accès fichier par sub | 0,5s | 1m42s |
196 | Un seul accès fichier | 0,35s | 30s |
197 | Parallélisation pour 4 accès fichier | 0,15s | 10s |
198
199 ## Optimisation en énergie
200
201 ### Méthodologie^(très nulle^©™)
202
203 Pour mesurer à la louche la consommation énergétique des commandes j'utilise un
204 programme nommé `energy`, disponible chez Bitreich[^1]. Du propre aveu de la
205 personne l'ayant écrit, c'est peu fiable[^4]. En plus de n'être pas fiable energy
206 ne fait pas la différence entre les processus et renvoie donc l'énergie totale
207 consommée lors de l'exécution de la commande. J'ai donc écrit un wrapper qui
208 fait la différence entre l'énergie totale et celle habituellement consommée par
209 l'ordinateur au repos. Evidemment cela dépend fortement du matériel.
210
211 Mon script est le suivant :
212
213 res=$( (time -f %e energy $*) 2>&1 1>/dev/null)
214 timeres=$(echo "$res" | tail -n1)
215 idleenergy=$(echo "11.03 * $timeres" | bc -l)
216 totenergy=$(echo "$res" | sed -nE '1,2 s/.* ([0-9]+\.[0-9]+).*/\1/ p' | paste -s -d'+' | bc -l)
217 diffenergy=$(echo "$totenergy - $idleenergy" | bc -l)
218 echo "$timeres : command exec time"
219 echo "$idleenergy : estimated J consumption for $timeres of idle time"
220 echo "$totenergy : total energy consumption"
221 echo "$diffenergy : added consumption of command"
222
223 Pas bien joli mais ça fonctionne. La constante `11.03` est la conso moyenne en
224 joule de mon pc durant une seconde avec uniquement l'environnement de bureau et
225 une console ouverte.
226
227 Le script récupère la durée d'exécution de la commande ainsi que la totalité de
228 l'énergie consommée durant puis fait la différence entre ce total et 11.03 fois
229 la durée d'exécution en seconde.
230
231 Évidemment la moyenne de 11.03 n'est pas tout à fait juste, mon pc fait plein
232 de choses en arrière plan[^2] et je n'ai pas la patience de faire plusieurs mesures
233 pour faire des moyennes. Je m'astreins donc à ne tirer des conclusions
234 définitives que de grosses différences de mesure.
235
236 Il faudrait refaire toutes les mesures avec un logiciel et une méthode plus sérieuse.
237
238 Mon est un Dell Latitude 5480 avec un Intel i5-7200U @ 2.50 GHz.
239
240 ### Les mesures
241
242 Pour la toute première solution sur le petit fichier :
243
244 1.167 : command exec time
245 12.87201 : estimated J consumption for 1.167 of idle time
246 29.96 : total energy consumption
247 17.08799 : added consumption of command
248
249 L'exécution de cette commande aurait donc plus que doublé la quantité d'énergie
250 consommée. A vérifier parce que ça me semble beaucoup.
251
252 Cette même solution mais sur le gros fichier
253
254 133.03 : command exec time
255 1467.3209 : estimated J consumption for 133.03 of idle time
256 2608.30 : total energy consumption
257 1140.9791 : added consumption of command
258
259 Ici la commande ajoute proportionnellement moins d'énergie. A noter
260 que le temps d'exécution est tellement long que l'écart inévitable
261 entre la moyenne de consommation au repos et la réelle valeur risque
262 d'être très grande. Par exemple, juste après ce test j'ai lancé un
263
264 energy sleep 133.03
265
266 et j'ai obtenu 1061.39 et donc une consommation supplémentaire de 1549.91 J
267 soit 400 J de plus qu'annoncé ! L'une des valeurs n'est pas plus vraie que
268 l'autre, simplement avec cette technique plus le temps d'exécution est long
269 plus le résultat est peu fiable.
270
271 Toutes les substitutions en un seul appel à sed :
272
273 29.90 : command exec time
274 329.7970 : estimated J consumption for 29.90 of idle time
275 696.41 : total energy consumption
276 366.6130 : added consumption of command
277
278 et la technique la plus rapide :
279
280 9.717 : command exec time
281 107.17851 : estimated J consumption for 9.717 of idle time
282 400.66 : total energy consumption
283 293.48149 : added consumption of command
284
285 Avec les fortes incertitudes de notre méthode je pense que l'on peut conclure
286 que ces deux techniques se valent en terme de consommation d'énergie.
287
288 ### Synthèse
289
290
291 | Méthode | texte d'origine | très gros texte |
292 |---------|-----------------|-----------------|
293 | Un accès fichier par sub | 17J | 1140J |
294 | Parallélisation d'un accès fichier par sub | pas mesuré | pas mesuré |
295 | Un seul accès fichier | pas mesuré | 366.6J |
296 | Parallélisation pour 4 accès fichier | pas mesuré | 293J |
297
298 ## Remarques
299
300 Il faut reconnaître que ce travail d'optimisation n'est qu'à but expérimental.
301 Le texte d'origine et la taille du corpus dont il vient ne sont pas d'ampleur
302 suffisante pour que cela vaille nécessairement le coup. Cela dit passer du
303 premier script au dernier a été assez rapide, les outils `sed` et `xargs`
304 permettant d'écrire du code très expressif et d'itérer très rapidement.
305
306 Donnons momentanément du crédit à notre méthode de calcul de consommation
307 d'énergie. On apprend que notre première solution, en plus d'être lente n'est
308 pas efficace. Je suppose que le temps perdu en accès mémoire, à relancer sed à
309 chaque fois etc se traduit aussi par de l'énergie perdue à les exécuter.
310 Autrement dit, on met une quantité non négligeable d'énergie à faire d'autres
311 choses que des substitutions. Un peu comme lorsque l'on pédale sur un mauvais
312 vélo. On y met de l'énergie mais seulement une fraction sert réellement à nous
313 faire avancer.
314
315 L'appel unique à sed est manifestement beaucoup plus efficace. Il est
316 raisonnable de penser qu'en s'économisant tout ce travail superflu il va plus
317 vite en consommant moins.
318
319 La technique la plus rapide a une consommation d'énergie similaire à la
320 précédente. Je suppose que les 3 cycles supplémentaires de lancement de sed
321 d'ouverture de fichier etc sont trop peu nombreux pour avoir une incidence
322 significative sur la consommation d'énergie. Ici la parallélisation a
323 considérablement accéléré la tâche[^3] (divisé par ~3) ce qui a suffit pour
324 compenser la charge presque quatre fois plus grande imposée au CPU. On constate
325 donc une accélération "gratuite" en terme de consommation d'énergie. Le temps
326 d'exécution de la tâche s'est plus ou moins linéairement réduite avec la
327 quantité de puissance mise dedans. D'ailleurs, et c'est certainement bien connu
328 des personnes bossant dans le domaine, j'ai le sentiment que l'on a des
329 rendements décroissants avec l'augmentation du nombre de processus parallélisé
330 et qu'ici quatre était le chiffre magique. Du moins c'est ce que je constate
331 souvent, j'imagine que ça dépend du matériel et du type de processus. Sur des
332 CPU avec de très nombreux cœurs on peut peut-être continuer à constater des
333 rendements linéaires pour des quantités de processus plus élevés. Est-ce que la
334 meilleure quantité de processus a un rapport avec la quantité de cœurs de son
335 CPU ? Si vous avez la réponse n'hésitez pas à m'éduquer. D'ailleurs si vous
336 avez une quelconque expertise en calcul intensif je veux bien un retour sur cet
337 article. J'ai l'impression de redécouvrir la roue mais en commençant avec un
338 prototype carré.
339
340 ## Aller plus loin en consommant moins
341
342 Le périmètre initiale de notre problème était celui de `sed`. Nous sommes
343 probablement allez aussi loin que possible sans avoir à y passer une quantité
344 de temps déraisonnable. Il est cependant possible de résoudre ce problème de
345 façon bien plus efficace à l'aide de langages de programmation plus complexes.
346
347 Marc Chantreux et Pierre Gerhard en font la démonstration avec respectivement
348 un [script perl](./sub.pl) et un [script python](./sub.py). Sur mon pc les
349 perfs sont :
350
351 | Méthode | texte d'origine | très gros texte |
352 |---------|-----------------|-----------------|
353 | script perl | 0,006s | 0,4s |
354 | script python | 0,005s | 0,7s |
355
356 Les deux scripts ont des performances énergétiques drastiquement meilleures que
357 tous nos scripts sed. Elles ajoutent une consommation énergétique d'environ 9J
358 pour le gros fichier soit deux ordres de grandeur en dessous de notre meilleur
359 résultat sed.
360
361 Pourquoi ? Parce que ces deux scripts n'ont qu'une commande de substitution.
362 Pour chaque ligne une seule tentative de substitution est faite contre 213
363 dans les scripts sed. Pour que cela puisse fonctionner quand bien même il y
364 a bien des chaînes différentes à substituer dans chacune des paires dans
365 `corres` ils utilisent une astuce avec un tableau associatif. En se reposant
366 sur le principe que toutes les substitutions vont commencer par la même chaine
367 `https://...` ont peut créer un "template" de substitution type :
368
369 s;https://sws.geonames.org/ID/about.rdf;NEWID;g
370
371 Où l'on récupère la clef ID pour chercher la valeur correspondante NEWID dans
372 le tableau associatif. Ainsi on construit une seule substitution que l'on
373 modifie à la volée lorsque l'on a matché le début de notre chaîne. Pour saisir
374 pourquoi c'est beaucoup plus efficace imaginons une ligne sur laquelle il n'y a
375 rien à substituer. Nos scripts sed vont tester une première fois de trouver un
376 h, puis un t, puis un t sans succès puis passer à une autre substitution pour
377 tenter de retrouver un h puis un t puis un t alors même que la première
378 substitution n'a pas matché et que l'on sait donc qu'aucune autre ne matchera !
379 Nos scripts perl et python vont tenter de trouver un h, puis un t, puis un t
380 mais une fois arrivé au bout de la ligne sans match ils passeront à la ligne
381 suivante. La complexité en sed croît avec le produit de `nb_sub*nb_lignes`
382 quand nos scripts perl/python croient en `nb_lignes` uniquement.
383
384 Il semblerait donc que pour un problème de substitution de ce type et pour de
385 très gros volumes de donnée la complexité supplémentaire d'embarquer
386 perl/python et la relative verbosité des scripts associés puissent valoir le
387 coup temporellement et énergétiquement. C'est un cas intéressant d'arbitrage
388 coût/bénéfice, selon moi ici plutôt à l'avantage de perl/python. C'est aussi
389 une belle démonstration que la compétence des développeurs et l'adéquation des
390 outils peut faire des miracles pour les performances. Parfois bien plus que du
391 matériel plus puissant. C'est cette idée là qui me fait penser que la meilleure
392 chose que l'on puisse faire pour réduire l'impact environnemental d'un centre
393 de calcul est d'embaucher des ingénieur·e·s calcul supplémentaires pour
394 accompagner les chercheureuses. Bien évidemment les problèmes scientifiques
395 auxquels iels sont confronté·e·s sont extrêmement divers et ne peuvent pas tous
396 être aussi bien optimisés mais je parie que l'on serait surpris.
397
398 [^1]: git clone git://bitreich.org/energy
399 [^2]: #ubuntu
400 [^3]: contrairement à la première tentative de parallélisation de notr solution naïve
401 [^4]: Pour avoir comparé les valeurs avec un wattmètre branché sur une prise les valeurs sont étonamment justes. En tout cas les ordres de grandeur sont bons, ça nous suffit.