Причины, по которым 64-битные программы требуют больше стековой памяти

В форумах люди часто упоминают, что 64-битные версии программ поглощают больший объем памяти и стека. При этом обычно ссылаются на то, что размеры данных стали в 2 раза больше. Однако это необоснованное утверждение, так как размер большинства типов (char, short, int, float) в языке Си/Си++ остался прежним на 64-битных системах. Конечно, например, увеличился размер указателей, но ведь не все данные в программе состоят из указателей. Причины роста потребляемой памяти и стека более сложны. Я решил подробнее исследовать данный вопрос.

В данной заметке я поговорю о стеке, а в будущем планирую обсудить выделение памяти и размер двоичного кода. И еще хочу сразу заметить, что статья посвящена языку Си/Си++ и среде разработки Microsoft Visual Studio.

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

При разработке соглашений по вызовам (calling conventions) для архитектуры x86-64 решили положить конец существованию различных вариантов вызова функций. В Win32 существовал целый ряд соглашений о вызове: stdcall, cdecl, fastcall, thiscall и так далее. В Win64 только одно "родное" соглашение по вызовам. Модификаторы подобные __cdecl компилятором игнорируются. Думаю, что все согласятся в благородстве такого резкого сокращение числа соглашений.

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

RCX: 1-й целочисленный аргумент

RDX: 2-й целочисленный аргумент

R8: 3-й целочисленный аргумент

R9: 4-й целочисленный аргумент

Остальные целочисленные аргументы передаются через стек. Указатель "this" считается целочисленным аргументом, поэтому он всегда помещается в регистр RCX. Если передаются значения с плавающей точкой, то первые четыре из них передаются в регистрах XMM0-XMM3, а последующие - через стек.

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

Хотя аргументы могут быть переданы в регистрах, компилятор все равно резервирует для них место в стеке, уменьшая значение регистра RSP (указателя стека). Как минимум, каждая функция должна резервировать в стеке 32 байта (четыре 64-битных значения, соответствующие регистрам RCX, RDX, R8, R9). Это пространство в стеке позволяет легко сохранить содержимое переданных в функцию регистров в стеке. От вызываемой функции не требуется сбрасывать в стек входные параметры, переданные через регистры, но резервирование места в стеке при необходимости позволяет это сделать. Если передается более четырех целочисленных параметров, в стеке нужно зарезервировать соответствующее дополнительное пространство.

Рассмотрим пример. Некая функция передает два целочисленных параметра дочерней функции. Компилятор положит значения аргументов в регистры RCX и RDX и при этом вычтет 32 байта из регистра RSP. Вызываемая функция может обратиться к параметрам через регистры RCX и RDX. Если же коду этой функции данные регистры понадобятся для какой-то иной цели, он сможет скопировать их содержимое в зарезервированное пространство стека размером 32 байта.

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

Обратим внимание еще на один момент. Указатель стека RSP должен перед очередным вызовом функции быть выровнен по границе 16 байт. Таким образом, суммарный размер используемого стека при вызове в 64-битном коде функции без параметров составляет: 8 (адрес возврата) + 8 (выравнивание) + 32 (резерв для аргументов) = 48 байт!

Рассмотрим, к чему это может приводить на практике. Здесь и далее для экспериментов я буду использовать Visual Studio 2010. Составим рекурсивную функцию вида:
void StackUse(size_t *depth)

{

  volatile size_t *ptr = 0;


  if (depth != NULL)

    ptr = depth;


  cout << *ptr << endl;


  (*ptr)++;


  StackUse(depth);


  (*ptr)--;

}

Функция немного запутанна, чтобы оптимизатор не превратил ее в "ничто". Основное здесь следующее: функция имеет аргумент типа указатель и одну локальную переменную, также типа указатель. Посмотрим, сколько стека потребляет функция в 32-битном и 64-битном варианте и сколько раз она может быть рекурсивно вызвана при стеке размером 1 мегабайт (размер по умолчанию).

Release 32-bit: последнее выведенное число (глубина стека) - 51331

Компилятор использует при вызове данной функции 20 байт.

Release 64-bit: последнее выведенное число - 21288

Компилятор использует при вызове данной функции 48 байт.

Таким образом, 64-битный вариант функции StackUse оказывается прожорливее 32-битного в более чем в 2 раза.

Замечу, что изменение правил выравнивания данных также может оказывать влияние на размер поглощаемого стека. Предположим, что функция принимает в качестве аргумента структуру:
struct S

{

  char a;

  size_t b;

  char c;

};


void StackUse(S s) { ... }

Размер структуры 'S' из-за изменений правил выравнивания и изменения размера члена 'b' вырастет с 12 до 24 байт при перекомпиляции в 64-битном режиме. Структура передается в функцию по значению. А, следовательно, структура в стеке также займет в два раза больше памяти.

Неужели все так плохо? Нет. Не следует забывать про большее количество регистров имеющихся в распоряжении 64-битного компилятора. Усложним код экспериментальной функции:
void StackUse(size_t *depth, char a, int b)

{

  volatile size_t *ptr = 0;


  int c = 1;

  int d = -1;


  for (int i = 0; i < b; i++)

    for (char j = 0; j < a; j++)

      for (char k = 0; k < 5; k++)

        if (*depth > 10 && k > 2)

        {

          c += j * k - i;

          d -= (i - j) * c;

        }


  if (depth != NULL)

    ptr = depth;


  cout << c << " " << d << " " << *ptr << endl;

  (*ptr)++;


  StackUse(depth, a, b);


  (*ptr)--;

}

Результаты запуска:

Release 32-bit: последнее выведенное число - 16060

Компилятор использует при вызове данной функции уже 64 байта.

Release 64-bit: последнее выведенное число - 21310

Компилятор использует при вызове данной функции по-прежнему 48 байт.
Для данного примера 64-битному компилятору удалось использовать дополнительные регистры и построить более эффективный код, что позволило сократить количество используемой стековой памяти!

Выводы



  1. Невозможно предсказать, сколько стековой памяти будет использовать 64-битный вариант программы по сравнению с 32-битным. Размер может быть как меньше (что маловероятно), так и значительно больше.

  2. Для 64-битной программы на всякий случай стоит увеличить объем зарезервированного стека в 2-3 раза. Лучше в 3 раза для спокойствия. Для этого в настройках проекта имеется параметр Stack Reserve Size (ключ /STACK:reserve). По умолчанию размер стека составляет 1 мегабайт.

  3. Не следует беспокоиться, что 64-битная программа потребляет больше стековой памяти. Физической памяти в 64-битных системах значительно больше. Стек размером 2 мегабайта на 64-битной системе с 8 гигабайтами памяти, занимает меньший процент памяти, чем 1 мегабайт стека в 32-битной системе с 2 гигабайтами памяти.


Дополнительные ссылки



  1. Raymond Chen. The history of calling conventions, part 5: amd64. http://www.viva64.com/go.php?url=325

  2. Kevin Frei. x64 ABI vs. x86 ABI (aka Calling Conventions for AMD64 & EM64T). http://www.viva64.com/go.php?url=326

  3. MSDN. x64 Software Conventions. http://www.viva64.com/go.php?url=327

  4. Wikipedia. x86 calling conventions. http://www.viva64.com/go.php?url=328

Per informazioni più dettagliate sulle ottimizzazioni basate su compilatore, vedere il nostro Avviso sull'ottimizzazione.