Спонсор: Intel
Новосибирский Государственный Университет
Опубликован: 18.02.2011 | Доступ: свободный | Студентов: 919 / 281 | Оценка: 4.27 / 3.95 | Длительность: 13:14:00
Специальности: Программист
Лекция 9:

Статическое и динамическое профилирование

Аннотация: Компиляция приложения, снабженного механизмом сбора информации для динамического профилирования. Плюсы и минусы использования динамической памяти. Способы улучшения работы с динамической памятью. Кодогенератор, задачи и особенности, возникающие в процессе генерации кода. Планирование инструкций.
Ключевые слова: профилировщик, компилятор, оптимизация, подвыражение, цикла, ПО, вывод, инвариант, if, адрес, инструкция, вероятность, кэш, расходы, функция, тело функции, вес, анализ, вычисление, входные данные, программа, перечисление, переменная, выражение, приложение, запуск, файл, информация, представление, производительность, место, память, массив, время компиляции, динамическое выделение памяти, new, DELETE, free, кэширование, объект, кэширование данных, сборка, менеджер, алгоритм, альтернативные, список, линейный массив, процессор, байт, ассемблер, ссылка, физическая память, элемент списка, STL, конструктор, копирование, Си, компонент, кодогенерация, синтаксически корректный, условные присваивания, значение, стек, улучшение, регистр, операции, запись, вытеснение, Register, граф, множество вершин, Раскраска, пользователь, вариант использования, буфер, исполняемый код, надежность, корректность, отладка, слово, ALL, Full, опция, help, fast, SSE2, SSE3, SSE, atom, команда, точность, поле

Смотреть лекцию на: ИНТУИТ | youtube.com

Если проблемы с видео, нажмите выше ссылку youtube

Get Adobe Flash Player

Сегодня мы поговорим про функцию профилировщика и про так называемые Профайл гайд оптимизации. Какие же вопросы решает профилировщик? Когда мы говорили про оптимизации, я говорил, что бывает такая проблема, когда компилятор пытается что-то заоптимизировать, а получается все хуже. Отчего такие ситуации возникают? Давайте посмотрим на самом простом примере. Есть такая оптимизация — вынос общих подвыражений. Когда у нас есть подвыражение, которое считается не один раз, то мы говорим: "А хорошо было бы это подвыражение посчитать, сохранить его во времянку и потом его повторно не пересчитывать, а просто переиспользовать". Вот у нас есть простая ситуация: одно из подвыражений стоит перед циклом, а второе стоит внутри цикла. При этом цикл практически никогда не выполняется. Будет ли оптимизация по иллиминировании этого выражения выгодна? В обычном случае мы его всегда считаем один раз… Когда мы его оптимизируем, мы создаем дополнительную времянку, выделяем дополнительную ячейку для хранения этой информации. И это тоже работа. Это оказывает отрицательный эффект, потому что мы увеличиваем размер стека, который необходим нашей программе. И в результате мы получаем, что хотели как лучше, а получаем хуже. Реально от нашей оптимизации нет никакой выгоды, а может даже быть небольшой вред. Отсюда вывод: невыгодно сохранять общее подвыражение во временной переменной, если часто используется только одно подвыражение.

То же самое касается выноса инвариантов. У нас может быть инвариант в цикле, но этот инвариант находится под каким-то if'ом, под каким-то условием. И он практически никогда не вычисляется. Мы его можем вычислить, завести для него времянку, вынести во времянку из цикла. Но будет ли эта оптимизация полезна — не понятно. Мы говорили о том, что когда мы вызываем те или иные функции, управление передается с одного адреса на другой. И это тоже может вызывать задержки, потому что мы вызвали функцию, а адрес, с которой начинается ее инструкция, не находится в КЭШе. Мы тратим время на то, чтобы эти инструкции подгружать из КЭШа. Поэтому хорошо было бы собрать вместе все инструкции, которые у нас находятся в интенсивном использовании, и положить их рядом так, чтобы вероятность того, что мы будем переиспользовать какие-то адреса в памяти, которые мы закачали в кэш, увеличилась. Опять же, появляется оценочная категория "часто используемые инструкции/редко используемые инструкции".

То же самое можно сказать про инструкцию постановка инлайнинг. Мы инлайним функцию, полагая, что благодаря тому, что мы ее вставляем, у нас снижаются расходы на вызов, протягиваются какие-то переменные и т.д., а будет ли выгоден инлайнинг, если у нас эта функция вызывается крайне редко? Она тоже стоит в графе потоков управления под каким-то условием, условие практически никогда не выполняется, функция практически никогда не вызывается. Мы ее инлайним, увеличиваем тело функции, а эффекта положительного от этого нет. Эффект может быть только отрицательным. Существует масса вещей, которые хотелось бы оценить.

Мы разговаривали про структуры, и я вам говорил, что может получиться замечательный эффект, если мы будем часто используемые поля структуры располагать в этой структуре вместе. Компилятор мог бы многое выиграть, если бы он для одних функций, которые реально горячие и много по времени выполняются, тратил бы все свои усилия, чтобы их соптимизировать, а те функции, которые не важны, которые праткически никогда не выполняются, минимальные какие-нибудь оптимизации для них делал. При использовании таких знаний, мы получили бы выигрыш даже в колтайме. Для больших проектов было бы здорово.

Задача профилировщика — попытаться собрать какие-то оценки. Профилировщик вычисляет вероятность условных переходов, вычисляет некий вес базовых блоков, насколько часто базовый блок, по мнению профилировщика, бывает в работе. Все это профилировщик делает на основании исходного кода. Когда подключается межпроцедурный анализ, статистический профилировщик может попытаться рассчитать условный вес функций на основе анализа кулграфа. Это делается в зеленых и желтых попугаях, то есть они чисто приблизительные. Анализ исходного кода не может обеспечить точное вычисление весовых характеристик. В общем случае неизвестны входные данные исполняемой программы, время вычислений ограничено. Поскольку компилятор не знает входных данных и обычной информации у него мало. Кроме того, это расчет тщательный и вдумчивый, он потребовал бы массу времени и сложных алгоритмов, что не всегда приемлемо. Статический профилировщик поступает с таким же уровнем точности, как в анекдоте про блондинку: 50 %. Я сталкивался с интересным результатом работы статического профилировщика: есть шахматная программа, и она пытается оценить вероятность того, что фигура, расположенная на доске, является пешкой. Каким образом это делается? Реально фигура описана в каком-то перечислении. Компилятор заходит в это перечисление и смотрит, сколько вообще существует фигур. Фигур восемь. Отсюда вероятность того, что фигура пешка, одна восьмая. А то, что их на доске 8 он не знает, потому что есть входные данные программы. Этот пример показывает проблемы, с которыми сталкивается профилировщик. Это даже хороший случай, потому что фигура описана в перечислении. А если у вас какая-то переменная, и идет проверка, больше она 5 или меньше. Как оценить эту вероятность?

Вот такие задачи решает статистический профилировщик. Он анализирует ваш код и пытается расставить веса кодов. Есть закономерности, которые более или менее логичны. Если у вас какая-то большая вложенная цепочка проверок, условных переходов, то у блока, который стоит в самом сердце этих проверок, вероятность этих проверок достаточно низкая. Такие соображения статистический профилировщик пытается выполнить, чтобы разбросать по базовым блокам их вес, по ифам - вероятность того или иного перехода.

Есть встроенная функция builtin_expect , которая предназначена передать информацию о вероятности ветвления. То есть у вас была проверка xa=0, вы можете переписать ее вот так. Если builtin_expect, после этого вы пишете то выражение, которое стояло там изначально, и после этого вы пишете вес того, что оно истинно. Вы можете написать 1, и это будет означать, что вот это if всегда выполняется. Если вы напишите 0, то вы подразумеваете, что оно практически никогда не бывает истинным. И компилятор эту информацию учтет и пересчитает вероятности и веса разных базовых блоков.

Помимо статистического профилировщика такую же роль может играть динамический профилировщик. Динамический профилировщик более точный инструмент, потому что он собирает статистику, которая используется при запуске вашего приложения с какими-то разумными входными данными. Динамический профилировщик работает в следующем режиме: если вы хотите использовать динамический профилировщик, вы собираете ваше приложение со специальной опцией Qprof-gen. С этой опцией компилятор ваше приложение инструментирует, то есть он внутрь вставляет функции, которые позволяют собирать статистик о различных событиях при выполнении вашей программы. После этого вы запускаете ваше приложение с каким-то набором входных данных. Первый запуск создает файл статистики, каждый последующий запуск в этот файл статистик добавляет информацию. Это вероятность того или иного перехода, вес базовых блоков… та же самая информация, которую динамический профилировщик пытается собрать, анализируя ваш исходный код. После того, как вы эти статистики собрали, снова запускаете компилятор, чтобы собрать ваше приложение, но уже запускаете его с опцией Profuse. Тогда вместо статистического профилировщика будет запускаться динамический профилировщик, который будет читать статистическую информацию, на основании ее расставлять веса. И в результате код будет лучше оптимизирован. Будут лучше усчитаны те входные данные, которые вы подавали вашему приложению при пробных прогонах.

Почему эти может хорошо сказаться? Одна из больших проблем для всяких явлистик - это понимание того, сколько итераций в том или ином цикле. Компилятор этого не знает, ему входных данных часто не хватает. А после того, как вы используете динамический профилировщик, у компилятора уже будет представление, сколько итераций в цикле. Если вы написали расчетную программу, которая работает с матрицами, вы можете оптимизировать под 2 вида работы: работу с большими матрицами, подав при тестовых прогонах большие матрицы, можете оптимизировать ту же самую программу для работы с маленькими матрицами. В одном случае он сделает параллелизацию, потому что увидит, что внутри циклов делается много работы. Во втором случае циклы будут маленькие, итераций будет мало, параллелизацию не сделает. Третий вариант – в компиляторе стоят проверки времени выполнения, и вот этот фактор учитывается. Можете проверить, если вы поиспользуете эту технологию оптимизации, будет ли у вас выигрыш. Запустите сначала тот вариант, который вы уже создали, потом создайте новый вариант с использованием оптимизации. И посмотрите, будет ли разница в производительности.

Некоторые оптимизации выполняются внутри компилятора только при наличии информации от динамического профилировщика. Представьте себе, что есть оптимизации, которые могут как эффективно улучшить, так и ухудшить производительность. Мы можем решить, что применять их можно только тогда, когда мы четко себе представляем, что их применение улучшит ситуацию. И разработчики компилятора могут принять решение, что давайте эту оптимизацию использовать только тогда, когда у нас есть представления, какого размера цикл, который нам дает Профайер гайд оптимизация. Если вдруг что-то стало работать медленнее, мы скажем пользователю: "Извините, ребята, вы подавали нерепрезентативные данные, когда тестировали ваш компилятор". Репрезентативные – это представительные.

Есть такая оптимизация как перестановка базовых блоков. Мы смотрим на оценки, которые нам дал динамический профилировщик, и видим, что у нас там есть блоки, которые практически никогда не вызываются. Мы можем взять и физически вынести в какое-то специальное место, назовем его "холодным" местом. Туда, куда управление практически никогда не будет передаваться. Благодаря этому что может получиться? Если у нас цикл под каким-то редко выполняемым ифом делает массивную операцию, мы редко выполняемый блок вынесли. В результате блок, горячая часть которого стала более компактна, позволил улучшить производительность. Компилятор пытается группировать часто используемые функции. И даже есть оптимизация, когда холодные блоки выносятся в отдельную секцию, подальше от всего остального кода. Очень большую роль, особенно для С и С++ выражений играет технология динамического выделения памяти. Объекты и массивы, для них может по-разному выделяться память. Мы можем сказать, что у нас есть некий статический массив размерности вот такой, а можем попытаться выделять для него память в процессе выполнения. То же самое касается создания новых классов и объектов. Типичные ситуации, кода выделение памяти необходимо, когда вам необходимо создать большой массив, размер которого неизвестен во время компиляции. Этот массив может быть просто очень большим для того, чтобы расположить его на стеке. Объекты должны создаваться во время работы программы, но количество необходимых объектов неизвестно. Это типичные ситуации, когда необходимо динамическое выделение памяти.

Нужно заметить, что в случае, когда нам необходимо выделить память, когда мы используем утилиту new и delete, mafloc и free, мы обращаемся к менеджеру памяти. Менеджер памяти – это библиотека, которая с вашей программой никак не связана: она получает от вас запросы, она предоставляет вам запрашиваемую информацию. Она может выделять вам память, может освобождать ее для дальнейшего использования.

Неудобства динамического выделения памяти:

  • выделение и освобождение памяти имеет свою цену: в одном случае, когда мы статически выделяем память, это делается в момент старта программ. Там выделяется память на стек, и это может быть дешевле. А в случае, когда мы обращаемся к менеджеру памяти, он смотрит, сколько вам нужно памяти. Он должен принять решение и найти подходящий блок, в котором вам эту память предоставит. Когда вы говорите free, освобождаете эту память. То же самое, менеджер должен быстро взять в своих таблицах, которые он ведет для того, чтобы отображать текущее состояние памяти, пометить, что вот в этом блоке память освободилась. Должен попытаться сделать какие-то дополнительные действия, то есть понять, свободна ли соседняя с ним память отразить ситуацию, что здесь не одна ячейка памяти освободилась, она слилась с соседними свободными ячейками, и стало памяти свободной больше.
  • при динамическом выделении памяти она становится быстро фрагментируемой: вы запросили достаточно большой блок, вам менеджер памяти этот блок нашел, выделил. Вы работаете, в том или в другом месте выделяется память, освобождается. В результате у вас вся память изначально была пустой, а после этого она очень сильно фрагментирована. Память перемешена, и больших блоков в ней может не оказаться.

Поскольку выделенная память становится фрагментированной, зачастую это делает неэффективным кэширование памяти. Мы говорили о том, что хорошая ситуация, когда мы берем один объект, а поскольку у нас копируется из памяти в кэш целая линейка 64 байта, то существует вероятность, что вместе с маленькой ячейкой, которую вы запросили, нам попадется память, которая пригодится на следующей итерации. Когда память сильно фрагментирована, то вероятность такого счастливого совпадения сильно снижается. Это делает неэффективным кэширование данных. Еще есть такая проблема: если нам необходимо расширить уже существующее выделение памяти, иногда удается увеличить размер блока, а если его нет, то приходится выделять в другом месте этот блок. Информацию, которая содержалась в старом, копировать в новое место. Необходима сборка мусора и т.д.

Менеджер памяти — это некая технология, алгоритм. Существуют различные менеджеры памяти, которые пытаются различным образом оптимизировать процесс выделения памяти. Варианта два: накладные расходы на поддержку в правильном состоянии информации о том, как менеджер выделяет память. А второе, насколько хорошо то, как была выделена память, помогает быстро работать приложению.

Существуют альтернативные менеджеры памяти. Я привел тут Смартхип, он говорит, я, типа, хорошо помогаю работать с С++ приложением. Одним из важных факторов производительности в С++ является компактность размещения в памяти совместно используемых объектов, объединенных в различные связные списки. Связные списки - это одна из больших технологий, которая используется в С++. Суть многих реальных программ, которые мы анализируем, заключается в том, что создается какой-то связный список различных объектов. Потом эти объекты обрабатываются, существуют какие-то проходчики через эти объекты.

Связный список менее эффективен, чем линейный массив, потому что каждый объект аллоцируется отдельно, выделение и освобождение объекта имеет свою цену, и объекты в памяти в связном цикле будут храниться непоследовательно. При обходе связного цикла вероятность попадания в кэш снижается. Поэтому процессорная предвыборка данных неэффективна. Когда мы идем по линейному массиву, процессор может заметить, что мы движемся с определенным шагом, и начать подгружать в кэш необходимые нам адреса. Если мы идем по списку, то никакой закономерности нет, и механизм предвыборки не может нам ничем помочь. На каких интересных вещах может быть основана выгода на различных динамических менеджерах памяти? Если вы запрашиваете память какого-то размера, то менеджер памяти для себя принимает такое решение: я выделю достаточно большую область памяти, и в ней я буду выделять для пользователя блоки памяти размером от 1 до 8, в другой области я буду выделять блоки памяти размером от 8 до 16…В результате может случиться, что различные менеджеры памяти отличаются только этими факторами.

Например, у того же Смартхип это правило очень плотное. Он разные запросы с разным количеством байт каждый раз выделяет в новом блоке. У него может возникнуть ситуация, что каждый раз, когда вы пытаетесь аллоцировать объект определенного класса, который у вас завязан в цепочку, он будет выделять объект именно в этом куске памяти. Благодаря этому улучшается вероятность, что все элементы рано или поздно окажутся в КЭШе. И какие-то элементы попадут туда случайно, потому что были запрошены их соседи. Улучшения при работе с динамическим менеджером памяти могут быть существенными. Мы много раз проводили эксперимент: включаешь один менеджер памяти – результат один, включаешь другой – результат на 15 % лучше. Что можно сделать, если вы хотите понять, из-за чего у вас такие чудеса случаются. Собрать промахи, увидеть, что у вас ассемблер и там, и там одинаковый. В одном варианте программы ассемблер одинаковый и ничем не отличается от другого варианта программы, но при этом у вас на одном и том же ассемблере разное количество промахов по памяти. Это эффект от использования различных динамических менеджеров памяти.

Как выглядит связный список. У объекта, как правило, есть ссылка на следующий объект цепочки и на предыдущий объект цепочки. Несколько объектов завязаны в такой список. В памяти они могут располагаться совершенно случайным образом. Если мы виртуальную память наложим на память процессора Нихалем или Сандибридж, у которого технология Нума и разная физическая память привязана к разным процессорам, то могут получиться чудесные вещи. Когда один элемент списка лежит в памяти, физически привязанной к первому процессору, второй элемент лежит в памяти, физически привязанной к другому процессору и т.д. То есть из-за чего возникают проблемы. Пример показывает технологию контейнера: вместо того, чтобы каждый раз с помощью мелока выделять память для нового элемента, мы можем нашими программными средствами выделить большую память для хранения 1000 элементов данного типа. А потом при запросе памяти для нового элемента, из этого куска памяти выделять новые адреса. То есть мы берем на себя работу менеджера памяти. Мы знаем особенности нашей программы, знаем, для чего мы запрашиваем память. Поэтому мы можем сделать работу лучше, чем менеджер памяти. Пример показывает, что в одном случае он отработал практически секунду, а в другом – 0,8 сек. За счет того, что мы поменяли выделение памяти, мы получили выигрыш 20 % производительности.

Какие существуют методы улучшения работы с динамически аллоцированной памятью:

  • через использование контейнеров. Создание и использование контейнеров это один из примеров эффективного использования шаблонов. Есть распространенный набор контейнеров, которые сейчас поставляются современными С++ компиляторами.

Минусы:

  • эта библиотека разрабатывалась для гибкости использования, а вопросы производительности имели низкий приоритет, поэтому там основная проблема в том, что делается какое-то выделение памяти под какое-то количество объектов, это количество объектов не очень большое.Тот момент, когда нам необходимо хранить большее количество объектов, мы выделяем новый объем памяти в 2 раза больше и делаем копирование старых данных в эту новую область памяти. В этом месте производительность приложения очень сильно падает. Поэтому при работе с этими контейнерами нужно это осознавать

Также еще один популярный метод – метод пула в памяти. Агнер говорит, что при копирования памяти из старого блока в новый в библиотеке STL используется конструктор копирования. То есть мы не всю информацию копируем единым блоком, а используем поэлементное копирование. В методе пулов при расширении места в памяти можно использовать эм си пи ай (MCPI), то есть всю память копировать, и это несколько эффективно. Поэтому все проблемы про динамическое выделение памяти очень актуальны. Поэтому при компиляторах есть специальные библиотеки поддержки пулов. Мы, выделяя новый объект, говорим создать новый объект вот в этом пуле. И он вложится в какую-то конкретную область памяти, чтобы добиться компактности для выделения памяти. Я думаю, что эта технология пулов, либо контейнеров, должна возникнуть в любом С++ приложении, если производительность вас действительно интересует.

После всех наших исследований мы дошли до одной из самых важных компонент: генератор кода.

Преимущества кодогенератора по отношению к тем компонентам, которые идут до него. До кодогенератора мы рассматривали внутренне представление, которое имеет промежуточное положение между кодом разработчика и машинными командами. На уровне кодогенерации мы спускаемся на язык машинных команд. Наше представление трансформируется и очень связано с конкретным процессором, с конкретными процессорными инструкциями. И там уже можно делать массу вещей процессорно специфичных. Кодогенератор сначала конвертирует синтаксически корректную программу в последовательность инструкций, которые могут выполняться на машине. Начиная с какого-то процессора, у нас появились условные присваивания. То есть вместо того, чтобы писать простой иф, условие такое-то, переменной b присвоить значение 1, мы создаем условный оператор, который заменяет зависимость по графу потоков управления на зависимость по данным, и он эффективно улучшает все, поскольку в данном случае у нас не может возникнуть проблем из-за неугадывания направления переходов.

Помимо этого, кодогенератор подставляет тела простейших интринсиков, выравнивает базовые блоки в памяти, то есть он уже имеет представление о том, сколько места в памяти занимает та или иная инструкция, поэтому он может посчитать, сколько памяти необходимо для инструкции какого базового блока. Иногда есть ситуации, когда выгодно, чтобы базовый блок начинался с какого-то конкретного адреса, либо был не очень маленьким, и т.д.

Кодогенератор занимается подготовкой вызовов процедур, то есть в зависимости от того, каким образом у нас реализуется вызов процедуры, он либо на стек складывает параметры, либо передает их через какие-то регистры, и т.д. То же самое для вызываемой процедуры: выделяет места на стеке для локальных переменных и т.д.

Помимо этого, он осуществляет планирование инструкций, то есть существует зависимость от того, насколько у нас много зависимых инструкций подается на процессор. Если их много, то могут возникнуть задержки на процессоре. Если мы зависимые инструкции попытаемся перемешать в коде с другими инструкциями, то возможно улучшение процесса. То же самое касается исполняемых устройств. У нас несколько исполняемых устройств с различными свойствами, поэтому нам было бы хорошо, если бы в блоке, откуда процессор выбирает себе инструкции для исполнения, были инструкции, которые эффективно легли бы на все эти блоки. Кодогенератор делает планирование переходов, учитывает задержки при обращении к памяти, важная работа — это распределение регистров, и вычисление дистанций для переходов.

Одна из базовых задач кодогенератора — распределение регистров. У нас есть какое-то множество переменных, с которыми мы работаем, и нам нужно понять, каким образом мы с ними будем работать? В какой момент времени нужно конкретную ячейку из памяти загрузить в какой-то регистр, чтобы выполнить с ней какую-то операцию, как поступить с ней дальше: вернуть обратно на стек или оставить в этом регистре для последующего переиспользования. Все эти операции делает кодогенератор. Чтобы сделать все качественно, ему нужно понимать, какова вероятность того или иного перехода. Даже кодогенератору нужны данные профилировщика, по большому счету, данные о переходах и т.д.

Поскольку обычно количество переменных в программе значительно превосходит количество доступных физических регистров, то идет постоянное возвращение регистров, запись регистров на стек (ну, если это локальные регистры), запись регистров в память. Мы такую проблему обсуждали, она называлась "вытеснение регистров". То есть, если переменных очень много и они постоянно все нужны, у нас возникает сильный трафик между памятью и регистрами.

Один из популярных методов, который используется в компиляторе, называется метод раскраски графов несовместимости (register coloring). Будем определять распределение переменных следующим образом: определим область жизни переменных, то есть тот промежуток, условно говоря, времен, когда эта переменная нужна, программный регион, в котором переменная используется. И присвоим этой переменной некое уникальное имя. Мы создаем граф, мы нарисовали множество вершин. После этого мы строим граф несовместимости. Если время жизни каких-то переменных пересекается, то мы эти переменные соединяем гранью, соответствующие вершины соединяются дугой. Известно, что у нас количество регистров – константа, мы берем количество цветов, которые соответствуют количеству регистров. Если мы каким-то цветом раскрасили какую-то вершину, то другие вершины, с которыми она соединена гранями, должны быть раскрашены другим цветом. Если нам удастся раскрасить так, чтобы вершины, соединенные гранями, были раскрашены разными цветами, мы выполним работу распределения переменных по регистру. Отсюда такое красивое название- раскраска графов. Если у нас не получилось так сделать, мы можем одну вершину разбить на две, сократить время жизни переменной: вместо одного интервала непрерывной жизни мы создаем два интервала. Это значит, что где-то в середине мы эту переменную сохраним на стек, а потом со стека обратно запишем. Раздвоив вершину мы опять пытаемся выполнить эту задачу, и так до того момента, пока не получится раскрасить все.

Каждая переменная, которую мы часто используем, должна лежать на каком-то регистре (если она нам реально нужна), соответственно, если она соединена с другой переменной, они должны одновременно находиться в памяти, они должны быть раскрашены разными цветами, потому что они лежать в одном регистре не могут. Это физический смысл этого алгоритма.

Наибольших успех производительности будет достигнут, если мы будем хранить в регистрах часто используемые данные. Понятно, что во время компиляции не всегда удается оценить, какие данные будут наиболее часто использоваться, какие – наименее, потому что информации не хватает. Информация, собираемая профилировщиками, крайне необходима в этом случае. По идее, после того, как все переходы будут иметь статистику, эта работа будет более успешна.

Иногда существуют ситуации, когда очень много деталей определяются различными наборами данных, а вы не можете подобрать репрезентативные данные: сами их не знаете, например. Например, вы готовите какой-то вычислитель, а какие реально задачи пользователь будет на этой вычислительной машине гонять, вы себе слабо представляете.

Те же самые зависимости в кодогенераторе могут быть использованы по-другому. При кодогенерации возникает другой вариант использования зависимостей — для определении возможностей переиспользования данный при вычислениях. Это позволяет избежать ненужных загрузок из памяти и сохранения в память. Допустим, если у нас есть цикловая оптимизация, то имеет смысл сохранить в регистре A(I+1) для того, чтобы на последующей итерации не загружать этот регистр из памяти. У кодогенератора свои задачи и свои сложности.

Про планировании инструкций несколько дополнений. Планирование инструкция — это компьютерная оптимизация, которая используется для улучшения уровня инструкционного параллелизма. Оптимизация обычно осуществляется за счет изменения порядка инструкций для уменьшения задержек процессорного конвейера.

Процессор содержит собственных механизм для планирования инструкций и распределения их по исполняющим устройствам. Этот механизм предусматривает упреждающий просмотр поступающих инструкций, но он может быть недостаточно эффективным из-за того, что просматривается только некое окно инструкции. Некий буфер. То, что ледит за пределами этого окна – кодогенератор может помочь. Например, размер буфера 60 инструкций, внутри 60-ти инструкций не имеет смысла заниматься планировкой, потому что все равно процессор будет сам все это выбирать. А если вы будете планировать инструкции в большем объеме, то это может помочь процессору.

Инструкции могут переставляться в соответствии со следующими соображениями:

  1. Вынос чтения из памяти как можно дальше от использования результата чтения.
  2. Смешение инструкций, использующих разные исполняющие устройства процессора.
  3. Сближение инструкций, использующих одну и ту же переменную для упрощения выделения регистра

Очень многое зависит от качества кодогенератора.

Вот здесь (1.01.33) я привожу простой пример: процессорно-аргитектурная оптимизация (использование условного присвоения). С помощью условного присвоения зависимость по исполнению заменяется на зависимость по данным. Как мы можем проверить: мы можем взять одну и ту же программу скомпилировать для разных процессоров. Одну для процессора, в которой этой инструкции нет, во втором случае мы заказываем исполняемый код для процессора, на котором данная инструкция уже реализована, и сморим, что получится. То есть, взяли эту программу и в одном случае скоординировали ее с опцией –esp, а в другом с неким умолчательным процессором. В результате мы получили ассемблер для первого случая и увидели, что там появилась инструкция cmovne, то есть, процессор ее использовал. Во втором случае этой инструкции нет. Простой пример показывает, что кодогенератор использует полезные дополнительные инструкции, которые возникают при развитии процессора.

На кодогенератор мы повлиять практически не можем.

Целью нашей встречи было рассказать о каких-то техниках, которые можно использовать для оптимизации приложений, о техниках, которые компилятор использует для оптимизации приложений, о тез трудностях, которые компилятору мешают сделать все работу за вас и т.д.

Вы понимаете, что оптимизация – вещь затратная, она требует ресурсов, она что-то меняте, в принципе, она может внести в ваш код какие-то ошибки. Поскольку приложение и алгоритм сложные, то не исключено, что какая-то оптимизация будет выполнена неверно и породит ошибки. Поэтому нужно понимать, что всегда существует золотая середина, которую программист должен сам определить. Если вы хотите, чтобы ваше приложения минимально зависело от компилятора, вы можете сделать ставку на отладку и на надежность. Вы можете сказать ,что никакие оптимизации компиляторные я видеть не хочу в своей программе, я хочу, чтобы компилятор делал только то, что я сам написал, делал максимально консервативно. И пусть это будет немного медленнее, но это мое желание. Или вы можете, например, сказать, что размер кода моей программы был как можно меньше. Мы запрещаем всякий инлайнинг, запрещаем все оптимизации, которые увеличивают размер кода, разрешаем компилятору делать только те оптимизации ,которые на размере кода никак не сказываются. Мы хотим, чтобы размер кода нашей программы был как можно меньше.

Вы можете поставить на первое место производительность программы, вы говорите: "Хочу, чтобы компилятор компилировал хоть несколько дней, но чтобы в результате я получил 5-10% производительности". Начинаете включать самые тяжелые опции оптимизации, вы имеете некое неудобство, поскольку вы тратите много времени на компиляцию, но, тем не менее, программа работает чуть-чуть быстрее. Либо вы говорит: "Для меня неважно, с какой скоростью будет моя программы работать, мне важно, чтобы мне было комфортно сейчас: нажимаю кнопку, секунда, все скомпилировалось, я иду дальше". То есть, существуют различные варианты выбора.

Пошаговый алгоритм отладки и улучшения производительности программа, наверное, может выглядеть так: на первом шаге вы реализовали вашу программу, написали алгоритм, вам важно проверить корректность. Если она у вас не ломается, возвращает нужный результат – замечательно. Если она у вас ломается, значит, нужна отладка. В компиляторе есть специальные опции, которые позволяют создать программы с отладочной информацией, после этого ее можно будет в дебагере исследовать. Наиболее понятное слово – дебаг и слово. Слово может быть all, full, minimal, none, то есть вы можете сказать, всю дебаговскую информацию собрать, полную, минимальную и так далее. Опция управляет уровнем сбора дебаговой информации. Надо много практиковаться в дебагере, чтобы знать тонкие отличия. В большинстве случаев умолчательной информации вам хватит.

Вы написали алгоритм. Чтобы проверить корректность, убедиться, что все хорошо, вы включаете опцию –Od, не даете компилятору делать никаких оптимизаций. Ваша задача убедиться, что ваш конкретный код работает нормально, мало ли, что сделает компилятор? Вдруг он в ваш код по какой-то причине внесет ошибку. С опцией Od убедились, что все работает прекрасно, ошибок нет, значит, переходите к следующему шагу.

Следующий шаг - это использование стандартной опции оптимизации. Я просто запустил компилятор в опцией help, он с этой опцией выдает огромную простыню разных опций, я их разбросал по важности их применения .

Есть опция О1 – оптимизировать для максимальной скорости, но выключить некоторые оптимизации, которые увеличивают код с малым увеличением производительности.

Опция О2 – умолчательная: оптимизировать для максимальной скорости. При этом эта опция включает уже и векторизацию и т.д.

Опци О3 включает уже агрессивные оптимизации, которые, возможно, требуют достаточно большого времени компиляции.

Это три самые главные опции, которые позволяют вам эффективно выбрать уровень оптимизации.

Еще предлагаю обратить внимание на опцию fast – это некий список опций, которые включают в себя опцию О3, плюс еще специализированные опции для получения максимального кода не только за счет выбора уровня оптимизации, но также за счет использования процессора. Здесь есть функция QxHOST, включается Qipo, используются дополнительные опции. То есть это мощная маленькая замена целого набора опций.

Использование процессорно-специфических опций. Когда мы говорили про оптимизации., эти опции уже затрагивали. Есть три набора опций Qx: генерить специализированный код для работы на какой-то конкретной архитектуре, которая поддерживает набор расширений процессорных. То есть, здесь есть SSE2, SSE3, SSSE3, SSE 4.1., AVX и сейчас еще появилась SSE3 Atom.

Опция QxHOST может быть почему интересна: если вы не хотите разбираться, что же за архитектуру вы будете использовать, у вас есть конкретная машина, вы на ней собираетесь как компилировать, так и выполнять ваше приложение, то эта опция QxHOST автоматически определит, какую машину вы использовали, какой набор расширения там реализовано и будет это все использовать.

То же самое - второй набор: опция Qxa, которая создает многоверсионное приложение, то есть, содержит код для различных архитектур, и в момент работы она сама выбирает, какой конкретно код запускать.

Третий вариант – опция arch со своими деталями.

Четвертое, что вы можете сделать: включить межпроцедурный анализ. Например, вы включили его и говорите: нет, меня это не устраивает. Можете попытаться заменить на модульный и посмотреть, есть разница в производительности между сборкой с модульной ipo с полной программой и вообще, в вашем конкретном случае межпроцедурная оптимизация оказывает какой-то эффект улучшения производительности или нет. Задайте ключик и посмотрите, что получится.

Определите подходящий уровень инлайнинг, как вам удобнее. Если вы никак не говорили про инлайнинг, компилятор сам сделал какие-то действия. Оптимально он это сделал или нет? Устраивает вас получившийся размер кода или нет? Можно посмотреть на то, что заинлайнилось, а что нет, поставить опцию Ob1, чтобы инлайнить только то, что вы сами определили.

Следующий шаг – использование автоматической параллелизации циклов, то есть, опция Qparallel включает автопараллелизацию. С помощью опции Qpar-report можно посмотреть, смог компилятор хоть что-то запараллелизовать в вашей программе, есть эффект от автоматической параллелизации или нет.

Есть опция Qopt-prefetch, это когда компилятор пытается в некоторые циклы вставить запросы на предвыборку данных из памяти. Тоже можно оценить, проверить, насколько эффективно он это делает.

Последний шаг (по усложнения, я надеюсь) — использование динамического профилировщика, опция Qprof-gen, Qprof-use.

Есть еще интересные вещи, вязанные с работой с вещественными. Если у вас много вещественных вычислений, производительность меняется в зависимости от того, какой уровень вы хотите получить при работе с вещественными. Есть команда fp, которая задает уровни точности при работе с вещественными. Если точно для вас не самая важная вещь, а важна именно скорость, то вы можете понизить точность вычислений с вещественными и увеличить скорость вычислений вашего приложения.

Ну и потом идет анализ производительности, когда вы, получив после манипуляции с ключиками какой-то код, продолжаете хотеть его улучшить. Для этого вы начинаете использовать Vtune, начинаете использовать горячие функции, начинаете смотреть на функции, смотреть на ассемблер, чтобы понять, почему что-то происходит медленно, чтобы это исправить. Например, находите горячие функции, проверяете работу подсистемы памяти (насколько у вас много кэш-попаданий), и смотрите, нет ли у вас таких переходов, которые вам производительность замедляют.

Итак, что можно проанализировать: качество параллелизации, векторизации, качество работы с подсистемой памяти и цикловые оптимизации. Вот простой алгоритм улучшения производительности вашего приложения.

Потом можно проанализировать инлайнинг. То есть, в вашей горячей функции есть вызов какой-то функции, который не был заинлайнен, например. И вы задаетесь вопросом: почему он не заинлайнен, а что будет, если его заинлайнить? Проверяете ваше предположение. Ну и потом вы начинаете думать: а если бы я в своих структурах данных в другом порядке написал поля, это сказалось бы положительно на работе подсистем ы памяти, на работе КЭШей или нет? А что было бы, если бы я взял и эту необходимую информацию в эту структуру поместил, а не дергал бы ее из другой структуры? То есть, вариантов для анализа много.

Можно попытаться вставить программную предвыборку. Например, когда можно эффективно вставлять программную предвыборку? У вас есть какая-то процедура, которая, например, извлекает объекты из списка и вызывает какой-то метод этого объекта. Соответственно, объект только из памяти появился, вероятность того, что он находится в КЭШе – невелика. Взять и посмотреть тот метод, который начинает работать с этим объектом, он начинает запрашивать какие-то поля этого объекта. А что будет, если мы перед вызовом этого метода поставим m-prefetch какое-то поле в память подгрузим. У нас позгрудка адресов начнется еще в тот момент, когда будет выполняться вызов вот этой процедуры, на этом можно чуть-чуть выиграть.

Можно каким-то образом улучшить динамическое выделение памяти.

Александр Мальцев
Александр Мальцев
Россия
Лариса Перерва
Лариса Перерва
Россия, Владивосток, ДВГУ, 1986