[Перевод] Там добавим const, отсюда удалим const…
Эта задача возникла, когда я писал документацию для некоторых утилит, которые я использую для исследования регрессий кода, связанных с увеличением размера скомпилированных бинарников под 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.