Il m’arrive très fréquemment de compiler des noyaux Linux, souvent durant des sessions de formation ou des prestations d’ingénierie (principalement dans le domaine de l’embarqué ou le développement de drivers), et parfois à titre expérimental ou par simple curiosité pour rédiger des articles ou mon prochain livre.
La durée de compilation varie beaucoup en fonction de la quantité de code (de drivers, systèmes de fichiers, protocoles, etc.) et de la puissance de la machine hôte. Sur un PC de milieu de gamme, la compilation d’un kernel ajusté pour un système embarqué dure environ trois minutes. Sur une machine d’entrée de gamme (ou un peu ancienne), la compilation d’un noyau générique pour PC (disposant donc de centaines de drivers sous forme de modules) peut durer une heure.
Pour tirer parti du parallélisme proposé par les processeurs actuels (systèmes multiprocesseurs, multicoeurs ou avec hyper-threading), la commande make
nous permet de lancer simultanément plusieurs jobs. Ainsi
$ make -j 4
s’arrangera pour qu’il y ait toujours quatre jobs de compilation actifs.
J’ai longtemps répété que « si vous avez N processeurs (ou coeurs, ou CPU virtuels) disponibles, vous gagnerez du temps de compilation en lançant 2N jobs en parallèle« . Ceci repose sur l’idée que pour chaque processeur nous avons un job qui effectue de la compilation (en consommant du temps CPU) et tandis qu’un autre job peut terminer de sauvegarder les résultats de la compilation précédente ou charger le fichier source du traitement suivant. Mais… est-ce vrai ?
Script de test
Pour en avoir le coeur net, j’ai écrit le petit script suivant, qui télécharge au besoin les sources d’un noyau et les décompresse, puis réalise plusieurs compilations en démarrant un nombre variable de jobs.
Par exemple si on lance
$ ./test-make-j.sh 3 5 8
Il effectue trois compilations complètes : l’une avec trois tâches en parallèle, la suivante avec cinq jobs et la dernière avec huit, les résultats étant cumulés dans un fichier de texte. Le script est le suivant.
test-make-j.sh #! /bin/sh KERNEL_VERSION="linux-3.2" KERNEL_URL_PATH="www.kernel.org/pub/linux/kernel/v3.0/" RESULT_FILE="compilation-timing.txt" if [ "$#" -eq 0 ] then echo "usage: $@ jobs_number..." >& 2 exit 0 fi if [ ! -d "${KERNEL_VERSION}" ] then if [ ! -f "${KERNEL_VERSION}.tar.bz2" ] then wget "${KERNEL_URL_PATH}/${KERNEL_VERSION}.tar.bz2" if [ $? -ne 0 ] || [ ! -f "${KERNEL_VERSION}.tar.bz2" ] then echo "unable to obtain ${KERNEL_VERSION} archive" >&2 exit 1 fi fi tar xjf "${KERNEL_VERSION}.tar.bz2" if [ $? -ne 0 ] then echo "Error while uncompressing kernel archive" >&2 exit 1 fi fi cd "${KERNEL_VERSION}" echo "# Timings of ${KERNEL_VERSION} compilations" >> "${RESULT_FILE}" nb_cpu=$(grep "^processor" /proc/cpuinfo | wc -l) echo "# Processors: ${nb_cpu}" >> "${RESULT_FILE}" affinity=$(taskset -p $$ | sed -e 's/^.*://') >> "${RESULT_FILE}" echo "# Affinity mask: ${affinity}" >> "${RESULT_FILE}" for nb in "$@" do echo "# Compiling with $nb simultaneous jobs" >> "${RESULT_FILE}" make mrproper make i386_defconfig sync sleep 10 # Let's all calm down start=$(date "+%s") make -j $nb sync end=$(date "+%s") # This script will fail during february 2038 ;-) echo "$nb $((end - start))" >> "${RESULT_FILE}" done
Résultats
Voici les résultats d’une exécution sur un processeur Intel Q6600 Quad-Core (fichier Intel-Q6600-1.txt)
# Timings of linux-3.2 compilations # Processors: 4 # Affinity mask: f # Compiling with 1 simultaneous jobs 1 675 # Compiling with 2 simultaneous jobs 2 346 # Compiling with 3 simultaneous jobs 3 241 # Compiling with 4 simultaneous jobs 4 197 # Compiling with 5 simultaneous jobs 5 198 # Compiling with 6 simultaneous jobs 6 194 # Compiling with 7 simultaneous jobs 7 195 # Compiling with 8 simultaneous jobs 8 196 # Compiling with 9 simultaneous jobs 9 197 # Compiling with 10 simultaneous jobs 10 198 # Compiling with 11 simultaneous jobs 11 198 # Compiling with 12 simultaneous jobs 12 198 # Compiling with 13 simultaneous jobs 13 200 # Compiling with 14 simultaneous jobs 14 201 # Compiling with 15 simultaneous jobs 15 201 # Compiling with 16 simultaneous jobs 16 200
Observons-les graphiquement avec cette petite ligne de commande pour Gnuplot. Horizontalement, nous voyons le nombre de jobs simultanés et verticalement le temps de compilation en secondes.
$ echo "set terminal png size 640,480 ; set output './Intel-Q6600-1.png'; plot 'Intel-Q6600-1.txt' with linespoints" | gnuplot
Visiblement, les meilleurs résultats sont atteints (à quelques fluctuations près) dès make -j 4
. Essayons de confirmer ceci. Avant de lancer le script, nous le limitons sur deux processeurs avec la commande suivante qui fixe les jobs lancés à partir du shell courant sur les processeurs 2 et 3.
$ taskset -pc 2-3 $$
Voici le résultat (fichier Intel-QL6600-2.txt).
# Timings of linux-3.2 compilations # Processors: 4 # Affinity mask: c # Compiling with 1 simultaneous jobs 1 684 # Compiling with 2 simultaneous jobs 2 360 # Compiling with 3 simultaneous jobs 3 362 # Compiling with 4 simultaneous jobs 4 366 # Compiling with 8 simultaneous jobs 8 370 # Compiling with 16 simultaneous jobs 16 376 # Compiling with 32 simultaneous jobs 32 377 # Compiling with 64 simultaneous jobs 64 378
Cette fois, il est visible que le minimum de temps est obtenu avec make -j 2. Si nous répétons l’expérience sur un seul CPU, on obtient les valeurs suivantes (fichier Intel-Q6600-3.txt).
# Timings of linux-3.2 compilations # Processors: 4 # Affinity mask: 8 # Compiling with 1 simultaneous jobs 1 683 # Compiling with 2 simultaneous jobs 2 698 # Compiling with 3 simultaneous jobs 3 708 # Compiling with 4 simultaneous jobs 4 709 # Compiling with 5 simultaneous jobs 5 719 # Compiling with 6 simultaneous jobs 6 719 # Compiling with 7 simultaneous jobs 7 720 # Compiling with 8 simultaneous jobs 8 724
Ce qui se représente sur le graphique suivant.
Nous pouvons regrouper ces trois courbes sur un même graphique pour mieux visualiser leurs échelles (je n’ai pas prolongé la courbe de la compilation sur un seul CPU, mais on peut imaginer qu’elle se poursuit avec une légère croissance).
Pour en avoir le coeur net, nous pouvons recommencer l’expérience sur un autre processeur avec deux coeurs (AMD QL66). Les résultats sont les suivants (fichier AMD-QL66-1.txt).
# Timings of linux-3.2 compilations # Processors: 2 # Affinity mask: 3 # Compiling with 1 simultaneous jobs 1 1113 # Compiling with 2 simultaneous jobs 2 844 # Compiling with 3 simultaneous jobs 3 875 # Compiling with 4 simultaneous jobs 4 863 # Compiling with 5 simultaneous jobs 5 840 # Compiling with 6 simultaneous jobs 6 844 # Compiling with 7 simultaneous jobs 7 844 # Compiling with 8 simultaneous jobs 8 851
Essayons une dernière expérience, sur la même machine (deux CPU), en désactivant deux éléments :
- la lecture anticipée des blocs suivants du disque (qui permet d’améliorer les lectures localisées) avec
echo 0 > /sys/block/sda/read_ahead_kb
- l’écriture différée (de 30 secondes environ) des blocs (qui évite les accès répétitifs au disque en cas de modifications successives) avec
mount / -o sync,remount
.
Cette fois les résultats sont très différents (fichier AMD-QL66-2.txt). Les temps sont beaucoup plus longs que précédemment car à chaque écriture sur le disque, le processus attend que les données soient transmises au périphérique pour continuer son travail.
Timings of linux-3.2 compilations # Processors: 2 # Affinity mask: 3 # Compiling with 1 simultaneous jobs 1 3487 # Compiling with 2 simultaneous jobs 2 2562 # Compiling with 3 simultaneous jobs 3 2198 # Compiling with 4 simultaneous jobs 4 1963 # Compiling with 5 simultaneous jobs 5 1779 # Compiling with 6 simultaneous jobs 6 1646 # Compiling with 7 simultaneous jobs 7 1636 # Compiling with 8 simultaneous jobs 8 1602 # Compiling with 9 simultaneous jobs 9 1738 # Compiling with 10 simultaneous jobs 10 1577
Ici, la courbe est plus proche de celle que j’imaginais à l’origine. Le fait de placer plusieurs jobs par CPU permet de tirer parti des temps d’attente liés au disque pour avancer dans une autre compilation. Regroupons les deux courbes pour bien voir les durées respectives.
Conclusion
Nous voyons qu’avec la qualité de l’ordonnanceur d’entrées-sorties (IO Scheduler) de Linux, et la gestion optimisée des périphériques blocs, les meilleurs temps de compilation sont obtenus dès que l’on lance un job par processeur.
Je modifierai donc à l’avenir ma recommandation en « Si vous avez N processeurs disponibles, compilez votre noyau avec make -j N
pour avoir le meilleur temps d’exécution« .
PS : si vous avez l’occasion de faire fonctionner ce script sur des architectures différentes (8 processeurs, 16 processeurs, etc.) je serai très intéressé par vos résultats.
Très intéressant !
Voila les résultats du script sur une machine avec 2 Intel(R) Xeon(R) CPU E5540 @ 2.53GHz.
(cpuinfo). Donc au total 8 coeurs physiques hyperthreadés.
Les résultats, et un petit gnuplot.
Hello,
I ran your script on a Pandaboard (TI OMAP4 based CPU @ 1GHz) as I was interested to know if we would have similar results on an ARM platform. Results seems to be inline with what you could analyze on Intel cores:
# Timings of linux-3.2 compilations
# Processors: 2
# Affinity mask: 3
# Compiling with 1 simultaneous jobs
1 3526
# Compiling with 2 simultaneous jobs
2 1952
# Compiling with 3 simultaneous jobs
3 1992
# Compiling with 5 simultaneous jobs
5 2034
[…] D’après les expérimentations faites par Christophe Blaess, l’idéal serait d’utiliser un job par processeur disponible. Ainsi, si vous avez N cœurs disponibles, vous obtiendrez les meilleurs performances en utilisant: […]