Perché malloc + memset è più lento di calloc?


256

È noto che callocè diverso dal fatto mallocche inizializza la memoria allocata. Con calloc, la memoria è impostata su zero. Con malloc, la memoria non viene cancellata.

Quindi, nel lavoro di tutti i giorni, io considero calloccome malloc+ memset. Per inciso, per divertimento, ho scritto il seguente codice per un benchmark.

Il risultato è confuso.

Codice 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++;
        }
}

Uscita del codice 1:

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

Codice 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++;
        }
}

Uscita del codice 2:

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

La sostituzione memsetcon bzero(buf[i],BLOCK_SIZE)in Codice 2 produce lo stesso risultato.

La mia domanda è: perché malloc+ è memsetmolto più lento di calloc? Come può callocfarlo?

Risposte:


455

La versione breve: utilizzare sempre calloc()anziché malloc()+memset(). Nella maggior parte dei casi, saranno gli stessi. In alcuni casi, calloc()farà meno lavoro perché può saltare memset()completamente. In altri casi, calloc()puoi persino imbrogliare e non allocare memoria! Tuttavia, malloc()+memset()farà sempre l'intero lavoro.

Capire questo richiede un breve tour del sistema di memoria.

Tour rapido della memoria

Ci sono quattro parti principali qui: il tuo programma, la libreria standard, il kernel e le tabelle delle pagine. Conosci già il tuo programma, quindi ...

Gli allocatori di memoria amano malloc()e calloc()sono principalmente lì a prendere piccole allocazioni (da 1 byte a 100s di KB) e raggrupparle in pool di memoria più grandi. Ad esempio, se si allocano 16 byte, malloc()si tenterà innanzitutto di ottenere 16 byte da uno dei suoi pool, quindi chiedere più memoria dal kernel quando il pool si esaurisce. Tuttavia, poiché il programma di cui stai chiedendo si sta allocando per una grande quantità di memoria in una volta, malloc()e calloc()chiederà semplicemente quella memoria direttamente dal kernel. La soglia per questo comportamento dipende dal tuo sistema, ma ho visto 1 MiB usato come soglia.

Il kernel è responsabile per l'allocazione della RAM effettiva a ciascun processo e per assicurarsi che i processi non interferiscano con la memoria di altri processi. Si chiama protezione della memoria, è stato molto diffuso dagli anni '90 ed è la ragione per cui un programma può andare in crash senza far cadere l'intero sistema. Quindi quando un programma ha bisogno di più memoria, non può semplicemente prendere la memoria, ma invece richiede la memoria dal kernel usando una chiamata di sistema come mmap()o sbrk(). Il kernel darà RAM ad ogni processo modificando la tabella delle pagine.

La tabella delle pagine mappa gli indirizzi di memoria sulla RAM fisica effettiva. Gli indirizzi del processo, da 0x00000000 a 0xFFFFFFFF su un sistema a 32 bit, non sono memoria reale ma sono invece indirizzi nella memoria virtuale. Il processore divide questi indirizzi in 4 pagine KiB e ogni pagina può essere assegnata a un diverso pezzo di RAM fisica modificando la tabella delle pagine. Solo il kernel è autorizzato a modificare la tabella delle pagine.

Come non funziona

Ecco come l'allocazione di 256 MiB non funziona:

  1. Il processo chiama calloc()e richiede 256 MiB.

  2. La libreria standard chiama mmap()e richiede 256 MiB.

  3. Il kernel trova 256 MiB di RAM inutilizzata e lo fornisce al tuo processo modificando la tabella delle pagine.

  4. La libreria standard azzera la RAM con memset()e ritorna da calloc().

  5. Alla fine il processo termina e il kernel recupera la RAM in modo che possa essere utilizzata da un altro processo.

Come funziona davvero

Il processo sopra descritto funzionerebbe, ma non succede in questo modo. Vi sono tre differenze principali.

  • Quando il tuo processo ottiene nuova memoria dal kernel, quella memoria è stata probabilmente usata da qualche altro processo in precedenza. Questo è un rischio per la sicurezza. E se quella memoria avesse password, chiavi di crittografia o ricette segrete di salsa? Per evitare la perdita di dati sensibili, il kernel pulisce sempre la memoria prima di consegnarla a un processo. Potremmo anche cancellare la memoria azzerandola, e se la nuova memoria viene azzerata potremmo anche renderla una garanzia, in modo da mmap()garantire che la nuova memoria che restituisce sia sempre azzerata.

  • Esistono molti programmi che allocano la memoria ma non la usano subito. Alcune volte la memoria viene allocata ma mai utilizzata. Il kernel lo sa ed è pigro. Quando si alloca nuova memoria, il kernel non tocca affatto la tabella delle pagine e non fornisce RAM al processo. Invece, trova dello spazio degli indirizzi nel tuo processo, prende nota di ciò che dovrebbe andare lì e promette che metterà RAM lì se il tuo programma lo utilizza realmente. Quando il programma tenta di leggere o scrivere da tali indirizzi, il processore attiva un errore di pagina e il kernel passa a assegnare la RAM a tali indirizzi e riprende il programma. Se non si utilizza mai la memoria, l'errore di pagina non si verifica mai e il programma non ottiene mai effettivamente la RAM.

  • Alcuni processi allocano la memoria e quindi la leggono senza modificarla. Ciò significa che molte pagine in memoria attraverso processi diversi possono essere riempite con zero incontaminati restituiti mmap(). Poiché queste pagine sono tutte uguali, il kernel fa in modo che tutti questi indirizzi virtuali puntino a una singola pagina di memoria condivisa da 4 KiB piena di zero. Se provi a scrivere su quella memoria, il processore innesca un altro errore di pagina e il kernel interviene per darti una nuova pagina di zero che non è condivisa con nessun altro programma.

Il processo finale è più simile a questo:

  1. Il processo chiama calloc()e richiede 256 MiB.

  2. La libreria standard chiama mmap()e richiede 256 MiB.

  3. Il kernel trova 256 MiB di spazio di indirizzi inutilizzato , prende nota di ciò per cui ora viene utilizzato tale spazio di indirizzi e restituisce.

  4. La libreria standard sa che il risultato mmap()è sempre pieno di zero (o lo sarà una volta che avrà effettivamente un po 'di RAM), quindi non tocca la memoria, quindi non c'è nessun errore di pagina e la RAM non viene mai assegnata al processo .

  5. Alla fine il tuo processo termina e il kernel non ha bisogno di recuperare la RAM perché non è mai stato allocato in primo luogo.

Se si utilizza memset()per azzerare la pagina, memset()verrà attivato l'errore della pagina, verrà allocata la RAM e quindi azzerata anche se è già piena di zero. Questa è un'enorme quantità di lavoro extra e spiega perché calloc()è più veloce di malloc()e memset(). Se finisce per usare la memoria comunque, calloc()è ancora più veloce di malloc()e memset()ma la differenza non è così ridicola.


Questo non funziona sempre

Non tutti i sistemi dispongono di memoria virtuale paginata, quindi non tutti i sistemi possono utilizzare queste ottimizzazioni. Questo vale per processori molto vecchi come l'80286 e per processori integrati che sono troppo piccoli per una sofisticata unità di gestione della memoria.

Anche questo non funzionerà sempre con allocazioni più piccole. Con allocazioni più piccole, calloc()ottiene la memoria da un pool condiviso invece di andare direttamente al kernel. In generale, il pool condiviso potrebbe contenere dati spazzatura memorizzati nella vecchia memoria utilizzata e liberata free(), quindi calloc()potrebbe richiedere quella memoria e chiamare memset()per cancellarla. Le implementazioni comuni monitoreranno quali parti del pool condiviso sono incontaminate e ancora piene di zero, ma non tutte le implementazioni lo fanno.

Dissolvere alcune risposte sbagliate

A seconda del sistema operativo, il kernel può o meno azzerare la memoria nel suo tempo libero, nel caso in cui sia necessario recuperare un po 'di memoria azzerata in seguito. Linux non azzera la memoria in anticipo e Dragonfly BSD ha recentemente rimosso anche questa funzione dal proprio kernel . Tuttavia, altri kernel eseguono zero memoria in anticipo. L'azzeramento delle pagine durante il periodo di inattività non è sufficiente per spiegare comunque le grandi differenze di prestazioni.

La calloc()funzione non utilizza una versione speciale allineata di memoria di memset(), e ciò non lo renderebbe comunque molto più veloce. La maggior parte delle memset()implementazioni per i processori moderni sembrano in questo modo:

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

Quindi puoi vedere, memset()è molto veloce e non otterrai nulla di meglio per grandi blocchi di memoria.

Il fatto che l' memset()azzeramento della memoria sia già azzerato significa che la memoria viene azzerata due volte, ma ciò spiega solo una differenza di prestazioni 2x. La differenza di prestazioni qui è molto più grande (ho misurato più di tre ordini di grandezza sul mio sistema tra malloc()+memset()e calloc()).

Trucco di festa

Invece di loop 10 volte, scrivere un programma che alloca la memoria fino a quando malloc()o calloc()restituisce null.

Cosa succede se aggiungi memset()?


7
@Dietrich: la spiegazione della memoria virtuale di Dietrich sull'OS che alloca più volte la stessa pagina riempita di zero per calloc è facile da controllare. Basta aggiungere alcuni loop che scrivono dati spazzatura in ogni pagina di memoria allocata (scrivere un byte ogni 500 byte dovrebbe essere sufficiente). Il risultato complessivo dovrebbe quindi essere molto più vicino poiché il sistema sarebbe costretto ad allocare realmente pagine diverse in entrambi i casi.
Kriss,

1
@kriss: in effetti, anche se un byte ogni 4096 è sufficiente nella stragrande maggioranza dei sistemi
Dietrich Epp

In realtà, calloc()fa spesso parte della mallocsuite di implementazione e quindi ottimizzato per non chiamare bzeroquando si ottiene memoria mmap.
mirabilos,

1
Grazie per il montaggio, è quasi quello che avevo in mente. All'inizio affermi di usare sempre calloc invece di malloc + memset. Si prega di indicare 1. default su malloc 2. se è necessario azzerare una piccola parte del buffer, memorizzare quella parte 3. altrimenti utilizzare calloc. In particolare, NON malloc + memset per l'intera dimensione (usare calloc per quello) e NON di default a richiamare tutto perché ostacola cose come valgrind e analizzatori di codice statico (tutta la memoria viene improvvisamente inizializzata). A parte questo, penso che vada bene.
impiegato del mese il

5
Sebbene non sia correlato alla velocità, callocè anche meno soggetto a bug. Cioè, dove large_int * large_intsi tradurrebbe in un overflow, calloc(large_int, large_int)ritorna NULL, ma malloc(large_int * large_int)è un comportamento indefinito, poiché non si conosce la dimensione effettiva del blocco di memoria restituito.
Dunes,

12

Perché su molti sistemi, in tempi di elaborazione ridotti, il sistema operativo va in giro impostando la memoria libera a zero da solo e contrassegnandola come sicura calloc(), quindi quando si chiama calloc(), potrebbe già avere memoria libera, azzerata per darti.


2
Sei sicuro? Quali sistemi fanno questo? Ho pensato che la maggior parte dei sistemi operativi spegnesse il processore quando erano inattivi e azzerava la memoria su richiesta per i processi che si allocavano non appena scrivono su quella memoria (ma non quando lo allocano).
Dietrich Epp,

@Dietrich - Non ne sono sicuro. L'ho sentito una volta e mi è sembrato un modo ragionevole (e ragionevolmente semplice) per renderlo calloc()più efficiente.
Chris Lutz,

@Pierreten - Non riesco a trovare buone informazioni su calloc()ottimizzazioni specifiche e non ho voglia di interpretare il codice sorgente libc per l'OP. Puoi cercare qualcosa per dimostrare che questa ottimizzazione non esiste / non funziona?
Chris Lutz,

13
@Dietrich: FreeBSD dovrebbe riempire zero le pagine in tempo di inattività: vedi le sue impostazioni vm.idlezero_enable.
Zan Lynx,

1
@DietrichEpp mi dispiace per necro, ma per esempio Windows lo fa.
Andreas Grapentin,

1

Su alcune piattaforme in alcune modalità malloc inizializza la memoria su un valore tipicamente diverso da zero prima di restituirla, quindi la seconda versione potrebbe inizializzare la memoria due volte

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.