company_banner

Причины, по которым 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
    Intel
    177,95
    Компания
    Поделиться публикацией

    Комментарии 31

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


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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

                              Под операционкой я остался как одной из используемых, а вот их систем программирования стараюсь избегать. Если у них хватает наглости плевать на программистов, то пусть, есть иные решения.
                              0
                              Мне не понятно почему было решено выбрать именно 4 регистра и именно эти (допустим про RCX понятно)? почему нельзя было для параметров использовать регистры от R15 и вниз до нужного количества параметров, и сделать одну команду которая выгрузит на стек их все вместе (pusha )? почему параметры с плавающей запятой только 4 почему не все 8 (или просто все регистры сопроцессора)…
                              И если подумать, то окажется что fastcall подходит для функций которые не вызывают другие функции, иначе сохранение параметров ест весь профит… и как я понимаю если вызвать из одной функции несколько других то параметры будут многократно сохраняться и восстанавливаться?
                              Я за одно универсальное решение, но я против плохо обдуманных решений…
                                +1
                                >> Мне не понятно почему было решено выбрать именно 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, которая по маске сохраняет любую комбинацию регистров.
                                0
                                >> Смысл использования такого неэкономного механизма я не уловил. Что-то говорится про унификацию и упрощение отладки

                                printf("%d %d %d %d %d", 1, 2, 3, 4, 5);
                                и т.п.

                                  0
                                  Прошу развить мысль. Вновь не улавливаю. :(

                                  Вот есть:
                                  void Foo1(int a, int b, int c);
                                  
                                  void Foo2(int a, int b, int c)
                                  {
                                    Foo1(a, b, c);
                                    // a, b, c - больше не используем
                                    . . .
                                  }


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

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

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

                                    У Sparc и PPC стековый фрейм начинается от сотни байт и никого это вообще не волнует
                                    По PPC ABI резервируется место под 8 64 битных аргументов + 6 дополнительных 64битных полей для разных указателей.
                                  0
                                  Размер структуры 'S' из-за изменений правил выравнивания и изменения размера члена 'b' вырастет с 12 до 24 байт при перекомпиляции в 64-битном режиме. Структура передается в функцию по значению
                                  А как же http://msdn.microsoft.com/ru-ru/library/ms235286.aspx
                                  Любые аргументы, не равные 8 байтам или 1, 2, 4 или 8 байтам, передаются по ссылке. Попыток разместить один аргумент по нескольким регистрам не происходит.
                                  ?
                                    0
                                    Неожиданный некрокомментарий :). Я уже даже не помню, про что писал в этой статье. Прошу провести самостоятельный эксперимент с Visual Studio и потом привести результаты и теоретическое обоснование.

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

                                  Самое читаемое