vendredi 20 juin 2014

printf() magic tricks and %n specifier

Si l'on sait que la fonction printf est puissante, sa puissance est rarement utilisée. Sans doute la syntaxe de spécification du format n'est-elle pas très agréable. C'est vrai, mais je ne connais pas de fonction de sortie pour laquelle la spécification d'un format soit agréable; c'est, par nature, compliqué.

On trouve de nombreuses questions sur l'usage du %n dans les formats de sortie. Si nombreux sont ceux qui savent ce que cela fait, il est étrange de constater que tout aussi nombreux sont ceux qui n'en voient pas l'usage. Il est vrai qu'à l'ère des GUI  les affichages en mode texte paraissent désuets, et pourtant…

Voici ce qu'en raconte les manuels:

%n : Print nothing, but write number of characters successfully written so far into an integer pointer parameter.

Donc ce n'est pas un paramètre de sortie mais une valeur calculée lors d'une sortie et qui est rangée dans un entier dont l'adresse est spécifiée en argument.

Le code suivant :

int l;
printf("Bonjour%n\n",&l);
printf("%d\n",l);

génère à l'exécution la sortie suivante :

Bonjour
7

La valeur 7 correspond au nombre de caractères écrits depuis le début du premier printf jusqu'au %n, soit 7. Bien entendu, il s'agit de tout ce qui a été écrit, et non de la chaîne du format elle-même, ainsi :

int l;
char *s = "Au revoir";
printf("%s%n\n",s&,l);
printf("%d\n",l);

génère la sortie :

Au revoir
9

Alors à quoi cela sert-il ? À formater on vous dit! N'oublions pas que le formatage comporte deux aspects :

  • le premier est la représentation d'une valeur d'un type donné (entier en hexa, ou octal par exemple),
  • le second est le placement de la représentation dans l'espace réservé à l'affichage de la valeur du type (calage à gauche, bourrage, etc.).


%n sert pour le formatage d'un troisième genre : la vue tabulaire, c'est-à-dire le placement relatif et contrôlé des différentes représentations.

Si je souhaite afficher un tableau, comment m'en sortir ? La première solution est de parier sur la fixité de certains paramètres du tableau, comme dans ce qui suit :

printf("Table | i+100 | i+200 |\n");
for (int i=0; i<10; i++) {
printf("i=%-3d | %5d | %5d |\n",i,i+100,i+200);
}
return 0;

et qui permet d'obtenir :

Table | i+100 | i+200 |
i=0   |   100 |   200 |
i=1   |   101 |   201 |
i=2   |   102 |   202 |
i=3   |   103 |   203 |
i=4   |   104 |   204 |
i=5   |   105 |   205 |
i=6   |   106 |   206 |
i=7   |   107 |   207 |
i=8   |   108 |   208 |
i=9   |   109 |   209 |

parfait, mais cet affichage ne fonctionne que parce que les tailles des différentes colonnes ont été calculées à l'avance! Que faire si l'entête du tableau est obtenu via des valeurs calculées ou obtenues depuis l'extérieur ?

char *f1 = "Table", *f2="i+100", *f3="i+200";
int l1, l2, l3;
l1 = strlen(f1);
l2 = strlen(f2);
l3 = strlen(f2);
printf("%s | %s | %s |\n",f1,f2,f3);
for (int i=0; i<10; i++) {
        printf("i=%*d | %*d | %*d |\n",
               -(l1-2),i,l2,i+100,l3,i+200);
}

On remarquera au passage : l'usage de l'étoile * qui permet en sortie de spécifier via une variable la largeur du champ, et l'usage d'une valeur négative pour la longueur du champ qui signifie un calage à gauche...

Reste que l'on doit encore écrire des bouts de code pour calculer les longueurs. C'est là que %n débarque :

printf("%s%n | %s%n | %s%n\n",f1,&c,f2,&c1,f3,&c2);
for (int i=0; i<10; i++) {
        printf("i=%*d | %*d | %*d\n",
               -(c-2),i,c1-c-3,i+100,c2-c1-3,i+200);
}

Je vous laisse tester avec différentes valeurs pour les champs d'entête de colonne (f1, f2, et f3), voici ce que j'ai obtenu :

Ma table magique | Premiere colonne | Seconde
i=0              |              100 |     200
i=1              |              101 |     201
i=2              |              102 |     202
i=3              |              103 |     203
i=4              |              104 |     204
i=5              |              105 |     205
i=6              |              106 |     206
i=7              |              107 |     207
i=8              |              108 |     208
i=9              |              109 |     209
DjiBee

dimanche 1 juin 2014

Linux : Numérotation des processeurs et topologie d'iceux

Comptons les processeurs, 

Y'a pas de thread chez nous, 

Y'en a chez la voisine... 

Besoin primaire

Sur des machines disposant de plusieurs processeurs, il arrive que l'on ait besoin pour (tenter d') assurer qu'un processus ait de bonnes performances, que l'on "épingle" le processus sur un processeur: en clair, on indique au système qu'il doit impérativement exécuter le dit processus sur le processeur de numéro N.

Si le processus épinglé a plusieurs threads logicielles, ou si l'on a plusieurs processus, on peut être amené à forcer l'exécution de ces différentes threads (ou processus) sur différents processeurs (numéros N, N+1, N+2...).

Facile donc ?

Posix nous fournit tout ce qu'il faut ? Euh non. Quand on a ce genre de besoins, il faut utiliser les outils spécifiques du système. Portabilité ? Ben, non. De toute manière, si vous faîtes ça pour des raisons d'amélioration de performances, votre travail est à faire (ou pour le moins à adapter) pour chaque système d'accueil.

Fixer un (des) processeur(s) d'exécution sur Linux

  • taskset est une commande qui permet de contraindre l'exécution d'un programme ou d'un processus sur un ou des processeurs spécifiés en paramètre.
  • Par programmation en C, on peut utiliser sched_affinity ou pthread_setaffinity_np.
Cliquer sur les liens ci-dessus pour obtenir les pages  manuels.

 Facile donc

Oui, il suffit d'utiliser les outils ci-dessus et le tour est joué.  :-)
Ça a pourtant l'air facile. On fixe des numéros de processeurs, en vérifiant que l'on nomme des processeurs existants de la machine (pour simplifier : de numéro inférieur au nombre de processeurs de la machine, que l'on peut obtenir sur Linux par la commande nproc).

En fait, et bien évidemment, c'est  souvent,  sinon le début des ennuis, tout au moins l'entrée dans un espace de complexité accrue. Tant pis pour vous. 

Un processeur = un processeur ?

Ce n'est malheureusement pas une question de philo. Ce n'est pas vraiment qu'il y ait des processeurs plus z'égaux que d'autres. Mais il faut quand même être très attentif. Je sais, tu sais, il sait, nous savons,... qu'il existe des processeurs multi-cœurs et des processeurs hyper-threadés. Pour simplifier, ne considérons que le monde x86 (32 et/ou 64 bits).

"Hiérarchie de processeurs"

On a donc des:
  • processeurs (puces avec plein de pattes qui s'enfichent dans des "sockets" sur la carte mère, et que du coup on appelle assez souvent sockets) qui contiennent
    • des cœurs (cores) que l'on peut parfois appeler abusivement processeurs qui eux-mêmes contiennent
      • des hyper-threads que l'on appelle souvent et abusivement des processeurs.
Pas question  ici de rentrer dans le détail des subtilités. Mais de manière très schématique:
  • les hyper-threads d'un même cœur partagent souvent les mêmes unités de calculs et les caches mémoires L1, L2, L3. Comme elles se partagent les unités de calcul, la puissance de calcul de ces deux hyper-threads, n'est (très généralement) pas le double de celle d'un processeur sans hyper-thread. (On reviendra sur cet aspect dans un article ultérieur).
  • Les cœurs d'une même puce peuvent partager des caches L3 et L2.
  • Des puces différentes ne partagent rien. Quoique....
Si on considère des machines un peu plus puissantes on va avoir en plus des machines de type NUMA (Non Uniform Memory Access) qui vont venir nous compliquer la tâche. Les temps d'accès à une adresse mémoire donnée ne sont pas identiques en fonction du processeur (puce) depuis lequel on référence cette adresse mémoire.

Problèmes de choix

Du coup,voilà quelques problèmes :
  • Si on met 2 processus (pthreads) sur deux cpu (hyper-threads) du même cœur, ils vont se partager (ou être en compétition pour) l'utilisation du cache L1. Mais surtout la puissance de calcul n'est pas forcément celle espérée.
  • Si on met 2 processus (pthreads) sur deux cpu sur des cœurs différents, en supposant qu'il n'y ait pas d'autres processus squattant les hyper-threads co-localisées sur ces cœurs, la puissance de calcul "disponible" devrait être meilleure. Mais les partages de caches étant différents, qui sait... 
Il ne s'agit pas ici, malheureusement, de fournir les clés permettant de répondre à ces questions. Déception. Consternation. Mais simplement de se dire que le placement des processus sur les cpu est une chose importante.  Du coup, il devient nécessaire de savoir si deux CPU de numéros respectifs N et M partagent ou ne partagent pas le même cœur, et/ou les mêmes caches.

De la numérotation à la topologie

Naïvement, on pourrait penser que si le cpu N°0 correspond à la première hyper-thread du premier cœur de la première puce, le cpu  N°1 serait logiquement la deuxième hyper-thread du premier cœur de la première puce...

Perdu!

Comment savoir alors ? Grâce à la commande  lscpu (voir la page manuel). Voici ce qu'elle indique sur une machine :

fa$ lscpu
Architecture:          x86_64
CPU op-mode(s):        32-bit, 64-bit
Byte Order:            Little Endian
CPU(s):                24
On-line CPU(s) list:   0-23
Thread(s) per core:    2
Core(s) per socket:    6
CPU socket(s):         2
NUMA node(s):          2
Vendor ID:             GenuineIntel
CPU family:            6
Model:                 45
Stepping:              7
CPU MHz:               1901.000
BogoMIPS:              3799.47
Virtualization:        VT-x
L1d cache:             32K
L1i cache:             32K
L2 cache:              256K
L3 cache:              15360K
NUMA node0 CPU(s):     0,2,4,6,8,10,12,14,16,18,20,22
NUMA node1 CPU(s):     1,3,5,7,9,11,13,15,17,19,21,23

fa$
Bon , pas très informatif. Ah si notre machine a :
  • 2 sockets (puces)
  • qui sont équipées chacune de 6 cœurs
  •  qui sont chacun "hyper-threadé".
On a donc 24 processeurs à disposition.   Apparemment notre machine a deux nœuds NUMA : l'accès depuis un des processeurs  du nœud 0 (les cpu de numéro pair) à la mémoire  du nœud 0 sera plus rapide qu'un accès à la mémoire du nœud 1 depuis ces mêmes cpu de numéro pair.

En utilisant l'argument -p, on obtient un résultat difficilement lisible mais beaucoup plus instructif:
fa$ lscpu -p
# The following is the parsable format, which can be fed to other
# programs. Each different item in every column has an unique ID
# starting from zero.
# CPU,Core,Socket,Node,,L1d,L1i,L2,L3
0,0,0,0,,0,0,0,0
1,1,1,1,,1,1,1,1
2,2,0,0,,2,2,2,0
3,3,1,1,,3,3,3,1
4,4,0,0,,4,4,4,0
5,5,1,1,,5,5,5,1
6,6,0,0,,6,6,6,0
7,7,1,1,,7,7,7,1
8,8,0,0,,8,8,8,0
9,9,1,1,,9,9,9,1
10,10,0,0,,10,10,10,0
11,11,1,1,,11,11,11,1
12,0,0,0,,0,0,0,0
13,1,1,1,,1,1,1,1
14,2,0,0,,2,2,2,0
15,3,1,1,,3,3,3,1
16,4,0,0,,4,4,4,0
17,5,1,1,,5,5,5,1
18,6,0,0,,6,6,6,0
19,7,1,1,,7,7,7,1
20,8,0,0,,8,8,8,0
21,9,1,1,,9,9,9,1
22,10,0,0,,10,10,10,0
23,11,1,1,,11,11,11,1

fa$

On voit ici que ce sont les cpu N°0 et N°12 qui sont "co-localisés" sur le même cœur de la même socket, et qu'ils partagent les mêmes caches L1, L2, L3...

Voilà!

"Petits compléments"

Si l'envie vous prenait de plonger plus avant dans les problèmes de topologie des processeurs et caches, prenez la peine de lire :