Что быстрее: выделение стека или выделение кучи - PullRequest
477 голосов
/ 02 октября 2008

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

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

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

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

Ответы [ 23 ]

471 голосов
/ 02 октября 2008

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

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

163 голосов
/ 02 октября 2008

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

sub esp, 0x10

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

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

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

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

115 голосов
/ 02 октября 2008

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

#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 ++ не исключил конструкторов.


За годы, прошедшие с того момента, как я это написал, в Stack Overflow предпочтение отдавалось повышению производительности за счет оптимизированных сборок. В общем, я думаю, что это правильно. Тем не менее, я все еще думаю, что глупо просить компилятор оптимизировать код, когда вы на самом деле не хотите, чтобы этот код был оптимизирован. Мне кажется, что я очень похож на то, чтобы доплачивать за парковку, но отказываюсь сдавать ключи. В данном конкретном случае я не хочу, чтобы оптимизатор работал.

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

#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
29 голосов
/ 02 марта 2009

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

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

18 голосов
/ 02 октября 2008

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

Также я согласен с Торбьёрном Гиллингингом в отношении ожидаемого времени жизни объектов. Хороший вопрос!

7 голосов
/ 02 октября 2008

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

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

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

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

6 голосов
/ 02 октября 2008

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

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

6 голосов
/ 26 октября 2009

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

4 голосов
/ 02 октября 2008

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

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

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

3 голосов
/ 10 августа 2010

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

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

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

...