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

В форумах люди часто упоминают, что 64-битные версии программ поглощают больший объем памяти и стека. При этом обычно ссылаются на то, что размеры данных стали в 2 раза больше. Однако это необоснованное утверждение, так как размер большинства типов (char, short, int, float) в языке Си/Си++ остался прежним на 64-битных системах. Конечно, например, увеличился размер указателей, но ведь не все данные в программе состоят из указателей. Причины роста потребляемой памяти и стека более сложны. Я решил подробнее исследовать данный вопрос.
В данной заметке я поговорю о стеке, а в будущем планирую обсудить выделение памяти и размер двоичного кода. И еще хочу сразу заметить, что статья посвящена языку Си/Си++ и среде разработки 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-битному компилятору удалось использовать дополнительные регистры и построить более эффективный код, что позволило сократить количество используемой стековой памяти!
Выводы
- Невозможно предсказать, сколько стековой памяти будет использовать 64-битный вариант программы по сравнению с 32-битным. Размер может быть как меньше (что маловероятно), так и значительно больше.
- Для 64-битной программы на всякий случай стоит увеличить объем зарезервированного стека в 2-3 раза. Лучше в 3 раза для спокойствия. Для этого в настройках проекта имеется параметр Stack Reserve Size (ключ /STACK:reserve). По умолчанию размер стека составляет 1 мегабайт.
- Не следует беспокоиться, что 64-битная программа потребляет больше стековой памяти. Физической памяти в 64-битных системах значительно больше. Стек размером 2 мегабайта на 64-битной системе с 8 гигабайтами памяти, занимает меньший процент памяти, чем 1 мегабайт стека в 32-битной системе с 2 гигабайтами памяти.
Дополнительные ссылки
- Raymond Chen. The history of calling conventions, part 5: amd64. http://www.viva64.com/go.php?url=325
- Kevin Frei. x64 ABI vs. x86 ABI (aka Calling Conventions for AMD64 & EM64T). http://www.viva64.com/go.php?url=326
- MSDN. x64 Software Conventions. http://www.viva64.com/go.php?url=327
- Wikipedia. x86 calling conventions. http://www.viva64.com/go.php?url=328
комментарии (29)
После этой фразы становится ясно, что автор имеет в виду ровно один компилятор, в ровно одной конфигурации, а «64-битной системой» называет ровно одну архитектуру. В gcc и llvm, например, размер int будет регулироваться настройками при сборке. Про различные calling conventions (передачу через регистры vs стек) в разных ОС и на разных архитектурах — почему-то вообще умолчали…
Но в целом, хотелось бы, конечно, и про другие компиляторы и архитектуры услышать.
Заменяем слово «компилятор» на Visual Studio и получаем правдивое предложение.
> Компилятор использует при вызове данной функции 48 байт.
gcc использует 24. Ха-ха.
Согласен, я бы даже в теги его добавил — как и слово windows )
..«передаются через регистры», наверное?
Спасибо, очень интересно.
Наверное Вы все же имели ввиду
“Ведь если параметры передаются через РЕГИСТРЫ — код функции короткий и нет необходимости сохранять аргументы в памяти (стеке), а количество используемой функцией стековой памяти сокращается. „
В этой статье, помимо нюансов выделения памяти в стеке, мне понравился один момент — отказ в Win64 от различных типов соглашений о вызове ( stdcall, cdecl, fastcall, thiscall и так далее ) в пользу одного (модифицированого fastcall). Вот взял Microsoft и решил, что эта неразбериха с кучей стандартов мешает — и отказался от неё в пользу одного четкого решения. Пошел на риск обратной несовместимости, обязал программеров лишний раз подумать. Ради нового лучшено стандарта, ради улучшения качества кода в будущем.
Вы можете хотя бы теоретически представить, что подобный шаг когда-либо будет сделан в Линуксе? Да ни в жизни! Найдется куча сторонников каждого типа вызовов! Каждый будет отстаивать свою точку зрения, хвалить своё болото. Все компиляторы и языки программирования будут вынуждены поддерживать все типы вызовов. Ради поддержки старого кода невозможно будет выкинуть это из ядра. Если даже какой-нибудь дистрибутив невероятной джедайской силой воли ведущего разработчика перейдет на единое соглашение — тут же возникнет несколько форков и группа разработчиков разбежится по этим форкам в равных пропорциях.
Это я все к чему? Майкрософт, имея сравнительно немного людей, работающих над операционкой (ну пусть пару десятков тысяч) определяет вектор развития на годы вперед, не боится отказываться от старого, задавать стандарты и говорить: «если Вы это не поддерживаете — Вам нет места под нашей операционкой». Линукс так не может. Под его крышей есть место всем. Это конечно хорошо, с одной стороны, но с другой — багаж обратной совместимости давит все сильней, приходится тянуть за собой кучу старого кода, поддерживать массу дистрибутивув, версий ядра и отдельных программ и т.д. И в итоге, сообщество Линукс, имея миллионы пользователей (львиная доля которых — программисты и администраторы) не может похвастаться столь решительными шагами в какую-либо сторону прогресса.
Да, поддержка всяких новых фич в Линуксе появляется раньше Винды, делаются успешные демки и в фич-листе гордо ставиться галочка «поддерживается». Но стандартом это не становиться, каждый придумывает свой велосипед, пользуется фичей 2% от всех линукс-пользователей и она либо умирает в муках, либо как-то развивается, но значительно медленее, чем в 5 раз худшая изначально фича от Майкрософта, вошедшая в Винду и используемая сотнями миллионов людей ежедневно.
Это бездоказательный наезд!
В своё время в ранних процах x86 архитектур были введены «продвинутые» команды типа movsb, pusha и т.п., которые представляли собой просто аппаратное совмещение команд «поменьше-попроще». Служило всё это для ускорения выполнения прог.
Однако с приходом вытесняющей многозадачности и суперскалярной архитектуры такие команды вообще-то могут сделать прямо противоположное, т.е. замедлить всю систему. Но вот сами-знаете-какой-производитель почему-то до сих пор поддерживает все эти ненужные (реально неиспользуемые) команды даже в своих новейших процах. А это и усложняет их архитектуру, и негативно сказывается на стоимости.
Вот, бл*, почему он не "… Пошел на риск обратной несовместимости, обязал программеров лишний раз подумать. Ради нового лучшено стандарта, ради улучшения качества кода в будущем"???
P.S. Нынче в той конторе модно херачить каждый раз новую цоколёвку проца при изменении «чего-то там у него внутри». А давеча они даже изменили количество ног у процов соседних линеек на одну!
Вот жаль только что не получилось. Совместимость для разработчиков и пользователей стала уже гораздо дороже всего остального.
Под операционкой я остался как одной из используемых, а вот их систем программирования стараюсь избегать. Если у них хватает наглости плевать на программистов, то пусть, есть иные решения.
И если подумать, то окажется что fastcall подходит для функций которые не вызывают другие функции, иначе сохранение параметров ест весь профит… и как я понимаю если вызвать из одной функции несколько других то параметры будут многократно сохраняться и восстанавливаться?
Я за одно универсальное решение, но я против плохо обдуманных решений…
Эвристика, вероятно подкрепленная некоей статистикой.
x86 — 2 регистра из 8 (fastcall)
68k (gcc) — 4 из 16 (на Amiga более гибко)
ARM — 4 из 16
x86-64 — 4 из 16
Power/PPC — 8 из 32
Sparc — 8 из 32
наблюдается очевидная закономерность, из которой выбиваются только
MIPS — 4 из 32
CELL SPU — 72 из 128
>> почему нельзя было для параметров использовать регистры от R15 и вниз до нужного количества параметров
Команды с r8..r15 длинее на 1 байт
>> сделать одну команду которая выгрузит на стек их все вместе
Это очень олдскульно.
Сделать одну команду можно, как сделано в 68k или ARM, только геморно.
push-ы на OoO x86 ренеймятся и выполняются независимо друг от друга.
На том же 68040 отдельные операции move были быстрее 1 жирной movem, которая по маске сохраняет любую комбинацию регистров.
printf("%d %d %d %d %d", 1, 2, 3, 4, 5);
и т.п.
Вот есть:
void Foo1(int a, int b, int c); void Foo2(int a, int b, int c) { Foo1(a, b, c); // a, b, c - больше не используем . . . }В данном случае в стек переменные не сохраняем, но место резервируем. Зачем? Что это дает?
поэтому аргументы они должны лежать в памяти.
В отладочном режиме агрументы также будут лежать в памяти чтобы их смотреть
в отладчике.
В релизном режиме для одних функций это нужно, для других не нужно.
Естественно желание унифицировать всё это безобразие.
Не вижу ничего страшного в 40 байтовом стеке.
У Sparc и PPC стековый фрейм начинается от сотни байт и никого это вообще не волнует
По PPC ABI резервируется место под 8 64 битных аргументов + 6 дополнительных 64битных полей для разных указателей.