Оптимизирующий компилятор
То, что мы обсуждали на прошлой лекции, было то, какие факторы влияют на производительность приложения. Нами были выделены и обсуждены более-менее подробно следующие факторы:
- эффективность вычислений. Под эффективностью вычислений я подразумевал, что всё, что можно сделать в компайл тайм, вычислено. Ну и там, где есть возможность вычислить какое-то значение и в дальнейшем его переиспользовать – это все делается. Это эффективность вычислений: не делать лишней работы;
- эффективность работы с памятью, то есть, вся необходимая для вычислений память должна располагаться в подсистеме вот этой вот кэш-памяти;
- правильное предсказание переходов;
- эффективность использования векторных инструкций, то есть, если вы используете векторные инструкции, векторизацию, то, как правило, вы можете серьезно увеличить производительность вашей программы;
- эффективность параллелизации. Следующий шаг – это использовать параллелизм, выполнять вашу программу в несколько потоков. Как вы это будете делать – уже детали, но в принципе, это тоже возможность для улучшения производительности программы;
- уровень инструкционного параллелизма. Можно стараться распределять инструкции таким образом, чтобы рядом находилось большое количество зависимых вычислений. Зачастую, даже программисты, которые это понимают, легко какое-то вычисление просто местами поменять. Компилятор, в принципе, тоже это делает, но мало ли.
Ну и теперь, соответственно, поговорим про компилятор. Может быть, я ошибаюсь, но у нас почему-то делается упор на лексический и семантический анализ и создается такое впечатления, что компилятор — это прежде всего правила языка и лексический, семантический анализ. Но на самом деле, если мы возьмем современные компиляторы, к примеру, наш компилятор, я думаю, что весь front end (собственно, та часть, которая делает лексический и семантический анализ), она, ну, может быть, 5% составляет по сложности, по количеству кода от всей остальной части компилятора, от тех частей, которые оптимизируют, и так далее. Формально можно сказать, что есть front end, который просто делает разбор и строит внутреннее дерево. А остальные 95% компилятора делают различные оптимизации, потому что, допустим, генерация кода – это формально одна большая оптимизация, потому что от нее очень много зависит, от того, как сгенерен код. По сути дела, эффект на производительность огромный. То есть если вы возьмете какую-нибудь простую програ ммку и будете ее компилировать с ОД, запретив все оптимизации, то есть компилятор идет по самой консервативной линии и пытается вообще ничего не делать, просто у него есть какие-то умолчательные наборы, он их делает. И с минимальными опциями – О2, О3 с оптимизирующим компилятором. Там разница вот только эта, а получается зачастую в 10 раз. Вы на практических занятиях, может быть, поэкспериментируете и это увидите.
Конечно, есть еще задача стабильности, чтобы компилятор понимал вашу программу, делал все корректно, чтобы у него не возникали runtime error при компиляции, чтобы он корректно отлавливал все ваши ошибки. Задача номер 1 – она наиболее важна – чтобы компилятор делал корректный код из ваших исходных файлов. Ну, после того, как мы в какой-то момент времени этого добились, задача №2 – это добиться максимально высокой производительности, чтобы ваша программа работала максимально производительно на заданном процессоре. Вторая задача достаточно сложная.
Что я хотел вообще этим слайдом сказать: формально задача оптимизации кода вступает в противоречие с теми установками, которые должны действовать при написании крупных программных продуктов. При написании у вас создался коллектив, у вас должна быть модульность, чтобы были группы и каждая отвечала за какой-то отдельный модуль. Второе: у вас должна существовать возможность отладки и так далее. То есть модульность формально вступает в противоречие с оптимизацией, она вызывает много дополнительных проблем для производительности вашего программного кода, но без нее обойтись практически невозможно. Например, обычная задача: может ли миллион китайцев написать за 1 час программу, которая оценивается в миллион человекочасов? Наверное, нет.
Поэтому компилятор должен уметь бороться и справляться с такими модульными программами, при этом он должен уметь делать качественный быстро исполняемый код, должен предоставлять разработчику унифицированную надежную среду разработки, возможность варьировать уровень отладки и быстродействия, возможность получать высокоэффективный код для разных процессоров.
Соответственно, оптимизирующий компилятор – это сложный программный комплекс с массой различных управляющих переменных, опций для того, чтобы можно было варьировать работу, в зависимости от ее требований к результирующему коду. Большую роль оказывает то, что доказательства допустимости тех или иных оптимизаций зачастую очень сложно, также как и доказательства расчета их выгодности. Во время компиляции отсутствуют представления о типичных входных данных и так далее.
Мой посыл такой: для достижения оптимального результата компилятору необходимо тесное сотрудничество с разработчиком. Или, скажем, разработчику - тесное сотрудничество с компилятором. То есть для того, чтобы достичь наилучшего результата разработчик (если он действительно заинтересован в производительности) должен иметь представление по архитектуре, на которой будет выполняться его приложение, быть знакомым с флагами, которые управляют работой компилятора, иметь представления о базовых техниках улучшения производительности программы (чем мы, собственно, собираемся с вами заниматься и уже занимаемся). Ознакомиться с основными проблемами, вызывающими замедление работы программы (ну, мы уже ознакомились), знать примерные данные, с которыми будет работать программа и уметь пользоваться инструментами для анализа производительности программы. Это требования, на мой взгляд, грамотному разработчику, пишущему быстродействующие программы.
Что предлагает разработчикам наша компания? У нас есть компиляторов С++ и Фортран, они работают формально на Виндоусе, Линуксе и Макоси. Вы знаете, что бывают 32-х битные приложения операционной системы, бывают 64-битные (поддерживают указатели размером 64 бита, то есть больше адресное пространство и так далее). Формально одну и ту же программу можем откомпилировать в 32-битной моде и в 64-битной моде: соответственно, тут есть уровень варьирования. То есть на Виндоус, на Линукс компилятор может создавать программу как 32-битнцю, так и 64-битную.
На Виндоусе мы сотрудничаем тесно с компанией Майкрософт, и наши компиляторы выходят, по сути, как плагины к Visual студии. И на Виндоус, соответственно, декларируется совместимость с Visual студией. Совместимость – это когда вы берете один объектный файл и компилируете Visual студией, второй объектный файл компилируете Интеловским компилятором, после этого их линкуете и все работает. Формально это наша ниша, за которую мы боремся – ниша именно тех людей, которые хотят получить какое-то улучшение производительности, они покупают, допустим, наш компилятор, после этого какие-то наиболее критические файлы пытаются компилировать нашим компилятором. Либо всё. Это как вариант совместимости: вы заходите в проект, который у вас есть на Visual студии, и говорите: а вот эти файлы (помечаете) компилировать интеловским компилятором.
Фортран – это, я думаю, наиболее распространенный в мире интеловский Фортран. Если вы когда-нибудь будете заниматься каким-нибудь серьезным делом, вычислениями, то на Фортране делать более эффективно.
Когда имеет смысле совместимость? Когда у нас есть гигантская и широко распространенная программа на Visual студии, которая, допустим, на Виндоусе пользуется 90% разработчиков. Соответственно, как проще разработчиков подвигнуть к использованию интеловского компилятора? Сказать: "А, ничего, ребята, сложно нет, поставтье крыжик, и у вас будет полная совместимость. И при этом + 10% производительность". Вот это стратегия.
Вот мы в прошлый раз разговаривали о машинах, об архитектуре компилятора. Вот, архитектура компилятора на уровне "есть руль, колеса, двигатель" выглядит примерно так. У вас изначально есть исходные файлы. Сначала исходные файлы обрабатывает так называемый front end (FE) – это та часть компилятора, которая делает семантический и лексический анализ и на основании этого создает некое внутреннее представление. После этого запускается какая-то магическая компонента, которая называется "профилировщик", потом есть скалярные оптимизации или оптимизации с графом потока управления. Существует однопроходный и двухпроходный вариант работы компилятора. В первом случае после этого делаются оптимизации высокого уровня, генератор кода и получается объектный файл (из каждого исходника получается один объектный файл). В случае с однопроходной работой мы последовательно обрабатываем все процедуры, которые есть в вашем исходном файле.
Двухпроходный — это когда после каких-то минимальных оптимизаций внутреннее представление сбрасывается в объектный файл (этот объектный файл с тем совершенно разный, вот лежит объектный код (28.12), в котором можно только чуть-чуть с помощью линковщика связи установить, чтобы получить исполняемый файл. А в другом объектном файле лежит просто какое-то внутреннее представление зашифрованное, упакованное). После этого запускаются межпроцедурные оптимизации, которые делают разные вещи, например, инлайнинг, подстановки вызовов, подстановки тела функции вместо вызова и так далее. После этого делается еще раз скалярная оптимизация, оптимизация высокого уровня и опять работа генератора кода. Это примерная схема компилятора.
Понятно, что front end делает синтаксический и лексический разбор. Он выделяет сначала токены, то есть ищет служебные слова, потом создает какое-то внутреннее дерево информации, которое отражает синтаксическую структуру входной последовательности и так далее.
Когда я рассказывал про оптимизирующий компилятор, я никогда не рассказывал про внутреннее представление. Поскольку народ со страшной силой интересуется, что это такое и хочет хотя бы минимально представлять, что это такое, всегда приходится про это рассказывать.
Внутреннее представление компилятора — это различные таблицы данных, которые связаны друг с другом по контексту. Базовым является лист утверждений. Когда вы рассматриваете вашу программу, как правило, любая операция внутри вашей программы соответствует какому-то утверждению. Есть утверждение-вход в программу, утверждение-присвоение, утверждение-while_do, утверждение-выход. У вас был список каких-то команд, в результате обработки этого списка получилось дерево каких-то утверждений. Утверждения используются для отображение присваиваний, команд управления потоком (if'ы, переходы, вызовы, выходы), дальше есть такие Phi-statements'ы (phi-утверждения) и так далее.
Утверждения могут быть представлены двумя способами: лексическими (то есть, последовательно идущими утверждениями друг за другом. Каждое утверждение имеет предшественника и потомка). Либо они могут быть связаны с помощью графа потока управления: когда выделяются участки непрерывного кода, в котором нет никакого перехода, и объединяются в некоторые базовые блоки, а вот между этими базовыми блоками – грани. То есть базовые блоки являются вершинами графа потока управления, а стрелочками являются разные переходы. То есть из этого базового блока есть переход вот в этот базовый блок при каких-то условиях, они связываются, формально будем говорить, стрелочкой, гранью.
И вот, формально мы можем представить утверждения как вот такую структуру (32.54). Есть ссылка на предыдущее утверждение, на последующее и есть привязка к базовому блоку, то есть частью графа потока управления, которой это утверждение принадлежит.
Большинство оптимизаций, самые простые оптимизации, они базируются на чем? Они проходят все утверждения, которые есть, собирают какую-то информацию, потом проходят, допустим, все утверждения по второму разу и делают какие-то модификации. Поэтому формально в компиляторе масса разных обходчиков, которые позволяют в разном порядке обходить всякие утверждения.
Отдельная переменная не будет утверждением. Помимо утверждений (statements) есть еще выражения (expressions). Выражение представляет собой дерево выражений. Вот, пожалуйста, у нас наверху идет утверждение, утверждение присвоения. Вот здесь, в структуре, есть члены (34.16), например, какие есть члены у присвоения: у него есть левая часть и правая часть, соответственно для присвоения левой части присвоения лежит некое выражение, в правой части присвоения лежит тоже некое выражение. То есть выражение может быть деревом выражений, либо на конце вот этого дерева выражений могут находиться граничные выражения, которые могут быть переменными (то есть переменная-ссылка на область памяти) либо значением. Понятно, что все переменные содержатся в какой-то таблице и у каждой переменной есть масса свойств. На уровне внутреннего представления мы пытаемся все эти свойства тщательно собирать и исследовать эти переменные. То есть там есть имя, информация о классах (если эта переменная член класса), сколько она занимает размер в памяти, выравнивание в памяти (выравнивание – это адрес может, допустим, быть кратным 16-ти, то есть все переменные они в памяти выровнены, они не случайно начинаются с непонятно какой-точки, они, как правило, кратны чему-либо). Есть тип данной переменной. Формально типы могут лежать в отдельной таблице, могут лежать тут же, рядом с переменными (просто они будут иметь атрибут, что это не просто переменная, а переменная, которая описывает тип); размер для массива; указательное описание структуры массива, если что-то об этом массиве известно; указатель на родительскую структуру, если переменная является членом класса или полем структуры; область хранения; область видимости и куча различных атрибутов. То есть является эта переменная объектом объединения, брался ли адрес этой переменной, тип доступа и так далее. То есть, вот еще одна таблица (37.05).
Понятно, что внутри нашего кода есть вызовы функций. Каждая функция лежит в отдельной таблице, и каждый раз, когда у нас идет утверждение- вызов функции, тут же мы по какому-то указателю можем попасть в таблицу, где описаны все свойства функции и, соответственно, посмотреть на свойства этой функции: на имя и так далее. То есть масса атрибутов, которые определяют ее свойства.
То, что я вам сейчас рассказываю, — это верхушка айсберга. Формально внутреннее представление: там куча различных структур, то есть каждая оптимизация вводит свои какие-то оптимизирующие структуры и собирает информацию, которая интересна именно этой оптимизации. При работе с циклами существуют какие-то структуры, которые описывают эти циклы и так далее. То есть то, что я вам рассказываю, это после того, как отработал фронт энд, нам пришли вот такие таблицы, мы их взяли и начинаем их усложнять, на базе одних таблиц генерить более сложные таблицы, которые требуют от нас какой-то оптимизации, мы их нагородили, сделали оптимизацию, удалили и начинаем, например, другие какие-то создавать. Тем не менее, внутреннее представления — это вот эта информация, просто она содержится не в виде программы, с которой работает человек, а в виде каких-то таблиц.
Побочные эффекты. Вам понятие чистой функции знакомо? Есть, например, функция, которая получает какие-то аргументы, но известно, что эта функция не изменяет аргументы, которые в нее пришли. Например, не меняет никаких глобалов и не производит никакой печати. Все, что от нее пришло, — это ее результат. Имеется в виду, функция, которая не имеет побочных эффектов. Эта информация очень важна для оптимизатора кода.
Граф потоков управления — это представление всех путей, которые могут быть в процессе выполнения программы. То есть у нас существуют if'ы, и в каждый момент времени мы можем пойти и начать выполнять одни инструкции, либо другие. А граф потоков управления дает полное представление об этом. Базовым узлом этого представления являются базовые блоки, то есть кусок непрерывного кода без ветвлений, без переходов и меток переходов. Обычно метка перехода начинает блок, а некий переход его завершает. Условные присваивания не считаются, они не делят базовые блоки. Почему-то многие видят похожесть на блок-схему, ну не знаю, по-моему, это немного другая вещь. С помощью блок-схемы вы алгоритм пытаетесь нарисовать ,если у вас ваш алгоритм на каждом if'е содержит стрелочки, - наверное, это будет очень похоже на граф потоков управления. Но если же вы внутри каких-то блоков вашего алгоритма подразумеваете какие-то сложные действия, которые внутри тоже содержат переходы, то, соответственно, это не будет похоже.
Выделим отдельно блок входа: блок, через который все потоки управления входят в граф, то есть это, по сути дела, начало вашей функции. И блок выхода.
Если вам блок-схему напоминает, то, как говорят в конце фильма "все фамилии выбраны случайно и любое совпадение не является предумышленным".
Поскольку я сказал, что скалярные оптимизации — это, в основном, оптимизации, которые подразумевают скалярные либо оптимизации работы с графом потока управления, поэтому нам важно понимать, что изначально, когда мы получили от фронт энда информацию, она имеет лексический порядок. В какой-то момент времени нам надо построить вот эти базовые блоки и граф потока управления. Строится он очень просто.
У фронт энда, естественно, есть правила, и он пытается распознать то, что вы написали: это может быть утверждение (действие, которое что-то делает), либо описание какого-то свойства. Эта работа, соответственно, фронт энда. От фронт энда мы получаем какие-то таблицы, минимальные таблицы я вам пытался рассказать — это утверждение, выражение, имена переменных и так далее. Это базовый уровень, который получается от фронт энда.
Дальше одна из первых операций – это построение этих базовых блоков. То есть в результате у нас появилась новая таблица, в которой мы имеем, допустим, базовый блок 1, базовый блок 2, базовый блок n. Каждый базовый блок имеет ссылку на первое утверждение, на последнее, допустим, на предыдущее и список тех базовых блоков, откуда в этот базовый блок может идти управление. И, соответственно, список базовых блоков, куда управление из этого блока может перейти.
Внутри компилятора есть масса всяких разных обходчиков, которые проходят все утверждения программы уже не в лексическом порядке, а уже в связи с графом потоков управления. Вот, они могут выглядеть вот так (45.14). Для всех блоков и для всех утверждений, которые находятся внутри блока, мы выбираем, допустим, утверждение, которое является присвоением и дальше что-то делаем, анализируем.
Я работал, например, в свое время с фронт эндом Фортрана (не с фронт эндом, а с эндом Фортрана, некое промежуточное звено), который был написан на С++, и в чем была проблема. В том, что там разработчикам приходилось прилагать очень много усилий для того, чтобы уменьшать время компиляции. Потому что правила и возможности языка накладывают большое ограничение на возможность оптимизации. Во-вторых, мы будем немножко разговаривать про проблемы в С++, они в основном связаны с динамическим выделением памяти для объектов.
Теперь мы будем разговаривать про скалярные оптимизации. (47.15) Что такое скалярные оптимизации? Есть такая область знаний как анализ потоков данных. Dataflow-анализ заключается в чем. В каждый момент времени, когда вы, например, какой-то переменной присваиваете ее константу, может существовать использование этой переменной, которой очевидно может приплыть только вот эта константа, и других значений этой переменной не может быть в этом месте вашей программы. Это понимание базируется на анализе графа потока управления. То есть если вы поняли, что вы присвоили переменной константу и единственной в этом использовании значения может быть эта константа, мы заменяем в этом случае переменную на эту константу. Это называется протяжка констант.
То же самое происходит, если вы в одну переменную присваиваете другую переменную. Формально у вас существует некая область памяти, которая связана с переменной, и в эту область памяти вы присваиваете значение, которое лежало в другой области памяти. И вот возникает ситуация: при использовании этой переменной единственное значение, которое там может лежать — это то значение, которое мы взяли из той области памяти. (49.11) В этом случае мы можем взять и убрать эту лишнюю переменную из этого выражения: вместо двух утверждений у=x, z=3+y, у нас останется после этой операции (протяжка копий) только одно утверждение.
Компилятор делает такие вот оптимизации, он анализирует граф потока управлений, делает протяжку констант, протяжку копий.
*Ответ на реплику из зала*: У нас появляется промежуточная область памяти. В одном случае мы сначала скопировали данные из одной области памяти в другую, а потом модифицировали и результат записали в третью ячейку. А во втором случае мы использовали всего две ячейки. То есть формально у нас может получиться так, что вот эта ячейка y по результатам всей нашей деятельности выявится, что она лишняя, она нигде не используется, она не нужна.
Еще одна интересная оптимизация — удаление повторных вычислений. То есть когда вы внутри вашего кода делаете поиск идентичных подвыражений. Найдя такие идентичные подвыражения, вы сохраняете первое вычисление в какую-то времянку, и потом в том месте, где было второе подвыражение, туда вставляете эту времянку, то есть как бы переиспользуете вычисленные данные.
Я вам говорил, что задачу компиляции можно сравнить с задачей движения из точки А в точку В. Физические свойства человека можно связать с мощью процессора, а вот выбор пути — это то, что делает компилятор. И вот, если вдруг вы говорите компилятору: "Я буду за тобой следить, я хочу дебаговскую информацию, я хочу в любой момент получить посмотреть, а что лежит в этой ячейке памяти, в переменной у". Соответственно, компилятор сразу теряет возможность сделать протяжку копий и удалить переменную у. Почему? Потому что вы хотите посмотреть значение у, а он этого у (игрека) хочет убить и выкинуть из программы. То есть ваши интересы тут на него накладывают ограничения, и он перестает это делать.
*Вопрос из зала*: А почему он не может сказать, что константа убита?
*Ответ*: Ну да ,есть такой вариант, если вы включаете кучу оптимизаций, то в некоторых мануалах написано: "вот в этом случае у вас дебаговская опция включена с оптимизациями, имейте в виду, что у вас могут переменные исчезнуть".
Существует оптимизация удаление мертвого кода, она существует в нескольких ипостасях. Это первая ее ипостась, самая простая: удаление кода, который не изменяет выходных данных программы. К мертвому коду относится код, который никогда не выполняется или изменяет только мертвые переменные.
Если вы взяли и в какую-то переменную b сохранили константное значение и после этого эту переменную никогда не используете нигде…
*Вопрос из зала*: А он ее не пытается протянуть?
*Ответ*: А куда ее протягивать? Она нигде не используется! Он ее убьет. Это и есть удаление мертвого кода. Мертвый код зачастую может появляться после других оптимизаций компилятора и поэтому эта оптимизация делается много раз, на каждом этапе.
Ну вот, например, некоторые вещи, которые делаются для оптимизации переходов: удаление излишнего ветвления, протяжка условий. Удаляются блоки кода, которые не могет быть достижимы из-за цепочки условных ветвлений. Понятно, что если вы где-то написали условие if(x>0), то всякие условия if(x>0) внутри этого блока являются излишними. Такие вещи компилятор пытается делать, делает. Я вот недавно буквально смотрел на чудовищную функцию, которую программист написал, в которой было очень много разных дублирующих проверок (ну, это было сделано потому, что человеку так было удобнее писать). Компилятор отлично справился со своей работой, он вычистил все лишние проверки и оставил буквально три проверки, которые реально необходимы, без которых не обойтись. Это оптимизация удаления излишнего ветвления
Отрасль знания о том, как это делается — это анализ потока данных, dataflow-анализ — это техника для сбора информации о возможном наборе значений переменных вычисляемых в различных точках программы. Граф потока управления используется для определения тех частей программы, в которые может быть протянуто некоторое значение, присвоенное переменной.
Граф определения/использования (definition-use-graph)— это некий граф…Что такое граф — это некоторые точки, которые соединены связями. Точками, в данном случае вершинами, является определение и использование. Внутри каждого использования идут ветки от тех определений, которые могут в это использование попасть. (56.40)
Приблизительная теория, которая лежит внутри всего этого дела. Изначально была одна вот такая теорема… Вы сами понимаете, что если мы будем брать базовый блок, то есть часть кода, которая непрерывна внутри графа потока управления, то там все очевидно: там есть какое-то присвоение, и любое использование внутри непрерывного кода без всяких переходов туда предыдущее присвоение и притянется. Вся эта операция data-flow-анализа становится тривиальной. Для того, чтобы это все делать на уровне всей программы, нам нужно построить некий итерационный процесс, который будет обрабатывать эти базовые блоки последовательно. Если мы такой процесс построим, то мы решим задачу протяжки всех этих констант.
*Вопрос из зала*: Если мы провели протяжку констант, протянули, потом начали очищать условия, очистили условия. Обнаруживается, что мы можем еще какие-нибудь константы протянуть. Как мы это, рекурсивно что ли активизируем?
*Ответ*: Да. Ну, наверное, это уже зависит от алгоритма, который конкретно вы используете. В принципе, я думаю, что да. Мы оптимизируем до того момента, пока не исчезнут все константы, которые мы можем протянуть.
*Вопрос*: Может ли получиться так, что если мы в разном порядке будем применять оптимизации, у нас в одном случае соптимизируется лучше?
*Ответ*: Может быть. Это одна из больших проблем компилятора: никогда непонятно, в каком порядке нужно проводить оптимизации .
Формально вводятся несколько наборов: набор переменных которые используются в блоке, но не имеют определений внутри блока; набор определений, которые были сделаны в данном блоке и достигли конца этого блока; набор определений, которые были отменены внутри блока другими определениями и набор всех определений, сделанных в других блоках. Вот такие 4 набора вводятся.
Есть некая теорема. Для того, чтобы понять, какие определения будут использоваться внутри нашего базового блока, нам нужно знать, какие определения этого базово блока достигли. И вот у вас есть итерационный процесс. Мы можем вычислить набор тех определений, которые достигли нашего блока через наборы блоков-предшественников. Вот некая формула.
В чем сложность? У нас существуют, например, циклы. Один базовый блок может иметь сам себя своим предшественником. Но тем не менее, утверждается, что если этот процесс делать постоянно, то в какой-то момент времени у нас все, что нужно, определится и мы достигнем светлого будущего. Сходимость гарантирована. Когда вы полностью все блоки прошли, и ничего не изменилось. Всё, что мы могли, мы посчитали. Проблема всего этого процесса упирается в ресурсы, во время.
*Вопрос*: То есть, любое сворачивание получается конечным?
*Ответ*: Да. И теоретически для самой большой программы, для самого большого количества утверждений вы можете, сделав несколько итераций по базовым блокам этого утверждения, в конце концов определить для каждого блока…
*Вопрос*: А количество итераций?
*Ответ*: Это уже я не знаю. Оно сходится, но какое оно будет – неизвестно.
Все самые первые скалярные оптимизации были основаны на такой технологии, когда делалось много итераций через базовые блоки и на основании базовых блоков предыдущих множество reaches(b) вычислялось для следующего. После того, как процесс завершился, мы уже начинаем для каждого базового блока смотреть : если у нас для одной переменой в reaches только одно достигшее значение, мы можем делать эту скалярную оптимизацию. Сложность возникает в том, что вот этот граф определений и переиспользования может получиться очень большим. Здесь приведет такой пример (1.03.08), когда у нас х определяется в трех разных местах, а потом в разных местах используется. И здесь будет создано 9 дуг. Это говорит о том, что когда мы будем делать эту работу и пытаться сделать константную подтяжку, у нас будут возникать огромные массивы данных, которые мы должны будем создавать для того, чтобы сделать эту работу. Вот отсюда, кстати, и возникла некая форма (?) и она предложила вводить уникальное имя для каждого определения переменной и вводить специальные псевдоприсваивания. Для того, чтобы нам не создавать огромную таблицу, поддерживающую нашу работу, придумали вот эту форму. И у нас получилось, что вместо переменной х — переменная х1, переменная х2, переменная х3 и в том блоке, в котором возникает возможность перехода нескольких значений, мы вводим переменную х4 и показываем в этом списке функцию, которая нам показывает, какие конкретные присвоения могли сюда прийти (1.05.32). То есть вот эта задача снизила количество информации, которую мы должны создавать в процессе работы (ресурсы для отслеживания цепочек стали меньше), но при этом нам надо делать лишнюю работу: мы здесь увидели х4, мы должны пойти сюда (1.06.01) и отсюда пойти и посмотреть на эти варианты. Тем не менее, сейчас очень модна вот эта SSA-форма. В соответствии с этой формой мы создаем некий набор утверждений, по одному внутреннему представлению строим другое внутреннее представление. Интересно, мы имели какой-то набор утверждений, мы добавили пси-функцию (это специально утверждение, которое есть и в лексическом порядке утверждений и в графе). И у нас получилось в результате что: мы внутрь этой таблицы утверждений зашили def-use цепочки. Граф тот же самый, только в него добавляются новые утверждения. Информация о присвоении и использовании вмонируется в наши утверждения. Интересная техника получилась.
Если нам удается построить эту форму, у нас все становится тривиально. У нас все упрощается, как в случае, когда мы пытались data flow анализ делать для одного базового блока. У нас есть использование, и в этом случае любое использование ссылается на конкретное присвоение. Каждое последующее присвоение создает, по сути, новую переменную.
Параметры у этой пси-функции — соответствующие варианты. Мы создаем третий вариант переменной у, которая может получить значение либо из второго варианта переменной у, либо из первого варианта переменной у. Параметров может быть сколько угодно. Просто есть список некий, откуда берутся значения.
откуда берутся значения. Здесь при работе с графом потоков управления возникает хитрое понятие, которое, может, вы когда-нибудь услышите, называется "доминатор". Это узел, который доминирует другой узел. То есть поток управления от одного у другому узлу обязательно проходит через этот блок, то есть вы не можете решить вопрос, не обратившись к этому блоку. Здесь приведен пример, и вот эти доминаторы как раз появляются для того, чтобы построить эту SSA-формулу. Второе понятие — граница доминирования. Через эти понятия мы начинаем понимать, в какие места мы должны вставлять эти пси-функции.
Вы можете посмотреть, каким образом через эти термины, связанные с особенностями графа потока управления, решается задача построения SSA-формы. Посмотрите определение, которое я здесь привел, здесь есть примеры того, как определяются доминаторы, границы области доминирования, как на основании этих терминов строится SSA-форма. Естественно, когда я эту информацию сюда включал, я не собирался из вас делать гугу, которые будут где-то для какого-то вновь разрабатываемого языка реализовывать построение SSA-формы. Я хотел дать вам набор неких закладок, чтобы вы понимали, что есть такие проблемы, они таким образом друг с другом связаны, возникает концепция SSA-формы, эта концепция решается с введением в исследование терминов доминаторы, границы доминирования. Опять же, строится некий итерационный процесс, который позволяет для каждого базового блока определять границу доминирования и вставлять пси-функции, алгоритм здесь приведен (1.12.40), и вы можете на досуге это все решить.
Оптимизации, использующие SSA-форму все делают красиво и элегантно, работают по простому алгоритму. Чтобы быстро и красиво сделать скалярные оптимизации, нам нужно построить SSA-форму для каждой программы.
Если мы видим вот такую функцию a_next=f(c,c), мы делаем протяжку. Когда у нас есть пси-функция, и в ней вариант х1, х2, то это использование х1. Если мы в это использование смогли протянуть константу, то у нас может возникнуть ситуация, когда у нас переменная х как пси-функция от двух констант одинаковых: от константы с и с. В этом случае мы дальше эту константу с протягиваем. То же самое касается продвижения копий. Если мы видим, что у нас версии совпадают, мы можем это пси-присвоение убрать и сделать протяжку.
Я могу, работая с компилятором, компилируя какой-то файл, задать специальные ключики и получить внутреннее представление в С-подобном языке, какое оно было до какой-то оптимизации и какое оно стало после оптимизации. У каждой оптимизации дополнительно есть какие-то свои дампы, то есть та информация, которую она может выводить с определенными ключами, чтобы программисты могли исследовать.