arthur.bebou

Le site arthur.bebou.netlib.re - retour accueil

git clone git://bebou.netlib.re/arthur.bebou
Log | Files | Refs |

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.