web-dev-qa-db-fra.com

Pourquoi malloc + memset est plus lent que calloc?

On sait que calloc est différent de malloc en ce qu'il initialise la mémoire allouée. Avec calloc, la mémoire est mise à zéro. Avec malloc, la mémoire n'est pas effacée.

Donc, dans le travail quotidien, je considère calloc comme malloc + memset. Incidemment, pour le plaisir, j’ai écrit le code suivant pour un repère.

Le résultat est déroutant.

Code 1:

#include<stdio.h>
#include<stdlib.h>
#define BLOCK_SIZE 1024*1024*256
int main()
{
        int i=0;
        char *buf[10];
        while(i<10)
        {
                buf[i] = (char*)calloc(1,BLOCK_SIZE);
                i++;
        }
}

Sortie du code 1:

time ./a.out  
**real 0m0.287s**  
user 0m0.095s  
sys 0m0.192s  

Code 2:

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#define BLOCK_SIZE 1024*1024*256
int main()
{
        int i=0;
        char *buf[10];
        while(i<10)
        {
                buf[i] = (char*)malloc(BLOCK_SIZE);
                memset(buf[i],'\0',BLOCK_SIZE);
                i++;
        }
}

Sortie du code 2:

time ./a.out   
**real 0m2.693s**  
user 0m0.973s  
sys 0m1.721s  

Remplacer memset par bzero(buf[i],BLOCK_SIZE) dans le code 2 produit le même résultat.

Ma question est: Pourquoi malloc + memset est-il tellement plus lent que calloc? Comment calloc peut-il le faire?

244
kingkai

La version courte: Toujours utiliser calloc() au lieu de malloc()+memset(). Dans la plupart des cas, ils seront les mêmes. Dans certains cas, calloc() fera moins de travail car il peut ignorer memset() entièrement. Dans d'autres cas, calloc() peut même tricher et ne pas allouer de mémoire! Cependant, malloc()+memset() fera toujours tout le travail.

Comprendre cela nécessite une brève visite du système de mémoire.

Tour rapide de la mémoire

Il comprend quatre parties principales: votre programme, la bibliothèque standard, le noyau et les tables de pages. Vous connaissez déjà votre programme, alors ...

Les allocateurs de mémoire tels que malloc() et calloc() permettent généralement de prendre de petites allocations (de 1 octet à 100 Ko) et de les regrouper dans des pools de mémoire plus importants. Par exemple, si vous allouez 16 octets, malloc() essaiera d'abord d'extraire 16 octets de l'un de ses pools, puis demandera plus de mémoire au noyau lorsque le pool sera à sec. Cependant, étant donné que le programme dont vous parlez alloue en même temps une grande quantité de mémoire, malloc() et calloc() demanderont simplement cette mémoire directement du noyau. Le seuil de ce comportement dépend de votre système, mais j’ai vu 1 Mio utilisé comme seuil.

Le noyau est responsable de l'allocation de la mémoire RAM réelle à chaque processus et de la vérification de son intégrité par la mémoire des autres processus. Ceci s'appelle protection de la mémoire, c'est une saleté commune depuis les années 1990, et c'est la raison pour laquelle un programme peut planter sans effondrer tout le système. Ainsi, lorsqu'un programme a besoin de plus de mémoire, il ne peut pas prendre que de la mémoire, mais il demande à la mémoire du noyau d'utiliser un appel système tel que mmap() ou sbrk(). Le noyau donnera RAM à chaque processus en modifiant la table des pages.

La table de pages mappe les adresses de mémoire sur la RAM physique réelle. Les adresses de votre processus, 0x00000000 à 0xFFFFFFFF sur un système 32 bits, ne sont pas de la mémoire réelle mais sont plutôt des adresses dans mémoire virtuelle. Le processeur divise ces adresses en 4 pages de Ko, et chaque page peut être affecté à un autre élément physique RAM en modifiant la table des pages. Seul le noyau est autorisé à modifier la table des pages.

Comment ça ne marche pas

Voici comment allouer 256 MiB pas fonctionne:

  1. Votre processus appelle calloc() et demande 256 Mio.

  2. La bibliothèque standard appelle mmap() et demande 256 Mio.

  3. Le noyau trouve 256 Mio de RAM inutilisés et les communique à votre processus en modifiant le tableau des pages.

  4. La bibliothèque standard met à zéro le RAM avec memset() et renvoie à partir de calloc().

  5. Votre processus se termine finalement et le noyau récupère le RAM afin qu'il puisse être utilisé par un autre processus.

Comment ça marche?

Le processus ci-dessus fonctionnerait, mais cela ne se passe tout simplement pas comme ça. Il y a trois différences majeures.

  • Lorsque votre processus obtient une nouvelle mémoire du noyau, cette mémoire était probablement déjà utilisée par un autre processus. C'est un risque de sécurité. Que se passe-t-il si cette mémoire contient des mots de passe, des clés de cryptage ou des recettes secrètes de salsa? Pour empêcher les données sensibles de fuir, le noyau nettoie toujours la mémoire avant de la donner à un processus. Nous pourrions aussi bien effacer la mémoire en la mettant à zéro, et si une nouvelle mémoire est mise à zéro, nous pouvons aussi en faire une garantie, ainsi mmap() garantit que la nouvelle mémoire renvoyée est toujours mise à zéro.

  • De nombreux programmes allouent de la mémoire mais ne l'utilisent pas tout de suite. Parfois, la mémoire est allouée mais jamais utilisée. Le noyau le sait et est paresseux. Lorsque vous allouez une nouvelle mémoire, le noyau ne touche pas du tout la table des pages et ne donne pas de RAM à votre processus. Au lieu de cela, il trouve un espace d'adresse dans votre processus, note ce qui est supposé y aller et fait la promesse qu'il y mettra RAM si votre programme l'utilise réellement. Lorsque votre programme essaie de lire ou d'écrire à partir de ces adresses, le processeur déclenche un erreur de page et le noyau procède à l'affectation de RAM à ces adresses et reprend votre programme. Si vous n'utilisez jamais la mémoire, l'erreur de page ne se produit jamais et votre programme ne récupère jamais la RAM.

  • Certains processus allouent de la mémoire puis la lisent sans la modifier. Cela signifie qu'un grand nombre de pages en mémoire dans différents processus peuvent être remplies de zéros vierges renvoyés par mmap(). Comme ces pages sont toutes identiques, le noyau fait en sorte que toutes ces adresses virtuelles pointent vers une seule page partagée partagée de 4 ko de mémoire remplie de zéros. Si vous essayez d'écrire dans cette mémoire, le processeur déclenche une autre erreur de page et le noyau intervient pour vous donner une nouvelle page de zéros qui n'est partagée avec aucun autre programme.

Le processus final ressemble plus à ceci:

  1. Votre processus appelle calloc() et demande 256 Mio.

  2. La bibliothèque standard appelle mmap() et demande 256 Mio.

  3. Le noyau trouve 256 Mio inutilisés espace d'adressage, note le fonctionnement de cet espace d'adressage et le renvoie.

  4. La bibliothèque standard sait que le résultat de mmap() est toujours rempli de zéros (ou le sera une fois que la mémoire RAM est réellement disponible), de sorte qu'il ne touche pas la mémoire. il n'y a pas d'erreur de page et le RAM n'est jamais transmis à votre processus.

  5. Votre processus se termine finalement et le noyau n'a pas besoin de récupérer le RAM car il n'a jamais été alloué.

Si vous utilisez memset() pour mettre la page à zéro, memset() déclenchera la faute de page, provoquera l'allocation de la RAM, puis la remettra à zéro alors qu'elle est déjà remplie. zéros. Cela représente une énorme quantité de travail supplémentaire et explique pourquoi calloc() est plus rapide que malloc() et memset(). Si vous utilisez quand même la mémoire, calloc() est toujours plus rapide que malloc() et memset(), mais la différence n'est pas si ridicule.


Ça ne marche pas toujours

Tous les systèmes ne disposent pas de mémoire virtuelle paginée. Par conséquent, tous les systèmes ne peuvent pas utiliser ces optimisations. Cela s'applique aux très vieux processeurs comme le 80286 ainsi qu'aux processeurs intégrés qui sont tout simplement trop petits pour une unité de gestion de mémoire sophistiquée.

Cela ne fonctionnera pas toujours avec des allocations plus petites. Avec des allocations plus petites, calloc() obtient la mémoire d'un pool partagé au lieu d'aller directement au noyau. En général, le pool partagé peut contenir des données indésirables stockées dans une ancienne mémoire utilisée et libérée avec free(), de sorte que calloc() puisse utiliser cette mémoire et appeler memset() pour effacer IT out. Les implémentations courantes suivront quelles parties du pool partagé sont vierges et toujours remplies de zéros, mais toutes les implémentations ne le font pas.

Dissiper certaines mauvaises réponses

Selon le système d'exploitation, le noyau peut ou non mettre à zéro la mémoire pendant son temps libre, au cas où vous auriez besoin d'obtenir de la mémoire remise à zéro ultérieurement. Linux ne met pas la mémoire à zéro à l'avance, et Dragonfly BSD a récemment supprimé cette fonctionnalité de leur noya . Certains autres noyaux ne stockent aucune mémoire à l’avance, cependant. La mise à zéro des pages au ralenti n'est pas suffisante pour expliquer les grandes différences de performances.

La fonction calloc() n'utilise pas une version spéciale de memset() alignée sur la mémoire, et cela ne le rendrait pas beaucoup plus rapide de toute façon. La plupart des implémentations memset() pour les processeurs modernes ressemblent à ceci:

function memset(dest, c, len)
    // one byte at a time, until the dest is aligned...
    while (len > 0 && ((unsigned int)dest & 15))
        *dest++ = c
        len -= 1
    // now write big chunks at a time (processor-specific)...
    // block size might not be 16, it's just pseudocode
    while (len >= 16)
        // some optimized vector code goes here
        // glibc uses SSE2 when available
        dest += 16
        len -= 16
    // the end is not aligned, so one byte at a time
    while (len > 0)
        *dest++ = c
        len -= 1

Ainsi, vous pouvez voir que memset() est très rapide et que vous n'allez vraiment rien obtenir de mieux pour de gros blocs de mémoire.

Le fait que memset() mette à zéro la mémoire déjà mise à zéro signifie que la mémoire est remise à zéro deux fois, mais cela n'explique qu'une différence de performance de 2x. La différence de performance ici est beaucoup plus grande (j'ai mesuré plus de trois ordres de grandeur sur mon système entre malloc()+memset() et calloc()).

Tour du parti

Au lieu de boucler 10 fois, écrivez un programme qui alloue de la mémoire jusqu'à ce que malloc() ou calloc() renvoie la valeur NULL.

Que se passe-t-il si vous ajoutez memset()?

436
Dietrich Epp

Parce que sur de nombreux systèmes, pendant le temps de traitement disponible, le système d'exploitation contournait lui-même la mémoire libre et le désignait comme sûr pour calloc(). Ainsi, lorsque vous appelez calloc(), il se peut qu'il avoir de la mémoire libre et zéro pour vous donner.

12
Chris Lutz

Sur certaines plateformes dans certains modes, malloc initialise la mémoire à une valeur généralement non nulle avant de la renvoyer, de sorte que la deuxième version pourrait bien initialiser la mémoire deux fois.

1
Stewart