Я только что закончил серию изменений в коде браузера Chrome, которая уменьшила размер его бинарника под Windows примерно на 1 мегабайт, перенесла около 500 КB из read/write сегмента в read-only, а также уменьшила потребление оперативной памяти в общем примерно на 200 KB на каждый процесс Chrome. Удивительное заключается в том, что конкретно данная серия изменений состояла исключительно из удаления и добавления ключевого слова const в некоторых местах кода. Да, компиляторы — странные.

Эта задача возникла, когда я писал документацию для некоторых утилит, которые я использую для исследования регрессий кода, связанных с увеличением размера скомпилированных бинарников под Windows. Я запустил утилиту, скопировал в документацию её вывод и начал его описывать, когда заметил нечто странное: несколько больших глобальных объектов, которые согласно архитектуре должны были быть константными, почему-то находились в сегменте read/write данных. Сокращённая версия того вывода утилиты показана ниже:

image

Большинство исполняемых форматов имеют как минимум два сегмента данных — один для read/write объектов и ещё один для read-only. Если у вас есть константные данные, такие, например, как kBrotliDictionary, то их будет логично поместить в read-only сегмент, который является сегментом «2» в бинарнике Chrome под Windows. Однако некоторые константные данные, такие как unigram_table, device::UsbIds::vendors_ и blink::serializedCharacterData были в секции «3», то есть в read/write сегменте.

image

Расположение данных в read-only сегменте даёт несколько преимуществ. Это защищает данные от случайного повреждения, а также позволяет использовать их более эффективно. read-only страницы гарантировано будут использоваться совместно всеми процессами, которые загружают данную DLL (а в случае Chrome мы всегда имеем несколько процессов). Кроме того, в некоторых случаях (хотя, наверное, не в этих) компилятор может использовать константы непосредственно в коде.

Страницы в read/write сегменте могут также использоваться совместно, но это не гарантируется. Они все по-умолчанию созданы с флагом "сделать копию при необходимости изменений", что означает их общее использование лишь до первой операции записи, которая приведёт к копировании страницы в личную память процесса. Таким образом, если глобальная переменная будет инициализирована на рантайме — это автоматически сделает её недоступной для общего использования всеми процессами. Кроме того, даже если глобальная переменная всего лишь находится на той же странице памяти, что и другая копируемая при записи — это тоже делает её недоступной для общего использования — всё, знаете ли, происходит с гранулярностью размера страницы (4 KiB).

Приватные данные используют больше памяти, поскольку требуют их отдельной копии в каждом процессе. Кроме того, они более дороги и потому, что требуют места для свопа (чего не нужно для константных данных, ведь их можно при необходимости прочесть из образа бинарника процесса). Это приводит к дорогим операциям записи\чтения на HDD, которые, к тому же, в общем случае будут по рандомным адресам (ещё медленнее).

По всем этим причинам read-only страницы значительно более предпочтительны во всех случаях, когда это только возможно.

Добавить const — это хорошо


Таким образом, когда моя утилита ShowGlobals показала, что blink::serializedCharacterData был в сегменте read/write данных, а дальнейшее исследование подтвердило, что данный массив никогда не меняется, я добавил к его объявлению модификатор const, что логичным образом перенесло его в сегмент read-only данных. Очень просто. Подобные изменения всегда хорошая идея, но не всегда легко понять, насколько именно. Поскольку мы никогда не меняем данный массив, он, вполне возможно, будет создан в памяти лишь в одном экземпляре и использован всеми процессами Chrome. Но более вероятно, что его конец попадёт на одну страницу с другим объектом, который, возможно, будет меняться и таким образом приведёт к созданию копии страницы памяти (совместно с копией хвоста нашего массива). Таким образом мы потеряем 7748 или 3652 байт (размер массива минус одна или две страницы памяти в середине, которые гарантированно будут общими). Подобные изменения помогут (ну или по крайней мере не помешают) на всех платформах, со всеми компиляторами.

Явное объявление вашего константного массива с модификатором const — это хорошая идея, вам следует делать это. Но одной лишь рассказанной выше информации не будет достаточно для понимания всей картины. И здесь мы вступаем на неизведанную территорию…

Иногда убрать const — ещё лучше


Следующий массив, который я исследовал, был unigram_table. Это был странный случай, поскольку он инициализировался исключительно константными данными с помощью синтаксиса инициализации структур/массивов и был помечен модификатором const — но всё же почему-то находился в сегменте read/write данных. Это со всех сторон выглядело какой-то причудой компилятора VC++, так что я воспользовался своей же инструкцией по минимизации необходимого для воспроизведения бага кода и отправил багрепорт в Microsoft. Я скопировал типы и объявление массива в отдельный проект и продолжал уменьшать его, на каждом шагу проверяя расположение массива в read/write сегменте данных. В конце концов я дошел до минималистичного кода, который поместился бы в твит:

const struct T {const int b[999]; } a[] = {{{}}}; int main() {return(size_t)a;}

Если вы скомпилируете этот код и запустите ShowGlobals на полученном PDB, утилита покажет, что «а» находится в секции «3», несмотря на объявление с модификатором const. Вот конкретные шаги по сборке и тестированию кода:

> “%VS140COMNTOOLS%..\..\VC\vcvarsall.bat”
> cl /Zi constbug.cpp
/out:constbug.exe
> ShowGlobals.exe constbug.pdb
        Size   Section Symbol name
        3996   3       a

После уменьшения моего примера до менее 140 символов стало очень просто найти причину. С компиляторами VC++ (2010, 2015, 2017 RC) получается так, что если у вас есть класс/структура с константным членом данных, то любой глобальный объект данного типа попадёт в read/write сегмент данных. Jonathan Caves объяснил в своём комментарии к моему багрепорту, что это происходит потому, что тип получает сгенерированный компилятором удалённый конструктор по умолчанию (имеет смысл), что сбивает с толку компилятор VC++, который ошибочно определяет данный класс, как требующий динамической инициализации.

Таким образом, проблема в данном случае в модификаторе const, стоящем возле члена данных «b». Как только я удалил этот const — весь массив попал в read-only память (весьма иронично, правда?). Поскольку весь объект так или иначе является константным, удаление модификатора const у одного из его членов данных нисколько не уменьшает безопасность, а для компилятора VC++ по факту увеличивает её.

Я рассчитываю, что команда разработчиков VC++ исправит данный баг к выходу VS 2017 — в этом случае код можно было бы и не исправлять — но я не хочу ждать так долго. И я начал убирать модификаторы const в тех местах, где это вызывало подобные проблемы. Процесс был достаточно тривиальным — я просто продолжал просматривать список глобальных переменных в read/write сегменте данных и относить их к одной из следующих категорий:

  • Те, значения которых меняется — оставляем, как есть
  • Не меняются и не имеют модификатора const — добавляем его
  • Не меняются и имеют проблемный член данных с модификатором const — убираем его

Это было правда забавно


Так я шел по коду Chrome, добавляя и убирая const в подходящих местах. В большинстве случаев мои изменения, как и планировалось, приводили к перемещению данных из read/write сегмента в read-only сегмент. Но в двух случаях эти изменения сделали также кое-что ещё — уменьшили размер секций .text и .reloc. Это было просто отлично, даже слишком хорошо для того, чтобы быть правдой. Я предполагаю, что VC++ генерировал код для инициализации некоторых из этих массивов — и достаточно много кода.

Самым интересным изменением было удаление трёх const из определения структуры UnigramEntry. Это перенесло в read-only сегмент 53064 байт, а также уменьшило размер chrome.dll и chrome_child.dll на 364500 байт. Из этого следует, что компилятор VC++ молчаливо создавал код инициализации, который занимал по 7 байт на инициализацию каждого байта unigram_table. Такого попросту не могло быть. Это было слишком далеко за рамками моих ожиданий, так что я запустил Chrome под отладчиком Visual Studio и установил брейкпоинт на изменение данных в в конце массива unigram_table. Visual Studio предсказуемо остановила выполнение программы в инициализаторе. Ниже я приведу (немного вычищенный) ассемблерный код инициализатора (я заменил «unigram_table» на «u» для повышения читабельности):

55                    push ebp  
8B EC                 mov  ebp,esp  
83 25 78 91 43 12 00  and  dword [u],0  
83 25 7C 91 43 12 00  and  dword [u+4],0  
83 25 80 91 43 12 00  and  dword [u+8],0  
83 25 84 91 43 12 00  and  dword [u+0Ch],0  
C6 05 88 91 43 12 4D  mov  byte  [u+10h],4Dh  
C6 05 89 91 43 12 CF  mov  byte  [u+11h],0CFh  
C6 05 8A 91 43 12 1D  mov  byte  [u+12h],1Dh  
C6 05 8B 91 43 12 1B  mov  byte  [u+13h],1Bh  
C7 05 8C 91 43 12 FF 00 00 00 mov  dword [u+14h],0FFh  
C6 05 90 91 43 12 00  mov  byte  [u+18h],0  
C6 05 91 91 43 12 00  mov  byte  [u+19h],0  
C6 05 92 91 43 12 00  mov  byte  [u+1Ah],0  
C6 05 93 91 43 12 00  mov  byte  [u+1Bh],0  
… 52,040 lines deleted…
c6 05 02 6e 0b 12 6c  mov  byte  [u+cf42h],6Ch
c6 05 03 6e 0b 12 6e  mov  byte  [u+cf43h],6Eh
c6 05 04 6e 0b 12 a2  mov  byte  [u+cf44h],0A2h
c6 05 05 6e 0b 12 c2  mov  byte  [u+cf45h],0C2h
c6 05 06 6e 0b 12 80  mov  byte  [u+cf46h],80h
c6 05 07 6e 0b 12 c4  mov  byte  [u+cf47h],0C4h
5d                    pop  ebp
c3                    ret

Числа в 16-ричной системе счисления слева — это машинные коды команд, а текст справа — это их ассемблерное представление. После некоторого пролога мы видим код, заполняющий массив… по одному байту… используя 7 инструкций. Ну, это всё объясняет.

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

  • Она могла бы вообще не существовать. Массив инициализируется простым синтаксисом инициализации массивов в С и, если бы не вышеописанный баг в компиляторе VC++, код инициализатора вообще не нужно было бы генерировать (как это и происходит на других платформах).
  • Запись нулей можно было бы пропустить. Данный массив — это глобальная переменная, которая инициализируется лишь раз при запуске программы, а в этот момент вся память гарантированно заполнена нулями, так что записывать нули поверх нулей — бессмысленная работа.
  • Данные можно было бы записывать по 4 байта за раз, а не по одному
  • Адресс массива можно было бы загрузить в регистр и использовать его оттуда, вместо того, чтобы указывать его в каждой инструкции. Это сделало бы инструкции меньше, а также сохранило бы 2 байта на инструкцию релокации данных, найденных в .reloc сегменте.

Ну, в общем, вы поняли суть. Эта функция могла бы быть раза в 4 меньше, а также полностью отсутствовать. Она и пропала после убирания трёх модификаторов const (изменения уже доступны в Chrome Canary), а вместе с ней пропали лишние ~364500 байт кода и ~105000 байт в секции .reloc, и это произошло как в chrome.dll, так и в chrome_child.dll. Массив раньше был в .BSS (инициализируемая нулями часть read/write сегмента), где он не занимал никакого места на диске, а переместился в read-only сегмент, где стал занимать 53064 байт, поэтому общая экономия места на диске составила 416000 байт на каждую DLL.

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

Примеры изменений


Я начал с самых больших и часто используемых объектов и типов для того, чтобы получить хороший и сразу видимый результат. Я быстро уменьшил размер read/write сегмента примерно на 250 KB, переместив около 1500 глобальных переменных в read-only сегмент. Это дело, знаете ли, затягивает (что? у кого тут обсессивно-компульсивное расстройство? у меня? понятия не имею, о чём вы). Но мне удалось остановиться на каком-то этапе, хотя я точно знаю, что в коде всё ещё остались сотни более мелких глобальных переменных, которые можно было бы исправить аналогичным образом. В какой-то момент мне показалось, что затрачиваемые мною усилия больше не стоят достигаемого выигрыша в несколько байт памяти и пора двигаться куда-то дальше. Но, если вы всегда мечтали что-нибудь закоммитить в код Chrome, не стесняйтесь пойти вышеуказанным путём. Ради примера вы можете посмотреть на несколько проделанных мною изменений:

Изменения, удаляющие const:


Изменения, добавляющие const:


Попробуйте сами


Если вы хотите подебажить Chrome и посмотреть на код инициализатора unigram_table перед тем, как он пропадёт при следующем релизе Chrome — вам не нужно быть крутым разработчиком Chrome. Начните с выполнения вот этих двух команд:

> “%VS140COMNTOOLS%..\..\VC\vcvarsall.bat”
> devenv /debugexe chrome.exe

Убедитесь, что вы добавили в настройки отладчика путь к символьному серверу Chrome (вот по этой инструкции) и установили брейкпоинт вот на этот символ:

`dynamic initializer for 'unigram_table''

Убедитесь, что у вас нет запущенного в данный момент Chrome и запустите его из-под Visual Studio. Visual Studio загрузит символы Chrome (магия символьных серверов!) и установит брейкпоинт на инициализатор (если он всё-ещё существует). Ничего сложного. Вы можете переключиться в режим ассемблерного кода (Ctrl+F11). Если вы хотите видеть исходный код — просто включите использование сервера исходных кодов в настройках отладчика Visual Studio.
Поделиться с друзьями
-->

Комментарии (24)


  1. Antervis
    21.02.2017 13:33

    а еще можно помечать данные constexpr и есть шанс, что они вообще не будут созданы в памяти


    1. tangro
      21.02.2017 13:39
      +1

      Иногда можно, а иногда и нельзя. Вот что автор оригинальной статьи пишет по этому поводу:

      I love constexpr and it bothers me that the C++ committee designed in such a way that I often cannot use it. Look at the change in https://codereview.chromium.org/2608823002 for example – in that example a const array is defined in one translation unit and use in another. constexpr does not support that. You cannot go “extern constexpr”.

      The reason why is because constexpr means “available at compile time” even though I often wish that it meant “generated at compile time”. There is no way in C++ to require that something be generated at compile time without requiring that it be available at compile time, so enforcing my wishes for these arrays is, in general, impossible.

      That said, there are some places where I could have used constexpr. I think I tried that and hit internal compiler errors


      1. Antervis
        21.02.2017 20:53
        -1

        На мой взгляд, пример по ссылке как минимум странный. Зачем линковаться к константному массиву данных и дергать из него значения в рантайме, если можно вынести массив в заголовочный файл как constexpr, подключить его и тогда все операции с этим массивом перейдут в compile time, а в бинарник он вообще не попадет?


        1. mayorovp
          21.02.2017 21:05
          +3

          Не могут все операции с массивом перейти в compile time, за исключением тех случаев когда массив — промежуточный либо не нужен.


          Возьмем простейший код:


          constexpr int a[1000] = ...;
          int k;
          std::cin >> k;
          std::cout << a[k];

          Ну и как в такой ситуации компилятору a[k] вычислять-то?


    1. mayorovp
      21.02.2017 13:48

      … или будут, но в том месте, где потребуются. заполняясь побайтово? :-)


  1. DjOnline
    21.02.2017 15:10

    Вот бы все разработчики были такими дотошными и смотрели что делает их компилятор…


    1. monah_tuk
      26.02.2017 01:33
      +1

      Многие это делают, когда наступает момент: "пора!" У меня всего 300кБ на код, так что rabin2 + сортировка символов по размеру и начинаем с самых больших, задавая им вопрос: а что у тебя можно улучшить?


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


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


  1. Stawros
    21.02.2017 17:02
    +1

    Я рассчитываю, что команда разработчиков VC++ исправит данный баг к выходу VS 2017 — в этом случае код можно было бы и не исправлять — но я не хочу ждать так долго. И я начал убирать модификаторы const в тех местах, где это вызывало подобные проблемы.

    Звучит как огораживание костылями под отдельно взятый компилятор. Где-то читал, что для сборки хрома Google использует Clang — в нём поведение аналогично описанному в статье? Ну и после исправления бага в VS снова нужно будет проставлять const в соответствующих местах — имхо какой-то не системный подход.


    1. tangro
      21.02.2017 17:03

      Звучит, да. Для сборки Хрома используются разные компиляторы под разные платформы. Нет, после исправления бага в VS не нужно будет снова править код. Автор убирал лишь модификатор const у и так полностью константных объектов. На другие компиляторы (и пофигшенный VS) это не влияет.


  1. F0iL
    21.02.2017 17:02

    Интересно было бы узнать, как повлияли эти изменения на результаты компиляции c использованием GCC или Clang.


    1. tangro
      21.02.2017 17:03

      Никак, в статье есть об этом — «Поскольку весь объект так или иначе является константным, удаление модификатора const у одного из его членов данных нисколько не уменьшает безопасность, а для компилятора VC++ по факту увеличивает её.».


  1. Deosis
    22.02.2017 08:54
    -2

    Уменьшение потребления памяти на 200 КБ выглядит как выбрасывание кофеварки из самолета для уменьшения его веса.


    1. mayorovp
      22.02.2017 09:04

      Только разница в том, что кофеварка в самолете была нужна, а эти 200 КБ были лишними.


    1. Pakos
      22.02.2017 10:31
      +2

      Не из самолёта, а из каждого ящика, перевозимого самолётом. Для Боинга с 5ю ящиками — незаметно, а из Цесны с сотней ящиков — очень даже.


      1. vbif
        22.02.2017 12:18

        Хром — тот ещё боинг, но и кофеварок в нём на целого пассажира наберётся.


  1. laughman
    22.02.2017 14:47

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


    1. tangro
      22.02.2017 14:49
      -1

      В программе должны быть и логика, и оптимизация (ну по крайней мере в программе уровня Хрома, которой пользуются миллионы людей). Компилятор поправят, переделки останутся — и не принесут никакого вреда, поскольку эти объекты и так константны.


    1. mayorovp
      22.02.2017 14:52
      -1

      А где нарушилась логика программы?


    1. Danik-ik
      23.02.2017 22:38
      +2

      Ну, так если прочитать и понять(!) статью, то можно увидеть следующее: автор, использовав специальные средства, провёл работу над ошибками в плане проверки соответствия результата компиляции (даже так, уже круто!) логике программы. Нашёл кучу несоответствий именно логике программы и устранил. И — ура!!! Уменьшился размер, уменьшилось потребление памяти. И если второе "ура" было целью, первое оказалось приятным сюрпризом. Я не увидел в статье ни одного изменения, нарушающего логику. Ряд сущностей был переведён из переменных в константы (именно потому, что по логике это должно быть так), при этом ни одна константа не перестала быть константой, в том числе там, где const удалили (так как удалялись ИЗБЫТОЧНЫЕ const). И что конкретно Вам не понравилось, что автор сделал не так по-Вашему?
      А переделки остануться именно в силу того, что они сделали объявления более соответствующими логике программы.


      1. PsyHaSTe
        27.02.2017 15:40

        Избыточной константности не бывает. Это как константная пропертя, которая является тоже константной структурой. Кто-то может подумать "а зачем делать структуру константной, она же и так в константном поле" и уберет. Через пару месяцев нужно будет убрать константность свойства, и её уберут. А еще через пару месяцев кто-то запишет значение в массив, который предполагался быть readonly.


        В случае добавления const — да, это исправление логики программы и синхронизация с ней исходных кодов. А вот убирание const — совершенно обратная операция, призванная выиграть 300кб памяти. Я вот даже не представляю, как константность может быть избыточной. Константность поля никак не зависит от константности типа поля, например. Это разные уровни просто — перезапись свойства, перезапись свойства свойства, перезапись свойства свойства свойства,… И каждая требует (или нет) const на своем уровне.


  1. alexeiz
    22.02.2017 19:09

    Баг в компиляторе или не баг, но константные поля в структурах вообще имеют мало смысла, потому что у структур обычно нет конструкторов, которые бы эти поля инициализировали. В коде хроме с этими const в структурах явно переборщили.


    1. Antervis
      23.02.2017 15:24
      +1

      const int _someVal {15};


  1. Viacheslav01
    01.03.2017 02:39

    Запись нулей можно было бы пропустить. Данный массив — это глобальная переменная, которая инициализируется лишь раз при запуске программы, а в этот момент вся память гарантированно заполнена нулями, так что записывать нули поверх нулей — бессмысленная работа.


    А почему память гарантированно заполненна нулями? Последний раз (было давно) в памяти был мусор оставшийся в наследство от бывшего владельца.


    1. mayorovp
      01.03.2017 09:06

      Если бы запущенный процесс в своих глобальных массивах мог бы читать чужой мусор — это было бы нарушением изоляции процессов и уязвимостью. Поэтому ОС при выделении памяти всегда ее очищает.