Estimer la consommation avec des sondes logicielles

RAPL

La technologie RAPL (Running Average Power Limit) fournit un ensemble de compteurs donnant accès à des mesures de consommation énergétique et de puissance. Cette technologie s’appuie sur un ensemble de sondes matérielles et sur un modèle de puissance. Elle est exposée par les processeurs Intel à partir de la génération Sandy Bridge. Il est important également de noter que cette technologie doit avoir un accès direct aux compteurs, par le biais du noyau Linux, ce qui exclut de l’utiliser au sein d’une machine virtuelle.

Pour savoir si son architecture est compatible avec ces contraintes, on peut tout d’abord inspecter les informations concernant le micro-processeur :

$ lscpu
Architecture :                          x86_64
Mode(s) opératoire(s) des processeurs : 32-bit, 64-bit
Boutisme :                              Little Endian
Address sizes:                          39 bits physical, 48 bits virtual
Processeur(s) :                         4
[...]
Famille de processeur :                 6
[...]

Ici, il s’agit donc d’un processeur de 6ème génération, autrement dit Skylake, famille ultérieure à Sandy Bridge. Pour en avoir le cœur net :

$ cat /sys/devices/cpu/caps/pmu_name
skylake

Afin de bien comprendre comment les relevés des compteurs sont effectués, il faut avoir une idée du découpage logique des unités de consommation1. Le découpage s’effectue de la manière suivante :

  • le niveau package correspond à la consommation totale de tout le bloc processeur, incluant les cœurs, le cache L3, le contrôleur de mémoire et le processeur graphique intégré (GPU, s’il y en a un) :
  • le niveau core correspond à la consommation de l’ensemble des cœurs CPU ;
  • le niveau uncore correspond à la consommation du processeur graphique intégré. On a donc \(C_{core} + C_{uncore} \geq C_{package}\) ;
  • le niveau dram, séparé du processeur.

Le schema de la figure 1 nous montre une vue synthétique du découpage logique des compteurs RAPL.

Figure 1: Découpage logique des compteurs RAPL pour un processeur unique (à plusieurs cœurs)..

Il existe plusieurs outils et bibliothèques capables de lire les compteurs RAPL sous Linux. En s’appuyant directement sur le noyau Linux, il est possible d’accéder à ces valeurs de trois manières différentes2 :

  • en lisant les fichiers du dossier /sys/class/powercap/intel-rapl/intel-rapl:0 en utilisant l’interface powercap3. Cette interface ne requiert aucune permission spéciale et a été introduite dans la version 3.13 du noyau Linux ;
  • en utilisant l’API perf_event (ou perf)4. Cette API est un outil d’analyse de performance capable de remonter à l’utilisateur un grand nombre de métriques liées aux événements matériels sur une machine. Elle est disponible dans le noyau Linux depuis la version 2.6.31 et intègre les compteurs RAPL depuis la version 3.14. L’utilisation de perf_event nécessitera un accès superutilisateur. La liste de tous les événements disponibles peut être trouvée par perf list ou en consultant les fichiers du dosser /sys/bus/event_source/devices/power/events/ ;
  • en accédant directement aux MSR bruts. Les MSR (Model Specific Registers) correspondent aux registres de contrôle du jeu d’instructions x86 utilisé pour suivre l’exécution d’un programme, et faire de la mesure de performance. Le module msr (chargé avec modprobe msr) fournit une interface pour accéder aux MSR, situés dans /dev/cpu/CPUNUM/msr (CPUNUM étant ici le numéro du processeur, tel qu’il apparaît dans le fichier /proc/cpuinfo).

Note importante : Il semble que dans les versions la version 5.4.77 du noyau Linux, et plus précisément depuis le commit 19f6d91bdad42200aac557a683c17b1f65ee6c94 (10 novembre 2020), l’accès aux fichiers de mesure d’énergie de powercap soit restreint aux utilisateurs privilégiés. L’explication est qu’un accès non restreint aux mesures d’énergie expose le système à des failles de sécurité. On pourra trouver des exemples d’exploitation possible dans un article récent publié dans à ASIA-CCS’21 (Zhang et al. 2021), et qui démontre entre autres comment exploiter des signatures énergétiques afin de déterminer à quelles pages web un utilisateur accède.

Il y a plusieurs manières de contourner cette restriction. La première est d’effectuer toutes les mesures de consommation en tant que superutilisateur, ce qui implique donc de lancer tous les scripts de mesure qui nécessitent un accès à l’interface powercap (par exemple ceux qui concernent PyJoules ou CodeCarbon) en tant que superutilisateur. Une autre manière de contourner cette restriction est d’outrepasser les mesures de sécurité imposées par le noyau en donnant à tous les utilisateurs les droits en lecture sur les fichiers /sys/class/powercap/intel-rapl :

$ sudo chmod -R a+r /sys/class/powercap/intel-rapl

Par la suite, nous détaillerons essentiellement l’utilisation du middleware PowerAPI (INRIA 2020), qui s’appuie entre autres sur les sondes RAPL, mais dont l’objectif, orthogonal, est de fournir un cadre permettant d’automatiser au niveau d’un cluster la mesure de consommation s’appuyant sur des sondes logicielles.

PowerAPI

Comme l’indique la page Web dédiée, PowerAPI (INRIA 2020) fournit des outils dédiés au déploiement de sondes de mesure de puissance au niveau logiciel. Ces sondes permettent d’évaluer la consommation électrique d’un nœud de calcul ou d’un processus en temps réel.

L’architecture de PowerAPI est pensée pour découpler la partie mesure à proprement parler (réalisée à l’aide de sondes logicielles) de la partie agrégation des mesures pour calculer une estimation globale de la consommation. La partie mesure est réalisée par le déploiement de capteurs (sensors). Ces capteurs s’appuient sur les outils systèmes pour effectuer des mesures brutes de consommation, et peupler une base de données. Afin d’exploiter ces données, il faut déployer une formule (formula), qui se charge de récupérer les mesures brutes, de les agréger (par exemple s’il y a plusieurs capteurs), et de les transformer en estimations de consommation exploitables, dont elle peuplera une base de données de sortie.

Prérequis

Avant même de déployer des sondes logicielles propulsées par PowerAPI, il est préférable d’avoir sous la main un Système de Gestion de Bases de Données installé et configuré pour pouvoir accueillir les données recueillies par les sondes. Par défaut, les différentes sondes et formules fournies par PowerAPI sont capables d’exporter les données dans une base MongoDB, ce qui reste une manière simple et efficace de stocker et d’exploiter les données exportées (un export sous forme de fichiers CSV est également possible). Pour un export MongoDB, il faut donc avoir une instance de ce Système de Gestion de Bases de Données déployée.

On peut par exemple déployer cette instance localement en lançant le serveur de la manière suivante :

$ mongod --shardsvr --dbpath powerapi --port 27021

En outre, la plupart des capteurs et formules disponibles sur la page de PowerAPI sont fournis non seulement sous forme de code, mais également sous forme de conteneur Docker prêt à l’emploi. Ainsi, l’installation de Docker, même si elle n’est pas strito sensu nécessaire, facilite grandement le recueil des mesures.

Enfin, le capteur HWPC fourni pour la mesure des indicateurs de performance s’appuie sur la technologie RAPL (voir sec. 1), ce qui signifie que pour fonctionner, il a besoin d’être déployé sur une machine compatible avec cette technologie (processeur Intel à partir de la génération Sandy Bridge, accès direct aux compteurs, donc pas de machine virtuelle). En outre, pour effectuer ses relevés de consommation, ce capteur a besoin d’un accès privilégié à la machine hôte. Très concrètement, cela signifie que si on déploie ce capteur directement sur la machine hôte sans passer par Docker, il nous faudra un accès superutilisateur. Cela peut être contourné si l’on utilise la version Docker, qui peut avoir un accès privilégié au système hôte. Cela nécessitera bien entendu que l’utilisateur que l’on utilise soit bien autorisé à lancer des conteneurs docker avec l’option --priviledged.

Déployer le capteur HWPC

L’un des capteurs fournis par PowerAPI est le capteur HWPC-Sensor (Hardware Performance Counters Sensor). Ce capteur s’appuie sur l’API perf que nous avons évoquée en sec. 1.

Le capteur peut être déployé en utilisant le conteneur Docker fourni :

$ docker run --net=host --privileged --name powerapi-sensor \
           -v /sys:/sys -v /var/lib/docker/containers:/var/lib/docker/containers:ro \
           -v /tmp/powerapi-sensor-reporting:/reporting \
           powerapi/hwpc-sensor:latest \
           -n $SENSOR_NAME \
           -r "mongodb" -U $MONGO_ADDR -D $DB -C $COL \
           -s "rapl" -o -e RAPL_ENERGY_PKG \
           -s "msr" -e "TSC" -e "APERF" -e "MPERF" \
           -c "core" -e "CPU_CLK_THREAD_UNHALTED:REF_P" \
                     -e "CPU_CLK_THREAD_UNHALTED:THREAD_P" \
                     -e "LLC_MISSES" -e "INSTRUCTIONS_RETIRED"

Ici, il faut remplacer les variables SENSOR_NAME, MONGO_ADDR, DB et COL, représentant respectivement le nom du capteur (qu’on peut choisir arbitrairement, mais unique pour une campagne de mesure), l’adresse de l’instance de MongoDB, le nom de la base de données dans laquelle exporter, et le nom de la collection Mongo utilisée, par les valeurs qui conviennent. Ainsi, par exemple :

SENSOR_NAME="hwpcSensor"
MONGO_ADDR="mongodb://127.0.0.1:27021"
DB="powerapi"
COL="hwpcMeasures"

Le paramètre -e permet de spécifier la liste des événements que l’on souhaite mesurer. On doit spécifier un nom de groupe pour chaque groupe d’événements que l’on souhaite analyser : il s’agit du paramètre -c. La liste de événements à mesurer dépend de la formule qui sera utilisée pour calculer la consommation. Les paramètres donnés dans la commande ci-dessus sont ceux qui sont préconisés pour l’utilisation de la formule SmartWatts, que nous allons utiliser.

Une fois que le capteur HWPC est lancé, on peut vérifier la collecte des données en se connectant à la base Mongo concernée.

$ mongo mongodb://localhost:27021
MongoDB shell version v3.6.8
connecting to: mongodb://localhost:27021
[...]

test@machine:27021 >>> use powerapi
switched to db powerapi
powerapi@machine:27021 >>> db.hwpcMeasures.find().pretty()
{
    "_id" : ObjectId("600bc903e0f2d02f11007902"),
    "timestamp" : ISODate("2021-01-23T06:58:11.134Z"),
    "sensor" : "hwpcSensor",
    "target" : "all",
    "groups" : {
        "rapl" : {
            "0" : {
                "3" : {
                    "RAPL_ENERGY_PKG" : 34340864,
                    "time_enabled" : 1382541,
                    "time_running" : 1382541
                }
            }
        },
        "msr" : {
            "0" : {
                "3" : {
                    "MPERF" : 3257807,
                    "APERF" : 1768703,
                    "TSC" : 3281312,
                    "time_enabled" : 1367132,
                    "time_running" : 1367132
                },
                "0" : {
                    "MPERF" : 3174711,
                    "APERF" : 1721538,
                    "TSC" : 3297630,
                    "time_enabled" : 1373942,
                    "time_running" : 1373942
                },
                "1" : {
                    "MPERF" : 2109174,
                    "APERF" : 1149755,
                    "TSC" : 3298888,
                    "time_enabled" : 1374444,
                    "time_running" : 1374444
                },
                "2" : {
                    "MPERF" : 3264342,
                    "APERF" : 1771445,
                    "TSC" : 3284442,
                    "time_enabled" : 1368436,
                    "time_running" : 1368436
                }
            }
        }
    }
}
[...]

L’examen de la collection nous montre que le capteur HWPC exporte ses données sous deux cibles (target) distinctes : all et powerapi-sensor. La cible powerapi-sensor contient le groupe core qui se focalise sur les événements paramétrés avec -c core lors du déploiement du capteur. La cible all, quant à elle, relève les événements liés aux groupes msr et rapl.

Le groupe rapl a un statut spécial. Il permet de relever les événements liés à la consommation générale du processeur, en utilisant les sondes RAPL exposées par celui-ci. Selon l’architecture du processeur, les trois types d’événements suivants peuvent être disponibles :

  • RAPL_ENERGY_PKG : cet événement est dédié à la consommation de l’intégralité du socket CPU ;
  • RAPL_ENERGY_DRAM : cet événement est dédié à la consommation de la RAM ;
  • RAPL_ENERGY_GPU : cet événement est dédié au processeur graphique intégré.

Sur ma machine, l’événement RAPL_ENERGY_DRAM n’est pas disponible, mais RAPL_ENERGY_GPU l’est.

Déployer la formule SmartWatts

L’objectif d’une formule de calcul est de convertir les données brutes fournies par les capteurs RAPL en données énergétiques exploitables, c’est-à-dire des données de consommation exprimées en Watts. La formule de calcul va collecter les données issues des capteurs dans la base de données Mongo, et va stocker les données converties dans une autre base de données Mongo.

Le site de PowerAPI (INRIA 2020) propose d’utilise la formule simple RAPL pour faire des relevés généraux de la consommation à l’échelle d’un processeur. Cette formule est présentée comme une formule de démonstration pour illustrer les capacités de PowerAPI. En production, il est plutôt conseillé d’utiliser la formule SmartWatts qui permet une mesure beaucoup plus fine de la consommation. Le fonctionnement de la formule s’appuie sur une calibration en ligne qui permet un ajustement en temps réel du modèle de consommation à la machine sur laquelle elle est déployée. Le principe de fonctionnement est décrit en détails dans l’article de Fieni et al. (Fieni, Rouvoy, and Seinturier 2020).

Concrètement, la formule SmartWatts5 se présente sous la forme d’un programme Python que l’on peut installer et lancer, mais elle existe également sous la forme d’un conteneur Docker que l’on peut lancer comme suit :

$ docker run -td --net=host --name powerapi-formula powerapi/smartwatts-formula \
           -s \
           --input mongodb --model HWPCReport \
                           -u $MONGO_ADDR -d $INPUT_DB -c $INPUT_COL \
           --output mongodb --name power --model PowerReport \
                            -u $MONGO_ADDR -d $OUTPUT_DB -c $OUTPUT_COL \
           --output mongodb --name formula --model FormulaReport \
                            -u $MONGO_ADDR -d $OUTPUT_DB -c frep \
           --formula smartwatts --cpu-ratio-base $BASE_CPU_RATIO \
                                --cpu-ratio-min $MIN_CPU_RATIO \
                                --cpu-ratio-max $MAX_CPU_RATIO \
                                --cpu-error-threshold 2.0 \
                                --dram-error-threshold 2.0 \
                                --disable-dram-formula

Ici, il faut remplacer les variables respectives INPUT_* et OUTPUT_* par les informations concernant la connexion à la base de données d’entrée (données capteurs) et de sortie (écriture des données de consommation) de la formule SmartWatts. Ainsi par exemple :

MONGO_ADDR="mongodb://127.0.0.1:27021"
INPUT_DB="powerapi"
INPUT_COL="hwpcMeasures"
OUTPUT_DB="powerapi"
OUTPUT_COL="smartWattsValues"

Il y a également trois nouveaux paramètres à configurer, les paramètres relatifs à la fréquence du processeur :

  • $BASE_CPU_RATIO est la fréquence nominale du processeur, divisée par 100 ;
  • $MIN_CPU_RATIO est la fréquence minimale du processeur, divisée par 100. Cette valeur peut être trouvée à l’aide de la commande lscpu (ligne CPU min MHz) ;
  • $MAX_CPU_RATIO est la fréquence maximale du processeur, divisée par 100. Cette valeur peut être trouvée à l’aide de la commande lscpu (ligne CPU max MHz).

Une fois que la formule SmartWatts est déployée, on peut vérifier la collecte des données en se connectant à la base Mongo concernée.

$ mongo mongodb://localhost:27021
MongoDB shell version v3.6.8
connecting to: mongodb://localhost:27021
[...]

test@machine:27021 >>> use powerapi
switched to db powerapi
powerapi@machine:27021 >>> db.smartWattsValues.find().pretty()
{
    "_id" : ObjectId("600ef211804a0d545698ced4"),
    "timestamp" : ISODate("2021-01-25T16:29:57.533Z"),
    "sensor" : "hwpcSensor",
    "target" : "rapl",
    "metadata" : {
        "scope" : "cpu",
        "socket" : "0",
        "formula" : "RAPL_ENERGY_PKG",
        "ratio" : 1,
        "predict" : 0
    },
    "power" : 6.37811279296875,
    "socket" : "0"
}
{
    "_id" : ObjectId("600ef212804a0d545698ced5"),
    "timestamp" : ISODate("2021-01-25T16:29:58.534Z"),
    "sensor" : "hwpcSensor",
    "target" : "rapl",
    "metadata" : {
        "scope" : "cpu",
        "socket" : "0",
        "formula" : "RAPL_ENERGY_PKG",
        "ratio" : 1,
        "predict" : 0
    },
    "power" : 4.3121337890625,
    "socket" : "0"
}
[...]

Ici, la valeur indiquée dans le champ power (6.37811279296875 et 4.3121337890625 en l’occurrence) correspond à la valeur de consommation estimée par la formule, exprimée en watts.

Mesure de la consommation pour un groupe de processus

PowerAPI fournit également la possibilité de mesurer la consommation d’un processus unique ou d’un groupe de processus. Cela passe par l’utilisation des cgroups6, fonctionnalité incluse dans le noyau Linux depuis la version 2.6.24, et dont l’objectif est de mettre en place et de gérer des groupes de processus, dans le but de mesurer, limiter, ou contrôler l’accès à certaines ressources.

Techniquement, le capteur HWPC est capable de monitorer la consommation de tous les groupes de processus rattachés au contrôleur perf_event, qui est l’un des contrôleurs présents par défaut7.

La première étape pour cela est de créer un groupe de processus dont le contrôleur est perf_event. Cela peut se faire grâce à la commande (lancée en tant que superutilisateur) :

# cgcreate -g perf_event:$GROUP

Ici, $GROUP est le nom du groupe de processus qui servira à identifier la consommation. Cette commande doit être exécutée en tant que superutilisateur.

Ensuite, on peut rattacher tous les processus que l’on souhaite surveiller au groupe nouvellement créé.

# cgclassify -g perf_event:$GROUP $(pidof $PROGRAM_NAME)

Ici, on rattache tous les processus qui se rattachent au nom de programme $PROGRAM_NAME au groupe $GROUP.

Une fois cette classification accomplie, il faut, tout comme dans la sec. 2.1.2, déployer le capteur HWPC, puis la formule SmartWatts (voir sec. 2.1.3) pour transformer les données brutes en mesures estimées de la consommation. Dans les données exportées par cette formule, on trouve désormais toutes les données concernant les groupes de processus que nous avons crées. Ainsi, par exemple, si nous créons le groupe firefox pour monitorer les données de consommation du navigateur, nous retrouvons dans notre collection Mongo un ensemble d’objets dont la cible (target) correspond à la valeur /firefox :

powerapi@machine:27021 >>> db.smartWattsValues.find({"target": "/firefox"}).pretty()
{
    "_id" : ObjectId("6012bdb89279bf32da7f4b00"),
    "timestamp" : ISODate("2021-01-28T13:35:46.670Z"),
    "sensor" : "hwpcSensor",
    "target" : "/firefox",
    "metadata" : {
        "scope" : "cpu",
        "socket" : "0",
        "formula" : "4f87dd0a3afd331acc89541f4cd46e856f119c59",
        "ratio" : 0.20662675213078738,
        "predict" : 1.835740527645692
    },
    "power" : 1.835740527645692,
    "socket" : "0"
}
[...]

Traitement et visualisation des données

Il existe plusieurs possibilités pour traiter les données de consommation issues de PowerAPI. Une possibilité est d’utiliser un outil de visualisation tel que Grafana8, qui nécessitera un export des données dans une base de données temporelle InfluxDB9. Une autre possibilité est d’exporter les données dans un format utilisable facilement (CSV par exemple) afin de pouvoir l’exploiter dans un logiciel d’analyse. Là encore, il existe plusieurs possibilités : soit on peut directement exporter les données issues de la formule RAPL dans ce format, en paramétrant correctement la sortie du script de lancement ; soit on peut exporter ces données depuis la collection Mongo adéquate grâce à mongoexport. La commande suivante exporte toute les données de la collection smartWattsValues en se limitant aux champs target, timestamp et power, qui sont suffisants pour une analyse et une visualisation basiques.

$ mongoexport --uri "mongodb://localhost:27021/powerapi" \
              --collection=smartWattsValues --type=csv \
              --fields=target,timestamp,power --out=$OUTPUT_FILE

Il est ensuite possible d’exploiter ces données en utilisant les logiciels ou les bibliothèques adéquates. Voici un exemple de script Python permettant simplement de tracer le graphe de consommation en fonction du temps, en utilisant les bibliothèques pandas (pour la lecture du fichier CSV) et matplotlib (pour le tracé du graphe).

#!/usr/bin/env python3

import sys
import pandas as pd
import matplotlib.pyplot as plt
import dateutil.parser
import matplotlib.dates as mdates

MOVING_AVG_WINDOW = 50
NB_X_TICKS = 20
EXCLUDE_TARGETS = {'rapl', 'powerapi-formula', 'powerapi-sensor'}

if len(sys.argv) < 2:
    sys.stderr.write(f"Usage: {sys.argv[0]} csv_file\n")
    sys.exit(1)

# We read the CSV file
df = pd.read_csv(sys.argv[1])
    
# We create the figure and configure the plot
fig = plt.figure()
ax = fig.add_subplot(111)

# We group the tuples by target
gb = df.groupby('target')

# We plot the graph for each group
# Using a rolling average to smooth the graph and better show trends
for name, group in gb:
    if name not in EXCLUDE_TARGETS:
        ax.plot([dateutil.parser.isoparse(d) for d in group['timestamp']],
                group['power'].rolling(MOVING_AVG_WINDOW).mean(), label=name)

# Now we configure the plot
ax.grid(color=(0.9, 0.9, 0.9))
x = [dateutil.parser.isoparse(d) for d in df['timestamp']]
# We show only NB_X_TICKS ticks on the x axis
ax.set_xticks(
    [x[i] for i in range(len(x) // NB_X_TICKS // 2, len(x), len(x) // NB_X_TICKS)])
# We format the date
ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S'))
fig.autofmt_xdate()
# We set the axes labels
ax.set_xlabel("Temps")
ax.set_ylabel("Consommation (watts)")
# We position the legend
plt.legend(loc="upper left", frameon=True)
# We show the graph
plt.show()

Ce script produit le graphe apparaissant dans la figure 2. Nous nous limitons à l’estimation de la consommation globale, et à deux groupes de processus créés au préalable avec cgcreate comme indiqué ci-dessus. La figure montre une moyenne glissante des données avec une fenêtre de 50 échantillons. Cela permet de mieux visualiser les tendances en lissant les variations locales, mais annule les valeurs extrêmes (ainsi, par exemple, dans les données d’entrée visualisées sur la figure, la valeur de consommation maximale est d’environ 58 W, ce qui ne transparaît pas du tout sur le graphe).

Figure 2: Visualisation des données de consommation sur un graphe en fonction du temps, en moyenne glissante de 50 échantillons.

PyJoules

Le middleware PowerAPI présenté dans la sec. 2 est dédié essentiellement à la mesure de consommation énergétique à l’échelle d’un cluster. Le cas d’usage typique de PowerAPI est le suivi de consommation sur du long terme. Il n’a a priori pas été pensé pour faire de la mesure comparative de performances d’algorithmes ou de programmes.

Si l’on souhaite effectuer des mesures comparatives de performance d’algorithmes, on peut utiliser la bibliothèque PyJoules10. Il s’agit d’une bibliothèque Python qui s’appuie sur les compteurs RAPL (voir sec. 1) pour mesurer l’empreinte énergétique d’une machine lors de l’exécution de portions de code Python. PyJoules est également capable de mesurer la consommation énergétique de certains processeurs graphiques NVidia, en s’appuyant sur la bibliothèque Nvidia Management Library.

Il faut noter que PyJoules mesure la consommation d’énergie globale au niveau de tous les processus, et non au niveau du simple programme que l’on cherche à mesurer. C’est pourquoi il est important d’éliminer tous les programmes extérieurs lorsque l’on effectue le relevé, afin de minimiser les consommations additionnelles induites qui auraient pour effet de biaiser les mesures.

Utilisation de PyJoules

La manière la plus simple d’utiliser PyJoules est d’équiper les fonctions dont on souhaite mesurer la consommation à l’aide du décorateur @measure_energy

from pyJoules.energy_meter import measure_energy

@measure_energy
def python_sort(shuffled):
    shuffled.sort()

Lorsque l’on le lance, le programme affiche une ligne de mesure pour chaque passage dans la fonction équipée du décorateur en question :

begin timestamp : 1612345928.8571808; tag : python_sort; duration : 0.0008618831634521484; package_0 : 7751.0; dram_0 : 610.0; core_0 : 3418.0; uncore_0 : 0.0

Ici, la durée est exprimée en secondes, et la consommation des différents domaines en microjoules (μJ).

Le décorateur peut également être paramétré par :

  • l’ensemble des différents domaines, parmi pyJoules.device.rapl_device.RaplPackageDomain, pyJoules.device.rapl_device.RaplDramDomain, pyJoules.device.rapl_device.RaplUnCoreDomain, pyJoules.device.rapl_device.RaplCoreDomain et pyJoules.device.nvidia_device.NvidiaGPUDomain
  • un gestionnaire (handler), qui permet de spécifier vers quelle sortie les données seront envoyées. Les gestionnaires disponibles sont :
    • la sortie standard (par défaut) ;
    • le gestionnaire CSV (pyJoules.handler.csv_handler.CSVHandler) qui exporte les mesures sous la forme d’un fichier CSV ;
    • le gestionnaire Pandas (pyJoules.handler.pandas_handler.PandasHandler) qui exporte les mesures sous la forme d’une variable de type DataFrame de la bibliothèque pandas ;
    • le gestionnaire MongoDB (pyJoules.handler.mongo_handler.MongoHandler) qui exporte les données vers une collection Mongo.

Outre l’utilisation d’un décorateur, il est également possible d’utiliser un gestionnaire de contexte (ContextManager) qui rend l’instrumentation du code plus souple :

from pyJoules.energy_meter import measure_energy

@measure_energy(handler=pandas_handler)
def numpy_sort(tab):
    with EnergyContext(handler=pandas_handler, start_tag='Numpy create') as ctx:
        array = np.array(tab)
        ctx.record(tag='Numpy sort')
        np.sort(array)

Ainsi, par exemple, le code ci-dessus instrumente une partie du code de tri, en effectuant deux relevés : le premier correspond au code situé entre la création de l’objet EnergyContext et l’instruction ctx.record(), et le second entre cette instruction et la fin du gestionnaire de contexte.

Cas d’utilisation complet

Nous allons dans cette section présenter un cas d’utilisation complet de mesure de consommation avec pyJoules. Nous nous fixons comme objectif de comparer différents algorithmes de tri à l’aune de leur consommation énergétique et d’afficher le résultat sous forme de graphique avec Matplotlib.

Commençons par le préambule du programme, et quelques bibliothèques dont nous allons nous servir :

#!/usr/bin/python3

import numpy as np
import random
import sys

from pyJoules.energy_meter import measure_energy, EnergyContext
from pyJoules.handler.pandas_handler import PandasHandler
import matplotlib.pyplot as plt
import matplotlib.cm as cm

Pour le traitement des données, nous allons utiliser la bibliothèque Pandas, et le gestionnaire pyJoules qui lui est associé :

pandas_handler = PandasHandler()

Instrumentation du code de tri

La deuxième partie du programme est dédiée aux algorithmes de tri à proprement parler, instrumentés pour les relevés de consommation. Le tri par insertion :

@measure_energy(handler=pandas_handler)
def insertion_sort(tab):
    for i in range(1, len(tab)):
        j = i
        while tab[j - 1] > tab[j] and j > 0:
            tab[j - 1], tab[j] = tab[j], tab[j - 1]
            j -= 1

Le tri rapide (quicksort), avec un choix de pivot déterministe défini comme le premier élément du tableau à trier :

def qsort(tab):
    def split(tab, first_idx, last_idx, pivot_idx):
        tab[pivot_idx], tab[last_idx] = tab[last_idx], tab[pivot_idx]
        j = first_idx
        for i in range(first_idx, last_idx):
            if tab[i] <= tab[last_idx]:
                tab[i], tab[j] = tab[j], tab[i]
                j += 1
        tab[last_idx], tab[j] = tab[j], tab[last_idx]
        return j

    def quick_sort(tab, first_idx, last_idx):
        if first_idx < last_idx:
            pivot_idx = first_idx
            pivot_idx = split(tab, first_idx, last_idx, pivot_idx)
            quick_sort(tab, first_idx, pivot_idx - 1)
            quick_sort(tab, pivot_idx + 1, last_idx)

    quick_sort(tab, 0, len(tab) - 1)

La fonction de tri par défaut de Python fondée sur l’algorithme TimSort :

@measure_energy(handler=pandas_handler)
def python_sort(tab):
    tab.sort()

La fonction de tri par défaut de Numpy, fondée sur l’algorithme QuickSort. Ici, nous instrumentons à la fois la portion de code dédiée à l’exécution de l’algorithme lui-même, et la portion de code dédiée à la création du tableau Numpy à partir d’une liste Python :

def numpy_sort(tab):
    with EnergyContext(handler=pandas_handler, start_tag='Numpy create') as ctx:
        array = np.array(tab)
        ctx.record(tag='Numpy sort')
        np.sort(array)

Traitement des données pour la visualisation

La troisième partie du programme est dédiée au traitement des données exportées par pyJoules sous la forme d’un objet DataFrame de la bibliothèque Pandas. Nous commençons par utiliser cette bibliothèque pour calculer un regroupement des données par compteur (tag), et calculer la moyenne et l’écart-type pour chaque groupe, en effectuant une agrégation.

Le code restant est dédié au tracé du diagramme à barres, équipé des marges d’erreur pour chaque classe du diagramme.

def process_data(df):
    # We group the tuples by algorithm and compute the means and standard deviations
    aggregate = df.groupby('tag').agg([np.mean, np.min, np.max])

    # We get the list of domains (package, dram,...) and the list of algorithms tested
    domains = ['core_0', 'uncore_0', 'package_0', 'dram_0']
    algos = aggregate.head().index.values
    N = len(algos)
    M = len(domains)

    means = [[aggregate[domain]['mean'][algo] / 1000000 for algo in algos]
             for domain in domains]
    mins = [[aggregate[domain]['amin'][algo] / 1000000 for algo in algos]
            for domain in domains]
    maxs = [[aggregate[domain]['amax'][algo] / 1000000 for algo in algos]
            for domain in domains]

    ind = np.arange(N)

    # We configure the bar chart
    bar_width = 0.15  # bar width
    bar_sep = 0.05
    total_width = (M - 1) * (bar_width + bar_sep)
    unit_offset = total_width / (M - 1)

    def offset(i):
        return (- total_width / 2 + i * unit_offset + unit_offset / 2)

    colors = cm.get_cmap('Paired')  # color sheme
    fig = plt.figure()
    ax = fig.add_subplot(111)

    # We draw the bar charts with error bars
    bars = [None] * M
    for i in range(M - 1):
        bars[i] = ax.bar(ind + offset(i), means[i], bar_width, color=colors(2*i))
        ax.errorbar(ind + offset(i), means[i], yerr=[mins[i], maxs[i]],
                    fmt='o', markersize=4, capsize=4,
                    color=colors(2*i + 1))
    bars[M - 1] = ax.bar(ind + offset(M - 2), means[M - 1], bar_width, bottom=means[M - 2],
                         color=colors(2*M - 2))
    ax.errorbar(ind + offset(M - 2) + 0.05, np.array(means[M - 1]) + means[M - 2],
                yerr=[mins[M - 1], maxs[M - 1]],
                fmt='o', markersize=4, capsize=4,
                color=colors(2*M - 1))

    # We configure the axes and legend
    ax.set_axisbelow(True)
    ax.yaxis.grid(color=(0.8, 0.8, 0.8), linestyle='dashed')
    plt.ylabel('Consommation moyenne (joules, échelle logarithmique)')
    plt.yscale('log')
    plt.xlabel('Algorithme')
    plt.xticks(ind, algos)
    ax.legend([bar[0] for bar in bars], domains)

    plt.show()

Script de lancement des tests

Enfin, la dernière partie du programme est dédiée au lancement des tests à proprement parler. Nous pouvons paramétrer à la ligne de commande la taille des listes à trier, ainsi que le nombre d’exécutions de chaque algorithme de tri. La liste à trier est une permutation aléatoire de l’intervalle d’entiers \(\{ 0, ..., N-1 \}\), où \(N\) est initialisé grâce à la ligne de commande.

def test_sort(tab, sort_func, nb_tries):
    for i in range(nb_tries):
        cpy = tab.copy()
        sort_func(cpy)


if __name__ == "__main__":
    if len(sys.argv) < 3:
        sys.stderr.write(f"Usage: {sys.argv[0]} size_list nb_tries\n")
        sys.exit(1)

    size_list, nb_tries = map(int, sys.argv[1:3])
    shuffled = list(range(size_list))
    random.seed(123456789)
    tested_algorithms = [insertion_sort, qsort, python_sort, numpy_sort]
    for algo in tested_algorithms:
        random.shuffle(shuffled)
        test_sort(shuffled, algo, nb_tries)

    df = pandas_handler.get_dataframe()
    process_data(df)

La figure 3 montre le résultat de l’exécution de ce script sur des listes de 10000 éléments, chaque algorithme étant exécuté 10 fois. Les domaines core_0 et uncore_0 étant des sous-domaines du domaine package_0, l’invariant core_0 + uncore_0 <= package_0 est censé être vérifié pour chaque algorithme. En pratique, les incertitudes de mesures font qu’il ne l’est pas toujours. Dans la figure, la barre empilée package_0 + dram_0 montre la consommation totale des domaines mesurés par pyJoules (voir figure 1 pour un rappel sur le périmètre englobé par chaque domaine). En pratique, dans ce cas de figure, on peut voir que la consommation du domaine dram_0 est faible par rapport aux autres consommations.

D’après la figure 3, on peut constater qu’il y a presque 4 ordres de grandeur entre l’algorithme le moins efficace énergétiquement (tri par insertion codé manuellement) et l’algorithme le plus efficace (quicksort Numpy).

Enfin, afin de donner un ordre de grandeur, la consommation énergétique engendrée par le tri par insertion tourne en moyenne autour de 55 joules, ce qui revient approximativement à 0,015 wattheures. À titre de comparaison, la consommation énergétique engendrée par le tri Numpy de 1000000 de valeurs (soit 100 fois plus) tourne en moyenne autour de 0,7 J (0,00019 Wh) pour la création du tableau Numpy et 1,3 J (0,000361 Wh) pour le tri en lui-même.

Bien entendu, toutes ces valeurs ne sont qu’approximatives, étant donné que la mesure a été faite dans un environnement où d’autres processus s’exécutaient en parallèle (en particulier l’environnement graphique de bureau).

Figure 3: Comparaison des données de consommation pour quatre algorithmes de tri, en fonction des domaines considérés. Moyenne sur 10 exécutions, sur des listes de 10000 éléments.

Nous pouvons par curiosité refaire les mêmes analyses sur des listes déjà triées. La figure 4 montre que les résultats sont très différents. La forte empreinte énergétique de l’algorithme quicksort codé en Python s’explique très probablement par le fait que le choix déterministe du pivot, fixé au premier élément du tableau, impose dans ce cas une profondeur de récursion linéaire (il faut d’ailleurs augmenter la taille de la pile Python car dans le cas de 10000 éléments, elle est par défaut insuffisante).

Figure 4: Comparaison des données de consommation pour quatre algorithmes de tri, en fonction des domaines considérés. Moyenne sur 10 exécutions, sur des listes déjà triées de 10000 éléments.

CodeCarbon

CodeCarbon (Schmidt et al. 2021) est une bibliothèque Python sous licence MIT développée par un collectif de data scientists. L’objectif de cette bibliothèque est de fournir un outil permettant d’estimer très simplement l’impact carbone de l’exécution d’une portion de code. Même si la page Web de CodeCarbon11 met en avant l’utilisation de cette bibliothèque pour l’évaluation d’impact d’algorithmes d’apprentissage machine, elle peut être utilisée avec n’importe quel code Python, de la même manière que PyJoules (cf sec. 3).

Afin de calculer l’impact carbone d’un calcul, CodeCarbon a besoin de deux éléments :

  1. un système de mesure de consommation d’énergie sur le système hôte ;
  2. une formule de conversion de l’énergie consommée (en J ou Wh) en équivalent CO2.

Mesure de la consommation d’énergie

Afin de pouvoir évaluer l’énergie consommée par le programme, CodeCarbon va s’appuyer sur Intel Power Gadget sous Windows et Mac, et sur le système RAPL (voir sec. 1) sous Linux, ce qui nécessite donc un accès aux fichiers de /sys/class/powercap/intel-rapl, tout comme pour PyJoules − CodeCarbon accède notamment aux fichiers energy_uj qui contiennent la valeur de consommation cumulée en µJoules. Si jamais CodeCarbon ne parvient pas à accéder à ces sondes logicielles, la bibliothèque utilisera une stratégie de repli pour évaluer la consommation, à savoir s’appuyer sur le thermal design power (TDP) du CPU hôte. CodeCarbon s’appuie pour cela sur une liste de données de plusieurs centaines de CPU AMD et Intel. Si CodeCarbon ne parvient pas à trouver le CPU hôte dans cette liste, il suppose une valeur par défaut à 85W. Dans tous les cas, CodeCarbon suppose que le CPU fonction à 50% de son TDP.

Évaluation de l’impact carbone

Afin de pouvoir calculer l’impact carbone d’un calcul, CodeCarbon transforme la quantité d’énergie électrique consommée en un équivalent CO2. Pour ce faire, la bibliothèque estime l’intensité carbone du système de production électrique du pays dans lequel a lieu le calcul en s’appuyant sur une base de données recensant le mix électrique pour la plupart des pays du monde. Ce mix électrique est réparti en quatre sources :

  1. charbon ;
  2. pétrole ;
  3. gaz naturel ;
  4. autre source à faible impact carbone (hydroélectricité, solaire, éolien, nucléaire…)

Pour chacune de ces sources, un coefficient d’intensité carbone est utilisé. L’intensité carbone est donc simplement calculée comme une somme de ces coefficients d’intensité pondérée par les pourcentages de chaque source dans le mix énergétique du pays. À noter toutefois que ces coefficients d’intensité ne prennent en compte que les émissions durant la phase de génération, et donc sont légèrement sous-estimées. En particulier, pour les sources de type 4 (autre), le coefficient d’intensité est considéré comme étant égal à 0.

Utilisation de CodeCarbon

L’utilisation de CodeCarbon est très similaire à celle de PyJoules. On peut par exemple instrumenter une fonction Python grâce à l’utilisation d’un décorateur :

from codecarbon import track_emissions

@track_emissions
def python_sort(tab):
    tab.sort()

Ce décorateur par défaut nécessite un accès Internet afin de déterminer la localisation de la machine sur laquelle est lancée le calcul. Si l’on ne souhaite pas utiliser cette fonctionnalité de détection (ou que le calcul est lancé hors-ligne), il faut utiliser le paramètre offline=True et préciser en même temps le pays dans lequel le calcul se déroule :

from codecarbon import track_emissions

@track_emissions(offline=True, country_iso_code="FRA")
def python_sort(tab):
    tab.sort()

Une autre manière de procéder, sans passer par le décorateur, est d’instancier directement un objet de type EmissionsTracker ou OfflineEmissionsTracker et de le lancer manuellement :

from codecarbon import EmissionsTracker

def python_sort(tab):
    tracker = EmissionsTracker()
    tracker.start()
    tab.sort()
    tracker.stop()

La liste de tous les paramètres pouvant être passés à ces objets est détaillée sur la page de documentation.

Exploitation des résultats

Une fois le calcul terminé, CodeCarbon stocke les résultats dans un fichier CSV (par défaut emissions.csv) qui contient une ligne de données par projet. Un projet peut être défini simplement en fixant la valeur du paramètre project_name à l’instanciation du décorateur ou de l’objet EmissionsTracker. Fixer ce paramètre permet de recenser plusieurs valeurs différentes pour différents endroits du code.

Il faut noter que CodeCarbon ne stocke qu’une seule valeur par projet, bien qu’il soit possible de régler la fréquence avec laquelle la bibliothèque effectue ses mesures de consommation (avec le paramètre measure_power_secs). Étant donné que CodeCarbon lit les valeurs des sondes RAPL en quantité d’énergie (en microjoules), et non en puissance, et que CodeCarbon ne fournit pas de relevés instantanés de consommation énergétique pour observer une évolution dans le temps, l’utilité de ce paramètre measure_power_secs ne semble pas extrêmement claire.

Voici un exemple de fichier de sortie produit par CodeCarbon sur l’exemple du tri déjà utilisé pour PyJoules (voir sec. 3.1.2) :

To Do
Fieni, Guillaume, Romain Rouvoy, and Lionel Seinturier. 2020. SmartWatts: Self-Calibrating Software-Defined Power Meter for Containers.” In CCGRID 2020 - 20th IEEE/ACM International Symposium on Cluster, Cloud and Internet Computing. https://doi.org/10.1109/CCGrid49817.2020.00-45.
INRIA, University of Lille. 2020. “PowerAPI.” 2020. http://powerapi.org/.
Schmidt, Victor, Kamal Goyal, Aditya Joshi, Boris Feld, Liam Conell, Nikolas Laskaris, Doug Blank, Jonathan Wilson, Sorelle Friedler, and Sasha Luccioni. 2021. CodeCarbon: Estimate and Track Carbon Emissions from Machine Learning Computing.” https://doi.org/10.5281/zenodo.4658424.
Zhang, Zhenkai, Sisheng Liang, Fan Yao, and Xing Gao. 2021. “Red Alert for Power Leakage: Exploiting Intel RAPL-Induced Side Channels.” In ASIA CCS ’21: ACM Asia Conference on Computer and Communications Security, Virtual Event, Hong Kong, June 7-11, 2021, edited by Jiannong Cao, Man Ho Au, Zhiqiang Lin, and Moti Yung, 162–75. ACM. https://doi.org/10.1145/3433210.3437517.

  1. On peut par exemple trouver quelques explications par sur le blog de Chi (https://blog.chih.me/read-cpu-power-with-RAPL.html), ou encore sur la page de pyJoules (https://pyjoules.readthedocs.io/en/latest/devices/intel_cpu.html#domains).↩︎

  2. Voir la page de Vince Weaver pour plus d’informations sur la question : http://web.eece.maine.edu/~vweaver/projects/rapl/index.html↩︎

  3. https://github.com/powercap/powercap↩︎

  4. https://perf.wiki.kernel.org/↩︎

  5. https://powerapi-ng.github.io/smartwatts.html↩︎

  6. https://www.kernel.org/doc/Documentation/cgroup-v1/↩︎

  7. Pour vérifier qu’il est bien présent, cat /proc/cgroups | grep perf_event↩︎

  8. https://grafana.com/↩︎

  9. https://www.influxdata.com/↩︎

  10. https://github.com/powerapi-ng/pyJoules↩︎

  11. https://github.com/mlco2/codecarbon↩︎