Memory mapped file |
Le présent document est publié sous la licence « Verbatim Copying » telle que définie par la Free Software Fondation dans le cadre du projet GNU.
Toute copie ou diffusion de ce document dans son intégralité est permise (et même encouragée), quel qu'en soit le support à la seule condition que cette notice, incluant les copyrights, soit préservée.
Copyright (C) 2007 Yann LANGLAIS, ilay.
La plus récente version en date de ce document est sise à l'url http://ilay.org/yann/articles/mmap/.
Toute remarque, suggestion ou correction est bienvenue et peut être adressée à Yann LANGLAIS, ilay.org.
Dans un premier temps, regardons ce que peut nous apporter mmap sur un exemple simple : la lecture d'un fichier, ou plus exactement, le passage en revue de tous les octets d'un fichier. Afin d'avoir des points de comparaison, nous allons traiter le problème de trois façons différentes :
Nous allons nous baser sur les fonctions de haut niveau fopen(), fgetc() et fclose().
Le code va se découper en 4 étapes :
L'étape 1 vise à connaître a priori le nombre d'octet du fichier. Il est possible d'ignorer cette taille et de se baser sur la fonction feof(). Cependant, cette dernière prend pas mal de temps et elle est utilisée à toutes les itérations. une autre alternative serait de comparer le retour de fgetc avec le caractère EOF (Ctrl-D).
Le parcourt est simulé par une simple affectation à une variable de type char. On pourrait faire une sortie écran ou fichier pour vérifier le bon fonctionnement. Cependant, dans l'optique de l'évaluation des performances respectives de chaque versions, il est préférable d'éviter les fonctions de formattages qui sont à ma fois très couteuses et dont la durée d'exécution dépend fortement de la charge machine et des éventuels blocages d'entrées-sorties.
Une boucle est ajoutée pour pouvoir traiter plusieurs fichiers.
Voici donc le code source « stdio_read_file.c »:
#include <stdlib.h> #include <stdio.h> #include <sys/types.h> #include <sys/stat.h> int main(int n, char *a[]) { int i, j; FILE *pf; struct stat stats; char c; /* if (n < 2) return 1; */ for (j = 1; j < n; j++) { /* 1 Get file size: */ if (stat(a[j], &stats)) { perror("Cannot stat file: "); return 2; } /* 2 Open the file: */ if (!(pf = fopen(a[j], "r"))) { perror("Cannot open file: "); return 3; } /* 3 Acces the file: */ for (i = 0; i < stats.st_size; i++) { c = getc(pf); } /* 4 Fermeture du fichier: */ fclose(pf); } return 0; }
Pour le compiler, on tapera :
cc stdio_read_file.c -o stdio_read_file
ou
gcc stdio_read_file.c -o stdio_read_file
Après les fonctions de haut niveau, voyons les fonctions de plus bas niveau open(), read() et close().
Le code va se découper en 7 étapes :
Les étapes 3 et 4 visent à éviter la lecture octet par octet par la fonction read(). En effet, cette fonction n'est pas optimale pour des octets. En allouant tout l'espace nécessaire pour le chargement en mémoire, on accélère beaucoup les choses... et l'on obtiendrait presque l'équivalent d'un « mapping » en mémoire. En fait, ce n'est pas du tout un mapping, mais une duplication des données.
Voici donc le code source « io_read_file.c »:
#include <stdlib.h> #include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int main(int n, char *a[]) { int i, j; int fid; struct stat stats; char *p, c; for (j = 1; j < n; j++) { /* 1 Get file size: */ if (stat(a[j], &stats)) { perror("Cannot stat file: "); return 2; } /* 2 Open the file: */ if ((fid = open(a[j], O_RDONLY)) < 1) { perror("Cannot open file: "); return 3; } /* 3 Allocate memory: */ if (!(p = (char *) malloc(stats.st_size))) { perror("Cannot allocate memory: "); close(fid); return 4; } /* 4 Read the whole file : */ read(fid, p, stats.st_size); /* 5 Access the data : */ for (i = 0; i < stats.st_size; i++) c = p[i]; /* 6 Deallocate memory: */ free(p); /* 7 Close the file: */ close(fid); } return 0; }
Pour le compiler, on tapera :
cc io_read_file.c -o io_read_file
ou
gcc io_read_file.c -o io_read_file
Et maintenant, passons à mmap
Le code va se découper en 6 étapes :
L'algorithme reste a peu près le même, sauf que l'opération de lecture est remplacée par une opération de mappage et sa contrepartie.
Voici donc le code source « map_read_file.c »:
#include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <sys/mman.h> #include <string.h> #include <stdio.h> #include <errno.h> int main(int n, char *a[]) { int i, j; int fid; struct stat stats; char *p, c; for (j = 1; j < n; j++) { /* 1 Get file size: */ if (stat(a[j], &stats)) { perror("Cannot stat file: "); return 2; } /* 2 Open the file: */ if ((fid = open(a[j], O_RDONLY)) < 1) { perror("Cannot open file: "); return 3; } /* 3 Map the file to process memory: */ if (!(p = (char *)mmap(NULL, stats.st_size, PROT_READ, MAP_PRIVATE, fid, 0))) { perror("Cannot map file: "); close(fid); return 4; } /* 4 Access the file: */ for (i = 0; i < stats.st_size; i++) c = p[i]; /* 5 Unmap the file */ munmap(p, stats.st_size); /* 6 Close the file: */ close(fid); } return 0; }
Pour le compiler, on tapera :
cc map_read_file.c -o map_read_file
ou
gcc map_read_file.c -o map_read_file
On peut envisager différentes comparaisons. La première, du point de vue intuitif, porte sur le code. La seconde, déjà moins intuitive, est celle des appels systèmes. Enfin, on peut aussi se pencher sur les différences de performances des 3 solutions.
Dans l'ensemble, la complexité des trois solutions reste comparable. L'utilisation de mmap() est moins intuitive que notre traditionnel triptique ouverture/lecture/fermeture. Mais, à part le nombre et le sens des arguments utilisés, le mapping nous permet de n'avoir à manipuler qu'un simple pointeur.
Les autres différences portent sur la façon de gérer les données du fichier. Avec les fonctions de flux comme avec mmap, nous n'avons pas besoin de réserver de mémoire. Par contre, avec le read, il est nécessaire de stocker les données quelque part. Nous devons donc faire un malloc;.. et un free.
Pour vérifier la complexité des appels systèmes, on ne lit qu'un seul fichier. Par ailleurs, on crée une version modifiée de la vesion « stdio » afin de n'effectuer qu'un seul fgetc().
Sous Solaris 8, on lance les différents programmes de la manière suivante :
truss nom_du_programme 10Mo
Et on obtient :
stdio |
stat("10Mo", 0xFFBEEC1C) = 0 open("10Mo", O_RDONLY) = 4 fstat64(4, 0xFFBEEAA8) = 0 brk(0x00020BF8) = 0 brk(0x00024BF8) = 0 ioctl(4, TCGETA, 0xFFBEEA34) Err#25 ENOTTY read(4, " y\n y\n y\n y\n y\n y\n".., 8192) = 8192 llseek(4, 0xFFFFFFFFFFFFE001, SEEK_CUR) = 1 close(4) = 0 llseek(0, 0, SEEK_CUR) = 990736 _exit(0) |
---|---|
io |
stat("10Mo", 0xFFBEEC24) = 0 open("10Mo", O_RDONLY) = 4 brk(0x00020C68) = 0 brk(0x00A22C68) = 0 read(4, " y\n y\n y\n y\n y\n y\n".., 10485760) = 10485760 close(4) = 0 llseek(0, 0, SEEK_CUR) = 1001947 _exit(0) |
mmap |
stat("10Mo", 0xFFBEEC24) = 0 open("10Mo", O_RDONLY) = 4 mmap(0x00000000, 10485760, PROT_READ, MAP_PRIVATE, 4, 0) = 0xFE800000 munmap(0xFE800000, 10485760) = 0 close(4) = 0 llseek(0, 0, SEEK_CUR) = 994638 _exit(0) |
Sous GNU/Linux, on lance la commande suivante :
strace nom_du_programme 10Mo
Et on obtient :
stdio |
stat("10Mo", {st_mode=S_IFREG|0644, st_size=10485760, ...}) = 0 brk(0) = 0x501000 brk(0x522000) = 0x522000 open("10Mo", O_RDONLY) = 3 fstat(3, {st_mode=S_IFREG|0644, st_size=10485760, ...}) = 0 mmap(NULL, 32768, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x2aaaaaaac000 read(3, "y\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\n"..., 32768) = 32768 close(3) = 0 munmap(0x2aaaaaaac000, 32768) = 0 exit_group(0) = ? Process 16478 detached |
---|---|
io |
stat("10Mo", {st_mode=S_IFREG|0644, st_size=10485760, ...}) = 0 open("10Mo", O_RDONLY) = 3 mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x2aaaaaada000 read(3, "y\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\n"..., 10485760) = 10485760 close(3) = 0 exit_group(0) = ? Process 16540 detached |
mmap |
stat("10Mo", {st_mode=S_IFREG|0644, st_size=10485760, ...}) = 0 open("10Mo", O_RDONLY) = 3 mmap(NULL, 10485760, PROT_READ, MAP_PRIVATE, 3, 0) = 0x2aaaaaada000 munmap(0x2aaaaaada000, 10485760) = 0 close(3) = 0 exit_group(0) = ? Process 16548 detached |
On voit clairement que les deux systèmes n'implémentent pas la lecture des fichiers de la même manière. Sous Solaris, les différents types de lecture sont implémentés de façons différentes. A contrario, sous GNU/Linux, on fait toujours du mmap sans le savoir. On voit bien que sous GNU/Linux, la méthode io effectue une copie inutile. En effet, le fichier étant mappé, la lecture en mémoire de l'ensemble du fichier est une étape inutile d'un point de vue système. Sous GNU/Linux toujours, il n'est pas non plus intéressant de passer par stdio dans le mesure ou la lecture totale du fichier implique plusieurs fgetc(). De ce fait, il est plus rationnel de passer par mmap, même si la syntaxe est un peu plus contraignante.
Sous Solaris, il est plus difficile de se prononcer. On foit que les méthodes stdio et io réservent de la mémoire (appels à la fonction brk()) alors que la solution mmap n'en réserve pas. En nombre d'appels système, la méthode mmap est la plus concise.
Pour analyser les performances, on peut se baser sur des fichiers de différentes tailles, allant de 1ko à 10Mo, que l'on pourra générer de la manière suiavante :
yes | head -c 10485760 > 10Mo
On relie 10 fois le même fichier pour mieux mettre en évidence les différences, et on réïtére 10 fois les mesures afin de s'affranchir de l'effet de la charge du système :
for i in `seq 10`; do time map_read_file 10Mo 10Mo 10Mo 10Mo 10Mo 10Mo 10Mo 10Mo 10Mo 10Mo ; done
Une fois la moyenne de ces 10 exécutions calculées, on obtient les valeurs suivant:
Solaris | GNU/Linux | |||||
---|---|---|---|---|---|---|
stdio | io | mmap | stdio | io | mmap | |
1 Ko | 0.0282 | 0.0232 | 0.0295 | |||
10 Ko | 0.0319 | 0.0397 | 0.0324 | |||
100 Ko | 0.1101 | 0.0761 | 0.0637 | |||
1 Mo | 0.6298 | 0.5074 | 0.3365 | |||
10 Mo | 6.4386 | 4.9960 | 3.0685 | 1.6820 | 1.0760 | 0.9800 |
Ces résultats confirment l'intérêt de mmap par rapport aux autres méthodes, même sous Solaris, en particulier pour les fichiers de taille importante.
L'écriture dans un fichier avec mmap peut s'avérer un peu plus difficile. En effet, comme nous avons notre fichier en mémoire, nous devons sa taille. On distingue deux cas. Dans le premier, on ne fait que remplacer des octets existants. Dans le deuxième, on ajoute des octets à la fin d'un fichier.
Parler des différences de mémoires partagées pour les libs (flags lors du truss/strace pendant le chargement des bibliothèques.