С точки зрения разработчика компилятора, скомпилированная программа работает в собственном адресном пространстве. Это логическое адресное пространство, не обязательно 1:1 соответствует физической памяти.
Управление разделяется между компилятором, ОС и целевой машиной. Обычно, задачей сопоставления логических и физических адресов занимается ОС.
Адресное пространство – из нескольких областей данных. На схеме приведено типичное распределение логических адресов:
Количество памяти, необходимое для размещения объекта, определяется его типом.
На схему размещения оказывают влияние ограничения целевой машины. Например, многие архитектуры требуют выравнивания: размещения в адресах, кратных какой-то константе, например 4. Тогда, хотя для хранения массива 10 байт, могут выделяться 12 байт из-за выравнивания.
Размер целевого кода однозначно известен на момент компиляции, поэтому он размещается статически (обычно) в начале адресного пространства. Там же могут быть размещены данные, размер которых известен. Напр., глобальные переменные или константы, и информация сгенерированная компилятором.
Стек и куча – по разные стороны свободного адресного пространства, максимально эффективное использование адресов. Размеры кучи и часто стека при необходимости изменяются в процессе работы программы, они “растут” навстречу друг другу.
Статическое выделение памяти – решение принято в процессе компиляции. В противном случае, динамическое выделение памяти.
Для динамического выделения памяти – комбинация стратегий:
Локальные данные процедуры и вспомогательная информация (запись активации) – в стеке.
Данные, время жизни которых не определено – в куче.
Позволяет переиспользовать память для локальных переменных не перекрывающихся по времени процедур. Кроме того, использование стека позволяет компилировать код процедур таким образом, чтобы относительные адреса её нелокальных переменных не зависели от последовательности вызовов.
Говоря о памяти на стеке, вызов процедуры принято называть активацией, а процедуры, которые в настоящий момент выполняются – активными.
Вызовы процедур и возвраты управляются стеком управления. Каждая активная процедура имеет запись активации. Записи активации иногда называются кадрами или фреймами. На стеке всегда фреймы, соответствующие активным процедурам. Фрейм последней по времени активации – на вершине стека.
Не исчерпывающий список возможных данных в фрейме:
Активация реализуется с помощью последовательности вызова – кода, создающего фрейм. Последовательность возврата восстанавливает состояние машины при выходе из процедуры, чтобы вызывающий код мог продолжить работу.
Последовательность вызова разделяется между вызывающей и вызываемой процедурами. Максимально – размещение в начале вызываемой процедуры (пролог). Аналогичные соображения о последовательности возврата (эпилог).
При проектировании структуры записи активации, принципы:
Рассмотрим, как процедура обращается к данным, не принадлежащим ей.
Для языков без вложенных процедур (C), доступ к нелокальным переменным – простой. Переменные:
Существенно сложнее при вложенных процедурах: переменные, локальные для внешней процедуры, должны быть доступны во внутренней.
Реализация – через связь доступа.
Если процедура P
непосредственно вложена в процедуру Q
, то связь доступа каждой активации P
указывает на последнюю активацию Q
.
Связи доступа образуют цепочку записей активации, в которых все данные, доступные процедуре.
Введём понятие глубины вложенности. Для процедур, не вложенных ни в какую другую, глубина вложенности – 0. Для процедур, определённых непосредственно внутри процедуры с глубиной вложенности \(n\), глубина вложенности будет \(n+1\).
Если процедура P
с вложенностью \(n_p\) получает доступ к локальным данным процедуры Q
с вложенностью \(n_q\), (понятно, \(n_q \le n_p\)), то ищется последний фрейм для Q
– через \(n_p-n_q\) переходов по связям доступа. Локальные данные Q
– по фиксированному смещению от фрейма Q
.
Язык с передачей процедур как параметров – ещё немного сложнее. При вызове процедуры, переданной параметром, недостаточно информации для определения связи доступа. Решение – при передачи процедуры, вместе с ней передавать и связь доступа. Связь доступа известна в месте определения.
При большой вложенности процедур – многократный переход по связям доступа; негативно для производительности. Тогда в каждом фрейме – массив связей доступа, индексированный глубиной вложенности. Такие массивы называются дисплеями. Минус: при каждой активации – копирование дисплея.
Кучей управляет диспетчер памяти, интерфейс между прикладной программой и ОС.
При выделении памяти, диспетчер памяти ищет в куче свободный блок нужного размера. Если не найден, то увеличение кучи через запрос памяти у ОС.
При освобождении памяти – возможен возврат в ОС, обычно – не делается или не сразу.
Хороший диспетчер памяти должен:
В начале работы, куча – непрерывный блок свободной памяти. В процессе работы, куча разбивается на свободные и используемые блоки памяти. Свободные блоки – не обязательно непрерывны.
При выделении памяти, запрошенный объём выделяется из свободного блока достаточного размера. Если нет свободного блока ровно нужного размера, блок большего размера разбирается на два, один из которых остаётся свободным.
При освобождении памяти, блоки помечаются как свободные. Смежные свободные блоки – сливаются, иначе их размер будет монотонно уменьшаться.
“Плохой” диспетчер фрагментирует кучу, т.е. куча состоит из большого количества несмежных мелких свободных блоков. При очередном выделении памяти – подходящих блоков нет, при этом общий объём свободной памяти – велик.
Снижение фрагментации – управлением размещения в куче. Эмпирически, хорошая стратегия – выделение в наименьшем из подходящих блоков. Называется стратегией наилучшего подходящего (best-fit).
Выделение в первом найденном блоке – простая стратегия первого подходящего (first-fit), проигрывает в смысле фрагментации памяти, но возможно заметно быстрее.
Блоки свободной памяти – по “корзинам” в соответствии с их размерами.
Много блоков в корзине с малым размером, и меньше – с большим.
Например, диспетчер памяти dlmalloc (Lea), выравнивает блоки на 8-байтовые границы. По одной корзине для блоков каждого кратного 8 байтам размера от 16 до 512 байт. Корзины размеров, больших 512 байт – с блоками разных размеров, минимальный размер каждой корзины вдвое больше предыдущего, а внутри корзины блоки упорядочены по размерам. Один блок свободной памяти имеет изменяемый размер и считается самым большим. Такая схема позволяет достаточно быстро находить блоки нужного размера.
“Корзины” – списки указателей на начальные адреса блоков, хранятся в массиве.
Стратегия best-fit – негативна для локальности. Модификация – для идущих подряд запросов на выделение памяти – по возможности выделение из смежных блоков. Называется “стратегия следующего подходящего” (next-fit)
Об объединении свободных блоков.
При наличии “корзин” фиксированного размера – объединение бессмысленно. Тогда, занятость блоков – по битовой карте. При нехватке свободных блоков – запрос памяти у ОС и расширение битовой карты.
В противном случае, комбинация двух подходов:
Дескрипторы границ. С обоих концов блока хранится информация, указывающая, свободен блок или занят, а также его размер.
Двусвязный список свободных блоков. Свободные блоки связываются в двусвязный список. Указатели этого списка хранятся в самих блоках. Порядок блоков в списке может быть произвольный, но вообще говоря список может быть и отсортирован, например, по размеру, чтобы облегчить поиск подходящих блоков.
Тогда при освобождении памяти – проверка дескриптов границ смежных блоков. Если свободны – объединяются. Объединение – удаление всех, кроме первого, блоков из списка и увеличение длины дескрипторах границ.
Генератор кода – последняя часть заключительной стадии компилятора.
Вход – промежуточный код. Выход – код исполняемый на целевой платформе (машинный код, байт-код для виртуальной машины).
Выходной код – семантически эквивалентен входному коду. Должен эффективно использовать ресурсы.
Задача генерации оптимального кода – алгоритмически неразрешима. В результате – эвристики.
Задачи генератора кода:
Сложность разработки хорошего кодогенератора зависит от целевой архитектуры. Наиболее распространёнными являются архитектуры типа:
RISC-архитектуры – много регистров, простой набор команд. Хорошо вписываются в концепцию кодогенератора, но плохи для “ручной” разработки.
CISC-архитектуры — мало регистров, множество различных, возможно дублирующих, команд. Хорошо для “ручной” разработки, хуже для кодогенератора. Ассемблеры x86 и x86_64 – CISС. На практике – RISC-ядро и трансляция CISC в RISC.
Стековые машины – редки в “железном” исполнении. Чаще – виртуальная машина для байт-кода, например, Java.
Кодогенератор отображает промежуточный код на целевой.
Если промежуточный код – высокоуровневый, каждый элемент промежуточного кода – множество команд целевого. Может требовать дальнейшей оптимизации.
Если промежуточный код ближе к машинному, может быть достаточно предварительной оптимизации.
Напр., команды x=y+z
– несколько инструкций загрузки из памяти и сохранения в память. Несколько последовательных команд такого вида могут избыточно работать с памятью.
Другой фактор – скорость выполнения разных команд целевого кода. Напр., инструкция a=a+1
может быть (абстрактно) транслирована в ADD a, 1
или в INC a
. Могут иметь разное время выполнения.
Одна из ключевых задач эффективной кодогенерации.
Регистры – наиболее быстрая память, но их мало. Важно использовать максимально эффективно.
Объём | Время доступа | |
---|---|---|
>64 ГБ | Виртуальная память (файл подкачки) | 3-15 мс |
сотни МБ - 64 ГБ | Физическая оперативная память | 100-150 нс |
128 КБ - 4 МБ | Кэш второго уровня | 40-60 нс |
десятки КБ | Кэш первого уровня | 5-10 нс |
сотни байт | Регистры | 1 нс |
Оптимальное распределение регистров – NP-полная задача. Следовательно, эвристики.
Ряд регистров – под специализированные нужды, напр. указатель на вершину стека. Другие – свободно.
Рассмотрим x = y + z
(где +
– любой оператор).
Тогда, сначала – регистры для y
и z
, затем – регистр для x
.
Регистры для y
и z
– одинаково. Выбор для y
:
y
– уже в регистре, использовать егоy
не в регистре, и есть пустой регистр (не содержащий живых переменных), используем егоR
, хранящего значение v
:
v
есть где-то кроме R
(например, было загружено из памяти), используем R
v
есть x
, т.е. будет перезаписано текущей командой, и x
– не y
или z
, используем R
v
в базовом блоке более не используется, используем R
R
нельзя использовать.Выбор регистра для x
похож, но есть отличия:
x
, всегда подходит для x
, даже если x
есть y
или z
(если допускается целевой машиной, обычно – да)y
или z
более не используется в рамках базового блока, их регистры можно использовать для x
.Вне базовых блоков осмыслено назначение “глобальных” регистров для часто используемых переменных (например, счётчики циклов). Правило не универсально, компиляторы, могут производить анализ ожидаемого выигрыша.
Большинство оптимизирующих компиляторов применяют т.н. “оптимизацию замочной скважины”. Сначала генерируется простой машинный код, затем по коду перемещается “замочная скважина” – окно, в которое попадает локальная окрестность текущей команды. К коду внутри окна применяются трансформации.
В общем случае может требоваться несколько проходов.
Примеры оптимизаций:
Современные процессоры выполняют за один такт несколько операций. Но если все операции в программе сильно взаимозависимы, никакие методы улучшат производительности. Вычислительные и графические программы часто допускают крайне высокую степень параллельности.
Параллелизм уровня команд – концептуально, несколько команд за один такт. В действительности – параллелизм за счёт конвейерной обработки.
Идея – начало обработки следующей команды до завершения предыдущей. Типичный процессор для каждой команды выполняет:
Выполняются различными исполнительными блоками, могут выполняться одновременно. Тогда, когда для i-той команды выполняется действие 5, для i+1-й может выполняться действие 4, для i+2-й действие 3 и т.д.
Сложность – команды ветвления. Неизвестно, по какой ветви пойдёт выполнение. Следствие – опустошение конвейера. Решение – спекулятивное исполнение, попытка предсказать, по какой ветви пойдёт исполнение, тогда опустошение – только при неверном предсказании. Следствие – уязвимости Meltdown и Spectre. Как следствие исправления – потеря производительности.
Не все команды полностью конвейеризуемы. Если следующая команда зависит от результатов предыдущей, то не может быть конвейеризована. Компиляторы (и даже процессоры!) могут менять порядок выполнения команд для обхода этой проблемы. Может влиять на наблюдаемое поведение.
Другой источник ускорения – векторные команды, т.е. команды, производящие операцию над несколькими адресами. Может быть реализовано как в компиляторе (тогда используются специальные инструкции, называемые в общем VLIW – very long instruction word), так и непосредственно в исполнительном устройстве, тогда такой процессор называется суперскалярным.
Задачи эффективного использования регистров и улучшения параллелизма (за счёт конвейеризации или векторизации) часто вступают в конфликт. Максимально эффективное использование регистров подразумевает повторное использование недавно освободившихся регистров, что создаёт зависимости между командами, не позволяющими полностью их конвейеризовать или векторизовать.
Два понятия локальности:
Локальность данных важна, поскольку в кэши процессора данные загружаются “блоками”. Обращение к данным, находящимся рядом в памяти, с большей вероятностью использует быстрый кэш. Та же логика – к временной локальности – если к одним и тем же данным производятся обращения в течение небольшого промежутка времени, эти данные с большей вероятностью будут в кэше.
Два эмпирических принципа:
Принципы не всегда выполняются. Следствие – потеря производительности.
Пример:
тот же код можно написать иначе:
Оба варианта имеют одинаковое количество присваиваний. Без оптимизаций, первый вариант будет – быстрее за счёт лучшей локальности (заметно при больших n
).
Пример:
против
Одно и то же, но в разном порядке. Первый вариант проходит по массиву линейно, второй – “прыгает” по массиву. В силу худшей локальности, второй вариант – медленнее (возможно на порядок!)
Задача оптимизации локальности значительно усложняется, если в рамках рассмотрения к тому же оказывается и параллелизм. В частности, если предполагается параллельное исполнение кода на нескольких процессорах/ядрах, то разные данные могут быть “локальными” для разных процессоров.