войти зарегистрироваться

Intel

Intel
хабраиндекс
418,06

Причины, по которым 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-битному компилятору удалось использовать дополнительные регистры и построить более эффективный код, что позволило сократить количество используемой стековой памяти!

Выводы

  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

комментарии (29)

  • Шикарно! Всё разъяснили. Как раз перехожу на 64 бита при написании программ, очень хороший ликбез!
    • размер большинства типов (char, short, int, float) в языке Си/Си++ остался прежним на 64-битных системах


      После этой фразы становится ясно, что автор имеет в виду ровно один компилятор, в ровно одной конфигурации, а «64-битной системой» называет ровно одну архитектуру. В gcc и llvm, например, размер int будет регулироваться настройками при сборке. Про различные calling conventions (передачу через регистры vs стек) в разных ОС и на разных архитектурах — почему-то вообще умолчали…
      • Цитирую. «И еще хочу сразу заметить, что статья посвящена языку Си/Си++ и среде разработки Visual Studio.»

        Но в целом, хотелось бы, конечно, и про другие компиляторы и архитектуры услышать.
        • А еще там есть в хабракате «Различия в использовании стека Win32 и Win64 приложениями». На картинку что ли про Windows пририсовать. :)
      • Собственно говоря, автор в первом же абзаце честно пишет "… статья посвящена языку Си/Си++ и среде разработки Visual Studio 2010" так что к чему данный упрек?
  • > Хотя аргументы могут быть переданы в регистрах, компилятор все равно резервирует для них место в стеке, уменьшая значение регистра RSP (указателя стека).

    Заменяем слово «компилятор» на Visual Studio и получаем правдивое предложение.

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

    gcc использует 24. Ха-ха.
    • А сколько в аналогичном использует gcc в 32 битной системе?
      • 12, очевидно.
        • Мне казалось что 16, из-за выравнивания на границе страницы памяти, но это так, теория, на практике не проверял.
          • Страница — 4K (в x86 без large pages). 16 байт — параграф.
    • > Заменяем слово «компилятор» на Visual Studio и получаем правдивое предложение

      Согласен, я бы даже в теги его добавил — как и слово windows )
  • Маленькое уточнение — такое поведение следствие MS ABI. Но, например, на Linux используется AMD ABI, там через регистры передается до 6 параметров и нет необходимости резервировать в стеке место под те параметры, которые переданы в регистрах.
  • Ведь если параметры передаются через стек — код функции короткий и нет необходимости сохранять аргументы в памяти (стеке),


    ..«передаются через регистры», наверное?

    Спасибо, очень интересно.
  • «Ведь если параметры передаются через стек — код функции короткий и нет необходимости сохранять аргументы в памяти (стеке), а количество используемой функцией стековой памяти сокращается. „
    Наверное Вы все же имели ввиду
    “Ведь если параметры передаются через РЕГИСТРЫ — код функции короткий и нет необходимости сохранять аргументы в памяти (стеке), а количество используемой функцией стековой памяти сокращается. „
    • Спасибо. Переписал предложение.
  • Меня, конечно, заминусуют, но я таки напишу.
    В этой статье, помимо нюансов выделения памяти в стеке, мне понравился один момент — отказ в Win64 от различных типов соглашений о вызове ( stdcall, cdecl, fastcall, thiscall и так далее ) в пользу одного (модифицированого fastcall). Вот взял Microsoft и решил, что эта неразбериха с кучей стандартов мешает — и отказался от неё в пользу одного четкого решения. Пошел на риск обратной несовместимости, обязал программеров лишний раз подумать. Ради нового лучшено стандарта, ради улучшения качества кода в будущем.

    Вы можете хотя бы теоретически представить, что подобный шаг когда-либо будет сделан в Линуксе? Да ни в жизни! Найдется куча сторонников каждого типа вызовов! Каждый будет отстаивать свою точку зрения, хвалить своё болото. Все компиляторы и языки программирования будут вынуждены поддерживать все типы вызовов. Ради поддержки старого кода невозможно будет выкинуть это из ядра. Если даже какой-нибудь дистрибутив невероятной джедайской силой воли ведущего разработчика перейдет на единое соглашение — тут же возникнет несколько форков и группа разработчиков разбежится по этим форкам в равных пропорциях.

    Это я все к чему? Майкрософт, имея сравнительно немного людей, работающих над операционкой (ну пусть пару десятков тысяч) определяет вектор развития на годы вперед, не боится отказываться от старого, задавать стандарты и говорить: «если Вы это не поддерживаете — Вам нет места под нашей операционкой». Линукс так не может. Под его крышей есть место всем. Это конечно хорошо, с одной стороны, но с другой — багаж обратной совместимости давит все сильней, приходится тянуть за собой кучу старого кода, поддерживать массу дистрибутивув, версий ядра и отдельных программ и т.д. И в итоге, сообщество Линукс, имея миллионы пользователей (львиная доля которых — программисты и администраторы) не может похвастаться столь решительными шагами в какую-либо сторону прогресса.

    Да, поддержка всяких новых фич в Линуксе появляется раньше Винды, делаются успешные демки и в фич-листе гордо ставиться галочка «поддерживается». Но стандартом это не становиться, каждый придумывает свой велосипед, пользуется фичей 2% от всех линукс-пользователей и она либо умирает в муках, либо как-то развивается, но значительно медленее, чем в 5 раз худшая изначально фича от Майкрософта, вошедшая в Винду и используемая сотнями миллионов людей ежедневно.
    • «Вы можете хотя бы теоретически представить, что подобный шаг когда-либо будет сделан в Линуксе? Да ни в жизни!»
      Это бездоказательный наезд!
      • очень даже основательный ибо в линуксе ничего не может быть запрещено всем сообществом, поэтому запрет использования других соглашений не состоится
        • помоему ядром все еще плотно занимается товарищ Линус, думаю его послушаются многие, так что — безосновательно заявляете
    • Вроде в amd64 в linux тоже один тип вызова, не? Наоборот для amd64 в Microsoft выпендрились и не стали использовать универсальное соглашение о вызовах, а изобрели свой велосипед.
    • «А нельзя ли выкинуть труп петуха, традиционно замурованного в левую пассажирскую дверцу»? Убогие, тупые твари, чтоб им в аду сгореть.… Петух, етить, — это вообще история. Он нужен для совместимости с версией одна тысяча двадцать седьмого года. Не могут историю, блин, изучить, а рассуждать лезут. Ламерьё.
      © баян о покупке автомобиля
    • Насчёт обратной совместимости есть одна штука, которой я не могу никак понять.
      В своё время в ранних процах x86 архитектур были введены «продвинутые» команды типа movsb, pusha и т.п., которые представляли собой просто аппаратное совмещение команд «поменьше-попроще». Служило всё это для ускорения выполнения прог.

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

      Вот, бл*, почему он не "… Пошел на риск обратной несовместимости, обязал программеров лишний раз подумать. Ради нового лучшено стандарта, ради улучшения качества кода в будущем"???

      P.S. Нынче в той конторе модно херачить каждый раз новую цоколёвку проца при изменении «чего-то там у него внутри». А давеча они даже изменили количество ног у процов соседних линеек на одну!
      • Вы неправы. Они пошли на это! И создали Itanium с архитектурой IA-64. Выбросили все устаревшее и вообще сделали хорошо. И именно этой архитектуре пророчили будущее и именно по этому не хотели затем признавать x86-64 за полноценного приемника x86.

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

      Под операционкой я остался как одной из используемых, а вот их систем программирования стараюсь избегать. Если у них хватает наглости плевать на программистов, то пусть, есть иные решения.
  • Мне не понятно почему было решено выбрать именно 4 регистра и именно эти (допустим про RCX понятно)? почему нельзя было для параметров использовать регистры от R15 и вниз до нужного количества параметров, и сделать одну команду которая выгрузит на стек их все вместе (pusha )? почему параметры с плавающей запятой только 4 почему не все 8 (или просто все регистры сопроцессора)…
    И если подумать, то окажется что fastcall подходит для функций которые не вызывают другие функции, иначе сохранение параметров ест весь профит… и как я понимаю если вызвать из одной функции несколько других то параметры будут многократно сохраняться и восстанавливаться?
    Я за одно универсальное решение, но я против плохо обдуманных решений…
    • >> Мне не понятно почему было решено выбрать именно 4 регистра
      Эвристика, вероятно подкрепленная некоей статистикой.

      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 - больше не используем
        . . .
      }


      В данном случае в стек переменные не сохраняем, но место резервируем. Зачем? Что это дает?
      • В упомянутом мною случае будет цикл по памяти от &arg[1] до &arg[5]
        поэтому аргументы они должны лежать в памяти.

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

        В релизном режиме для одних функций это нужно, для других не нужно.
        Естественно желание унифицировать всё это безобразие.
        Не вижу ничего страшного в 40 байтовом стеке.

        У Sparc и PPC стековый фрейм начинается от сотни байт и никого это вообще не волнует
        По PPC ABI резервируется место под 8 64 битных аргументов + 6 дополнительных 64битных полей для разных указателей.
Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.