Что быстрее: распределение стека или распределение кучи

этот вопрос может показаться довольно элементарным, но это дискуссия, которую я имел с другим разработчиком, с которым я работаю.

Я заботился о том, чтобы штабелировать вещи, где я мог, вместо того, чтобы кучно распределять их. Он разговаривал со мной и смотрел через мое плечо и прокомментировал, что это не обязательно, потому что они одинаково мудры в исполнении.

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

Это кажется мне чем-то, что, вероятно, будет очень зависимым от компилятора. Для этого проекта я использую где расположен metrowerks компилятор PPC архитектура. Понимание этой комбинации было бы очень полезно, но в целом, для GCC и MSVC++, в чем дело? Распределение кучи не так высоко, как распределение стека? Разве нет разницы? Или различия настолько незначительны, что становится бессмысленной микро-оптимизацией.

23 ответов


распределение стека намного быстрее, так как все, что он действительно делает, это переместить указатель стека. Используя пулы памяти, вы можете получить сопоставимую производительность из распределения кучи, но это связано с небольшой сложностью и собственной головной болью.

кроме того, стек и куча это не только учет производительности; это также говорит вам много о ожидаемой существования объектов.


стек намного быстрее. Он буквально использует только одну инструкцию на большинстве архитектур, в большинстве случаев, например, на x86:

sub esp, 0x10

(который перемещает указатель стека вниз на 0x10 байт и тем самым "выделяет" эти байты для использования переменной.)

конечно, размер стека очень, очень ограничен, так как вы быстро узнаете, злоупотребляете ли вы распределением стека или пытаетесь сделать рекурсию :-)

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

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


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

#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 такта.


да, оптимизирующий компилятор может elide создание пустых объектов. Если я правильно понимаю, это может даже сначала устранить все петля. Когда я поднял итерации до 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

интересная вещь, которую я узнал о распределении стека и кучи на процессоре Xbox 360 Xenon, который также может применяться к другим многоядерным системам, заключается в том, что выделение в куче вызывает ввод критического раздела для остановки всех других ядер, чтобы аллок не конфликтовал. Таким образом, в узком цикле распределение стека было способом пойти для массивов фиксированного размера, поскольку это предотвратило киоски.

Это может быть еще одно ускорение, чтобы рассмотреть, если вы кодируете для multicore / multiproc, в этом ваше распределение стека будет просматриваться только ядром, выполняющим вашу функцию области, и это не повлияет на другие ядра/процессоры.


вы можете написать специальный распределитель кучи для определенных размеров объектов, который очень эффективен. Однако общие распределитель кучи не особенно эффективен.

также Я согласен с Torbjörn Gyllebring об ожидаемом времени жизни объектов. Хорошая мысль!


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

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

в 32-разрядных операционных системах, имеющих несколько потоков, стек часто довольно ограничен (хотя обычно по крайней мере до нескольких Мб), потому что адресное пространство должно быть разделено, и рано или поздно один стек потоков столкнется с другим. В однопоточных системах (Linux glibc single threaded anyway) ограничение намного меньше, потому что стек может просто расти и расти.

в 64-битных операционных системах достаточно адресного пространства, чтобы сделать стеки потоков довольно большими.


обычно распределение стека просто состоит из вычитания из регистра указателя стека. Это на тонны быстрее, чем искать кучу.

иногда для распределения стека требуется добавить страницу(ы) виртуальной памяти. Добавление новой страницы обнуленной памяти не требует чтения страницы с диска, поэтому обычно это все равно будет намного быстрее, чем поиск кучи (особенно если часть кучи также была выгружена). В редкой ситуации, и вы могли бы построить такой пример, достаточно места просто оказывается доступным в части кучи, которая уже находится в ОЗУ, но выделение новой страницы для стека должно ждать, пока какая-то другая страница будет записана на диск. В этой редкой ситуации куча быстрее.


помимо преимущества производительности порядка величины над распределением кучи, распределение стека предпочтительнее для длительных серверных приложений. Даже лучшие управляемые кучи в конечном итоге становятся настолько фрагментированными, что производительность приложений ухудшается.


стек имеет ограниченную емкость, а кучи нет. Типичный стек для процесса или потока составляет около 8K. Вы не можете изменить размер после его выделения.

переменная стека следует правилам области видимости, а куча-нет. Если указатель инструкции выходит за пределы функции, все новые переменные, связанные с функцией, исчезают.

самое главное, вы не можете заранее предсказать общую цепочку вызовов функций. Всего лишь 200 распределение байт с вашей стороны может возникнуть переполнение стека. Это особенно важно, если вы пишете библиотеку, а не приложение.


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

выделение их в стеке, близком к вызову операции, имеет тенденцию вызывать огромные накладные расходы, поскольку строительство дорого. Добро способ заключается в выделении в куче и повторном использовании объектов транзакций либо путем объединения, либо с помощью простой политики типа "этому модулю нужен только один объект транзакции".

Это во много раз быстрее, чем выделение объекта при каждом вызове операции.

причина просто в том, что объект имеет дорогостоящую конструкцию и довольно длительный срок службы.

Я бы сказал: попробуйте оба и посмотреть, что работает лучше в вашем случае, потому что это действительно может зависеть от поведение вашего кода.


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

для других приложений, где синхронизация не является проблемой, это может не иметь значения, но если вы выделяете кучу много, это повлияет на скорость выполнения. Всегда старайтесь использовать стек для кратковременной и часто выделяемой памяти (например, в циклах), а как можно дольше-делайте выделение кучи при запуске приложения.


Это не распределение стека jsut, которое быстрее. Вы также выигрываете много при использовании переменных стека. Они лучше ориентируются в местности. И, наконец, высвобождение намного дешевле.


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

однако существуют более серьезные проблемы при работе с общей производительностью распределения на основе стека и кучи (или в несколько лучших терминах, локальное и внешнее распределение). Обычно распределение кучи (внешнее) происходит медленно, потому что оно имеет дело со многими различными видами распределений и модели распределения. Уменьшение объема используемого распределителя (что делает его локальным для алгоритма/кода) будет способствовать повышению производительности без каких-либо серьезных изменений. Добавление лучшей структуры к шаблонам распределения, например, принудительное упорядочение LIFO по парам распределения и освобождения, также может улучшить производительность вашего распределителя, используя распределитель более простым и структурированным способом. Или вы можете использовать или написать распределитель, настроенный для вашего конкретного шаблона распределения; большинство программы часто выделяют несколько дискретных размеров, поэтому куча, основанная на буфере lookaside нескольких фиксированных (предпочтительно известных) размеров, будет работать очень хорошо. Именно по этой причине Windows использует свою низкую фрагментацию кучи.

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


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

вообще распределение кучи ваше последнее средство если вы заботите о представлении. Ля жизнеспособный промежуточный вариант может быть распределителем фиксированного пула, который также является только парой инструкций и имеет очень мало накладных расходов на распределение, поэтому он отлично подходит для небольших объектов фиксированного размера. С другой стороны, он работает только с объектами фиксированного размера, не является по своей сути потокобезопасным и имеет проблемы фрагментации блоков.


существует общая точка зрения о таких оптимизациях.

оптимизация, которую вы получаете, пропорциональна количеству времени, которое счетчик программы фактически находится в этом коде.

Если вы попробуете счетчик программы, вы узнаете, где он проводит свое время, и это обычно в крошечной части кода, и часто в библиотечных подпрограммах вы не контролируете.

только если вы обнаружите, что он тратит много времени на распределение кучи ваши объекты будут заметно быстрее складываться-распределять их.


распределение стека намного быстрее.


как говорили другие, распределение стека, как правило, намного быстрее.

однако, если ваши объекты дорого копировать, выделение в стеке может привести к огромной производительности позже, когда вы используете объекты, если вы не будете осторожны.

например, если вы выделяете что-то в стеке, а затем помещаете его в контейнер, было бы лучше выделить в куче и сохранить указатель в контейнере (например, с помощью std::shared_ptr). То же самое true, если вы передаете или возвращаете объекты по значению и другим подобным сценариям.

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


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

это было бы так в asm. Когда ты в func на f1 и указатель f2 был выделен в стеке (автоматизированное хранилище). И кстати, Фу!--4--> не имеет влияния инструкции на указателе стека (esp), он был выделен, если func хочет получить , это инструкция примерно такая: lea ecx [ebp+f1], call Foo::SomeFunc(). Еще одна вещь, которую выделяет стек, может заставить кого-то думать, что память-это что-то вроде FIFO на FIFO просто случилось, когда вы входите в некоторая функция, если вы находитесь в функции и выделяете что-то вроде int i = 0, не было никакого толчка.


ранее упоминалось, что распределение стека просто перемещает указатель стека, то есть одну инструкцию на большинстве архитектур. Сравните это с тем, что вообще происходит в случае выделения кучи.

операционная система поддерживает части свободной памяти в виде связанного списка с данными полезной нагрузки, состоящими из указателя на начальный адрес свободной части и размер свободной части. Чтобы выделить X байт памяти, список ссылок пересекается и каждая нота посещается последовательно, проверяя, является ли ее размер по крайней мере X. Когда часть с размером P >= X найдена, P разбивается на две части с размерами X и P-X. связанный список обновляется и возвращается указатель на первую часть.

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


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

было бы неплохо сделать это различие: вы можете использовать "распределитель стека" в куче. Строго говоря, я возьмите распределение стека, чтобы означать фактический метод распределения, а не местоположение распределения. Если вы выделяете много вещей в фактическом стеке программы, это может быть плохо по разным причинам. С другой стороны, использование метода стека для выделения в куче, когда это возможно, является лучшим выбором для метода выделения.

поскольку вы упомянули Metrowerks и PPC, я предполагаю, что вы имеете в виду Wii. В этом случае память, и с помощью стека метод выделения, где это возможно, гарантирует, что вы не тратите память на фрагменты. Конечно, это требует гораздо большей осторожности, чем" обычные " методы распределения кучи. Разумно оценивать компромиссы для каждой ситуации.


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

теперь для примера, где стек не может быть использован:

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

если выделить некоторую память в процедуре S и поместить ее в стек, а затем выйти из него, выделенные данные будут удалены из стека. Но переменная x в P также указывала на эти данные, поэтому x теперь указывает на какое-то место под указателем стека (предположим, что стек растет вниз) с неизвестным содержимым. Содержание может быть все еще там если указатель стека просто перемещается вверх, не очищая данные под ним, но если вы начинаете выделять новые данные в стеке, указатель x может фактически указывать на эти новые данные.


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

Если вы серьезно с приложением, то VTune его или использовать любой подобный инструмент профилирования и посмотреть на горячих точках.

Кетан


Я хотел бы сказать, что на самом деле код генерируется 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    80, %rsp
      popq    %rbp
      ret 
  Leh_func_end1:

Итак, что бы ни было, сколько локальной переменной у вас есть (даже внутри if или switch), просто 3880 изменится на другое значение. Если у вас не было локальной переменной, эту инструкцию просто нужно выполнить. Поэтому выделите локальную переменную нет накладных расходов.