Parallélisation de compilations

Publié par cpb
Jan 14 2012

(English translation here)

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

 

Compilations parallèles sur quatre CPU

Compilations parallèles sur quatre CPU

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
Compilations parallèles sur deux CPU

Compilations parallèles sur deux CPU

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.

Compilations parallèles sur un seul CPU

Compilations parallèles sur un seul CPU

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).

Compilations parallèles sur processeur Q6600

Compilations parallèles sur processeur Q6600

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
Compilations parallèles sur deux CPU

Compilations parallèles sur deux CPU

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
Compilations parallèles sans optimisation disque

Compilations parallèles (2 CPU) sans optimisation disque

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.

Compilation parallèle sur QL66

Compilations parallèles sur QL66

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.

3 Réponses

  1. Cassou dit :

    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

  2. […] 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: […]

URL de trackback pour cette page