Archive for avril 2011

Expériences avec le cache (3)

Microprocesseur | Publié par cpb
avr 29 2011

Pour continuer nos expériences précédentes (voir les articles 1 et 2), je vous propose d’examiner les variations de temps d’accès en fonction de la saturation du TLB.

Le TLB (Translation Look-Aside Buffer) est une zone de mémoire cache utilisée pour accélérer l’accès à la MMU. Rappelons que la MMU (Memory Managment Unit) sert à convertir les adresses des pages de mémoire virtuelle en adresses de mémoire physique. Le contenu de la MMU étant potentiellement assez grand (plus d’un million d’entrées sur un processeur 32 bits dont les pages mémoires mesurent 4096 octets, chaque entrée occupant 4 octets), on l’organise généralement sous forme de tables imbriquées, occupant un volume mémoire plus petit. Toutefois la traduction d’une adresse de page mémoire virtuelle en page physique prend plus de temps en raison de la consultation successive de plusieurs tables. Pour accélérer cette traversée de la MMU, on utilise une mémoire cache, le TLB, qui conserve une liste des dernières traductions réalisées.

Lorsqu’on accède à une page dont la traduction en adresse physique est présente dans le TLB, le temps de traversée de la MMU est très court. Au contraire, lorsqu’un échec du cache TLB se produit, nous verrons notre temps de traduction s’allonger. Pour mettre ceci en évidence, nous allons réaliser des accès aléatoires sur un nombre variable de pages, et nous observerons le temps moyen d’accès à une page en fonction du nombre total de pages allouées. Le programme ci-dessous est construit sur le même principe que celui décrit dans le premier article de cette série. On peut le télécharger dans cette archive.

Le programme alloue systématique des structures mesurant 64 octets (chacune sur une page spécifique obtenue avec mmap()) afin d’occuper à coup sûr une ligne de cache L1 complète et éviter que deux entrées consécutives se retrouvent dans la même ligne – ceci surtout pour le second exemple plus bas.

depassement-tlb.c :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/time.h>

typedef struct {
    int valeur;
    char padding[62]; // 64 - sizeof(int)
} entree_t;

int main(int argc, char * argv[])
{
    register int i, n;

    int nb_entrees;
    int nb_parcours;

    volatile entree_t ** entrees = NULL;

    struct timeval tv_debut;
    struct timeval tv_fin;
    long long int duree;

    if ((argc != 3)
     || (sscanf(argv[1], "%d", & nb_entrees) != 1)
     || (sscanf(argv[2], "%d", & nb_parcours) != 1)) {
        fprintf(stderr,"usage: %s nb_entrees nb_parcours\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    // On alloue une table de N pointeurs sur les entrees
    entrees = calloc(nb_entrees, sizeof(entree_t *));
    if (entrees == NULL) {
        perror("calloc");
        exit(1);
    }
    // Puis on alloue chaque entree sur une page specifique
    for (i = 0; i < nb_entrees; i ++) {
        entrees[i] = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, 0, 0);
        if (entrees[i] == MAP_FAILED) {
            perror("mmap");
            exit(1);
        }
    }
    // La table est initialisee avec des -1
    for (i = 0; i < nb_entrees; i ++) {
        entrees[i]->valeur = -1;
    }
    n = 0;
    i = 0;
    // Remplir N-1 entrees (la derniere sert d'arret)
    while (n < nb_entrees-1) {
        // Remplir de maniere aleatoire avec le numero
        // d'une autre entree actuellement a -1.
        entrees[i]->valeur = rand() % nb_entrees;
        // Si elle n'est pas vide, rechercher la premiere
        // entree a -1
        while (entrees[entrees[i]->valeur]->valeur != -1) {
            entrees[i]->valeur ++;
            if (entrees[i]->valeur == nb_entrees)
                entrees[i]->valeur = 0;
        }
        i = entrees[i]->valeur;
        n++;
    }
    // La derniere entree contient toujours -1

    gettimeofday(& tv_debut, NULL);
    for (n = 0; n < nb_parcours; n ++) {
        i = 0;
        while(i != -1) {
            i=entrees[i]->valeur;
        }
    }
    gettimeofday(& tv_fin, NULL);
    duree  = tv_fin.tv_sec - tv_debut.tv_sec;
    duree *= 1000000;
    duree += tv_fin.tv_usec - tv_debut.tv_usec;

    // Duree en centiemes de nanosecondes
    duree  = duree  * 100000 / (nb_entrees * nb_parcours);

    fprintf(stdout, "%3lld.%02lld\n", duree/100, duree%100);
    return 0;
}

Un second programme réalise les mêmes opérations, mais les entrées sont allouées avec malloc() de manière contiguës, sur un nombre beaucoup plus restreint de pages,  donc avec une probabilité de réussite du TLB plus élevée.

sans-depassement-tlb.c :
[...]
    // Puis les entrees sont allouees successivement dans le tas
    for (i = 0; i < nb_entrees; i ++) {
        entrees[i] = malloc(sizeof(entree_t));
        if (entrees[i] == NULL) {
            perror("malloc");
            exit(1);
        }
    }
[...]

Pour réaliser des tests en série, le script suivant permet de réaliser des accès sur des listes de 4 à 1024 entrées.

serie.sh :
[...]
for i in $(seq 4 1024)
do
        printf "%4d " $i
        ./depassement-tlb $i $((1000000/$i))
done > "${FIC_RESULT_AVEC}"

for i in $(seq 4 1024)
do
        printf "%4d " $i
        ./sans-depassement-tlb $i $((1000000/$i))
done > "${FIC_RESULT_SANS}"
[...]

Un graphique est généré automatiquement par le script à l’aide de Gnuplot.

Voici un exemple, obtenu sur un Intel Core 2 Quad Q6600, sous Linux 2.6.39-rc3 (noyau de test de la prochaine version stable) sur une distribution Fedora 14. Pour avoir un comportement assez reproductible, je me suis affranchi des interférences liées aux migrations du processus entre cœurs en exécutant auparavant la commande

# taskset -pc 1 $$

qui bloque le shell (et les programmes qu’il lance par la suite) sur le cœur 0. En outre j’ai démarré le script ci-dessus en ordonnancement temps-réel fifo avec une haute priorité ainsi :

# chrt -f 90 ./serie

Expérience avec le cache - 3

Pour la courbe bleue, le TLB remplit son rôle jusqu’à une valeur de 256 pages (la taille du TLB « données » sur ce type de processeur). Ensuite la courbe monte rapidement jusqu’à un second plateau durant lequel les données tiennent encore dans le cache L1. Après avoir dépassé les 512 entrées, la courbe s’envole vers des temps d’accès de plus en plus longs en fonction du nombre de pages concernées.

La courbe rouge montre des temps d’accès beaucoup plus courts, les traductions d’adresses mémoire étant peu nombreuses (les données sont contiguës), le TLB fonctionne de manière optimale. A partir de 512 entrées de 64 octets, le cache L1 (mesurant 32 ko sur ce processeur) est débordé et les performances commencent seulement à se dégrader.

Pour le programmeur cela confirme l’optimisation obtenue lorsque les données sont très localisées. C’est pour cela que la fonction de bibliothèque malloc() réalise une allocation par tas (les zones sont juxtaposées) pour allouer dynamiquement des petites zones de données – quelques kilo-octets. La mise à contribution du TLB lors de l’accès successif à plusieurs de ces données est alors très efficace. Au contraire, les grosses zones de mémoire (plusieurs méga-octets) sont allouées – avec mmap() – dans des emplacements nettement distincts de l’espace d’adressage. Ceci leur réserve leurs propres pages mémoires spécifiques, le TLB servant alors à optimiser les accès successifs à des données appartenant à la même zone de mémoire.

[ACTU] Nouveau Patch Linux Preempt-RT 2.6.33.9-rt31

Actualité, Embarqué, Linux, Temps-réel | Publié par cpb
avr 22 2011

Le 11 avril dernier Thomas Gleixner a publié un nouveau patch pour le projet Linux Preempt RT. Celui-ci s’applique sur les noyaux de la branche long terme 2.6.33 (linux 2.6.33.9 en particulier). Ce patch implémente des améliorations par rapport au précédent, sans ajouter de véritables nouveautés. Il peut toutefois être intéressant de le mettre en œuvre et de le tester pour vérifier si les performances temps-réel d’un système sont améliorées.

Voici un exemple de mise en service sur une carte Igep v2.

1 – Téléchargement du nouveau patch Linux-Preempt-RT

# cd ~/tmp/
# wget http://www.kernel.org/pub/linux/kernel/projects/rt/patch-2.6.33.9-rt31.bz2
 --2011-04-16 00:17:17--  http://www.kernel.org/pub/linux/kernel/projects/rt/patch-2.6.33.9-rt31.bz2
Resolving www.kernel.org... 199.6.1.164, 130.239.17.4
Connecting to www.kernel.org|199.6.1.164|:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 275667 (269K) [application/x-bzip2] Saving to: “patch-2.6.33.9-rt31.bz2”  100%
[===============================================================================>] 275,667      761K/s   in 0.4s
2011-04-16 00:17:18 (761 KB/s) - “patch-2.6.33.9-rt31.bz2” saved [275667/275667]
#

2 – Téléchargement du noyau Linux 2.6.33.9

# wget http://www.kernel.org/pub/linux/kernel/v2.6/longterm/v2.6.33/linux-2.6.33.9.tar.bz2
--2011-04-16 00:32:10--  http://www.kernel.org/pub/linux/kernel/v2.6/longterm/v2.6.33/linux-2.6.33.9.tar.bz2
Resolving www.kernel.org... 199.6.1.164, 130.239.17.4
Connecting to www.kernel.org|199.6.1.164|:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 66226244 (63M) [application/x-bzip2]
Saving to: “linux-2.6.33.9.tar.bz2”
100%[=========================================================================>] 66,226,244  1.58M/s   in 40s
2011-04-16 00:32:50 (1.58 MB/s) - “linux-2.6.33.9.tar.bz2” saved [66226244/66226244]
#

3 – Décompression des sources de Linux et du patch

# tar xjf linux-2.6.33.9.tar.bz2
# bunzip2 patch-2.6.33.9-rt31.bz2
#

4 – Application du patch Preempt-RT

# cd linux-2.6.33.9
# patch -p1 < ../patch-2.6.33.9-rt31 
patching file Documentation/hwlat_detector.txt
patching file Documentation/trace/histograms.txt
patching file MAINTAINERS
patching file Makefile
[...]
patching file sound/drivers/pcsp/pcsp.h
patching file sound/drivers/pcsp/pcsp_input.c
patching file sound/drivers/pcsp/pcsp_lib.c
patching file virt/kvm/kvm_main.c
#

5 – Compilation du noyau

Nous utilisons un fichier de configuration pour la carte Igep v2 déjà préparé avec une version antérieure.

# cp ../config-linux-2.6.33-rt  ./.config
# make ARCH=arm menuconfig

On vérifie que la configuration semble correcte. En particulier l’option « Preemption Mode » du menu « Kernel Features« , qui doit être configurée avec la valeur « Complete Preemption (Real-time)« . Nous quittons en sauvant la configuration et lançons la compilation avec :

# make   ARCH=arm   CROSS_COMPILE=/cross-arm-linux/usr/bin/arm-linux-  uImage  -j 16

Rappelons que l’option « CROSS_COMPILE contient un préfixe à ajouter avant le nom des outils de la chaîne de compilation Gnu (gcc, ld, as). Ceci permet de sélectionner le cross-compiler que nous avons généré dans un article précédent. On remarquera que nous compilons une « uImage » c’est-à-dire une image préparée pour uBoot, le boot-loader préinstallé sur la carte Igep. Enfin l’option « -j 16 » permet d’accélérer la compilation en faisant tourner 16 jobs en parallèle (on optimise ainsi l’utilisation des quatre cœurs de ce processeur).

6 – Installation du noyau compilé

Après quelques minutes, le noyau est prêt, on peut le copier sur la carte micro-SD utilisée pour le boot de la carte Igep :

[...]
Image Name:   Linux-2.6.33.9-rt31-rt-cpb
Created:      Sat Apr 16 00:43:14 2011
Image Type:   ARM Linux Kernel Image (uncompressed)
Data Size:    1870284 Bytes = 1826.45 kB = 1.78 MB
Load Address: 80008000
Entry Point:  80008000
  Image arch/arm/boot/uImage is ready
# cp arch/arm/boot/uImage  /media/boot/
# umount /media/boot/
#

7 – Test du nouveau noyau

La carte micro-SD est insérée dans la carte Igep v2, celle-ci est mise sous tension. Dès que les LEDs affichent le signe de vie habituel (dans un script de démarrage personnalisé), on peut se connecter depuis le PC par le réseau :

# ssh root@192.168.3.152
root@192.168.3.152's password:
BusyBox v1.16.1 (2011-03-10 12:17:29 CET) built-in shell (ash)
Enter 'help' for a list of built-in commands.
[IGEP]# uname -a
Linux (none) 2.6.33.9-rt31-rt-cpb #1 PREEMPT RT Sat Apr 16 00:43:05 CEST 2011 armv7l GNU/Linux
[IGEP]#

Il ne reste plus qu’à tester et vérifier si le comportement des tâches temps-réel d’un projet voient leur comportement s’améliorer sur ce nouveau patch, et éventuellement poster ses remarques dans la mailing-list « linux-rt-users@vger.kernel.org« .

Expériences avec le cache (2)

Microprocesseur | Publié par cpb
avr 15 2011

Nous allons poursuivre nos expériences de la semaine passée pour tenter d’observer des effets macroscopiques dus au comportement de la mémoire cache intégrée dans nos processeurs. Ces expériences sont inspirées par la lecture du texte d’Ulrich Drepper « What Every Programmer Should Know About Memory » que je recommande vivement. Les programmes présentés ci-dessous sont accessibles dans cette archive.

Cette fois, nous allons examiner comment le cache L1 est réparti entre les différents cœurs du processeur. Le CPU accède aux données dans le cache par lignes. Ceci signifie le cache est constitué par des blocs de mémoire (nommés lignes) dont la taille est de 64 octets sur la plupart des processeurs modernes. Une ligne est chargée depuis la mémoire centrale (via le cache de niveau 2) dès que le processeur tente d’accéder à l’un des octets de la ligne. Le cache contient un nombre de lignes qui dépend de la construction du processeur, une valeur de 512 lignes (32 ko) est assez fréquente de nos jours.

Suivant la conception du processeur, le cache de niveau 1 peut être partagé ou non entre les cœurs du processeur. Lorsque le cache est partagé, l’accès simultané aux données de la même ligne est simple. Même lorsque deux cœurs veulent écrire simultanément à deux adresses se trouvant dans la même ligne, la synchronisation est relativement simple.

Lorsque le cache L1 n’est pas partagé les choses sont plus compliquées. Tant que les deux cœurs ne font que de la lecture sur les données, la même ligne peut être chargée dans les deux caches. Lorsqu’un des deux cœurs désire écrire des données dans cette ligne, il doit la verrouiller pour s’assurer qu’elle ne soit pas modifiée pendant le même temps par l’autre cœur. Ceci est réalisé par l’intermédiaire de messages RFO (Request For Ownership) émis par le cœur demandant l’accès exclusif. Naturellement le temps d’accès est plus long dans cette seconde situation.

Suivant l’organisation du processeur, nous pourrons donc observer des comportements différents. Nous allons réaliser une première expérience consistant à faire accéder simultanément par deux threads distincts – sur deux cœurs différents – à deux variables se trouvant dans la même ligne de mémoire.

Dans ce premier programme, les threads en parallèle exécutent chacun une boucle chronométrée de 2^32 itérations. Ils affichent en se terminant leur durée d’exécution (en micro-secondes). La synchronisation des démarrages des threads est assurée par une barrière pthread_barrier_t.

acces-meme-ligne-multithreads.c :
#define _GNU_SOURCE    // necessaire pour setaffinity_np()

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/time.h>

pthread_barrier_t barriere;

void * fonction_thread(void * arg)
{
	volatile unsigned int * i = arg;
	struct timeval tv_debut;
	struct timeval tv_fin;
	long long int duree;

	// synchronisation des demarrages des threads
	pthread_barrier_wait(& barriere);

	(* i) = 0xFFFFFFFF; // 2^32 -1
	gettimeofday(& tv_debut, NULL);
	while ((*i) != 0)
		(*i) --;
	gettimeofday(& tv_fin, NULL);

	duree  = tv_fin.tv_sec - tv_debut.tv_sec;
	duree *= 1000000;
	duree += tv_fin.tv_usec - tv_debut.tv_usec;
	fprintf(stdout, "Duree execution : %lld\n", duree);
	return NULL;
}

int main(int argc, char * argv[])
{
	pthread_attr_t attr;
	pthread_t thr[2];
	int * ptr[2];
	cpu_set_t cpuset;
	int cpu[2] = {0, 1};

	if (argc > 1) {
		if (sscanf(argv[1], "%d", & (cpu[0])) != 1) {
			fprintf(stderr, "usage: %s [CPU] [CPU]\n", argv[0]);
			exit(EXIT_FAILURE);
		}
	}
	if (argc > 2) {
		if (sscanf(argv[2], "%d", & (cpu[1])) != 1) {
			fprintf(stderr, "usage: %s [CPU] [CPU]\n", argv[0]);
			exit(EXIT_FAILURE);
		}
	}

	ptr[0] = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, 0, 0);
	if (ptr[0] == MAP_FAILED) {
		perror("mmap");
		exit(EXIT_FAILURE);
	}
	ptr[1] = ptr[0] + sizeof(int);

	pthread_barrier_init(& barriere, NULL, 2);
	pthread_attr_init(& attr);

	CPU_ZERO(& cpuset);
	CPU_SET(cpu[0], & cpuset);
	pthread_attr_setaffinity_np (& attr, sizeof(cpuset), & cpuset);
	pthread_create(& thr[0], & attr, fonction_thread, ptr[0]);

	CPU_ZERO(& cpuset);
	CPU_SET(cpu[1], & cpuset);
	pthread_attr_setaffinity_np (& attr, sizeof(cpuset), & cpuset);
	pthread_create(& thr[1], & attr, fonction_thread, ptr[1]);

	pthread_join(thr[0], NULL);
	pthread_join(thr[1], NULL);

	return 0;
}

Nous allons exécuter ce programme sur un processeur Intel Core 2 Quad CPU. Celui-ci possède donc quatre cœurs, et nous allons mettre successivement en concurrence chaque cœur avec chacun des trois autres pour l’accès en écriture sur la même ligne de cache, et comparer les résultats d’exécution :

$ ./acces-meme-ligne-multithreads 0 1
Duree execution : 13458508
Duree execution : 13463652
$ ./acces-meme-ligne-multithreads 0 2
Duree execution : 16580434
Duree execution : 16582678
$ ./acces-meme-ligne-multithreads 0 3
Duree execution : 16507888
Duree execution : 16508028
$ ./acces-meme-ligne-multithreads 1 2
Duree execution : 16177846
Duree execution : 16179444
$ ./acces-meme-ligne-multithreads 1 3
Duree execution : 16599132
Duree execution : 16603046
$ ./acces-meme-ligne-multithreads 2 3
Duree execution : 13451863
Duree execution : 13460000
$

Nous voyons que l’accès simultané aux données de la même ligne de cache par les cœurs (C0, C1) ou les cœurs (C2, C3) est sensiblement plus rapide (13 secondes contre 16 secondes) que l’accès simultané par les cœurs (C0, C2),  (C0, C3),  (C1, C2), ou (C1, C3).

Nous pouvons en conclure que sur ce processeur, il existe deux caches L1, chacun étant partagé par une paire (C0, C1) ou (C2, C3).

Pour compléter cette expérience, nous allons vérifier le comportement lorsque les données accédées simultanément se trouvent dans deux lignes de caches différentes, et ne présentent donc pas de collisions entre les cœurs. Pour cela, nous reprenons le même principe de programme, seules les lignes d’allocation mémoire diffèrent, nous prenons nos deux pointeurs dans deux pages séparées, donc a fortiori dans deux lignes de cache différentes.

Ainsi, au lieu de la ligne :

        ptr[1] = ptr[0] + sizeof(int);

de notre premier programme, nous utilisons l’allocation d’une nouvelle page avec :

acces-differentes-lignes-multithreads.c :
[...]
	ptr[1] = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, 0, 0);
	if (ptr[1] == MAP_FAILED) {
		perror("mmap");
		exit(EXIT_FAILURE);
	}
[...]

L’exécution cette fois, ne présente pas de différences suivant les paires de cœurs utilisées :

$ ./acces-differentes-lignes-multithreads 0 1
Duree execution : 12588085
Duree execution : 12606074
$ ./acces-differentes-lignes-multithreads 0 2
Duree execution : 12588740
Duree execution : 12591586
$ ./acces-differentes-lignes-multithreads 0 3
Duree execution : 12590018
Duree execution : 12591842
$ ./acces-differentes-lignes-multithreads 1 2
Duree execution : 12588323
Duree execution : 12591253
$ ./acces-differentes-lignes-multithreads 1 3
Duree execution : 12590367
Duree execution : 12594167
$ ./acces-differentes-lignes-multithreads 2 3
Duree execution : 12590264
Duree execution : 12601760
$

La différence de temps entre l’accès sur des lignes différentes (12,6 secondes environ dans cet exemple) contre l’accès sur la même ligne dans le même cache (13,5 secondes dans l’expérience précédente) est due au mécanisme de synchronisation inter-cœurs pour l’accès sur la même ligne.

En conclusion, je soulignerai que le placement des threads sur les cœurs du processeur lors de l’utilisation de données aux adresses « proches en mémoire » (deux champs de la même structure par exemple) n’est pas anodin au regard des performances du système global. Dans le cas d’une application critique (temps-réel strict par exemple) il est important de prendre en compte l’architecture du processeur cible (éventuellement en vérifiant à l’aide du programme ci-dessus l’organisation du cache L1)  pour décider de l’emplacement de chaque thread.

Expériences avec le cache (1)

Microprocesseur | Publié par cpb
avr 08 2011

En parcourant l’excellent texte d’Ulrich Drepper « What Every Programmer Should Know About Memory » j’ai eu envie de vérifier si j’obtenais expérimentalement des résultats similaires à ceux qu’il présente. J’ai construit une série de petits programmes, qui accèdent de différentes manières à la mémoire et nous présentent des statistiques sur leurs temps d’exécution. Ils sont disponibles dans cette archive.

Le premier programme qui créé une table d’entiers dont la taille N est indiquée en premier argument sur la ligne de commandes. La table est remplie de manière séquentielle, chaque case contient le numéro d’indice de la case suivante.

Nous chronométrons ensuite une série de P parcours de cette table (P étant fourni en second argument sur la ligne de commande) durant lesquels nous consultons simplement le contenu de chaque case. Puis une seconde série de P parcours chronométrés durant lesquels on va modifier la table (un parcours avec incrémentation du contenu de chaque case, suivi d’un parcours avec décrémentation des contenus).

acces-sequentiels-memoire.c :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/time.h>

int main(int argc, char * argv[])
{
	register int i, n;

	int nb_entrees;
	int nb_parcours;
	int * bloc;
	struct timeval tv_debut;
	struct timeval tv_fin;
	long long int duree_lectures;
	long long int duree_ecritures;

	if ((argc != 3)
	 || (sscanf(argv[1], "%d", & nb_entrees) != 1)
	 || (sscanf(argv[2], "%d", & nb_parcours) != 1)) {
		fprintf(stderr,"usage: %s nb_entrees nb_parcours\n", argv[0]);
		exit(EXIT_FAILURE);
	}

	fprintf(stderr, "Preparation de la table des donnees...\n");
	bloc = calloc(nb_entrees, sizeof(int));

	// Chaque entree contient le numero de la case suivante
	for (i = 0; i < nb_entrees-1; i ++)
		bloc[i] = i+1;
	// La derniere entree est indiquee par -1
	bloc[i] = -1;

	fprintf(stderr, "Table prete...\n");

	fprintf(stderr, "Parcours en lecture...\n");
	gettimeofday(& tv_debut, NULL);
	for (n = 0; n < nb_parcours; n ++) {
		i = 0;
		while(i != -1) {
			i=bloc[i];
		}
	}
	gettimeofday(& tv_fin, NULL);
	duree_lectures  = tv_fin.tv_sec - tv_debut.tv_sec;
	duree_lectures *= 1000000;
	duree_lectures += tv_fin.tv_usec - tv_debut.tv_usec;

	fprintf(stderr, "Parcours en ecriture...\n");
	gettimeofday(& tv_debut, NULL);
	for (n = 0; n < nb_parcours; n ++) {
		i = 0;
		// On decremente chaque entree sauf la derniere
		while(i != -1) {
			i=(bloc[i]--);
		}
		i = 0;
		// On incremente chaque entree sauf la derniere
		while(i != -1)
			i=(++bloc[i]);
	}
	gettimeofday(& tv_fin, NULL);
	duree_ecritures  = tv_fin.tv_sec - tv_debut.tv_sec;
	duree_ecritures*= 1000000;
	duree_ecritures += tv_fin.tv_usec - tv_debut.tv_usec;
	duree_ecritures /= 2; // Deux parcours complets

	fprintf(stderr, "Parcours termines...\n");

	// Durees en centiemes de nanosecondes
	duree_lectures  = duree_lectures  * 100000 / (nb_entrees * nb_parcours);
	duree_ecritures = duree_ecritures * 100000 / (nb_entrees * nb_parcours);

	fprintf(stdout, "Duree par acces lecture  : %lld.%lld ns\n", duree_lectures/100, duree_lectures%100);
	fprintf(stdout, "Duree par acces ecriture : %lld.%lld ns\n", duree_ecritures/100, duree_ecritures%100);
	return 0;
}

Nous allons tester ce programme avec différentes valeurs de N et P. Pour équilibrer grosso-modo le temps de fonctionnement de l’application, nous allons multiplier N (le nombre de valeurs dans la table) par deux à chaque nouvelle exécution, tandis que nous diviserons P (le nombre parcours) par deux. Pour cela, le petit script suivant nous sera utile :

serie-acces-sequentiels.sh :
#! /bin/sh

FIC_RESULTATS="resultats/sequentiels.txt"
FIC_DATA_READ="resultats/read-sequentiels.txt"
FIC_DATA_WRITE="resultats/write-sequentiels.txt"
FIC_GRAPHIQUE="resultats/graph-sequentiel.gif"

> "$FIC_RESULTATS"

N=1
P=$((512*1024*1024))

taskset -pc 0 $$

while [ "$P" -ge 1 ]
do
        echo "$N / $P " >&2
        echo "taille bloc : $N, nb_cycles : $P" >> "$FIC_RESULTATS"
        ./acces-sequentiels-memoire "$N" "$P" >> "$FIC_RESULTATS"
        N=$((N<<1))
        P=$((P>>1))
done
grep ecriture < "$FIC_RESULTATS" | awk '{print $6}' > "$FIC_DATA_WRITE"
grep lecture  < "$FIC_RESULTATS" | awk '{print $6}' > "$FIC_DATA_READ"

echo "set terminal gif giant size 1600,1200; set output '$FIC_GRAPHIQUE'; set multiplot layout 2,1; " \
     "set xlabel 'log2(nombre de blocs)'; set ylabel 'duree boucle (ns)'; "\
     "plot   '$FIC_DATA_WRITE' with line lc rgb '#FF0000'; " \
     "replot '$FIC_DATA_READ'  with line lc rgb '#0000FF';" \
 | gnuplot

Pour éviter toute migration intempestive du processus, on le fixe (avec taskset) sur le processeur 0.

On remarquera l’initialisation des variables N et P : nous allons commencer par un bloc de 1 entier, accédé 512 millions de fois, pour terminer par un bloc de 512 millions d’entrées entières (occupant donc 2Go) accédé une seule fois. On peut naturellement jouer sur ces valeurs en fonction de la taille mémoire et de la vitesse de traitement du système. On notera également que les dernières lignes du script invoquent l’utilitaire gnuplot pour créer un fichier graphique avec les résultats.

Voici une première exécution :

$ ./series-acces-sequentiels.sh 
pid 6971's current affinity list: 0-3
pid 6971's new affinity list: 0
1 / 536870912
Preparation de la table des donnees...
Table prete...
Parcours en lecture...
Parcours en ecriture...
Parcours termines...
2 / 268435456
[...]
536870912 / 1
Preparation de la table des donnees...
Table prete...
Parcours en lecture...
Parcours en ecriture...
Parcours termines...
$

Et le résultat se présente sur les figures suivantes :

 

Accès séquentiels sur une zone mémoire

Accès séquentiels sur une zone mémoire

Pour pouvoir comprendre l’intérêt de ce graphique, il nous faut le comparer à un autre type d’accès mémoire : l’accès aléatoire. Nous reprenons notre programme précédent en modifiant simplement l’initialisation du bloc : chaque case va contenir le numéro d’indice d’une autre case du tableau choisie aléatoirement. Pour s’assurer que nous ayons un parcours complet du tableau en N étapes, lorsque nous déterminons le numéro d’indice de la case visée, nous nous assurons que cette dernière soit vide (sinon on prend la suivante dans le tableau et on recommence la vérification).

acces-aleatoires-memoire.c :
[...]
	// On alloue une table de N entrees, initialisees a -1
	bloc = calloc(nb_entrees, sizeof(int));
	for (i = 0; i < nb_entrees; i ++)
		bloc[i] = -1;
	n = 0;
	i = 0;
	// Remplir N-1 entrees (la derniere sert d'arret)
	while (n < nb_entrees-1) {
		// Remplir de maniere aleatoire avec le numero
		// d'une autre entree actuellement vide.
		bloc[i] = rand() % nb_entrees;
		// Si elle n'est pas vide, rechercher la premiere
		// entree vide a la suite.
		while (bloc[bloc[i]] != -1) {
			bloc[i] ++;
			if (bloc[i] == nb_entrees)
				bloc[i] = 0;
		}
		i = bloc[i];
		n++;
	}
	// La derniere entree contient toujours -1
[...]

Bien sûr, un script nommé series-acces-aleatoires.sh permet d’exécuter ce programme avec les mêmes arguments que précédemment. Le temps d’exécution du script est nettement plus long, ne serait-ce qu’à cause de l’initialisation des tables qui demandent des calculs importants pour l’obtention des valeurs aléatoires.

$ ./series-acces-aleatoires.sh 
pid 8003's current affinity list: 0-3
pid 8003's new affinity list: 0
1 / 536870912
Preparation de la table des donnees...
Table prete...
Parcours en lecture...
Parcours en ecriture...
Parcours termines...
2 / 268435456
[...]

$

Voici le graphique correspondant à cette exécution.

 

Accès aléatoires sur une zone mémoire

Accès aléatoires sur une zone mémoire

Nous pouvons remarquer que le temps d’accès, en opérant de manière aléatoire sur les données, est sensiblement plus long dès que l’on dépasse une table de 2^13 entiers. Cette valeur correspond à 8192 entiers, soient 32Ko ce qui représente la taille du cache de premier niveau (L1d). Tant que les données manipulées arrivent à tenir dans ce cache, le mode de parcours importe peu, nous avons un comportement optimal pour l’accès aux données mémoire.

Une fois que l’on dépasse le cache L1, les parcours aléatoires nécessitent de charger fréquemment des lignes de mémoire depuis le cache L2, avec un temps d’accès un peu plus long. Les parcours séquentiels ne présentent pas beaucoup de différences d’exécution car le processeur utilise une « fenêtre » mémoire de la taille de L1 qu’il déplace sur l’ensemble des données.

Ensuite, nous remarquons à nouveau une envolée des temps d’accès aléatoire à partir de 2^19 entiers, soient 2 Mo de données. Cette fois c’est le cache de second niveau qui est débordé. Celui-ci contient environ 4Mo de données, mais il est partagé entre les différents cœurs du processeur et dès que le jeu de données manipulé dépasse 2Mo environ, le processeur doit aller chercher ses informations dans la mémoire dynamique du système, dont l’accès est beaucoup plus long que celui de la mémoire statique du cache.

Ces expériences nous montrent bien que l’utilisation d’ensembles de travail de tailles inférieures à 32 Ko est optimale, qu’une première dégradation intervient lorsqu’on dépasse cette limite sauf si les accès mémoire se font de manière très localisée (comparable à nos parcours séquentiels). Au-delà de 4Mo de données, les performances s’effondreront si nous ne sommes pas capables – en organisant et en structurant correctement nos variables – de garantir des accès les plus localisés aux données. Bien sûr ces valeurs dépendent du processeur et de son niveau de cache. Rappelons que le nom et le type de processeur sont visibles avec une commande :

$ cat /proc/cpuinfo

Les informations techniques sur le cache d’un processeur donné se trouvent assez facilement sur le site de son fabricant ou sur Wikipedia.

 

[ACTU] Groupement automatique des processus

Actualité, Linux, Temps-réel | Publié par cpb
avr 01 2011

Depuis 2003, plusieurs ordonnanceurs se sont succédé dans le noyau Linux 2.6. Le dernier en date (apparu dans Linux 2.6.24) est le « CFS – Complete Fair Scheduler » autrement dit « l’ordonnanceur vraiment équitable ». Celui-ci garantit que le temps CPU disponible est réparti de manière équitable entre les différentes tâches. Ceci ne concerne naturellement que les tâches ordonnancées en temps-partagé, celles s’exécutant en temps-réel disposent de tout le temps-processeur qu’elles désirent (voir toutefois cet article…)

Pour améliorer le temps de réponse des applications interactives (notamment l’interface utilisateur graphique), ce scheduler propose un partage du temps CPU entre les groupes de processus. Supposons que nous ayons un premier groupe de dix tâches réalisant des calculs intensifs (la compilation d’un projet important comme le noyau Linux). Le temps processeur sera alors réparti en dix tranches de 10 % (en ignorant les quelques pourcents indispensables pour faire fonctionner les autres processus du système). Si nous ajoutons à présent un second groupe de trois processus de calcul, la répartition se fera ainsi :

  • 5 % pour chacun des trois processus du premier groupe qui totalise donc 50% ;
  • 16,6 % pour chacun des trois processus du second groupe, qui reçoit les 50% restants.

 

Ce mécanisme, très pratique, est malheureusement assez compliqué à mettre en œuvre, et doit être explicitement activé lors de la création des tâches à placer dans les groupes.

Dans le noyau 2.6.38 – publié récemment comme je l’avais signalé dans un précédent article – est apparu un nouvel élément de configuration nommé CONFIG_SCHED_AUTOGROUP. Celui-ci est accessible dans le menu « General Setup » de l’interface de configuration du kernel (make menuconfig, make xconfig, ou make gconfig) sous la dénomination « Automatic process group scheduling » .

Avec l’auto-groupement des tâches, l’ordonnanceur prend automatiquement en considération le terminal de contrôle du processus. Le temps CPU est réparti équitablement entre les terminaux, quelque soit le nombre de tâches sur chaque terminal.

Pour tester cette fonctionnalité, nous allons utiliser le petit programme suivant, qui lance en parallèle le nombre de processus demandé sur sa ligne de commande. Chaque processus consomme, dans une boucle active, tout le temps CPU qui lui est offert :

boucles-actives.c :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char * argv[])
{
	int nb;
	if ((argc != 2) || (sscanf(argv[1], "%d", & nb) != 1)) {
		fprintf(stderr, "usage: %s \n", argv[0]);
		exit(1);
	}
	while (nb > 0) {
		if (fork() == 0)
			while (1)
				;
		nb --;
	}
	return 0;
}

Ouvrons un terminal et lançons un premier jeu de processus. Nous observons que la charge CPU monte en flèche.
Linux Sched Autogroup 1

Basculons alors sur un autre bureau virtuel et ouvrons un second terminal. Première surprise : la fluidité de l’interface graphique. Les applications de l’environnement graphique (Gnome sur ce poste) n’ont pas de terminaux de contrôle et appartiennent donc à un groupe différent de celui des processus en boucle. Le temps processeur dont elles ont besoin est donc réservé et nous n’observons plus de saccades dans la gestion de la souris par exemple comme ce pouvait être le cas précédement. Ce point a d’ailleurs été longuement commenté suite à un message enthousiaste de Linus Torvalds en novembre dernier lorsqu’il s’est aperçu que son interface graphique conservait toute sa réactivité, même durant une compilation largement parallélisée du noyau.

Ouvrons un second terminal et lançons une deuxième série de processus en boucle :

Linux Sched Autogroup 2

 

 

Puis en basculant sur un troisième terminal, observons les répartitions de temps processeur (sur cette machine, le processeur dispose de deux cœurs, aussi la somme des temps CPU est de 200%) :

$ ps axu
USER     PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root       1  0.0  0.0   2876  1332 ?        Ss   Mar29   0:01 /sbin/init
root       2  0.0  0.0      0     0 ?        S    Mar29   0:00 [kthreadd]
[...]
cpb     7413 10.1  0.0   1864    48 pts/0    R    08:24   0:07 ./boucles-actives 10
cpb     7414 10.3  0.0   1864    48 pts/0    R    08:24   0:07 ./boucles-actives 10
cpb     7415 10.0  0.0   1864    48 pts/0    R    08:24   0:07 ./boucles-actives 10
cpb     7416 10.3  0.0   1864    48 pts/0    R    08:24   0:07 ./boucles-actives 10
cpb     7417 10.3  0.0   1864    48 pts/0    R    08:24   0:07 ./boucles-actives 10
cpb     7418 10.3  0.0   1864    48 pts/0    R    08:24   0:07 ./boucles-actives 10
cpb     7419 10.3  0.0   1864    48 pts/0    R    08:24   0:07 ./boucles-actives 10
cpb     7420 10.1  0.0   1864    48 pts/0    R    08:24   0:07 ./boucles-actives 10
cpb     7421 10.1  0.0   1864    48 pts/0    R    08:24   0:07 ./boucles-actives 10
cpb     7422 10.1  0.0   1864    48 pts/0    R    08:24   0:07 ./boucles-actives 10
cpb     7425 23.4  0.0   1864    48 pts/1    R    08:24   0:15 ./boucles-actives 4
cpb     7426 24.0  0.0   1864    48 pts/1    R    08:24   0:16 ./boucles-actives 4
cpb     7427 23.7  0.0   1864    48 pts/1    R    08:24   0:16 ./boucles-actives 4
cpb     7428 23.4  0.0   1864    48 pts/1    R    08:24   0:15 ./boucles-actives 4
cpb     7456  0.0  0.0   4764   988 pts/2    R+   08:25   0:00 ps axu
$

Nous voyons bien que les dix processus issus de la commande ‘boucles-actives 10 » disposent chacun de 10 % du temps CPU, tandis que ceux lancés par la commande « boucles-actives 4 » profitent d’environ 25 % chacun. Pour voir les groupes de processus, nous pouvons utiliser la commande suivante :

$ ps ax -O pgrp
  PID  PGRP S TTY          TIME COMMAND
    1     1 S ?        00:00:01 /sbin/init
    2     0 S ?        00:00:00 [kthreadd]
[...]
 7413  7412 R pts/0    00:00:15 ./boucles-actives 10
 7414  7412 R pts/0    00:00:14 ./boucles-actives 10
 7415  7412 R pts/0    00:00:14 ./boucles-actives 10
 7416  7412 R pts/0    00:00:14 ./boucles-actives 10
 7417  7412 R pts/0    00:00:14 ./boucles-actives 10
 7418  7412 R pts/0    00:00:14 ./boucles-actives 10
 7419  7412 R pts/0    00:00:14 ./boucles-actives 10
 7420  7412 R pts/0    00:00:14 ./boucles-actives 10
 7421  7412 R pts/0    00:00:14 ./boucles-actives 10
 7422  7412 R pts/0    00:00:14 ./boucles-actives 10
 7425  7424 R pts/1    00:00:32 ./boucles-actives 4
 7426  7424 R pts/1    00:00:34 ./boucles-actives 4
 7427  7424 R pts/1    00:00:33 ./boucles-actives 4
 7428  7424 R pts/1    00:00:34 ./boucles-actives 4
 7477  7477 R pts/2    00:00:00 ps ax -O pgrp
$

Le numéro de groupe est indiqué en seconde colonne, le groupe 7412 contient les dix processus ayant chacun 10 % du temps CPU et le groupe 7424 est constitué des quatre processus avec 25 % de temps CPU chacun. La nouveauté du noyau 2.6.38 est d’organiser automatiquement ces groupes en se basant sur le terminal de contrôle (pts/0 dans un cas et pts/1 dans l’autre).

En conclusion, je ne peux que saluer cette nouveauté du noyau 2.6.38. Elle est particulièrement utile pour conserver une bonne réactivité à un système sur lequel de nombreuses tâches de calcul s’exécutent. L’emploi du terminal de contrôle des processus limite toutefois la portée de cette innovation, car la plupart des utilisateurs de Linux exécutent des applications purement graphiques (navigateurs, players vidéo, etc.) dans des environnements (Gnome, Kde, Xfce…) où la notion de terminal est absente. Ces processus sont présentés avec un point d’interrogation dans la colonne TTY de ps. Les principaux bénéficiaires seront plutôt les utilisateurs qui lancent régulièrement des tâches consommatrices de CPU (compilation, traitement vidéo, calculs…) depuis des terminaux shell.