Эта задача возникла, когда я писал документацию для некоторых утилит, которые я использую для исследования регрессий кода, связанных с увеличением размера скомпилированных бинарников под Windows. Я запустил утилиту, скопировал в документацию её вывод и начал его описывать, когда заметил нечто странное: несколько больших глобальных объектов, которые согласно архитектуре должны были быть константными, почему-то находились в сегменте read/write данных. Сокращённая версия того вывода утилиты показана ниже:
Большинство исполняемых форматов имеют как минимум два сегмента данных — один для read/write объектов и ещё один для read-only. Если у вас есть константные данные, такие, например, как kBrotliDictionary, то их будет логично поместить в read-only сегмент, который является сегментом «2» в бинарнике Chrome под Windows. Однако некоторые константные данные, такие как unigram_table, device::UsbIds::vendors_ и blink::serializedCharacterData были в секции «3», то есть в read/write сегменте.
Расположение данных в 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 для перемещения 53064 байт в read-only сегмент и сохранения 729168 байт кода (инициализатор записывал данный по одному байту!)
- Три удаления const для перемещения 166 KB в read-only сегмент и сохранения 224 KB кода (1500 отдельных глобальных переменных!)
- Пять удалений const для перемещения 12500 байт в read-only сегмент
- Удаление const для перемещения 6800 байт в read-only сегмент
- Четыре удаления const для перемещения 2500 байт в read-only сегмент
- Шесть удалений const для перемещения 960 байт в read-only сегмент
- Пять удалений const для перемещения 250 байт в read-only сегмент
Изменения, добавляющие const:
- Добавление const для перемещения 12864 байт в read-only сегмент данных
- Добавление трёх const для перемещения 11844 байт в read-only сегмент данных
- Добавление двух const для перемещения 3000 байт в read-only сегмент данных
- Добавление const для перемещения 396 байт в read-only сегмент данных
Попробуйте сами
Если вы хотите подебажить 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)
DjOnline
21.02.2017 15:10Вот бы все разработчики были такими дотошными и смотрели что делает их компилятор…
monah_tuk
26.02.2017 01:33+1Многие это делают, когда наступает момент: "пора!" У меня всего 300кБ на код, так что rabin2 + сортировка символов по размеру и начинаем с самых больших, задавая им вопрос: а что у тебя можно улучшить?
Но мне приходится жертвовать, в основном, скоростью в угоду размера, что, в общем, не суть.
Но заниматься этим на каждом этапе разработки, это непростительная трата времени. Как показывает личный опыт, сумма времени потраченного на оптимизации "по ходу пьесы", обычно больше, чем монолитный кусок, "когда действительно нужно".
Stawros
21.02.2017 17:02+1Я рассчитываю, что команда разработчиков VC++ исправит данный баг к выходу VS 2017 — в этом случае код можно было бы и не исправлять — но я не хочу ждать так долго. И я начал убирать модификаторы const в тех местах, где это вызывало подобные проблемы.
Звучит как огораживание костылями под отдельно взятый компилятор. Где-то читал, что для сборки хрома Google использует Clang — в нём поведение аналогично описанному в статье? Ну и после исправления бага в VS снова нужно будет проставлять const в соответствующих местах — имхо какой-то не системный подход.tangro
21.02.2017 17:03Звучит, да. Для сборки Хрома используются разные компиляторы под разные платформы. Нет, после исправления бага в VS не нужно будет снова править код. Автор убирал лишь модификатор const у и так полностью константных объектов. На другие компиляторы (и пофигшенный VS) это не влияет.
F0iL
21.02.2017 17:02Интересно было бы узнать, как повлияли эти изменения на результаты компиляции c использованием GCC или Clang.
tangro
21.02.2017 17:03Никак, в статье есть об этом — «Поскольку весь объект так или иначе является константным, удаление модификатора const у одного из его членов данных нисколько не уменьшает безопасность, а для компилятора VC++ по факту увеличивает её.».
laughman
22.02.2017 14:47и никто не скажет что наличие const должно бы определяться логикой программы, а не размером бинарника… компилятор поправят, а переделки const останутся
tangro
22.02.2017 14:49-1В программе должны быть и логика, и оптимизация (ну по крайней мере в программе уровня Хрома, которой пользуются миллионы людей). Компилятор поправят, переделки останутся — и не принесут никакого вреда, поскольку эти объекты и так константны.
Danik-ik
23.02.2017 22:38+2Ну, так если прочитать и понять(!) статью, то можно увидеть следующее: автор, использовав специальные средства, провёл работу над ошибками в плане проверки соответствия результата компиляции (даже так, уже круто!) логике программы. Нашёл кучу несоответствий именно логике программы и устранил. И — ура!!! Уменьшился размер, уменьшилось потребление памяти. И если второе "ура" было целью, первое оказалось приятным сюрпризом. Я не увидел в статье ни одного изменения, нарушающего логику. Ряд сущностей был переведён из переменных в константы (именно потому, что по логике это должно быть так), при этом ни одна константа не перестала быть константой, в том числе там, где const удалили (так как удалялись ИЗБЫТОЧНЫЕ const). И что конкретно Вам не понравилось, что автор сделал не так по-Вашему?
А переделки остануться именно в силу того, что они сделали объявления более соответствующими логике программы.PsyHaSTe
27.02.2017 15:40Избыточной константности не бывает. Это как константная пропертя, которая является тоже константной структурой. Кто-то может подумать "а зачем делать структуру константной, она же и так в константном поле" и уберет. Через пару месяцев нужно будет убрать константность свойства, и её уберут. А еще через пару месяцев кто-то запишет значение в массив, который предполагался быть readonly.
В случае добавления const — да, это исправление логики программы и синхронизация с ней исходных кодов. А вот убирание const — совершенно обратная операция, призванная выиграть 300кб памяти. Я вот даже не представляю, как константность может быть избыточной. Константность поля никак не зависит от константности типа поля, например. Это разные уровни просто — перезапись свойства, перезапись свойства свойства, перезапись свойства свойства свойства,… И каждая требует (или нет) const на своем уровне.
alexeiz
22.02.2017 19:09Баг в компиляторе или не баг, но константные поля в структурах вообще имеют мало смысла, потому что у структур обычно нет конструкторов, которые бы эти поля инициализировали. В коде хроме с этими const в структурах явно переборщили.
Viacheslav01
01.03.2017 02:39Запись нулей можно было бы пропустить. Данный массив — это глобальная переменная, которая инициализируется лишь раз при запуске программы, а в этот момент вся память гарантированно заполнена нулями, так что записывать нули поверх нулей — бессмысленная работа.
А почему память гарантированно заполненна нулями? Последний раз (было давно) в памяти был мусор оставшийся в наследство от бывшего владельца.mayorovp
01.03.2017 09:06Если бы запущенный процесс в своих глобальных массивах мог бы читать чужой мусор — это было бы нарушением изоляции процессов и уязвимостью. Поэтому ОС при выделении памяти всегда ее очищает.
Antervis
а еще можно помечать данные constexpr и есть шанс, что они вообще не будут созданы в памяти
tangro
Иногда можно, а иногда и нельзя. Вот что автор оригинальной статьи пишет по этому поводу:
Antervis
На мой взгляд, пример по ссылке как минимум странный. Зачем линковаться к константному массиву данных и дергать из него значения в рантайме, если можно вынести массив в заголовочный файл как constexpr, подключить его и тогда все операции с этим массивом перейдут в compile time, а в бинарник он вообще не попадет?
mayorovp
Не могут все операции с массивом перейти в compile time, за исключением тех случаев когда массив — промежуточный либо не нужен.
Возьмем простейший код:
Ну и как в такой ситуации компилятору
a[k]
вычислять-то?mayorovp
… или будут, но в том месте, где потребуются. заполняясь побайтово? :-)