Що швидше: розподіл стека або розподіл Heap


503

Це питання може здатися елементарним, але це дискусія, яку я мав з іншим розробником, з яким працюю.

Я дбав про те, щоб розміщувати речі, куди міг, а не купувати їх. Він розмовляв зі мною і спостерігав за моїм плечем і коментував, що це не потрібно, тому що вони однакові.

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

Це вражає мене як щось, від чого, ймовірно, буде залежати дуже компілятор. Зокрема, для цього проекту я використовую компілятор Metrowerks для архітектури КПП . Інсайт про цю комбінацію був би найбільш корисним, але в цілому для GCC та MSVC ++, що стосується? Чи розподіл купи не так ефективний, як розподіл стеків? Чи немає різниці? Або різниці настільки хвилинними, що стає безглуздою мікрооптимізацією.


11
Я знаю, що це досить давньо, але було б непогано побачити кілька фрагментів C / C ++, що демонструють різні види розподілу.
Джозеф Вайсман

42
Ваш коров'ячий розпусник страшенно необізнаний, але важливіше, що він небезпечний, оскільки він робить авторитетні претензії щодо речей, які він страшенно невідомий. Акцизуйте таких людей зі своєї команди якомога швидше.
Джим Балтер

5
Зауважте, що купа, як правило, набагато більша, ніж стек. Якщо вам виділено велику кількість даних, вам дійсно доведеться помістити їх у купу або інакше змінити розмір стека в ОС.
Пол Дрейпер

1
Усі оптимізації, за винятком випадків, коли у вас є тести чи аргументи складності, що підтверджують інше, є безглуздими мікрооптимізаціями за замовчуванням.
Бьорн Ліндквіст

2
Цікаво, чи має ваш колега досвід роботи з Java або C #. У цих мовах майже все виділяється під кришкою, що може призвести до таких припущень.
Корт Аммон

Відповіді:


493

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

Крім того, стек проти купи - це не лише врахування продуктивності; це також багато розповідає про очікуваний термін експлуатації об'єктів.


211
І що ще важливіше, стек завжди гарячий, пам'ять, яку ви отримуєте, набагато частіше буде в кеші, ніж будь-яка далека пам'ять, виділена
Benoît

47
У деяких архітектурах (в основному вбудованих, про які я знаю) стек може зберігатися у швидкій пам'яті (наприклад, SRAM). Це може зробити величезну зміну!
орендар

38
Тому що стек - це насправді, стек. Ви не можете звільнити шматок пам'яті, який використовує стек, якщо він не знаходиться над ним. Немає ніякого управління, ви натискаєте на нього чи попсові речі. З іншого боку, обробляється пам'ять купи: вона запитує ядро ​​про шматки пам'яті, можливо, розбиває їх, з’єднує їх, повторно використовує та звільняє. Стек дійсно призначений для швидких та коротких виділень.
Benoît

24
@Pacerier Тому що стек набагато менший, ніж Куча. Якщо ви хочете виділити великі масиви, краще розподіліть їх на Купі. Якщо ви спробуєте виділити великий масив на стеку, це дасть вам переповнення стека. Спробуйте, наприклад, в C ++ це: int t [100000000]; Спробуйте, наприклад, t [10000000] = 10; а потім cout << t [10000000]; Він повинен забезпечити переповнення стека або просто не працюватиме і нічого не покаже. Але якщо ви виділите масив на купу: int * t = new int [100000000]; і зробіть ті самі операції після, це буде працювати, тому що Heap має необхідний розмір для такого великого масиву.
Ліліан А. Морару

7
@Pacerier Найбільш очевидною причиною є те, що об’єкти в стеці виходять із сфери застосування після виходу з блоку, в якому вони виділені.
Джим Балтер,

166

Укладання набагато швидше. Він буквально використовує лише одну інструкцію для більшості архітектур, в більшості випадків, наприклад, для x86:

sub esp, 0x10

(Це переміщує вказівник стека вниз на 0x10 байт і тим самим "виділяє" ці байти для використання змінною.)

Звичайно, розмір стека дуже, дуже кінцевий, тому що ви швидко дізнаєтесь, чи перестараєтеся ви розподіляти стеки чи спробуєте зробити рекурсію :-)

Крім того, мало підстав для оптимізації продуктивності коду, який йому точно не потрібен, як, наприклад, продемонстровано профілюванням. "Передчасна оптимізація" часто викликає більше проблем, ніж варто.

Моє правило: якщо я знаю, що мені знадобляться деякі дані під час компіляції , і це розмір менше декількох сотень байт, я складаю їх-виділяю. В іншому випадку я купую його.


20
Одна інструкція, яку зазвичай поділяють ВСІ об’єкти на стеку.
MSalters

9
Це добре зробило, особливо це стосується того, що він справді потребує цього. Мене постійно дивує те, як занепокоєння людей щодо продуктивності зменшується.
Майк Данлаве

6
"Розподіл" також дуже простий і робиться за допомогою однієї leaveінструкції.
doc

15
Майте на увазі "приховану" вартість тут, особливо вперше ви розширюєте стек. Це може призвести до помилки сторінки, переключення контексту на ядро, яке повинно виконати певну роботу з розподілу пам'яті (або в гіршому випадку завантажити її з swap).
NOS

2
У деяких випадках ви навіть можете виділити його за допомогою 0 інструкцій. Якщо відомо деяка інформація про те, скільки байтів потрібно виділити, компілятор може виділити їх заздалегідь, одночасно виділяючи інші змінні стека. У таких випадках ви взагалі нічого не платите!
Корт Аммон

119

Чесно кажучи, тривіально написати програму для порівняння продуктивності:

#include <ctime>
#include <iostream>

namespace {
    class empty { }; // even empty classes take up 1 byte of space, minimum
}

int main()
{
    std::clock_t start = std::clock();
    for (int i = 0; i < 100000; ++i)
        empty e;
    std::clock_t duration = std::clock() - start;
    std::cout << "stack allocation took " << duration << " clock ticks\n";
    start = std::clock();
    for (int i = 0; i < 100000; ++i) {
        empty* e = new empty;
        delete e;
    };
    duration = std::clock() - start;
    std::cout << "heap allocation took " << duration << " clock ticks\n";
}

Кажуть, що безглузда консистенція - це хобгоблін маленьких розумів . Очевидно оптимізуючі компілятори - це хобгобліни умів багатьох програмістів. Ця дискусія колись була внизу відповіді, але, мабуть, люди не можуть заважати читати так далеко, тому я рухаю її сюди, щоб уникнути отримання питань, на які я вже відповів.

Оптимізуючий компілятор може помітити, що цей код нічого не робить, і може оптимізувати все це. Робота оптимізатора - робити такі речі, а боротьба з оптимізатором - це дурень.

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

Кожен, хто ввімкне оптимізатор і потім поскаржиться на боротьбу з ним, повинен бути предметом громадського глузування.

Якби я дбав про наносекундну точність, я б не користувався std::clock(). Якби я хотів опублікувати результати в якості докторської дисертації, я б зробив більшу справу з цього приводу, і, мабуть, я порівняв би GCC, Tendra / Ten15, LLVM, Watcom, Borland, Visual C ++, Digital Mars, ICC та інші компілятори. Як це відбувається, розподіл купи займає сотні разів довше, ніж розподіл стека, і я не бачу нічого корисного щодо подальшого дослідження цього питання.

Оптимізатор має місію позбутися коду, який я тестую. Я не бачу жодної причини сказати оптимізатору запуститись, а потім спробувати обдурити оптимізатор не насправді оптимізуючи. Але якби я бачив цінність у цьому, я зробив би одне чи більше з наступного:

  1. Додайте до цього члена даних emptyта отримайте доступ до цього члена даних у циклі; але якщо я коли-небудь читав з члена даних, оптимізатор може робити постійне складання і видаляти цикл; якщо я коли-небудь напишу члену даних, оптимізатор може пропустити всі, крім самої останньої ітерації циклу. Крім того, питання полягало не в "розподілі стеку та доступу до даних проти розподілу купи та доступу до даних".

  2. Декларуйте e volatile, але volatileчасто складено неправильно (PDF).

  3. Візьміть адресу eвсередині циклу (і, можливо, призначте його змінній, яка оголошена externта визначена в іншому файлі). Але навіть у цьому випадку компілятор може помітити, що - принаймні, у стеці - eзавжди буде виділено однакову адресу пам'яті, а потім робити постійне складання, як у (1) вище. Я отримую всі ітерації циклу, але об'єкт фактично ніколи не виділяється.

Окрім очевидного, цей тест є недоліком у тому, що він вимірює як розподіл, так і розподіл, і в оригінальному питанні не було питання про розселення. Звичайно, змінні, виділені в стеку, автоматично розміщуються в кінці своєї області дії, тому не викликає delete(1) перекручує числа (депозиція стека включена в числа про розподіл стеків, тому справедливо лише вимірювати купівлю делокації) і ( 2) спричинити досить поганий витік пам’яті, якщо ми не збережемо посилання на новий покажчик і дзвонимо deleteпісля того, як проведемо вимірювання часу.

На моїй машині, використовуючи g ++ 3.4.4 в Windows, я отримую "0 тактових тиків" як для розподілу стека, так і для купівлі, щонайменше, ніж 100000 виділень, і навіть тоді я отримую "0 годинних тиків" для розподілу стеків і "15 годинних тиків" "для розподілу купи. Коли я вимірюю 10 000 000 асигнувань, розподіл стеків займає 31 тактовий такт, а розподіл купи - 1562 тактових.


Так, оптимізуючий компілятор може зійти, створюючи порожні об'єкти. Якщо я правильно розумію, це може навіть схилити весь перший цикл. Коли я наткнувся на ітерації до 10 000 000 розподілу стеків, було потрібно 31 тактовий годинник, а розподіл купи - 1562 тактових. Я думаю, що можна впевнено сказати, що, не кажучи g ++ для оптимізації виконуваного файлу, g ++ не ухилився від конструкторів.


У роки, відколи я писав це, перевагу в переповненні стека було розміщенням продуктивності оптимізованих збірок. Взагалі, я вважаю, що це правильно. Однак я все ще думаю, що нерозумно попросити компілятор оптимізувати код, коли ви насправді не хочете, щоб цей код був оптимізований. Мені здається, що я дуже схожа на доплату за паркування автомобіля, але відмовляється здавати ключі. У цьому конкретному випадку я не хочу, щоб оптимізатор працював.

Використання трохи модифікованої версії еталону (для вирішення дійсної точки, що початкова програма не виділяла щось у стеку щоразу через цикл) та компіляції без оптимізацій, але посилаючись на звільнення бібліотек (для адреси дійсної точки, яку ми не робимо не хочу включати будь-яке уповільнення, викликане посиланням на бібліотеки налагодження):

#include <cstdio>
#include <chrono>

namespace {
    void on_stack()
    {
        int i;
    }

    void on_heap()
    {
        int* i = new int;
        delete i;
    }
}

int main()
{
    auto begin = std::chrono::system_clock::now();
    for (int i = 0; i < 1000000000; ++i)
        on_stack();
    auto end = std::chrono::system_clock::now();

    std::printf("on_stack took %f seconds\n", std::chrono::duration<double>(end - begin).count());

    begin = std::chrono::system_clock::now();
    for (int i = 0; i < 1000000000; ++i)
        on_heap();
    end = std::chrono::system_clock::now();

    std::printf("on_heap took %f seconds\n", std::chrono::duration<double>(end - begin).count());
    return 0;
}

дисплеї:

on_stack took 2.070003 seconds
on_heap took 57.980081 seconds

в моїй системі при компілюванні з командним рядком cl foo.cc /Od /MT /EHsc.

Ви можете не погодитися з моїм підходом до отримання неоптимізованої збірки. Це прекрасно: не соромтеся змінювати орієнтир скільки завгодно. Коли я включаю оптимізацію, я отримую:

on_stack took 0.000000 seconds
on_heap took 51.608723 seconds

Не тому, що розподіл стеків насправді миттєве, а тому, що будь-який напівпристойний компілятор може помітити, що on_stackне робить нічого корисного, і його можна оптимізувати. GCC на моєму ноутбуці Linux також помічає, що on_heapне робить нічого корисного, а також оптимізує його:

on_stack took 0.000003 seconds
on_heap took 0.000002 seconds

2
Крім того, вам слід додати цикл "калібрування" на самому початку вашої основної функції, щоб дати уявлення, скільки часу за цикл, який ви отримуєте, і відрегулювати інші петлі, щоб забезпечити, щоб ваш приклад працює деякий час, а не фіксовану константу, яку ви використовуєте.
Джо Пінеда

2
Я також радий, що збільшення кількості циклів опцій (плюс інструктаж g ++ не оптимізувати?) Дала значні результати. Тож тепер у нас є важкі факти, щоб стверджувати, що стек швидше. Дякуємо за ваші зусилля!
Джо Пінеда

7
Завдання оптимізатора - позбутися такого коду. Чи є вагомі причини ввімкнути оптимізатор, а потім перешкодити йому фактично оптимізувати? Я відредагував відповідь, щоб зробити речі ще зрозумілішими: якщо вам подобається боротися з оптимізатором, будьте готові дізнатися, наскільки розумні автори компілятора.
Макс Лібберт

3
Я запізнююсь, але тут також дуже варто зазначити, що розподіл купи запитує пам'ять через ядро, тому хіт продуктивності також сильно залежить від ефективності ядра. Використання цього коду з Linux (Linux 3.10.7-gentoo №2 SMP, ср. 4 вересня 18:58:21 MDT 2013 x86_64), модифікація таймеру HR та використання 100 мільйонів ітерацій у кожному циклі дають таку ефективність: stack allocation took 0.15354 seconds, heap allocation took 0.834044 secondsз -O0набором, виготовленням Виділення купи Linux лише повільніше, з коефіцієнтом близько 5,5 на моїй конкретній машині.
Taywee

4
У Windows без оптимізацій (збірка налагоджень) він використовуватиме налагоджувальну групу, що набагато повільніше, ніж купу, що не налагоджує. Я не думаю, що це взагалі погана ідея "обманювати" оптимізатор. Автори-компілятори розумні, але компілятори - не AI.
паулм

30

Цікава річ, яку я дізнався про Stack vs. Heap Allocation на процесорі Xbox 360 Xenon, який також може застосовуватися до інших багатоядерних систем, - це те, що виділення на Heap викликає введення критичного розділу для зупинки всіх інших ядер, щоб алокація не конфлікт. Таким чином, у вузькому циклі розподілення стеків було способом перейти на масиви фіксованого розміру, оскільки це запобігло зриви.

Це може бути ще одним прискоренням, яке слід врахувати, якщо ви кодуєте багатоядерні / багатопроцесові, оскільки розподіл стеків буде переглядати лише ядро, що виконує вашу масштабовану функцію, і це не вплине на будь-які інші ядра / процесори.


4
Це справедливо для більшості багатоядерних машин, а не тільки для Xenon. Навіть Cell це повинен зробити, тому що на цьому ядрі PPU можуть бути запущені дві апаратні нитки.
Crashworks

15
Це ефект від (особливо поганої) реалізації розподільника купи. Кращі розподільники купи не повинні отримувати замок на кожному виділенні.
Кріс Додд

19

Ви можете написати спеціальний розподільник купи для конкретних розмірів об'єктів, який є дуже ефективним. Однак загальний розподільник купи не є особливо ефективним.

Також я згоден з Torbjörn Gyllebring щодо очікуваного терміну експлуатації об'єктів. Гарна думка!


1
Це іноді називають розподілом плити.
Бенуа

8

Я не думаю, що розподіл стеків та розподіл купи взагалі взаємозамінні. Я також сподіваюся, що продуктивність обох є достатньою для загального використання.

Я настійно рекомендую для дрібних предметів, який із них більше підходить до обсягу виділення. Для великих предметів купа, ймовірно, необхідна.

У 32-розрядних операційних системах, що мають кілька потоків, стек часто досить обмежений (хоча, як правило, принаймні до декількох mb), тому що адресний простір потрібно вирізати, і рано чи пізно один стек потоків перейде в інший. Для однопотокових систем (Linux glibc з одиночною різьбою все одно) обмеження набагато менше, оскільки стек може просто рости і рости.

У 64-бітних операційних системах є достатньо адресного простору, щоб зробити стеки потоків досить великими.


6

Зазвичай розподіл стеку просто складається з віднімання з реєстру покажчиків стека. Це на тонни швидше, ніж пошук у купі.

Іноді для розподілу стеків потрібно додати сторінку (-ів) віртуальної пам'яті. Додавання нової сторінки з нульовою пам'яттю не вимагає зчитування сторінки з диска, тому зазвичай це все ще буде на швидкість швидше, ніж пошук у купі (особливо, якщо частина кучі теж була заподіяна). У рідкісній ситуації, і ви можете побудувати такий приклад, достатньо простору може бути доступним у частині купи, яка вже є в оперативній пам’яті, але, виділяючи нову сторінку для стека, потрібно зачекати, поки якась інша сторінка буде виписана на диск. У цій рідкісній ситуації купа йде швидше.


Я не думаю, що купу "шукають", якщо вона не створена на сторінці. Досить впевнений, що твердотільна пам'ять використовує мультиплексор і може отримати прямий доступ до пам'яті, отже, пам'ять випадкового доступу.
Джо Філіпс

4
Ось приклад. Програма, що викликає, просить виділити 37 байт. Функція бібліотеки шукає блок принаймні 40 байт. Перший блок у вільному списку має 16 байт. Другий блок у вільному списку має 12 байт. Третій блок має 44 байти. Бібліотека припиняє пошук у цій точці.
Програміст Windows

6

Окрім переваги продуктивності на замовлення порівняно з розподілом купи, розподіл стеків є кращим для довгого запуску серверних додатків. Навіть найкращі керовані групи в кінцевому підсумку стають настільки фрагментарними, що продуктивність додатків знижується.


4

Стек має обмежену ємність, а купа - ні. Типовий стек для процесу або потоку становить близько 8K. Ви не можете змінити розмір, коли він виділений.

Змінна стека дотримується правил розміщення, тоді як купи - ні. Якщо ваш покажчик інструкцій виходить за межі функції, всі нові змінні, пов'язані з функцією, відходять.

Найголовніше - ви не можете заздалегідь передбачити загальну ланцюжок викликів функцій. Таким чином, лише 200 байт з вашого боку може призвести до переповнення стека. Це особливо важливо, якщо ви пишете бібліотеку, а не заявку.


1
Кількість віртуального адресного простору, виділеного для стека режиму користувача в сучасній ОС, швидше за все, буде принаймні 64 кБ або більше (за замовчуванням 1 МБ). Ви говорите про розміри стека ядра?
bk1e

1
На моїй машині розмір стека за замовчуванням для процесу становить 8 МБ, а не кБ. Скільки років ваш комп’ютер?
Грег Роджерс

3

Я думаю, що життя має вирішальне значення, і чи має бути річ, що виділяється, будуватися складно. Наприклад, при моделюванні, що керується транзакціями, вам зазвичай доводиться заповнювати і передавати структуру транзакцій з купою полів операційним функціям. Для прикладу подивіться стандарт OSCI SystemC TLM-2.0.

Розміщення їх на стеці, близькій до виклику до операції, як правило, призводить до величезних накладних витрат, оскільки будівництво дороге. Хороший спосіб - виділити на купі та повторно використовувати об'єкти транзакції шляхом об'єднання або простої політики, наприклад "цьому модулю потрібен лише один об'єкт транзакції".

Це в багато разів швидше, ніж виділення об'єкта при кожному виклику операції.

Причина просто в тому, що об’єкт має дорогу конструкцію і досить тривалий термін експлуатації.

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


3

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

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


3

Це не швидше розподіл стеку jsut. Ви також багато виграєте, використовуючи змінні стека. Вони мають кращий орієнтир. І нарешті, розселення набагато дешевше.


3

Виділення стека - це пара інструкцій, тоді як найшвидший відомий мені розподільник rtos купи (TLSF) використовує в середньому близько 150 інструкцій. Також виділення стеків не потребують блокування, оскільки вони використовують локальне сховище потоків, що є ще одним величезним виграшем продуктивності. Тож розподіл стеків може бути на 2-3 порядки швидшим, залежно від того, наскільки сильно багатопотокове ваше середовище.

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


3

Проблеми, характерні для мови C ++

Перш за все, немає так званого "стека" або "купи" розподілу, дорученого C ++ . Якщо ви говорите про автоматичні об'єкти в областях блоків, вони навіть не «виділяються». (BTW, тривалість автоматичного зберігання в C, безумовно, НЕ та сама, що "виділена"; остання є "динамічною" в мові C ++.) Динамічно виділена пам'ять знаходиться у вільному сховищі , не обов'язково на "купі", хоча Останнє часто ( по замовчуванню) реалізація .

Хоча згідно з семантичними правилами абстрактних машин , автоматичні об'єкти все ще займають пам’ять, відповідною реалізацією C ++ дозволяється ігнорувати цей факт, коли він може довести, що це не має значення (коли це не змінює спостережувану поведінку програми). Цей дозвіл надається правилом нібито, в ISO C ++, що також є загальним пунктом, що дозволяє застосовувати звичайні оптимізації (а в ISO C також існує майже те саме правило). Окрім правила «як би», ISO C ++ також має правила копіювання елісей щоб дозволити опускання конкретних творінь об’єктів. Таким чином, виклики конструктора та деструктора, що залучаються, опускаються. Як результат, автоматичні об'єкти (якщо такі є) у цих конструкторах та деструкторах також усуваються, порівняно з наївною абстрактною семантикою, що має на увазі вихідний код.

З іншого боку, розміщення безкоштовного магазину, безумовно, "розподіл" за дизайном. Відповідно до правил ISO C ++, таке розподіл може бути досягнуто викликом функції розподілу . Однак, оскільки ISO C ++ 14, існує нове правило (не як якщо), яке дозволяє об'єднувати глобальну функцію розподілу (тобто ::operator new) викликів у конкретних випадках. Тож частини динамічних операцій розподілу також можуть бути неоперативними, як у випадку з автоматичними об'єктами.

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

Всі інші проблеми виходять за рамки C ++. Тим не менш, вони можуть бути все ще значущими.

Про реалізацію C ++

C ++ не виставляє змінених записів активації чи якихось першокласних продовжень (наприклад, відомих call/cc), немає ніякого способу безпосередньо маніпулювати кадрами записів активації - там, де реалізація потребує розміщення автоматичних об'єктів. Після того, як не відбувається (не портативна) взаємодія з базовою реалізацією ("нативним" непереносним кодом, таким як вбудований код складання), опущення базового розподілу кадрів може бути досить тривіальним. Наприклад, коли викликана функція вбудована, кадри можна ефективно об'єднати в інші, тому немає можливості показати, що таке "розподіл".

Однак, як тільки дотримуються інтеропи, справи стають складними. Типова реалізація C ++ дозволить виявити можливість взаємодії на ISA (архітектура набору інструкцій) з деякими умовами виклику як бінарної межі, спільної з нативним (машинним рівнем ISA) кодом. Це було б явно дорогим, зокрема, при підтримці покажчика стека , який часто утримується безпосередньо в регістрі рівня ISA (з певними конкретними інструкціями для доступу до машини). Вказівник стеку вказує межу верхнього кадру виклику функції (в даний час активний). Коли вводиться виклик функції, потрібен новий кадр, а покажчик стека додається або віднімається (залежно від умовності ISA) на значення, яке не менше необхідного розміру кадру. Потім кадр, як кажуть, виділенийколи вказівник стека після операцій. Параметри функцій можуть передаватися і на кадр стека, залежно від умовності виклику, що використовується для виклику. Кадр може містити пам'ять автоматичних об'єктів (можливо, включаючи параметри), визначені вихідним кодом C ++. У сенсі таких реалізацій ці об’єкти "виділяються". Коли управління виходить з виклику функції, кадр більше не потрібен, він зазвичай звільняється шляхом відновлення покажчика стека назад у стан перед викликом (збереженим раніше згідно з умовами виклику). Це можна розглядати як "угоду". Ці операції роблять запис активації ефективно структурою даних LIFO, тому його часто називають " стеком (викликом) ".

Оскільки більшість реалізацій C ++ (особливо ті, які орієнтуються на рідний код рівня ISA та використовують мову складання як безпосередній вихід), використовують подібні стратегії, як ця, така заплутана схема "розподілу" є популярною. Такі асигнування (як і транслокації) проводять машинні цикли, і це може бути дорого, коли (неоптимізовані) дзвінки трапляються часто, хоча сучасні мікроархітектури процесора можуть мати складні оптимізації, реалізовані апаратно для загальної схеми коду (наприклад, використання стековий двигун у виконанні PUSH/ POPінструкціях).

Але в будь-якому випадку, в цілому, правда, що вартість розподілу кадру стека значно менша, ніж виклик функції розподілу, що керує безкоштовним магазином (якщо вона повністю не оптимізована) , яка сама може мати сотні (якщо не мільйони :-) операції по підтримці покажчика стека та інших станів. Функції розподілу зазвичай базуються на API, наданому розміщеним середовищем (наприклад, час виконання, передбачений ОС). Такі відмінності від призначення проведення автоматичних об'єктів для викликів функцій, такі розподіли мають загальний характер, тому вони не матимуть структуру кадру, як стек. Традиційно вони виділяють простір із сховища в басейні під назвою купи (або декілька купи). На відміну від "стека", поняття "купа" тут не вказує на структуру даних, що використовується; це отримано з ранньої мовної реалізації десятиліть тому. (BTW, стек викликів зазвичай виділяється з фіксованим або визначеним користувачем розміром з купи оточенням середовища при запуску програми або потоку.) Характер випадків використання ускладнює розподіл і розстановку з купи набагато складніше (ніж push або pop of кадри стека), і навряд чи можливо їх безпосередньо оптимізувати апаратним забезпеченням.

Вплив на доступ до пам'яті

Звичайний розподіл стеків завжди ставить новий кадр вгорі, тому він має досить непогану місцевість. Це дружньо до кеша. OTOH, пам'ять, виділена випадковим чином у вільному магазині, не має такого властивості. Оскільки ISO C ++ 17, існують шаблони ресурсів пулу, які надає компанія <memory>. Пряме призначення такого інтерфейсу - дозволити, щоб результати послідовних виділень були близько в пам'яті. Це підтверджує той факт, що ця стратегія, як правило, хороша для роботи із сучасними реалізаціями, наприклад, дружніми для кешування в сучасних архітектурах. Хоча це стосується продуктивності доступу, а не розподілу .

Паралельність

Очікування одночасного доступу до пам’яті може мати різний вплив між стеком і купами. Стеку викликів, як правило, належить виключно один потік виконання у реалізації C ++. OTOH, купи часто поділяються між потоками в процесі. Для таких груп функції розподілу та розподілу повинні захищати спільну внутрішню адміністративну структуру даних від перегонів даних. Як результат, розподіли купи та розстановки можуть мати додаткові накладні витрати через внутрішні операції синхронізації.

Ефективність простору

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

Обмеження розподілу стеків

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

По-перше, немає можливості виділити простір у стеці з розміром, визначеним під час виконання, портативним способом з ISO C ++. Є розширення, що надаються подібними реалізаціямиalloca VLA + G ++ (масив змінної довжини), але є причини, щоб їх уникнути. (Джерело IIRC, Linux недавно видаляє використання VLA.) (Також зауважте, що ISO C99 має мандат VLA, але ISO C11 не підтримує підтримку.)

По-друге, не існує надійного і портативного способу виявлення виснаження місця у стопі. Це часто називають переповненням стека (хм, етимологія цього сайту) , але, мабуть, точніше, переповнення стека . Насправді це часто спричиняє недійсний доступ до пам'яті, і стан програми потім пошкоджується (... а може, ще гірше, дірка безпеки). Насправді ISO C ++ не має поняття "стек" і робить його невизначеною поведінкою, коли ресурс вичерпується . Будьте обережні, скільки місця повинно залишитися для автоматичних об'єктів.

Якщо місця в стеці вичерпано, в стеці виділено занадто багато об'єктів, що може бути викликано занадто великою кількістю активних викликів функцій або неправильним використанням автоматичних об'єктів. Такі випадки можуть припускати наявність помилок, наприклад, рекурсивний виклик функції без правильних умов виходу.

Тим не менш, іноді бажані глибокі рекурсивні дзвінки. У реалізаціях мов, що потребують підтримки незв’язаних активних дзвінків (де глибина виклику обмежена лише загальною пам'яттю), неможливо використовувати (сучасний) власний стек виклику безпосередньо як запис активації цільової мови, як типовий C ++ реалізація. Щоб вирішити проблему, потрібні альтернативні способи побудови записів активації. Наприклад, SML / NJ явно виділяє кадри на купу і використовує стеки кактусів . Складне розподіл таких кадрів запису активації зазвичай не таке швидке, як кадри стека викликів. Однак якщо такі мови впроваджуються далі з гарантією правильної рекурсії хвоста, пряме виділення стека в мові об'єкта (тобто "об'єкт" у мові не зберігається як посилання, але нативні примітивні значення, які можуть бути відображені один на один, зіставлені на нерозподілені об'єкти C ++), ще складніше, ніж більше виконавчий штраф загалом. Використовуючи C ++ для реалізації таких мов, складно оцінити ефективність роботи.


Як і stl, все менше і менше охочих відрізняти ці поняття. Багато хлопців на cppcon2018 також heapчасто використовують .
陳 力

@ 陳 力 "Куча" може бути однозначною з деякими конкретними реалізаціями, які мають на увазі, тому, можливо, іноді добре. Однак це зайве "взагалі".
FrankHB

Що таке інтероп?
陳 力

@ 陳 力 Я мав на увазі будь-які види "нативного" кодового взаємодії, що задіяні у джерелі C ++, наприклад, будь-який вбудований код складання. Це спирається на припущення (про ABI), не охоплені C ++. COM-інтероп (заснований на деяких специфічних для Windows ABI) більш-менш схожий, хоча в основному нейтральний до C ++.
FrankHB

2

Про такі оптимізації слід зробити загальний пункт.

Оптимізація, яку ви отримуєте, пропорційна кількості часу, коли лічильник програми фактично знаходиться в цьому коді.

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

Тільки якщо ви побачите, що витрачаєте багато часу на групу виділення ваших об'єктів, помітно швидше буде складати їх-розподіляти.


2

Розподіл стеків майже завжди буде настільки ж швидким або швидшим, ніж розподіл купи, хоча, звичайно, можливо, для розподільника купи просто використовувати техніку розподілу на основі стека.

Однак виникають більші проблеми при вирішенні загальної продуктивності розподілу на основі стека та купи (або дещо краще, місцевий та зовнішній розподіл). Зазвичай купівельний (зовнішній) розподіл відбувається повільно, оскільки він має справу з багатьма різними видами виділень та моделей розподілу. Зменшення обсягу використовуваного алокатора (зробивши його локальним для алгоритму / коду), як правило, підвищить продуктивність без великих змін. Додавання кращої структури до моделей розподілу, наприклад, примушування замовлення LIFO щодо пар розподілу та розсилки може також підвищити ефективність роботи розподільника, використовуючи розподільник більш простим та структурованим способом. Або ви можете використовувати або написати алокатор, налаштований на ваш конкретний шаблон розподілу; більшість програм часто виділяють кілька дискретних розмірів, тому купа, яка заснована на зовнішньому буфері кількох фіксованих (бажано відомих) розмірів, буде працювати дуже добре. Саме з цієї причини Windows використовує свою малу фрагментацію.

З іншого боку, розподіл на основі стека в 32-бітовому діапазоні пам'яті також загрожує небезпекою, якщо у вас занадто багато потоків. Стеки потребують суміжного діапазону пам'яті, тому чим більше потоків у вас буде, тим більше віртуального адресного простору вам знадобиться для роботи без переповнення стека. Це не буде проблемою (поки що) з 64-бітною версією, але це, безумовно, може спричинити хаос у тривалих програмах з великою кількістю потоків. Вичерпання віртуального адресного простору через фрагментацію - це завжди біль.


Я не згоден з вашим першим реченням.
Брайан Беунінг

2

Як говорили інші, розподіл стеків зазвичай набагато швидше.

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

Наприклад, якщо ви виділите щось у стеку, а потім помістите його в контейнер, було б краще виділити його на купу і зберегти вказівник у контейнері (наприклад, з std :: shared_ptr <>). Те саме відбувається, якщо ви передаєте або повертаєте об'єкти за значенням та інші подібні сценарії.

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


2
class Foo {
public:
    Foo(int a) {

    }
}
int func() {
    int a1, a2;
    std::cin >> a1;
    std::cin >> a2;

    Foo f1(a1);
    __asm push a1;
    __asm lea ecx, [this];
    __asm call Foo::Foo(int);

    Foo* f2 = new Foo(a2);
    __asm push sizeof(Foo);
    __asm call operator new;//there's a lot instruction here(depends on system)
    __asm push a2;
    __asm call Foo::Foo(int);

    delete f2;
}

Було б так у асм. Коли ви знаходитесь func, f1і покажчик f2виділяється на стек (автоматичне зберігання). І, до речі, Foo f1(a1)не має ніяких ефектів інструкції по покажчику стека ( esp), воно було виділено, якщо funcбажання отримати елемент f1, це інструкція що - щось на зразок цього: lea ecx [ebp+f1], call Foo::SomeFunc(). Інша річ, що виділяє стек, може змусити когось подумати, що пам'ять є чимось подібним FIFO, FIFOщойно сталося, коли ви переходите до якоїсь функції, якщо ви перебуваєте у функції та виділяєте щось на кшталт int i = 0, там не відбулося жодного поштовху.


1

Раніше згадувалося, що розподіл стека - це просто переміщення покажчика стека, тобто єдина інструкція для більшості архітектур. Порівняйте це з тим, що зазвичай відбувається у разі виділення купи.

Операційна система підтримує частини вільної пам'яті як пов'язаний список з даними корисного навантаження, що складається з вказівника на вихідну адресу вільної частини та розміру вільної частини. Щоб виділити X байт пам'яті, список посилань проходить, і кожна примітка відвідується послідовно, перевіряючи, чи є її розмір принаймні X. Коли частина з розміром P> = X знайдена, P розділяється на дві частини з розміри X і PX. Зв'язаний список оновлюється, і покажчик на першу частину повертається.

Як бачите, розподіл купи залежить від можливих факторів, таких як кількість пам'яті, яку ви запитуєте, як фрагментарна пам'ять тощо.


1

Взагалі розподіл стеків швидше, ніж розподіл у купі, як згадується майже у кожній відповіді вище. Натискання або вивільнення стека - це O (1), тоді як виділення або звільнення від купи може вимагати проходження попередніх виділень. Однак зазвичай не слід виділяти тісні цикли, що вимагають високої продуктивності, тому вибір зазвичай зводиться до інших факторів.

Це може бути добре зробити це розмежування: ви можете використовувати "розподільник стеків" на купі. Власне кажучи, я вважаю, що розподіл стеків означає фактичний метод розподілу, а не місце розподілу. Якщо ви виділяєте багато речей на фактичний стек програми, це може бути погано з різних причин. З іншого боку, використання методу стека для розподілу на купі, коли це можливо, є найкращим вибором для методу виділення.

Оскільки ви згадали про Metrowerks та PPC, я думаю, ви маєте на увазі Wii. У цьому випадку пам'ять переважає, а використання методу розподілу стеків, коли це можливо, гарантує, що ви не витрачаєте пам’ять на фрагменти. Звичайно, для цього потрібно набагато більше уважності, ніж "звичайні" методи розподілу купи. Доцільно оцінювати компроміси для кожної ситуації.


1

Зауважте, що міркування, як правило, не стосуються швидкості та продуктивності при виборі стека проти розподілу купи. Стек діє як стек, а це означає, що він добре підходить для натискання блоків і повторного вискакування, останнє, перше. Виконання процедур також є стекоподібним, попередня процедура, що вводиться, спочатку повинна бути закрита. У більшості мов програмування всі змінні, необхідні в процедурі, будуть видимі лише під час виконання процедури, таким чином, вони висуваються при введенні процедури та вискакують зі стека при виході чи поверненні.

Тепер для прикладу, коли стек неможливо використовувати:

Proc P
{
  pointer x;
  Proc S
  {
    pointer y;
    y = allocate_some_data();
    x = y;
  }
}

Якщо ви виділите деяку пам'ять у процедурі S і помістите її в стек, а потім вийдете з S, виділені дані будуть вискакувати зі стека. Але змінна x в P також вказувала на ці дані, тому x тепер вказує на якесь місце під вказівником стека (припустимо, стек зростає вниз) з невідомим вмістом. Вміст все ще може бути там, якщо покажчик стека просто переміщується вгору, не очищаючи дані під ним, але якщо ви почнете виділяти нові дані в стеці, вказівник x може фактично вказувати на ці нові дані.


0

Ніколи не робіть передчасне припущення, оскільки інший код програми та використання можуть вплинути на вашу функцію. Тому дивитися на функцію ізоляція не приносить користі.

Якщо ви серйозно ставитесь до програми, тоді VTune або скористайтеся будь-яким подібним інструментом для профілювання та погляньте на гарячі точки.

Кетан


-1

Я хотів би сказати, що насправді генератор коду GCC (я пам’ятаю також VS) не має накладних витрат, щоб робити розподіл стеків .

Скажіть наступну функцію:

  int f(int i)
  {
      if (i > 0)
      {   
          int array[1000];
      }   
  }

Далі йде генерація коду:

  __Z1fi:
  Leh_func_begin1:
      pushq   %rbp
  Ltmp0:
      movq    %rsp, %rbp
  Ltmp1:
      subq    $**3880**, %rsp <--- here we have the array allocated, even the if doesn't excited.
  Ltmp2:
      movl    %edi, -4(%rbp)
      movl    -8(%rbp), %eax
      addq    $3880, %rsp
      popq    %rbp
      ret 
  Leh_func_end1:

Отже, скільки ви маєте локальної змінної (навіть всередині, якщо або перемикаєте), просто 3880 зміниться на інше значення. Якщо у вас не було локальної змінної, цю інструкцію потрібно просто виконати. Отже, місцева змінна не має накладних витрат.

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