Межпроцедурные оптимизации
Мы сегодня обсуждаем достаточно большую тему, большую компоненту: то, что связано с межпроцедурным анализом и межпроцедурными оптимизациями. Почему это важно и интересно. Когда мы обсуждали предназначение компилятора и что вообще от этого компилятора ожидается, мы говорили, что существует некое противоречие между тем, как нужно правильно и наглядно писать большие проекты и теми требованиями оптимизации кода. Для того чтобы коллектив мог работать над каким-то большим проектом необходимо, чтобы четко была проведена граница между различными группами, которые отвечают за поддержку той или иной компоненты. Основные компоненты должны реализовываться в виде черных ящиков, то есть все разработчики проекта не должны знать? что делает та или иная функция, а должны иметь информацию только о входных параметрах и о том, что в результате данная функция вычисляет. То есть с одной стороны это очень хорошо для читаемости и для руководства над проектом, но, с другой стороны, это накладывает некоторые ограничения на возможность оптимизации данного кода. Понятно, что все разработчики, которые пишут какие-то утилиты, они пытаются сделать эту утилиту как можно более общей, чтобы она делала правильную работу. То есть, не пытаются выделить какие-то частные случаи, а пытаются создать некий общий механизм. А с точки зрения оптимизации интереснее иметь дело с какими-то частными и общими случаями, потому что именно для общих случаев, когда мы имеем дополнительную информацию, можно сделать какую-то полезную оптимизацию.
До этого мы знакомились со скалярными оптимизациями или с оптимизациями, связанными с графом потока управления, с векторизацией, параллелизацией и оптимизацией циклов. И все эти оптимизации — процедурного уровня. Они эффективно работают только с локальными переменными, всякий вызов функции является "черным ящиком", про который ничего неизвестно, поэтому нам неизвестны свойства параметров, которые мы обрабатываем, и неизвестны свойства глобальных переменных. Для того, чтобы сделать картину более прозрачной, нам нужно исследовать программу в целом: исследовать все функции, какая функция что вызывает и так далее.
Когда мы говорили про скалярные оптимизации, мы вводили несколько множеств: множества значений переменных, которые достигли данного базового блока и так далее. На основании этого анализа мы пытались понять, нужно ли там константы протягивать, делать протяжку копий, выделять общие подвыражения и так далее. Так вот, если у вас внутри вашего базового блока происходит вызов какой-то неизвестной функции, то нам необходим все переменные, которые вот эта базовая функция может изменить, вставить в множество убитых, значения которых были изменены и формально про это значение мы ничего не знаем. Мы знаем, что оно ожжет быть изменено, но на что, что оно после этого может содержать — об этом у нас представлений никаких нет. Эта ситуация сильно сужает возможности скалярных оптимизаций. Вызов функций внутри базового блока — это плохо для скалярных и цикловых оптимизаций.
Легко предложить какой-то метод, чтобы вы могли убедиться, что я говорю не беспочвенно. Мы возьмем простую программку и посмотрим, будут ли в данном случае значения переменной а, которой мы присвоили значение 5, протягиваться через вызов неизвестной функции. Если бы она протягивалась, то у нас сразу бы условия if a тождественно или равно 5, оно бы просто исчезло, поскольку оно стало бы бессмысленным, всегда было бы верным. Какой можно метод взять: откомпилировать программу с каким-нибудь ключом оптимизации и добавить опцию –F, которая позволяет вам посмотреть на ассемблер. И вот когда вы получите ассемблерный файл, вы смотрите внутрь него, вы видите там call_unknown, смотрите, есть ли после этого вызова инструкции сравнения. Если есть, значит, в общем случае, когда о вызываемой функции ничего неизвестно, константа, присвоенная переменной, не протягивается. Это компиляторный эксперимент.
Легко подобные эксперименты можно провести, чтобы понять, работает ли удаление подобщих выражений оптимизации. Если у нас есть общее выражение (в данном случае это sqrt (a+b)), то хотелось бы понять, а будет компилятор это общее подвыражение сохранять во времянку с тем, чтобы потом переиспользовать? Это определяется тем же самым методом: мы компилируем с –S, получаем ассебмлерный файл и в общем случае смотрим (9.18). Вот у нас есть операция sqrtsd, она один раз встречает до вызова, один раз после вызова, то есть, экспериментально мы подтвердили, что вызов функции портит и не дает нам делать скалярные оптимизации. Доказательство можно продолжить, включить межпроцедурные оптимизации и посмотреть, с ними будет происходить скалярная оптимизация или нет. То есть, однопроходная и двухпроходная компиляция.
Компилятор обычно работает в двух режимах. Он берет некую процедуру, начинает ее исследовать, делать для нее оптимизации, генерить для нее объектный код (начиная от парсинга и кончая получением объектного кода) — это однопроходная оптимизация. Понятно, что для такой оптимизации, мы можем иметь только ту информацию, которая может встречаться в этой рутине или до этого встретилась в каких-то описаниях. Есть возможность передать компилятору дополнительную информацию о каких-то функциях. Тем не менее, при однопроходной оптимизации все, что у нас есть – это некие атрибуты, мы работаем в области одной процедуры. Соответственно, для того, чтобы собрать свойства каких-то процедур, а потом эти свойства использовать, очевидно, нам необходим второй проход. То есть на первом проходу мы должны эти свойства собрать, на втором проходе мы их должны использовать. Более того, поскольку каждая функция может вызывать другие функции, также может вызывать себя, то нам, чтобы получить представление о природе всех функций, необходимо более четкий анализ, необходимо построить граф вызовов: получить представление, какая функция какую вызывает. Граф вызовов на гранях содержит различные процедуры вашей функции ,а дуги, которые эти грани соединяют — это возможность вызова этой функции из другой функции.
Существует статический граф, то есть то, что компилятор может построить при компиляции. Статический граф отображает все возможные варианты и пути передачи управления из одной функции в другую, все возможные варианты вызовов.
И есть динамический граф, который реально соответствует тому графу вызовов, который происходит при исполнении вашей программы на определенном заданном наборе данных. Основной задачей нашего межпроцедурного анализа является построение этого графа вызовов, после этого — выяснение свойств функции на основе анализа этого графа вызовов. Например, мы с вами уже обсуждали понятие чистой функции. Чистая функция - это функция, которая принимает аргументы какие-то, при этом она их не модифицирует, она не модифицирует никаких глобальных переменных, не делает выходов. Всё, что она делает — это формирует некий результат, который возвращает к вызывающей ее программе. А что будет, если у нас будет функция, которая содержит пары других функций? Можем ли мы в этом случае понять, что эта функция является чистой? Реально – можем, да. Если мы проанализируем вызываемые функции и увидим, что они других функций не вызывают, сами являются чистыми. Это можно в дальнейшем использовать.
Если у нас есть вызов чистой функции в базовом блоке, то не надо никакие локальные переменные и глобальные переменные заносить в блок, значение которых может быть изменено. Соответственно, через нее можно протягивать константы, можно протягивать копии и так далее. Для того, чтобы мы работали со всей функцией, со всей программой, нам также необходимо иметь информацию о всех библиотечных функциях, которые мы вызываем внутри нашей функции. То есть если у нас такой информации нет, то остается ситуация неопределенности. Существуют две возможности: когда у нас существует полный граф вызовов, который все полностью отражает, и существует возможность, когда у нас граф вызова неполный и мы не можем полноценный анализ осуществить, потому что какие-то части остались за кадром. В случае с библиотечными функциями более-менее все понятно, потому что мы понимаем, что библиотечные функции не могут вызывать наши функции из основной программы (в большинстве случаев). Все гораздо хуже, когда мы не можем получить представление, потому что не можем включить в наш анализ какую-то часть программы.
Если мы вернемся к той архитектуре компилятора, которую мы уже рассматривали. Мы говорили о том, что в этой архитектуре заложены две различные схемы: один проход и два прохода. При одном проходе мы последовательно проходим через все оптимизации, скалярные, оптимизации высокого уровня, генератор кода. Получаем объектные файлы, после этого линковщик эти объектные файлы линкует, и уже получается исполняемая программа. В том случае, если мы делаем два прохода, ситуация несколько отличается. Формально мы берем некий исходный файл, делаем минимальные скалярные оптимизации и после этого всё внутреннее представление, которое мы для этого объектного файла построили, мы каким-то образом упаковываем, сжимаем и кладем в объектный файл. Вот, после этого частичного прохода у нас появился объектный файл с информацией о внутреннем представлении исходного файла.
После того, как мы все исходные файлы таким образом обработаем, у нас получилось n объектных файлов с внутренним представлением для каждого исходного файла. После этого мы начинаем делать второй проход. Сначала мы последовательно все эти объектные файлы читаем, все процедуры читаем, анализируем их и строим граф вызовов. По сути дела, мы опять проходим по всему объектному файлу для того, чтобы получить граф вызовов. Получив граф вызовов, мы начинаем делать межпроцедурный анализ, чтобы на основе анализа графа вызовов получить представление о свойствах наших функций процедур.
После того, как мы эту информацию получили, мы опять начинаем последовательно начинаем все процедуры, которые у нас есть, в наши программы оптимизировать с учетом этой информации. И уже в этой ситуации скалярные оптимизации учитывают свойства функций, оптимизации высокого уровня, цикловые оптимизации, векторизация, параллелизация учитывают свойства функций, генератор кода генерирует код. И получается опять объектный файл, но есть некие различия. Если здесь мы работали с процедурками в порядке, как они лежали в исходном файле, то здесь, собственно, мы можем избрать любой метод и в любом порядке, более удобном нам генерить код. И складывать мы можем их в один объектный файл, но, возможно возникает проблема с ресурсами, поэтому компилятор просто берет и нарезает, создает несколько объектный файлов в зависимости от величины проекта, чтобы не делать один большой проектный файл, чтобы сохранить ресурсы. Чем объект больше, тем объектных файлов больше. После этого эти объектные файлы опять линкуются и получается исполняемый код. В тот момент, когда мы начинаем уже оптимизировать процедурки, параллельно мы делаем сначала какие-то межпроцедурные оптимизации. Широко известная межпроцедурная оптимизация — это инлайнинг — подстановка вместо вызова функции тела этой функции.
Был задан вопрос про параллельную компиляцию. Я вам скажу, в случае, когда мы работаем в однопроходном режиме, у нас вообще проблем нет с параллельной компиляцией. Мы запускаем компиляцию каждого исходного модуля в параллель, одновременно компилируем все исходные модули, одновременно получаем все объектные файлы, и там дальше линковщик линкует.
А здесь возникает некая проблема: вы в какой-то момент должны построить этот граф вызовов. Операцию по получении объектного файла с внутренним представлением мы тоже можем делать в параллель, а вот здесь уже вотчина межпроцедурных оптимизаций. Тем не менее, есть такая специальная опция, она называется на Виндоусе минус квиподжобс , которая вот эту задачу пытается делать в параллель и передает ей число n, информацию о том, сколько будет строится джобс, во сколько потоков она будет работать.
Тем не менее, на каждом потоке нам нужно выполнять достаточно сложную работу. Каждому потому необходимо иметь свой граф вызовов. Иногда для какие-то приложений, где очень много различных функций, необходимость построить граф вызовов — это самая тяжелая, сложная работа и выхлоп от распараллеливания этой задачи, распараллеливания компиляции может для таких задач быть очень незначительным. Много однотипной, общей работы каждому потоку необходимо сделать.
Проблема в том, что для делания межпроцедурных оптимизаций нам нужен весь граф вызовов. Либо нам нужно научиться считать граф вызовов, каким-то образом получать информацию о функциях, после этого граф вызовов и информацию о функциях сохранять, и после этого кадый из потоков будет эту информацию для себя читать, либо нужно в каждом потоке граф вызовов строить. Возможность распараллеливать компиляцию двухпоточную, она есть, но на больших приложениях может несколько разочаровать тех людей, которые считают, что время компиляции от этого значительно уменьшится.
Здесь еще есть некая тонкость. Сейчас существует две моды работы этого межпроцедурного двухпроходного компилятора. Видите, в чем недостаток одного прохода? В том, что мы работем с одним файлом, с одной процедурой всегда. Возможен промежуточный вариант: мы возьмем и для одного исходного файла построим этот частичный граф, то есть попытаемся понять, как внутри одного исходного файла взаимосвязаны процедуры между собой, какая процедура какую вызывает и так далее. Существует такой атрибут "статик" на функции, вы должны понять, что использование этого атрибута — это очень хороший метод для улучшения качества вашей программы. Если у вас есть возможность, всегда помечайте функцию как "статик". Если вы пишете процедуру и вы отдаете себе отчет о том, что эта конкретная утилита вами написана только для этого конкретного модуля, эту функцию помечайте как "статик". Во-первых, в этом случае, когда у вас есть внутри функции статики, очень сильно улучшается возможность для оптимизации межпр оцедурной оптимизации, которая работает на уровне модуля. Построив этот граф вызовов вы отдаете себе отчет, что для этой статик-функции невозможно никаких других вызовов, кроме вызовов из этого исследуемого модуля. Отсюда сразу появляется возможность каких-то оптимизаций, меньше расчетов и так далее. Наличие статиков очень сильно улучшает возможности межпроцедурного анализа.
Модульный межпроцедурный анализ берет ваш один исходный код и пытается понять, какие функции как друг друга вызывают внутри этого файла исходного. На основании этого удается собрать качественную информацию о природе этих статиков, сделать какие-то межпроцедурные оптимизации и так далее. (30.22)
Итак, в чем смысл модульной компиляции. Мы сначала берем этот наш исходный файл, делаем первый проход, то есть, делаем какие-то простенькие скалярные оптимизации и получаем, по большому счету, внутреннее представление для различных функций этого исходного файла. После этого мы повторно все это читаем, строим частичный колграф, анализируем его и после этого мы идем по этой ветке (31.00), но мы обрабатывали частичный колграф, значит, работаем только с функциями, которые определены в этом исходном файле. Тем не менее, уже после этого мы знаем о каких-то свойствах функций, которые внутри этого исходного файла определены, и здесь строится объектный файл для конкретного исходного файла, то есть это такой "полуторопроходный" вариант . То есть у нас есть однопроходный вариант, полуторопроходный и двух с половиной проходный.
Здесь приведены опции компилятора, которые включают эти различные варианты работы. Опция Qip включает модульные межпроцедурные оптимизации, опция Qipo включает оптимизации для всех исходных файлов. Хочу обратть ваше внимание, что есть специальная опция: - Qipo-S. Я рассказывал вам, что вот здесь (32.43) при включенных межпроцедцрных оптимизациях генерятся какие-то объектные файлы, которые называются, например, ipo-1_obj, ipo-2 и так далее до ipo-n. Для того, чтобы увидеть ассемблер после межпроцедурной оптимизации, опция –S не подходит, нужно включать опцию –Qipo-S, тогда у вас появится ipo-1_asm файл, ipo-2_asm файл и так далее. Не знаю, будете ли вы когда-нибудь анализировать ассемблер, но такой факт есть и достаточно важен.
Можно вернуться к примерам про протяжку констант, про вычислений общих подвыражений и дополнить те примеры некой чистой функцией. Она не совсем чистая, потому что она здесь печатает, но, тем не менее, не изменяет глобальных переменных и не изменяет тех аргументов, адреса которых получает. После этого можно даже те же самые эксперименты, которые мы делали в начале, повторить, но уже посмотреть на ассемблер уже после межпроцедурных оптимизаций с ключиком –Qipo-S. Чтобы не получилось так, что эта unknown – неизвестная (теперь уже известная) функция заинлайнилась, нужно добавить опцию, которая отключит инлайнинг, чтобы эксперимент был чистый. В данном случае это опция –Ob0. Какеи оптимизации делаются при межпроцедурном анализе? Смысл наших рассуждений был в том, что мы можем собрать множество, которое описывает, какие конкретные объекты изменяет та или иная функция. Это называется модреф анализ: мы собираем множество тех объектов, которые были модифицированы внутри функции и множество тех объектов, к которым мы обращались внутри функции. В тот момент, когда мы пытаемся делать какие-то скалярные преобразования, мы ставим вопрос так: может ли вызов этой функции изменить конкретно вот этот объект. Берется объект, сравнивается с множеством объектов, которые данная функция может модифицировать, если он в этом множестве присутствует, значит, он может быть модифицирован, если нет — видимо, он не может быть модифицирован.
Все это несколько усложняется из-за поинтеров (pointer). Объект — это объект, а поинтер — это некая сущность, которая может указывать на некоторые объекты. Значит, появляется еще необходимость отслеживать, куда может указывать тот или иной поинтер. Понятно, что у нас есть глобальный поинтер и у нас есть граф вызовов. Мы можем, формально, всю программу изучить, посмотреть, где этот поинтер и как присваивался, и получить список тех объектов, на которые внутри нашей большой программы этот поинтер может указывать.
Модреф анализ может выполняться и для поинтеров. Если у нас где-то модифицируется поинтер, какое-то значение по адресу присваивается, то мы берем возможный список объектов, на которые этот поинтер может указывать и добавляем его во множество модифицированных объектов. Это, по большому счету, называется "глобальный анализ потока данных".
Термин "анализ совмещений" — это часть анализа потока данных. Анализ совмещений — это важная часть механизма определения зависимостей. Всегда интересно, а применен ли такой термин в компиляторе? В каких диапазонах он работает? Берешь, пишешь какой-нибудь простенький тест типа такого (39.48). Берешь два глобальных указателя: a и b, после этого в какой-то процедурке init берешь и эти указатели на один объект направляешь , и после этого смотришь, будут ли осуществляться цикловые оптимизации с таким циклом. Видите, внутри цикла мы работаем с объектом а и работаем с объектом b, то есть здесь мы объект а используем, а здесь мы его переопределяем (40.31). По большому счету, это зависимость, и компилятор должен разобраться, что здесь есть зависимость и что она помешает делать массу цикловых оптимизаций. Можно проэкспериментаровать на любом компиляторе: работает он правильно или нет. Я этот вопрос оставляю открытым.
Какая есть еще болезнь внутри С++-ного компилятора? Если у нас есть какая-то переменная, даже локальная, мы можем взять у нее адрес и записать его в какой-то глобальнай поинтер. После этого, допустим, в какой-то функции, которая вызывается из нашей функции, где у нас эта переменная заведена как локальная, функция, которая там вызывается с помощью этого глобального поинтера, взять и изменить значение этой нашей локальной переменной. В С++ возможны такие "махинации". Поэтому для всяких глобальных переменных становится актуальным понять, могут ли хитрые программисты с этим глобалом таким образом действовать. Косвенным признаком является то, что где-то у этой переменной берется адрес.
Нам всегда интересен вопрос объективности, мы всегда хотим получить наилучший результат, делая наименьшие действия. Оптимизации внутри компилятора бывают разные: бывают легкие, быстрые, но которые не дают нам возможности достичь каких-то глобальных результатов, а бывают оптимизации правильные, которые позволяют достичь глобальных целей, но они, как правило, требуют много усилий. Например, мы можем определять, куда могут ссылаться разные указатели — это сложный длинный путь, который требует массу работы. А мы можем взять и проверить, что у данной глобальной переменной никогда не брался адрес и на основании этого сделать какие-то заключения, что позволит выполнять эффектно более дешевые оптимизации. То есть, понять, брался у какого-то объекта адрес или нет — это та информация, которая нам позволяет с этим объектом работать более легкими путями.
Делается такая важная задача, как продвижение данных. Я говорил, что хорошо, когда у нас функция имеет атрибут "статик". То же самое: хорошо, когда у нас не глобальная переменная заведена, а статическая. Если мы какую-то переменную планируем использовать только внутри этого модуля, для целей внутри этого модуля, то лучше ее сразу обозначить как статическую. Это вам поможет избежать каких-то возможных казусов. И если у вас появится необходимость где-то ее использовать, то это будет ваше осознанное решение. Если вы завели очень много глобалов, это определенным образом расширяет вашу программу, и поэтому компилятор, проанализировав всю программу в целом, он видит, например, что это глобал меняется только внутри данного модуля. И он, соответственно, делает ему понижение его области видимости. Это, в принципе, полезно.
То же самое, что функции, используемые только в одном исходном модуле, получают атрибут статик. С помощью этого анализа всей программы мы можем удалить неиспользуемые глобальные переменные. Очевидно, что это хорошо.
Здесь у нас еще появляется третья ипостась удаления мертвого кода. Первую ипостась удаления мертвого кода мы рассматривали на первой лекции: это когда у нас, допустим, есть присвоение в переменную определения переменной, но нет ее использования, — это можно удалить. Вторая ипостась — это проанализировать реальное использование, проанализировать, печатается результат или нет, и после этого попытаться удалить все выражения, которые считают какие-то неиспользуемые результаты. Эта ипостась более сложная. А третья ипостась удаления мертвого кода — это построить граф вызовов и вдруг увидеть, что в этом графе вызовов есть какие-то области, которые никаким образом не связаны с нашей функцией мэйн. Естественно, это для исполняемого модуля, это не касается библиотек. Если у нас есть такие области, в которые управление никогда не передается из функции мэйн, мы просто берем и все эти ненужные функции удаляем из нашего кода. Это самая мощная ипостась удаления мертвого кода.
Существует какой-то замкнутый участок внутри контрол флоу графа, в который вообще не входит ниоткуда никак управление. Это очень частая ситуация. Вы, например, подключая какие-то библиотеки, в которых много функциональностей, при отсутствии межпроцедурного анализа будете всю эту функциональность в ваш выполняемый код тянуть, потому что вы без межпроцедурного анализа не сможете определить, нужна вам конкретная библиотечная функция или нет. Понятно, что там тоже есть свои сложности: вызов каких-то функций через указатели, всякие методы класса. Но в общем случае удаление мертвого од работает достаточно эффективно и определенным образом влияет на производительность программ, потому что, чем компактнее ваша программа, тем больше вероятность того, что она будет чуть быстрее выполняться.
Мы говорили о той информации, которая необходима векторизатору: это информация о выравнивании аргументов, соответственно, межпроцедурный анализ эту информацию анализируется, то есть анализирует объекты, которые передаются как аргументы в ваши функции, выровнены они или нет. Эту информацию он собирает и потом использует.
Помимо этого достаточно большая работа делается с аргументами функций. Если у вас есть функция, в которую передаются какие-то константы, то они будут протягиваться этой функцией. Пример простой: напишите модуль, в котором вы вызываете функцию fu, оба раза передавая в нее в качестве параметра константное значение. После этого вы можете посмотреть на ассемблер этой функции. После оптимизации вы увидите, что везде, где использовался аргумент, он был измене на единицу (вот эту константу).
Помимо этого протягиваются различные другие свойства аргументов внутрь функции. Зачем передавать константу? Например, есть шахматная программа, в которой есть функции расчета движения белых и черных фигур. И в этой длинной функции в конце передается пятый аргумент "цвет фигуры". Этот аргумент всегда константа: он либо белая фигура, либо черная. Существуют, например, такие вещи, когда программист с помощью какой-то константы определяет, эта функция делает какие-то дополнительные действия или нет. Он когда планировал свою задачу, он подразумевал, что иногда это ему будет нужно, он эту функциональность написал, а потом в какой-то момент он понял, что ему эта функциональность не нужна, возможно, понадобится в будущем, и он везде просто ставит на вызов этой дополнительной функциональности false. Такая ситуация очень часто возникает, и проанализировав, что вы всегда передаете, скажем, в качестве этого пятого аргумента false и удалите из вашего исполняемого кода эту никогда не работающую дополнительную функциональность, это будет очень полезно для производительности.
Я начал рассказывать про протяжку константных аргументов. Если при каждом вызове некой процедуры в качестве аргумента (первого, второго и т.д.) передается некая константа, то это позволяет протянуть эту константу внутрь этой функции, и осуществить дальше протяжку констант в программу. Функция F благодаря этому сильно упростится.
То же самое касается протяжки возвращаемых значений. Как ни странно, бывают функции, которые практически всегда возвращают одинаковое значение.
Вот простой пример (55.35). У нас есть некая известная функция, которая в зависимости от значения первого параметра (от того, больше он нуля или нет), выполняет какие-то различные действия. Поскольку вы часто используете библиотеки, например, набор утилитов, которые кто-то для вас написал, вы понимаете ту функциональность, которая вам нужна и выбираете именно эту, чтобы лишняя функциональность не просочилась к вам в экзешник, это очень необходимая оптимизация. В данном случае для того, чтобы понять, что функции передается константа в качестве аргумента, мы сначала должны выполнить скалярную оптимизацию, протяжку констант. Мы ее выполняем, и у нас в нашу известную функцию протягивается константа 2, которая очевидно больше, чем 0. В результате, после того ,как мы посмотрим на ассемблер, посмотрим, что из себя представляет функция known,мы увидим, что там не осталось ничего, кроме инкрементирования переданного второго аргумента. Компилятор просто протянул туда значения и исправил код. Если бы вы эту програ мму расширили и добавили сюда еще один вызов с другим значением, ситуация была бы несколько сложнее (57.39).
Подстановка удаляет накладные расходы, связанные с подготовкой к вызову функции, удаляет переходы, поскольку вызов каждой функции есть некий переход управления, который связан с тем, что мы должны на какой-то новый адрес в памяти перейти. Если этот адрес не окажется по какой-то причине в КЭШе, то мы на вызове какой-то простой функции можем получить огромные задержки из-за того, что с вызовом функции автоматически возникает еще и переход.
Подстановка (инлайнинг) – это вообще самая чудесная оптимизация, с одним ограничением: она увеличивает размер приложения. Поэтому вопрос, может ли быть все заинлайнено, он зависит от вопроса, насколько у вас большой проект. Если вы попробуете какую-то большую программу заинлайнить, то у вас полученный исполняемый код надо будет приносить клиентам на жестком диске.
Существует такое ограничение, как время компиляции. Дело в том, что время компиляции от размера кода зависит нелинейно. Под компиляцией я понимаю произведение каких-то оптимизаций. Поскольку существую оптимизации, которые нелинейно зависят от размера кода, то расширение ваших процедур вызовет увеличение времени компиляции. Поэтому всегда, когда делается подстановка необходимо знать мнение разработчиков: насколько они считают возможным увеличение размеров кода с его исполняемым модулем. Сначала мы пытаемся инлайнить те функции, которые, мы считаем, инлайнить наиболее выгодно. И когда мы эти функции инлайним, переходя от более выгодных к менее выгодным, в какой-то момент мы упираемся в некие барьеры: ограничение по расширению кода. И в этот момент мы останавливаемся и прекращаем подстановки.
Понятно, что иногда нужно компилятору свои пожелания передать с помощью, например, атрибута инлайн. То есть, программист рекомендует компилятору подстановку такой функции в тело.
Компилятор всегда будет пытаться эту функцию подставлять, то есть, вы ему рекомендовали ее подставить. Иногда из каких-то соображений он может отказаться, но это ваше требование он будет учитывать, он всегда будет подставлять вот это выражение внутрь и на выходе, скорее всего, от этой функции ничего не останется (1.02.44). Если мы убедимся, что во всех тех случаях, где она была использована, она была подставлена, то само тело этой функции будет удалено. У вас есть в программе функция exforsys, и вместо того, чтобы делать call, вы берете это выражение и его подставляете. То есть, у вас было написано y= exforsys от пяти, вы подставляете y=25. Тело функции подставляем вместо функции.
Существуют директивы различные, вы можете, кроме подстановки инлайнинг , еще какими-то способами передавать информацию, передавая компилятору свои пожелания. Даже для языка Фортран есть аналоги.
Существует масса опций, которые связаны с инлайнингом внутрь компилятора, но они по большей части вас разочаруют, потому что они в основном определяют границы возможного разбухания кода, который вы хотите установить для компилятора. Более-менее информативная и важная опция — это Ob<n>, которая контролирует сам инлайнинг. То есть, вы можете вообще инлайнинг запретить, либо с опцией Ob<n> вы будете инлайнить только те функции, которые у вас внутри с помощью атрибута инлайн были помечены разработчиком. А опция n=2-Ob2— это умолчательная опция, когда компилятор сам решает, какие подстановки он хочет делать, а какие нет.
Есть еще много разных опции.
Если вы какую-то подстановку сделает, это автоматически увеличивает область, в которой делаются разные оптимизации процедурного уровня. То есть, например, у вас вызывается какая-то функция fu много раз. Конкретно на этом месте, куда вы смотрите, стоит вызов fu с константными аргументами. Наличие константными аргументами — это один из плюсов возможной подстановки функции ,потому что вы тело этой функции подставите и после этого все эти константные аргументы тут же протянете. У вас код упростится. Вместо общего варианта вы вызовете вариант, в котором дополнительные оптимизации сделаны. Каждый раз, когда внутрь функции передается какая-то дополнительная информация, то есть существует какая-то дополнительная информация о свойствах аргумента и так далее, иногда, может быть, выгодно заинлайнить эту функцию, чтобы это информацию применить. Если эту функцию не инлайнить, то не всегда можно эту информацию эффективно применить для оптимизации.
Я рассуждал про программу, которая считает либо ходы для белых, либо для черных фигур. Это, кстати, хороший вариант для того, чтобы сделать хороший клонинг – клонирование функции. Сделав клонов мы, в данном случае, получим два варианта функции: расчета хода для белых и расчета хода для черных. Возможно, они будут лучше, чем общий вариант, в котором передавалась дополнительный признак, для каких фигур эта функция вызывается. По сути дела, мы берем и вместо одной функции заводим похожую на нее, которая отличается каким-то свойством. Это идея клонирования функции.
Мы можем заинлайнить эту функцию. У нас, допустим, сначала идет вызов этой функции для белых фигур или сначала для черных. То есть если мы заинлайним и тот, и другой вызов, мы получим тот же самый результат. Иногда мы не можем это сделать из соображений разбухания этого кода. В этом случае эта информация просто потеряется, и мы не сможем никак протянуть, потому что мы вызываем и для белых, и для черных эту функцию. А если мы напишем клонов для белых и клонов для черных, то эта информация будет эффективно использована при оптимизации.
Формально, нужно проверять, насколько компилятор грамотный и как он делает этот клонинг. С точки зрения программирования, возможно, чтобы вам не править сразу в трех-четырех местах, вам выгоднее писать функцию с каким-то большим количеством аргументов. А с точки зрения производительности вам нужно писать какие-то узкоспециализированные функции. Поэтому, если вас интересует производительность, то один из вариантов ее улучшения — это писать клоны, писать узкоспециализированные функции не функции, которыми можно эффективно рулить с помощью большого количества параметров, а функции, которые более эффективно делают какую-то частную работу.
Про клонинг я не могу сказать, насколько эффективно компилятор его делает. Если инлайнинг, наверное, любой компилятор делает, то клонинг – нужно разбираться, в каких конкретно случаях компилятор делает вывод о необходимости клонировать.
А вы, как девелоперы, должны себе представлять то, что я сказал про оптимизацию. Эффективно иметь, если вам это нужно, очень хорошо заточенные под какую-то конкретную операцию инструмент. Если вы заинтересованы в том, чтобы делать эту операцию быстро и качественно. Ну это тоже зависит от вашей мотивации.
Частичная подстановка. Например, если в какой-то гипотетической функции f в начале содержится некоторый код, который зависит только от аргументов, то этот код моет быть вставлен перед вызовом этой функции, а из этой функции удален. Это идея частичной подстановки.
Когда вы работаете над большим проектом, все держатели компонент заинтересованы в том, чтобы эффективно определять, в каком месте произошла ошибка. Они хотят изолировать себя от всяких несуразностей, которые происходят в других компонентах. Каким образом они этого достигают? Они, например, вставляют в те утилиты, которые они печатают, проверки на правильность пришедших к ним аргументов. Эта работа, как правило, бывает лишней. Гораздо эффективнее проверять совместимость аргументов перед вызовом функции, а не внутри вызова функции, когда какая-то работа на её вызов уже осуществлена. Частичная подстановка могла бы решать такие проблемы. Если внутри функции первым действием, которое вы делаете, является проверка указателя, который вам внутрь передали в качестве аргумента, то если он ну, то "return". Очевидно, что эту проверку выгоднее вынести наверх, то есть проверять перед вызовом функции. Если не ну, то вызываем, а в противном случае ничего не делаем. Такая мелочь может показывать хорошие результа ты производительности на специфических программах. Какие вещи может делать межпроцедурные оптимизации?
Перейдём к виртуальным функциям. В чём заключается основное свойство виртуальной функции? В объекте вашего класса есть указатель, который ссылается на таблицу виртуальных функций. Если вы хотите вызвать какую-нибудь виртуальную функцию в runtime, то вы берёте эту таблицу, смотрите этот указатель и вызываете эту функцию по этому указателю. Очевидно, что с точки зрения "красоты" языка – это прекрасно, но с точки зрения производительности – это лишние накладные расходы.
Если мы возьмём простую программу, чтобы посмотреть, как реализован механизм вызова виртуальных функций, мы увидим, рассмотрев ассемблер, что мы сначала получаем один пойнтер, потом получаем второй пойнтер, а потом вызываем функцию по этому адресу. Такой механизм вызова функции – очень "дорогой", поэтому оптимизирующий компилятор делает определённые усилия, чтобы отказаться от использования виртуальных функций там, где возможно, и поставить однозначный вызов той функции, которая должна быть вызвана. В некоторых случаях этого удаётся достичь. Какие самые простые случаи? Например, у вас существует цепочка наследования. Класс B порождается из класса А, класс С порождается из B, и все эти классы имеют свой набор виртуальных функций. А потом в программе используем только объекты класса С. В этом случае можно делать девиртуализацию. Существует также отдельный анализ, называемый "Анализ типов". В данном случае определяется, какие именно типы вы используете при работе вашей программы.
Межпроцедурный анализ позволяет также делать трансформации данных. Когда могут быть выгодны трансформации данных? Например, представьте, что у вас есть некая структура, в которой лежит огромное количество разной информации. Здесь целенаправленно усложнена структура – есть поля двойной точности double x,y,z, между ними вставлены символьные поля. Начинаем внутри программы, внутри процедуры, которая обрабатывает структуры этого типа интенсивно работать с полями x,y,z. Мы последовательно считаем из массива структур каждый последующий элемент и начинаем манипулировать полями x,y,z. Возникает проблема в том, что поскольку x,y,z лежат в памяти на большом удалении друг от друга, то для того, чтобы получить эти данные в кэш, мы должны будем каждый раз делать несколько запросов к шине данных. Если бы они лежали вместе, то за один запрос мы могли бы получить все три значения и закачать в кэш. В нашем же случае, шина будет работать крайне неэффективно. Поэтому возникает идея определить внутри компилятора те поля, которые интенсивно используются внутри приложения и неинтенсивно используются. Эта программа содержит код до того, как была сделана оптимизация, и после. Я завёл переменную окружения PERF. Без этой переменной у меня так, как было раньше, с ней же я оставил в структуре только x,y,z, создал указатель cold и вынес символьные поля наружу и изменил программу. ПО сути, вычисления сами по себе никак не меняются, и меняется только локация данных. Видим небольшое улучшение производительности, благодаря более эффективной работе с подсистемой памяти.
Что является "обратным злом" для этой оптимизации? Это излишние ссылки на память. Если мы угадали и действительно указали только те поля, которые используются, то это улучшит нашу производительность. Если мы не угадали и "холодные" поля используются, то мы имеем "излишние ссылки на память" (pointer chasing). Доступ к данным через несколько ссылок – одна из самых распространённых проблем в С++ коде. Например, вы берёте pointer на какую-либо структуру, она содержит pointer на другую структуру и все эти данные не лежат в кэше (иначе каждый раз вы будете ждать по 200 тактов). Если раньше поступило предложение складывать "холодные данные" в одну структуру, то теперь предлагаю и "горячие данные" объединять в одной структуре.