¿Por qué malloc + memset es más lento que calloc?

256

Se sabe que calloces diferente a mallocque inicializa la memoria asignada. Con calloc, la memoria se establece en cero. Con malloc, la memoria no se borra.

Entonces, en el trabajo diario, considero calloccomo malloc+ memset. Por cierto, por diversión, escribí el siguiente código para un punto de referencia.

El resultado es confuso.

Código 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++;
        }
}

Salida del Código 1:

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

Código 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++;
        }
}

Salida del Código 2:

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

Reemplazar memsetcon bzero(buf[i],BLOCK_SIZE)en el Código 2 produce el mismo resultado.

Mi pregunta es: ¿por qué malloc+ es memsetmucho más lento que calloc? ¿Cómo puede callochacer eso?

kingkai
fuente

Respuestas:

455

La versión corta: siempre use en calloc()lugar de malloc()+memset(). En la mayoría de los casos, serán lo mismo. En algunos casos, calloc()hará menos trabajo porque puede saltarse por memset()completo. En otros casos, ¡ calloc()incluso puede hacer trampa y no asignar memoria! Sin embargo, malloc()+memset()siempre hará todo el trabajo.

Comprender esto requiere un breve recorrido por el sistema de memoria.

Recorrido rápido de memoria

Aquí hay cuatro partes principales: su programa, la biblioteca estándar, el kernel y las tablas de páginas. Ya conoces tu programa, así que ...

A los asignadores de memoria les gusta malloc()y calloc()están principalmente allí para tomar pequeñas asignaciones (desde 1 byte hasta 100s de KB) y agruparlas en grandes grupos de memoria. Por ejemplo, si asigna 16 bytes, malloc()primero intentará obtener 16 bytes de uno de sus grupos, y luego solicitará más memoria del núcleo cuando el grupo se seque. Sin embargo, dado que el programa que está preguntando está asignando una gran cantidad de memoria a la vez, malloc()y calloc()solo pedirá esa memoria directamente desde el núcleo. El umbral para este comportamiento depende de su sistema, pero he visto 1 MiB utilizado como umbral.

El núcleo es responsable de asignar RAM real a cada proceso y asegurarse de que los procesos no interfieran con la memoria de otros procesos. Esto se llama protección de memoria, ha sido muy común desde la década de 1990, y es la razón por la cual un programa puede bloquearse sin derribar todo el sistema. Entonces, cuando un programa necesita más memoria, no solo puede tomar la memoria, sino que pide la memoria del núcleo usando una llamada al sistema como mmap()o sbrk(). El núcleo le dará RAM a cada proceso modificando la tabla de páginas.

La tabla de páginas asigna direcciones de memoria a la RAM física real. Las direcciones de su proceso, 0x00000000 a 0xFFFFFFFF en un sistema de 32 bits, no son memoria real, sino que son direcciones en memoria virtual. El procesador divide estas direcciones en 4 páginas KiB, y cada página se puede asignar a una parte diferente de RAM física modificando la tabla de páginas. Solo el núcleo puede modificar la tabla de páginas.

Como no funciona

He aquí cómo la asignación de 256 MiB qué no funciona:

  1. Su proceso llama calloc()y solicita 256 MiB.

  2. La biblioteca estándar llama mmap()y pide 256 MiB.

  3. El kernel encuentra 256 MiB de RAM no utilizada y la entrega a su proceso modificando la tabla de páginas.

  4. La biblioteca estándar pone a cero la RAM con memset()y regresa de calloc().

  5. Su proceso finalmente se cierra y el núcleo recupera la RAM para que pueda ser utilizado por otro proceso.

Cómo funciona realmente

El proceso anterior funcionaría, pero simplemente no sucede de esta manera. Hay tres diferencias principales.

  • Cuando su proceso obtiene nueva memoria del núcleo, esa memoria probablemente fue utilizada por algún otro proceso anteriormente. Este es un riesgo de seguridad. ¿Qué pasa si esa memoria tiene contraseñas, claves de cifrado o recetas secretas de salsa? Para evitar que se filtren datos confidenciales, el núcleo siempre elimina la memoria antes de entregarla a un proceso. También podríamos restregar la memoria poniéndola a cero, y si la memoria nueva se pone a cero, también podríamos hacerla una garantía, por lo mmap()que garantiza que la nueva memoria que devuelve siempre se pone a cero.

  • Existen muchos programas que asignan memoria pero no la utilizan de inmediato. Algunas veces la memoria se asigna pero nunca se usa. El núcleo lo sabe y es flojo. Cuando asigna nueva memoria, el kernel no toca la tabla de páginas y no le da RAM a su proceso. En cambio, encuentra algo de espacio de direcciones en su proceso, toma nota de lo que se supone que debe ir allí y promete que pondrá RAM allí si su programa realmente lo usa. Cuando su programa intenta leer o escribir desde esas direcciones, el procesador desencadena un error de página y los pasos del núcleo asignan RAM a esas direcciones y reanuda su programa. Si nunca usa la memoria, la falla de la página nunca ocurre y su programa nunca obtiene la RAM.

  • Algunos procesos asignan memoria y luego leen de ella sin modificarla. Esto significa que muchas páginas en la memoria a través de diferentes procesos pueden llenarse con ceros prístinos devueltos mmap(). Dado que estas páginas son todas iguales, el núcleo hace que todas estas direcciones virtuales apunten a una sola página compartida de 4 KiB de memoria llena de ceros. Si intenta escribir en esa memoria, el procesador activa otra falla de página y el kernel interviene para darle una nueva página de ceros que no se comparte con ningún otro programa.

El proceso final se parece más a esto:

  1. Su proceso llama calloc()y solicita 256 MiB.

  2. La biblioteca estándar llama mmap()y pide 256 MiB.

  3. El kernel encuentra 256 MiB de espacio de direcciones no utilizado , toma nota de para qué se usa ese espacio de direcciones y regresa.

  4. La biblioteca estándar sabe que el resultado de mmap()siempre está lleno de ceros (o lo será una vez que realmente obtenga algo de RAM), por lo que no toca la memoria, por lo que no hay falla de página y la RAM nunca se entrega a su proceso .

  5. Su proceso finalmente se cierra, y el núcleo no necesita reclamar la RAM porque nunca se asignó en primer lugar.

Si usa memset()la página para ponerla a cero, memset()activará la falla de la página, hará que se asigne la RAM y luego la pondrá a cero aunque ya esté llena de ceros. Esta es una enorme cantidad de trabajo extra y explica por qué calloc()es más rápido que malloc()y memset(). Si termina usando la memoria de todos modos, calloc()sigue siendo más rápido que malloc()y, memset()pero la diferencia no es tan ridícula.


Esto no siempre funciona

No todos los sistemas tienen memoria virtual paginada, por lo que no todos los sistemas pueden usar estas optimizaciones. Esto se aplica a procesadores muy antiguos como el 80286, así como a procesadores integrados que son demasiado pequeños para una unidad de administración de memoria sofisticada.

Esto tampoco siempre funcionará con asignaciones más pequeñas. Con asignaciones más pequeñas, calloc()obtiene memoria de un grupo compartido en lugar de ir directamente al kernel. En general, el grupo compartido podría tener datos basura almacenados en él desde la memoria anterior que se utilizó y se liberó free(), por lo que calloc()podría tomar esa memoria y llamar memset()para borrarla. Las implementaciones comunes rastrearán qué partes del grupo compartido son impecables y aún están llenas de ceros, pero no todas las implementaciones hacen esto.

Disipando algunas respuestas incorrectas

Dependiendo del sistema operativo, el núcleo puede o no tener memoria cero en su tiempo libre, en caso de que necesite obtener algo de memoria puesta a cero más tarde. Linux no pone a cero la memoria antes de tiempo, y Dragonfly BSD recientemente también eliminó esta característica de su núcleo . Sin embargo, algunos otros núcleos hacen cero memoria antes de tiempo. Poner a cero las páginas durante la inactividad no es suficiente para explicar las grandes diferencias de rendimiento de todos modos.

La calloc()función no está utilizando alguna versión especial de memoria alineada memset(), y eso no lo haría mucho más rápido de todos modos. La mayoría de las memset()implementaciones para procesadores modernos se ven así:

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

Como puede ver, memset()es muy rápido y realmente no obtendrá nada mejor para grandes bloques de memoria.

El hecho de que memset()esté poniendo a cero la memoria que ya está puesta a cero significa que la memoria se pone a cero dos veces, pero eso solo explica una diferencia de rendimiento 2x. La diferencia de rendimiento aquí es mucho mayor (midí más de tres órdenes de magnitud en mi sistema entre malloc()+memset()y calloc()).

Truco de fiesta

En lugar de recorrer 10 veces, escriba un programa que asigne memoria hasta malloc()o calloc()devuelva NULL.

¿Qué pasa si agregas memset()?

Dietrich Epp
fuente
77
@Dietrich: la explicación de memoria virtual de Dietrich sobre el sistema operativo que asigna la misma página llena de cero muchas veces para calloc es fácil de verificar. Simplemente agregue un bucle que escriba datos basura en cada página de memoria asignada (escribir un byte cada 500 bytes debería ser suficiente). El resultado general debería estar mucho más cerca ya que el sistema se vería obligado a asignar realmente diferentes páginas en ambos casos.
kriss
1
@kriss: de hecho, aunque un byte cada 4096 es suficiente en la gran mayoría de los sistemas
Dietrich Epp
En realidad, a calloc()menudo es parte del mallocpaquete de implementación y, por lo tanto, está optimizado para no llamar bzeroal obtener memoria mmap.
mirabilos
1
Gracias por editar, eso es casi lo que tenía en mente. Temprano declaras usar siempre calloc en lugar de malloc + memset. Establezca 1. predeterminado en malloc 2. si una pequeña parte del búfer necesita ser puesta a cero, recuerde esa parte 3. de lo contrario use calloc. En particular, NO malloc + memset de todo el tamaño (use calloc para eso) y NO realice el cálculo predeterminado de todo, ya que dificulta cosas como los analizadores de código estático y valgrind (toda la memoria se inicializa repentinamente). Aparte de eso, creo que esto está bien.
empleado del mes
55
Si bien no está relacionado con la velocidad, calloctambién es menos propenso a errores. Es decir, donde large_int * large_intdaría como resultado un desbordamiento, calloc(large_int, large_int)retorna NULL, pero malloc(large_int * large_int)es un comportamiento indefinido, ya que no conoce el tamaño real del bloque de memoria que se devuelve.
Dunes
12

Debido a que en muchos sistemas, en el tiempo de procesamiento libre, el sistema operativo configura la memoria libre en cero por sí mismo y lo marca como seguro calloc(), por lo que cuando llama calloc(), es posible que ya tenga memoria libre y puesta a cero.

Chris Lutz
fuente
2
¿Estás seguro? ¿Qué sistemas hacen esto? Pensé que la mayoría de los sistemas operativos simplemente apagaban el procesador cuando estaban inactivos y ponían a cero la memoria bajo demanda para los procesos que se asignaban tan pronto como escribían en esa memoria (pero no cuando lo asignaban).
Dietrich Epp
@Dietrich - No estoy seguro. Lo escuché una vez y parecía una forma razonable (y razonablemente simple) de hacer calloc()más eficiente.
Chris Lutz
@Pierreten: no puedo encontrar ninguna buena información sobre calloc()optimizaciones específicas y no tengo ganas de interpretar el código fuente de libc para el OP. ¿Puedes buscar algo para demostrar que esta optimización no existe / no funciona?
Chris Lutz
13
@Dietrich: se supone que FreeBSD rellena cero páginas en tiempo de inactividad: consulte su configuración vm.idlezero_enable.
Zan Lynx
1
@DietrichEpp lamentamos necro, pero por ejemplo Windows hace esto.
Andreas Grapentin
1

En algunas plataformas, en algunos modos, malloc inicializa la memoria a un valor típicamente distinto de cero antes de devolverla, por lo que la segunda versión podría inicializar la memoria dos veces

Stewart
fuente