Чому malloc + memset повільніше, ніж calloc?


256

Відомо, що callocвін відрізняється від того, mallocщо він ініціалізує виділену пам'ять. З calloc, пам'ять встановлена ​​в нуль. З malloc, пам'ять не очищається.

Тож у повсякденній роботі я розглядаю callocяк malloc+ memset. До речі, для розваги я написав наступний код для орієнтиру.

Результат - заплутаний.

Код 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++;
        }
}

Вихід коду 1:

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

Код 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++;
        }
}

Вихід коду 2:

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

Заміна memsetз bzero(buf[i],BLOCK_SIZE)в Code 2 виробляє той же самий результат.

Моє запитання: Чому malloc+ memsetтак набагато повільніше, ніж calloc? Як це можна callocзробити?

Відповіді:


455

Коротка версія: Завжди використовувати calloc()замість malloc()+memset(). У більшості випадків вони будуть однаковими. У деяких випадках calloc()буде робити менше роботи, оскільки вона може пропустити memset()повністю. В інших випадках calloc()можна навіть обдурити і не виділити жодної пам’яті! Однак malloc()+memset()завжди виконає повний обсяг роботи.

Для розуміння цього потрібна коротка екскурсія по системі пам'яті.

Швидкий тур пам'яті

Тут є чотири основні частини: ваша програма, стандартна бібліотека, ядро ​​та таблиці сторінок. Ви вже знаєте свою програму, тому ...

Розподільники пам’яті люблять malloc()і calloc()в основному існують там, щоб брати невеликі виділення (від 1 байта до 100 кб) і групувати їх у більші пули пам’яті. Наприклад, якщо ви виділите 16 байт, malloc()спершу спробуйте витягти 16 байт з одного з його пулів, а потім попросіть більше ядра у ядра, коли пул просохне. Однак, оскільки програма, про яку ви запитуєте, виділяє відразу великий об'єм пам'яті, malloc()і calloc()вона просто запитає цю пам'ять прямо з ядра. Поріг такої поведінки залежить від вашої системи, але я бачив, що 1 МіБ використовується як поріг.

Ядро несе відповідальність за розподіл фактичної оперативної пам’яті під кожен процес і за те, щоб процеси не заважали пам’яті інших процесів. Це називається захистом пам’яті, це забруднення звичним з 1990-х, і це одна причина, коли одна програма може вийти з ладу, не збивши всю систему. Отже, коли програмі потрібно більше пам’яті, вона не може просто взяти пам’ять, а натомість запитує пам'ять з ядра за допомогою системного виклику, як mmap()або sbrk(). Ядро надасть оперативну пам’ять кожному процесу, змінивши таблицю сторінок.

Таблиця сторінок відображає адреси пам'яті у фактичній фізичній пам'яті. Адреси вашого процесу, 0x00000000 до 0xFFFFFFFF у 32-бітній системі, не є реальною пам'яттю, а натомість є адресами у віртуальній пам'яті. Процесор розділяє ці адреси на 4 сторінки KiB, і кожна сторінка може бути призначена іншому фрагменту фізичної оперативної пам’яті шляхом зміни таблиці сторінок. Лише ядро ​​може змінювати таблицю сторінок.

Як це не працює

Ось як не працює розподіл 256 Мб :

  1. Ваш процес дзвонить calloc()і просить 256 Мб.

  2. Стандартна бібліотека дзвонить mmap()і просить 256 Мб.

  3. Ядро знаходить 256 Мб невикористаної оперативної пам’яті і передає його вашому процесу шляхом зміни таблиці сторінок.

  4. Стандартна бібліотека обнулює RAM з memset()і повертається з calloc().

  5. Зрештою, ваш процес закінчується, і ядро ​​відновлює оперативну пам'ять, щоб його можна було використовувати в іншому процесі.

Як це насправді працює

Вищеописаний процес спрацював би, але це просто не відбувається таким чином. Є три основні відмінності.

  • Коли ваш процес отримує нову пам'ять з ядра, ця пам’ять, ймовірно, використовувалася іншим процесом раніше. Це ризик для безпеки. Що робити, якщо в цій пам'яті є паролі, ключі шифрування або секретні рецепти сальси? Щоб запобігти витоку чутливих даних, ядро ​​завжди очищає пам’ять, перш ніж передавати їх процесу. Ми можемо також очистити пам'ять, обнуляючи її, і якщо нова пам'ять буде нульовою, ми можемо також зробити її гарантією, тому mmap()гарантує, що нова пам'ять, яку вона повертає, завжди нульова.

  • Є багато програм, які виділяють пам'ять, але не використовують пам'ять відразу. Інколи пам'ять виділяється, але ніколи не використовується. Ядро це знає і лінивий. Коли ви виділяєте нову пам'ять, ядро ​​взагалі не торкається таблиці сторінок і не дає ніякої оперативної пам’яті вашому процесу. Натомість він знаходить деякий адресний простір у вашому процесі, записує те, що потрібно туди відправляти, і обіцяє, що він поставить туди оперативну пам’ять, якщо програма коли-небудь насправді використовує її. Коли ваша програма намагається прочитати або записати з цих адрес, процесор запускає помилку на сторінці і виконує дії ядра при призначенні оперативної пам’яті цим адресам і відновить вашу програму. Якщо ви ніколи не використовуєте пам'ять, помилка сторінки ніколи не відбувається, і ваша програма ніколи фактично не отримує оперативну пам'ять.

  • Деякі процеси виділяють пам'ять, а потім читають з неї, не змінюючи її. Це означає, що багато сторінок пам'яті в різних процесах можуть бути заповнені незайманими нулями, поверненими звідти mmap(). Оскільки ці сторінки однакові, ядро ​​змушує всі ці віртуальні адреси вказувати єдину спільну пам'ять на 4 KiB, заповнену нулями. Якщо ви спробуєте записати в цю пам'ять, процесор запускає іншу помилку сторінки, і ядро ​​вводить, щоб отримати свіжу сторінку нулів, яка не поділяється з будь-якими іншими програмами.

Заключний процес виглядає приблизно так:

  1. Ваш процес дзвонить calloc()і просить 256 Мб.

  2. Стандартна бібліотека дзвонить mmap()і просить 256 Мб.

  3. Ядро знаходить 256 Мб невикористаного адресного простору, робить примітку про те, для чого використовується цей адресний простір, і повертає.

  4. Стандартна бібліотека знає , що результат mmap()завжди заповнений нулями (або буде , коли він фактично отримує оперативну пам'ять), так що це не стосується пам'яті, так що немає ніякої помилки сторінки, і RAM ніколи не дається до процесу .

  5. Зрештою, ваш процес закінчується, і ядро ​​не потребує повернення оперативної пам’яті, оскільки його ніколи не було виділено.

Якщо ви використаєте memset()нуль сторінки, memset()це призведе до помилки сторінки, спричинить виділення оперативної пам’яті та занулює її, хоча вона вже заповнена нулями. Це величезна кількість зайвої роботи, і пояснюється, чому calloc()це швидше malloc()і ніж memset(). Якщо все- calloc()таки використовувати пам'ять все одно, це все-таки швидше malloc()і, memset()але різниця не настільки смішна.


Це не завжди працює

Не всі системи підтримують віртуальну пам’ять, тому не всі системи можуть використовувати ці оптимізації. Це стосується дуже старих процесорів, таких як 80286, а також вбудованих процесорів, які занадто малі для складного блоку управління пам'яттю.

Це також не завжди працюватиме з меншими асигнуваннями. З меншими розмірами, calloc()отримує пам'ять із спільного пулу, а не прямує безпосередньо до ядра. Загалом, спільний пул може містити непотрібні дані, що зберігаються в ньому зі старої пам’яті, яка була використана та звільнена за допомогою free(), тож calloc()можна взяти цю пам’ять та зателефонувати, memset()щоб очистити її. Загальні реалізації відстежують, які частини спільного пулу є первозданними і все ще заповнені нулями, але не всі реалізації роблять це.

Розвіяти деякі неправильні відповіді

Залежно від операційної системи, ядро ​​може або не може нульової пам'яті у вільний час, якщо вам потрібно буде отримати трохи нульової пам'яті пізніше. Linux не занулює пам'ять раніше часу, а Dragonfly BSD нещодавно також видалив цю функцію зі свого ядра . Однак деякі інші ядра роблять нульову пам’ять раніше часу. Нульові сторінки, що мають нульові очікування, недостатньо, щоб все-таки пояснити великі відмінності в продуктивності.

Ця calloc()функція не використовує якусь спеціальну версію, орієнтовану на пам'ять memset(), і це не зробить її набагато швидшою. Більшість memset()реалізацій для сучасних процесорів виглядають приблизно так:

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

Отже, ви можете бачити, memset()це дуже швидко, і ви насправді не отримаєте нічого кращого для великих блоків пам'яті.

Той факт, що memset()нульова пам'ять, яка вже нульова, означає, що пам'ять отримує нуль двічі, але це лише пояснює різницю продуктивності в 2 рази. Різниця в продуктивності тут набагато більша (я вимірював більше трьох порядків у своїй системі між malloc()+memset()і calloc()).

Партія витівки

Замість циклу 10 разів напишіть програму, яка виділяє пам'ять до malloc()або calloc()поверне NULL.

Що станеться, якщо додати memset()?


7
@Dietrich: пояснення віртуальної пам’яті Дітріха про те, що ОС виділяє ту саму заповнену нулем сторінку багато разів для calloc, легко перевірити. Просто додайте цикл, який записує непотрібні дані на кожну виділену сторінку пам'яті (одного байта кожні 500 байтів має бути достатньо). Загальний результат повинен тоді стати набагато ближчим, оскільки система буде змушена по-справжньому виділяти різні сторінки в обох випадках.
kriss

1
@kriss: дійсно, хоча одного байта кожні 4096 вистачає на переважну більшість систем
Дітріх Епп,

Насправді, calloc()часто є частиною пакету mallocреалізації, і, таким чином, оптимізовано не дзвонити, bzeroколи отримує пам'ять mmap.
mirabilos

1
Дякую за редагування, це майже те, що я мав на увазі. На початку ви заявляєте, що завжди використовуйте calloc замість malloc + memset. Будь ласка, вкажіть значення 1. за замовчуванням до malloc 2. якщо мала частина буфера має бути нульовою, запам'ятайте цю частину 3. в іншому випадку використовуйте calloc. Зокрема, НЕ malloc + запам'ятовуйте весь розмір (використовуйте calloc для цього) і НЕ замовчуйте називати все, оскільки це перешкоджає таким речам, як вальдрінд та аналізатори статичного коду (вся пам'ять раптово ініціалізована). Крім того, я вважаю, що це добре.
працівник місяця

5
Хоча це не пов'язано з швидкістю, callocтакож менше схильні помилки. Тобто, де large_int * large_intце призведе до переповнення, calloc(large_int, large_int)повертається NULL, але malloc(large_int * large_int)це невизначена поведінка, оскільки ви не знаєте фактичного розміру повернутого блоку пам'яті.
Дюни

12

Оскільки у багатьох системах у вільний час обробки ОС працює навколо встановлення вільної пам'яті на нуль самостійно і маркування її безпечною calloc(), тому коли ви телефонуєте calloc(), вона вже може мати безкоштовну, нульову пам'ять.


2
Ти впевнений? Які системи роблять це? Я подумав, що більшість ОС просто вимикають процесор, коли вони простоюють, і нульову пам’ять вимагають для процесів, які виділяються, як тільки вони записують у цю пам'ять (але не тоді, коли вони її виділяють).
Дітріх Епп

@Dietrich - Не впевнений. Я чув це один раз, і це здавалося розумним (і досить простим) способом зробити calloc()більш ефективним.
Кріс Луц

@Pierreten - Я не можу знайти гарну інформацію про calloc()специфічні оптимізації, і мені не здається інтерпретувати вихідний код libc для ОП. Чи можете ви шукати що-небудь, щоб показати, що ця оптимізація не існує / не працює?
Кріс Луц

13
@Dietrich: FreeBSD повинен заповнити нульові сторінки в режимі очікування: Перегляньте його налаштування vm.idlezero_enable.
Zan Lynx

1
@DietrichEpp вибачте за некроз, але, наприклад, це робить Windows.
Андреас Грапентін

1

На деяких платформах в деяких режимах malloc ініціалізує пам'ять до деякого, як правило, ненульового значення перед поверненням, тому друга версія може цілком ініціалізувати пам'ять двічі

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.